Skip to content

feat(m2l): target-tiled CSR Pallas M2L kernel with on-chip rotations (opt-in lane) - #340

Merged
TobiBu merged 15 commits into
perf/small-leavesfrom
perf/small-leaves-csr-m2l
Sep 10, 2026
Merged

feat(m2l): target-tiled CSR Pallas M2L kernel with on-chip rotations (opt-in lane)#340
TobiBu merged 15 commits into
perf/small-leavesfrom
perf/small-leaves-csr-m2l

Conversation

@TobiBu

@TobiBu TobiBu commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #339 (base = perf/small-leaves); retarget to main once #339 merges. Record: docs/small_leaves_2026-09.md.

The kernel (jaccpot/pallas/m2l_real_csr.py): one Pallas program per target node loops over its far-pair segment of a target-sorted source list (CSR built in the wrapper with one stable argsort, padding and active_pair_count honoured) and builds rotate -> z-translate -> rotate-back on chip from the two alignment angles, in the centred (degree, m) layout: Dz(t) = diag(cos|m|t) + A sin(|m|t) with a constant antisymmetric pattern, the constant B_U stack per degree, and a degree-only z-core ((n+k)! matrix, rinv^(n+1)/rinv^k as exp(deg*log rinv)) because the z-translation preserves m. Nothing per pair touches HBM except one multipole row; the program owns its output row, so the chunked lane's per-chunk argsort + segment_sum scatter are gone. Forward only; the pure-JAX lane stays the differentiable path.

Wiring: an opt-in fifth flat lane in _solidfmm_downward_accumulate_from_multipoles behind JACCPOT_STATIC_STRICT_FUSED_M2L_CSR=1 (Ampere+; JACCPOT_M2L_CSR_INTERPRET=1 for CPU parity). Default off, so nothing changes without the flag.

Measured (idle A100, bench/m2l_csr_microbench.py):

pairs p CSR ns/pair pure-JAX chunked degree-batched
1M 4 19.8 69.4 55.6
6M 4 12.1 68.9 55.3
1M 6 12.4 126.1 86.2
6M 6 12.0 124.9 85.6

In strict_run_v2 at N=200k Plummer, theta 0.6, p=4 the kernel costs 12.9 ms per step at leaf 64 where the pure-JAX M2L cost ~360; per step 236.5 -> 176.7 (leaf 64), 145.2 -> 122.3 (128), 649 -> 509 (32), 126.4 -> 120.3 (256), on top of #339. Forces identical to the chunked lane (aggL2 to 4 digits at every leaf; per-order sweep p=2..6 monotone and equal to 4 digits), strict-lane truncation check < 7e-4, 300-step dynamics with the lane on vs off: identical dKE/dLz to 4 digits, round-off-level divergence only.

Tests: interpret parity vs m2l_rot_scale_real_batch < 1e-10 (fp64, orders 2-6) and < 3e-4 (fp32); random CSR with empty targets and a padded tail; active_pair_count truncation; on-axis deltas (rho = 0); wrapper under jit; axis contracts; pack/unpack round trip; Triton run for orders 2/4/6 on A100; a wiring test that counts the kernel entry and pins the force (rel < 2e-5); strict integration tests 17/17 on A100. The wiring test (28 s on CPU) is on the slow list.

The per-step leaf optimum still sits at leaf 128-256: with M2L at 13 ms the remaining ~100 ms of a leaf-64 step is the traced dual-tree walk and list compaction, which scale with the leaf count and the traced pair queue. That is the next lever, and it is outside this PR.

🤖 Generated with Claude Code

TobiBu and others added 15 commits September 7, 2026 09:59
…ernel

One extra unconditional pass per program over the program's own leaf, diagonal
masked before the rsqrt, gated to chunk 0 on the source-chunk grid, replaces
the lax.scan over leaves (_self_contributions) on the forward prepacked lane.
JACCPOT_NEARFIELD_LEAFPAIR_FOLD_SELF=0 restores the scan; the cvjp lane keeps
self_acc + cvjp(...) so the gradient path is byte-identical. Kernel name gains
_self so the two variants never alias in the compilation cache.

Tests: fold == pairs + _self_contributions to fp32 summation order on both grids,
subtile lane indexing, softening 0 (no NaN, potential lane), wide accumulator,
default-off byte parity, and a Triton (sm_80) run registered in the GPU gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_chunk_segment_scatter_add ran once per 4096-pair chunk and its two scatters
were pathological for XLA's atomic-add lowering: segment_sum sent every pair
of a target to the same group row, and the final .at[].add sent every
non-head slot (~3800 of 4096) to node 0 with a zero value. At leaf 64
(992k far pairs, 243 chunks/step) that was 169 ms of a 410 ms step, 686 us
per launch for a 4096 x 25 reduction. Now a segmented associative_scan
reduces within the sorted segments with no scatter, and non-head slots carry
an out-of-bounds sink index dropped by mode='drop', so the one remaining
scatter has unique in-bounds indices and no atomics.

Pinned to np.add.at on random chunks (padded tail, all-same-target, nothing
valid, degenerate width), node-0 untouched unless targeted, deterministic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…R by target)

One program per target node loops over its far-pair segment of a target-sorted
source list and builds rotate -> z-translate -> rotate-back on chip from the
two alignment angles: Dz as diag(cos |m| t) + A sin(|m| t) with a constant
antisymmetric pattern, the constant B stack per degree, and a separable z-core
Z = Zsf * outer(rinv^(n+1), rinv^k). Nothing per pair touches HBM except one
(Cp,) multipole row; the program owns its output row, so the chunked lane's
per-chunk argsort + segment_sum scatter disappear. Forward only.

Interpret-mode parity vs m2l_rot_scale_real_batch: < 1e-10 (fp64) for orders
2..6, < 3e-4 (fp32); random CSR with empty targets and a padded -1 tail,
active_pair_count truncation, on-axis deltas, and the wrapper under jit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…as no n-ary stack

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…icrobench

JACCPOT_STATIC_STRICT_FUSED_M2L_CSR=1 (Ampere+, or JACCPOT_M2L_CSR_INTERPRET=1
for CPU parity) routes _solidfmm_downward_accumulate_from_multipoles' flat real
lanes to m2l_real_csr_pallas: one program per target owns its local row, so the
chunked scan and its per-chunk scatter are gone. Wiring test counts the kernel
entry and pins the force to the chunked lane (rel < 2e-5 at N=3000, p=3).
bench/m2l_csr_microbench.py: ns/pair vs the pure-JAX chunked lane (degree-batched
off/on) at 1M and 6M pairs, orders 4 and 6 -- the plan's G2a gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…k, degree-only z-core

The first cut held two 128x32 one-hot pack/unpack matrices and a Cp x Cp z-core
in registers per program and ran at ~93 ns/pair on a shared A100 (non-record),
no faster than the pure-JAX lane. The multipole table is now packed into the
centred (Bp, Wp) layout once in the wrapper and the output unpacked once; the
z-translation preserves m, so its operator is a Bp x Bp degree matrix per
column. Half the per-pair MACs, a third of the constants; 4 warps per program.
Microbench: iterate the well-separation rejection (a coincident pair made the
reference NaN), assert finiteness. CSR sm_80 tests registered in the GPU gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n the slow list (28 s on CPU)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…(the knob is trace-time)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ixes, per-step table, capacity rules

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… sm_80 tests run in the ordinary suite

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CI on #340: test_type_annotation_guard flagged the inner `one`/`body` closures;
pydoclint wanted Raises sections on m2l_real_csr_tables / m2l_real_csr_pallas
and full docstrings on _bapply/_dz; black reformatted four files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@TobiBu
TobiBu merged commit 7d66933 into perf/small-leaves Sep 10, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant