Skip to content

ci: batch unit test packages into one go test run per group - #16335

Merged
pitasi merged 4 commits into
mainfrom
anto/ci-unit-test-batching
Aug 11, 2026
Merged

ci: batch unit test packages into one go test run per group#16335
pitasi merged 4 commits into
mainfrom
anto/ci-unit-test-batching

Conversation

@pitasi

@pitasi pitasi commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

what

runs each unit test group as a single go test invocation instead of one per package, drops the group count from 10 to 5, and fixes two problems with how we use the actions cache.

why

run-unit-tests-group.sh ran go test once per package, serially, so a group used 0.4 of the runner's 4 cores — 98s of CPU over 242s of wall clock. these tests are latency-bound, not CPU-bound, so serializing packages wastes almost the whole runner. batching them lets go test schedule packages concurrently up to -p.

group wall time is now bounded by the slowest single package rather than by how many packages are in the group, so 10 groups buy nothing over 5. locally at GOMAXPROCS=4: group 0 of 5 went 231s -> 94s, and the heaviest group (the one with pkg/ingester) ran in 254s against a 485s serial sum.

the cache fixes came out of checking whether we were actually getting anything from the runner cache:

  • the restore keys embedded the full build image reference, digest included, in both the primary key and its only fallback prefix. the build image gets rebuilt every time its own tooling deps are bumped — 8 times in the currently retained cache window, none of which touch the go toolchain — and each one dropped all three caches to nothing on every branch until main warmed them again. main is doing this right now with chore(deps): update module github.com/google/go-containerregistry to v0.21.8 (main) #16327.
  • compare-helm-with-jsonnet.yml and update-vendored-mimir-prometheus.yml called setup-go without cache: false, unlike the two calls in this file. that was 26 entries / 8.18GB, as much as every deliberate go build cache combined, all scoped to refs/pull/N/merge so only another push to the same PR could ever restore them. the module half is empty anyway since we vendor.

caches are reclaimed on last access, not on age, so that footprint was shortening how long the shared caches on main survive.

notes

  • per-package retry is why the per-package loop existed. kept it by parsing the FAIL\t<pkg> lines out of the output and re-running only those packages.
  • the second restore-keys tier is safe because go keys build cache entries by compiler build ID — entries from a different go version get carried but never hit.
  • this renames the required status checks (test (0, 10) -> test (0, 5)), so branch protection needs updating or merges will block.
  • deliberately based a couple of commits behind main: main just bumped the build image, and branching off that would have measured a cold cache on top of the batching change. will rebase before review.
  • -parallel 16 on top of this is worth another 25-30% but flaked TestIngester_compactBlocksToReduceInMemorySeries_Concurrency, so not here.
  • the integration test split is 3.03x imbalanced by round-robin and is the next thing worth doing, but it needs a committed timings file since none of those tests call t.Parallel().

pitasi and others added 3 commits August 10, 2026 12:55
run-unit-tests-group.sh invoked "go test" once per package, serially, so a group
used 0.4 of the runner's 4 cores (98s of CPU over 242s of wall clock). These tests
are latency-bound rather than CPU-bound, so serializing packages wastes almost the
whole runner. Batch them into one invocation per race/no-race set instead and let
"go test" schedule packages concurrently up to -p.

Per-package retry, which is why the loop existed, is kept by parsing the
"FAIL<tab>package" lines out of the output and re-running only those.

Group time is now bounded by the slowest single package rather than by the number
of packages in the group, so 10 groups no longer buy anything over 5: measured
locally at GOMAXPROCS=4, group 0 of 5 went 231s -> 94s and the heaviest group
(pkg/ingester) ran in 254s against a 485s serial sum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two unrelated problems with how the repository uses the Actions cache.

The restore keys embedded the full build image reference, digest included, in both
the primary key and its only fallback prefix. The build image is rebuilt whenever
its own tooling dependencies are bumped (8 times in the currently retained cache
window: smithy-go, cuelang, klauspost/compress, ...), and none of those touch the
Go toolchain, yet each one dropped all three caches to nothing on every branch
until main warmed them again. Add a second fallback tier that drops the image
reference. This is safe because Go keys build cache entries by compiler build ID,
so entries produced by a different Go version are carried but never hit.

compare-helm-with-jsonnet.yml and update-vendored-mimir-prometheus.yml called
setup-go without "cache: false", unlike the two calls in test-build-deploy.yml.
That produced 26 entries totalling 8.18GB, as much as every deliberate Go build
cache combined. They are scoped to refs/pull/N/merge, so only another push to the
same PR can ever restore them, and the module half is empty anyway because this
repository vendors its dependencies. Since caches are reclaimed on last access,
that footprint shortens how long the shared caches on main survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A cache written by a pull request run is scoped to that PR's merge ref, and GitHub
only lets re-runs of the same PR restore it: not main, not sibling PRs. Once the PR
closes nothing can hit those entries again, so deleting them is lossless.

Measured on 2026-08-10: 10.38GB across 165 entries were PR scoped, against
9.93GB across 32 on main, and 16 of the 21 PRs still holding caches were already
merged or closed. Since caches are reclaimed on last access rather than on age,
that dead weight directly shortens how long the shared caches on main survive.

Caches a PR only reads from main are owned by refs/heads/main, a different scope,
so they are never listed by the ref filter this uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pitasi pitasi added the changelog-not-needed PRs that don't need a CHANGELOG.md entry label Aug 10, 2026
@pitasi
pitasi marked this pull request as ready for review August 10, 2026 13:24
@pitasi
pitasi requested a review from a team as a code owner August 10, 2026 13:24

@tcard tcard 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.

Nice 👍

pitasi added a commit that referenced this pull request Aug 11, 2026
## what
gives `TestIngesterSharding` an integration group to itself and
round-robins the remaining 110 tests over the other 19.

## why
the integration split round-robins the sorted test list, which leaves it
**3.13x imbalanced**. measured on run 31381351961, the 20 groups ranged
from 90s to **703s** against a 224s average.

`TestIngesterSharding` is the whole problem: at **290s**. pinning it
takes the slowest group from 628s to 290s of test time.

## notes
- this won't move wall clock on its own: #16335 and this one together
are what get a run to ~11 min

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@pitasi
pitasi merged commit 6e13bbe into main Aug 11, 2026
92 of 93 checks passed
@pitasi
pitasi deleted the anto/ci-unit-test-batching branch August 11, 2026 07:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog-not-needed PRs that don't need a CHANGELOG.md entry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants