01. Particles: sort, cell lists, and data-dependent neighbour loops
An SPH density loss whose efficient form sorts particles into cells and loops over a data-dependent number of neighbours.
The problem
\(N\) particles in a periodic square box, each with an SPH density \[\rho_i = \sum_j m\, W(\lVert x_i - x_j\rVert, h),\] where \(W\) is the 2D cubic-spline kernel with support \(2h\), and the loss \[\mathcal{L}(x) = \sum_i (\rho_i - \rho_0)^2 .\] We want \(\nabla_x \mathcal{L}\), the gradient with respect to all particle positions. The box grows with \(N\) so every particle has about 31 neighbours.
This loss came up in a Slack discussion as “something JAX can’t do because it needs sort!”. That is not quite right: jax.lax.sort is differentiable. What matters is how the efficient algorithm works:
compute each particle’s cell (integer, zero derivative),
sort particles by cell so each cell is contiguous in memory,
for each particle, loop over the 3×3 neighbouring cells, each a range start[c]:start[c+1]-1 whose length depends on the data,
accumulate kernel-weighted contributions.
The gradient flows through the gather in step 2 (as a permutation) and through step 4. Steps 1–3 only decide which pairs interact.
Code
The Enzyme version is the algorithm above as plain loops, differentiated as written: on the CPU the whole pipeline, including the counting sort, is inside autodiff. JAX and Reactant cannot express step 3 (a loop whose bounds come from the data), so they regularise it. JAX uses jax-md’s padded neighbour list; Reactant uses a padded cell list. Both must pick a capacity from the data and check for overflow.
# The whole pipeline is differentiated: cell ids, counting sort, gather into sorted# order, and neighbour loops whose ranges depend on the data.functionsph_loss(x::AbstractMatrix{T}, p::Params{T}) where {T} N, nc =size(x, 2), p.nc s, rc = p.L / nc, 2* p.h# 1. cell id of every particle, and particles per cell cell =Vector{Int}(undef, N) start =zeros(Int, nc * nc +1)@inboundsfor i in1:N cx =mod(floor(Int, x[1, i] / s), nc) cy =mod(floor(Int, x[2, i] / s), nc) cell[i] =1+ cx + nc * cy start[cell[i] +1] +=1end# 2. counting sort by cell: start[c] is the first sorted index of cell c start[1] =1for c in1:nc * nc start[c +1] += start[c]end cursor = start[1:end-1] perm =Vector{Int}(undef, N)@inboundsfor i in1:N perm[cursor[cell[i]]] = i cursor[cell[i]] +=1end# 3. gather positions into sorted order: each cell is contiguous in memory xs =similar(x)@inboundsfor k in1:N xs[1, k] = x[1, perm[k]] xs[2, k] = x[2, perm[k]]end# 4. density and loss over the 3×3 neighbouring cells; each cell is the range# start[c]:start[c+1]-1, whose length depends on the data loss =zero(T)@inboundsfor k in1:N c = cell[perm[k]] -1 cx, cy = c % nc, c ÷ nc ρ =zero(T)for dy in-1:1, dx in-1:1 cn =1+mod(cx + dx, nc) + nc *mod(cy + dy, nc)for l in start[cn]:start[cn +1] -1 rx =minimg(xs[1, k] - xs[1, l], p.L) ry =minimg(xs[2, k] - xs[2, l], p.L) r2 = rx * rx + ry * ryif r2 < rc * rc ρ += p.m *W(sqrt(r2), p.h)endendend loss += (ρ - p.ρ0)^2endreturn lossend
I0000 00:00:1791991487.502186 561125 se_gpu_pjrt_compiler.cc:422] Found GPU target config and a PjRtClient with matching configuration. Performing a JIT compilation.
I0000 00:00:1791991488.027130 561125 cuda_dnn.cc:478] Loaded cuDNN version 92100
cap = 23 loss = 12476.3617428792 ‖∇loss‖ = 7526.066606
# Idiomatic JAX: jax-md's neighbour list. It is a fixed-capacity padded pair list;# `allocate` (outside jit) sizes it from the data, `update` (inside jit) refills it,# and `did_buffer_overflow` must be checked after every call.NUMBER_DENSITY, H, MASS =10.0, 0.5, 1.0RC, RHO0 =2* H, NUMBER_DENSITYdef box_size(n):return math.sqrt(n / NUMBER_DENSITY)def W(r, h=H):"""2D cubic spline. No `if`: both branches are evaluated and selected.""" q = r / h sigma =10.0/ (7.0* math.pi * h * h) t = jnp.maximum(2.0- q, 0.0)return sigma * jnp.where(q <1.0, 1.0-1.5* q**2+0.75* q**3, 0.25* t**3)def make_loss(n, displacement): w0 = MASS *float(W(0.0)) # self-contribution; jax-md's pair list excludes i == jdef loss(x, nbrs): nbrs = nbrs.update(x) # rebuild the cell list from the current positions i, j = nbrs.idx mask = i < n # padded slots hold index n dR = jax.vmap(displacement)(x[i], x[j]) dR = jnp.where(mask[:, None], dR, 1.0) # padded dR = 0 would make ∇‖dR‖ NaN r = jnp.sqrt(jnp.sum(dR**2, axis=1)) w = jnp.where(mask, MASS * W(r), 0.0) rho = w0 + jax.ops.segment_sum(w, i, num_segments=n)return jnp.sum((rho - RHO0) **2), nbrsreturn lossclass Particles:"""Jitted loss and gradient with jax-md's overflow-and-reallocate protocol."""def__init__(self, x, capacity_multiplier=1.25):self.n = x.shape[0] L = box_size(self.n) displacement, _ = space.periodic(L)self.neighbor_fn = partition.neighbor_list( displacement, L, RC, dr_threshold=0.0, capacity_multiplier=capacity_multiplier, format=partition.Sparse)self.nbrs =self.neighbor_fn.allocate(x) loss = make_loss(self.n, displacement)self._loss = jax.jit(loss)self._grad = jax.jit(jax.grad(loss, has_aux=True))def _checked(self, f, x): out, nbrs = f(x, self.nbrs)if nbrs.did_buffer_overflow: # host sync, as in jax-md's examplesself.nbrs =self.neighbor_fn.allocate(x) out, nbrs = f(x, self.nbrs)self.nbrs = nbrsreturn outdef loss(self, x):returnself._checked(self._loss, x)def grad(self, x):returnself._checked(self._grad, x)
Shared definitions (Params, the kernel W, the minimum image, the input) are in common.jl.
Same gradient from all three
Each implementation computes the Float64 gradient for the same 1000 particles, checks it against finite differences, and saves it. The build fails unless all three agree.
implementation
‖g‖₂
max rel. diff vs enzyme
enzyme
7526.066606
0.0e+00
reactant
7526.066606
6.6e-16
jax
7526.066606
1.1e-15
Timings
Float64, median of 3–10 runs after warm-up, GPU synchronised. Each gradient includes rebuilding the cell or neighbour list. NVIDIA RTX 5000 Ada Laptop GPU and Intel i9-13980HX, with Enzyme on the CPU single-threaded and JAX on the CPU using XLA’s default threading.
Table 1: N = 1,000,000 particles
implementation
forward
gradient
gradient / forward
pair slots evaluated
compile [s]
enzyme (cpu)
1.42 s
4.78 s
3×
91 M
0.4
enzyme (gpu)
30.6 ms
2.57 s
84×
91 M
0.1
jax (cpu)
5.46 s
5.98 s
1×
39 M
13.9
reactant (gpu)
39.6 ms
196.1 ms
5×
234 M
17.7
jax (gpu), N = 1,000,000: RESOURCE_EXHAUSTED: Out of memory while trying to allocate 4.07GiB with allocator GPU_0_bfc on device 0.
“Pair slots evaluated” counts the candidate pairs each method computes a distance for. Enzyme visits exactly the particles in the 3×3 cells. JAX’s pair list is padded to 1.25× the pairs found at allocation. Reactant’s padded cell list reads cap slots per cell, with cap the largest cell occupancy.
What this shows
Differentiating the irregular algorithm works. Enzyme differentiates the mutating, sort-based, data-dependent loop code directly and gets the same gradient as JAX and Reactant to machine precision. No padding, no capacity, no overflow check, no where tricks to keep sqrt finite. On the CPU at \(N = 10^6\), single-threaded Enzyme takes 4.78 s for the gradient, against 5.98 s for multithreaded JAX. The forward pass alone takes 1.42 s against 5.46 s.
The padded version runs out of memory on the GPU. In Float64, jax-md’s neighbour list for \(10^6\) particles does not fit on the 16 GB GPU: building it needs a further 4 GiB intermediate on top of a pair list of 39 million slots. Enzyme’s cell list needs no capacity at all.
Reactant behaves like JAX. Reactant is Julia, but compiled through XLA, and it needs the same regularisation: padded cells, masks, a branch-free kernel. At \(N = 10^5\) its gradient takes 21.1 ms against JAX’s 10.3 ms. Its padded cell list is leaner than jax-md’s pair list (no stored pair indices), so it still fits at \(N = 10^6\) (196.1 ms). The constraint comes from the static-graph execution model, not from the language.
Where JAX wins / where Enzyme hurts
WarningOn the GPU, today, the padded versions are much faster than Enzyme’s
At \(N = 10^5\) the Enzyme GPU gradient takes 119.1 ms, against 10.3 ms for JAX and 21.1 ms for Reactant. At \(N = 10^6\) it takes 2.57 s, 84× its own forward pass.
The reason is how Enzyme compiles reverse-mode GPU kernels with a data-dependent inner loop: it stores values for every inner iteration in device malloc memory, in both the augmented forward kernel and the reverse kernel. With CUDA’s default 8 MB device heap this crashes above about 30 000 particles, so the notebook raises the heap to 4 GiB. A branch-free kernel and a fixed-trip-count kernel showed the same behaviour.
What we did not do:
No hand-written adjoint on either side. A JAX expert could write the neighbour loop as a Pallas kernel with a hand-written backward pass. An Enzyme user could restructure the GPU gradient as a forward kernel plus a gather kernel that applies Enzyme only to the scalar pair function. Both are faster, and both give up “the adjoint for free”.
No multithreaded Enzyme CPU version. The CPU numbers are single-threaded Julia against multithreaded XLA.
jax-md’s capacity_multiplier left at 1.25, its default.