Skip to content

perf(mpi): ⚡ route every term through one GF(2)-linear router - #351

Draft
diagonal-hamiltonian wants to merge 6 commits into
perf/stack-1-chunked-storefrom
perf/stack-2-router
Draft

diagonal-hamiltonian wants to merge 6 commits into
perf/stack-1-chunked-storefrom
perf/stack-2-router

Conversation

@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Summary

Rank routing was duplicated inline at more than one call site. This PR consolidates it into a
single routing::Router (detail/mpi/Routing.h) with one default scheme: a GF(2)-linear
term→slot map, resolved per call from a fingerprint_positions fold of the monomial's positions.
monoprop_ROUTING=splitmix stays a live fallback (monomial_hash % (R × S)) for a rank count that
is not a power of two, where the linear scheme has no XOR structure to use; under the new default,
such a rank count now throws instead of silently routing.

EnvConfig.h centralizes the knobs the engine reads at runtime (ROUTING, ROUTE_SEED,
COMMPROF, NUM_THREADS, PARTITIONS — deliberately not MEMPROF/TIMEPROF, which stay local
diagnostics per the owner's decision and do not land in this stack). The router is resolved per
call (build_layer, propagator construction, update_initial_operator, the coverage report)
rather than held as a MonomialPropagator member, so downstream signatures are unchanged and PR 3
and PR 4's diffs against this PR stay small.

This is PR 2 of 7, based on perf/stack-1-chunked-store. The router is neutral at R=1 and the
tip of every A/B row below confirms it. The next PR (perf/stack-3-sparse-exchange) uses the
router's peer geometry to narrow the exchange to reachable peers.

Changes

Engine

  • cpp/monoprop/detail/EnvConfig.h (new, 140 lines): parses the routing/thread/partition knobs
    in one place.
  • cpp/monoprop/detail/mpi/Routing.h (new, 279 lines): Router, linear_hash,
    fingerprint_positions, gf2_rank, UnroutableGeometry.
  • cpp/monoprop/detail/mpi/MPIUtils.h: find_rank(mono, Router), router_for,
    check_routing_agreement (a collective that confirms every rank resolved the same geometry).
  • cpp/monoprop/detail/mpi/MPICompat.h/.cpp: geometry export used by the agreement check.
  • cpp/monoprop/detail/evolution/layer_build/Engine.h, Scan.h: destination calls take the
    Router; Scan.h takes it in place of a bare rank count (rank_count = router.flat_world()).
  • cpp/include/monoprop/MonomialPropagator.h/.inl: seeds and resolves the router, reports
    routing coverage; c310423 skips the scan and exchange outright for an identity generator.

Tests

  • cpp/tests/env_config_tests.cpp (new), routing_tests.cpp (new, 429 lines: the linear/splitmix
    equivalence and the non-power-of-two refusal).
  • cpp/tests/mpi_utils_tests.cpp: extended to both routers, plus a check_routing_agreement case
    exercised by the mpiexec -n 2 ctest variant.
  • tests/test_parameter_validation.py: test_schrodinger_cutoff_error_survives_several_partitions
    (the flake-fix regression test, see Notes below).

Docs

  • docs/content/docs/features/parallelism.mdx: "### Rank routing" section and env table rows.
  • docs/bibliography.bib, docs/content/docs/references.mdx: the routing citations.
  • AGENTS.md: one Notes bullet documenting the splitmix fallback and its refusal behavior.

Measurements

Gated multiset (not positional) vs origin/main c5e88c8's raw coefficient bits, because routing
moves the row → rank assignment at R > 1, which moves the raw dump's positional layout even
though it changes no value and no term set. gate record md5
33275f8b1563c2b646c6d11f5aba7779 (rebased onto PR 1 at 19bd012).

3 interleaved reps, ratios only:

rung time st2/mainB time st1/mainB (predecessor) peak st2/mainB peak st1/mainB (predecessor)
L1-hubbard 1.003 1.001 0.885 0.886
L1-pauli 0.999 0.994 0.870 0.885
M2a-hubbard 0.988 0.988 0.959 0.960

All three rungs run at R=1, where the router is a no-op by design; st2 is within noise of st1 on
every row (time and peak both), which is the expected result for this PR — it is neutral until a
later level exercises R>1.

Notes for reviewers

  • Non-power-of-two rank counts now throw under the default (linear) routing —
    "linear routing needs a power-of-two rank count, got 3 ..." — where main previously routed
    silently under its inline scheme. This is deliberate and documented (parallelism.mdx,
    AGENTS.md); monoprop_ROUTING=splitmix is the escape and is exercised at n=3 in
    --with-mpi pytest.
  • EnvConfig parses comm_profile/parse_bool, but nothing reads it yet — its consumer,
    report_comm_profile_, is PR 5's — so monoprop_COMMPROF is deliberately not documented in
    parallelism.mdx until then.
  • routing_tests.cpp does not carry SlotWindow/PeerPlan/WindowVec cases; those types land
    with PR 3's Comm.h.
  • CI, disclosed flake: the first post-rebase n=4 --with-mpi pytest run failed with one rank
    exit 1. Root cause: check_routing_agreement sat near the top of the constructor, before the
    schrodinger_cutoff ceiling check, so one partition could unwind its throw while peers were still
    inside the agreement allreduce, poisoning ShmComm and reporting "ShmComm poisoned" instead of
    the real validation error. Fixed in 19bd012 by moving the agreement collective to the
    constructor's last act, after every deterministic validation; the contract is unchanged (it still
    fires before the first exchange, and seeding communicates nothing, so a rank with a divergent
    router has only claimed the wrong share by the time it throws). Verified with 10/10 clean
    mpiexec -n 4 --with-mpi pytest runs after the fix, against 1 failure in 9 before it.
  • No new ledger keys in this PR.

Checklist

  • Tests added or updated to cover the changes
  • Documentation updated (docstrings, docs/, CONTRIBUTING.md) if needed
  • CHANGELOG / release notes updated if applicable (n/a — the repository has no CHANGELOG)

AI/LLM disclosure

  • I did not use LLM tooling, or used it only privately for ideation
  • I used the following tool to help write this PR description: Claude Code (claude-sonnet-5)
  • I used the following tool to generate or modify code: Claude Code (claude-opus-5)

Important

By opening this PR I confirm that I have read CONTRIBUTING.md and I agree to the terms of the Contributor License Agreement.

Warning

If you're contributing on behalf of your employer, contact cla@algorithmiq.fi to arrange a Corporate CLA.

monoprop_ROUTING and monoprop_ROUTE_SEED select and seed the rank routing
(detail/mpi/Routing.h, next commit); monoprop_COMMPROF switches the exchange's
report line. All three go through EnvConfig so the environment is parsed once
per process and read the same way everywhere.

Invariant: these three THROW on a malformed value instead of falling back.
Each silently changes the transport or what is reported, so a typo that
defaulted would stay invisible until a postmortem -- unlike
monoprop_NUM_THREADS, where ignoring a bad value only loses a hint.

Assisted-by: ClaudeCode:claude-opus-5
… ranks

Routing was two inline copies of `monomial_hash(M) % P` -- one in Scan.h's query
emission, one in find_rank -- which have to agree exactly or ownership splits
silently. Both now go through routing::Router::dest and nothing else; the
rank-count overload of find_rank is deleted rather than kept, because it would
answer splitmix during a linear run, which is that split.

The reason for the abstraction is that splitmix's full avalanche is what makes
the exchange dense: a gate maps M to M^G, so with an avalanching owner function
a rank's queries for ONE generator spray across all R ranks and the message
count grows as R*(R-1). Taking the rank index from a GF(2)-linear function of
the support instead gives h(M^G) = h(M) ^ h(G), so every query a rank emits for
one generator lands on one peer. Nothing exploits that yet -- the transport is
still the dense pair -- this makes the structure exist and proves it is free.

Two levels, because they cost differently: across ranks the message count is
what hurts, within a rank partitions talk through shared memory where fanout is
free and only balance matters. rank = fp & (R-1); part = the next log2(S) bits
of fp when S is 2^k, else mix64(fp) % S.

Invariants:
  * linear or not is a switch, not a dial: the rank takes every bit of the
    linear hash or none of them. None of them is splitmix, bit-for-bit the map
    this commit replaces, and R == 1 is that case by construction -- so a
    single-rank run is unchanged, and so is any run under
    monoprop_ROUTING=splitmix.
  * R > 1 under linear routing must be a power of two, or the geometry is
    REJECTED (UnroutableGeometry) at construction. A fallback taken on some
    ranks only is a deadlocked exchange, not a wrong answer.
  * check_routing_agreement lands in this commit, not a later one, for the same
    reason: a knob that did not reach one rank deadlocks the first exchange, so
    it is converted into an exception by two allreduces at construction. Two
    independent digests, because allreduce_sum is the only collective in the
    tree and a sum is not an equality test.

At R > 1 with linear routing a term's owning rank changes, so per-term values
are identical and the row layout differs; term counts, being
topology-independent, do not move.

mpi::geometry() is new because size() alone cannot tell an inter-rank message (a
network hop) from an inter-partition one (a memcpy), and the split is exactly
what the routing needs. It is exported: public template chains reach it from
headers across the hidden-visibility boundary.

Assisted-by: ClaudeCode:claude-opus-5
Linear routing rests on one condition: the per-generator rank shifts must span
log2(R) dimensions over GF(2). If they span only rho < log2(R), the ranks a
query can reach are a coset of a rho-dimensional subspace and R - 2^rho ranks
receive nothing at all.

That is a load-balance failure and not a wrong answer -- every term still lands
on exactly one owner -- so it is a stderr warning (COMMROUTE, one greppable
rank-identified line) and not a throw, and it sits at the head of the gate loop
rather than beside check_routing_agreement, because at construction the gate
list does not exist yet. One line per rank per propagator: only partition 0
speaks, and only once whatever the outcome.

Measured on the production point it holds with room to spare: rank 32 over the
60-site Hubbard's 416 distinct shifts, against the 7 bits R = 128 needs.

Assisted-by: ClaudeCode:claude-opus-5
…nerator

An empty Majorana generator anticommutes with nothing, so the scan returns on
its empty fold-column set having produced no query, no cosine block and no swept
coefficient -- work done to discover it was not needed -- and run_exchange's
three collectives per pass fire on payload size zero all the same, two passes
per gate.

These generators are not an edge case: a gate whose every term falls below its
atol expands to the identity monomial, and a zero chemical potential alone
contributes 60 of the 60-site Hubbard's 476 generators per Trotter layer.

`gen.any()` is unanimous -- the generator list is replicated on every rank -- so
skipping needs no agreement and no collective to decide it. This is not gate
fusion: no gate is merged, a no-op gate is simply not exchanged for, and the
layer is still built, so the graph's gate and parameter bookkeeping is
untouched. finish() reads neither the query nor the response streams, and an
untouched CosineWordBuilder finishes to exactly the empty CosMask the skipped
scan would have produced, so the skipped path lands in the state the
empty-payload exchange left it in -- bit-identically.

Assisted-by: ClaudeCode:claude-opus-5
The routing change flips a shipped default and adds two environment variables,
none of which appeared anywhere in docs/. The rank hash is a homomorphism of the
group the gate acts by, so state the identity, what follows from it (fanout, the
involution, cosets of the kernel), and the one condition it rests on -- that the
generator shifts span the rank space, which is a load-balance property and not a
correctness one.

Distinguishes the scheme from the block-sum-mod-N map of [@Broers2025-or]: that
sum is additive modulo the rank count while the gate acts by XOR, so carries
leave the destination dependent on the operand's bits and only bound the fanout.

Assisted-by: ClaudeCode:claude-opus-5
check_routing_agreement was called near the top of the constructor, before the
schrodinger_cutoff ceiling check. Every partition rejects that setting, so one
could be unwinding its own throw while its peers were still inside the
agreement collective: ShmComm poisons on a peer throwing inside a collective,
and the caller was then told "ShmComm poisoned: a peer partition threw during a
collective" instead of which setting was wrong. Intermittent, ~1 run in 9 of
the Python suite, and only with more than one partition -- the propagator built
on MPI.COMM_SELF takes one partition per core, so it is the default path on a
16-core box.

The check now runs as the constructor's last act, after every deterministic
validation. It is still before the first exchange, which is all its contract
needs: seeding communicates nothing, so a rank that resolved a different router
has merely claimed the wrong share of the initial terms by the time this throws.

Invariant, and the reason for the comment at the call site: a collective in the
constructor must follow every validation the constructor can fail on.

The regression test constructs at four in-process partitions and asserts the
message, eight times over: the ordering is a race, so one attempt is not
evidence.

Assisted-by: ClaudeCode:claude-opus-5
@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

Copy link
Copy Markdown

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

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files
@@                     Coverage Diff                     @@
##           perf/stack-1-chunked-store     #351   +/-   ##
===========================================================
  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

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