feat(mutual): rungs on the distributed mesh, and a block-step entry point for it - #252
Conversation
Track C's library half. `mutual/distributed.py` had no rung vocabulary at all -- not one occurrence of `rung`, `level_weights` or `k_max` -- so block-step individual timesteps were single-device only. It now takes the same `rung`/`level_weights` pair `mutual/force.py::mutual_weighted_accelerations` does, on both halves of every pair. The near half's predicate is exact and per-particle, so the remote endpoint's rung has to travel WITH the remote endpoint: it rides round B of the demand-driven halo import (`import_near_halo(payload_sorted=...)`, needs TobiBu/yggdrax#53), sized by the halo. It cannot ride the coarse frontier, which is `all_gather`-ed -- `leaf_width` rung columns there would be `O(N_total)` traffic and defeat the LET. The weight then multiplies `inv3`, the one tensor both sides of the tile read, so a weighted cross block is antisymmetric for the same reason an unweighted one is. The far half is cell-level, so the frontier IS the right channel: one scalar per leaf, appended to the multipole row it belongs to and read from the END of that row, so the split cannot drift away from `_far_payload`. The local endpoint may be an internal node -- `accept_only_leaf_pairs` constrains the remote side, which has to be addressable -- so `_node_rungs` gives every node its rung. It gets there as a range maximum expressed as one prefix count per level, because this module has only the inclusive node ranges and not the level schedule `force._cell_rungs` walks; `k_max` is small, so that is `O(n * k_max)` with no parent array and no propagation scan. The far weight is applied ONCE, on the evaluating device, to both directions of the batched M2L, before either leaves. That is legal because everything downstream of an expansion is linear in its coefficients -- L2L re-centres, L2P evaluates -- so weighting the expansion is weighting the pair. And it matters: applying it again on import would square it, and applying it on neither side would leave the far half unweighted while the near half is weighted. Also here, because they are the same file's changes: * `backend` and `pallas_interpret` on the config, and `use_pallas` finally passed to `build_mutual_state_device`, which has accepted it all along while the driver never passed it -- so this lane ran pure JAX whatever the caller asked for. Routing mirrors the single-device lane exactly, including its two measured decisions: the far field stays pure JAX (both Pallas M2L shapes are slower) and every Pallas lane goes through its `custom_vjp` wrapper. * `make_distributed_mutual_evaluator`, which partitions and compiles ONCE. `shard_map` wraps a fresh closure per call, so `jax.jit` sees a fresh cache key and `distributed_mutual_fmm` recompiles every time -- fine for one force, ruinous for the `n_sub + 1` evaluations a base step asks for. Measured on 2 forced CPU devices at N = 128: build < 0.1 s, first evaluation 20.5 s, each later one ~8 s. `distributed_mutual_fmm` is now that plus one call, unchanged in behaviour. * The readout is a `jnp` scatter and the "every real particle appears exactly once" check moved to the partition, where it belongs -- it depends only on the frozen gid layout. That is what makes a whole evaluation traceable, which is what lets nornax's `block_kdk_rollout` drive this lane. The overflow read is attempted rather than gated on `isinstance(..., Tracer)`, the same discipline `BlockStepFMM._validate_rung` uses and for the same reason: a concrete array closed over by a scan body is not a Tracer yet still cannot be read. * `cross_far_pairs` on both result types, because a test of the cross far field is vacuous without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`DistributedBlockStepFMM` is track C's user-facing half: `BlockStepFMM`'s contract, backed by the distributed mutual lane. It satisfies nornax's `MutualForceModel` and `FusedMutualForceModel` structurally, without importing nornax, so the dependency graph stays acyclic -- and is exported from `jaccpot.__all__` so ODISSEO can import it directly. `prepare()` freezes the partition, the padding layout, the tree bounds, the capacities and the compiled program; `level_accelerations`, `total_accelerations`, `boundary_kick`, `boundary_kick_at` and `advance_base_step` then run on live inputs against them. What it freezes is NOT the topology, and the docstring says so rather than implying otherwise. On one device `prepare` builds the accepted pair lists and every boundary reuses them; here the per-device tree is rebuilt inside the mapped program from the positions handed in, so successive boundaries see trees built from their own positions. That is a finer rebuild cadence, not a coarser one, and nothing about legality changes -- every evaluation is internally self-consistent, so its levels partition its own pairs and each level's momentum cancels exactly. The consequence worth stating is the other way round: `prepare` does not have to be called once per base step, only when the partition should change. `traced_boundary_weights = True` is declared explicitly rather than left to nornax's signature probe. The case is stronger on this lane than on one device: each boundary kick is a whole distributed program -- tree build, cross walk, halo exchange, reverse halo -- and under an outer `jit` or `lax.scan` over base steps the cached executable is inlined anyway, so unrolling would put `2**k_max` copies of it in one graph. Two deliberate differences from the single-device class: * It RAISES on a starved capacity instead of reporting it. An overflow drops a canonical pair, which drops both its halves, so the global momentum sum stays exact and no norm on the result reveals it. Reported is not good enough for a force an integrator will step with. The check is skipped under trace, where an exception is not available -- so a traced driver must evaluate once eagerly first. * There is no `rebuild_state` and no `advance_base_step(scan_boundaries=True)`. Every method is eager at its own level; the boundary loop is always the unrolled Python one, which costs nothing here because the mapped program is compiled once and every boundary reuses it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cannot see
The acceptance criteria for track C, and the reason there are four of them rather
than one.
Momentum is what the block-step scheme is DEFINED by, so it is the obvious
criterion -- and it is structurally blind to every way the level split can be
wrong. Any single symmetric scalar per pair conserves momentum exactly, whatever
that scalar is. Measured by injecting each fault into the cross FAR half and
reading three criteria off a 2-device run at N = 256, leaf 4,
theta = cross_theta = 0.5 (33 cross far pairs, 991 cross near pairs):
fault partition linearity momentum
none 2.1e-16 2.9e-16 <4e-17
far half left UNWEIGHTED 3.9e-02 2.0e-02 <4e-17
far weight applied TWICE 2.1e-16 9.4e-03 <4e-17
far ignores the REMOTE rung 2.1e-16 2.9e-16 <4e-17
So:
* `test_the_levels_partition_the_total[_with_both_far_fields_on]` -- one-hot
weights must sum to the unweighted total. Catches a half left unweighted. It
CANNOT catch squaring: `w**2 == w` for 0 and 1.
* `test_the_weighting_is_linear_so_it_cannot_have_been_applied_twice` and its
far-field twin -- `a(u) + a(1 - u) == a(1)` at fractional rows, none of them 0
or 1. This is the squaring detector, and it is a separate test for that reason.
* `test_the_cross_far_level_uses_the_REMOTE_endpoints_rung` -- the fourth fault
gets past all three above, because a level assignment that is wrong yet
CONSISTENT is still one scalar per pair. Two rung-uniform clumps, one per
device, so every cross pair provably belongs to one level and clump A's level-0
force is an exact direct sum. Injected as `f_level = node_rung[f_local]` it
moves 1.2e-3 against a 1e-13 bound while the other three stay at round-off.
`cross_pairs == 0` is asserted separately because it establishes the premise --
a partition that split a clump would leave nearby particles on opposite
devices, and they would refine to near pairs.
* Per-level momentum, on a GLOBAL sum, with `cross_far_pairs > 0` asserted so it
is not the exact lane wearing a different configuration.
Also: one-hot against an exact direct-sum oracle at theta = cross_theta = 0,
where the whole force is a direct sum and the oracle exists; the guards on the
way in (a weight table with no rung, a rung above `k_max`, a table disagreeing
with `k_max`, an unknown backend); Pallas parity and a WEIGHTED Pallas test,
since `level_weights[k]` is exactly the lowering that broke before and the
throughput benchmark runs unweighted.
`tests/unit/mutual/test_distributed_node_rungs.py` checks `_node_rungs` against a
loop oracle and against `force._cell_rungs` on a real tree -- the two lanes must
split the far field the same way or a system's levels stop partitioning it the
moment it is spread across devices. Its vacuity guards earned their place: a free
random rung draw at leaf 8 with three levels puts a level-2 particle in every
leaf, so every cell rung is 2 and the comparison would hold for a function that
ignored its inputs.
`tests/integration/test_mutual_distributed_nornax.py` is the C3 criterion:
nornax's own scanned `block_kdk_rollout` over >= 2 devices, multi-rung, with
momentum (exact, 1.8e-17) and energy (loose, and it fails independently -- an
unstable rollout conserves momentum perfectly). Plus jaccpot's own
`advance_base_step` against nornax's, since they are separate implementations of
the same palindrome.
The rung fixtures share one evaluator rather than calling
`distributed_mutual_fmm` per weighting. That is a 4x test-time win, and it is
also the claim under test: only shapes are static, so ONE compiled program must
serve every weight row and every rung assignment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things the design document did not cover, now that the distributed lane does them: which channel each rung travels on and why the obvious one is wrong (the frontier is all_gather-ed, so a per-particle payload there is O(N_total)); why the far half's weight is applied once on the evaluating side, and why that is legal (everything downstream of an expansion is linear in its coefficients); the backend routing and the two measured decisions it mirrors; and the partition-frozen-but-tree-rebuilt distinction, with the measured compile numbers. The table of injected faults is the part worth keeping. Three of them leave the per-level momentum residual below 4e-17, so a residual at 1e-17 is not evidence the levels are right -- which is the opposite of how this lane's own tests read before track C. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The near half's rung rides `import_near_halo(payload_sorted=...)`, which lands in TobiBu/yggdrax#53. Against an older yggdrax that is a `TypeError` raised deep inside a `shard_map` trace, on every weighted test -- so both modules would fail rather than skip, which is the outcome this file's existing guard exists to prevent and has been caught not doing once before. `test_mutual_distributed.py`'s guard now checks the parameter on the halo import as well as the two on the cross walk, keeping its own rule: check EVERY parameter this module actually passes, and name the PR each one came from, because "needs a newer yggdrax" is not actionable. `test_mutual_distributed_nornax.py` gets its own rather than inheriting one. It drives the weighted lane exclusively, so every test in it depends on the newest name; only that one is checked, since the three landed in order and the older two are a prerequisite of this lane existing at all. Verified by running both modules against the OLD yggdrax checkout, which lacks the parameter: 2 skipped, each naming payload_sorted and #53. A guard that has not been seen to fire is decoration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as vacuity Two independent ways `test-distributed-mutual` went red on this branch, one of which was hiding the other. **Exit 5.** The module-level guard for `payload_sorted` fired -- CI installs yggdrax from its default branch, which does not have it until TobiBu/yggdrax#53 -- so the whole file skipped, and in a job that runs ONE file that is pytest's exit 5, "no tests were collected". Under `set -o pipefail` that fails the pytest step, so the NEXT step, the one that exists to say "the suite was skipped, not run" with an actionable message, never ran. The step now tolerates exit 5 specifically and still fails on everything else. **The requirement was too wide.** `payload_sorted` carries a cross-domain near pair's REMOTE rung, so only the rung-weighted path needs it -- but it was passed as `payload_sorted=None` on the unweighted path too, which still requires the parameter to exist. Passed conditionally instead, so the UNWEIGHTED lane runs against the published yggdrax and the guard becomes per-test. Against the old checkout the file now runs its pre-existing criteria and skips only the weighted ones, rather than skipping wholesale and taking every one of them down with it -- which is the vacuous-green state this job was added to prevent, arrived at from the other direction. **And a skip is no longer read as vacuity.** Every skip in this file used to be a whole-module one, an old yggdrax or fewer than two devices, so "any skip" and "nothing ran" were the same thing and `^SKIPPED` was exact. They are not the same any more: the file now carries per-test skips that are CORRECT on a CPU runner -- a kernel needing sm_80, and the level-weighted path above. Left as it was, this job would have gone red again the moment yggdrax#53 merged, over a test that could not have run anywhere on this box. It now matches nothing-collected or one of the two whole-module guards firing, both of whose reasons say "the distributed mutual force needs". That is a deliberate loosening and worth stating: a future change that skipped most of the suite for some other reason would not be caught, where `^SKIPPED` would have caught it. Table-tested the five cases that matter -- old yggdrax, single device, no tests ran, an sm_80 skip alone, and today's mixed state -- and the first three fail while the last two pass. Verified: 68 passed, 1 skipped (the sm_80 case, green on 2x A100) against the new yggdrax on 2 forced CPU devices; 4 passed, 1 skipped on the fast subset against the OLD yggdrax, where the previous guard gave 0 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Update: CI on this branch, and a scope fix it forced
Exit 5. The module-level guard for The requirement was too wide, and that is the substantive fix. A skip is no longer read as vacuity. Every skip in this file used to be a That is a deliberate loosening, and worth flagging rather than burying: a future change
Re-verified after the change
Two caveats on CI coverage, neither new
|
…export Two repo-wide guards this branch tripped, both correctly, and both in `tests/unit` rather than anywhere near the distributed lane -- which stayed green throughout. `test_public_api_surface.py::test_all_exports_are_frozen`: adding `DistributedBlockStepFMM` to `jaccpot.__all__` is a public-API change, and that test's whole purpose is to make one impossible to do silently. Its docstring prescribes the fix -- update the frozen set in the SAME change, so the contract gets a conscious review -- so `EXPECTED_ALL` and the class-kind list gain the name. ODISSEO imports from this surface, which is why it is frozen at all. `test_type_annotation_guard.py`: `DistributedMutualEvaluator.layout`, `.rung_layout` and the `build` closure were unannotated, so runtime type-checking would have had holes exactly where a caller hands in positions, masses and rungs. Annotated, along with `_program`, `__call__` and `__init__` on the same class while in there. Neither was caught locally because the verification runs were targeted subsets -- `tests/unit/mutual/`, the subdirectory the new unit test lives in -- instead of the plan's `pytest -q`. The substitution was made because each distributed compile costs ~25 s on a box at load ~70, and it was a bad trade: these two guards run in 1.3 s together and exist precisely to catch a new public export and a new unannotated callable, which is what this branch is. Verified this time over the whole of `tests/unit` plus the golden `tests/characterization`: exit 0, no failures, and characterization unmoved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Track C of
docs/plan_2026-08_C_rungs.md, from a freshorigin/main(a07528a).Needs TobiBu/yggdrax#53 first — the exact per-particle near-field predicate needs the
remote endpoint's rung, which has to ride the demand-driven halo import.
What landed
rung/level_weightsthrough theshard_mapbody and the driver,k_maxon the configmutual/distributed.pyDistributedBlockStepFMM, plusmake_distributed_mutual_evaluatornornax_adapter.pybackend/pallas_interpret,use_pallasactually threadedmutual/distributed.pyBefore this,
mutual/distributed.pyhad zero occurrences ofrung,level_weightsork_max, so block-step individual timesteps were single-device only.C2 is the part that was not plumbing, and it needed a fourth test
The plan says a momentum test alone will not catch a weight applied to neither side. It
is worse than that. I injected each fault and measured three criteria — 2 devices,
N = 256, leaf 4, θ = cross_θ = 0.5, 33 cross far pairs, 991 cross near pairs:
Momentum sees none of the three. A per-level residual at 1e-17 is not evidence the
levels are right — any single symmetric scalar per pair conserves momentum exactly,
whatever that scalar is. So:
cannot catch squaring:
w² == wfor 0 and 1;a(u) + a(1−u) == a(1)at fractional rows) catches squaring —hence a separate test, with rows deliberately none of them 0 or 1;
that is wrong yet consistent is still one scalar per pair, so it passes partition,
linearity and momentum. It needed
test_the_cross_far_level_uses_the_REMOTE_endpoints_rung: two rung-uniform clumps,one per device, so every cross pair provably belongs to one level and clump A's
level-0 force is an exact direct sum. Injected as
f_level = node_rung[f_local]itmoves 1.2e-3 against a 1e-13 bound while the other three stay at round-off.
Structurally, the weight is applied once, on the evaluating device, to both directions
of the batched M2L, before either leaves. Legal because everything downstream of an
expansion is linear in its coefficients (L2L re-centres, L2P evaluates), so weighting
the expansion is weighting the pair. The far payload's rung is read from the end of
the row, so the multipole width is stated once — indexing it at a computed
sh_size(order)offset would state it twice, and a mismatch would read a coefficientas a rung, which survives the clamp as a perfectly valid pair level.
On the plan's second C2 concern — a cell straddling a boundary getting the same rung on
both devices — it holds by construction and is now asserted rather than assumed: a far
pair's remote endpoint is a coarse leaf holding exactly one frontier node
(
accept_only_leaf_pairs+ thedegeneratecontract check), so each endpoint's rung iscomputed exactly once, by the device that owns the particles, and never recomputed
across the wire.
Which channel each rung travels on
multipole row.
Per-particle rungs could not go on the frontier: it is
all_gather-ed, soleaf_widthcolumns there would beO(N_total)— invisible atcross_theta = 0wherethe import fetches everything anyway, and a hard cap the moment θ collapses the import
to a surface halo.
_node_rungsgives every node its cell rung as a range maximum expressed as oneprefix count per level — this module has only the inclusive node ranges, not the level
schedule
force._cell_rungswalks.k_maxis small, so it isO(n·k_max)with noparent array and no propagation scan, and
tests/unit/mutual/test_distributed_node_rungs.pychecks it against both a loop oracleand
_cell_rungson a real tree.C3 reaches the plan's literal criterion, and needed a traceable readout
block_kdk_rolloutwalks its base steps with alax.scan, so it needs a force it cantrace. The driver assembled its result with a NumPy scatter and raised on a
host-read overflow flag — neither survives a trace. Both moved:
jnpscatter;where it belongs — it depends only on the frozen gid layout, not on any evaluation;
isinstance(..., Tracer), thediscipline
BlockStepFMM._validate_rungdocuments: a concrete array closed over by ascan body is not a
Traceryet still cannot be read.Result: nornax's own scanned
block_kdk_rollout, 2 devices, multi-rung, 2 base steps— momentum drift 1.8e-17, energy bounded, and jaccpot's own
advance_base_stepmatching nornax's to 1e-12.
And a compile that was being paid per force
shard_mapwraps a fresh closure per call, sojax.jitsees a fresh cache key anddistributed_mutual_fmmrecompiles every time — fine for one force, ruinous for then_sub + 1evaluations a base step asks for.make_distributed_mutual_evaluatorpartitions and compiles once. Measured, 2 forced CPU devices, N = 128:
distributed_mutual_fmmis now that plus one call, behaviour unchanged.What
prepare()freezes is not the topology, and the docstring says so rather thanimplying otherwise: the partition, padding layout, bounds, capacities and compiled
program are frozen; the per-device tree is rebuilt inside the program from the positions
handed in. That is a finer rebuild cadence than the single-device lane's, not a
coarser one, and legality is unchanged — every evaluation is internally self-consistent,
so its levels partition its own pairs and each level's momentum cancels exactly. The
consequence runs the other way:
prepareneed not be called once per base step, onlywhen the partition should change.
C4
build_mutual_state_devicehas accepteduse_pallasall along and the driver neverpassed it, so this lane ran pure JAX whatever the caller asked. Routing now mirrors the
single-device lane exactly, including its two measured decisions: the far field stays
pure JAX (both Pallas M2L shapes are slower), and every Pallas lane goes through its
custom_vjpwrapper, never the bare kernel. The cross-domain near field is pure JAXeither way — a different kernel (
_tile_forces), and a Pallas lane for it is a newkernel, not a wiring change.
Per the plan's trap list, the weighted path has its own Pallas test:
level_weights[k]is exactly the lowering that broke before, and the throughput benchmark runs unweighted.
interpret ∈ {True, False}is kept parametrized — theinterpret=Falsecase is theonly thing standing between a lowering regression and a broken
backend="pallas", andit skips off sm_80.
Verification
test_mutual_distributed.py+tests/unit/mutual/test_mutual_distributed_nornax.pyinterpret=Falsecase CPU must skip, + the rung subsettest_mutual_fmm.py,test_mutual_fmm_nornax.py,test_custom_vjp_parity.pyThe single-device row is the regression check, and it is insurance rather than a gate:
the diff does not touch
mutual/force.py,nearfield.py,farfield.py,device_topology.pyortopology.py, and the only deleted line innornax_adapter.pyis its__all__, soBlockStepFMMis untouched byconstruction. Run anyway, because "additive by inspection" is a claim.
The one skip on CPU is
test_the_pallas_backend_matches_the_jax_backend[False]--the real Triton lowering, which needs sm_80. It is green on 2x A100, which
matters more than the rest of the C4 row: interpret mode validates the kernel's
LOGIC, not its lowerability, and per this repo's own record two mutual kernels
passed every CPU interpret test and then failed on their first GPU run.
Wall-clock is long because the box was at load ~70 on 64 cores throughout, not
because of anything here.
CI will SKIP these tests until yggdrax#53 merges
Deliberately, and verified. CI installs yggdrax from its default branch, so until #53
lands
payload_sorteddoes not exist there -- and that is aTypeErrorraised deepinside a
shard_maptrace on every weighted test. Both modules would go red ratherthan skip, which is exactly the failure
test_mutual_distributed.py's own docstringrecords having been caught not preventing once before. So its guard now checks the
halo import's parameter alongside the two on the cross walk, and the nornax module
gets its own. Checked by running both against the OLD yggdrax checkout: 2 skipped,
each naming
payload_sortedand #53. A guard nobody has seen fire is decoration.pre-commit run --all-files(black, isort, pydoclint, flake8) green over the tree.Why one PR
C1–C4 are interdependent: C1/C2's tests drive C3's evaluator (a 4× test-time win, and
also the claim under test — only shapes are static, so one compiled program must serve
every weight row and every rung assignment), and C4's threading lives inside the
function C3's refactor created. Reviewable commit by commit; the four commits are split
by concern.
Not in scope, flagged for track E
mutual/distributed.py's module docstring is stale and predates this PR: "The onesimplification, stated plainly: cross-domain interactions are EXACT. The cross walk is
driven with
theta = 0" — untrue since thecross_thetalift (#208). Left alonebecause track E owns module docstrings; the parameter-level docs it contradicts are
already correct.
🤖 Generated with Claude Code