Skip to content

perf(evolution): ⚡ halve the follower-marking epoch stamp - #259

Open
diagonal-hamiltonian wants to merge 2 commits into
mainfrom
perf/epoch-stamp-noshrink-v2
Open

perf(evolution): ⚡ halve the follower-marking epoch stamp#259
diagonal-hamiltonian wants to merge 2 commits into
mainfrom
perf/epoch-stamp-noshrink-v2

Conversation

@diagonal-hamiltonian

@diagonal-hamiltonian diagonal-hamiltonian commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Halve the follower-marking epoch stamp (u32 → u16)

Narrows detail::MatchedEpochSet's per-term stamp to uint16_t — 2 B/term of operator row storage — with wrap handled explicitly and no shrink of the array anywhere. perf/epoch-stamp-noshrink-v2 8dbe2a5, one commit on 412315e, 7 files +105/−12.

  1. The stamp width. One stamp per term, for a counter that never leaves the struct — never serialised, never exchanged between ranks, never compared against anything but cur_. The wrap branch is now live (once per 65535 gate applications instead of once per 2^32) and the std::fill is what keeps it correct; matched_epoch_stamp_wrap_reached_by_gate_count cycles a whole period rather than assigning to cur_, which is the only way to prove the fill runs rather than that the branch is reachable.
  2. matched_scratch_bytes joins the memory breakdown, inside total_bytes(). The stamp array is propagator-owned, so estimate_memory_usage() cannot see it and only MonomialPropagator::operator_memory_usage() can fill it in — the partitioned path sums per-partition breakdowns, so each picks up its own array.

Memory: main 48cadcb vs this branch, both ENABLE_PROFILE=OFF

Kernel /usr/bin/time -v peak RSS, summed over the ranks on the node, produced outside the code under test. 16 cells × 10 interleaved reps in one allocation, order flipped per (rep, cell); paired per-rep ratios, median of ratios; agree = reps pointing the same way.

cell main GiB port GiB port/main agree
hubbard · A 1x128 · N=1 · fresh 9.54 9.49 0.9948 10/10
hubbard · A 1x128 · N=2 · fresh 10.33 10.02 0.9702 10/10
hubbard · B 8x16 · N=1 · fresh 11.06 10.96 0.9911 10/10
hubbard · B 8x16 · N=2 · fresh 14.18 13.92 0.9813 10/10
hubbard rung · A 1x128 · N=1 · fresh 26.61 26.05 0.9788 10/10
hubbard rung · A 1x128 · N=2 · fresh 31.23 30.46 0.9750 10/10
hubbard rung · B 8x16 · N=1 · fresh 28.41 27.84 0.9797 10/10
hubbard rung · B 8x16 · N=2 · fresh 34.83 34.04 0.9772 10/10
pauli · A 1x128 · N=1 · fresh 10.39 10.31 0.9922 10/10
pauli · A 1x128 · N=1 · graph 18.13 17.93 0.9890 10/10
pauli · A 1x128 · N=2 · fresh 10.90 10.88 0.9987 10/10
pauli · A 1x128 · N=2 · graph 36.70 36.53 0.9952 10/10
pauli · B 8x16 · N=1 · fresh 12.17 12.03 0.9891 9/10
pauli · B 8x16 · N=1 · graph 19.68 19.48 0.9902 10/10
pauli · B 8x16 · N=2 · fresh 14.94 14.92 0.9969 8/10
pauli · B 8x16 · N=2 · graph 40.78 40.57 0.9951 10/10

Peak RSS falls in 16 of 16 cells, 14 at unanimous 10/10, and 15 of 16 clear Holm across the memory family — largest adjusted p = 0.043 (pauli-B-N1 fresh, 9/10); the only failure is pauli-B-N2 fresh (8/10), the smallest effect present. That unanimity is node-sum only: on worst-rank RSS two cells reverse direction (pauli-A-N2 fresh 1.0000, pauli-B-N2 fresh 1.0027). Hubbard beating pauli is what a per-term saving must do — but 2 B/term is a floor on the array's saving, not on the peak-RSS delta, because peak RSS is a maximum over time and the stamp array need not be at its own maximum at that instant: hubbard propagate at N=1 implies only 0.54 and 1.05 B/term of node-sum delta, below the array's own floor, which is a statement about when the peak lands and not about the width.

Timing: a null

1 of 24 tests resolved; the other 23 span 0.964–1.014x. The one survivor is gradient[pauli], layout A 1x128, N=1: 11355.2 → 11172.4 ms, 0.9836x, 10/10, Holm-adjusted p = 0.0469 — exactly at the boundary, and on an operation that reaches only the allreduce and never touches a table this diff changes, so it reads as a null either way. Absolute medians at N=1 below; the N=2 half and the full 24-row breakdown will follow in a comment. Grid: layout A = 1 rank/node × 128 partitions, B = 8 × 16, N = 1 and 2 nodes; hubbard --hubbard-cutoff=10 --hubbard-lower-atol=1.25e-05 (96,981,051 terms), pauli --pauli-cutoff=14 --pauli-lower-atol=5e-05 (91,273,861 terms), plus a build_graph[hubbard] rung at --hubbard-trotter-steps=2 with --hubbard-lower-atol=1e-04, not the main grid's 1.25e-05.

cell operation main ms port ms port/main agree
hubbard · A 1x128 propagate[hubbard] 17624.0 17600.3 0.9950 7/10
hubbard · B 8x16 propagate[hubbard] 19194.4 19150.7 0.9974 6/10
hubbard rung · A 1x128 build_graph[hubbard] 3976.1 3917.2 0.9928 6/10
hubbard rung · B 8x16 build_graph[hubbard] 3093.7 3089.3 0.9988 6/10
pauli · A 1x128 build_graph[pauli] 13947.9 14100.9 1.0065 9/10
pauli · A 1x128 propagate[pauli] 11852.8 11755.8 0.9937 5/10
pauli · A 1x128 energy[pauli] 2473.0 2418.7 0.9867 6/10
pauli · A 1x128 gradient[pauli] 11355.2 11172.4 0.9836 10/10
pauli · B 8x16 build_graph[pauli] 13300.9 13270.3 0.9984 8/10
pauli · B 8x16 propagate[pauli] 10585.8 10533.2 0.9962 8/10
pauli · B 8x16 energy[pauli] 2860.2 2860.8 1.0046 7/10
pauli · B 8x16 gradient[pauli] 11583.9 11549.9 0.9988 7/10

Caveats and scope

  • total_bytes() is not comparable across this commit: a build without matched_scratch_bytes reports a total lower by roughly the stamp array while holding the same or more resident memory, so subtract the field or re-measure the baseline.
  • Not reproducible from this diff: Deucalion, 2× AMD EPYC 7742 / 128 cores / SMT off / NPS4 / 242 GiB per node; a private benchmark harness that does not ship; two prebuilt venvs (main 1250a27e…, port 6f14ecb1…).
  • The measured binaries contain one thing this branch no longer does. They were built from a cut that also released init_op_map in get_operator(); that mechanism has since been split out, so get_operator() here is main's. The release was worth 1,189 B (8357 → 7168 B in this campaign's own ledger) against cells of 9.5–40 GiB — order 1e-7, far below the smallest resolved effect — so the figures above stand. Re-gated, not re-measured.

Gates

ctest -L unit 219/219 and -L serial 218/218 green on the ENABLE_PROFILE=OFF build of this tree (87ac7147…), the four Python MPI layouts 1×1, 1×16, 2×8 and 8×16 at 592 passed each, and clang-format --output-replacements-xml 0 replacements on all 7 changed C++/binding files. Rebased onto origin/main 412315e; the four commits since 48cadcb are docs and build config only — zero files under cpp/, src/monoprop/, CMake or compiler flags — and their one build change (nanobind >=2.13.0,<3==2.15.0) is the version both measured arms were already built with, so the numbers stand.

@github-actions github-actions Bot added documentation Improvements or additions to documentation python cpp labels Aug 20, 2026
@github-actions

Copy link
Copy Markdown

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

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.70%. Comparing base (668c90b) to head (0d4df5f).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #259   +/-   ##
=======================================
  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.

@diagonal-hamiltonian
diagonal-hamiltonian changed the base branch from perf/profile-graph-coverage to main August 20, 2026 18:08
@diagonal-hamiltonian
diagonal-hamiltonian force-pushed the perf/epoch-stamp-noshrink-v2 branch from f1f3067 to b01e6fa Compare August 20, 2026 18:08
@diagonal-hamiltonian
diagonal-hamiltonian marked this pull request as draft August 21, 2026 07:20
@diagonal-hamiltonian
diagonal-hamiltonian force-pushed the perf/epoch-stamp-noshrink-v2 branch from b01e6fa to 2be9966 Compare August 21, 2026 08:12
`detail::MatchedEpochSet` carried a `uint32_t` stamp per term for a counter that never leaves the
struct -- never serialised, never exchanged, only ever compared to `cur_`. This narrows it to
`uint16_t`, keeps the wrap discipline explicit, and does NOT shrink the array anywhere.

Two mechanisms:
- the stamp width, with the wrap branch now live (once per 65535 gates) and the `std::fill` the
  thing that keeps it correct -- `epoch_` retains stale stamps for row indices reused after a
  truncation, so deleting the fill aliases marks;
- `matched_scratch_bytes` joins `MPOperatorMemoryBreakdown` inside `total_bytes()`, with
  the stamp array being propagator-owned, so only `MonomialPropagator::operator_memory_usage()` can
  fill it in. The partitioned path sums per-partition breakdowns, so each picks up its own array.

NO `shrink_to_fit()` ANYWHERE, not even an uncalled method. An earlier revision called
`matched_scratch_.shrink_to_fit()` from `initialize_operator_caches_()` on the premise that the array
"grows only when the term count does". That is false on Hubbard: the function runs after EVERY
`build_graph()` and `propagate()`, so 29 Trotter steps reach it 29 times against a term count that
grows at each step. Its A/B read +0.2821 GiB peak RSS (+3.12 B/term), 6/6, non-overlapping, at the
1x16 hubbard cell; removing it read -0.24 GiB, 6/6. And the shrink cannot free resident bytes at
all -- `resize(n, 0)` never writes past `n`, so the capacity it releases was never faulted in. A
retained-but-uncalled method plus a comment asking the next reader not to call it is a trap; the
rejected experiment lives in the PR body instead.

Re-cut twice and rebased onto main: the original was authored on a base carrying the profiling
instrument, then briefly on the graph-memory PR whose key-set test is not on main. The mechanism
never changed across any of it.

It contains no profiling code: monoprop_PROF, profile:: and Profile.h appear zero times in the diff.

MEASURED, campaign pr7v2off, both arms ENABLE_PROFILE=OFF, 12 cells x 10 interleaved reps in one
allocation with order flipped per cell: kernel peak RSS (`/usr/bin/time -v`, node-sum) falls in 16 of
16 memory cells, 14 at unanimous 10/10, 15 of 16 clearing Holm across the memory family at largest
adjusted p = 0.043. Hubbard `propagate` 0.97-0.99x, the hubbard `build_graph` rung 0.975-0.980x,
pauli 0.989-0.999x -- hubbard beating pauli is what a per-term saving must do. Timing is a null: 1 of
24 tests resolved (`gradient[pauli]`, A 1x128, N=1, 0.9836x, 10/10, adjusted p = 0.0469, on an
operation this diff cannot reach), the other 23 inside 0.964-1.014x.

2 B/term is a floor on the ARRAY's saving, not on the peak-RSS delta: peak RSS is a maximum over
time and the stamp array need not be at its own maximum then. Hubbard `propagate` at N=1 implies
0.54 and 1.05 B/term of node-sum delta, below the array's own floor, which says something about WHEN
the peak lands and nothing about the width.

SPLIT: an earlier cut of this branch also released `init_op_map` in `get_operator()`. That is now a
separate change so this PR carries one story, which means the measured binaries contain a release this
branch does not. It was worth 1,189 B (8357 -> 7168 B in this campaign's own ledger) against cells of
9.5-40 GiB -- order 1e-7, far below the smallest resolved effect -- so the figures stand.
Re-gated, not re-measured.

An earlier instrumented pair (both arms ENABLE_PROFILE=ON) agrees on the `build_graph` rung and on
pauli but read hubbard `propagate` at 0.96-0.97x -- it overstated exactly the number being
advertised. The uninstrumented pair above is what certifies the shipping code, and is what is quoted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@diagonal-hamiltonian
diagonal-hamiltonian force-pushed the perf/epoch-stamp-noshrink-v2 branch from 2be9966 to 8dbe2a5 Compare August 21, 2026 08:20
@diagonal-hamiltonian

diagonal-hamiltonian commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 AI text below 🤖

Measured at b01e6fa, which also carried an init_op_map release in get_operator(). That mechanism has since been split out of this PR, so the branch is now 8dbe2a5 and get_operator() matches main; the release was worth 1,189 B against cells of 9.5–40 GiB, so these figures stand. See the body's caveats.

Full timing breakdown — all 24 tests, OFF/OFF

Campaign pr7v2off: main src/mp-main-48cadcb (1250a27e…, 48cadcb) vs port src/pr7v2 (6f14ecb1…, b01e6fa), 10 interleaved reps per cell. port/main is the median of the per-rep paired ratios, not the ratio of the two side medians, so it can disagree in sign with the main ms/port ms columns — propagate[hubbard] at A / N=2 is the one row where it does. raw p is one two-sided sign test in isolation, uncorrected.

cell N operation main ms port ms port/main agree raw p
hubbard · A 1x128 1 propagate[hubbard] 17624.0 17600.3 0.9950 7/10 0.344
hubbard · A 1x128 2 propagate[hubbard] 46373.7 46670.9 0.9996 5/10 1.000
hubbard · B 8x16 1 propagate[hubbard] 19194.4 19150.7 0.9974 6/10 0.754
hubbard · B 8x16 2 propagate[hubbard] 13761.7 13843.4 1.0032 7/10 0.344
hubbard rung · A 1x128 1 build_graph[hubbard] 3976.1 3917.2 0.9928 6/10 0.754
hubbard rung · A 1x128 2 build_graph[hubbard] 8298.2 8312.4 1.0027 6/10 0.754
hubbard rung · B 8x16 1 build_graph[hubbard] 3093.7 3089.3 0.9988 6/10 0.754
hubbard rung · B 8x16 2 build_graph[hubbard] 2935.8 2807.5 0.9643 8/10 0.109
pauli · A 1x128 1 build_graph[pauli] 13947.9 14100.9 1.0065 9/10 0.021
pauli · A 1x128 1 propagate[pauli] 11852.8 11755.8 0.9937 5/10 1.000
pauli · A 1x128 1 energy[pauli] 2473.0 2418.7 0.9867 6/10 0.754
pauli · A 1x128 1 gradient[pauli] 11355.2 11172.4 0.9836 10/10 0.002
pauli · A 1x128 2 build_graph[pauli] 28878.9 28858.4 0.9988 6/10 0.754
pauli · A 1x128 2 propagate[pauli] 23310.2 23467.5 1.0142 7/10 0.344
pauli · A 1x128 2 energy[pauli] 4659.1 4756.0 1.0120 8/10 0.109
pauli · A 1x128 2 gradient[pauli] 15958.2 15720.6 0.9858 6/10 0.754
pauli · B 8x16 1 build_graph[pauli] 13300.9 13270.3 0.9984 8/10 0.109
pauli · B 8x16 1 propagate[pauli] 10585.8 10533.2 0.9962 8/10 0.109
pauli · B 8x16 1 energy[pauli] 2860.2 2860.8 1.0046 7/10 0.344
pauli · B 8x16 1 gradient[pauli] 11583.9 11549.9 0.9988 7/10 0.344
pauli · B 8x16 2 build_graph[pauli] 9248.5 9250.4 1.0007 5/10 1.000
pauli · B 8x16 2 propagate[pauli] 7197.5 7162.5 0.9944 8/10 0.109
pauli · B 8x16 2 energy[pauli] 756.5 760.1 1.0010 5/10 1.000
pauli · B 8x16 2 gradient[pauli] 4612.5 4607.9 0.9996 5/10 1.000

Holm across all 24 leaves one survivor, gradient[pauli] at A 1x128 / N=1, adjusted p = 0.0469. The next smallest raw p — build_graph[pauli] at A 1x128 / N=1, 0.021 at 9/10 — adjusts to 0.494.

Worst-rank peak RSS, for the OOM question

Node-sum is the only cross-layout figure and is what the body tables; worst-rank is what an OOM is decided by. At layout A the two are equal (1 rank/node). The direction holds on worst-rank everywhere except two pauli cells, which reverse: pauli-A-N2 fresh 5.5067 → 5.5141 GiB (1.0000x, 5/10) and pauli-B-N2 fresh 0.9682 → 0.9703 GiB (1.0027x, 7/10). Both are unresolved and neither contradicts the node-sum result, but the body's "16 of 16" is a node-sum claim and does not transfer.

@diagonal-hamiltonian
diagonal-hamiltonian marked this pull request as ready for review August 21, 2026 08:58
@sonarqubecloud

Copy link
Copy Markdown

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

Labels

cpp documentation Improvements or additions to documentation python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant