build(test): stop test forks oversubscribing the machine - #16158
build(test): stop test forks oversubscribing the machine#16158jamesfredley wants to merge 2 commits into
Conversation
The `tasks.named('isolatedTestsTwo', Test)` block set `maxParallelForks = 1`
and `forkEvery = 100`, but it was registered BEFORE the
`tasks.withType(Test).configureEach` block in the same script. Gradle runs
both as deferred configuration actions in registration order at task
realization, so the later `configureEach` overwrote both values and the task
ran with `configuredTestParallel` forks instead of one.
Its three test patterns have been deliberately serialized since 2013 because
they are order sensitive, so running them in parallel risked exactly the kind
of static-state flakiness the suite is isolated to avoid.
Move the override after the `configureEach` block so it wins, and add a
comment recording the ordering requirement.
CI impact is negligible: the task filters three classes and sharding assigns
the whole task to a single shard, so `forkEvery = 100` is never reached.
Assisted-by: claude-code:claude-opus-5
A plain `./gradlew build` could leave a developer's workstation unusable. Three multipliers stacked, each reasonable on its own: 1. `availableProcessors()` reports LOGICAL processors - SMT threads on x64, and on Apple silicon every efficiency core as well as every performance core. Taking 3/4 of that already overstates real capacity. 2. `maxParallelForks` is per Test task, and with `org.gradle.parallel=true` several test-bearing modules run at once, so the real ceiling is the worker-lease pool rather than any single task's fork count. 3. Every forked JVM sizes its own GC and JIT thread pools for the WHOLE machine, because no fork knows the others exist. On a 20-processor host that is 15 ParallelGCThreads + 4 ConcGCThreads + 12 CICompilerCount = 31 threads per fork before a single test runs. Gradle cannot see the third one: it charges each fork a single worker lease, as though a fork were one thread. Gradle's own default for maxParallelForks is 1 for exactly that reason; raising it opts out of that protection. Two changes, applied to all three builds in this repository (root, grails-gradle and grails-forge each have their own settings.gradle): - Local test forks drop from 3/4 of the logical processors to half. CI keeps its existing budget, so CI fork counts are unchanged. - Every test fork is told how many processors it may size its thread pools from, as availableProcessors / maxWorkerCount. The denominator is the BUILD-WIDE worker limit rather than any one task's maxParallelForks, because that is what actually bounds how many forks run concurrently. A floor of 2 keeps G1 rather than dropping to Serial GC. It is supplied through jvmArgumentProviders rather than jvmArgs because several modules assign jvmArgs wholesale, which would discard it. Measured on a 14-core/20-thread host with `:grails-core:test --rerun-tasks`, daemons stopped between runs, comparing second runs: baseline first: baseline 3m25s, treatment 2m45s (19.5% faster) treatment first: treatment 2m24s, baseline 3m05s (22.2% faster) Running the treatment first rules out filesystem-cache ordering bias. Peak JVM count fell from 29 to 24; projected per-fork JVM threads fell from 31 to 5, so projected total threads fell from roughly 465 to 50. `-PmaxTestParallel` still overrides the default, and now does so in grails-forge as well, where it was previously ignored. Assisted-by: claude-code:claude-opus-5
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16158 +/- ##
==================================================
- Coverage 52.3252% 52.3088% -0.0164%
+ Complexity 18536 18529 -7
==================================================
Files 2039 2039
Lines 97498 97498
Branches 17138 17138
==================================================
- Hits 51016 51000 -16
- Misses 38998 39011 +13
- Partials 7484 7487 +3 🚀 New features to boost your workflow:
|
✅ All tests passed ✅🏷️ Commit: 13c9641 Learn more about TestLens at testlens.app. |
CI measurement (first draft run)All 59 jobs passed. Comparing the
Whole-workflow wall clock was 95.5 min against a ~2h14m median. How much to trust thisNot much yet, on its own. This is one run against a three-run baseline, and per-job variance on GitHub runners is large: baseline What makes the direction plausible rather than noise is that the local A/B benchmark was run in both orderings on an idle machine, and the improvement held each way (19.5% with baseline first, 22.2% with this branch first). What is actually being measured hereCI fork counts are unchanged by this PR - the The macOS result is the most interesting one for day-to-day work, since most committers develop on macOS and GitHub's macOS runners are the smallest at 3 vCPU / 7 GB. Suggested next stepRe-run this workflow a couple more times before drawing conclusions, so each job has a comparable sample count to the baseline. |
CI measurement, second sample - correcting the firstA second run of the same commit landed (59/59 jobs green again). It does not reproduce the improvement reported above, and the earlier numbers should be treated as retracted.
What this actually showsRunner variance dominates. macOS JDK 21 moved from 62.5 to 89.9 minutes on identical code - a 35 percentage point swing between two runs. Any single-sample CI comparison on this workflow, including my first one, is noise. The honest reading of two samples is that CI wall clock is roughly unchanged. Windows JDK 25 shard 0 is the one consistent signal, slower in both samples (+23.7%, +25.3%). Two samples is still thin, but it is the only job where both point the same way, so it deserves attention rather than dismissal. Worth noting the baseline for that job spans 66.8-93.7 minutes across three runs, so even this may be variance. Does this invalidate the change?Not the local result, which is the stronger evidence and was measured under controlled conditions - idle machine, daemons stopped between runs, and critically run in both orderings so filesystem-cache bias pointed against the change in one of them: That reproducibility is what a shared GitHub runner cannot offer. It is also worth restating what CI is even exercising here: fork counts are unchanged on CI by design ( The developer-machine problem this PR exists to fix - 23 JVMs, 12.4 GB, and roughly 465 threads on a 20-thread box - is unaffected by any of this. Suggested next stepIf CI timing is a merge criterion, this needs several more samples per job to say anything, particularly for Windows shard 0. If it is not, the local A/B plus unchanged CI fork counts should be sufficient. |
Draft — opened as a draft so CI measures the change before review. See Measuring this on CI.
The problem
A plain
./gradlew buildcan leave a developer's workstation unusable. Observed on a 14-core / 20-thread machine: 23 JVMs, 12.4 GB, with the desktop unresponsive.Three multipliers stack, and each looks reasonable on its own.
1.
availableProcessors()counts logical processors. SMT threads on x64, and on Apple silicon every efficiency core as well as every performance core.* 3 / 4of that is already measured against an inflated number — 15 forks on a 14-core host, and on an M-series Mac most of those forks land on efficiency cores.2.
maxParallelForksis per Test task. Withorg.gradle.parallel=true, several test-bearing modules run concurrently, so the real ceiling is Gradle's worker-lease pool (--max-workers, default =availableProcessors), not any single task's fork count.3. Each forked JVM sizes its own thread pools for the whole machine, because no fork knows the others exist. Measured on JDK 21 at 20 visible processors:
ParallelGCThreadsConcGCThreadsCICompilerCountThat is 31 threads per fork before a single test runs. Gradle cannot see it: it charges each fork one worker lease, as though a fork were a single thread. Gradle's own default for
Test.maxParallelForksis 1 for precisely this reason — raising it opts out of the protection Gradle already provides.15 forks × 31 threads ≈ 465 threads contending for 20 hardware threads, plus 15 × 1 GB heaps (2 GB in
grails-test-suite-persistence) against a 5 GB daemon. That is the thrashing.The change
Applied to all three builds in this repository — root,
grails-gradleandgrails-forgeeach have their ownsettings.gradle, so the duplication is unavoidable.isCiBuild ? 4, and? 3ingrails-gradle), so CI fork counts are unchanged.availableProcessors / maxWorkerCount, floored at 2. The denominator is the build-wide worker limit rather than any one task'smaxParallelForks, because that is what actually bounds how many forks run at once. A floor of 2 keeps G1 rather than dropping to Serial GC.Supplied via
jvmArgumentProvidersrather thanjvmArgs, because several modules assignjvmArgswholesale and would discard it.Also fixes
grails-forge, where-PmaxTestParallelwas silently ignored.Measured result
:grails-core:test --rerun-tasks, daemons stopped between runs, comparing second runs:Running this branch first rules out filesystem-cache ordering bias — the advantage held in both directions.
ActiveProcessorCountThread figures are a computed projection from
java -XX:+PrintFlagsFinal -version, not a live thread count.Second commit:
isolatedTestsTwowas not actually isolatedgrails-test-suite-uber/build.gradlesetmaxParallelForks = 1onisolatedTestsTwo, but registered that block before thetasks.withType(Test).configureEachblock in the same script. Gradle runs both as deferred configuration actions in registration order, so the laterconfigureEachoverwrote it and the task ran fully parallel. Its test patterns have been deliberately serialized since 2013 because they are order sensitive.Moving the override after the
configureEachblock fixes it. CI impact is negligible: the task filters three classes and sharding assigns it to a single shard.Measuring this on CI
Baseline for
gradle.ymlon8.0.x: median 2h 14m, p90 3h 52m, with core build jobs at 1h 20m – 1h 40m. Draft PRs do trigger the workflow, so this PR's own run is the measurement. Since CI fork counts are unchanged, any CI movement comes from the per-fork processor cap alone.Follow-ups, deliberately not in this PR
org.gradle.workers.maxor a sharedBuildService.withReuse, so N forks means N containers — and on macOS and Windows that memory comes from a Docker Desktop VM the JVM cannot see.