jaccpot is a JAX-first Fast Multipole Method (FMM) package for N-body gravity
and related hierarchical interaction problems. It provides multipole/local
expansion operators, near/far-field kernels, and a preset-driven high-level
solver API.
Tree construction and traversal artifacts are provided by the companion package
yggdrax.
- High-level
FastMultipoleMethodAPI withfast,balanced,accurate, andlarge_n_gpupresets - Configurable expansion basis —
real(Dehnen) is the default production basis (the radix large-N fast lane runs pure-real end to end, no complex↔real conversion);complex/solidfmmare retained for cross-checking;cartesianalso available - Pure-JAX real spherical harmonic rotate+scale M2L path
- Adaptive-order far-field evaluation with fixed
p_gearsbuckets - Optional topology reuse for multiple nearby timesteps
- Optional Pallas acceleration for the real-basis z-translation hotspot
- Modular runtime with grouped/dense interaction pathways
- Near-field and far-field execution paths with optional prepared state reuse
- Explicit octree execution backend for
basis="solidfmm" - End-to-end differentiable FMM force — exact
jax.grad/jax.vjpgradients w.r.t. positions and masses at fixed topology, verified from N=64 to N=1,000,000 (see Differentiable FMM) - Differentiable direct-sum helper via JAX autodiff, retained as the exact-gradient oracle
Install from source:
pip install -e .yggdrax is not on PyPI yet. Install it from GitHub first (use the latest
main, which includes native RadixTree JAX pytree registration):
git clone https://github.com/TobiBu/yggdrax.git
cd yggdrax
pip install -e .
cd ..Install with development tooling:
pip install -e ".[dev]"import jax
import jax.numpy as jnp
from jaccpot import FastMultipoleMethod
key = jax.random.PRNGKey(0)
key_pos, key_mass = jax.random.split(key)
positions = jax.random.uniform(key_pos, (1024, 3), minval=-1.0, maxval=1.0)
masses = jax.random.uniform(key_mass, (1024,), minval=0.5, maxval=1.5)
# basis defaults to "real" (Dehnen); pass basis="solidfmm" only to cross-check.
solver = FastMultipoleMethod(preset="balanced")
accelerations = solver.compute_accelerations(positions, masses)
print(accelerations.shape)Real-basis rotate+scale FMM uses the same high-level API:
solver = FastMultipoleMethod(
preset="accurate",
basis="real",
adaptive_order=True,
p_gears=(2, 3, 4),
)
accelerations = solver.compute_accelerations(positions, masses, max_order=4)For very large single-GPU runs, use the dedicated throughput/memory preset:
solver = FastMultipoleMethod(
preset="large_n_gpu",
basis="solidfmm",
precision="fp32",
)large_n_gpu keeps JIT enabled while applying memory-oriented runtime defaults
for large particle-count single-GPU runs (streamed far-pair mode, reduced
near-field precompute retention, and cache retention disabled).
For split-step integrators (for example active-particle substeps), you can evaluate only a subset while still using all particles as FMM sources:
active = jnp.asarray([0, 7, 11, 32], dtype=jnp.int32)
state = solver.prepare_state(positions, masses)
active_acc = solver.evaluate_prepared_state(state, target_indices=active)For integrators that require jerk, use:
velocities = jax.random.uniform(key_pos, (1024, 3), minval=-0.2, maxval=0.2)
acc, jerk = solver.compute_accelerations_and_jerk(
positions,
masses,
velocities,
jerk_mode="fast_approx", # or "accurate"
)See docs/derivatives_and_jerk.md for API details, mode tradeoffs, and output tensor layouts. Current higher-order status:
- jerk is available via
compute_accelerations_and_jerk(...) - higher total time derivatives are available via
compute_accelerations_with_time_derivatives(...) - public time-derivative support currently reaches crackle
(
max_time_derivative_order=3) - the general time-derivative API currently uses the analytic
accuratepath - orders above crackle are not yet available
- acceleration spatial derivatives (
max_acc_derivative_order > 0) currently requirebasis="solidfmm"
There is also a worked example notebook for jerk, snap, and crackle, including
a small-N direct-sum accuracy check:
examples/time_derivatives_demo.ipynb.
| Goal | Mode | Notes |
|---|---|---|
| Lowest runtime overhead | fast_approx |
Exact near-field jerk + far-field convective term. |
| Highest fidelity (includes source-motion effects) | accurate |
Analytic far-field source-motion term + convective + exact near-field jerk. |
| Stable default for large production runs | fast_approx |
Benchmark against your own workload before switching defaults. |
For ODISSEO-style primitive states (N, 2, 3), you can use the adapter:
from jaccpot import OdisseoFMMCoupler
coupler = OdisseoFMMCoupler(solver, leaf_size=16, max_order=4)
coupler.prepare(primitive_state, masses) # full source tree
acc_active = coupler.accelerations(primitive_state, active_indices=active)The default runtime path remains radix-oriented. To request explicit octree execution, configure both the tree type and runtime backend:
from jaccpot import (
FastMultipoleMethod,
FMMAdvancedConfig,
RuntimePolicyConfig,
TreeConfig,
)
solver = FastMultipoleMethod(
preset="fast",
basis="solidfmm",
advanced=FMMAdvancedConfig(
tree=TreeConfig(tree_type="octree"),
runtime=RuntimePolicyConfig(execution_backend="octree"),
),
)Current practical scope:
- the octree backend is validated for
basis="solidfmm" - prepared-state evaluation supports full outputs, target subsets, potentials, JIT/eager traversal, and prepared-state cache reuse
- non-default runtime modes such as baseline nearfield and class-major farfield are covered in the solver tests
Still worth keeping in mind:
execution_backend="auto"may still resolve to the radix backend- topology reuse remains radix-only
- validation is currently most reliable on the preferred project validation GPU
Example:
basis="real"(the default, and the production choice): real spherical harmonic coefficient layout (Dehnen, no sqrt-2) with rotate+scale-to-z M2L. The large-N radix fast lane runs pure-real end to end, with no complex<->real conversion.basis="solidfmm"— complex solid-harmonic path, kept for cross-checking.realvssolidfmmagree to 4.5e-13 at N=2048/p=4/theta=0.5, which is a genuine independent-basis check and a good result.basis="complex"— an alias forsolidfmm, not a third basis. The two select the same code path and produce bit-identical forces (measured at N=2048/p=4/theta=0.5: max difference exactly 0.0). Prefer spelling it"solidfmm";"complex"is retained for backwards compatibility.basis="cartesian"— experimental; not for quantitative work. Its relative L2 force error is ~1.8e-1 independent of expansion order, which is a divergent-series signature rather than truncation error: raising the order does not improve it. solidfmm is 8.1e-5 on the same configuration, ~2000x better. Selecting it emits aUserWarning; setJACCPOT_ALLOW_CARTESIAN_BASIS=1to silence it. It is the sole reason the characterization anchor for cartesian carries a 0.35 tolerance. Seedocs/dehnen_mass_mac_status_and_plan.md.
GPU results are reproducible to a few ulps, not to the bit. The near field and M2L accumulate via scatter-add, which XLA lowers to atomics, and floating point addition is not associative, so the summation order varies run to run.
Measured on an A100 at N=512/leaf=16/p=4/float64, 8 runs on identical inputs:
0 of the 7 later runs were bit-identical to the first; the worst elementwise
deviation was 3.8 eps (8.1e-17 relative to the rms |a|). This is normal for a
GPU FMM rather than a defect, but it means a test must not assert bit equality
between two GPU runs — see tests/unit/runtime/_reproducibility.py for the
helpers the suite uses instead.
XLA_FLAGS=--xla_gpu_deterministic_ops=true does restore bit equality, at a cost
that makes it unusable for a suite: with it set, three tests in
tests/unit/runtime did not finish in 50 minutes. Use it for a one-off
investigation, not as a default.
CPU results are bit-reproducible run to run, which is why the characterization goldens are generated and checked there.
Use precision to select runtime dtype explicitly:
solver_fp32 = FastMultipoleMethod(
preset="fast",
basis="solidfmm",
precision="fp32",
)
solver_fp64 = FastMultipoleMethod(
preset="accurate",
basis="solidfmm",
precision="fp64",
)precision="fp64" requires jax_enable_x64=True. You can still pass
working_dtype directly; if both are set, they must match.
Use adaptive_order=True together with a static gear list:
solver = FastMultipoleMethod(
preset="accurate",
basis="real",
adaptive_order=True,
p_gears=(2, 3, 4),
)p_gears must be a fixed tuple or list of orders. This keeps all hot paths
JIT-friendly and avoids shape polymorphism.
Adaptive order selection now uses yggdrax's generic pair_policy +
interaction_tags traversal hook. The tree backend only provides generic
far-pair tags; jaccpot owns the solver-side policy state, order selection, and
per-order bucketing.
The current adaptive acceptance model is solver-owned and error-aware:
- acceptance uses the highest available order as a Dehnen-style safety check
- accepted pairs are limited to a relaxed geometric cone to avoid pathological over-acceptance
- once accepted, the solver picks the smallest passing order from
p_gears
In other words, the highest candidate order decides whether a pair is safe to
accept, while the first passing order decides how much far-field work is needed.
The current notebook example prints the resulting tag-derived
far_pairs_by_gear_counts from the solver runtime.
Adaptive traversal currently has two practical runtime modes:
adaptive_error_model="tail_proxy"(default): the validated high-performance mode; this remains the recommended default when runtime matters mostadaptive_error_model="dehnen_paper": the paper-inspired comparison mode; for JAX-native runs pair it withdehnen_geometry_mode="tree_approx"
Other available knobs:
adaptive_error_model="dehnen_degree": a simplified degree-resolved Dehnen-style source-power estimatordehnen_geometry_mode="exact": exact reference geometry for paper comparisons; not a throughput modedehnen_geometry_mode="tree_approx": JAX-native paper geometry based on approximate leaf spheres plus upward merged spheresadaptive_eps=...: override the default theta-derived adaptive tolerance with a direct solver-side tolerance scale
Examples:
Use the following three solver configurations for reproducible comparisons on this branch. Keep the remaining benchmark settings fixed, for example:
preset="accurate"basis="real"theta=0.6leaf_size=16max_order=4p_gears=(2, 3, 4)for adaptive runs- enlarged traversal caps, as used in
examples/adaptive_vs_fixed_benchmark.ipynb
Fixed non-adaptive baseline:
fixed = FastMultipoleMethod(
preset="accurate",
basis="real",
theta=0.6,
adaptive_order=False,
)Adaptive high-performance default:
tail_proxy = FastMultipoleMethod(
preset="accurate",
basis="real",
theta=0.6,
adaptive_order=True,
p_gears=(2, 3, 4),
adaptive_error_model="tail_proxy",
mac_force_scale_mode="prev",
)Adaptive paper-inspired JAX-native mode:
dehnen_paper = FastMultipoleMethod(
preset="accurate",
basis="real",
theta=0.6,
adaptive_order=True,
p_gears=(2, 3, 4),
adaptive_error_model="dehnen_paper",
adaptive_eps=1.0e-3,
dehnen_geometry_mode="tree_approx",
mac_force_scale_mode="paper",
)Interpretation on the current branch:
fixed: fastest non-adaptive baselinetail_proxy: best validated adaptive runtime defaultdehnen_paper: higher-accuracy, paper-inspired comparison mode
Adaptive traversal can weight its solver-side policy state with per-node force
scales. Select how those scales are estimated with mac_force_scale_mode:
"prev": reuse the previous full-step per-node force-scale estimate (self._last_force_scale_nodes, refreshed after every full-order evaluation). This is the cheapest option and is the practical default fortail_proxy."prepass": run a cheap lowest-order prepass for the current configuration and derive force scales from that pass, on everyprepare_state."paper": run the stricter paper-style current-step prepass used byadaptive_error_model="dehnen_paper", on everyprepare_state."paper_cached": run the paper-style prepass once, on the cold call, then reuse the cached scale (refreshed from each full evaluation). This is whatmac_type="dehnen_error"selects by default.
Interpretation:
previs a runtime-oriented reuse mode.paperis the more publication/reference-oriented mode because it derives the threshold from a dedicated current-step prepass rather than from historical state. It is also the most expensive: the extra prepass on every step dominatesprepare_state.paper_cachedis the production form ofpaper. Dehnen §5.4 licenses the reuse explicitly -- the previous step's accelerations are "only very slightly worse" than the exacta_b-- and a real simulation always has them.prepasssits between the two as a generic current-step estimate that is not specifically tied to the paper-style Dehnen path.
These scales stay inside jaccpot's adaptive policy state; they are no longer
exported as backend-specific traversal node_features.
The real-basis z-translation core can be accelerated with Pallas:
solver = FastMultipoleMethod(
preset="accurate",
basis="real",
use_pallas=True,
)Current behavior:
- rotations stay in pure JAX
- only the real-basis z-axis M2L core is offloaded
- unsupported backends fall back to the pure-JAX kernel automatically
On the current expanse CPU environment, the example reports fallback rather
than true Pallas execution:
FastMultipoleMethod.differentiable_accelerations gives exact gradients of the
FMM force with respect to particle positions and masses. The tree topology is
held fixed from a pre-built state while the numeric pipeline (P2M, centre-of-mass
expansion centres, the M2M/M2L/L2L translations, L2P, near-field P2P) is
re-evaluated on the live inputs, so jax.grad transposes it exactly.
import jax, jax.numpy as jnp
from jaccpot import FastMultipoleMethod
fmm = FastMultipoleMethod(basis="real", theta=0.6, softening=1e-3)
# Build the topology ONCE, outside the differentiated function --
# prepare_state does host-side tree construction and is not traceable.
state = fmm.prepare_state(positions0, masses0, max_order=4, leaf_size=64)
def loss(positions, masses):
return jnp.sum(fmm.differentiable_accelerations(state, positions, masses) ** 2)
grad_positions, grad_masses = jax.grad(loss, argnums=(0, 1))(positions, masses)Gradients match jax.grad of an exact direct O(N²) sum to the FMM's own force
accuracy, not to machine precision.
The large_n_gpu preset is differentiable and is the leanest configuration
measured. It needs retain_far_pairs_for_grad=True so the frozen M2L pair list
survives prepare_state:
fmm = FastMultipoleMethod(preset="large_n_gpu", basis="real")
state = fmm.prepare_state(
positions0, masses0, max_order=4, leaf_size=256,
retain_far_pairs_for_grad=True,
)A100 40 GB, fp32, θ=0.7, order 4, clustered galaxy disc (steady-state):
| N | forward | forward+backward | reverse peak memory |
|---|---|---|---|
| 200 k | 0.86 s | 2.59 s | 2.62 GB |
| 1 M | 2.50 s | 66.4 s | 11.07 GB |
Pass a GradConfig. Its most important field is nearfield_lane, which selects
the near-field traversal — the near field is ~83% of the forward and ~91% of the
reverse. It defaults to "auto", which switches to the leaf-major fast lane at
N ≥ 100 000 because the bucketed reverse OOMs at that scale (30 GB peak at
200 k against the fast lane's 6.8 GB):
from jaccpot import GradConfig
cfg = GradConfig(nearfield_lane="fast_lane", fused_m2l_pallas=True)
accel = fmm.differentiable_accelerations(state, positions, masses, grad_config=cfg)Every field falls back to a JACCPOT_* environment variable when left None, so
existing env-configured scripts keep working; an explicit field always wins.
Full guide, contract, limits, and troubleshooting: docs/differentiable_fmm.md.
For small multi-step particle motion, you can reuse cached topology and interaction lists for a bounded number of steps:
solver = FastMultipoleMethod(
preset="accurate",
basis="real",
reuse_topology=True,
rebuild_every=3,
)The solver always recomputes reordered particles, geometry, upward multipoles, and downward locals for the current state. Reuse only applies to cached topology/traversal artifacts when the Morton ordering key remains unchanged.
Example:
Run quality gates locally:
black --check .
isort --check-only .
pytestOr run pre-commit hooks:
pre-commit run --all-filesCoverage is enforced in CI via pytest-cov:
pytest --cov=jaccpot --cov-report=term-missingCI also runs a benchmark regression guard based on:
Run the lightweight runtime-path benchmark and CI guard locally:
python -m bench.bench_parallel_paths --n 512 --runs 3 --warmup 1
python -m bench.ci_benchmark_guard --n 384 --runs 2 --warmup 1If a performance change is intentional, refresh the baseline:
- Run
bench/bench_parallel_paths.pywith the CI benchmark arguments. - Read the
timings_sline and update:target_eval_mean_sandadaptive_prepare_mean_sinbench/benchmark_baseline.json. - Re-run
bench/ci_benchmark_guard.pyto confirm the new baseline passes.
examples/benchmark_runtime_accuracy.ipynb: main runtime/accuracy benchmark workflowexamples/adaptive_vs_fixed_benchmark.ipynb: adaptive-order vs fixed-order comparisonexamples/benchmark_gpu_radix_runtime.ipynb: GPU/radix runtime and memory-pressure deep diveexamples/benchmark_gpu_single_n_memory.ipynb: interactive single-NGPU memory probe with plots/tablesexamples/benchmark_gpu_n_ladder_production.py: production-oriented large-Nparameter sweepexamples/time_derivatives_demo.ipynb: usage plus direct-sum accuracy checks for jerk, snap, and crackleexamples/jerk_modes_demo.ipynb: compare jerkfast_approxvsaccurate, including analytic source-motion behaviorexamples/real_sh_adaptive_order.ipynb: real-basis adaptive-order demo
jaccpot can enable package-wide runtime checking for annotated callables using
jaxtyping + beartype at import time.
- Disabled by default.
- Enable when needed with:
export JACCPOT_RUNTIME_TYPECHECK=1jaccpot/solver.py: preset-first user-facing FMM APIjaccpot/config.py: config model for solver/runtime knobsjaccpot/runtime: execution internals and integration with yggdrax artifactsjaccpot/operators: harmonic, translation, and multipole operatorsjaccpot/upward,jaccpot/downward,jaccpot/nearfield: sweep and near-field modulestests: unit, integration, and performance checks
GitHub Actions runs:
- formatter checks (
black,isort) - unit/integration tests with coverage threshold
- release build and PyPI publish on version tags
Workflow files:
.github/workflows/ci.yml.github/workflows/release.yml