Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9ee6a41
Merge remote-tracking branch 'origin/perf/nearfield-row-split-batched…
TobiBu Sep 7, 2026
774cb6b
perf(nearfield): fold the self-leaf block into the leaf-pair Pallas k…
TobiBu Sep 7, 2026
8ea81ad
perf(m2l): contention-free per-chunk reduction into the locals
TobiBu Sep 7, 2026
20d61f1
feat(pallas): target-tiled real M2L kernel with on-chip rotations (CS…
TobiBu Sep 9, 2026
51b7725
fix(pallas): CSR M2L radius powers as exp(deg * log rinv) -- Triton h…
TobiBu Sep 9, 2026
567bde3
feat(m2l): opt-in CSR Pallas lane for the flat real-basis M2L + G2a m…
TobiBu Sep 9, 2026
0f6aeeb
perf(pallas): CSR M2L in the centred layout -- no per-pair pack/unpac…
TobiBu Sep 9, 2026
76bcd52
test(m2l-csr): axis contracts + pack/unpack round trip; wiring test o…
TobiBu Sep 9, 2026
f00948c
bench(m2l-csr): clear jit caches between the degree-batched variants …
TobiBu Sep 9, 2026
8c2930b
Merge remote-tracking branch 'origin/main' into perf/small-leaves-p1
TobiBu Sep 9, 2026
28ef6b1
docs: small-leaves record -- attributed baseline, the three kernels/f…
TobiBu Sep 9, 2026
5a97ea3
bench(gpu_gate): keep _MUST_RUN_SM80 to its one gated module; the new…
TobiBu Sep 9, 2026
15a74d4
perf(nearfield): fold the self-leaf block into the leaf-pair Pallas k…
TobiBu Sep 7, 2026
d67cf32
perf(m2l): contention-free per-chunk reduction into the locals
TobiBu Sep 7, 2026
f868bf6
bench(gpu_gate): keep _MUST_RUN_SM80 to its one gated module
TobiBu Sep 9, 2026
bb19e04
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 9, 2026
53219dd
Merge perf/small-leaves (PR #339) into perf/small-leaves-csr-m2l
TobiBu Sep 9, 2026
213ce3a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 9, 2026
9ade84b
style(m2l-csr): annotate the two closures, pydoclint sections, black
TobiBu Sep 10, 2026
7d66933
Merge pull request #340 from TobiBu/perf/small-leaves-csr-m2l
TobiBu Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bench/gpu_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@
"test_the_production_real_fused_m2l_kernel_carries_the_axis_derivative",
"test_the_production_complex_fused_m2l_kernel_carries_the_axis_derivative",
)
# NOT registered here, by the registry's own contract: `tests/unit/test_gpu_gate_checks.py`
# derives the gated set from ONE module (test_transverse_degeneracy_jvp.py) and
# fails on any fragment outside it. The sm_80 tests of the near-field self-fold
# (test_pallas_nearfield_fused.py::test_leafpair_include_self_gpu_matches_reference)
# and of the CSR M2L kernel (test_m2l_real_csr_pallas.py::test_csr_pallas_gpu_matches_rot_scale)
# therefore run under the ordinary GPU suite; widening the registry to several
# modules is a separate change.

# Measured on an A100 sm_80 / jax 0.10.2 and documented in ARCHITECTURE.md §9,
# which carries the per-entry reasoning and the deterministic-ops column. A hit
Expand Down
170 changes: 170 additions & 0 deletions bench/m2l_csr_microbench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Go/no-go microbench for the CSR M2L Pallas kernel (plan "small leaves", gate G2a).

Synthetic far-pair lists of 1M and 6M directed pairs over ``n`` nodes (the leaf
64 / leaf 32 counts at N=200k), orders 4 and 6, fp32:

* ``m2l_real_csr_pallas`` (one program per target, rotations on chip), jitted;
* the pure-JAX chunked lane as production runs it -- ``m2l_rot_scale_real_batch``
over 4096-pair chunks in a ``lax.scan`` with ``_chunk_segment_scatter_add`` --
with ``JACCPOT_M2L_DEGREE_BATCHED`` off and on (the plan's 2.0 candidate, and
the honest pure-JAX baseline).

Reports ns per directed pair (min of the timed calls) and the fp32 rel-L2 of
the kernel against the pure-JAX lane. Gate: <= 15 ns/pair at p=4 and parity
<= 1e-5 rel is "go" for wiring; the pure-JAX lane sits at ~130-375 ns/pair.

CUDA_VISIBLE_DEVICES=<idle> python bench/m2l_csr_microbench.py [--pairs 1000000 6000000] [--orders 4 6]
"""

from __future__ import annotations

import argparse
import json
import os
import time

import numpy as np


def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--pairs", type=int, nargs="+", default=[1_000_000, 6_000_000])
ap.add_argument("--orders", type=int, nargs="+", default=[4, 6])
ap.add_argument("--nodes", type=int, default=12_500)
ap.add_argument("--repeats", type=int, default=5)
ap.add_argument("--chunk", type=int, default=4096)
ap.add_argument("--out", default=None)
args = ap.parse_args()
os.environ.setdefault("XLA_PYTHON_CLIENT_PREALLOCATE", "false")

import jax
import jax.numpy as jnp
from jax import lax

from jaccpot.operators.m2l_real_rot_scale import m2l_rot_scale_real_batch
from jaccpot.operators.real_harmonics import sh_size
from jaccpot.pallas.m2l_real_csr import m2l_real_csr_pallas
from jaccpot.runtime.kernels._m2l import _chunk_segment_scatter_add

dev = jax.devices()[0]
print(f"device {dev} cc={getattr(dev, 'compute_capability', '?')}", flush=True)
rng = np.random.default_rng(0)
n = int(args.nodes)
centers = jnp.asarray(rng.uniform(-1, 1, (n, 3)).astype(np.float32))
results = []

def timed(fn, *a):
out = jax.block_until_ready(fn(*a))
ts = []
for _ in range(args.repeats):
t0 = time.perf_counter()
out = jax.block_until_ready(fn(*a))
ts.append(time.perf_counter() - t0)
return out, min(ts), float(np.median(ts))

for order in args.orders:
C = sh_size(order)
mult = jnp.asarray(rng.standard_normal((n, C)).astype(np.float32))
for P in args.pairs:
# random well-separated pairs: reject |delta| < 0.3 (the MAC would too)
src = rng.integers(0, n, P).astype(np.int32)
tgt = rng.integers(0, n, P).astype(np.int32)
cn = np.asarray(centers)
for _ in range(50): # resample until every pair is well separated
bad = np.linalg.norm(cn[tgt] - cn[src], axis=1) < 0.3
if not bad.any():
break
src = np.where(bad, rng.integers(0, n, P), src).astype(np.int32)
assert not bad.any()
src_j, tgt_j = jnp.asarray(src), jnp.asarray(tgt)
counts = np.bincount(tgt, minlength=n)

csr = jax.jit(
lambda m, c, s, t: m2l_real_csr_pallas(m, c, s, t, order=order)
)
out_k, t_k, med_k = timed(csr, mult, centers, src_j, tgt_j)

chunk = int(args.chunk)
n_chunks = -(-P // chunk)
pad = n_chunks * chunk - P
src_p = jnp.pad(src_j, (0, pad), constant_values=0)
tgt_p = jnp.pad(tgt_j, (0, pad), constant_values=0)

def pure(m, c, s, t, P=P, chunk=chunk, n_chunks=n_chunks):
def body(acc, i):
idx = i * chunk + jnp.arange(chunk, dtype=jnp.int32)
valid = idx < P
sc = s[idx]
tc = t[idx]
contrib = m2l_rot_scale_real_batch(
m[sc], c[tc] - c[sc], order=order
)
return (
_chunk_segment_scatter_add(
acc, contrib, tc, valid, chunk_size=chunk
),
None,
)

acc, _ = lax.scan(
body,
jnp.zeros((n, C), jnp.float32),
jnp.arange(n_chunks, dtype=jnp.int32),
)
return acc

rows = {}
for db in ("0", "1"):
os.environ["JACCPOT_M2L_DEGREE_BATCHED"] = db
# the knob is read at trace time and the cascade's inner jits are
# cached across variants -- without this the second variant reuses
# the first's trace (measured: bit-identical output and time)
jax.clear_caches()
fn = jax.jit(pure)
out_p, t_p, med_p = timed(fn, mult, centers, src_p, tgt_p)
rows[db] = (out_p, t_p, med_p)
del fn
ref = np.asarray(rows["0"][0], np.float64)
assert np.all(np.isfinite(ref)), "pure-JAX reference has non-finite rows"
assert np.all(
np.isfinite(np.asarray(out_k))
), "CSR kernel produced non-finite rows"
rel = float(
np.linalg.norm(np.asarray(out_k, np.float64) - ref)
/ np.linalg.norm(ref)
)
rel_db = float(
np.linalg.norm(np.asarray(rows["1"][0], np.float64) - ref)
/ np.linalg.norm(ref)
)
row = dict(
order=order,
pairs=P,
nodes=n,
longest_row=int(counts.max()),
csr_ns_per_pair=1e9 * t_k / P,
csr_ms=1e3 * t_k,
csr_median_ms=1e3 * med_k,
pure_ns_per_pair=1e9 * rows["0"][1] / P,
pure_ms=1e3 * rows["0"][1],
pure_db_ns_per_pair=1e9 * rows["1"][1] / P,
pure_db_ms=1e3 * rows["1"][1],
rel_l2_csr_vs_pure=rel,
rel_l2_db_vs_pure=rel_db,
chunk=chunk,
)
results.append(row)
print(
f"p={order} pairs={P/1e6:.0f}M longest_row={counts.max()}: CSR {row['csr_ns_per_pair']:.1f} ns/pair "
f"({row['csr_ms']:.1f} ms) | pure-JAX {row['pure_ns_per_pair']:.1f} ns/pair | degree-batched "
f"{row['pure_db_ns_per_pair']:.1f} ns/pair | rel-L2 csr {rel:.2e}, db {rel_db:.2e}",
flush=True,
)
if args.out:
with open(args.out, "w") as fh:
json.dump(results, fh, indent=2)
print("wrote", args.out)


if __name__ == "__main__":
main()
91 changes: 91 additions & 0 deletions docs/small_leaves_2026-09.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Small leaves on the fused single-GPU lane (2026-09-07 .. 2026-09-10)

Plan: `~/.claude/plans/ok-please-draft-a-effervescent-crown.md` (Odisseo box). Branch
`perf/small-leaves-p1`. Harness: `Odisseo-bench-multigpu/benchmark_multigpu/codes/{fit_smallleaf_caps,
smallleaf_baseline,smallleaf_dynamics}.py`, `FAST_LANE_ENV_BY_LEAF` in `compare_force.py`; results under
`artifacts/smallleaf/`.

## Why

At N=200k Plummer, p=4, theta 0.6 the fused lane at leaf 256 sums 58 % of N directly per target
(pkdgrav3: 0.24 %). The direct share falls as ~W^0.9 with the leaf size, so smaller leaves are the
only route to pkdgrav3's arithmetic -- and before this work leaf 64 was 3.1x SLOWER per step than
leaf 256 with 3x fewer pair evaluations.

## What the attributed baseline said

`strict_run_v2` per step, N=200k, theta 0.6, idle A100 (`baseline_base_*.json`):

| leaf | step ms | eval-only ms | leaf-pair kernel | downward (M2L+L2L) | launches/step |
|---|---|---|---|---|---|
| 256 | 131.8 | 70.0 | 79 | 41 | 7.8k |
| 128 | 187.5 | 43.2 | 48 | 130 | 15.8k |
| 64 | 409.6 | 26.5 | 22 | 372 | 32k |
| 32 | 1168 | 19.6 | 13 | ~1100 | -- |

The Pallas leaf-pair kernel has **no small-W cliff** (its time is exactly the direct-volume ratio),
the self-leaf scan was already negligible after #334, and the whole penalty was the far field:
one XLA scatter fusion launched once per 4096-pair M2L chunk at 686 us (`_chunk_segment_scatter_add`:
`segment_sum` with hundreds of duplicates per address plus ~3800 zero-adds onto node 0 per chunk,
both serialised by the atomic-add lowering) = 169 ms of the 410 ms leaf-64 step, and behind it a
21k-launch/step storm from the chunked rotation cascade.

## What was built

1. **Self-leaf block folded into the leaf-pair Pallas kernel** (`nearfield_fused_leaf.py`,
`include_self`; flag `JACCPOT_NEARFIELD_LEAFPAIR_FOLD_SELF`, default on, forward prepacked lane
only). Time-neutral (the scan was already batched), removes the per-leaf launch family.
2. **Contention-free chunk reduction** (`_m2l.py::_chunk_segment_scatter_add`): segmented
`associative_scan` + out-of-bounds sink (`mode="drop"`, unique in-bounds indices). Leaf 64:
410 -> 237 ms/step; leaf 128: 188 -> 145; leaf 32: 1168 -> 649.
3. **Target-tiled CSR M2L Pallas kernel** (`jaccpot/pallas/m2l_real_csr.py`, flag
`JACCPOT_STATIC_STRICT_FUSED_M2L_CSR=1`): one program per target owns its local row; rotations
assembled on chip from the two alignment angles in the centred (degree, m) layout, degree-only
z-core, no per-pair operand in HBM. Microbench on an idle A100 (`m2l_csr_microbench.json`):

| pairs | p | CSR ns/pair | pure-JAX | 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 |

Parity 1e-6 (fp32) against `m2l_rot_scale_real_batch`; interpret parity < 1e-10 (fp64) for orders
2-6. In the step at leaf 64 the kernel costs 12.9 ms where the pure-JAX lane cost ~360.

## Where it landed (per step, theta 0.6, N=200k, idle A100, foreign-process-free rows only)

| leaf | baseline | fold + scatter | + CSR M2L | eval-only | aggL2 vs fp64 direct |
|---|---|---|---|---|---|
| 256 | 131.8 | 126.4 | **120.3** | 69 | 8.27e-4 |
| 128 | 187.5 | 145.2 | **122.3** | 42 | 1.15e-3 |
| 64 | 409.6 | 236.5 | **176.7** | 24 | 1.20e-3 |
| 32 | 1168 | 649.0 | **509.4** | 15 | 1.30e-3 |

Theta 0.8 with CSR: leaf 64 113.5 (baseline 192.0), leaf 32 364.1 (baseline OOM'd on a shared card).

**The far field is solved; the per-step leaf optimum did not move.** With M2L at 13 ms, the leaf-64
step is ~100 ms of traced dual-tree walk and list compaction (pair-queue-sized scatter fusions at
~800 us x ~55 launches, ~200 memcpys, sorts) that scale with the leaf count and the traced
`max_pair_queue` (1M at leaf 64, 2M at leaf 32). Eval-only (force at fixed lists) is 3-5x faster at
leaf 32-64 than at 256, so a workload that refreshes lists every k steps, or a walk that costs
O(leaves) rather than O(queue), would collect the gain. Gate G3 (<= 35 ms per step at 200k) is
missed; G1, G2 and G2a are met. Next lever: the traced walk's queue buffers (yggdrax side).

## Capacity rules for small leaves (fitted, `FAST_LANE_ENV_BY_LEAF`)

* compact far-pair cap: pow2 >= 1.5x the far-pair count (101k / 363k / 992k / 2.34M at 256/128/64/32).
* neighbour-EDGE cap: the traced refresh pads to `num_leaves x traced_neighbour_cap`, and the #333
carry-over sets that cap to pow2(1.5 x longest eager row + 1) with the longest row =
`num_leaves - 1` -> 2^21 already fails at leaf 128 (2^23), 2^25 at 64, 2^27 at 32.
* `max_neighbors_per_leaf` must be explicit above the 2048 clamp; `max_interactions_per_node`
needs 16384 at leaf 32 (and at leaf 64 for theta 0.4).

## Traps recorded

* The perfetto trace of a leaf-32 step hung for 44 h after the timing (use `--no-trace` there).
* Back-to-back configs on one card get rejected by the guard on the previous process's stale
utilisation reading; the queue scripts wait for 0 % and retry.
* `JACCPOT_M2L_DEGREE_BATCHED` is read at trace time; a microbench that flips it must
`jax.clear_caches()` or the second variant reuses the first trace (bit-identical output).
* The `l2l_only` detail diag mode is inconsistent with the cumulative modes -- do not attribute from it.
Loading
Loading