Chapter 25Lesson 02~320 minutes

Gradle Daemon, Workers, Parallelism, File-System Watching, Profiling, and Build Performance: Guided Hands-On Workflow and Core Operations

Profile a disposable multi-project JVM build with a verified Wrapper, compare warm and cold process conditions, vary worker/parallel settings one at a time, inspect file watching, and identify the real critical path from local evidence.

Gradle 9.7.1--profile--max-workers--parallelVFS watching

Learning objectives

  • Create a disposable multi-project JVM workload using only a reviewed Gradle 9.7.1 Wrapper and isolated Gradle User Home.
  • Record Daemon/JDK/worker/file-system context before measuring.
  • Capture repeated serial and parallel profiles while controlling task reuse with --rerun-tasks.
  • Use local profile HTML and logs to separate configuration cost from execution critical-path cost.
  • Observe file-system watching and Daemon reuse without editing normal user-wide Gradle configuration.
  • Choose a next experiment from evidence rather than copying a “fastest settings” recipe.

1. Scenario and safety boundary

The fixture has three independent Java subprojects. Each runs one tiny JUnit test, creates a JAR, and executes a 450 ms synthetic task. The root build intentionally performs an expensive audit-only fingerprint during configuration. The synthetic delays are not claims about a real application; they make configuration and execution branches visible enough to practice measurement.

Use a reviewed Wrapper. Chapter 15 established the Gradle 9.7.1 Wrapper and distribution checksum. This chapter does not regenerate the Wrapper. Set WRAPPER_SOURCE to a trusted local project that already contains the reviewed gradlew, gradlew.bat, and gradle/wrapper/ files.

2. Preflight: preserve environment evidence

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

test -f "$WRAPPER_SOURCE/gradlew" || { echo "Set WRAPPER_SOURCE to a reviewed Gradle 9.7.1 wrapper project"; exit 1; }
test -f "$WRAPPER_SOURCE/gradle/wrapper/gradle-wrapper.properties" || exit 1

cp "$WRAPPER_SOURCE/gradlew" "$LAB/"
cp "$WRAPPER_SOURCE/gradlew.bat" "$LAB/"
mkdir -p "$LAB/gradle"
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 env-gradle.txt
java -version 2>&1 | tee env-java.txt
./gradlew --status | tee env-daemons-before.txt || true
printf 'processors='; getconf _NPROCESSORS_ONLN 2>/dev/null || true
printf 'GRADLE_USER_HOME=%s
' "$GRADLE_USER_HOME" | tee env-home.txt

Windows PowerShell can set the same isolation with $env:GRADLE_USER_HOME = "$PWD\.gradle-user-home" and use .\gradlew.bat. The Bash path above is the canonical lab transcript.

3. Create the project structure

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

# 1 MiB deterministic local policy fixture; used only to magnify eager configuration work.
python - <<'PY'
from pathlib import Path
Path('config/policy.bin').write_bytes((b'policy-v1\n' * 120000)[: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"))
}

Save the root snippets as settings.gradle.kts and build.gradle.kts. Save the subproject build snippet as each of alpha/build.gradle.kts, beta/build.gradle.kts, and gamma/build.gradle.kts.

4. Add tiny production/tests so correctness remains in the benchmark

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()); }
}

Place each pair under its matching module path created above. The external footprint is intentionally small: one JUnit BOM/test dependency set from Maven Central, already used in Chapter 22.

5. Read-only model inspection before timing

./gradlew projects
./gradlew perfPipeline --dry-run --console=plain | tee graph-serial.txt
./gradlew --status | tee daemon-status.txt
./gradlew tasks --all | grep -E 'perfPipeline|simulateWork|policyFingerprint' || true

The dry run proves which tasks are selected. It does not measure concurrency, and it should not be used to claim that tasks are safe to overlap. Safety comes from correct dependencies and non-overlapping state.

6. Warm dependencies and the Daemon separately from measurement

./gradlew clean perfPipeline --console=plain
./gradlew --status | tee daemon-after-warmup.txt
# Preserve the first report/log as warm-up evidence, but do not mix it into the measured series.

The first invocation may download the Gradle distribution and JUnit dependencies and start/JIT-warm a Daemon. Measuring it as though it represented a steady developer edit loop would mix unrelated cold-start effects into the execution experiment.

7. Baseline: forced work, one worker, no project parallelism

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-profiles.txt

--rerun-tasks forces selected task work so Chapter 24’s up-to-date/build-cache mechanisms do not dominate this performance experiment. Build and Configuration Caches are also disabled for the measured series. The Daemon remains enabled so repeated-build startup behavior is representative of Gradle’s recommended mode.

8. Daemon experiment: change only process reuse

./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache   --no-parallel --max-workers=1 --profile --console=plain | tee daemon-on.log

./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache   --no-parallel --max-workers=1 --no-daemon --profile --console=plain | tee daemon-off.log

./gradlew --status | tee daemon-status-after.txt || true

Interpret this as a startup/process-reuse comparison, not a universal CI prescription. Current Gradle guidance recommends the Daemon in CI too. Also remember the nuanced implementation detail: --no-daemon can still result in a single-use build JVM when the client JVM cannot satisfy the build JVM requirements.

9. Worker/parallel experiment: one variable family at a time

# Keep source, Daemon mode, cache flags, and workload identical.
./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache   --no-parallel --max-workers=2 --profile --console=plain | tee workers2-serial.log

./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache   --parallel --max-workers=2 --profile --console=plain | tee workers2-parallel.log

./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache   --parallel --max-workers=3 --profile --console=plain | tee workers3-parallel.log

First, comparing max-workers 1 versus 2 without --parallel asks whether other Gradle work uses the additional worker capacity. Then enabling --parallel tests whether independent subprojects shorten the wall-clock critical path. Finally, 2 versus 3 workers tests scaling. Do not assume the highest number wins on a two-core CI limit.

10. Observe file-system watching without changing correctness

./gradlew help --watch-fs --info --console=plain | tee watch-on-1.log
./gradlew help --watch-fs --info --console=plain | tee watch-on-2.log
printf '
// harmless local comment
' >> alpha/build.gradle.kts
./gradlew help --watch-fs -Dorg.gradle.vfs.verbose=true --info --console=plain | tee watch-after-change.log
./gradlew help --no-watch-fs --info --console=plain | tee watch-off.log

Look for VFS/file-watching diagnostics and changed-file effects, but do not write a CI gate that depends on one log phrase. On unsupported file systems Gradle can fall back without making the build invalid. Restore the harmless comment before final artifact comparison.

11. Read the profile as a critical-path hypothesis

Open the newest files under build/reports/profile/. Record startup/settings/configuration time and the slowest tasks. The three simulateWork tasks are deliberately independent across projects, so their overlap should matter after project parallelism is enabled. The eager policy fingerprint appears as configuration overhead instead.

Observation Likely hypothesis Next controlled test
Large startup on every run Compatible Daemon not reused, Wrapper distribution/startup/init script issue. Compare --status, Daemon log, JVM args and same workload warm runs.
Large configuration on even help Eager build-script/plugin work. Profile help, remove/defer one eager computation, compare.
Three independent project tasks run serially Parallel projects disabled or worker capacity limited. Enable --parallel at fixed workers, then vary workers.
More workers increases elapsed time CPU/RAM/I/O oversubscription. Reduce workers while preserving graph/workload and compare host metrics.
Second no-change invocation still scans heavily VFS unsupported/disabled or build logic invalidates state. Inspect --watch-fs --info evidence and filesystem context.

12. Challenge: choose the correct control

Your profile shows 1.2 seconds of configuration and one 7-second :gamma:test task. CPU is mostly idle during that test because it waits on a local synthetic service fixture. Which first change is more defensible: increase --max-workers from 2 to 8, enable --parallel, or profile/fix the 7-second test fixture?

Reason before revealing: identify the critical path and whether additional runnable work exists. If the 7-second test lies on the critical path and is not CPU-parallelizable, worker count is unlikely to solve it. Fix or isolate the measured test bottleneck first, then remeasure.

13. Cleanup

cd "$LAB"
# Preserve profile/log evidence first if this were a real performance investigation.
cd ..
rm -rf gradle-performance-lab

Only the disposable lab and its isolated User Home are removed. Do not delete normal ~/.gradle or shared CI caches as a performance ritual.

Knowledge check

Why use --rerun-tasks in the measured execution series?

Why warm once before collecting steady-state samples?

What does comparing --parallel at the same --max-workers isolate?

Why can 3 workers be slower than 2?

Why is --scan not mandatory here?

A no-change help build is slow. Which phase should you examine first?

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.