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.
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?
It is audit-only and no build model/task decision consumes its
value. The explicit policyFingerprint task
preserves the capability without charging every invocation.
Which variable is held constant when comparing baseline with Optimization 1?
Worker count, parallel mode, Daemon mode, cache flags, source/dependencies/toolchain and selected workload; only configuration architecture changes.
What does Optimization 2 change?
Project parallel execution and the bounded worker ceiling. It does not change task dependencies, source, tests, or artifact inputs.
Why might workers=3 lose to workers=2?
Host CPU/memory/I/O contention can exceed the benefit of another runnable branch. The measured critical path and resource envelope determine the answer.
What evidence makes a speedup unacceptable?
Missing/skipped required tests, changed/missing task dependencies, changed JAR bytes without an explained input change, stale results, or weakened security/correctness controls.
What is the next production boundary in Chapter 26?
Publishing components and metadata to Maven/Ivy-style repositories with explicit coordinates, signing/credentials, interoperability and promotion/immutability guarantees.
Official references and version notes
- Gradle 9.7.1 release notes — current pinned patch release, including 9.7.1 file-system-watching improvements.
- Gradle Daemon — client versus Daemon JVM, compatibility, status/logs, CI recommendation, memory defaults, and performance behavior.
-
Build environment configuration
—
org.gradle.jvmargs,org.gradle.parallel,org.gradle.workers.max, and VFS properties/defaults. -
Gradle CLI
—
--max-workers,--parallel,--profile,--scan,--watch-fs, and Daemon options. - Developing Parallel Tasks / Worker API — work queues and no/classloader/process isolation; worker Daemons are scoped to one build session.
-
Inspecting and profiling builds
— Build Scan, free local
--profilereports, and low-level profiling options. - Best practices for performance — measure configuration/execution work and avoid expensive configuration computations.
- Parallel project execution — project-parallel behavior, graph constraints, and the distinction from configuration-on-demand.
- Compatibility matrix — current Gradle runtime and supported-platform expectations, including file-system assumptions.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.