Each update reads values updated a moment earlier in the same sweep. Natural as a loop, awkward as an array program.
The problem
Solve \(-\Delta u = \theta\) on the unit square (\(n \times n\) interior points, \(u = 0\) on the boundary) with \(K\) Gauss–Seidel sweeps from \(u = 0\), and differentiate \[\mathcal{L}(\theta) = \lVert u_K(\theta) - u_{\text{obs}} \rVert^2\] with respect to the source \(\theta\). Each update is \[u_{ij} \leftarrow \tfrac14\left(h^2\theta_{ij} + u_{i-1,j} + u_{i+1,j} + u_{i,j-1} + u_{i,j+1}\right),\]in place: \(u_{i-1,j}\) and \(u_{i,j-1}\) are values already updated earlier in the same sweep. That is what makes Gauss–Seidel converge about twice as fast as Jacobi, and it is what makes it a sequential loop.
The same pattern appears in SOR, incomplete factorisations, the Thomas algorithm, upwind sweeps and line relaxation.
Two variants:
Lexicographic, the textbook sweep: a triple loop.
Red-black, which colours the grid like a chessboard so that each colour can be updated in parallel. It is a different iteration, with a different result, and it is the formulation a GPU or JAX user writes. Gradients are compared within each variant.
# Red-black Gauss–Seidel as a KernelAbstractions kernel (runs on CPU and GPU): points# of one colour read only the other colour, so all threads of a colour are independent.@kernelfunctionrb_half_sweep!(u, @Const(θ), colour) I =@index(Global, Cartesian) i, j = I[1] +1, I[2] +1 h2 =one(eltype(u)) / (size(θ, 1) +1)^2# computed here: GPU kernels take no active scalars@inboundsif (i + j) %2== colour u[i, j] = (h2 * θ[i-1, j-1] + u[i-1, j] + u[i+1, j] + u[i, j-1] + u[i, j+1]) /4endend@kernelfunctionsqdiff!(e, @Const(u), @Const(uobs)) I =@index(Global, Cartesian)@inboundse[I] = (u[I[1]+1, I[2]+1] - uobs[I])^2end"Loss terms e (the loss is sum(e)); u is the in-place solution buffer."functionrb_terms!(e, u, θ, uobs, K) backend, n =get_backend(u), size(θ, 1)fill!(u, 0)for _ in1:K, colour in (0, 1)rb_half_sweep!(backend)(u, θ, colour; ndrange=(n, n))endsqdiff!(backend)(e, u, uobs; ndrange=(n, n))returnnothingendfunctionrb_loss(θ, uobs, K) n =size(θ, 1)e, u =similar(θ), similar(θ, n +2, n +2)rb_terms!(e, u, θ, uobs, K)returnsum(e)end
rb_loss (generic function with 1 method)
functionrb_gradient(θ, uobs, K) n =size(θ, 1)e, de =similar(θ), fill!(similar(θ), 1) u, du =similar(θ, n +2, n +2), fill!(similar(θ, n +2, n +2), 0) dθ =fill!(similar(θ), 0) Enzyme.autodiff(Reverse, rb_terms!, Const, Duplicated(e, de), Duplicated(u, du),Duplicated(θ, dθ), Const(uobs), Const(K))return dθendg_rb =rb_gradient(θ0, uobs0, K_CHECK)g_rb_gpu =Array(rb_gradient(CuArray(θ0), CuArray(uobs0), K_CHECK))@printf"loss = %.12f ‖∇loss‖ = %.6e GPU vs CPU: %.1e\n"rb_loss(θ0, uobs0, K_CHECK) sqrt(sum(abs2, g_rb)) maximum(abs, g_rb_gpu - g_rb) /maximum(abs, g_rb)
loss = 0.105843754705 ‖∇loss‖ = 6.366981e-04 GPU vs CPU: 3.2e-16
Lexicographic:
# Lexicographic Gauss–Seidel. Arrays are immutable and loop bodies must be pure, so# the sweep is a scan over columns, each a scan over rows carrying the new u[i-1, j].# Same update order as the Julia loop (i fastest, then j).@functools.partial(jax.jit, static_argnums=2)def gs_loss(theta, uobs, K): n = theta.shape[0] h2 =1.0/ (n +1) **2def column(u, j): left, old, right = u[:, j], u[:, j +1], u[:, j +2]def point(below, i): # below = new u[i-1, j] new = (h2 * theta[i, j] + below + old[i +2] + left[i +1] + right[i +1]) /4return new, new _, col = jax.lax.scan(point, old[0], jnp.arange(n))return u.at[1:n +1, j +1].set(col), Nonedef sweep(u, _):return jax.lax.scan(column, u, jnp.arange(n))[0], None u, _ = jax.lax.scan(sweep, jnp.zeros((n +2, n +2), theta.dtype), None, length=K)return jnp.sum((u[1:-1, 1:-1] - uobs) **2)
# The same triple loop as the Enzyme version. Loops over traced data must be marked# @trace, and indexing a traced array at a traced position must be explicitly allowed.# Each scalar update becomes one step of an XLA while loop.functiongs_loss(θ, uobs, K) n =size(θ, 1) h2 =1.0/ (n +1)^2 u = Reactant.Ops.fill(0.0, (n +2, n +2))@tracefor _ in1:K@tracefor j in2:n+1@tracefor i in2:n+1 Reactant.@allowscalar u[i, j] = (h2 * θ[i-1, j-1] + u[i-1, j] + u[i+1, j] + u[i, j-1] + u[i, j+1]) /4endendendreturnsum((u[2:end-1, 2:end-1] .- uobs) .^2)end
# Red-black as array operations: one masked stencil per colour. The colour mask is built# from traced iota arrays rather than captured as a Julia constant: with a constant mask# compilation took minutes at n = 64.functionrb_loss(θ, uobs, K) n =size(θ, 1) h2 =1.0/ (n +1)^2 i = Reactant.Ops.iota(Int64, [n, n]; iota_dimension=1) j = Reactant.Ops.iota(Int64, [n, n]; iota_dimension=2) red =iseven.(i .+ j) u = Reactant.Ops.fill(0.0, (n +2, n +2))@tracefor _ in1:Kfor colour in (red, .!red)new= (h2 .* θ .+ u[1:end-2, 2:end-1] .+ u[3:end, 2:end-1] .+ u[2:end-1, 1:end-2] .+ u[2:end-1, 3:end]) ./4 u[2:end-1, 2:end-1] =ifelse.(colour, new, u[2:end-1, 2:end-1])endendreturnsum((u[2:end-1, 2:end-1] .- uobs) .^2)end
I0000 00:00:1792007279.908700 31484 se_gpu_pjrt_compiler.cc:422] Found GPU target config and a PjRtClient with matching configuration. Performing a JIT compilation.
I0000 00:00:1792007280.026785 31484 cuda_dnn.cc:478] Loaded cuDNN version 92100
‖∇loss‖: lexicographic 6.409859e-04, red-black 6.366981e-04
Same gradient
\(n = 32\), \(K = 20\), Float64. Each gradient is also checked against finite differences.
Table 1: lexicographic
implementation
‖g‖₂
max rel. diff vs enzyme-lex
enzyme-lex
6.409859e-04
0.0e+00
reactant-lex
6.409859e-04
4.3e-16
jax-lex
6.409859e-04
2.1e-16
Table 2: red-black
implementation
‖g‖₂
max rel. diff vs enzyme-rb
enzyme-rb
6.366981e-04
0.0e+00
reactant-rb
6.366981e-04
5.4e-16
jax-rb
6.366981e-04
4.3e-16
Timings
\(K = 100\) sweeps, Float64, median of 3–10 runs after warm-up. A sweep stops once a single gradient takes more than 5 s.
What this shows
The sequential sweep on the CPU. At \(n = 512\) the lexicographic gradient takes 325.7 ms with Enzyme and 19.7 s with JAX, although their forward passes are close (177.6 ms and 517.8 ms). Enzyme’s gradient costs 1.8× its forward pass; JAX’s costs 38×. JAX expresses the sweep as nested lax.scans, and the transposed scan of a scan does much more work per step than the scalar loop it came from.
The sequential sweep on the GPU. At \(n = 64\) the lexicographic gradient takes 7.3 s with JAX and 8.3 s with Reactant, against 4.1 ms for Enzyme on one CPU core. Every scalar update becomes a step of an XLA loop, which on the GPU means a device round trip per grid point. Reactant traces the same Julia triple loop as Enzyme, so the language is not the issue: the loop is slow once it goes through XLA.
Memory does not grow with K, for either tool. We expected reverse mode through \(K\) in-place sweeps to store \(K\) copies of \(u\). It doesn’t, because each update is linear: its derivative is the constant \(\tfrac14\), so the backward pass needs no stored values. Both Enzyme’s activity analysis and JAX’s linearisation see this.
Table 3: Gradient memory [MiB], lexicographic, n = 256 (Enzyme: bytes allocated by the call; JAX: XLA temp buffers)
impl
enzyme
jax
K
10
1.52
2.04
100
1.52
2.04
1000
1.52
2.04
A nonlinear sweep, such as nonlinear Gauss–Seidel with a local Newton step, would need stored values in both tools and make memory a real issue. That is not tested here.
Where JAX wins / what we did not do
Note
Red-black on the GPU is where the XLA tools shine. At \(n = 1024\) the red-black gradient takes 16.8 ms with Reactant, 20.0 ms with JAX and 21.9 ms with Enzyme through KernelAbstractions; on small grids Enzyme is several times slower. Enzyme launches two kernels per sweep, forward and reverse, while XLA compiles the whole loop into one program. That launch overhead dominates on small grids.
A JAX expert can rewrite the lexicographic sweep without a scalar loop. Along each column the update is a first-order linear recurrence, which jax.lax.associative_scan evaluates in parallel. That is a different algorithm, with different rounding and \(O(\log n)\) depth, and you have to know the trick.
No Enzyme multithreading and no fused multi-sweep GPU kernel.