Skip to content

perf(mpi): ♻️ lease alltoallv staging buffers instead of allocating per call - #233

Closed
diagonal-hamiltonian wants to merge 1 commit into
pr/layer-profilefrom
pr/mpi-lease-staging
Closed

perf(mpi): ♻️ lease alltoallv staging buffers instead of allocating per call#233
diagonal-hamiltonian wants to merge 1 commit into
pr/layer-profilefrom
pr/mpi-lease-staging

Conversation

@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Stacked. Based on pr/layer-profile (#232) so the diff shown here is this change alone. It retargets to main when that one merges.

Summary

begin_alltoallv resized a fresh send buffer every call and copied every per-rank vector into it, and wait_into allocated each per-source block. Both sit inside the exchange timer, once per gate per partition.

Fitting exchange = A + B·bytes across two problem sizes with the collective count held constant isolates a fixed component A = 9.38 partition-seconds — 21.4% of a leg that is itself 68.2% of layer time. Per-call heap traffic is what that component is made of.

Changes

The buffers now come from a per-thread free list. Ownership is the partition master, which is the per-partition scope: each partition runs its whole gate sequence on one pinned master, so capacity carries gate to gate and every buffer stays on the NUMA node whose thread first grew it. The list is typed on the element type, so the query leg can never be handed a bundle belonging to the response leg.

What makes reuse safe is the handle's lifetime, not a caller obligation. Comm.h requires a buffer be unmodified until the verb's second barrier. PendingAlltoallv is now move-only and completes its MPI request in the destructor, before the bundle returns to the pool. Recycling a buffer under a live Ialltoallv is exactly the silent-wrong-answer failure this class of change invites, so it is closed structurally rather than by convention.

Tests

Three reuse tests, all in the shape that actually fails — sizes going large, small, large, so a logical size left at the high-water mark or a counts tail never rewritten shows up. Verified by mutation: dropping the recv_counts clear fails 12 cases; appending instead of assigning in wait_into fails 1.

Not covered: recycling under a live MPI_Ialltoallv, since ShmComm never sets a request. That path needs the multi-rank equivalence run, which is green at 1/2/4 ranks here but is not a targeted test of it.

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)

…er call

begin_alltoallv resized a fresh send buffer every call and copied every per-rank vector
into it, and wait_into allocated each per-source block; both sit inside the exchange timer.
Fitting exchange = A + B*bytes across two rungs with the collective count held constant
isolates a fixed component A = 9.38 partition-seconds, 21.4% of a leg that is itself 68.2%
of layer time, and per-call heap traffic is what that component is made of.

The buffers now come from a per-thread free list. Ownership is the partition master, which
is the per-partition scope: each partition runs its whole gate sequence on one pinned
master, so capacity carries gate to gate and every buffer stays on the NUMA node whose
thread first grew it -- the same reasoning as Scan.h's nz. The list is typed on the element
type, so the query leg can never be handed a bundle belonging to the response leg.

What makes reuse safe against Comm.h's "unmodified until the verb's second barrier"
contract is the handle's lifetime, not a caller obligation: PendingAlltoallv is now
move-only and completes its MPI request in the destructor before the bundle returns to the
pool. Recycling under a live Ialltoallv is exactly the silent-wrong-answer failure this
class of change invites, so it is closed structurally.

Three reuse tests, all in the shape that actually fails -- sizes going large, small, large,
so a logical size left at the high-water mark or a counts tail never rewritten shows up.
Verified by mutation: dropping the recv_counts clear fails 12 cases, appending instead of
assigning in wait_into fails 1. Not covered: recycling under a live MPI_Ialltoallv, since
ShmComm never sets a request -- that path needs the multi-rank equivalence run.

Assisted-by: ClaudeCode:claude-opus-5
@github-actions github-actions Bot added the cpp 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-233.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 (0a66dd7) to head (130a13b).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@                Coverage Diff                @@
##           pr/layer-profile     #233   +/-   ##
=================================================
  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

cpp test-in-draft Run CI even in Draft mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant