Chapter 25Lesson 05~340 minutes

Checkpoint Lab — Gradle Daemon, Workers, Parallelism, File-System Watching, Profiling, and Build Performance

Profile one representative multi-project build, remove a deterministic configuration bottleneck, reduce an execution bottleneck with controlled parallelism, and prove the task graph, tests, and artifact bytes remain correct.

CheckpointCritical pathProfile evidenceParallel projectsArtifact identity

Learning objectives

  • Capture a reproducible performance baseline with Wrapper/JDK/Daemon/resource notes and local profile reports.
  • Identify one configuration bottleneck and one execution bottleneck from evidence rather than intuition.
  • Move an audit-only eager configuration computation into explicit task work without changing build semantics.
  • Use controlled project parallelism/worker limits to shorten independent execution branches.
  • Prove that tests, selected task graph, and JAR bytes remain correct after each optimization.
  • Document rollback criteria and bridge performance evidence to Chapter 26 publishing/repository-promotion work.

1. Checkpoint acceptance contract

You will create a three-subproject build with two deliberate performance defects: an audit-only expensive fingerprint executed during configuration and independent synthetic execution tasks forced through a one-worker serial baseline. You will:

  • record Gradle/JDK/Daemon/CPU-memory context,
  • capture three comparable baseline profiles,
  • predict what configuration and execution changes should occur,
  • remove the eager configuration bottleneck without deleting the audit capability,
  • enable measured project parallelism at a bounded worker count,
  • keep JUnit tests in the workload,
  • compare the selected task graph before/after, and
  • compare JAR SHA-256 values before/after optimization.

2. Setup and preflight

export LAB="$PWD/gradle-performance-checkpoint"
export WRAPPER_SOURCE="$PWD/trusted-gradle-wrapper"
rm -rf "$LAB"
mkdir -p "$LAB/gradle"

test -f "$WRAPPER_SOURCE/gradlew" || { echo "Need reviewed Gradle 9.7.1 wrapper files"; exit 1; }
cp "$WRAPPER_SOURCE/gradlew" "$LAB/"
cp "$WRAPPER_SOURCE/gradlew.bat" "$LAB/"
cp -R "$WRAPPER_SOURCE/gradle/wrapper" "$LAB/gradle/"
chmod +x "$LAB/gradlew"

cd "$LAB"
export GRADLE_USER_HOME="$LAB/.gradle-user-home"
./gradlew --version | tee evidence-gradle.txt
java -version 2>&1 | tee evidence-java.txt
./gradlew --status | tee evidence-daemon-before.txt || true
printf 'cpu='; getconf _NPROCESSORS_ONLN 2>/dev/null || true
printf 'lab=%s
userHome=%s
' "$LAB" "$GRADLE_USER_HOME" | tee evidence-paths.txt

Assumptions: Gradle 9.7.1 Wrapper, Gradle runtime/toolchain JDK 21, Java 17 release target, JUnit 6.1.3, internet access only for the small Maven Central test dependency set and Wrapper distribution if not already cached. No paid or hosted performance service is required.

3. Create the exact baseline build

mkdir -p config
mkdir -p alpha/src/main/java/dev/academy/performance/alpha alpha/src/test/java/dev/academy/performance/alpha
mkdir -p beta/src/main/java/dev/academy/performance/beta beta/src/test/java/dev/academy/performance/beta
mkdir -p gamma/src/main/java/dev/academy/performance/gamma gamma/src/test/java/dev/academy/performance/gamma
python - <<'PY'
from pathlib import Path
Path('config/policy.bin').write_bytes((b'checkpoint-policy-v1\n' * 90000)[:1048576])
PY
rootProject.name = "performance-lab"
include("alpha", "beta", "gamma")
import java.security.MessageDigest

plugins {
    base
}

// Intentionally bad teaching fixture: this audit-only fingerprint is computed
// eagerly during configuration for every Gradle invocation, even `help`.
fun expensiveFingerprint(bytes: ByteArray): String {
    var value = ByteArray(0)
    repeat(120) {
        value = MessageDigest.getInstance("SHA-256").digest(bytes)
    }
    return value.joinToString("") { "%02x".format(it) }
}

val policyBytes = layout.projectDirectory.file("config/policy.bin").asFile.readBytes()
val policyFingerprint = expensiveFingerprint(policyBytes)
logger.lifecycle("configuration policy fingerprint: ${policyFingerprint.take(16)}")

tasks.register("perfPipeline") {
    group = "verification"
    description = "Runs tests, jars, and one synthetic independent work task per subproject."
    dependsOn(
        ":alpha:check", ":beta:check", ":gamma:check",
        ":alpha:jar", ":beta:jar", ":gamma:jar",
        ":alpha:simulateWork", ":beta:simulateWork", ":gamma:simulateWork"
    )
}
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction

plugins {
    `java-library`
}

group = "dev.academy.performance"
version = "1.0.0"

repositories {
    mavenCentral()
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.release.set(17)
}

dependencies {
    testImplementation(platform("org.junit:junit-bom:6.1.3"))
    testImplementation("org.junit.jupiter:junit-jupiter")
}

tasks.test {
    useJUnitPlatform()
}

abstract class SimulateWork : DefaultTask() {
    @get:Input
    abstract val delayMs: Property<Long>

    @get:Input
    abstract val label: Property<String>

    @get:OutputFile
    abstract val outputFile: RegularFileProperty

    @TaskAction
    fun executeWork() {
        Thread.sleep(delayMs.get())
        val out = outputFile.get().asFile
        out.parentFile.mkdirs()
        out.writeText("${project.name}:done\n")
    }
}

tasks.register<SimulateWork>("simulateWork") {
    // A deterministic training fixture that makes independent project work
    // visible in a profile. Real projects should profile real tasks first.
    delayMs.set(450)
    label.set(project.name)
    outputFile.set(layout.buildDirectory.file("performance/simulated.txt"))
}
package dev.academy.performance.alpha;
public final class AlphaService {
    private AlphaService() {}
    public static String value() { return "alpha"; }
}
package dev.academy.performance.alpha;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class AlphaServiceTest {
    @Test void valueIsStable() { assertEquals("alpha", AlphaService.value()); }
}
package dev.academy.performance.beta;
public final class BetaService {
    private BetaService() {}
    public static String value() { return "beta"; }
}
package dev.academy.performance.beta;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class BetaServiceTest {
    @Test void valueIsStable() { assertEquals("beta", BetaService.value()); }
}
package dev.academy.performance.gamma;
public final class GammaService {
    private GammaService() {}
    public static String value() { return "gamma"; }
}
package dev.academy.performance.gamma;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class GammaServiceTest {
    @Test void valueIsStable() { assertEquals("gamma", GammaService.value()); }
}

Write the snippets to the same paths used in Lesson 2. Before running anything, review that the eager fingerprint is audit-only: no compile/test/JAR/task decision consumes policyFingerprint. That fact is what makes moving it out of configuration semantics-preserving.

4. Predict before measuring

Prediction Why Independent verification
Replacing eager fingerprint with an explicit unselected task will reduce configuration time for perfPipeline. The audit fingerprint is not a model input to the pipeline. Compare serial profiles at the same worker/cache/Daemon settings; policyFingerprint remains available when explicitly selected.
Enabling --parallel --max-workers=2 will overlap some alpha/beta/gamma execution work. The projects are siblings and simulateWork tasks have independent project-local outputs. Profile/task timestamps; same dry-run selected graph; measured wall time.
Changing worker/parallel settings will not change JAR bytes. They change scheduling, not source/compiler/archive inputs. SHA-256 before/after.
Tests must remain selected and passing. Performance tuning must preserve required verification. check/test task outcomes and XML/console evidence.

5. Warm once, then freeze the benchmark conditions

./gradlew clean perfPipeline --console=plain | tee warmup.log
./gradlew --status | tee evidence-daemon-warm.txt
./gradlew perfPipeline --dry-run --console=plain | tee graph-before.txt

# Capture baseline artifact identity after a clean build.
./gradlew :alpha:clean :beta:clean :gamma:clean   :alpha:jar :beta:jar :gamma:jar   --no-build-cache --no-configuration-cache --console=plain
sha256sum alpha/build/libs/alpha-1.0.0.jar beta/build/libs/beta-1.0.0.jar gamma/build/libs/gamma-1.0.0.jar   | tee jars-before.sha256

From now on, do not change source/tests/dependencies/toolchain. Use --rerun-tasks and disable Chapter 24 caches during performance timing so the executed workload remains comparable.

6. Capture the baseline series

for i in 1 2 3; do
  ./gradlew perfPipeline     --rerun-tasks --no-build-cache --no-configuration-cache     --no-parallel --max-workers=1     --profile --console=plain | tee "baseline-${i}.log"
done
ls -1t build/reports/profile/*.html | head -n 3 | tee baseline-profile-files.txt

Record the median/representative elapsed time rather than selecting the single fastest run. In the profile, identify (a) eager configuration fingerprint cost and (b) the serial chain of independent synthetic/module work. Preserve the reports before editing the build.

7. Optimization 1: remove unnecessary eager configuration work

Replace only the root build.gradle.kts with the following. The expensive audit remains available as policyFingerprint but is no longer executed by every invocation.

import java.security.MessageDigest
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction

plugins {
    base
}

// The same audit operation is now explicit execution work. It is not selected
// by perfPipeline because the fingerprint was never an input to build decisions.
abstract class FingerprintPolicy : DefaultTask() {
    @get:InputFile
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val inputFile: RegularFileProperty

    @get:OutputFile
    abstract val outputFile: RegularFileProperty

    @TaskAction
    fun fingerprint() {
        val bytes = inputFile.get().asFile.readBytes()
        var value = ByteArray(0)
        repeat(120) {
            value = MessageDigest.getInstance("SHA-256").digest(bytes)
        }
        val text = value.joinToString("") { "%02x".format(it) }
        val out = outputFile.get().asFile
        out.parentFile.mkdirs()
        out.writeText(text + "\n")
    }
}

tasks.register<FingerprintPolicy>("policyFingerprint") {
    inputFile.set(layout.projectDirectory.file("config/policy.bin"))
    outputFile.set(layout.buildDirectory.file("reports/policy.sha256"))
}

tasks.register("perfPipeline") {
    group = "verification"
    description = "Runs tests, jars, and one synthetic independent work task per subproject."
    dependsOn(
        ":alpha:check", ":beta:check", ":gamma:check",
        ":alpha:jar", ":beta:jar", ":gamma:jar",
        ":alpha:simulateWork", ":beta:simulateWork", ":gamma:simulateWork"
    )
}
./gradlew policyFingerprint --console=plain
cat build/reports/policy.sha256

for i in 1 2 3; do
  ./gradlew perfPipeline     --rerun-tasks --no-build-cache --no-configuration-cache     --no-parallel --max-workers=1     --profile --console=plain | tee "config-fixed-${i}.log"
done

Verify causality: worker/parallel/cache/Daemon settings are unchanged from the baseline; only the configuration architecture changed. Profile configuration time should fall. If total wall time does not improve, preserve that finding—the execution branch may dominate.

8. Optimization 2: overlap independent project work

for i in 1 2 3; do
  ./gradlew perfPipeline     --rerun-tasks --no-build-cache --no-configuration-cache     --parallel --max-workers=2     --profile --console=plain | tee "parallel2-${i}.log"
done

# One bounded scaling check; do not assume it will win.
./gradlew perfPipeline   --rerun-tasks --no-build-cache --no-configuration-cache   --parallel --max-workers=3   --profile --console=plain | tee parallel3.log

Explain the result in terms of the graph and host. If workers=2 beats workers=3 on a constrained machine, that is successful diagnosis, not a failed lab. The production recommendation should be the smallest setting that reliably improves the representative workload.

9. File-system-watching evidence

./gradlew help --watch-fs --info --console=plain | tee checkpoint-watch-1.log
./gradlew help --watch-fs -Dorg.gradle.vfs.verbose=true --info --console=plain | tee checkpoint-watch-2.log
./gradlew help --no-watch-fs --info --console=plain | tee checkpoint-watch-off.log

Document whether the target filesystem supports/benefits from watching. Do not include --no-watch-fs in the final recommendation unless evidence for the actual environment supports it.

10. Prove correctness after optimization

# Same selected graph. Scheduling may differ; selected tasks/dependencies must not disappear.
./gradlew perfPipeline --dry-run --parallel --max-workers=2 --console=plain | tee graph-after.txt

diff -u graph-before.txt graph-after.txt || true

# Run the required verification normally once after tuning.
./gradlew :alpha:clean :beta:clean :gamma:clean   :alpha:check :beta:check :gamma:check   :alpha:jar :beta:jar :gamma:jar   --parallel --max-workers=2 --no-build-cache --no-configuration-cache --console=plain   | tee correctness-after.log

sha256sum alpha/build/libs/alpha-1.0.0.jar beta/build/libs/beta-1.0.0.jar gamma/build/libs/gamma-1.0.0.jar   | tee jars-after.sha256

cut -d' ' -f1 jars-before.sha256 > before.hashes
cut -d' ' -f1 jars-after.sha256 > after.hashes
diff -u before.hashes after.hashes

A clean check must execute the JUnit tests. JAR hashes must match because source, compiler target, and archive inputs did not change. The dry-run text may have cosmetic/order differences; review whether the same required tasks remain selected and dependencies remain intact.

11. Write the checkpoint performance record

Environment
- Wrapper / Gradle: 9.7.1
- Gradle runtime/toolchain: JDK 21
- Java target: 17
- Effective CPU / memory: <record>
- GRADLE_USER_HOME: isolated lab directory
- Daemon mode: enabled for measured series
- Build/Configuration Cache: disabled for timing series

Baseline
- 3 serial runs, max-workers=1
- representative wall time: <record>
- configuration hotspot: eager audit fingerprint
- execution hotspot: independent simulateWork/module branches serialized

Optimization 1
- audit fingerprint moved to explicit policyFingerprint task
- serial/worker settings unchanged
- configuration-time change: <record>

Optimization 2
- --parallel --max-workers=2
- task graph/correctness gates preserved
- execution/wall-time change: <record>

Correctness
- all JUnit tests pass
- before/after JAR SHA-256 values match
- selected task graph remains complete
- no tests/input/security controls disabled

Recommendation / rollback
- recommended settings: <record>
- rollback trigger: slower median, instability, memory/GC pressure, filesystem incompatibility, or changed correctness evidence

12. Cleanup and rollback

cd "$LAB/.."
# Preserve logs/profile HTML first if required by your review process.
rm -rf gradle-performance-checkpoint

Rollback in a real repository means reverting only the performance-specific build logic/flags while preserving evidence. Do not wipe normal Gradle caches or change application source to manufacture a faster result.

13. What Chapter 25 adds to the operating model

The course now has a performance discipline: distinguish startup/configuration/execution, record Daemon and resource identity, reason about graph parallelism, bound worker/process concurrency, validate filesystem assumptions, and use local profiles before changing knobs. Most importantly, every speedup is subordinate to tests, task-model correctness, artifact identity, and supply-chain controls.

Chapter 26 crosses the next boundary: publishing. You will turn internal Gradle components into Maven/Ivy repository contracts with coordinates, POM/Gradle Module Metadata, signing, credentials, and promotion semantics while preserving immutable artifact identity.

Knowledge check

Why is the eager fingerprint safe to move out of configuration in this checkpoint?

Which variable is held constant when comparing baseline with Optimization 1?

What does Optimization 2 change?

Why might workers=3 lose to workers=2?

What evidence makes a speedup unacceptable?

What is the next production boundary in Chapter 26?

Official references and version notes

Version-sensitive behavior was rechecked against current Gradle primary documentation on 2026-08-24. The mandatory path uses Gradle 9.7.1 through the previously verified Wrapper, JDK 21 as the Gradle runtime/toolchain, Java 17 as the project target, JUnit 6.1.3 only for the small local test fixture, and an isolated GRADLE_USER_HOME. Build Scan publication and commercial/hosted telemetry are optional; the required evidence uses the free local --profile report and ordinary logs.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.