Stencil performance deep dive
Scope: every operator in scripts/benchmark_operators.py (58 cases: difference, interpolation,
divergence, vorticity / PV fluxes, Coriolis, Arakawa Jacobian, diffusion, momentum advection,
advection and the spherical operators), measured forward, reverse-mode (VJP) and forward-mode
(JVP). Date: 2026-10-14. Baseline: main at 32ffb45.
The question: given that finitevolX stencils are plain jnp slicing (no kernex, no
convolutions, no custom kernels), how fast can each operator be made, and what is left once
the slicing is written well? Rules distilled from this note live in
Writing fast stencils.
One backend measured
Every number below is XLA:CPU (a 2-vCPU cloud VM, JAX 0.10.1, float32). The structural
quantities (jaxpr slice / pad counts, XLA fusion counts, temporary buffers) explain
why a rewrite is faster and transfer across backends; the ratios themselves must be
re-measured on GPU / TPU (§5, phase 3).
0. Summary
| # | Finding | Cost before | Status |
|---|---|---|---|
| 1 | Advection VJP is the dominant cost of the library. WENO-5 2-D: 0.93 ms forward, 34 ms VJP (36×). 83 % of the VJP is a single XLA fusion that sums the transposed pads. |
10–36× the forward for WENO / TVD / upwind-3 | Root-caused; not fixed — seven JAX-level fixes tried and rejected (§4). Plan in §5. |
| 2 | WENO-7 / WENO-9 smoothness indicators used jnp.stack + jnp.einsum over a 4×4 / 5×5 constant matrix → stack, dot and transposes. |
WENO-7 forward 33.5 ms vs WENO-5 0.93 ms | Fixed: forward 11×, JVP 6.3×, VJP 1.65× (WENO-9 forward 14×). |
| 3 | Composed linear stencils slice the same offset several times (diff_x_fwd(h) - diff_x_bwd(h) reads h[1:-1, 1:-1] twice); each slice expression transposes to its own pad. |
Laplacian VJP 2 fusions | Fixed (slice each offset once): Laplacian VJP 1.5–2.3×, spherical Laplacian 2.0–2.3×, Arakawa Jacobian VJP 4.0–4.4×. |
| 4 | Diffusion built full-size zero flux buffers (zeros().at[...].set(...)) only to re-slice them. |
Diffusion 4–6 fusions | Fixed: 2-D forward / VJP / JVP ~2×, one fusion; 3-D forward 2–3×. |
| 5 | Sums of padded operators (Difference3D.divergence = diff_x_U_to_T(u) + diff_y_V_to_T(v)) |
two passes + add | Fixed: forward 1.5–2.5×. |
| 6 | 3-D z-ghost zeroing by two scatters (.at[0].set(0).at[-1].set(0)) |
Fixed (pad of the interior slabs): Coriolis3D forward 1.6×, VJP fusions 6 → 4. |
|
| 7 | Biharmonic: XLA fused the first Laplacian into each of the four shifted reads of the second. | 11 VJP fusions | Fixed (_materialize): 5 fusions, forward 1.2–1.5×, VJP 1.4–1.9×. |
What did not need changing: single-offset differences and averages
(diff_*, avg_*, Interpolation*, Divergence2D, curl), interior() (XLA turns
zeros.at[1:-1, 1:-1].set(v) into a fused pad; it is free) and masked_laplacian
(jnp.roll lowers to the same slices + concatenate a hand-written wrap-pad does; §4).
These already run at 70–130 % of a 2x + 1 kernel moving the same number of bytes at 514²
(cache-resident, where the reference kernel is itself not at peak); after this PR the
Laplacian runs at 91 % forward and 79 % VJP of that roofline at 1026².
1. Method
- Harness:
scripts/benchmark_operators.py(extended in this PR with JVP timing,--repeatand--profile). Each case is jitted; forward,jax.vjp(op, *x)[1](ct)with a fixed random cotangent, andjax.jvp(op, x, t)are timed as the median of ≥0.3 s of calls,--repeat 3keeps the minimum of three medians. - Noise control:
mainand this branch were each run three times, alternating, and the tables report the minimum per side. Operators whose code did not change are listed as controls; they land at 0.95–1.08×, which is the noise floor. A speedup is only claimed when the fusion count or the slice count moved with it. - Grids: 514² (512² interior; 1 MB per float32 field, cache-resident) for everything, 1026² (4 MB, memory-bound) for the changed operators; 3-D cases use 10 levels.
- Profiles:
--profile REGEXruns the jitted VJP underjax.profiler.traceand ranks XLA kernels by time.
2. Results
2.1 Changed operators, 514² (min of 3 runs per side)
| operator | forward ms | × | VJP ms | × | JVP ms | × | VJP fusions |
|---|---|---|---|---|---|---|---|
Difference1D.laplacian |
0.077 → 0.079 | 0.97 | 0.236 → 0.101 | 2.34 | 0.082 → 0.078 | 1.05 | 2 → 1 |
Difference2D.laplacian |
0.095 → 0.090 | 1.06 | 0.230 → 0.130 | 1.76 | 0.087 → 0.091 | 0.96 | 2 → 1 |
Difference3D.divergence |
1.39 → 0.547 | 2.53 | 1.79 → 1.34 | 1.34 | 1.36 → 0.575 | 2.37 | 3 → 3 |
Difference3D.laplacian |
0.523 → 0.551 | 0.95 | 0.936 → 0.755 | 1.24 | 0.527 → 0.539 | 0.98 | 2 → 1 |
Coriolis3D |
1.38 → 0.848 | 1.62 | 1.96 → 1.82 | 1.08 | 1.38 → 0.899 | 1.53 | 6 → 4 |
ArakawaJacobian2D |
0.179 → 0.183 | 0.97 | 2.76 → 0.638 | 4.32 | 0.355 → 0.362 | 0.98 | 3 → 2 |
arakawa_jacobian |
0.132 → 0.126 | 1.05 | 2.56 → 0.641 | 3.99 | 0.225 → 0.223 | 1.01 | 3 → 2 |
Diffusion2D |
0.256 → 0.129 | 1.98 | 0.399 → 0.187 | 2.13 | 0.268 → 0.135 | 1.98 | 4 → 1 |
Diffusion2D[kappa field] |
0.300 → 0.158 | 1.89 | 0.508 → 0.399 | 1.27 | 0.361 → 0.252 | 1.43 | 5 → 2 |
BiharmonicDiffusion2D |
0.410 → 0.334 | 1.23 | 0.842 → 0.602 | 1.40 | 0.411 → 0.294 | 1.40 | 11 → 5 |
Diffusion3D |
1.61 → 0.807 | 2.00 | 2.37 → 1.55 | 1.53 | 1.70 → 0.814 | 2.09 | 6 → 2 |
SphericalLaplacian2D |
0.152 → 0.160 | 0.95 | 0.464 → 0.233 | 1.99 | 0.148 → 0.170 | 0.87 | 2 → 1 |
SphericalDifference2D.laplacian_merid |
0.143 → 0.131 | 1.09 | 0.254 → 0.214 | 1.19 | 0.130 → 0.130 | 1.00 | 1 → 1 |
SphericalDiffusion2D |
0.341 → 0.362 | 0.94 | 0.539 → 0.277 | 1.95 | 0.360 → 0.368 | 0.98 | 5 → 1 |
Advection2D[weno7] |
33.5 → 3.0 | 11.2 | 183 → 111 | 1.65 | 81.1 → 12.9 | 6.27 | 198 → 133 |
Advection2D[weno9] (forward only) |
57.4 → 4.2 | 13.8 | |||||
| controls (code unchanged) | |||||||
Interpolation2D.T_to_U |
0.074 → 0.069 | 1.07 | 0.078 → 0.075 | 1.04 | 1 → 1 | ||
Divergence2D |
0.093 → 0.095 | 0.98 | 0.205 → 0.200 | 1.03 | 3 → 3 | ||
Coriolis2D |
0.218 → 0.224 | 0.97 | 0.635 → 0.621 | 1.02 | 3 → 3 | ||
Advection2D[weno5] |
0.927 → 0.943 | 0.98 | 33.9 → 31.4 | 1.08 | 2.58 → 2.57 | 1.00 | 48 → 48 |
2.2 Changed operators, 1026² (memory-bound)
| operator | forward ms | × | VJP ms | × | JVP ms | × |
|---|---|---|---|---|---|---|
Difference2D.laplacian |
0.255 → 0.278 | 0.92 | 0.753 → 0.486 | 1.55 | 0.287 → 0.277 | 1.04 |
ArakawaJacobian2D |
0.593 → 0.583 | 1.02 | 10.6 → 2.41 | 4.40 | 1.22 → 1.17 | 1.05 |
Diffusion2D |
0.782 → 0.396 | 1.97 | 1.20 → 0.601 | 2.00 | 0.772 → 0.395 | 1.95 |
BiharmonicDiffusion2D |
1.32 → 0.856 | 1.54 | 3.54 → 1.86 | 1.90 | 1.71 → 0.901 | 1.90 |
Diffusion3D |
39.2 → 13.1 | 2.99 | 48.9 → 25.6 | 1.91 | 37.3 → 13.8 | 2.71 |
SphericalLaplacian2D |
0.520 → 0.507 | 1.02 | 1.61 → 0.716 | 2.25 | 0.519 → 0.489 | 1.06 |
SphericalDifference2D.laplacian_merid |
0.422 → 0.436 | 0.97 | 0.806 → 0.618 | 1.30 | ||
Difference3D.divergence |
18.7 → 12.6 | 1.48 | 25.1 → 24.9 | 1.01 | 17.6 → 12.6 | 1.39 |
Advection2D[weno7] |
164 → 15.4 | 10.6 | 961 → 577 | 1.67 | 390 → 89.5 | 4.36 |
Single runs; the 3-D cases at this size (40 MB per field) are the noisiest rows.
3. Root causes
3.1 The advection VJP (open)
Reverse-mode AD turns every static slice h[a:b, c:d] into a pad of that slice's cotangent,
and the input's cotangent is the sum of those pads. For a linear stencil the pad operands are
cheap (a coefficient times the output cotangent), and XLA:CPU emits one fused loop over the
input — the Laplacian VJP runs at 79 % of the elementwise roofline at 1026² after this PR.
For a nonlinear stencil the operand of each pad is the transposed stencil: for WENO-5 the
derivative of q̂ = Σ ω_r q_r / Σ ω_r with ω_r = γ_r / (β_r + ε)² with respect to one of its
five inputs, evaluated on both flow signs. XLA:CPU still fuses every pad and its producer into
one loop over the input (pad_add_fusion), so for each input point it evaluates the transposed
WENO once per shifted read (5 offsets × 2 flow signs), behind per-element bounds checks that
block vectorisation. Profile of Advection2D(method="weno5") VJP at 514²:
| kernel | ms | share |
|---|---|---|
pad_add_fusion |
35.1 | 83 % |
| next 7 kernels | 3.3 | 8 % |
| total | 42.2 |
The boundary fallbacks make it worse. Each reconstruction stitches WENO-3 / first-order columns
onto the WENO-5 bulk with jnp.concatenate; isolating that on the x-face flux:
| x-face WENO-5 flux, 514² | forward | VJP |
|---|---|---|
| bulk faces only (no fallbacks) | 0.28 ms, 1 fusion | 4.0 ms |
+ fallbacks stitched with concatenate |
0.78 ms, 8 fusions | 18.2 ms |
+ interior() (= the library's weno5_x) |
0.66 ms, 8 fusions | 22.4 ms |
So the bulk WENO VJP is ~14× its forward, and the stitching multiplies it by another ~4.5×.
The same structure explains weno3 (12×), wenoz5 (46×), van_leer (8.5×), upwind3 (5×)
and the masked path (upwind_flux blends three full-size tiers).
3.2 WENO-7 / WENO-9 smoothness indicators (fixed)
_smoothness_indicator evaluated βᵣ = qᵣᵀ Bᵣ qᵣ as
jnp.einsum("i...,ij,j...->...", jnp.stack(q), B, jnp.stack(q)). On a 4×4 or 5×5 constant
matrix that is a stack (materialised), a dot_general (a library dot call on XLA:CPU,
ynn_fusion) and transposes of the stacked field; the y-direction added swapaxes copies on top.
Profile before: concatenate_bitcast_fusion (the stacks), ynn_fusion (the dots) and
transpose_copy_fusion were the top kernels. After unrolling to
Σᵢ qᵢ (Σⱼ Bᵢⱼ qⱼ) with Python-float coefficients the indicators are elementwise code inside
one fusion; float64 results are bitwise identical, float32 within ~3e-6 relative.
3.3 One pad per slice expression (fixed)
(diff_x_fwd(h) - diff_x_bwd(h)) slices h[1:-1, 1:-1] twice. Forward, XLA CSEs the two
reads. Reverse, JAX transposes each slice equation separately, so the VJP sums two pads of
the same offset; with five offsets the Laplacian VJP had 8 pads and split into 2 fusions with
an intermediate. Binding each offset to one view (_neighbours(h)) makes JAX accumulate the
cotangent of that view before transposing (add_any), so the VJP has exactly one pad per
distinct offset (5 for the Laplacian, 16 for the Arakawa Jacobian, down from 32) and is one
fusion. The forward arithmetic is unchanged, so outputs are bitwise identical.
tests/test_stencil_lowering.py pins the pad counts.
3.4 Full-size intermediates (fixed)
Diffusion2D built flux_x = zeros_like(h).at[1:-1, 1:-2].set(...), multiplied by the mask and
re-sliced at two offsets for the divergence; the spherical diffusion did the same. Computing
only the faces the divergence reads, and entering the zero closed-wall faces as a pad of the
face array, removes the buffers: one fusion, forward and VJP. Difference3D.divergence
summed two full-size padded arrays; writing interior(a + b) fuses them.
zero_z_ghosts replaced two scatters into the full 3-D array by a pad of the interior slabs,
which also lets XLA skip computing the two discarded slabs.
3.5 Recomputation of a reused intermediate (fixed)
In the biharmonic, the second Laplacian reads the first at four offsets. With the rewritten
diffusion but without a barrier, XLA splits the forward into 5 fusions around the shifted
reads; with _materialize (lax.optimization_barrier) the first Laplacian is one buffer and
the forward is 2 fusions (VJP 11 → 5 fusions for rules 2 and 4 together). Verified by
comparing the compiled HLO with and without the barrier: they differ, so the barrier is not
stripped before fusion on CPU (it does not, however, survive on a cotangent; §4).
4. Negative results (tried on the advection VJP, not adopted)
All bitwise-checked against the library where they claim equality; timings at 514².
| Idea | Result |
|---|---|
| Slice each offset once in WENO-5 (rule 3.3) | bulk-faces VJP 3.9 → 6.5 ms (worse); library structure 1.2–1.5× faster in two runs, within the noise of that size |
| Mirror trick: select the 5 upwind inputs by flow sign, evaluate one WENO-5 | forward ≈ same, VJP 3.9 → 12.4 ms; not bitwise (β summation order) |
optimization_barrier on each view's cotangent (identity with a barriered tangent) |
fusion count unchanged (6 → 6), 3.3 → 3.2 ms |
jax.checkpoint (default, nothing_saveable, everything_saveable) |
15.0–18.9 ms vs 15.5 ms |
custom_jvp with the five partials precomputed (via jvp or vjp) and barriered |
14.8–22.7 ms vs 4.0 ms (XLA re-fuses the partials into the pads) |
custom_vjp with a gather-form adjoint |
5.1 vs 5.7 ms; loses forward mode (jax.jvp, 4DVar TLM) → rejected |
Fallback columns by pad + add, or .at[:, k].set, instead of concatenate |
bitwise equal; forward 2.4 / 0.97 vs 0.69 ms, VJP unchanged |
Fallbacks by jnp.where on a column index (one shared set of views, no concat) |
bitwise equal; full Advection2D forward 1.1 → 3.0 ms, VJP 46 → 55 ms |
masked_laplacian: jnp.roll → wrap-pad + slices, or explicit concatenate |
bitwise equal; identical compiled cost (roll already lowers to slices) |
The common thread: the expensive part is an XLA:CPU fusion decision (inline the transposed
nonlinear stencil into every pad), and nothing expressible in jnp slicing changed it. The
only lever that changes the transposed program by construction, custom_vjp, costs forward
mode and still did not win on CPU.
5. Plan of attack
Phase 1 — this PR
Rules 1–7 of Writing fast stencils applied to the operators in §2,
each rewrite commented with the composed form it equals; lowering tests (pad per offset,
equality with the composed form, no dot_general in WENO-7); benchmark harness with JVP,
repeats and kernel profiles.
Phase 2 — the advection adjoint (largest remaining cost)
Gate everything on numbers from the target backends first: if XLA:GPU's fusion cost model
already refuses to duplicate the transposed WENO (it treats divide-heavy producers as
expensive), the CPU result may not carry over.
| PR | Change | Gate to merge | Depends on |
|---|---|---|---|
| 2.1 | Run benchmark_operators.py --only Advection --repeat 3 on one GPU and one TPU; add the results here |
— | Phase 1 |
| 2.2 | Restructure the boundary fallbacks so the bulk reconstruction is never stitched with concatenate on the differentiated path (e.g. compute the closed-wall tendency from bulk faces and add the boundary-cell corrections as separate thin tendencies) |
bitwise-equal goldens; VJP ≥ 2× on at least one backend; no forward regression | 2.1 |
| 2.3 | Closed-form WENO partials ∂q̂/∂q_k = Σ_r ω_r ∂q_r/∂q_k + Σ_r ω_r (q_r − q̂) ∂ln α_r/∂q_k from at most nine shared intermediates, as a custom_jvp (keeps JVP and VJP, higher-order AD still works) |
VJP ≥ 2× without forward regression; JVP unchanged; second derivatives match autodiff | 2.1 |
| 2.4 | If 2.2–2.3 do not reach ~4× the forward on accelerators: a fused flux kernel (Pallas) behind the same API, with custom_vjp + a separate JVP path |
maintainer decision: AGENTS.md currently says pure jnp slicing |
2.3 |
Phase 3 — validate the rules off CPU
Re-run the whole suite on GPU / TPU; in particular check that _materialize (rule 4) and the
one-view-per-offset rule (rule 1) do not regress there, and record the ratios in
docs/performance.md.
Phase 4 — medium-cost operators
Each is 1–2 ms VJP at 514² with 7–15 fusions; apply rules 1–2:
Strain2D.okubo_weiss(14 VJP fusions): build the X-point∂v/∂xand∂u/∂yonce and share them between the shear strain and the vorticity instead of two_x_with_ghostspasses.MomentumAdvection2D(14–15): curl → vorticity flux →[2:-2]slice chains through full-sizeinterior()buffers.Vorticity2D.pv_flux_*(7): the sameinterior()chains (qu_at_q→avg_y_bwd).Advection2D[weno5, masked]:upwind_fluxevaluates three full-size tiers and blends them; restrict each tier to the faces whose mask selects it, or blend inside one fusion.
Phase 5 — guard rails
tests/test_stencil_lowering.py already fails on a regression to slice-per-term code; add a
fusion-count check for the Phase 1 operators behind an opt-in marker, and an optional
benchmark job on a GPU runner that posts --compare against main.