Skip to content

perf(bench-ratchet): scope the -count reducer — min for the informational A/B, mean for the ratchet - #22

Open
mparrett wants to merge 5 commits into
nooga:mainfrom
mparrett:perf/bench-ratchet-min
Open

perf(bench-ratchet): scope the -count reducer — min for the informational A/B, mean for the ratchet#22
mparrett wants to merge 5 commits into
nooga:mainfrom
mparrett:perf/bench-ratchet-min

Conversation

@mparrett

@mparrett mparrett commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Reworked after @nooga's review. The original version reduced -count repetitions by min everywhere; the compounding-with-the-ratchet catch was right, so this scopes the reducer — mean by default, min only where there's no persistence — and addresses all three review points (mapping at the bottom).

What this does

aggregateFromFile collapses the N -count repetitions of each benchmark into one number. That reducer is now caller-selected via a -reducer flag:

  • mean (default) — for anything feeding the persistent docs/perf/baseline.json ratchet. ratchetMerge already 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.yml passes -reducer min on both A/B halves. The base half builds bench-ratchet from the merge-base commit, which can predate the flag, so it feature-detects -reducer and 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.json was captured at -count 1, where min == 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 + the perf-pr.yml invocation; still informational-only, never a gate. The residual noise on larger memory-bound families (whole capture-windows slowed at once, where min can't help) is the separate repeat-the-A/B gate I'm coordinating on elsewhere.

Field evidence — real perf-pr A/B, min vs mean

On the same-runner A/B this reducer feeds, the same VM-optimization stack produced opposite verdicts depending on which reducer was in play:

perf-pr run reducer (both sides) BenchmarkGetOwn/n=64/last verdict
fork #12 mean +20.1% 1 regression > 10% budget
fork #15 min −2.4% 0 regressions > budget

GetOwn touches 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 -count repetitions reduce by min under -reducer min, with raw samples retained.
  • TestReducerChoiceChangesRegressionVerdict — feeds 15 real captured GetOwn/n=16/last samples (a clean cluster + a contention-slowed upward tail) through the actual compareAndReport at 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

  1. Reducer compounding with ratchetMerge → scoped: mean is the default (persistent ratchet), min is opt-in (informational A/B only). aggregateFromFile is no longer unconditional min.
  2. Evidence at N=3, not just N=15 → added TestMinReducerRobustAtProductionCount3 at the shipped count.
  3. baseline.json not migrated → stamped to v2 (reducer/sample_count); faithful metadata-only migration since it was captured at -count 1.

go build/vet/gofmt clean; actionlint clean; cmd/bench-ratchet tests pass. Composes with the other open perf PRs — the only conflict is a documented 3-line one with #24 in aggregateFromFile (see comment below).

mparrett added a commit to mparrett/paserati that referenced this pull request Jul 12, 2026
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.
mparrett added a commit to mparrett/paserati that referenced this pull request Jul 12, 2026
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%).
mparrett added a commit to mparrett/paserati that referenced this pull request Jul 12, 2026
…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.
@mparrett

mparrett commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

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.

  • [P1] The reducer and sample count are absent from the persisted schema. A minimum is count-dependent: increasing -count increases the opportunity to observe a lower value. Mean-era and min-era snapshots therefore aren’t directly comparable, yet both remain schema version 1. Record reducer and sample_count, reject incompatible baseline comparisons, and annotate or split the timeline at this transition. See main.go:641 and perfdata.go:5.

  • [P2] The “controlled proof” demonstrates verdict suppression, not that the suppressed result is phantom noise. Of its 15 GetOwn samples, 10 are in the slower ≥12 ns regime. The ratios are approximately: minimum 8.14, mean 9.46, median 10.32. Because the test defines the accepted baseline as the minimum, its conclusion is largely built into the setup. The premise that noise “only ever makes a run slower” is also too absolute; frequency scaling, cache state and ordinary measurement variation can produce favorable samples. Reframe this as selecting the lower performance envelope under assumed upward contamination, not estimating “true cost.”

For the current informational, same-runner A/B report, min-of-3 is an acceptable pragmatic heuristic if those limitations are explicit. It should not become a performance gate. The Go team’s benchmark tooling instead recommends at least 10 samples and reports medians, confidence intervals, and distribution comparisons. [Official benchstat documentation](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat).

My recommended path:

  1. Keep min temporarily for the informational PR report.
  2. Add reducer/count provenance and mark the timeline discontinuity.
  3. Soften the PR description and test names—“illustrates lower-envelope behavior,” not “proves phantom regression.”
  4. Make the planned repeated A/B—or benchstat with ≥10 samples—the eventual defensible gate.

Implementation tests pass locally (go test ./cmd/bench-ratchet), and the PR’s CI/lint checks are green. The concern is interpretation and data compatibility, not code correctness.

@mparrett

Copy link
Copy Markdown
Contributor Author

LLVM guidance may be helpful here: It mostly argues for improving experiment design, not choosing min.

LLVM guidance Paserati status Recommendation
Run repeatedly to recognize noise Partial: -count 3, but aggregation collapses samples to one minimum Preserve and compare distributions; ideally ≥10 samples with benchstat
Eliminate measurement bias Weak: PR workflow always runs all base measurements, then all head measurements Interleave runs—ABBA or randomized blocks—after prebuilding both test binaries
Disable frequency scaling, Turbo, competing work; isolate CPUs/SMT Not available on shared ubuntu-latest runners Keep results informational, or use a controlled self-hosted runner for gating
Avoid storage Microbenchmarks mostly comply; Test262 reads thousands of files and writes per-shard JSON Put Test262 corpus/output on tmpfs or prewarm it when measuring execution time
Use a high-resolution timer / perf Go’s benchmark timer plus large b.N is adequate for timing; hardware counters are absent Longer benchmark windows help tiny operations; perf stat would be useful diagnostically on a dedicated runner
Prefer static linking Largely satisfied by this pure-Go/no-CGO project No major action
Disable ASLR Potentially relevant but lower priority than CPU isolation and ordering Only worthwhile on a dedicated runner

The most important issue is fixed ordering in perf-pr.yml:91. The sample set used in PR #22 actually looks like a regime transition—five fast measurements followed by ten slow ones. If that transition happens between the base and head phases, neither mean nor minimum can tell environmental drift from a code change. Interleaving is the direct remedy.

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:

  • For same-runner PR comparisons, anchor normalization is probably unnecessary and may add noise because the arithmetic anchor and memory-sensitive benchmarks react differently to system conditions. Compare raw base/head distributions.
  • For the cross-runner timeline, keep the anchor but only compare matching CPU model, Go version, architecture, reducer and sample count. Anchor normalization cannot make heterogeneous runners genuinely equivalent.

So I’d prioritize:

  • Prebuild base and head benchmark binaries.
  • Run them in ABBA or randomized order.
  • Retain ≥10 samples and use median/confidence intervals through Go’s official benchstat, which explicitly recommends at least ten runs. benchstat documentation.
  • Keep shared-runner results informational.
  • Use a controlled runner before introducing a gate.

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

mparrett added a commit to mparrett/paserati that referenced this pull request Jul 12, 2026
… 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>
@mparrett

mparrett commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Both comments make sense- the fixes are in (61d8e49).

Provenance / compatibility (your P1). Done. perfdata.Baseline now records reducer + sample_count, and schemaVersion goes 1→2. A mean-era v1 baseline is rejected on read, so min-era and mean-era snapshots can't be silently diffed; update re-seeds on the mismatch with an explicit "intentional timeline discontinuity" note rather than ratcheting a min bar onto a mean one. compareAndReport also warns on a same-version reducer/count mismatch — a forward guard for the median reducer below.

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 aggregateFromFile rationale and renamed the test to TestReducerChoiceChangesRegressionVerdict; it now states plainly that it shows verdict suppression under an upward-contamination assumption — informational-only, never a gate.

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.

mparrett added a commit to mparrett/paserati that referenced this pull request Jul 12, 2026
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 nooga left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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

  1. Compounding with ratchetMerge: ratchetMerge, used by update/check against the persistent docs/perf/baseline.json, already does minF(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," but aggregateFromFile is 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.

  2. Evidence uses N=15, production uses N=3: perf-pr.yml/perf-timeline.yml both 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.

  3. docs/perf/baseline.json not migrated: it's still version: 1. Post-merge, bench-ratchet check against the default path will hard-fail until someone runs update once. 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 ratchetMerge interacting with min-reduced input — worth one given concern #1.

@mparrett

Copy link
Copy Markdown
Contributor Author

Addressed in ad52ea0 — all three concerns:

1. Reducer compounding (P1). Added a -reducer flag: mean is now the default (safe for the durable ratchet, since ratchetMerge already takes min-over-history — min-of-N input would compound into an unclearable floor), and 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 min-era and mean-era points are never silently diffed. aggregateFromFile is no longer unconditional min.

2. N=3 evidence. Added 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 — demonstrating min is stable, not outlier-chasing, at the production count.

3. baseline.json migration. The committed docs/perf/baseline.json was captured at -count 1 (one sample/benchmark, where min == mean), so I did a faithful metadata-only stamp — version 2, reducer "mean", sample_count 1 — numbers untouched (no re-benchmark on a foreign CPU). The version gate now passes; check won't hard-fail post-merge.

build/vet/gofmt clean; bench-ratchet tests pass; actionlint clean.

@mparrett

mparrett commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up: this composes with #24 and there's one small merge conflict. Both this PR and #24 edit cmd/bench-ratchet/main.go's aggregateFromFile. Verified by composing all open PRs on a clean nooga/main base — this is the only conflict across the set, and it's a 3-line resolution in the Result{...} build: keep this PR's reducer form and add #24's SetHash:

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.

@mparrett mparrett changed the title perf(bench-ratchet): reduce -count samples by min, not mean perf(bench-ratchet): scope the -count reducer — min for the informational A/B, mean for the ratchet Jul 14, 2026
@mparrett
mparrett requested a review from nooga July 16, 2026 13:36

@nooga nooga left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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.

mparrett and others added 5 commits July 25, 2026 11:20
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>
@mparrett
mparrett force-pushed the perf/bench-ratchet-min branch from d81f41f to 4fdad50 Compare July 25, 2026 18:27
@mparrett
mparrett requested a review from nooga July 25, 2026 18:41
@mparrett

Copy link
Copy Markdown
Contributor Author

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

@mparrett

Copy link
Copy Markdown
Contributor Author

Summary of what changed:

mparrett added a commit to mparrett/paserati that referenced this pull request Jul 26, 2026
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>
mparrett added a commit to mparrett/paserati that referenced this pull request Jul 27, 2026
…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.
mparrett added a commit to mparrett/paserati that referenced this pull request Jul 27, 2026
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.
mparrett added a commit to mparrett/paserati that referenced this pull request Jul 27, 2026
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>
mparrett added a commit to mparrett/paserati that referenced this pull request Aug 4, 2026
…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>
mparrett added a commit to mparrett/paserati that referenced this pull request Aug 4, 2026
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>
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