Skip to content

Make the benchmarks able to resolve real effects - #15

Merged
brunoborges merged 19 commits into
mainfrom
improve-benchmark-statistics
Aug 5, 2026
Merged

Make the benchmarks able to resolve real effects#15
brunoborges merged 19 commits into
mainfrom
improve-benchmark-statistics

Conversation

@brunoborges

@brunoborges brunoborges commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

The focused cache restore benchmark could not distinguish a real improvement from runner noise, so its headline numbers were not evidence of anything.

The benchmark reported effects that do not exist

Runs 30972482195 and 30973171928 measured the same unchanged actions/setup-java@v4.8.0 twelve minutes apart. The true difference is exactly zero.

Run v4 samples (s) Median
1 2,1,3,1,1,2,3,2,2,2 2
2 3,4,2,4,4,3,4,3,3,1 3

A 1.2s separation whose bootstrap 95% CI is [-1.97, -0.43]it excludes zero. Over the same two runs the reported main vs v4 delta flipped from +0.6s to -0.8s. Every published -1.0s was inside the noise band.

Root causes:

  1. The clock was too coarse. Durations came from the Actions API, whose step timestamps have one-second resolution, used to measure a 2-6s quantity. Note that every sample above is an integer.
  2. The comparison was confounded. Each arm ran as its own matrix of independent jobs, so the arm difference was entangled with between-runner variance, which on hosted runners exceeds the effect.
  3. The pairing was not real. The report paired sample N of one arm with sample N of the other, but those jobs shared no runner and no point in time. It removed no variance while the label implied precision the data did not have. No intervals were reported anywhere.

What changed

  • scripts/measure.mjs times setup from inside the job at millisecond resolution.
  • Both arms run in the same job in ABBA order. Each runner yields one genuinely paired difference with its own speed cancelled, and the mirrored order cancels drift. Each slot deletes ~/.m2 first.
  • One seeded cache entry, restored by both arms (see below).
  • The matrix runs in waves of four, so it does not measure its own cache contention.
  • scripts/stats.mjs adds seeded bootstrap intervals, a paired permutation test, and a Hodges-Lehmann shift, and converts them into an explicit verdict. A comparison whose interval includes zero reports inconclusive rather than a number that looks like a result.
  • Two guard rails in every report: a noise floor from within-runner repeat spread, and an A/A control applying the same estimator to the baseline against itself. Both cost zero extra jobs.
  • setup-java-repository / baseline-ref / candidate-ref inputs, so a PR branch can be measured directly.

The A/A check found a confound in this PR

I ran the new workflow with baseline-ref and candidate-ref both set to main. The true effect is exactly zero, so anything else is a harness defect. It reported a 0.859s improvement (run 30974285173). The A/A control was flat, so slot ordering was not the cause.

The cause was that each arm seeded its own cache entry. A cache entry's download throughput depends on where the service placed the stored blob, and that placement is fixed for the entry's life. In one job on one runner:

Slot Arm 160 MB blob
1 baseline 59.4 MB/s
2 candidate 131.8 MB/s
3 candidate 105.1 MB/s
4 baseline 60.3 MB/s

The same ordering held on every runner. Because that bias is constant across runners rather than random, pairing cannot remove it and more samples only tighten the interval around the wrong answer. The arm was confounded with the blob. One entry is now seeded and both arms restore it, holding the blob constant by construction.

Validation on real runners

Run Refs Verdict Estimate 95% CI Noise floor
30974505137 main vs main (A/A) inconclusive -0.401s [-1.318, +0.578] 1.758s
30974605249 v4.8.0 vs main inconclusive -0.619s [-2.359, +0.974] 1.890s
30974739627 v4.8.0 vs main, waves of 4 inconclusive +0.073s [-0.107, +0.217] 0.170s

The A/A run reports no effect, as it must. Capping concurrency cut the noise floor 11x (1.890s to 0.170s) and the interval width 10x.

Synthetic validation with a known injected effect under realistic between-runner spread:

True effect Verdict Estimate 95% CI p
0 ms inconclusive +39 ms [-20, +101] 0.263
100 ms inconclusive -61 ms [-120, +1] 0.098
400 ms improvement -361 ms [-420, -299] 0.002

What this says about main

On this scenario main is not measurably different from v4.8.0: +0.073s with a 95% CI of [-0.107, +0.217]. The interval is tight enough to rule out the previously reported -1.0s improvement outright.

26 tests pass. The real A/A dataset is pinned in scripts/stats.test.mjs so unpaired sampling is not reintroduced.

The same treatment, applied to the other three workflows

Benchmark setup-java and JDK cache had the same three defects, so they now share the machinery. scripts/paired.mjs holds the pairing, grouping, noise floor and verdict logic that all three reports build on.

JDK cache became a two-arm ABBA comparison of cache-jdk: false against cache-jdk: true on the same action ref, so the result isolates JDK caching rather than conflating it with implementation changes. One seed job populates a single Maven entry that both arms restore; only the JDK entry differs between them.

Benchmark setup-java became a seven-arm mirrored sweep. Every runner sets up v1..main and then main..v1 in one job, which places each version's two slots symmetrically about the middle of the job. Every version supporting cache-dependency-path restores one shared entry. cache-read-only is not used, because it exists only on main and applying it asymmetrically would change post-job behaviour between arms.

Three more A/A failures, each a real defect

Running each harness against itself is what found every remaining problem. In each case the true effect is exactly zero, so any other verdict is a defect rather than a finding.

A one-off first-slot cost. The first JDK cache run under the new harness failed its control: comparing the no-cache arm's own first and last slot, which run identical configuration, reported -0.4s, 95% CI [-0.7, -0.1]. The first setup in a job pays DNS resolution, TLS handshakes to the cache service and the JDK host, and a cold page cache. That is a spike at slot 1, not drift across the job, so mirrored ordering cannot cancel it — it simply biases whichever arm holds the first slot. Every job now runs one unmeasured warm-up slot.

A single stalled slot dominating the mean. A focused A/A with both refs on main reported "Slower", +0.821s, 95% CI [0.119, 1.672] (run 30976592589). Seven of ten runners agreed to within 0.12s; two had one slot that stalled on the cache service (8.169s against a 3.286s sibling, 10.030s against 4.449s) and both landed on the candidate arm by chance.

Runners whose own arm disagrees with itself by more than a robust threshold are now discarded. That decision uses only within-arm spread, which has the same distribution whether or not the arms differ, so unlike filtering on the arm difference it cannot bias the result. On the same data: -0.020s, 95% CI [-0.069, 0.030], p = 0.498, inconclusive. The dataset is pinned as a regression test.

The estimator stays the mean rather than becoming a median — a sign-flip test on the median has almost no power here, giving p = 0.13 for six runners agreeing on a 12.5s effect. Robustness comes from the filter.

Interval and test disagreeing. A verdict now requires the permutation test to agree with the bootstrap interval. The percentile bootstrap is only approximate at ten paired observations while the sign-flip test is exact under the null, so where they disagree the interval is wrong. This earned its keep immediately: the revalidation run had an interval excluding zero but p = 0.081, and correctly reported inconclusive.

Stalls no longer hold a run open

Two runs were held open by a slot sitting on a cache restore for minutes while its neighbours took seconds. Each measured setup is capped at three minutes and each job at 25, so a stall now costs one runner — which the report already discards — instead of the run. The sweep restores fifteen times per job against the two-arm workflows' five, so it runs in narrower waves.

Results

A clean run of all four workflows from this branch. Every control comes back clean, which is what makes the headline numbers admissible.

JDK cache, 20 runners (run 30981227720):

Estimate 95% CI p Verdict
cache-jdk: true vs false -1.0s [-1.3, -0.6] 0.000 improvement
A/A control -0.5s [-1.1, -0.0] inconclusive

Noise floor 0.202s, no runner discarded. The JDK cache benefit is measured rather than asserted. An earlier run put it at -2.9s; that estimate was inflated by stalled runners the filter now removes, which is the difference between a number and a number you can rely on.

Focused cache restore, v4.8.0 vs main, 20 runners (run 30981229350):

Estimate 95% CI p Verdict
main vs v4.8.0 +0.049s [+0.008, +0.087] 0.033 within-noise
A/A control -0.013s [-0.093, +0.084] inconclusive

Noise floor 0.057s, 4 runners discarded. This is the harness declining to sell a result it cannot support: the interval excludes zero and p clears 0.05, but the estimate is smaller than the floor, so it reports within-noise instead of a 49ms regression. Under the old harness this would have been a headline.

Version sweep, 10 runners, warm restore path (run 30981226274):

main is the newest code, so it is the thing under test and each released version is a baseline it is measured against — the same direction the two-arm workflows use. Every number below describes main, and an improvement means main is faster than that version. Only versions that do the same work from the same stored cache entry are ranked, with Holm's step-down correction across that family.

Compared with main vs it 95% CI Holm p Verdict for main
v4.8.0 -0.011s [-0.141, +0.087] 1.000 inconclusive
v5.2.0 -0.038s [-0.209, +0.098] 1.000 inconclusive
v5.6.0 -0.441s [-0.746, -0.220] 0.007 improvement
A/A control -0.2s [-0.5, +0.0] inconclusive

Noise floor 0.083s, no runner discarded. main is indistinguishable from v4 and v5.2. main is measurably faster than v5.6.0, and this is the one sweep finding that survives scrutiny: it has now reproduced across three independent runs, and v5.6.0 is the slower arm on all ten runners individually in each of them, which no amount of blob luck explains. The point estimate moves between runs (-0.4s here, -1.3s in the previous run) because it scales with how slow the runners happen to be that day; the sign does not move.

An earlier revision of this PR reported that same measurement as a regression in v5.6.0. That states it backwards — nothing regressed in v5.6.0; it shipped, and then main got faster. The sweep now differences main minus each version so the verdict describes the code under test, and a test pins the direction.

v1, v2 and v3 are measured on the same runners and their durations published, but they carry no verdict, because for them a difference in duration is a difference in the workload rather than in the implementation:

Version Median Why it is not ranked
v1.4.4 3.008s Installs its own JDK and does no dependency caching
v2.5.1 0.214s Its bundled cache client is rejected by the current cache service, so it restores nothing
v3.14.1 1.959s Predates cache-dependency-path and keys on pom.xml, so it restores its own entry — a blob's throughput is fixed for the life of the entry, and pairing cannot remove that confound

The old report would have called v2 a 1.5s improvement over main. An intermediate version of this PR split the sweep into three cohorts with three references; that was reverted, because it deleted the v4/v5-vs-main comparison the sweep exists to make, and it handed v3 a verdict at p=0.046 — a live false positive of exactly the kind this PR exists to prevent. A stored blob's throughput is fixed for the life of the entry, and pairing cannot remove that.

Maven configuration warm path, v4.8.0 vs main, 36 configurations (run 30981230695):

Group Blocks Estimate 95% CI p Verdict
Pooled 32 +0.025s [-0.027, +0.074] 0.346 inconclusive
cache: none 11 -0.144s [-0.213, -0.095] 0.001 improvement
cache: maven 11 +0.123s [+0.083, +0.168] 0.001 regression
cache: gradle 10 +0.104s [+0.069, +0.141] 0.002 regression
A/A control +0.013s [-0.009, +0.036] inconclusive

Noise floor 0.027s, 4 configurations discarded. This workflow runs each configuration once, so it pools paired differences across the matrix instead of quoting a per-cell verdict it cannot support. The pooled number is inconclusive because two real effects of opposite sign cancel: main is about 0.14s faster than v4.8.0 when no cache is configured and about 0.11s slower when one is. Both signs and magnitudes reproduce across runs. Neither is visible in the pooled figure, which is exactly why the report breaks the matrix down rather than publishing a single number.

37 tests pass. The original A/A dataset is pinned in scripts/stats.test.mjs so unpaired sampling cannot be reintroduced, and tests cover the properties the design rests on: that differencing within a runner removes between-runner speed differences, that the mirrored order cancels drift linear across the job, and that a stalled runner is excluded.


Update: benchmarks rescoped by question

The three timing workflows were measuring one quantity repeatedly, and mostly not one setup-java controls.

  • maven-configuration-warm-path was not a warm path. It had no seed job. Its synthetic benchmark/pom.xml hashed to a key nothing had ever stored, so every "warm" restore was a miss — job logs read maven cache is not found and Path Validation Error ... hence no cache is being saved. Its three cache profiles were three variations on a failed lookup, and the −0.144 s / +0.11 s figures it published were cold-miss effects under a heading that said warm.
  • focused-cache-restore duplicated the version sweep, comparing v4.8.0 against main on the same warm restore.
  • All three timed the transfer, which setup-java hands to @actions/cache. Identical code varied 9.8× across runners (SD 1.208 s) while the effect under test was 0.440 s — 0.36× that spread.
  • Nothing measured cache-versus-no-cache, key stability, or save cost.

The workflows now

Workflow Question Fixture Resolves
Cache value What does caching buy at all? Real PetClinic, cached vs none Tens of seconds
Cache key stability Does it hit when it should? Deterministic key assertions Pass/fail
Action overhead What does setup-java's code cost? ~1 MiB entry, genuinely seeded Tens–hundreds of ms
Transfer overlap Does it overlap its transfers? 160 MiB + wrapper entry Hundreds of ms
Cache save What does run #1 pay? Maven-shaped tree, post-run save Seconds
JDK cache Is caching the JDK worth it? unchanged Seconds
Benchmark (sweep) How does main compare per release? unchanged Hundreds of ms

Action overhead and Transfer overlap are the same measurement at two fixture sizes, deliberately. A large fixture measures the network; a small one measures the action. Together they decompose a restore into the part setup-java controls and the part it does not, and the 0.44 s overlap win from actions/setup-java#1174 should appear in one and not the other.

Action overhead's four cache profiles are now nested levels — none, maven-miss, maven-hit, gradle-miss — that difference into a published decomposition, so the report says where the time goes rather than only whether two refs differ. Every slot records cache-hit, and the report leads with a failure banner if a maven-hit slot missed, because that would silently turn one level into another.

Cache key stability measures nothing: the key is a deterministic function of the tree, and a key that moves when it shouldn't costs a full miss (~1 min on PetClinic) where the timing workflows resolve tens of milliseconds. It stores nothing either — setup-java skips its post-job save when the cache path doesn't exist, and no probe creates ~/.m2.

The methodology from earlier in this PR is unchanged and applies to all of them: millisecond in-job timing, same-runner mirrored ordering, one shared cache entry per comparison, a discarded warm-up slot, stalled-runner dropping, and a verdict that requires the permutation test and the interval to agree.

58 unit tests pass; shellcheck and YAML parse clean.

Note: the five new/renamed workflows cannot be dispatched until this branch merges — GitHub only accepts workflow_dispatch for files present on the default branch.

The focused cache restore benchmark could not distinguish a real improvement
from runner noise, so its headline numbers were not evidence of anything.

Two consecutive runs against an unchanged actions/setup-java@v4.8.0, where the
true difference is exactly zero, reported medians of 2s and 3s. That spurious
1.2s separation has a bootstrap 95% CI of [-1.97, -0.43], which excludes zero.
Across the same two runs the reported candidate delta flipped from +0.6s to
-0.8s.

Three defects caused this:

- Durations were read from the Actions API, whose step timestamps have
  one-second resolution. Setup takes two to six seconds, so every sample
  carried +/-500ms of quantization error, the same magnitude as the effects
  being measured. Every recorded sample was an integer.
- Each arm ran as its own matrix of independent jobs, so the comparison was
  confounded with between-runner variance, which on hosted runners is larger
  than the effect. Adding samples to each arm does not remove it.
- The report paired "sample N" of one arm with "sample N" of the other. Those
  jobs shared no runner and no point in time, so the pairing removed no
  variance while the label implied precision the data did not have. Results
  were published as bare point estimates with no interval.

This change rebuilds the workflow around measurements that can support a
verdict:

- scripts/measure.mjs times the setup step from inside the job at millisecond
  resolution instead of reading the API clock.
- Both arms now run in the same job in ABBA order, so each runner produces one
  genuinely paired difference with its own speed cancelled out, and the
  mirrored order cancels drift across slots. Each slot deletes ~/.m2 first so
  every restore extracts into an empty tree. This also uses fewer jobs than the
  design it replaces.
- scripts/stats.mjs adds seeded bootstrap intervals, a paired permutation test,
  and a Hodges-Lehmann shift estimate, and converts them into an explicit
  verdict. Comparisons whose interval includes zero are reported as
  inconclusive rather than as a number.
- The report publishes a noise floor derived from the within-runner repeat
  spread, and an A/A control that applies the same estimator to the baseline
  against itself. Because each arm is already measured twice per runner, the
  control costs no extra jobs. A run whose control resolves a difference is
  flagged as untrustworthy.
- The workflow takes setup-java-repository, baseline-ref, and candidate-ref, so
  a PR branch can be measured directly.

Validated against synthetic data with a known effect injected under realistic
between-runner spread: a true 0ms effect reports inconclusive, and a true 400ms
effect is recovered as -361ms with a 95% CI of [-420, -299] and p = 0.002. The
previous harness could not resolve anything below one second and returned false
positives at zero.

The real A/A dataset is pinned in scripts/stats.test.mjs so unpaired sampling
is not reintroduced.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
Copilot AI lite review requested due to automatic review settings August 5, 2026 04:04

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

This PR overhauls the Focused cache restore benchmark so it can reliably detect real performance effects (vs runner noise) by switching to in-job millisecond timing, same-runner ABBA pairing, and statistical reporting with intervals and explicit verdicts.

Changes:

  • Replace Actions API step-timestamp timing with an in-job stopwatch (scripts/measure.mjs) and per-runner ABBA sampling in the workflow.
  • Introduce shared statistical helpers (scripts/stats.mjs) with bootstrap intervals, permutation testing, and verdict classification, plus comprehensive tests.
  • Rewrite the focused report generator to consume uploaded CSV timings, compute paired analysis + guard rails (noise floor, A/A control), and update docs/tests accordingly.
Show a summary per file
File Description
scripts/stats.mjs Adds statistical primitives (bootstrap/paired intervals, permutation test, HL shift, verdict formatting).
scripts/stats.test.mjs Adds tests for determinism, intervals, classification, and a pinned regression dataset for unpaired false positives.
scripts/report-focused.mjs Reworks focused report to read uploaded timing CSVs, build paired samples, analyze, and render a verdicted report.
scripts/report-focused.test.mjs Adds tests for parsing/pairing, noise floor, verdict behavior, and report rendering.
scripts/measure.mjs Adds millisecond stopwatch + CSV recorder for benchmark slots inside the job.
scripts/focused-cache-restore.sh Adds helpers for cache identity, fixture seeding, reset, and verification across ABBA slots.
.github/workflows/focused-cache-restore.yml Runs both arms in one job in ABBA order, records timings, uploads artifacts, and generates the new report.
scripts/report.test.mjs Removes focused-job-name parsing tests that no longer apply.
README.md Updates benchmark methodology documentation and explicitly calls out limitations of other workflows still using API timing/unpaired arms.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread scripts/stats.mjs Outdated
Comment on lines +186 to +190
export function formatInterval(interval, {digits = 1, unit = 's'} = {}) {
if (!interval) return 'n/a';
const {estimate, low, high} = interval;
return `${estimate.toFixed(digits)}${unit} (95% CI ${low.toFixed(digits)} to ${high.toFixed(digits)})`;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — fixed. formatInterval now reads the level off the interval itself (confidence, defaulting to 0.95 for intervals built before that field was carried), so widening an interval can no longer publish it under a 95% label. Covered by a test in scripts/stats.test.mjs.

Comment on lines 241 to 250
export async function main(env = process.env) {
const [owner, repo] = env.GITHUB_REPOSITORY.split('/');
const token = env.GH_TOKEN;
const runId = env.GITHUB_RUN_ID;
const attempt = env.GITHUB_RUN_ATTEMPT;
const samples = Number(env.SAMPLES);
const javaVersion = env.JAVA_VERSION;
if (!owner || !repo || !token || !runId || !attempt || !samples) {
const baselineRef = env.BASELINE_REF;
const candidateRef = env.CANDIDATE_REF;
const setupJavaRepository = env.SETUP_JAVA_REPOSITORY;
if (!owner || !repo || !token || !runId || !baselineRef || !candidateRef) {
throw new Error('Missing required GitHub Actions environment variables');
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and this applied to the other three reports too. Added a shared requireEnv helper in paired.mjs and pointed all four reports at it, each declaring every variable it renders — including SETUP_JAVA_REPOSITORY and JAVA_VERSION here. It names the missing variables rather than reporting a generic failure, and the owner/repo split now has its own message. I verified all four workflows actually set what they now require.

brunoborges and others added 8 commits August 5, 2026 00:08
The first live run missed the seeded wrapper cache on every runner, so all six
measure jobs failed with the wrapper fixture absent.

setup-java keys its separate wrapper cache on
**/.mvn/wrapper/maven-wrapper.properties, and cache-dependency-path overrides
the pattern for the main dependency cache only, not for the additional one.
setup-java itself carries such a file under __tests__/cache/maven, so the
derived key depends on how many copies of the action are checked out. The seed
jobs checked out one ref and the measure job checked out two, which produced
different wrapper keys and a permanent miss.

Every job now checks out both refs, including the arm it does not use, so all
jobs hash an identical tree. The fixture check also reports a missing file
directly instead of letting wc fail with a bare no-such-file error.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
An A/A run of the new workflow, with baseline-ref and candidate-ref both set to
main, reported a 0.859s improvement on identical code. The A/A control was flat
at -0.060s, so slot ordering was not the cause.

The cause was that each arm seeded its own cache entry. A cache entry's download
throughput depends on where the service placed the stored blob, and that
placement is fixed for the life of the entry. In one job on one runner the
baseline blob was served at 59.4 and 60.3 MB/s while the candidate blob was
served at 131.8 and 105.1 MB/s, and the same ordering held on every runner.
Because that bias is constant across runners rather than random, pairing cannot
remove it, and additional samples only tighten the interval around the wrong
answer. The arm was confounded with the blob.

A single entry is now seeded, from the candidate so the wrapper cache is
populated, and both arms restore it. The blob is therefore held constant across
the comparison by construction rather than assumed not to matter.

The README documents the confound and states that an A/A run is the check to
perform after changing the harness.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
With one shared cache entry, running the whole matrix at once puts every runner
on the same blob at the same moment. At 20 concurrent runners the mean restore
rose from about 3s to about 8s and the noise floor from 0.34s to 1.89s, which is
enough to hide the effect under test. That is contention no real workflow would
experience, and it costs sensitivity for nothing.

The matrix now runs in waves of four, so each measurement reflects an ordinary
restore.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
These two workflows still measured the way the focused benchmark did before it
was rebuilt: durations read from the Actions API's one-second clock, arms spread
across independent jobs, no intervals, and one seeded cache entry per arm. Their
sub-second results were therefore not evidence of anything, and the JDK cache
workflow carried the same arm-versus-blob confound that an A/A run exposed in
the focused benchmark.

scripts/paired.mjs now holds the pairing logic that all three share: reading
per-runner timing CSVs, grouping slots by arm, discarding runners that did not
complete every slot, deriving the noise floor from within-arm repeats, and
producing an interval, a permutation p-value and a verdict. The focused report is
rebuilt on it unchanged in behaviour.

JDK cache becomes a two-arm ABBA comparison. One seed job populates a single
Maven entry and a single JDK entry, and both arms restore the same Maven entry,
so only the JDK entry differs between them. cache-jdk false and true are then
measured in one job per runner, with the tool cache and the local repository
cleared before every slot. The action ref is now an input so a branch can be
measured.

The version sweep generalises ABBA to seven arms: every runner sets up v1 through
main and then main back through v1, which places each version's two slots
symmetrically about the middle of the job so drift cancels. Every version that
supports cache-dependency-path restores one shared entry. v3 predates that input
and keys on pom.xml, so it necessarily has its own entry and the report says to
treat its difference with more caution. Versions are compared against main with
a paired interval per runner, and main against itself supplies the A/A control.

cache-read-only is not used in the sweep. It exists only on main, so applying it
to some arms and not others would make post-job behaviour asymmetric; every slot
restores on an exact primary-key hit, which actions/cache already skips saving.

Both matrices run in waves of four, because every runner pulls the same seeded
blob and running them all at once measures contention on the cache service that
no real workflow would see.

Tests cover the two properties the design depends on: that differencing within a
runner removes between-runner speed differences, and that the mirrored order
cancels drift that is linear across the job.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
The version sweep and the JDK cache benchmark both checked PetClinic out over
the workspace root. The JDK cache workflow then had no copy of the benchmark
scripts to invoke, and the version sweep, which checked them out to .benchmark,
put them inside PetClinic's basedir, where its nohttp check failed the seed
build on http:// URLs in a toolchains template.

Check the benchmark repository out at the root and PetClinic below it instead.
The scripts are then addressable as scripts/, PetClinic's build only sees its
own tree, and the cache-identity markers move with it.

Also stop jdk-cache.sh from truncating the wrapper properties; the seed job
needs the real contents to build, and an idempotent append keeps the hashed
file identical between seeding and measuring.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
A measurement job is a couple of minutes of work, but a slot can stall
indefinitely on a cache download or a JDK fetch. One such runner in the version
sweep sat on a step that takes two seconds elsewhere, and with the default
six-hour job timeout it would have blocked the report for the rest of the day.

Bound the measurement jobs at 25 minutes. A stalled runner now fails quickly and
drops out of the report, which already discards runners that did not complete
every slot, and the remaining runners still produce a verdict.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
The JDK cache benchmark's A/A control failed: comparing the no-cache arm's own
first and last slot reported an improvement of 0.4s, 95% CI -0.7 to -0.1, on
slots that ran identical configuration. The headline verdict could not be
trusted while that was true.

The cause is that the first setup in a job pays costs the later ones do not:
DNS resolution, TLS handshakes to the cache service and to the JDK host, and a
cold page cache. That is a one-off spike at slot 1 rather than drift across the
job, so the mirrored slot order cannot cancel it, and it biases the arm that
happens to hold the first slot.

Run one unmeasured warm-up slot in every measurement job so the measured slots
all start from the same warmed state. Applied to all three workflows rather than
only the one where it was caught, since they share the structure that exposes it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
The sweep reported v2.5.1 as a 1.9s improvement over main. That number is real
but it means v2 does no caching and so never restores the Maven repository the
other versions spend most of their time on. Reported as an improvement it invites
exactly the wrong conclusion.

Report v1 and v2 as not comparable and say why. They are still measured, because
seeing what the caching versions spend their time on is the point of including
them.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
@brunoborges brunoborges changed the title Make the focused benchmark able to resolve real effects Make the benchmarks able to resolve real effects Aug 5, 2026
brunoborges and others added 7 commits August 5, 2026 01:02
An A/A run with both refs set to main reported a 0.821s regression, 95% CI
[0.119, 1.672], on identical code. Seven of the ten runners agreed to within
0.12s. Two had a single slot that stalled on the cache service — 8.169s against
a 3.286s sibling, and 10.030s against 4.449s — and both stalls happened to land
on the candidate arm, which is enough to move the mean of ten paired differences
by more than any effect these workflows measure.

Two changes, and the same data now reports -0.020s, 95% CI [-0.069, 0.030],
p = 0.498, inconclusive.

Runners whose own arm disagrees with itself by more than a robust threshold are
discarded before the estimate is formed. The threshold is the median within-arm
spread plus six times its median absolute deviation, so it adapts to the run
rather than being a fixed number of seconds, and at most half the runners can be
dropped. The decision uses only within-arm spread, which has the same
distribution whether or not the arms differ; filtering on the arm difference
itself would bias the result, but this cannot. Every report lists what it
discarded and why.

A verdict now also requires the permutation test to agree with the interval. The
percentile bootstrap is only approximate at these sample sizes while the
sign-flip test is exact under the null, so where they disagree the interval is
the one that is wrong. In the run above the interval excluded zero while the test
reported p = 0.099, and the verdict believed the interval.

The statistic stays the mean rather than becoming a median. A sign-flip test on
the median has almost no power here: six runners agreeing on a 12.5s effect gives
p = 0.13. Robustness comes from the filter, not from the estimator.

The failing A/A dataset is pinned as a regression test.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
A slot in the version sweep sat on a cache restore for 214 seconds while its
neighbours in the same job took three. It is the second time a run has been held
open this way, and the version is incidental: the sweep restores the same 154 MiB
blob fifteen times per job across ten jobs, and the cache service occasionally
stalls one of them.

Cap each measured setup at three minutes. A stalled slot now fails its runner,
which the report discards along with the other incomplete ones, rather than
running out the job timeout. Narrow the sweep's waves to two, since it restores
three times as often per job as the two-arm workflows.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
A slot in the version sweep sat for 214 seconds on a setup that took 0 seconds
in the same job's mirrored slot, on the same runner and the same action version.
The action had already finished: it logged "maven cache is not found" and then
produced no further output until the job was cancelled.

The cause is in @actions/cache. Its promiseWithTimeout clears the timeout inside
a .then, which only runs when the raced promise fulfils. When the cache service
drops the blob mid-download, downloadToBuffer rejects, clearTimeout is skipped,
and the armed timer keeps the event loop alive for the remainder of the segment
timeout after all work is done. That default is 10 minutes.

Cap it at 2 minutes here. These restores move 154 MiB in about 3 seconds, so the
cap is far beyond any legitimate download and cannot abort real work; it only
bounds the idle. The stall filter already keeps such a runner out of the
estimate, so this is about not paying for the wait.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
The stall filter was only applied to the two-arm workflows, so the sweep still
formed its estimates from runners that had stalled on a restore. It showed:
its A/A control, comparing main with itself, reported 0.6s against a noise floor
of 0.157s.

Generalise the filter across arms. A runner whose two slots for any one version
disagree by more than the robust threshold is dropped, on the same reasoning as
before: which version a stall lands on is arbitrary, and the decision uses only
within-version spread, so it cannot bias the differences between versions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
The Maven configuration warm path was the last workflow still measuring the old
way. It ran the arms ABAB, which does not cancel drift across the job: with a
steady slowdown of d per slot the candidate is biased by +d, because its slots
sit later on average than the baseline's. It had no warm-up slot, so the first
setup's DNS, TLS and cold page cache costs landed entirely on the baseline. And
it summarised each configuration on its own, where one runner yields one paired
difference and no interval is possible.

Run the arms ABBA after a discarded warm-up slot, matching the other workflows,
and add a report job that pools across the matrix. Each of the 36 configurations
is a block: the arms are compared within it, on one runner, and the differences
are combined. That answers what this workflow actually asks — whether the
candidate differs from the baseline across configurations — and costs no extra
jobs. Breakdowns by operating system and cache profile get their own intervals,
and per-configuration numbers are published as single observations with no
verdict attached, because that is all they are.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
Two ways the reports could publish something misleading rather than fail.

formatInterval hard-coded "95% CI" while the interval it was given carries its
own confidence level, so a caller that widened or narrowed an interval would
have had it labelled 95% regardless. Read the level off the interval.

The reports also checked only some of the environment variables they go on to
render. A missing SETUP_JAVA_REPOSITORY or JAVA_VERSION reached the summary as
"undefined" instead of stopping the run. Check every variable a report renders,
in one shared helper, and name the ones that are missing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
The version sweep used to place all seven versions in one table and compare each
against main. That is not a fair ranking: v1 and v2 are uncached, v3 uses an
older cache-key contract, and v5.6/main restore the Maven Wrapper in addition
to dependencies.

Keep the same-runner mirrored measurements, but report three cohorts: uncached
(v1/v2), dependency cache (v3/v4/v5.2), and dependency plus wrapper cache
(v5.6/main). Each cohort has its own reference and A/A control. Apply Holm's
step-down correction within a cohort so multiple comparisons do not turn an
uncertain p-value into a headline verdict. Retain the all-arm diagnostics for
runner quality and workload shape, explicitly not as a ranking.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
@brunoborges

Copy link
Copy Markdown
Collaborator Author

Updated the version sweep based on the review: it now reports three behaviorally comparable cohorts (uncached v1/v2; dependency cache v3/v4/v5.2; dependency + wrapper cache v5.6/main), with a cohort-specific reference, A/A control, interval, and Holm-adjusted p-values. The all-arm analysis remains only as a runner/workload diagnostic and is explicitly not a ranking. README and regression tests were updated; 35 tests pass.

brunoborges and others added 3 commits August 5, 2026 02:09
The previous commit split the sweep into three cohorts, each with its own
reference. That was wrong in two ways.

It deleted the comparison the sweep exists to make. v4 and v5.2 are what users
are actually running, and after the split neither could be compared with main
at all: they sat in a cohort referenced against v5.2, while main sat in another.

It also gave a verdict to a comparison the harness cannot make. v3 keys on
pom.xml, so it restores its own cache entry, and a blob's throughput is fixed
for the life of that entry. The README already says pairing cannot remove that
confound. In run 30979614347 v3 took 3.69s and 3.89s on two runners that ran v4
in 0.50s and 0.52s moments later in the same job, and the cohort report
published that sevenfold gap as a `regression` at p=0.046.

Rank against main again, but rank only v4, v5.2 and v5.6 - the versions that
restore the same seeded entry, so that a difference between them is a difference
in the implementation. Keep Holm's correction, which was the sound half of the
previous commit, and apply it across that family. Measure v1, v2 and v3 as
before and publish their durations, each with the reason it carries no verdict,
because for them a difference in duration is a difference in the workload.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
main is the newest code, so calling v5.6.0 a `regression` for being slower than
it states the finding backwards. Nothing regressed in v5.6.0; it shipped, and
then main got faster. The table was reading the difference in the direction that
made the older code look at fault for the improvement that came after it.

The two-arm workflows already have this right: they difference candidate minus
baseline, so the verdict describes the code under test. The sweep is the same
question with more baselines, so difference main minus each version and let the
verdict describe main. v5.6.0 now reads as a 1.303s `improvement` in main rather
than a 1.303s `regression` in v5.6.0, which is the same measurement said the
right way round.

Pin the direction with a test, because the sign is easy to flip back and the
resulting table is wrong in a way that still looks plausible.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
The Maven configuration workflow was called a warm path and never had a seed
job, so its synthetic pom hashed to a key nothing had stored and every "warm"
restore was a miss. Its three cache profiles were three variations on a failed
lookup. The focused workflow, meanwhile, compared the same two refs on the same
warm restore as the version sweep.

Between them the benchmarks measured one quantity, repeatedly, and it is mostly
not a quantity setup-java controls: the transfer is handed to @actions/cache, so
a large fixture times the network. Identical code varied ninefold across runners
while the effect under test was a third of that spread.

Reorganise them by question. Cache value builds PetClinic for real, cached
against uncached, to establish the scale everything else is a fraction of. Cache
key stability asserts the key rather than timing it, because a key that moves
when it should not costs a whole miss. Action overhead keeps the configuration
matrix but seeds a megabyte-sized entry so restores genuinely hit and the
action's own work is what remains; its four cache profiles are now nested levels
that difference into a decomposition. Transfer overlap is the same measurement
at 160 MiB, where concurrency has something to hide. Cache save measures what
the first run pays, which nothing measured before.

Every action-overhead slot records cache-hit, and the report refuses to be read
normally when a profile did not do the work its name claims.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 71c45320-1417-4029-8402-69075d61dac1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants