Skip to content

build(test): stop test forks oversubscribing the machine - #16158

Merged
jamesfredley merged 4 commits into
8.0.xfrom
fix/test-fork-oversubscription
Aug 19, 2026
Merged

build(test): stop test forks oversubscribing the machine#16158
jamesfredley merged 4 commits into
8.0.xfrom
fix/test-fork-oversubscription

Conversation

@jamesfredley

@jamesfredley jamesfredley commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

The problem

A plain ./gradlew build can 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 / 4 of 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. maxParallelForks is per Test task. With org.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:

Setting Value
ParallelGCThreads 15
ConcGCThreads 4
CICompilerCount 12
Total per fork 31

That 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.maxParallelForks is 1 for precisely this reason - raising it opts out of the protection Gradle already provides.

15 forks x 31 threads is roughly 465 threads contending for 20 hardware threads, plus 15 x 1 GB heaps (2 GB in grails-test-suite-persistence) against a 5 GB daemon. That is the thrashing.

The oversubscription ratio scales with the machine

This is why the problem is felt on workstations and barely registers on CI. Each concurrent fork sizes its pools for every visible processor, so the "virtual processors" a build demands is forks x visible processors, against however many the host actually has:

Host Concurrent forks Visible procs per fork Virtual : real
20-thread workstation, before up to 20 20 20x
4-vCPU CI runner, before 4 4 4x
Any host, after this change unchanged 2 2x

The change normalises every host to roughly 2x. The benefit is therefore proportional to how oversubscribed the host was to begin with: dramatic on a large developer machine, modest on a small CI runner.

The change

Applied to all three builds in this repository - root, grails-gradle and grails-forge each have their own settings.gradle, so the duplication is unavoidable.

  1. Local test forks: 3/4 of the logical processors → half. CI keeps its existing budget (isCiBuild ? 4 in root, ? 3 in grails-gradle and now also in grails-forge).
  2. Every test fork is told how many processors it may size its pools from, as availableProcessors / maxWorkerCount, floored at 2. 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 at once. A floor of 2 keeps G1 rather than dropping to Serial GC. That also means -PmaxTestParallel=1 still advertises 2 processors to the single fork when maxWorkerCount is the usual unrestricted local pool; it is not a "give the one fork the whole machine" switch.

Supplied via jvmArgumentProviders rather than jvmArgs, because several modules assign jvmArgs wholesale and would discard it.

Also fixes grails-forge, where -PmaxTestParallel was silently ignored.

Measured result

Full build

This is the workload the PR exists for. DO_NOT_CACHE_TESTS=1 ./gradlew build --continue --profile --console=plain, on a 14-core / 20-thread i7-12800H with 63.7 GB, Gradle daemons stopped before each run, and the treatment run first so that filesystem-cache warmth works against the change:

Baseline (b964e60) This branch (13c9641) Change
Wall clock 1h 14m 51s 49m 35s 33.7% faster
Peak live threads 2,739 1,967 -28.2%
Peak working set 31.1 GB 25.2 GB -18.8%
Peak JVMs 30 32 +2
Actionable tasks executed 593 / 2977 565 / 2976 baseline +28

Thread and memory figures here are live samples taken every 2 seconds for the duration of each build, not projections.

Peak JVM count barely moves, and that is the expected result. With org.gradle.parallel=true and no org.gradle.workers.max, the number of concurrent forks is bounded by the worker-lease pool, not by any single task's maxParallelForks; lowering 15 → 10 mostly just frees leases for other modules to take. What changes is the thread count inside each fork. 1,967 versus 2,739 live threads against 20 hardware threads is essentially the whole effect.

The --profile reports confirm where the time goes: 83% of the reduction in task time is in test / integrationTest tasks.

Task Baseline This branch Change
:grails-data-hibernate7-core:test 1h 01m 54s 19m 07s -69.1%
:grails-fields:test 20m 40s 10m 40s -48.4%
:grails-gsp:test 25m 08s 14m 24s -42.7%
:grails-datamapping-core-test:test 22m 34s 14m 00s -37.9%
:grails-datamapping-support:test 16m 09s 12m 03s -25.3%
:grails-datastore-core:test 15m 33s 13m 28s -13.4%
:grails-data-mongodb-core:test 23m 10s 23m 39s +2.1%

Aggregate test + integrationTest task time fell from 21h 07m to 13h 57m. These are per-task wall-clock times in a parallel build, so they overlap and sum to far more than the elapsed time; treat them as a relative indicator.

Caveats, stated plainly:

  • n = 1 per arm. The direction and rough magnitude are not in doubt; the exact percentage is a single sample.
  • The baseline executed 28 more tasks and roughly 4m 35s more groovydoc / javadoc work, so of the 25-minute gap perhaps 4-5 minutes is task-set and cache-state noise rather than this change.
  • Peak JVM counts include unrelated JVMs running on the machine, so the deltas are meaningful and the absolute counts are not.
  • Both runs ended exit 1 on flaky integration tests, :grails-test-examples-mail:integrationTest in both. With --continue neither build was shortened by them.

Single module

:grails-core:test --rerun-tasks, daemons stopped between runs, comparing second runs:

Order Baseline This branch Change
baseline first 3m 25s 2m 45s 19.5% faster
treatment first 3m 05s 2m 24s 22.2% faster

Running this branch first rules out filesystem-cache ordering bias - the advantage held in both directions.

A single module understates the effect. With only one Test task in flight there is no cross-module contention to remove, so this measures little more than the fork-count reduction:

Baseline This branch
Forks 15 10
ActiveProcessorCount 20 (default) 2
Threads per fork 31 5
Projected total threads ~465 ~50
Peak JVMs 29 24

Per-fork thread figures are a computed projection from java -XX:+PrintFlagsFinal -version, not a live thread count.

Second commit: isolatedTestsTwo was not actually isolated

grails-test-suite-uber/build.gradle set maxParallelForks = 1 on isolatedTestsTwo, but registered that block before the tasks.withType(Test).configureEach block in the same script. Gradle runs both as deferred configuration actions in registration order, so the later configureEach overwrote 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 configureEach block fixes it. CI impact is negligible: the task filters three classes and sharding assigns it to a single shard.

Third commit: prove the flag lands, and cap forge CI forks

0f5f5b653a adds a forked-JVM assertion in each of the three builds. The test reads ManagementFactory.getRuntimeMXBean().getInputArguments() and fails if -XX:ActiveProcessorCount is missing, and checks that Runtime.availableProcessors() equals the advertised count. That is the regression that would catch a future wholesale jvmArgs = ... assignment swallowing the provider.

Those tests passed locally:

  • :grails-core:test --tests grails.util.ActiveProcessorCountForkTests
  • grails-gradle: :grails-gradle-plugins:test --tests org.grails.gradle.plugin.core.ActiveProcessorCountForkSpec
  • grails-forge: :grails-forge-core:test --tests org.grails.forge.ActiveProcessorCountForkSpec

grails-forge now uses the same configuredTestParallel formula as grails-gradle (isCiBuild ? 3, otherwise half the logical processors, still honouring -PmaxTestParallel). Previously forge always used processors.intdiv(2) with no CI cap.

Measuring this on CI

Baseline for gradle.yml on 8.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.

CI fork counts are unchanged on root and grails-gradle, so the only variable on those jobs is the per-fork processor cap: on a 4-vCPU runner each of the 4 forks goes from seeing 4 processors to seeing 2. Per the ratio table above that is 4x oversubscription down to 2x, against 20x down to 2x locally. CI is therefore expected to show a much smaller effect than the local full-build numbers, and one that is easily buried by runner variance - which is what the two samples collected so far look like.

Windows JDK 25 shard 0 was slower in both samples (+23.7%, +25.3%). That job is not comparable to shards 1 and 2: it runs build :grails-shell-cli:installDist groovydoc plus shard 0 tests, while shards 1 and 2 run only testShard. groovydoc and installDist run in the Gradle daemon, which does not receive -XX:ActiveProcessorCount, so those extra tasks cannot be attributed to the processor cap. The remaining plausible causes are Windows cache-writer variance plus the shard 0 tests seeing 2 processors instead of 4. That is not a reason to revert the cap: it is the expected cost of the 4x → 2x change on the least contention-bound job in the matrix. Worth watching on the next CI run of this commit, not a blocker.

Follow-ups, deliberately not in this PR

  • A memory budget. An earlier revision budgeted forks against physical RAM, but a per-task budget cannot bind build-wide, so it was removed rather than shipped as a guarantee it could not keep. Bounding aggregate memory needs org.gradle.workers.max or a shared BuildService.
  • A 5 GB Gradle daemon on a 7 GB macOS runner (GitHub's macOS runners are 3 vCPU / 7 GB, versus 4 vCPU / 16 GB on Linux and Windows) is already marginal and worth revisiting separately.
  • Testcontainers multiply with forks. The Mongo, Redis and forked Geb suites hold containers in per-JVM statics with no withReuse, so N forks means N containers - and on macOS and Windows that memory comes from a Docker Desktop VM the JVM cannot see.
  • grails-gradle and grails-forge have not been benchmarked. They are separate builds; the full-build numbers above cover the root build only.

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

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 52.3323%. Comparing base (b964e60) to head (ccca92f).
⚠️ Report is 30 commits behind head on 8.0.x.

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16158        +/-   ##
==================================================
+ Coverage     52.3252%   52.3323%   +0.0071%     
- Complexity      18536      18547        +11     
==================================================
  Files            2039       2039                
  Lines           97498      97521        +23     
  Branches        17138      17143         +5     
==================================================
+ Hits            51016      51035        +19     
- Misses          38998      39000         +2     
- Partials         7484       7486         +2     

see 10 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jamesfredley

Copy link
Copy Markdown
Contributor Author

CI measurement (first draft run)

All 59 jobs passed. Comparing the Build Grails-Core matrix jobs against the median of the three most recent successful gradle.yml runs on 8.0.x (runs 31893711990, 31892777441, 31730582241):

Job Baseline median This PR Change
Ubuntu JDK 21 84.8 min 59.5 min -29.8%
macOS JDK 21 77.9 min 62.5 min -19.8%
Windows JDK 25 shard 2 45.4 min 36.6 min -19.4%
Ubuntu JDK 25 81.0 min 77.4 min -4.4%
Windows JDK 25 shard 1 43.1 min 43.5 min +0.9%
Windows JDK 25 shard 0 77.1 min 95.4 min +23.7%

Whole-workflow wall clock was 95.5 min against a ~2h14m median.

How much to trust this

Not much yet, on its own. This is one run against a three-run baseline, and per-job variance on GitHub runners is large: baseline Windows JDK 25 shard 0 alone ranged 66.8 - 93.7 min across those three runs, so this PR's 95.4 min is only just outside its own baseline spread. Treat the Windows shard 0 regression and the Ubuntu JDK 21 improvement with equal caution until there are more samples.

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 here

CI fork counts are unchanged by this PR - the isCiBuild budgets stay at 4 (root) and 3 (grails-gradle). So any CI movement comes solely from the per-fork -XX:ActiveProcessorCount cap, not from running fewer forks. On a 4-vCPU runner each fork now sizes its GC and JIT pools for 2 processors instead of 4, which is a much smaller change than the local one (20 processors down to 2).

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 step

Re-run this workflow a couple more times before drawing conclusions, so each job has a comparable sample count to the baseline.

@jamesfredley

Copy link
Copy Markdown
Contributor Author

CI measurement, second sample - correcting the first

A 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.

Job 8.0.x baseline median Sample 1 Sample 2 Mean vs baseline
Ubuntu JDK 21 84.8 min 59.5 80.4 -17.5%
Ubuntu JDK 25 81.0 min 77.4 75.5 -5.6%
macOS JDK 21 77.9 min 62.5 89.9 -2.2%
Windows JDK 25 shard 0 77.1 min 95.4 96.6 +24.5%
Windows JDK 25 shard 1 43.1 min 43.5 45.1 +2.8%
Windows JDK 25 shard 2 45.4 min 36.6 44.2 -11.0%

What this actually shows

Runner 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:

baseline first:   baseline 3m25s, this branch 2m45s   (19.5% faster)
branch first:     this branch 2m24s, baseline 3m05s   (22.2% faster)

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 (isCiBuild ? 4), so the only CI-visible effect is the per-fork ActiveProcessorCount cap - 4 processors down to 2 on a runner, versus 20 down to 2 locally. A small or unmeasurable CI effect is the expected outcome, not a contradiction.

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 step

If 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Reduces workstation/CI contention during Gradle test execution by lowering local test parallelism and capping each forked JVM’s perceived processor count, plus fixes an ordering bug that prevented a specific “isolated” test task from being serialized.

Changes:

  • Reduce local maxParallelForks defaults (while keeping CI fork budgets unchanged) and honor -PmaxTestParallel consistently.
  • Add a CommandLineArgumentProvider that injects -XX:ActiveProcessorCount=... to cap per-fork JVM ergonomics (GC/JIT thread pools).
  • Fix isolatedTestsTwo configuration ordering so its maxParallelForks = 1 override is not overwritten.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
build.gradle Lowers local fork default and adds ActiveProcessorCountArgumentProvider to all Test tasks.
grails-gradle/build.gradle Mirrors root behavior in the separate grails-gradle build; adds the same JVM processor cap.
grails-forge/build.gradle Adds the JVM processor cap for forge tests and minor DSL cleanup.
grails-forge/gradle/test-config.gradle Ensures -PmaxTestParallel is honored for maxParallelForks.
grails-test-suite-uber/build.gradle Moves isolatedTestsTwo overrides after configureEach to prevent being overwritten.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

}

/** Caps each test fork's view of the machine. See the root build.gradle for the rationale. */
final class ActiveProcessorCountArgumentProvider implements CommandLineArgumentProvider {
Comment thread grails-forge/build.gradle
}

/** Caps each test fork's view of the machine. See the root build.gradle for the rationale. */
final class ActiveProcessorCountArgumentProvider implements CommandLineArgumentProvider {
// Half the LOGICAL processors, not 3/4. This separate Gradle build mirrors the root
// build.gradle - see it for the full rationale. CI keeps its existing budget.
configuredTestParallel = findProperty('maxTestParallel') as Integer ?:
(isCiBuild ? 3 : Math.max(1, (Runtime.runtime.availableProcessors() / 2) as int))
Forked test JVMs in all three builds now fail if the processor cap is missing.
grails-forge uses the same CI fork cap as grails-gradle.

Assisted-by: Cursor Grok 4.6
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Follow-up landed in 0f5f5b653a (pushed). What changed since the last description update:

  • Proof the flag reaches forks. Each of the three independent builds now has a forked-JVM test that fails if -XX:ActiveProcessorCount is missing and checks that Runtime.availableProcessors() matches it. Passed locally on root (:grails-core:test), grails-gradle (:grails-gradle-plugins:test), and grails-forge (:grails-forge-core:test).
  • grails-forge CI fork cap. Forge now uses the same configuredTestParallel formula as grails-gradle (isCiBuild ? 3, otherwise half the logical processors, still honouring -PmaxTestParallel). That was the last remaining consistency hole among the three builds.
  • Documented the floor-of-2 vs -PmaxTestParallel=1. A serial local run still advertises 2 processors to that one fork when maxWorkerCount is the unrestricted worker pool. Conservative and keeps G1; it is not a "give the fork the whole machine" switch.
  • Windows JDK 25 shard 0. The extra groovydoc / installDist work on that job runs in the Gradle daemon, which does not get ActiveProcessorCount. Those tasks cannot explain an APC regression. The remaining signal is cache-writer + full-build vs testShard workload, plus shard 0 tests seeing 2 processors instead of 4 (the expected 4x → 2x cost). Watching the next CI run; not reverting.

Still out of scope: memory budget, 5 GB daemon on 7 GB macOS runners, Testcontainers-per-fork, and unbenchmarked grails-gradle / grails-forge full builds.

Avoid Gradle 10's removal of implicit parent-project lookup when test
scripts read the fork-count property defined on each build root.

Assisted-by: Cursor Grok 4.6
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Follow-up ccca92fd4b: every remaining maxParallelForks = configuredTestParallel read now goes through rootProject.ext.configuredTestParallel (root, grails-gradle, grails-forge, and the suite/mongodb/neo4j scripts that set forks themselves). Same value, Gradle 10-safe lookup. No fork-count or APC behavior change.

@testlens-app

testlens-app Bot commented Aug 18, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: ccca92f
▶️ Tests: 68897 executed
⚪️ Checks: 79/79 completed


Learn more about TestLens at testlens.app.

@jamesfredley

Copy link
Copy Markdown
Contributor Author

CI on ccca92fd4b is fully green - no failures, only the usual publish/docs/JMH-on-PR jobs skipped.

Closing the shard 0 question from the description. This run:

Job Time
Windows JDK 25 shard 0 1h 34m 51s
Windows JDK 25 shard 1 44m 57s
Windows JDK 25 shard 2 47m 39s

Shard 0 stays roughly 2x shards 1 and 2, which is the workload difference, not a regression: shard 0 runs build + groovydoc + :grails-shell-cli:installDist on top of its tests, while shards 1 and 2 run testShard only. groovydoc and installDist execute in the Gradle daemon, which never receives -XX:ActiveProcessorCount, so the cap cannot be the cause of that gap.

The new forked-JVM assertions passed everywhere they run, so the flag is landing on real CI runners and not just locally.

@jamesfredley
jamesfredley merged commit 89e993c into 8.0.x Aug 19, 2026
82 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants