Skip to content

Time the benchmark's scaling rows on CPU time, and run its counts in parallel - #1352

Merged
derek73 merged 6 commits into
masterfrom
feat/benchmark-cpu-clock
Nov 12, 2026
Merged

derek73 merged 6 commits into
masterfrom
feat/benchmark-cpu-clock

Conversation

@derek73

@derek73 derek73 commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

tests/v2/test_benchmark.py's clock-timed tests failed at random on a laptop running several agent sessions at once. Tests and docs only; nothing under nameparser/ changes.

What changed

  • The 34 clock-timed tests read the thread's CPU time (time.thread_time, _CLOCK) instead of time.perf_counter(). These are 23 _SHAPES rows, 8 _PREFIXED_SHAPES rows, 2 _POLICY_SHAPES rows and the thousand-names backstop. The wall clock counts time the scheduler spends running other processes. CPU time doesn't, but it still counts everything the parse does on its own thread, C-level work and memory stalls included.
  • _best became _ratio. It interleaves the small and large input (one parse of each per round), so a slow stretch is charged to both. It takes _ROUNDS = 7 rounds. Where those read within 20% of the bound or over it (_NEAR = 0.8), it takes all _MAX_ROUNDS = 35 and judges the last minimums.
    • Each round can only lower the two minimums toward the true costs, so the estimate moves toward the true ratio in either direction: load can deflate a ratio as well as inflate it.
    • It is judged once, never at the first reading that passes.
    • test_ratio_takes_more_rounds_only_near_the_bound holds this on a scripted clock.
    • A quiet clean run (~4x) pays for no extra rounds. A loaded one does: the serial pass took 78–115s at 24 busy loops, against 47s for the first version of this PR.
  • No threshold or baseline moved. _MAX_RATIO, _BASE, _PREFIXED_BASE and every frame baseline are unchanged. The one unit change is the thousand-names bound: still 5.0, now seconds of CPU time instead of wall time. CPU time can't exceed the wall time of a single-threaded parse, so the bound is no looser.
  • The 28 untimed tests (frame, call and shift counts plus the table checks), and the new _ratio test, run in the parallel suite now, so a plain uv run pytest holds the _CALL_BASELINE band, the link pin, the reread ratios and the shift guards. Only the four timed test functions keep the serial mark (-n0 -m serial, unchanged in CI).
  • Docs updated: the module docstring, the _CLOCK/_ratio comments, the thousand-names docstring, tests/conftest.py, the marker description in pyproject.toml, the workflow comment, and the root AGENTS.md Commands block and the "Perf regressions" gotcha. CONTRIBUTING.md's command line didn't need to change. decisions/parse-cost gets a dated entry and its Standing backstop line is amended.

Before and after (py3.11, 12-core Apple silicon: 6 performance + 6 efficiency cores)

"Busy N" means N shell while :; do :; done loops started beside the run. The machine was never idle: other sessions held load at roughly 5–45 throughout, so "quiet" means no busy loops of mine.

Five runs of uv run pytest -n0 -m serial tests/v2/test_benchmark.py per row:

tree load added load avg (1 min) failed per run (of 62 before / 34 after)
before (970b54e) none 4–6 0, 0, 0, 0, 0
before busy 12 18–36 0, 0, 0, 0, 0
before busy 24 37–48 20, 23, 21, 19, 23 (up to 10.0x vs 6.0)
after none 16–43 0, 0, 0, 0, 0
after busy 12 14–21 0, 0, 0, 0, 0
after (first push) busy 24 29–43 0, 0, 0, 0, 0
after (final _ratio) none 31–55 0, 0, 0 (3 runs)
after (final _ratio) busy 24 50–62 0, 0, 0, 0, 0

Busy 12 barely loaded the machine (macOS spreads 12 loops over 12 cores, leaving the test its own core). Busy 24 is the case that reproduces the reported failures.

Per-row ratios on the same samples, three runs at busy 24, load 18–75:

  • wall clock: 27 of 33 rows read ≥ 6.0 at least once, max 14.0x
  • CPU clock, sequential best-of-7, measured back to back with the wall clock: max 5.76x. It read 5.76x with no busy loops too.
  • CPU clock, interleaved best-of-7: max 5.59x over 99 more readings (load 30–59)

CPU time is still noisy under contention. A clean row (~4.0x) read up to 5.6x, about +40%. The likely cause is a thread moved onto an efficiency core or a cold cache, which is charged the slower run. Setting the thread's QoS to user-interactive didn't help, so it isn't in the PR.

The noise runs both ways. With extra rounds taken only at or over the bound (this PR's first push), a planted 6.7x quadratic (given_part_titles) read 5.93x once at busy 24 and passed. The first push's loop could also stop on a dip under the bound. Review found both; the final _ratio fixes both:

  • the weak plant's 11 rows read 7.18x or more in all 5 runs at busy 24
  • clean master reads ≤ 5.06x

Every guard still fails on its regression

Each control below was run through the shipped estimator (_ratio, or a verbatim copy for trees whose nameparser predates this module's tables) and through the old _best on perf_counter, quiet and at busy 24, three runs each. Historical controls ran on scratch worktrees of the named commit. Plants went into a scratch copy of master; nothing in this branch's nameparser/ was touched.

row(s) regression old wall clock, quiet CPU, quiet CPU, busy 24
link_run b9ed142 (#397 per-member walk) 13.8–14.3 13.85–14.06 15.6–16.4
given_part_run / given_part_titles 003b496 (#553 list scans) 7.95–8.03 / 10.1 8.0–8.04 / 10.1–10.14 10.0–10.3 / 11.9–12.5
title_chain, pair_run, caps_pairs, delimiter_cores 9dd271b (#613) 11.5–12.1, 10.1, 10.2, 10.4–10.5 11.95–12.02, 9.95–9.99, 10.1–10.2, 10.35–10.41 14.0–14.8, 10.2–12.4, 12.2–12.4, 11.8–12.6
connective_between_particles 5752df6 (#939 list, before review) 8.40–8.45 8.50–8.55 8.9–10.2
(the rows above, final _ratio, busy 24, two more runs) same commits — — link_run 14.9–15.3, given_part 10.0–12.1, 9dd271b's four 11.8–13.7, connective 9.4–10.2
11 rows reading 6.3–7.0 quiet planted quadratic, D=2 (the weakest plant) 6.24–7.07 6.26–7.02 final _ratio: ≥ 7.18 on every row, 5 runs
slash_chain / hyphen_chain / member_chain planted: the cut copies its best piece list per step (#1103's recorded quadratic) 8.7–9.0 / 8.8–9.0 / 11.5 8.8–8.9 / 9.1 / 11.4–11.7 8.9–9.2 / 9.1–9.2 / 12.7–12.9
all _SHAPES, _PREFIXED_SHAPES, _POLICY_SHAPES rows planted: a C-level quadratic in the word count (-1 in list scanned len/D times), D=1 and D=2 — wall and CPU fail the same rows in every run, within 0.35x see the D=2 row above
thousand names planted: a backtracking regex per parse 7.36 s 7.31 s CPU 9.96 s CPU (wall 17.9 s)
(master, clean) 970b54e 3.97–4.19 3.97–4.19 3.8–5.06 (final _ratio)

Quiet, the two clocks fail the same rows under every plant, within 0.35x. Rows reading under 6.0 quiet with a given plant mean the plant is too weak for that row, and the wall clock reads them the same way.

Two notes on coverage:

  • 962301f (Fix the quadratic behind the title_chain and link_run CI failures #1036's memory move) reads 4.07–4.43 on CPU and 4.07–4.50 on the wall clock, quiet, as decisions/parse-cost recorded for the wall clock. That defect was only ever visible on CI's runners, and _ShiftCountingList holds it now. CPU time does count memory stalls, so CI should see it as before.
  • bracket_joined_members and maiden_clause have no recorded control I could re-run. Neither the word-count plant nor the cut plant changes how their cost grows (each token is short), so they're covered only by the general result that the clocks agree when quiet.

Frame tests in the parallel run

They pass:

  • each alone in a fresh process (-n0 path::test, all 28)
  • after test_cases.py and test_match.py in one process (21,065 tests before them)
  • in three full parallel suites (load 8–45)
  • under --cov on 3.11 (sys.settrace) and on 3.14 (sys.monitoring, CI's coverage interpreter), in parallel and -n0

Decisions/parse-cost (#1170) records that an absolute count depends on what the process parsed before. The guards hold anyway because each one is one of:

  • a ratio
  • a band (the reference band averages over 50 parses; the link pin read 2,395–2,405 against 2,370–2,466 in the test review's probe)
  • an exact count of one named function's calls (only=/caller=, == 0 up to == 6); none of those functions is lru_cache'd

Their code is unchanged; only the mark moved. The source-tree and installed-package CI jobs, which run only the parallel pass, now run these 28 tests too.

Reviews

  • Tests (pr-test-analyzer): no test dropped from either pass (62 → 34 serial + 28 untimed, disjoint); conftest deselection still behaves. Its optional-stopping finding is fixed (above). Two findings were addressed without changing code:

    • CPU time misses off-thread or blocked time; the thousand-names docstring now says so.
    • bound is passed separately from the assertion. Both read _MAX_RATIO and there is one caller, so this was left as is.
  • Comments (comment-analyzer): wording findings fixed.

    • The guard forms include exact pins up to == 6.
    • "read the same" became "passed".
    • The extra rounds are described as not a retry.
    • The wall-clock tables before 2026-11-11 are labelled as such.
    • Stale "one-second bound" and "800 times" wording fixed.
  • Design docs (design-docs-reviewer): the decisions entry was rewritten from the raw outputs: the 0.05x claim became 0.35x, 962301f's wall clock reads 4.07–4.50, the count after the 21,065 tests is corrected, and the deflation miss is recorded. That reviewer's deflation finding is what led to the _NEAR rule.

  • Fix commit (code-reviewer): no case the first version refused now passes. It found three things, all fixed:

    • the boundary row passed only by float drift; it now uses 4.7 and 4.9
    • AGENTS.md still said 21,093
    • the prose claimed the ratio moves monotonically toward the truth; it converges, not monotonically, which is why it is judged once

Checks

  • uv run pytest: 45,465 passed, 38 skipped, 4 xfailed (3.11)
  • uv run pytest -n0 -m serial: 34 passed (3.11)
  • uv run mypy, uv run ruff check: clean
  • 3.14 with --cov: both passes green (the module re-run after the review fixes)
  • 3.15 in its own UV_PROJECT_ENVIRONMENT: both passes green

🤖 Generated with Claude Code

…parallel

The clock-timed rows in tests/v2/test_benchmark.py read
time.perf_counter(), which counts the time a loaded machine spends
running other processes: under 24 busy loops on 12 cores 19-23 of the
module's 62 tests failed in each of five runs, at up to 10.0x against
a 6.0 bound. They now read the thread's CPU time, interleave the two
inputs, and take more rounds only where the first ones read over the
bound, so no threshold or baseline number moves; the thousand-names
bound stays 5s, now of CPU time.

The frame, call and shift counts lose the serial mark and run in the
parallel suite, so a plain `uv run pytest` holds the band; only the
four timed tests stay in the -n0 -m serial pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derek73 derek73 added this to the 3.1 milestone Nov 12, 2026
@derek73 derek73 added the tests label Nov 12, 2026
@derek73 derek73 self-assigned this Nov 12, 2026
derek73 and others added 5 commits November 11, 2026 23:34
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…claims

#1352's review found two ways a loaded run could pass a real quadratic:
more rounds were taken only at or over the bound, so a ratio that load
DEFLATED (a planted 6.7x read 5.93x once at 24 busy loops) was never
corrected, and the escalated loop stopped at its first reading under
the bound. `_ratio` now takes all 35 rounds wherever its first 7 read
within 20% of the bound or over it, and judges the last minimums;
test_ratio_takes_more_rounds_only_near_the_bound holds that on a
scripted clock and fails against both earlier loops. The weakest
planted quadratic now fails every row in five loaded runs.

Also corrects the review's wording findings: the counted guards include
exact pins up to == 6, "read the same" was "passed", the extra rounds
are not a retry, the 0.05x agreement was 0.35x, 21,093 was 21,065, and
the wall-clock tables before 2026-11-11 are labelled as such.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… monotonically

The fix commit's review: the 4.8 boundary row passed only because the
fake clock's float sums landed a ulp over 6.0 * 0.8; 4.7 and 4.9 sit
clear of it. And a ratio of two falling minimums is not monotone, which
is why _ratio judges it once at the end -- the prose said otherwise.
AGENTS.md's 21,093 becomes the population the decisions entry names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The row arrived from master written against the wall-clock helpers; as a
_PREFIXED_SHAPES entry it already measures through _ratio under @serial.
Its one-scan plant fails it on CPU time, quiet and beside 24 busy loops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derek73
derek73 merged commit 670b349 into master Nov 12, 2026
14 checks passed
@derek73
derek73 deleted the feat/benchmark-cpu-clock branch November 12, 2026 09:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant