Skip to content

test(perf): add a benchmark harness for the metric walk#1085

Merged
dekobon merged 5 commits into
mainfrom
test/1068-bench-harness
Jul 26, 2026
Merged

test(perf): add a benchmark harness for the metric walk#1085
dekobon merged 5 commits into
mainfrom
test/1068-bench-harness

Conversation

@dekobon

@dekobon dekobon commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Adds big-code-analysis-bench, the benchmark harness #1068 asked for,
and moves the wall-clock assertions out of the unit suite into it.

Fixes #1068

Why

Every performance claim this project has made lived only in a commit
message. The absence of a harness caused concrete problems in each of
the four #1052 / #1062 perf commits: a corpus misdescribed, a "~26%"
improvement that re-measured to a 7-41% interval, two measurements
invalidated by generator indentation, and a wall-clock assertion that
produced a false failure in four environments — including under
cargo llvm-cov, whose coverage job runs in CI.

What landed

A workspace member (member, not default member — the xtask
treatment, so cargo build at the root does not pull criterion) with
two bench targets. Neither runs in per-PR CI.

--bench scaling is a complexity-class gate. Eight probes pair a
generated shape with the metric selection that reaches one hot path,
measure it at three doubling depths, and fit time ~ depth^k. A probe
fails when k leaves the class it declares. The timed region is
Ast::metrics on a pre-parsed tree, so parse cost is excluded.

--bench metric_walk is criterion over a deterministic corpus
slice, one benchmark per metric, printing its own composition before
measuring.

make bench-scaling / make bench-walk / make bench are the local
entry points; .github/workflows/benchmark.yml runs the gate quarterly
and re-measures before filing an issue.

Against the four measurement traps

  • Indented generators — every shape emits a constant number of
    bytes per nesting level, and byte_growth_is_affine fails if one
    stops doing so.
  • Misdescribed corpusCorpusSlice::summary prints file count,
    byte total, per-language breakdown, and an explicit note when a
    ceiling truncated a language.
  • Sequential ratios — cells are interleaved with the visit order
    rotated per round, three points are fitted rather than two, and
    sub-millisecond cells repeat inside one timed sample.
  • No controlnom/nested-while is a metric control (Cognitive
    declares Nom); cognitive/nested-while and loc/nested-while are
    shape controls for the quadratic probes beside them.

What the first run found

Three walks measure quadratic in nesting depth, all through
Node::parent (tree-sitter stores no parent pointer, so the lookup
descends from the root):

Probe k Call
cognitive/nested-if 1.97 Checker::is_else_if
loc/nested-declaration 1.95 Node::count_specific_ancestors
nom/nested-quote 2.01 elixir_is_inside_quote_block

is_else_if was known. The other two were not — the shape controls
isolate them (loc/nested-while fits 0.99 on identical nesting without
a declaration). Filed as #1084; the probes are pinned under a
quadratic bound so further degradation still fails.

On removing the per-PR timing guard

BCA_ASSERT_SCALING is gone and both tests keep their value
assertions, renamed to cognitive_nesting_is_inherited_at_depth and
tokens_count_holds_at_depth. Your original comments were right that a
value assertion alone lets a reintroduced quadratic walk hang CI rather
than fail it, so the harness carries a per-walk budget: cells build in
ascending depth order and a probe whose walk exceeds MAX_CELL_WALK is
abandoned, contributing no cells at all. A pre-#1052-magnitude
regression is reported in a couple of minutes instead of running out
the job timeout.

Everything mechanical about the guard stays per-PR as ordinary unit
tests — shapes affine in bytes, shapes parse, shapes nest, each probe's
metric reads non-zero, the fit recovers 1.0/2.0/0.0 on synthetic data,
argument parsing. Only the timing verdict, the one thing a shared
runner cannot supply honestly, sits on the rare trigger. That tension
with lesson 80 is recorded as a sub-example on that lesson.

Review notes

Two review passes ran on this branch. The findings were all one shape —
measurement apparatus degrades toward a flattering number — and are
fixed in 2bca5d27 and d1d1de93:

  • The corpus walk followed directory symlinks, so
    DeepSpeech/tensorflow/native_client -> ../native_client made the
    Java bucket 8 distinct files counted twice while summary() reported
    sixteen. Directories are now deduplicated by canonical path, which
    also bounds the walk against a symlink cycle.
  • An abandoned probe kept its shallower cells in the schedule, costing
    rounds + WARMUP_ROUNDS walks that changed no verdict.
  • The report header printed exponent 0.00 (bound 1.50) OVER BOUND for
    an abandoned probe.
  • The gate now exits 2 for "harness could not run" vs 1 for "probe left
    its class", so an infrastructure error can no longer file an issue
    claiming a complexity regression.

Incidental, and worth a look

  • .checkmake.ini's cap moves 100 → 120. The help body had already
    reached exactly 100, so the ≥15-line headroom that file's own comment
    promises was spent before this branch touched it.
  • big-code-analysis-bench is added to .bcaignore, matching
    ./xtask/**. That means the bench crate is not measured by the
    project's own gate — deliberate, but say so if you would rather it
    were scanned.
  • 7b47aa28 is an unrelated one-line fix: lesson 81 cited 3fd01c70,
    an orphaned pre-amend copy of the fix(wire): recursive Serialize/Drop on FuncSpace lack the #700 de-recursion #1056 commit, rather than
    93547880 which is reachable from main.

Validation

make pre-commit green. Gate re-run end to end: all 8 probes within
bound. The two symlink regression tests were verified by reverting the
dedup and confirming they fail.

dekobon added 5 commits July 26, 2026 09:41
Adds `big-code-analysis-bench`, a workspace member (not a default
member, like `xtask`) with two bench targets.

`--bench scaling` is a complexity-class gate. Eight probes pair a
generated source shape with the metric selection that exercises one hot
path; each is measured at three doubling nesting depths with the cells
interleaved and rotated between rounds, and the reported figure is the
slope of ln(time) against ln(depth). A probe fails when that exponent
leaves the class it declares. Asserting the class rather than a
duration is what makes the guard portable: the wall-clock assertion it
replaces produced false failures on windows-latest, under llvm-cov, and
twice on a loaded local host.

`--bench metric_walk` runs criterion per metric over a deterministic
slice of the corpus submodules. The slice prints its own composition
before measuring, since a published number from the #1052 / #1062 work
described a corpus it had not analysed.

The wall-clock halves of `cognitive_deep_nesting_is_tractable` and
`tokens_deep_nesting_is_tractable` move into the gate and the
`BCA_ASSERT_SCALING` escape hatch is deleted. Both tests keep their
value assertions and are renamed to say what they now cover. A per-walk
time budget in the harness preserves the property those budgets
existed for: a reintroduced quadratic walk is abandoned at its
shallowest depth and reported, instead of running for hours.

Out of band by design. `.github/workflows/benchmark.yml` runs quarterly
and re-measures before filing an issue; `make bench-scaling` is the
local entry point.

The first run measured three walks as quadratic in nesting depth, all
through `Node::parent` — filed as #1084 and pinned under a quadratic
bound so a further degradation is still caught.

The checkmake cap moves 100 -> 120: the `help` body had reached exactly
100, spending the headroom that file's own comment promises, so the
next public target of any kind would have failed the gate.

Fixes #1068
The lesson cited 3fd01c7, an orphaned pre-amend copy of the same
change. 9354788 is the commit reachable from main.
`run` pushed a cell into `pending` and only then compared its walk
against `MAX_CELL_WALK`, so the cell the budget had just rejected was
still walked once per round. A reintroduced quadratic walk — over two
minutes at depth 2000 before #1052 — would have cost 13 or 21 rounds of
that under the scheduled workflow and tripped the 90-minute timeout,
which is the outcome the budget's own doc comment says it prevents.

The check now precedes the push. `run` delegates to
`run_with_budget(probes, rounds, max_cell_walk)` so the abandon path is
reachable from a test: a zero budget abandons every probe at its
shallowest depth in milliseconds, where exercising the real twenty
seconds would need a walk that genuinely takes them. The new test fails
against the previous ordering.

Also in this pass:

- `--rounds` rejects even values. `USAGE` said "must be odd" and
  `DEFAULT_ROUNDS` carries a compile-time parity assert, but the parser
  only rejected zero, so `--rounds 10` silently made every median an
  average of two samples.
- The gate reports an abandoned probe by the budget it blew rather than
  by its exponent. That exponent is fitted over the cells that
  finished, so it reads as a pass ("0.00 > 1.50") for the worst
  regression the gate can see.
- `bench_corpus` bails when `parse_slice` retains nothing. The slice
  selects by file extension, so a build without those languages
  compiled in would have timed empty loops and published them as walk
  numbers.
- `Report::failures` and `SHAPE_BENCH_DEPTH` doc comments corrected;
  the latter is the shallowest linear depth, not the middle one.
- AGENTS.md gains the crate-table row and a Benchmarking paragraph
  beside the mutation-testing one, so the workflow is discoverable from
  the file agents actually read.
Lesson 80 covers assertions that rot because they only fire on a rare
trigger. #1068 did the opposite deliberately: it moved a wall-clock
assertion out of the per-PR suite onto a quarterly bench, because a
shared runner cannot produce an honest wall clock and the assertion had
already redded CI four times on measurement artefacts.

The sub-example records the three conditions that made that acceptable
rather than a rot vector — every mechanical part of the guard mirrored
per-PR, only the timing verdict left on the rare trigger, and a
fail-fast budget so the rare gate reports instead of hanging — and the
trap the first cut hit twice: measurement apparatus degrades toward a
flattering number, so a truncated or empty measurement reads as an
excellent one unless each partial path carries its own verdict.

The closing paragraph gains the matching qualification; no lesson is
renumbered.
`collect_candidates` recursed on `Path::is_dir`, which follows
symlinks, so an aliased subtree was walked twice and every file beneath
it entered the candidate list under two paths. The corpus ships
`DeepSpeech/tensorflow/native_client -> ../native_client`, and it
duplicated the whole Java bucket: `summary()` reported sixteen files
where there were eight, each of them benched twice — in the one method
whose entire purpose is reporting what was actually analysed.

Directories are now deduplicated by canonical path through a set shared
across roots, which also bounds the recursion: a symlink cycle
previously descended until the stack overflowed, and a stack overflow
aborts rather than unwinds. Both are covered by unix-gated fixture
tests that fail against the previous walker. The real slice moves from
182 files / 886 KiB to 177 / 903 (Java's freed slots refill with
distinct, larger files).

Also from the same review pass:

- An abandoned probe now contributes no cells at all, not merely no
  deeper ones. `passed()` ignores the exponent once `over_budget` is
  set, so the shallower cells changed no verdict while costing
  `rounds + WARMUP_ROUNDS` walks of an input already known to be
  pathological — about sixteen minutes for a pre-#1052-magnitude
  regression under the scheduled workflow. Cells now accumulate into a
  local vec that reaches the schedule only if the probe finished, so
  the invariant is structural rather than a cleanup to remember. The
  `MAX_CELL_WALK` and `LINEAR_DEPTHS` comments claimed a fail-fast
  behaviour that did not hold; both are corrected against the
  magnitudes on record.
- The report header no longer prints an exponent for an abandoned
  probe. It is fitted over the cells that finished — none — so the
  line read `exponent 0.00 (bound 1.50) OVER BOUND`, a passing number
  beside a failing verdict for the worst regression the gate can see.
- The bench target exits 2 for "the harness could not run" and 1 for
  "a probe left its complexity class", and benchmark.yml branches on
  the two. Previously any infrastructure error that reproduced on the
  retry filed an issue claiming a complexity regression. The status
  capture uses `|| status=$?` because `$?` after an `if` block whose
  condition failed is the if statement's own 0, not the pipeline's.
- `bench_corpus` reports a partial drop, not just an empty one: the
  composition is printed from the slice while the numbers come from
  the parsed subset, and those diverge under a reduced feature set.
- `CorpusFile::path` is absolute, not repo-relative; a stale doc line
  left over from a moved const no longer heads the wrong test.
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.06%. Comparing base (1b4eee3) to head (d1d1de9).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #1085   +/-   ##
=======================================
  Coverage   98.06%   98.06%           
=======================================
  Files         268      268           
  Lines       66613    66586   -27     
  Branches    66183    66156   -27     
=======================================
- Hits        65321    65299   -22     
+ Misses        851      846    -5     
  Partials      441      441           
Flag Coverage Δ
python 100.00% <ø> (ø)
rust 98.05% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/metrics/cognitive.rs 99.86% <100.00%> (+0.07%) ⬆️
src/metrics/tokens.rs 99.38% <100.00%> (-0.01%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dekobon
dekobon merged commit 068dddf into main Jul 26, 2026
44 checks passed
@dekobon
dekobon deleted the test/1068-bench-harness branch July 26, 2026 19:02
dekobon added a commit that referenced this pull request Jul 26, 2026
PR #1085 merged by rebase, which rewrote every commit hash on the
branch. Lesson 80's sub-example was written before that merge and cited
bfad0b9 / 2bca5d2, neither of which is reachable from main. The
equivalents are 0eeb6fe and 93c23cd — verified by patch-id, not by
subject line.

This is the second time in one branch: 5f3d790 corrected lesson 81 for
citing an orphaned pre-amend copy of the same #1056 commit. A lessons
entry written before its own PR lands cites hashes that any rebase or
squash merge invalidates, so the citation has to be re-checked after the
merge, not at authoring time.
dekobon added a commit that referenced this pull request Jul 26, 2026
Issue and PR numbers are assigned once and never change. A commit hash
is not stable until the change reaches the default branch, and this
repository rebase-merges, so every hash on a branch is rewritten when
the PR lands. A lesson drafted alongside the change it describes — the
normal case, because that is when it is fresh — therefore cites hashes
that are orphaned the moment it merges, and nothing detects it: the text
still renders and the hash still looks like a hash.

That happened twice during PR #1085 and both were caught by chance.
Lesson 81 cited an orphaned pre-amend copy of the #1056 commit; lesson
80's sub-example, written in that same PR, cited two branch hashes its
own rebase merge rewrote.

The lessons-learned skill now records a PR rather than a hash at every
citation site (evidence capture, the candidate template, the entry
format, and the evidence-trail guardrail), and gives the `gh api
.../commits/<sha>/pulls` lookup to resolve one from the other.

Existing entries keep their hashes: retrofitting eighty-odd lessons
would be churn, and the skill says so explicitly so a future run does
not read this as licence to rewrite them. It also carves out the one
case where a hash is still right — a commit already on main with no PR
behind it — and asks that reachability be verified first.

The lessons file header is updated to match; it was the same guidance
stated in a second place.
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.

test(perf): add a benchmark harness for the metric walk

1 participant