Skip to content

fix(benches): 🐛 record placement for every benchmark, not just the random ones - #230

Closed
diagonal-hamiltonian wants to merge 1 commit into
mainfrom
pr/bench-record-placement
Closed

fix(benches): 🐛 record placement for every benchmark, not just the random ones#230
diagonal-hamiltonian wants to merge 1 commit into
mainfrom
pr/bench-record-placement

Conversation

@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Summary

The benchmark suite could not tell a fast run from an unpinned one, and several of its instruments were measuring the harness rather than the engine. This fixes three of them. No engine code changes.

The one that matters most is placement. pinned_thread_summary() was called from inside OpMemory.stop(), and the fixed-model benchmarks (bench_models.py) never open a memory window — so they recorded no placement at all. An unpinned multi-rank run is not a slow run, it is a meaningless one: co-located ranks collapsed onto the same cores produce timings that look like code changes. Placement is a property of the run, not of the memory window that happened to observe it, so it moves to module scope and is called from record_model_stats too. It still has to be sampled while the engine's partition threads are alive, which is why it stays inside an instrumentation hook rather than moving to configure time.

Changes

  • _record_placement(comm) at module scope in benches/conftest.py, called from both OpMemory.stop() and record_model_stats. min/max across ranks, because a partial failure — some ranks placed, some not — is the interesting case and the shape a batch-system cpuset confinement actually takes.
  • barriered() no longer times its own entry barrier. It was charging each rank with the skew accumulated in the preceding setup rather than with the operation — worth ~1.44× of a 2.86× observed spread on build_graph. The residual is still unexplained, so this makes the wall less untrustworthy, not trustworthy.
  • PssSampler is now opt-in behind --bench-pss-sampler. It reads /proc/self/smaps_rollup, whose cost is O(address space) and which takes mmap_lock; above a few GiB it perturbs the run it is measuring, and because it only samples when the GIL is released, the faster side of an A/B is sampled less and reports a lower peak. HighWaterMark is exact, needs no thread, and is what to quote.
  • Adds a per-operation memory window (op_memory) spanning the timed call alone — the autouse record_memory window includes setup, so for a benchmark with a setup it reports the construction transient rather than the operation.
  • Adds a test_random_propagate benchmark. Adding a benchmark is safe for Bencher's history key; nothing here renames or moves one.
  • AGENTS.md records the traps: pedantic's setup= builds round k+1 before releasing round k (so rounds > 1 roughly doubles peak memory), pytest's fd-level capture hides C++ diagnostics without -s, and asserting an instrument's emission count at the point of collection is what stops "the two arms measured the same" and "the instrument never fired" from being the same observation.

Verification

pytest benches/bench_random.py and benches/bench_models.py both run and now record pinning on either path (checked at 2 threads and at 8 ranks × 16 partitions, 16..16 of 19 under srun --cpu-bind=cores). prek run --from-ref origin/main --to-ref HEAD clean.

Checklist

  • Tests added or updated to cover the changes
  • Documentation updated (docstrings, docs/, CONTRIBUTING.md) if needed
  • CHANGELOG / release notes updated if applicable — release notes are generated from the PR title

AI/LLM disclosure

  • I used the following tool to help write this PR description: Claude Code (claude-opus-5)
  • I used the following tool to generate or modify code: Claude Code (claude-opus-5)

…ndom ones

The benchmark suite could not tell a fast run from an unpinned one, and
several of its instruments were measuring the harness rather than the
engine.

PLACEMENT. `pinned_thread_summary()` was called from inside
`OpMemory.stop()`. The fixed-model benchmarks never open a memory
window, so they recorded no placement at all -- and an unpinned
multi-rank run is not a slow run, it is a meaningless one: co-located
ranks that collapse onto the same cores report timings that look like
code. Placement is a property of the run, not of the memory window that
happened to observe it, so it moves to `_record_placement` at module
scope and is called from `record_model_stats` as well. It still has to
be sampled while the engine's partition threads are ALIVE, which is why
it stays inside an instrumentation hook rather than moving to configure
time. min/max across ranks, because a PARTIAL failure -- some ranks
placed, some not -- is the interesting case and the shape a batch-system
cpuset confinement actually takes.

TIMING. `barriered()` was timing its own entry barrier, charging each
rank with the skew accumulated in the PRECEDING setup rather than with
the operation. That was worth ~1.44x of a 2.86x observed spread on
`build_graph`. The residual is still unexplained, so this does not make
the wall a trustworthy instrument -- it makes it less untrustworthy.

MEMORY. `PssSampler` reads /proc/self/smaps_rollup, whose cost is
O(address space) and which takes mmap_lock; above a few GiB it perturbs
the run it is measuring, and because it only samples when the GIL is
released, the FASTER side of an A/B is sampled less and reports a lower
peak. It is now opt-in behind --bench-pss-sampler. `HighWaterMark` is
exact, needs no thread, and is what to quote.

Also adds a per-operation memory window (`op_memory`) whose span is the
timed call alone -- the autouse `record_memory` window includes `setup`,
so for a benchmark with a setup it reports the construction transient,
not the operation -- and a `test_random_propagate` benchmark. Adding a
benchmark is safe for Bencher's history; nothing here renames one.

AGENTS.md records the traps: pedantic's `setup=` builds round k+1 before
releasing round k (so rounds > 1 doubles peak memory), pytest's fd-level
capture hides C++ diagnostics without `-s`, and asserting an
instrument's emission count at the point of collection is what stops
"the arms measured the same" and "the instrument never fired" from being
the same observation.

Assisted-by: ClaudeCode:claude-opus-5
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 15, 2026
@diagonal-hamiltonian diagonal-hamiltonian added the test-in-draft Run CI even in Draft mode label Aug 15, 2026
@github-actions

Copy link
Copy Markdown

Docs preview: https://pr-230.monoprop-docs.pages.dev

@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.70%. Comparing base (6abd839) to head (8872185).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #230   +/-   ##
=======================================
  Coverage   97.70%   97.70%           
=======================================
  Files          14       14           
  Lines         742      742           
  Branches       98       98           
=======================================
  Hits          725      725           
  Misses         12       12           
  Partials        5        5           
Flag Coverage Δ
cpp 97.70% <ø> (ø)

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

@sonarqubecloud

Copy link
Copy Markdown

@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator Author

🤖 AI text below 🤖

Closing this stack to rebuild it around a single-path design. Not abandoned — the work returns as fresh PRs once it is ready.

Why. The stack accumulated runtime knobs as a way to A/B competing paths, and an A/B knob is not a shipping knob. Against main's 3 environment knobs the branch reached 15. The codebase already states the rule this violates, in EnvConfig.h:

monoprop_COMPACT_RECORD, monoprop_SPARSE_RESOLVE and monoprop_INSERT_PREFETCH were removed once they had been measured … A knob that has no losing arm left is dead configuration surface.

What the measurements decided. Several paths were being kept behind default-off knobs on the strength of stale or never-run comparisons:

  • Sparse emit + the positions encode measure ~4% slower in emit_s than the dense path at both P=1 and P=8 (6/6 paired reps, p=0.031), with a Pauli structural null confirming the effect is real rather than code layout. The encode optimisation was genuine — 43 ns/push — but it optimised a path that loses by more than it recovers. Both are deleted.
  • Zobrist routing's recorded 2.8% emit_s regression turned out to be stale: it was measured on an emit path that has since been rewritten, and now reads flat. Its disposition is under a P sweep.
  • The query-dump capture and replay tooling chose the wire format and is not load-bearing for any shipped behaviour, so it does not ship.

What returns. The sparse query record together with the positions-based resolve (this PR's successor), the self-resolve simplification, and the layer profiler. Roughly 1,800 lines are deleted rather than added, and every PR in the new stack is a pure addition — the intermediate record format is never introduced, so nothing is added and then removed.

Reopening as new PRs once the local work is verified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation test-in-draft Run CI even in Draft mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant