perf(bench-ratchet): scope the -count reducer — min for the informational A/B, mean for the ratchet - #22
perf(bench-ratchet): scope the -count reducer — min for the informational A/B, mean for the ratchet#22mparrett wants to merge 5 commits into
Conversation
Ports the let-go median-of-N gate to paserati. Single-shot perf-pr A/B is ungateable here too (nooga#21): the register-only anchor misses memory-bandwidth contention, so memory-bound families (GetOwn deep-object, PrototypeMethodAccess chain) blow out 14-27% on no-op PRs while the anchor stays flat. Median-of-N across interleaved base/head cycles suppresses it. Composes on top of the min-of-count reducer (nooga#22): min within a capture, median across cycles. perf-pr.yml + driver only, no cmd/bench-ratchet change.
Ports the let-go median-of-N gate to paserati. Single-shot perf-pr A/B is ungateable here too (nooga#21): the register-only anchor misses memory-bandwidth contention, so memory-bound families (GetOwn deep-object, PrototypeMethodAccess chain) blow out 14-27% on no-op PRs while the anchor stays flat. Median-of-N across interleaved base/head cycles suppresses it. Composes on top of the min-of-count reducer (nooga#22): min within a capture, median across cycles. perf-pr.yml + driver only, no cmd/bench-ratchet change. Cycles are counterbalanced (ABBA): odd cycles bench base-first, even head-first, so first-vs-second-position drift cannot masquerade as a head regression the median can't remove. N=7 (not 5): a 4-sample no-op validation at N=5/budget 10% held 0 false positives but only marginally — the worst family median reached 7.63% (GetOwn/n=16/round-robin), a 2.37% margin, with several memory-bound families each drawing a bad run at random. Bumping to 7 tightens the median tail rather than widening the budget (which would blind the gate to real ~10% regressions). Matches the let-go settle point (N=7/8%).
…0 FP) Real-data evidence for the gate (parallels the min-reducer's verdict test on nooga#22). Feeds captured no-op A/B samples through the gate's real decision (_summarize, extracted here from main so it's callable) and asserts: single-shot (one cycle) trips the budget on 3-4 families per run (up to +30%), median-of-N gates 0. Fixtures testdata/perf_pr_{n5,n7}_run.json are same-code fork runs, so every gate is a false positive by construction. Deterministic; run with python3 scripts/test_ab_repeat.py or pytest.
|
Reviewed. Conditionally defensible as a low-sample, best-case heuristic: but not with the PR’s current statistical claims. I’d request two changes before merging.
For the current informational, same-runner A/B report, My recommended path:
Implementation tests pass locally ( |
|
LLVM guidance may be helpful here: It mostly argues for improving experiment design, not choosing
The most important issue is fixed ordering in LLVM explicitly warns that low noise is insufficient because measurement bias can remain, and recommends repeated measurements plus system control. LLVM benchmarking guidance. Two additional conclusions for our setup:
So I’d prioritize:
The LLVM document does not really support “minimum estimates true cost.” Its recommendation to run repeatedly “to recognize noise” supports retaining and analyzing the sample distribution rather than reducing it immediately in main.go:641 |
… lower-envelope heuristic Addresses review feedback on nooga#22 — interpretation and data compatibility, not code correctness (implementation tests + CI were green). Provenance / compatibility: - perfdata.Baseline gains Reducer + SampleCount; buildCurrentBaseline stamps them ("min", N observed samples). A minimum is sample-count dependent, so this is the metadata needed to keep min-era and mean-era snapshots from being silently compared. - schemaVersion 1 -> 2: mean-era (v1, no provenance) baselines are now rejected on read. `update` re-seeds on the mismatch and prints an explicit "intentional timeline discontinuity" note rather than ratcheting a min-era bar onto a mean-era one. - compareAndReport warns on a same-version reducer/count mismatch (forward guard for the median-of-N reducer). Reframe (min selects a lower envelope, it does not estimate "true cost"): - aggregateFromFile doc reworded: pragmatic lower-envelope heuristic under an upward-contamination assumption, informational-only, never a gate; notes favorable samples exist and that a defensible gate is the median-of-N repeat A/B, not this reducer. - Rename TestMinVsMeanFlipsRegressionVerdict -> TestReducerChoiceChangesRegressionVerdict. Its 15 real samples are a regime transition (median 12.37), so it demonstrates verdict SUPPRESSION under upward contamination, not proof-of-phantom. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Both comments make sense- the fixes are in ( Provenance / compatibility (your P1). Done. Reframe (your P2). Agreed the "true cost / phantom" framing overclaimed. The sample set is a regime transition (median 12.37, well above the min), so the minimum is a lower envelope, not an estimate of truth. Reworded the On ordering, and the defensible gate (second comment). You're right that fixed base-then-head ordering is the deeper issue and that min-of-N must not gate. The gate you describe — repeated, interleaved A/B with a distribution comparison — is already built: an N=7 interleaved A/B with ABBA counterbalance (odd cycles base-first, even head-first), 10% budget, 0 false positives across 10 no-op runs. It composes with this reducer as min within a capture / median across cycles, and it'll come as a PR stacked on this one. Your regime-transition observation is a clean argument for exactly that interleaving — I've folded it in. So this PR stays the minimal informational reducer + provenance; the interleaved gate is the follow-up. |
N=7 tolerates floor(6/2)=3 contaminated cycles; observed CI contamination reached 2/5 and N=7 held 0 FP/10. N=9 tolerates 4 for extra margin, nearer benchstat's >=10-sample guidance without going even — an even-N median averages the two middle cycles, reintroducing the tail sensitivity we want to escape. Cost: 18 vs 14 suite runs per gated PR; the gate is opt-in via the perf-repeat label. The principled alternative (retain >=10 raw samples + a benchstat distribution comparison, per the nooga#22 review) is a larger follow-up, backlogged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nooga
left a comment
There was a problem hiding this comment.
Overview
Good, well-documented change with real test coverage and unusually honest comments about the reducer's limits. The core motivation — min is less contaminated by one-directional noise than mean — is standard benchmarking wisdom, and the included verdict-flip test is a genuinely convincing demonstration for the informational perf-pr comparison.
Concerns
-
Compounding with
ratchetMerge:ratchetMerge, used byupdate/checkagainst the persistentdocs/perf/baseline.json, already doesminF(cur, base)across commits forever — the bar never loosens without-force. This PR makes the input to that process itself a min-of-N. Two stacked minimums mean one lucky low sample, at any point in history, permanently sets a floor that legitimate future runs may never clear. The PR description scopes this as "informational-only," butaggregateFromFileis unconditional shared code feeding both paths. Please either scope the reducer choice to the informational path only, or add a test/analysis showing the long-term ratchet path is still sound. -
Evidence uses N=15, production uses N=3:
perf-pr.yml/perf-timeline.ymlboth run-count 3. The controlled proof test uses 15 samples. Worth adding a variant at N=3 (the actual shipped configuration) to confirm min-of-3 is stable and not just a different flavor of outlier-chasing on these sub-20ns benchmarks. -
docs/perf/baseline.jsonnot migrated: it's stillversion: 1. Post-merge,bench-ratchet checkagainst the default path will hard-fail until someone runsupdateonce. Please regenerate it as part of this PR (or explicitly call out the required manual step so it isn't forgotten).
Nits
- gofmt/go vet clean, build passes, new tests pass as-is.
- No test covers
ratchetMergeinteracting with min-reduced input — worth one given concern #1.
|
Addressed in 1. Reducer compounding (P1). Added a 2. N=3 evidence. Added 3. baseline.json migration. The committed build/vet/gofmt clean; bench-ratchet tests pass; actionlint clean. |
|
Heads-up: this composes with #24 and there's one small merge conflict. Both this PR and #24 edit NSPerOp: reduce(a.nsMin, a.nsSum, a.count),
BytesPerOp: int64(reduce(a.bytesMin, a.bytesSum, a.count)),
AllocsPerOp: int64(reduce(a.allocMin, a.allocSum, a.count)),
SetHash: a.setHash,The accumulator auto-merges (each PR adds distinct fields); only this block collides. Either order is fine — whichever lands second applies the above. |
nooga
left a comment
There was a problem hiding this comment.
Thanks Matt — the reducer scoping (mean for the ratchet, min for informational A/B) and provenance stamping look correct and well-evidenced. Approving, but this now conflicts with main since #24 (fingerprint) landed first and touches the same aggregateFromFile — as you'd already flagged. Could you rebase onto main and I'll merge right after? No changes needed beyond the rebase.
aggregateFromFile averaged the N -count repetitions of each benchmark. Mean is the wrong reducer for benchmark noise, which is one-directional: interference (GC pauses, CPU migration, a co-scheduled runner tenant, thermal throttling) only ever makes a run slower, never faster. A single slow sample drags the mean up and manufactures a phantom regression — exactly what the small (<20ns) BenchmarkGetOwn variants did on shared CI runners, flip-flopping 6/2/4 "regressions" across three otherwise-identical perf-pr runs on code paths the PRs never touched. Reduce by minimum instead: the fastest of N repetitions is the least contaminated estimate of true cost. All raw samples are still retained in Samples for provenance. alloc/bytes are deterministic per op (min == mean). Local stability check, BenchmarkGetOwn/n=16/last, count=6 x3 captures: mean-of-6 spread across captures: 3.1% min-of-6 spread across captures: 0.8% (~4x tighter; larger on noisier CI) Adds cmd/bench-ratchet/main_test.go (first test for the package) pinning the min reduction. Note: perf-pr's base-vs-head A/B benefits immediately and safely (both halves measured with the same reducer in one run). perf-timeline snapshots on the perf-data branch were captured with mean, so the first snapshot after this lands steps down once (min < mean) — a one-commit discontinuity on the historical dashboard, not a regression. Do NOT put the `perf` label on this PR: it would compare base's mean-reducer against head's min-reducer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n real samples TestMinVsMeanFlipsRegressionVerdict feeds 15 real captured BenchmarkGetOwn/n=16/last samples (memory-bound; a clean cluster plus a contention-slowed upward tail) and the anchor through the actual aggregate + compareAndReport path at perf-pr's 10% budget: min-reduced ratio 9.536/1.172 = 8.14 -> 0 regressions (true cost) mean-reduced ratio 11.759/1.243 = 9.46 -> 1 regression (+16% phantom) Same samples, opposite verdict — the phantom the min reducer removes, shown deterministically and runnable with `go test`. Complements the existing reduction unit test with the verdict-level effect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… lower-envelope heuristic Addresses review feedback on nooga#22 — interpretation and data compatibility, not code correctness (implementation tests + CI were green). Provenance / compatibility: - perfdata.Baseline gains Reducer + SampleCount; buildCurrentBaseline stamps them ("min", N observed samples). A minimum is sample-count dependent, so this is the metadata needed to keep min-era and mean-era snapshots from being silently compared. - schemaVersion 1 -> 2: mean-era (v1, no provenance) baselines are now rejected on read. `update` re-seeds on the mismatch and prints an explicit "intentional timeline discontinuity" note rather than ratcheting a min-era bar onto a mean-era one. - compareAndReport warns on a same-version reducer/count mismatch (forward guard for the median-of-N reducer). Reframe (min selects a lower envelope, it does not estimate "true cost"): - aggregateFromFile doc reworded: pragmatic lower-envelope heuristic under an upward-contamination assumption, informational-only, never a gate; notes favorable samples exist and that a defensible gate is the median-of-N repeat A/B, not this reducer. - Rename TestMinVsMeanFlipsRegressionVerdict -> TestReducerChoiceChangesRegressionVerdict. Its 15 real samples are a regime transition (median 12.37), so it demonstrates verdict SUPPRESSION under upward contamination, not proof-of-phantom. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…chet Addresses the CHANGES_REQUESTED review's three concerns: 1. Reducer compounding (P1). aggregateFromFile was unconditionally min, but it feeds both the throwaway per-run A/B *and* the persistent ratchet — and ratchetMerge already takes min over all history, so min-of-N input compounds into a floor a single lucky sample can set unclearably low. Add a -reducer flag: "mean" is the default (safe for the durable baseline), "min" is opt-in for the informational path. perf-pr.yml passes -reducer min on both A/B halves (base update + head check, against a RUNNER_TEMP baseline — no persistence). The reducer is recorded on the Baseline, so a min-era and mean-era point are never silently diffed. 2. N=3 evidence. The verdict-flip proof uses N=15; production ships -count 3. Add TestMinReducerRobustAtProductionCount3: at the shipped count, min-of-3 is invariant to a single upward-contaminated sample (stays pinned to the clean floor) while mean-of-3 moves — min is stable, not outlier-chasing. 3. baseline.json migration. The committed docs/perf/baseline.json was version 1, so a post-merge `check` would hard-fail on the version gate. It was captured at -count 1 (one sample per benchmark), where min == mean, so this is a faithful metadata-only stamp — version 2, reducer "mean", sample_count 1 — with the numbers untouched (no re-benchmark on a foreign CPU). go build/vet/gofmt clean; bench-ratchet tests pass; actionlint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rge-bases
The A/B builds bench-ratchet from the merge-base commit, which can predate the
-reducer flag; passing it unconditionally crashes that half ("flag provided but
not defined: -reducer") for any PR branched before this change. Detect support on
the base binary and reuse the decision for the head half via GITHUB_ENV, so both
halves run the SAME reducer: min when supported, and a graceful fall-back to the
default (mean) when the base is too old — like-for-like either way, since this is
the informational, non-gating report. Caught by a fork self-A/B dispatch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
d81f41f to
4fdad50
Compare
|
@nooga Thanks for taking the time to look at this! Rebased as requested (2 conflicts resolved). Looks green, so feel free to merge unless there are remaining concerns. |
|
Summary of what changed:
|
The fork was 27 behind / 9 ahead. Two of the 9 (perf-page family tabs, modal-CPU filter) had already landed upstream under different SHAs, so the real fork-only delta was smaller than it looked. Merge rather than rebase or reset: fork main hosts the live perf timeline/backfill workflows and the Pages dashboard, which only run from the default branch, so its history must not be rewritten. Conflict resolutions: - docs/perf/index.html -> upstream wholesale. The fork's only changes to it were the two commits already upstreamed, and upstream carries three further perf-page fixes (set-composition flagging, Overall excluding sparse Test262, ratios rebased off the modal snapshot). Upstream's copy is a strict superset; the file now matches upstream exactly. - .github/workflows/perf-timeline.yml -> kept the fork's side. The fork runs the Test262 macro unconditionally for a continuous dashboard series where upstream gates it behind [perf-test262], and the fork's macro body is ahead: it repeats -count N and keeps the min total (nooga#28) on top of upstream's mean-per-test + set_hash. Net change to this file is comments only, so CI behavior is untouched. Upstream's now-unread `test262` dispatch input was dropped rather than left as a no-op checkbox. - cmd/bench-ratchet/main.go, main_test.go -> the reducer form from the fork plus upstream's SetHash, the same resolution used when rebasing PR nooga#22. Divergence from upstream/main drops from 44 files to 4, all of it the fork-only perf infra (backfill workflow, the timeline's unconditional min-of-N macro, and the bench-ratchet min reducer that is PR nooga#22). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…quietly Three things the handoff review flagged as still open. Reducer provenance (blocker #3). perf-timeline.yml wrote method.reducer:"min" as a literal. It was true — bench-ratchet reduces -count repetitions by an unconditional min — but only accidentally, and only for now: nooga#22 makes mean the default with min opt-in, and on the day that lands the workflow would have gone on stamping "min" onto snapshots reduced by mean. Provenance that can disagree with what it describes is worse than none, because it is trusted. So bench-ratchet writes it, from the code that does the reducing (reducerName, declared beside aggregateFromFile) and the flags it was actually handed. The workflow now asserts only that the field is PRESENT, and fails the snapshot if it isn't — an unlabelled point is indistinguishable from one measured any other way. perfdata.Baseline gains Method{reducer,count,benchtime}, and TestAggregateFromFileUsesMin additionally checks that the recorded name matches the reduction it just verified, so the two cannot drift apart silently. This does not implement nooga#22, which is upstream and unmerged. It removes the reason nooga#22 landing would corrupt this fork's history. Reachability (blocker #2). fetch-depth:0 fetches everything reachable from a ref, which is not everything: orphan fork-CI commits are reachable from nothing on the remote, and 14 of the 47 existing snapshots sit on such commits. The timeline died at `git checkout` with "unable to read tree <sha>" a third of the way into a 30-minute job. Both writers now try an explicit fetch first and then fail with the cause and the backfill/* ref command, before doing any work. Writer queue (blocker #6). The shared concurrency group protects the active writer but does not queue the rest — GitHub keeps one pending run per group, so a batch dispatch lands one snapshot and evicts the others, which then report as cancelled somewhere nobody is looking. A workflow cannot prevent that: by the time it executes, any sibling it would displace already has been. Prevention is the dispatcher's job. What it can do is make the loss visible in the log of the run that survived, naming the targets that need re-dispatching, which is what scripts/perf-writer-queue-check.sh does. It never fails the job — this run's snapshot is legitimate, and discarding it to protest someone else's batch would turn one lost snapshot into two. Both writers call it before the target checkout, while the tree is still the workflow's own commit: a backfill target predates the script. Verified: snapshot output carries {"reducer":"min","count":2,"benchtime":"10ms"}; queue check reports clear against the live repo and renders the alert table correctly on a synthetic multi-run payload. actionlint clean apart from the pre-existing release.yml warning; go build/test green.
The backfill driver has been broken since the v2 cutover. It built the exact v1 snapshot name, "<stamp>-<sha>.json", and asked perf-data for it. Every file now carries a machine slug, so the lookup matched nothing, every dispatch looked like it had produced no snapshot, and each target burned all MAX_TRIES before being reported as failed — while the snapshots it was dispatching landed fine. Match on the stamp+sha prefix instead, and read cpu_model from either layout. Under v2 a retry no longer overwrites the previous attempt: each tier keeps its own file. So the question is whether ANY file for the commit is on the reference tier, not what the single file says. Report every tier seen. Add assert_sole_writer. Serializing our own dispatches was never sufficient — a dispatch from a second driver, or a hand-run of either writer workflow, evicts whatever this one has pending, and stopping the other driver afterwards does not bring the evicted run back. That cost a snapshot once already. The group permits exactly one pending run, so the only safe number of concurrent drivers is one; refuse to start when another writer is queued or in progress, FORCE=1 to override. The local sweep was rebuilding `method` wholesale, including its own reducer:"min" literal. bench-ratchet writes that object itself now, so merge into it and contribute only `host` — the one fact bench-ratchet cannot know. Rebuilding it would have dropped the real provenance and reinstated a literal that goes stale the moment nooga#22 lands. shellcheck clean; prefix matching and cpu extraction verified against the live 47-snapshot corpus.
The Test262 macro ran three times and kept the fastest, on the reasoning that suite noise is one-directional-slower so the fastest rep is the least contaminated (nooga#22, nooga#28). That reasoning describes real noise and misses the noise that actually bit us. 976a3fa and c989b25 sat 25% fast of their neighbours for weeks and read as a speedup; re-measured, both landed inside the band, and both carried the same set_hash as the entire modern series, so composition was never the cause. The variance was in timing, and min-of-3 selects the luckiest of three timings by construction. Min preserves a fast-side artifact; median rejects it. A commit that changed no engine code cannot get faster, so the reducer has to be able to say so. The other two reps were thrown away, which left test262.total with samples: 1 on every snapshot in the corpus — no point on the macro series had a spread, and the run-to-run scatter cited in nooga#28 had to be estimated from outside the corpus. They are all kept now, which is also what makes the median computable and what a later accumulation across runs extends. The reps must now agree on set_hash or nothing is written. The old loop asserted in a comment that the passing count and set_hash were "run-invariant" and checked nothing; reducing across reps that timed different sets mixes speed with membership. Empirically they do agree — 38 reference-tier points share one set_hash — so this should approximately never fire, and a missing macro point is visible in perf-gaps.sh section [2] where a mis-composed one is not. The loop lived in both the forward timeline and the backfill as a copy, and the copies had begun to differ, so it moves to one script both call. It is post-processing, so like bench-test262 it comes from the workflow's own commit rather than the tree being measured — staged before the target checkout in the timeline, run from the tool worktree in the backfill. The entry records its own reducer. The snapshot-level method describes what bench-ratchet did to the micro benchmarks, which is not the answer for this series, and the macro reducer just changed partway through the corpus — exactly the invisible protocol shift Method exists to prevent. Verified end to end locally on built-ins/Math: three reps, stable set_hash, median selected (2.42M ns/test between 2.35M and 2.51M — min would have taken a point 2.8% fast), folded into a v1 snapshot, converted by perf-migrate with method and all three samples intact, and passed perf-fixratio -verify on both the same-run and the foreign-anchor (MACRO_RECORD_ANCHOR=1) paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…median Ports reduceSamples from nooga/let-go#561, where a cold first rep in a bimodal anchor distribution turned real improvements into reported regressions of +38%/+31%/+17%. It discards the first repetition and takes the median of the rest, except at exactly three reps where all three are kept — discarding from three leaves two, and the median of two is their mean, which restores the single-spike sensitivity the median was chosen to remove. The default does NOT move, and that is the substance of this commit rather than a hedge. paserati's distribution is a tight core with a strictly one-sided slow tail: ~2 launches in 20 land 15-29% high and none land low. With no fast outliers to defend against, min is the closest estimator of the core. The hypothesis that -count process sharing drives our instability — the effect warmup-discarding mitigates — was refuted by experiment E7, and -count 3 measured more stable than the alternatives. Switching would also break comparability with the whole corpus and preempt nooga#22, which is open to scope exactly this. So it lands as -reducer {min,warmup-median}, min by default. The reducer name is still written into method.reducer by the aggregation itself rather than asserted by a caller, so a snapshot's recorded protocol cannot disagree with its applied one; the old const is gone rather than left to drift beside a now-variable choice. Aggregation now keeps per-rep values in capture order instead of a streaming min, because warmup-discarding is positional. The min path is unchanged -- TestAggregateFromFileUsesMin still passes untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both lanes ran bench-ratchet with no -reducer, -min-iterations or -iteration-tolerance, taking whatever the tool defaulted to. Those defaults are not fixed. nooga#22 makes `mean` the default upstream with `min` opt-in, so on the day that reaches this tree every CI snapshot changes reducer with no diff here to explain it, and every comparison against an existing snapshot silently crosses reducers. That is the same failure 07764f8 removed a hardcoded reducer:"min" to avoid, pointed the other way: there the provenance could outlive the behaviour, here the behaviour can move out from under the provenance. The b.N checks are pinned for the same reason. A warning that depends on a default is a warning that can vanish without anyone deciding it should. Values match today's defaults, so this changes nothing about what CI measures right now — that is the point. YAML and embedded shell both validated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What this does
aggregateFromFilecollapses the N-countrepetitions of each benchmark into one number. That reducer is now caller-selected via a-reducerflag:mean(default) — for anything feeding the persistentdocs/perf/baseline.jsonratchet.ratchetMergealready takes the min over all history, so feeding it a min-of-N would stack two minimums: one lucky-fast sample, at any point in history, permanently sets a floor legitimate future runs may never clear. Mean keeps the durable bar honest.min(opt-in) — for the throwaway per-run informational A/B (perf-pr.yml). Benchmark noise there is one-directional (GC pauses, CPU migration, a co-scheduled tenant, thermal throttling only ever make a run slower), so the fastest of N is the least-contaminated estimate. No persistence, so nothing compounds.The reducer and sample count are recorded on the baseline (schema v2), so a min-era and a mean-era snapshot are never silently diffed — a provenance mismatch re-seeds with an explicit "intentional discontinuity" note instead of ratcheting a min bar onto a mean one.
perf-pr.ymlpasses-reducer minon both A/B halves. The base half buildsbench-ratchetfrom the merge-base commit, which can predate the flag, so it feature-detects-reducerand falls back to the default when it's unsupported — keeping both halves on the same reducer rather than crashing on an old base. (Validated on a fork self-A/B.)docs/perf/baseline.jsonwas captured at-count 1, wheremin == mean, so it's migrated to v2 by a metadata-only stamp — the numbers are untouched, no re-benchmark on a foreign CPU.Scope is
cmd/bench-ratchet+ theperf-pr.ymlinvocation; still informational-only, never a gate. The residual noise on larger memory-bound families (whole capture-windows slowed at once, wheremincan't help) is the separate repeat-the-A/B gate I'm coordinating on elsewhere.Field evidence — real
perf-prA/B, min vs meanOn the same-runner A/B this reducer feeds, the same VM-optimization stack produced opposite verdicts depending on which reducer was in play:
perf-prrunBenchmarkGetOwn/n=64/lastGetOwntouches nothing those PRs changed — the +20.1% was pure runner noise the mean amplified into a phantom regression; under min it collapses to −2.4%, and the whole family lands within ±5.4%. (Caveat: #12 and #15 are separate PRs on different runners, so the reducer isn't the only variable — read this as corroborating field data. The controlled proof is below.)Local stability,
GetOwn/n=16/last, count=6 across 3 captures: mean-of-6 spread 3.1% vs min-of-6 0.8% (~4× tighter; wider on noisier CI).Controlled proof (
go test, no CI needed)TestAggregateFromFileUsesMin— pins that-countrepetitions reduce by min under-reducer min, with raw samples retained.TestReducerChoiceChangesRegressionVerdict— feeds 15 real capturedGetOwn/n=16/lastsamples (a clean cluster + a contention-slowed upward tail) through the actualcompareAndReportat the 10% budget. Same samples, opposite verdict: min → 0 regressions (true cost), mean → 1 regression (+16% phantom).TestMinReducerRobustAtProductionCount3— at the shipped-count 3, min-of-3 is invariant to a single upward-contaminated sample (stays pinned to the clean floor) while mean-of-3 moves. Confirms min is stable, not outlier-chasing, at the production count.Responding to the review
ratchetMerge→ scoped:meanis the default (persistent ratchet),minis opt-in (informational A/B only).aggregateFromFileis no longer unconditional min.TestMinReducerRobustAtProductionCount3at the shipped count.baseline.jsonnot migrated → stamped to v2 (reducer/sample_count); faithful metadata-only migration since it was captured at-count 1.go build/vet/gofmtclean;actionlintclean;cmd/bench-ratchettests pass. Composes with the other open perf PRs — the only conflict is a documented 3-line one with #24 inaggregateFromFile(see comment below).