GPU Acceleration Evaluation: JAX vs. Numba#

This page documents a 2026-07-26 evaluation of whether PyFracVAL’s numba-jitted hot path would benefit from a JAX/GPU port. The conclusion is negative: the existing numba CPU kernels outperform JAX (both CPU and GPU) by one to four orders of magnitude at every problem size PyFracVAL operates at, and the gap does not close as problems grow. Measurements were taken on an NVIDIA TITAN X (Pascal, compute capability 6.1).

Scope#

Every @jit-decorated function in pyfracval/ was catalogued (kernels in overlap.py, geometry.py, pca_kernels.py, cca_kernels.py, densify.py, and experimental/). The numerical hot path concentrates in two places:

  • overlap.py: pairwise overlap/distance checks between two point sets, called up to ~18,000 times per aggregate (once per rotation attempt during PCA/CCA sticking);

  • geometry._rodrigues_rotation_2d: rotation of an (N,3) cluster around an axis, called once per rotation attempt alongside the overlap check.

Both are, on their face, reasonable candidates for GPU acceleration: numerical array operations, called frequently, on a problem that nominally scales with particle count — the profile a JAX/GPU port typically targets.

Method#

benchmarks/jax_kernels.py ports the two hot-path kernels to JAX (rodrigues_rotation_jax, max_overlap_pairwise_jax, max_overlap_single_vs_agg_jax, batch_check_overlaps_pca_jax — the last mirrors pca_kernels.batch_check_overlaps_pca, batching K candidate rotations into a single call, the shape best suited to JAX/GPU). jax_enable_x64 is enabled, since PyFracVAL uses float64 throughout and JAX’s float32 default would silently change simulation precision.

benchmarks/jax_vs_numba_benchmark.py times numba against JAX-CPU and JAX-GPU for each kernel across a size sweep. Each kernel/framework/size combination is warmed up once (JIT-compile cost, excluded from the timed loop) and then timed over 50 calls with fresh random data per call, matching how the kernels are invoked in the simulation: coordinates and radii differ on every rotation attempt, so nothing can remain resident on the GPU across calls without a substantially larger rewrite of the sticking loop. JAX timings include host–device transfer and block_until_ready() per call, since the caller needs the concrete result before the next step of the algorithm; this reflects actual usage rather than a benchmark artifact. Raw results: benchmark_results/jax_vs_numba_summary.json.

Results#

Rodrigues rotation#

No early exit, purely vectorized — the case most favorable to JAX:

N particles

numba

JAX (CPU)

JAX (GPU)

16

6.6 µs

11.5 µs

62.6 µs

64

7.0 µs

11.5 µs

60.4 µs

256

8.0 µs

12.2 µs

45.4 µs

1,024

10.7 µs

15.9 µs

46.5 µs

4,096

20.9 µs

29.6 µs

59.9 µs

8,192

34.1 µs

54.2 µs

58.6 µs

Even for this kernel, with no branching and nothing for JAX to lose algorithmically, numba is faster at every size tested. JAX-GPU’s ~45–60 µs floor reflects dispatch, kernel-launch, and PCIe transfer latency; it changes little with N because at these sizes the compute is negligible relative to that fixed cost. numba’s cached compiled-to-native dispatch has no equivalent floor.

Pairwise overlap#

numba’s early exit and bounding-sphere pre-check against JAX’s dense O(N²) matrix:

N per cluster

numba

JAX (CPU)

JAX (GPU)

16

1.0 µs

13.2 µs

50.6 µs

64

0.8 µs

68.1 µs

55.8 µs

256

1.4 µs

438.2 µs

120.1 µs

1,024

3.5 µs

2,452 µs

412 µs

4,096

32.3 µs

45,647 µs

3,609 µs

The gap widens here, reaching roughly three orders of magnitude at N=4,096. numba’s calculate_max_overlap_cca_fast performs a cheap squared-distance vs. squared-radius-sum comparison before computing any sqrt, skipping the expensive arithmetic for pairs that cannot overlap, and returns as soon as it finds one pair that does. JAX/XLA has no equivalent: on SIMD/GPU hardware a per-element where does not skip work — both branches are computed for every lane — so JAX must materialize the full N×N distance matrix and reduce it unconditionally.

Two follow-up checks indicate the gap is architectural rather than a tuning artifact:

  • No crossover exists even far past realistic sizes. Sweeping the sparse/scattered scenario up to N=32,768 (≈1.07 billion pairs), numba stays at 67–200 µs throughout (the pre-check continues to skip nearly everything) while JAX-GPU grows to 165 ms; the gap widens with N.

  • Densely-packed data, where numba’s early return fires almost immediately, widens the gap further: numba drops to ~0.5–0.7 µs while JAX-GPU still pays 100 µs–2.9 ms for N=256–4,096, computing the full matrix regardless.

No data distribution in this problem favors JAX: sparse data lets the pre-check skip almost everything, and dense data lets the early return fire almost immediately, while JAX pays the full O(N²) cost in either case.

Batched overlap check#

K candidate rotations per call, mirroring pca_kernels.batch_check_overlaps_pca — the comparison expected to favor JAX/GPU most, since per-call dispatch overhead is amortized over K:

N_agg

K

numba

JAX (CPU)

JAX (GPU)

64

1

5.8 µs

13.5 µs

43.2 µs

64

360

9.0 µs

219.9 µs

60.2 µs

512

360

29.9 µs

640.2 µs

131.4 µs

2,048

1

10.3 µs

40.8 µs

50.6 µs

2,048

360

100.5 µs

1,532.6 µs

332.4 µs

Batching improves JAX’s relative standing: the numba/JAX-GPU ratio narrows from ~7× (K=1) to ~3.3× (K=360, N_agg=2,048), JAX’s closest result in this evaluation, though still a clear loss in absolute terms. numba’s batch_check_overlaps_pca is itself parallelized (prange across candidates) and cached, leaving little margin to close.

Compilation caching#

Both frameworks’ persistent caches were verified directly (empty vs. warm cache directory, same kernel, same input size):

Framework

Cold compile (empty cache)

Cache hit (warm .devenv/state/)

numba (cache=True)

501 ms

299 ms

JAX (jax_compilation_cache_dir)

155 ms

19 ms

JAX’s cache produces the larger relative improvement (~8×), since XLA compilation is heavier than numba’s LLVM path for these small kernels; JAX additionally pays a one-time ~300 ms CUDA context initialization per process, unrelated to caching. Both caches function as intended and matter for the startup latency of short-lived processes (a benchmark run, a single CLI invocation). Neither changes the steady-state figures above, which determine the outcome: the simulation loop runs each kernel thousands of times per aggregate within one long-lived process, so warm-cache startup cost is amortized away, and the per-call dispatch/launch overhead that caching does not address decides the comparison.

Conclusion#

A JAX/GPU port of overlap.py or geometry.rodrigues_rotation is not being pursued. The existing numba implementation is close to optimal for this problem’s shape: many small, branch-heavy, early-exit-friendly pairwise geometry computations, called in a tight sequential loop, on modest per-call array sizes. Branching that skips work is precisely what GPU-style parallelism cannot exploit, and the fixed per-call dispatch/launch/transfer floor (tens of microseconds, independent of framework maturity or caching) dominates at every problem size PyFracVAL generates aggregates at.

The result parallels the CCA sticking retrospective (experiments.md): a second case in which additional compute does not improve the outcome, because the binding constraint is not throughput but the structure of the problem. The devenv CUDA setup and the JAX kernel ports are retained (benchmarks/jax_kernels.py, benchmarks/jax_vs_numba_benchmark.py) in case a different parameterization — generating a large batch of independent aggregates in parallel on the GPU, rather than accelerating one aggregate’s inner rotation loop — is revisited later.

Implementation notes#

devenv.nix mirrors ../YASF-new/devenv.nix’s CUDA setup: a cudaPackages_12 toolkit via symlinkJoin, CUDA_HOME, and NUMBA_CUDA_DRIVER pointing at /run/opengl-driver/lib/libcuda.so, with /run/opengl-driver/lib appended to LD_LIBRARY_PATH (JAX’s CUDA plugin requires the driver directory on the standard dynamic-linker search path; numba does not, since it reads NUMBA_CUDA_DRIVER directly). devenv.yaml sets allow_unfree: true for the CUDA toolkit’s license. Both NUMBA_CACHE_DIR and JAX_COMPILATION_CACHE_DIR point at .devenv/state/, so compiled kernels persist across process restarts for both frameworks.

jax[cuda12] is not synced by default, since it pulls approximately 1.5 GB of CUDA wheels (cudnn, nccl, nvshmem); install it explicitly with uv sync --group test --group docs --group jax_bench (all three groups together, since uv sync --group X alone replaces rather than extends the synced set). With this in place:

>>> import jax; jax.devices()
[CudaDevice(id=0)]