Repository navigation
Shard the CI test batches across parallel jobs (#730) - #863
Conversation
The test workflow was one job whose wall-clock tracked the whole suite. On 2026-10-28 that measured 91 minutes of tests across 16 serial batches plus the MPI phase, giving 96-minute jobs against a 120-minute cap, and #689 was cancelled at 2h00m52s with no failures recorded. The cap had started deciding outcomes rather than bounding runaway runs, which is the trigger #730 was filed against. The serial batches now run as a four-way shard matrix. Each batch is assigned to a shard by hand from the measured times, recorded in scripts/test.sh beside the batch definitions, because the batches differ by two orders of magnitude in cost and round-robin would group the three expensive ones together. The split is 33 / 19 / 18 / 22 minutes, so wall-clock becomes about 38 minutes including install and build. `--shard K/N` selects one; omitting it runs everything, which is what a local full run wants. Two parts had to move rather than be copied. The tier C report costs about 42 minutes and is now its own job: as a step on a matrix job it would run once per shard to produce one report. The MPI phase belongs to shard 4 alone, for the same reason and because it is the only part holding several ranks at once. An aggregate check named `test` reports the combined result, so one conclusion still answers whether the suite passed, and `development` carries no required status check that the rename could strand. Each shard rebuilds rather than fetching a shared artefact. The shards run concurrently, so the repeated build costs wall-clock once, and the repository is public, so the runner minutes are free. #730 named artefact sharing as the part needing design; measured against what it would save, it is not worth a transfer round trip and a second definition of "built". check_test_coverage.py reads the batch globs out of test.sh to prove every test file is reachable, and now recognises the sharded call site too. Left as it was, it reported all 355 files unreachable -- loud, which is the right direction, but it has to be kept in step. Verified by stubbing pytest and running each shard: 28 + 53 + 81 + 194 = 356 files, matching the unsharded run exactly, with no file in two shards and none in neither. Left open on #730: the test_106*/test_107* band is 33 minutes and sets the floor. It is a tail, not a volume -- 48% of its 149 tests finish in the first two minutes. Since the distribution is --dist loadfile a file is atomic on a worker, so the band cannot finish before its slowest file, and splitting it further wants the per-file numbers rather than a guess. Underworld development team with AI support from Claude Code https://claude.ai/code/session_01E87Q7KrpapxeQiLD1RiNXv
The matrix listed [1, 2, 3, 4] and an env var said SHARDS: 4, while scripts/test.sh assigned batches to shards 1-4 independently. Three copies of one number, and the failure mode is not a nuisance: a matrix of [1, 2, 3] against an assignment that uses 4 runs GREEN while shard 4's batches -- 194 files, the largest group -- never run. Nothing in the logs says so. That is #721 exactly, files going dark behind a pattern that stopped covering them. So the number is derived rather than repeated. `--print-shards` reads the largest shard in the `batch` call sites out of the script itself and prints a JSON array; a `plan` job emits that, the matrix is built from it with fromJSON, and the shard argument uses strategy.job-total. Adding a `batch 5 ...` line now grows CI a fifth shard on the next run with nothing else to edit. Where a count still has to be compared, it is checked rather than assumed: --shard K/N refuses N that disagrees with the assignment, and refuses K outside 1..N, instead of running a partial suite quietly. Verified against the stub again after the refactor: 28 + 53 + 81 + 194 = 356 files, equal to the unsharded run, no file in two shards and none in neither. Also checked that the guards exit 1 rather than warning (mismatch and out-of-range both do), and that a failing batch fails its own shard and only its own shard. Underworld development team with AI support from Claude Code https://claude.ai/code/session_01E87Q7KrpapxeQiLD1RiNXv
The commit that landed the matrix predicted 33 / 19 / 18 / 22 minutes from the per-batch times and a ~38 minute wall-clock. Run 40638600008 measured 23.6 / 28.3 / 17.3 / 22.6 including install and build, for 28.7 against 96 before. Better than claimed, and ranked differently: shard 1 carries the 33-minute test_106*/test_107* band and finished in 23.6, while shard 2 was estimated at 19 minutes of tests and is the long pole at 28.3. A batch costs less in a job that runs only its own batches than it did in sequence, by more than the assignment assumed, so the per-batch times are not a linear predictor of a shard. The numbers are recorded rather than acted on. Re-assigning off one sample would be fitting runner noise: the imbalance left (28.3 against 17.3) is of the same order as the variance on a shared runner, so it wants several runs first. Underworld development team with AI support from Claude Code https://claude.ai/code/session_01E87Q7KrpapxeQiLD1RiNXv
First sharded run: measuredRun 40638600008, all green.
The mechanism works as designed: The estimates were conservative and ranked differently, which is worth We have recorded this rather than re-tuned on it ( Follow-up for #730, now measured rather than inferredThe 582 s is 79% of the 735 s measured so far, and |
…split-multigrid One conflict, in scripts/test.sh: #863 turned each pytest line into a `batch <shard> ...` call, and this branch added the test_108*/test_109* line in the old form. Resolved to the batch form, with the new line on shard 3, the lightest measured. check_test_coverage: all 376 files reachable. Underworld development team with AI support from Claude Code
Development now carries #863's sharded CI, and #882, #889 and #890 (this branch's base). The one conflict was scripts/test.sh: test_1090's serial entry now takes the batch form, on shard 4 beside test_1069. All 382 test files are reachable (check_test_coverage). Underworld development team with AI support from Claude Code
Closes the trigger condition on #730: the test job's wall-clock had started
deciding outcomes rather than bounding runaway runs.
Why now
#730 said to revisit when "Run tests" passed roughly 70 minutes. Measured on
run 40623403571 (
development, 2026-10-28, SUCCESS):Three recent successful runs took 96, 97 and 96 minutes. PR #689 was cancelled
at 2h00m52s having recorded no failures — its per-batch times run about
1.35x #677's throughout, so it cannot fit the box however many times it is
re-rolled.
What this does
The serial batches run as a shard matrix. Each batch is assigned to a shard by
hand from the measured times, which are recorded in
scripts/test.shbesidethe batch definitions:
Round-robin would have been worse than useless on that distribution — it would
group the three expensive bands together. The hand assignment gives
33 / 19 / 18 / 22 minutes, so wall-clock becomes about 38 minutes
including install and build, down from 96.
--shard K/Nselects one; omitting it runs everything, which is what a localfull run wants.
Two things had to move rather than be copied
The tier C report costs about 42 minutes and is now its own job. As a step
on a matrix job it would have run once per shard to produce one report.
The MPI phase belongs to one shard, for the same reason and because it is
the only part of the suite holding several ranks at once — the part most likely
to contend on a shared runner.
A new aggregate check named
testreports the combined result, so oneconclusion still answers whether the suite passed.
developmentcarries norequired status check that the rename could strand; we checked the ruleset
rather than assuming.
Each shard rebuilds
#730 named build-artefact sharing as "the part that needs designing". Measured
against what it saves, it is not worth it: the shards run concurrently, so the
repeated ~2.5 min build costs wall-clock once, and the repository is
public, so the runner minutes are free. Sharing it would add a transfer round
trip and a second definition of what "built" means.
Adversarial review
The review found one defect in this change, and it was the serious kind.
The shard count had three copies — the matrix list, a
SHARDSenv var, andthe assignments in
scripts/test.sh. The failure mode is not a nuisance: amatrix of
[1, 2, 3]against an assignment using 4 runs green while shard4's batches — 194 files, the largest group — never run, with nothing in the
logs to say so. That is #721 exactly, files going dark behind a pattern that
stopped covering them.
Fixed by deriving the number instead of repeating it.
--print-shardsreadsthe largest shard out of the
batchcall sites in the script itself; aplanjob emits it; the matrix is built with
fromJSON; the shard argument usesstrategy.job-total. Adding abatch 5 ...line now grows CI a fifth shardwith nothing else to edit.
Where a count still has to be compared it is checked, not assumed:
--shard K/Nrefuses anNthat disagrees with the assignment, and refusesKoutside1..N, rather than running a partial suite quietly.Evidence
Verified by stubbing
pytestand running each shard, before and after thatrefactor:
Also checked, because a guard that warns is not a guard:
scripts/check_test_coverage.pyreads the batch globs out oftest.shtoprove every test file is reachable. Left alone it reported all 355 files
unreachable — loud, which is the right direction for it to break in, but it
still had to learn the sharded call site. It now passes: all 355 test files
are reachable from scripts/test.sh.
Determinism is untouched. Files were not re-partitioned within batches,
only batches assigned to jobs, so which files share an xdist process is exactly
what it was. That matters because the worker count is what #567 turned on.
Left open on #730
The
test_106*/test_107*band is 33 minutes and sets the floor. It is atail, not a volume: 48% of its 149 tests finish in the first two minutes,
then the remainder takes 31. Because the distribution is
--dist loadfileafile is atomic on a worker, so the band cannot finish before its slowest file
does. Splitting it further wants the per-file numbers rather than a guess, and
a local per-file measurement is running now — #730 stays open for it.
Issue adjudication
fixed-in-PR. The sharding itdesigned is in; the per-file split it did not anticipate stays on it.
defect: it was cancelled by the cap with zero failures. It needs a re-roll
once this merges, not a fix.
than touching the globs it widened.
Underworld development team with AI support from Claude Code
https://claude.ai/code/session_01E87Q7KrpapxeQiLD1RiNXv