Chapter 11: Scientific Computing and Differentiable Simulation

Environment: JAX / jaxlib 0.12.0.1, CPU only, Python 3.14, using only the installed jax, numpy, scipy, optax and matplotlib. This chapter uses float64 throughout, and every conclusion was run in this environment and verified with assert.

Most examples in earlier chapters were machine learning. This chapter moves to computational mechanics / numerical PDEs / mesh generation: the same grad, jit, vmap and scan, applied to finite-difference operators, Krylov solvers, adjoint sensitivities and mesh optimization.

Learning goals

  1. Understand where JAX fits in scientific computing: inverse problems, sensitivity analysis and design optimization all rely on automatic differentiation; parameter sweeps rely on jit + vmap; and know why float64 must be turned on at the very start.
  2. Write a matrix-free five-point finite-difference operator, solve the Poisson equation with jax.scipy.sparse.linalg.cg, and verify second-order convergence.
  3. Assemble sparse matrices with jax.experimental.sparse.BCOO, do matrix-vector products, use sparsify, and know the limits of JAX's sparse support.
  4. Master implicit differentiation of solvers: the math behind the adjoint method, how to use lax.custom_linear_solve and lax.custom_root, and why it uses less memory and is more accurate than "differentiating through the unrolled iterations".
  5. Use grad + Optax (L-BFGS / Adam) to recover a spatially varying coefficient field $k(x)$ from observed data.
  6. Use lax.scan for explicit / Crank–Nicolson time stepping, compute sensitivities with respect to physical parameters and initial conditions, and control memory for long time integration with jax.checkpoint.
  7. Define mesh quality metrics, do differentiable mesh optimization with a Winslow-type energy + a barrier function, and write a numerically stable (double-where) barrier.
  8. Use vmap to solve a PDE for a whole batch of parameters at once.

Contents

SectionContent
0Environment: turn on float64 and assert it
1Why use JAX for scientific computing; why float64 is necessary (with ❌/✅ comparisons)
2Finite-difference Poisson: matrix-free operator, solving with cg, second-order convergence
3Sparse matrices: BCOO assembly, matvec, sparsify, cross-check against SciPy CSR, current status and limits
4Implicit differentiation of solvers: the adjoint method, unrolled differentiation vs custom_linear_solve, custom_root
5Inverse problem: recovering $k(x)$ in a variable-coefficient diffusion equation
6Time-dependent problems: heat equation, explicit / Crank–Nicolson, sensitivities, checkpoint
7Differentiable mesh optimization: quality metrics, Laplace vs Winslow, barriers and double where
8Parameter sweeps: batched solves with vmap
9A checklist of practical rules (including a check of the closure-constant bug under float64)
—Exercises (4) / Quiz (6) / Chapter summary / Cheat sheet

How this relates to earlier chapters (what was already covered isn't repeated)

  • The implicit function theorem, writing fixed-point derivatives with custom_vjp, DEQ implicit layers and the continuous ODE adjoint method were covered in Advanced 04: Advanced automatic differentiation (the 4.6 extra, the 4.10 hands-on, Extra exercise C). This chapter doesn't hand-write custom_vjp again; instead it uses the two primitives JAX provides specifically for linear / nonlinear solvers, lax.custom_linear_solve and lax.custom_root, and applies the adjoint method to PDEs.
  • For how to use lax.scan, the RK4 integrator, and the basics of checkpoint + scan, see Advanced 01: Advanced transformations and control flow (sections 1.2, 1.5, 1.8); for why while_loop can't be differentiated in reverse mode, see section 4.8 of Advanced 04. The time stepping in this chapter focuses only on PDE-specific issues: stability conditions, implicit schemes, and $O(\sqrt{T})$ checkpointing for long time integration.
  • Checkpoint policies and $\sqrt{L}$ segmentation are covered systematically in section 6.6 of Advanced 06: Large-scale model training.
  • How the "double where" trick works is in the extra in section 3.8 of Basics 03: Automatic differentiation with grad; section 7 of this chapter uses it in a mesh barrier function.
  • For this environment's CPU backend bug, see 00 Overview.

⚠️ Environment note: the CPU backend of jaxlib 0.12.0.1 has a known bug: a constant matrix captured by a jit closure, used in W @ x and then summed (or maxed), silently returns wrong results (see 00 Overview, "Known issues in this environment"). Section 9 checks how it behaves under float64. Whatever the result, this chapter follows one rule: operator matrices, coefficient fields and observed data are always passed in as function arguments, and key numbers are cross-checked against NumPy / SciPy float64.

0 Environment: turn on float64

jax.config.update("jax_enable_x64", True) must go at the very top of the first code cell, before any JAX array is created. Section 1.2 shows why with a ❌/✅ comparison. Right after the cell below turns it on, we assert that the default dtype is now float64.

# First thing: turn on 64-bit precision (must happen before any JAX array is created)
import jax
jax.config.update("jax_enable_x64", True)

import jax.numpy as jnp

# Self-check: the default float type, random numbers and Python scalar conversion are all float64
assert jnp.zeros(3).dtype == jnp.float64
assert jnp.asarray(1.0).dtype == jnp.float64
assert jax.random.normal(jax.random.key(0), (2,)).dtype == jnp.float64
assert jnp.arange(3).dtype == jnp.int64
print("Default float dtype:", jnp.zeros(3).dtype)
Default float dtype: float64
# Common imports
import os, sys, time, json, inspect, subprocess, textwrap
from functools import partial

import numpy as np
import scipy
import scipy.linalg
import scipy.sparse as sps
import scipy.sparse.linalg as spsl
import optax
import matplotlib
import matplotlib.pyplot as plt
from jax import lax
import jax.scipy.sparse.linalg as jsl
from jax.experimental import sparse
from jax.ad_checkpoint import print_saved_residuals

plt.rcParams["font.sans-serif"] = ["PingFang SC", "Heiti SC", "Arial Unicode MS", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False

print("JAX", jax.__version__, "| optax", optax.__version__, "| scipy", scipy.__version__,
      "| numpy", np.__version__, "| matplotlib", matplotlib.__version__)
print("Devices:", jax.devices())

def rel(a, b):
    """Relative error ||a-b|| / ||b|| (computed after converting everything to NumPy float64)"""
    a, b = np.asarray(a, np.float64), np.asarray(b, np.float64)
    return float(np.linalg.norm(a - b) / np.linalg.norm(b))

def timeit(fn, *args, n=20):
    """Warm up (compile) first, then time n runs and return the mean in milliseconds. Only for printing, never asserted."""
    jax.block_until_ready(fn(*args))
    t = time.perf_counter()
    for _ in range(n):
        jax.block_until_ready(fn(*args))
    return (time.perf_counter() - t) / n * 1e3
JAX 0.12.0.1 | optax 0.2.9 | scipy 1.18.1 | numpy 2.5.4 | matplotlib 3.11.2
Devices: [CpuDevice(id=0)]

1 Why use JAX for scientific computing

1.1 Three core benefits

⚪ Optional: if you want to know where JAX fits in scientific computing and what it costs

(1) Automatic differentiation makes "taking derivatives" no longer a separate engineering job. Lots of problems in computational mechanics need derivatives:

ProblemDerivative neededTraditional approach
Inverse problems / parameter identification: inferring material coefficients, boundary conditions or source terms from observationsGradient of the loss with respect to the whole coefficient field (thousands of parameters)Derive and hand-code the adjoint equations; error-prone, and has to be rewritten whenever the model changes
Sensitivity analysis: how fast an output changes with a parameter$\partial Q/\partial p$Finite differences: one PDE solve per parameter, plus step-size tuning
Design optimization: shape optimization, topology optimization, mesh optimizationGradient of the objective with respect to the design variablesSame as above

Reverse-mode AD computes the gradient of a scalar objective with respect to $n$ parameters for roughly the cost of a few forward solves, independent of $n$. Combined with implicit differentiation (section 4), even iterative solvers can be differentiated for the cost of "one extra adjoint solve".

(2) jit + vmap suit parameter sweeps and UQ. Write a solver once, and after vmap it solves a batch of parameters, load cases or random samples in one go (section 8); jit compiles the whole pipeline into one XLA program.

(3) Composability. grad(vmap(solve)), vmap(grad(loss)) and jit(scan(step)) nest freely, and solvers, optimizers and post-processing are all written as pure functions.

Be clear about the costs too: JAX has no mature sparse direct solver (section 3), and for irregular meshes you assemble things yourself with gather/scatter; it's not friendly to dynamic shapes (adaptive refinement); on some GPUs (consumer cards) float64 throughput is only 1/32 to 1/64 of float32; TPUs have no native float64.

1.2 Why float64 matters

🟢 Must-read

Scientific computing is much more demanding about precision than training neural networks:

  • Condition number: the condition number of the 2D Poisson five-point matrix is about $\kappa \approx \dfrac{4}{\pi^2 h^2}$, which is about $2.6\times10^4$ for $N=255$. float32 machine epsilon is $\varepsilon\approx 1.2\times10^{-7}$, so by the perturbation bound the relative error of the solution can be as bad as $\kappa\,\varepsilon\sim 3\times10^{-3}$. That's an upper bound and the actual error is usually much smaller (running $N=255$ in float32 below gives about $2\times10^{-5}$). But on fine meshes the discretization error itself is only around $10^{-5}$. When rounding error is about as big as discretization error, you can't measure the convergence order reliably.
  • Gradient checks: for a central difference with step $\eta$, the error is roughly a truncation term $O(\eta^2)$ plus a rounding term $O(\varepsilon/\eta)$. The best step is $\eta\sim\varepsilon^{1/3}$, which gives a relative error around $\varepsilon^{2/3}$. In float32 the best you get is around $10^{-5}$ (4 to 5 significant digits); if you keep the float64 habit of $\eta=10^{-6}$, only 2 to 3 digits are left (both are verified below). In float64 you can cross-check down to around $10^{-10}$.
  • Solver tolerance: in float32 the true relative residual of CG stalls around $10^{-5}$ no matter how small you set tol (verified below with $N=63$ and a random right-hand side), and the accuracy of adjoint gradients is limited along with it.

Why it must come first: jax_enable_x64 decides the default type for "arrays created and traces started from now on". Arrays created before it's turned on are still float32, and a float32 array times a Python float (weakly typed) is still float32, so single-precision data quietly sneaks into the program. Below we demonstrate both mistakes in a subprocess (the current kernel already has x64 on, so we can't reproduce them here).

# ❌ Wrong: create arrays first, turn on x64 later; the existing arrays stay float32 and spread through weakly typed operations
probe = textwrap.dedent("""
    import jax, jax.numpy as jnp, json
    a = jnp.ones(3)                                  # the default is still float32 at this point
    jax.config.update("jax_enable_x64", True)        # too late
    b = jnp.ones(3)
    print(json.dumps({"a": str(a.dtype), "b": str(b.dtype), "a*1.0": str((a * 1.0).dtype),
                      "a+b": str((a + b).dtype)}))
""")
try:
    r = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, timeout=120,
                       env={k: v for k, v in os.environ.items() if k != "JAX_ENABLE_X64"})
    late = json.loads(r.stdout.strip().splitlines()[-1])
    print("Measured dtypes:", late)
except Exception as e:
    late = None
    print("Subprocess failed:", type(e).__name__, e)
assert late["a"] == "float32" and late["a*1.0"] == "float32"   # the old array is still single precision, and so is the result of multiplying by a scalar
assert late["b"] == "float64"
Measured dtypes: {'a': 'float32', 'b': 'float64', 'a*1.0': 'float32', 'a+b': 'float64'}
# ✅ Correct: update first, then create any arrays (also checked in a subprocess; section 0 of this kernel does exactly this)
probe_ok = textwrap.dedent("""
    import jax
    jax.config.update("jax_enable_x64", True)        # first thing
    import jax.numpy as jnp, json
    a = jnp.ones(3)
    print(json.dumps({"a": str(a.dtype), "a*1.0": str((a * 1.0).dtype)}))
""")
r = subprocess.run([sys.executable, "-c", probe_ok], capture_output=True, text=True, timeout=120,
                   env={k: v for k, v in os.environ.items() if k != "JAX_ENABLE_X64"})
early = json.loads(r.stdout.strip().splitlines()[-1])
print("Measured dtypes:", early)
assert early == {"a": "float64", "a*1.0": "float64"}
assert jnp.ones(3).dtype == jnp.float64 and (jnp.ones(3) * 1.0).dtype == jnp.float64   # this kernel
Measured dtypes: {'a': 'float64', 'a*1.0': 'float64'}
# ❌ Wrong: doing a Poisson convergence study in float32; on fine meshes rounding error swamps discretization error and second-order convergence "disappears"
probe32 = textwrap.dedent("""
    import jax, jax.numpy as jnp, json
    import jax.scipy.sparse.linalg as jsl
    assert jnp.zeros(1).dtype == jnp.float32
    def neg_lap(u, h):
        up = jnp.pad(u, 1)
        return (4 * u - up[:-2, 1:-1] - up[2:, 1:-1] - up[1:-1, :-2] - up[1:-1, 2:]) / h**2
    solve = jax.jit(lambda f, h: jsl.cg(lambda v: neg_lap(v, h), f, tol=1e-10, maxiter=4 * f.size)[0])
    errs = []
    for N in [15, 31, 63, 127, 255]:
        h = 1.0 / (N + 1); x = jnp.linspace(h, 1 - h, N); X, Y = jnp.meshgrid(x, x, indexing="ij")
        ue = jnp.sin(jnp.pi * X) * jnp.sin(jnp.pi * Y)
        errs.append(float(jnp.max(jnp.abs(solve(2 * jnp.pi**2 * ue, h) - ue))))
    fr = jax.random.normal(jax.random.key(0), (63, 63))           # random right-hand side (not an eigenvector)
    res63 = float(jnp.linalg.norm(neg_lap(solve(fr, 1 / 64), 1 / 64) - fr) / jnp.linalg.norm(fr))
    g = lambda a: jnp.sum(jnp.sin(a * jnp.arange(10.0)))
    a, eps = jnp.float32(0.7), 1e-6
    print(json.dumps({"errs": errs, "fd": float((g(a + eps) - g(a - eps)) / (2 * eps)), "ad": float(jax.grad(g)(a)),
                      "fd_best": float((g(a + 1e-3) - g(a - 1e-3)) / 2e-3), "res63": res63}))
""")
try:
    r = subprocess.run([sys.executable, "-c", probe32], capture_output=True, text=True, timeout=300,
                       env={k: v for k, v in os.environ.items() if k != "JAX_ENABLE_X64"})
    res32 = json.loads(r.stdout.strip().splitlines()[-1])
    e32 = np.array(res32["errs"])
    print("float32 max error:", np.array2string(e32, precision=3))
    print("Error ratio between consecutive meshes:", np.round(e32[:-1] / e32[1:], 3), "  (should be about 4 for second-order convergence)")
    print(f"float32 finite-difference gradient {res32['fd']:.6f}  vs  AD gradient {res32['ad']:.6f}"
          f"  relative error {abs(res32['fd'] - res32['ad']) / abs(res32['ad']):.1e}")
    print(f"float32 finite difference (step 1e-3, near optimal) relative error {abs(res32['fd_best'] - res32['ad']) / abs(res32['ad']):.1e}"
          f"; true relative residual of CG (tol=1e-10, N=63, random right-hand side) {res32['res63']:.1e}")
except Exception as e:
    res32 = None
    print("Subprocess failed:", type(e).__name__, e)
assert e32[-2] / e32[-1] < 3.5                                     # the error ratio at the finest level is clearly off from 4
assert abs(res32["fd"] - res32["ad"]) / abs(res32["ad"]) > 1e-3    # with step 1e-6, float32 finite differences keep only 2 to 3 significant digits
assert 1e-6 < abs(res32["fd_best"] - res32["ad"]) / abs(res32["ad"]) < 1e-4   # even near the optimal step, only 4 to 5 digits
assert res32["res63"] > 1e-7                                       # CG's true residual can't get below 1e-7
float32 max error: [3.219e-03 8.037e-04 2.034e-04 5.102e-05 2.027e-05]
Error ratio between consecutive meshes: [4.005 3.952 3.986 2.518]   (should be about 4 for second-order convergence)
float32 finite-difference gradient 4.678033  vs  AD gradient 4.706342  relative error 6.0e-03
float32 finite difference (step 1e-3, near optimal) relative error 8.7e-06; true relative residual of CG (tol=1e-10, N=63, random right-hand side) 8.4e-06
# ✅ Correct: gradient check in float64 (for the convergence study see section 2; second-order convergence holds all the way to the finest mesh)
g = lambda a: jnp.sum(jnp.sin(a * jnp.arange(10.0)))
a0, eps = 0.7, 1e-6
fd64 = (g(a0 + eps) - g(a0 - eps)) / (2 * eps)
ad64 = jax.grad(g)(a0)
print(f"float64 finite difference {float(fd64):.10f}  vs  AD {float(ad64):.10f}  relative error {abs(fd64 - ad64) / abs(ad64):.1e}")
assert abs(fd64 - ad64) / abs(ad64) < 1e-8
# For the Poisson convergence order in float64, see section 2
float64 finite difference 4.7063417735  vs  AD 4.7063417736  relative error 1.7e-11

🔍 Extra: two other ways to turn on x64, and one cost

⚪ Optional: if you want to turn on x64 with an environment variable or a context manager, or need local single precision

  • You can also set the environment variable JAX_ENABLE_X64=1 before starting Python, or use the context manager jax.enable_x64(). The latter still works in 0.12, but it only affects arrays created and traces started inside the context, which makes mixing easier to get wrong. For a scientific computing notebook, the safest choice is still jax.config.update on the first line.
  • Once x64 is on, integers default to int64 too (we asserted the type of jnp.arange above), and BCOO index arrays become int64 as well, doubling their memory.
  • When you need "local single precision" (for example large-scale preprocessing on a GPU), write astype(jnp.float32) explicitly; but note the conclusion of section 9: even with x64 on, explicitly float32 arrays still trigger the closure-constant bug.

2 The finite-difference Poisson equation

2.1 Problem and discretization

🟢 Must-read

On the unit square $\Omega=(0,1)^2$, solve

$$-\Delta u = f \quad \text{in } \Omega,\qquad u = 0 \quad \text{on } \partial\Omega .$$

With $N\times N$ interior nodes and step $h = 1/(N+1)$, the five-point finite-difference scheme is

$$(A_h u)_{ij} = \frac{4u_{ij} - u_{i-1,j} - u_{i+1,j} - u_{i,j-1} - u_{i,j+1}}{h^2} = f_{ij},$$

where neighbors beyond the boundary take $u=0$ (homogeneous Dirichlet). $A_h$ is symmetric positive definite, and the local truncation error is $O(h^2)$.

Manufactured solution: take $u^\ast = e^{x + y/2}\sin(\pi x)\sin(\pi y)$ (zero on the boundary). We don't derive the source term $f = -\Delta u^\ast$ by hand; we compute it directly with jax.hessian, a small use of AD in numerical analysis. Each time the mesh is refined by a factor of 2, the max error should shrink to about $1/4$.

Why not use the more common $\sin(\pi x)\sin(\pi y)$? It happens to be a discrete eigenvector of the five-point operator, so CG converges exactly in one step, which tells you nothing about how the solver really performs (the extra in section 2.3 runs into this).

2.2 The matrix-free operator

🟢 Must-read

CG only needs one function, "given $v$, return $A_h v$"; it doesn't need the matrix itself. On a 2D grid we can write the five-point stencil directly with array slices:

  • No matrix assembly: memory is just one $N\times N$ array, and after jit the operator fuses into a single loop;
  • Differentiable: coefficients and source terms are just ordinary arrays, so grad passes straight through;
  • Easy to vmap: there's no sparse-format index structure.

The boundary condition is implemented by padding a ring of zeros with jnp.pad(u, 1). If you cut corners and use jnp.roll directly, you get periodic boundary conditions; see the ❌/✅ comparison below.

def neg_lap(u, h):
    """Matrix-free five-point operator -Δ_h u (homogeneous Dirichlet boundary, implemented by zero padding)"""
    up = jnp.pad(u, 1)
    return (4 * u - up[:-2, 1:-1] - up[2:, 1:-1] - up[1:-1, :-2] - up[1:-1, 2:]) / h**2

def scipy_neg_lap(N, h):
    """SciPy reference: assemble the same matrix with Kronecker products (CSR, float64)"""
    T = sps.diags([-np.ones(N - 1), 2 * np.ones(N), -np.ones(N - 1)], [-1, 0, 1])
    I = sps.identity(N)
    return ((sps.kron(I, T) + sps.kron(T, I)) / h**2).tocsr()

N = 31; h = 1.0 / (N + 1)
v_np = np.random.default_rng(0).standard_normal((N, N))
A_ref = scipy_neg_lap(N, h)
ref = (A_ref @ v_np.ravel()).reshape(N, N)            # row-major flattening, matching the ordering of kron(I, T) + kron(T, I)
err_pad = rel(neg_lap(jnp.asarray(v_np), h), ref)
print(f"pad-and-slice operator vs SciPy CSR: relative error {err_pad:.1e}")
assert err_pad < 1e-14
pad-and-slice operator vs SciPy CSR: relative error 1.1e-16
# ❌ Wrong: taking neighbors directly with jnp.roll; roll wraps values from the opposite edge around, which amounts to periodic boundary conditions
def neg_lap_roll_wrong(u, h):
    return (4 * u - jnp.roll(u, 1, 0) - jnp.roll(u, -1, 0) - jnp.roll(u, 1, 1) - jnp.roll(u, -1, 1)) / h**2

try:
    err_roll = rel(neg_lap_roll_wrong(jnp.asarray(v_np), h), ref)
    out_roll = np.asarray(neg_lap_roll_wrong(jnp.asarray(v_np), h))
    err_inner = rel(out_roll[1:-1, 1:-1], ref[1:-1, 1:-1])
    err_edge = rel(out_roll[0], ref[0])
    print(f"plain-roll operator vs SciPy (Dirichlet): overall relative error {err_roll:.3f}; "
          f"interior nodes {err_inner:.1e}, first row {err_edge:.3f}; no error raised, but every node next to the boundary is wrong")
except Exception as e:
    err_roll = None
    print(type(e).__name__, e)
assert err_roll > 0.05 and err_edge > 0.1 and err_inner < 1e-14
plain-roll operator vs SciPy (Dirichlet): overall relative error 0.085; interior nodes 1.1e-16, first row 0.351; no error raised, but every node next to the boundary is wrong
# ✅ Correct: pad a ring of zeros first, roll on the padded array, then crop the outer ring (exactly equivalent to the slicing version)
def neg_lap_roll(u, h):
    up = jnp.pad(u, 1)
    r = (4 * up - jnp.roll(up, 1, 0) - jnp.roll(up, -1, 0) - jnp.roll(up, 1, 1) - jnp.roll(up, -1, 1)) / h**2
    return r[1:-1, 1:-1]

err_roll_ok = rel(neg_lap_roll(jnp.asarray(v_np), h), ref)
print(f"pad + roll operator vs SciPy: relative error {err_roll_ok:.1e}")
assert err_roll_ok < 1e-14
assert jnp.allclose(neg_lap_roll(jnp.asarray(v_np), h), neg_lap(jnp.asarray(v_np), h), rtol=0, atol=1e-9)
pad + roll operator vs SciPy: relative error 1.1e-16

2.3 Solving with jax.scipy.sparse.linalg.cg

🟢 Must-read

In jsl.cg(A, b, x0=None, *, tol=1e-05, atol=0.0, maxiter=None, M=None), A can be a matrix or a function $v\mapsto Av$. b can be an array of any shape (even a pytree), so the solution on a 2D grid doesn't need to be flattened.

Note that h and f are both arguments of solve_poisson. The lambda v: neg_lap(v, h) closure inside CG captures a traced value, not a constant, so it isn't affected by the closure-constant bug. maxiter must be a Python integer; here it's derived from f.size (a static shape).

@jax.jit
def solve_poisson(f, h):
    """-Δu = f, homogeneous Dirichlet; the operator is passed to cg as a function"""
    u, info = jsl.cg(lambda v: neg_lap(v, h), f, tol=1e-12, maxiter=10 * f.size)
    return u

def u_star(p):
    """Manufactured solution u*(x, y), zero on the boundary of the unit square"""
    x, y = p
    return jnp.exp(x + 0.5 * y) * jnp.sin(jnp.pi * x) * jnp.sin(jnp.pi * y)

f_star = lambda p: -jnp.trace(jax.hessian(u_star)(p))       # f = -Δu*, computed automatically by AD

# Spot-check one point against the hand-derived analytic expression
p0 = jnp.array([0.3, 0.7])
x0, y0 = p0
E, sx, cx, sy, cy = jnp.exp(x0 + 0.5 * y0), jnp.sin(jnp.pi * x0), jnp.cos(jnp.pi * x0), jnp.sin(jnp.pi * y0), jnp.cos(jnp.pi * y0)
lap_hand = E * ((1 - jnp.pi**2) * sx * sy + 2 * jnp.pi * cx * sy + (0.25 - jnp.pi**2) * sx * sy + jnp.pi * sx * cy)
assert abs(f_star(p0) + lap_hand) < 1e-12

def manufactured(N):
    """Return h, the grid coordinates, the manufactured solution u* and the source term f = -Δu* (all on the N×N interior nodes)"""
    h = 1.0 / (N + 1)
    x = jnp.linspace(h, 1 - h, N)
    X, Y = jnp.meshgrid(x, x, indexing="ij")
    P = jnp.stack([X, Y], -1).reshape(-1, 2)
    ue = jax.vmap(u_star)(P).reshape(N, N)
    f = jax.vmap(f_star)(P).reshape(N, N)
    return h, X, Y, ue, f

Ns = [15, 31, 63, 127, 255]
errs = []
print(f"{'N':>5} {'h':>9} {'max error':>12} {'rel resid':>10} {'time (ms)':>9}")
for N in Ns:
    h, X, Y, ue, f = manufactured(N)
    u = solve_poisson(f, h)
    e = float(jnp.max(jnp.abs(u - ue)))
    res_rel = rel(neg_lap(u, h), f)
    errs.append(e)
    print(f"{N:>5} {h:>9.5f} {e:>12.4e} {res_rel:>10.1e} {timeit(solve_poisson, f, h, n=3):>9.1f}")
    assert res_rel < 1e-10                                # algebraic error is far below discretization error

errs = np.array(errs)
ratios = errs[:-1] / errs[1:]
orders = np.log2(ratios)
print("Error ratios:", np.round(ratios, 4))
print("Convergence orders:", np.round(orders, 4))
assert np.all(np.abs(ratios - 4) < 0.1)                   # refining the mesh by 2 shrinks the error to about 1/4
assert np.all(np.abs(orders - 2) < 0.02)                  # second-order convergence
POISSON_ORDERS = orders
    N         h    max error  rel resid time (ms)
   15   0.06250   5.8946e-03    6.1e-13       0.1
   31   0.03125   1.4855e-03    8.7e-13       0.3
   63   0.01562   3.7134e-04    1.1e-12       2.2
  127   0.00781   9.2860e-05    3.5e-12      18.0
  255   0.00391   2.3215e-05    1.8e-11     136.3
Error ratios: [3.968  4.0004 3.999  4.    ]
Convergence orders: [1.9884 2.0002 1.9996 2.    ]
# Cross-check against SciPy's sparse direct solver (same discrete system; should agree within rounding error)
N = 63
h, X, Y, ue, f = manufactured(N)
u_jax = solve_poisson(f, h)
u_sp = spsl.spsolve(scipy_neg_lap(N, h).tocsc(), np.asarray(f).ravel()).reshape(N, N)
print(f"JAX cg vs SciPy spsolve: relative difference {rel(u_jax, u_sp):.1e}")
assert rel(u_jax, u_sp) < 1e-10
JAX cg vs SciPy spsolve: relative difference 1.5e-14

🔍 Extra: jsl.cg doesn't tell you whether it converged

⚪ Optional: when you use jsl.cg for real computations and need to judge convergence and pick a preconditioner

  • The stopping criterion is $\|r\|_2 \le \max(\texttt{tol}\cdot\|b\|_2,\ \texttt{atol})$, matching newer SciPy (non-legacy); maxiter defaults to 10 * b.size.
  • The second return value info is always None (hard-coded in the source). If it hasn't converged by maxiter, there's no error or warning either. So always compute the residual yourself and assert on it, as above.
  • If the right-hand side happens to be an eigenvector of the operator (such as $\sin(\pi x)\sin(\pi y)$), CG converges in one step; avoid that when timing or counting iterations.
  • The operator must be symmetric positive definite (and so must M if you use a preconditioner); jsl.cg doesn't check this. For nonsymmetric problems use gmres / bicgstab. For variable-coefficient problems, a diagonal (Jacobi) preconditioner M=lambda r: r / diag is recommended, also passed as a function.
# Check: info is None; with too small a maxiter it silently returns an unconverged solution
h = 1.0 / 64
f = jnp.ones((63, 63))
u_bad, info = jsl.cg(lambda v: neg_lap(v, h), f, tol=1e-12, maxiter=5)
print("info =", info, "| relative residual after only 5 iterations:", f"{rel(neg_lap(u_bad, h), f):.2e}")
assert info is None
assert rel(neg_lap(u_bad, h), f) > 1e-2
info = None | relative residual after only 5 iterations: 3.78e+00

3 Sparse matrices: jax.experimental.sparse

Matrix-free operators suit structured grids. On unstructured meshes (finite elements, finite volumes), you usually still have to assemble a sparse matrix first. JAX's sparse support lives in jax.experimental.sparse, and the main format is BCOO (batched COO):

  • BCOO((data, indices), shape=...): data has shape (nse,), indices has shape (nse, 2), and nse is the number of stored elements;
  • Duplicate indices are allowed (they're summed during matrix-vector products), which matches exactly how finite-element assembly "accumulates element matrices into the global matrix"; sum_duplicates() merges duplicates;
  • It's a pytree, so it can be passed as an argument to jit / grad functions; data is differentiable, while indices are integers and don't take part in differentiation.

3.1 Assembling the five-point matrix from triplets and cross-checking against SciPy CSR

⚪ Optional: when you need to assemble a sparse matrix for an unstructured mesh

def five_point_triplets(N, h):
    """COO triplets (row, col, val) of the five-point matrix, generated with NumPy (preprocessing doesn't need to be differentiable)"""
    idx = np.arange(N * N).reshape(N, N)
    I, J = np.meshgrid(np.arange(N), np.arange(N), indexing="ij")
    rows, cols, vals = [idx.ravel()], [idx.ravel()], [np.full(N * N, 4 / h**2)]
    for di, dj in [(1, 0), (-1, 0), (0, 1), (0, -1)]:
        I2, J2 = I + di, J + dj
        m = (I2 >= 0) & (I2 < N) & (J2 >= 0) & (J2 < N)
        rows.append(idx[I[m], J[m]]); cols.append(idx[I2[m], J2[m]]); vals.append(np.full(m.sum(), -1 / h**2))
    return np.concatenate(rows), np.concatenate(cols), np.concatenate(vals)

N = 63; h = 1.0 / (N + 1)
r_, c_, v_ = five_point_triplets(N, h)
A_csr = sps.csr_matrix((v_, (r_, c_)), shape=(N * N, N * N))                       # SciPy reference
A_bcoo = sparse.BCOO((jnp.asarray(v_), jnp.stack([jnp.asarray(r_), jnp.asarray(c_)], axis=1)),
                     shape=(N * N, N * N))
print(A_bcoo, "| data:", A_bcoo.data.dtype, "| indices:", A_bcoo.indices.dtype)

x_np = np.random.default_rng(1).standard_normal(N * N)
matvec = jax.jit(lambda A, x: A @ x)                    # A is passed in as an argument, not captured in a closure
y_bcoo = matvec(A_bcoo, jnp.asarray(x_np))
y_csr = A_csr @ x_np
print(f"BCOO matvec vs SciPy CSR: relative error {rel(y_bcoo, y_csr):.1e}")
assert rel(y_bcoo, y_csr) < 1e-14
assert rel(neg_lap(jnp.asarray(x_np).reshape(N, N), h).ravel(), y_csr) < 1e-14   # matches the matrix-free operator

# Convert directly from SciPy, and convert back to dense for a small-scale check
A_from_sp = sparse.BCOO.from_scipy_sparse(A_csr)
assert A_from_sp.nse == A_csr.nnz
assert np.allclose(sparse.BCOO.from_scipy_sparse(scipy_neg_lap(5, 1 / 6)).todense(),
                   scipy_neg_lap(5, 1 / 6).toarray())
BCOO(float64[3969, 3969], nse=19593) | data: float64 | indices: int64
BCOO matvec vs SciPy CSR: relative error 1.1e-16
# Finite-element-style assembly: duplicate indices accumulate (BCOO keeps duplicates, sums them in matvec; sum_duplicates merges them)
A_dup = sparse.BCOO((jnp.array([1.0, 2.0, 3.0, 4.0]),
                     jnp.array([[0, 0], [0, 0], [1, 1], [0, 1]])), shape=(2, 2))
print("Dense form:\n", A_dup.todense(), "\nnse:", A_dup.nse, "→ nse after merging:", A_dup.sum_duplicates().nse)
assert np.allclose(A_dup.todense(), [[3.0, 4.0], [0.0, 3.0]])
assert A_dup.sum_duplicates().nse == 3
assert np.allclose(A_dup @ jnp.array([1.0, 1.0]), [7.0, 3.0])
Dense form:
 [[3. 4.]
 [0. 3.]] 
nse: 4 → nse after merging: 3

3.2 sparse.sparsify: letting "dense-style" functions accept sparse matrices

⚪ Optional: if you want to write sparse code in dense style, or need derivatives with respect to matrix entries

sparse.sparsify(f) replaces operations in f such as @, .T and elementwise multiplication with sparse versions, so you can write the function body as if the matrix were dense. Also, data can be differentiated directly. That's useful when you want "sensitivities with respect to matrix entries", for example to study perturbations of certain entries of a stiffness matrix. sparse.grad additionally wraps the gradient as a BCOO with the same sparsity structure.

def energy_dense(M, x):
    """Written as for a dense matrix: ||Mx||^2 + x^T M^T x"""
    return jnp.sum((M @ x) ** 2) + (M.T @ x) @ x

energy_sparse = jax.jit(sparse.sparsify(energy_dense))
xj = jnp.asarray(x_np)
e_sp = energy_sparse(A_bcoo, xj)
e_ref = np.sum((A_csr @ x_np) ** 2) + (A_csr.T @ x_np) @ x_np
print(f"sparsify result {float(e_sp):.6e}  vs  SciPy {e_ref:.6e}")
assert abs(float(e_sp) - e_ref) / abs(e_ref) < 1e-13

# Derivative with respect to the matrix nonzeros: d/dA_ij ||Ax||^2 = 2 (Ax)_i x_j, evaluated only at the nonzero positions
def loss_data(data, indices, x):
    M = sparse.BCOO((data, indices), shape=(N * N, N * N))
    return jnp.sum((M @ x) ** 2)

g_data = jax.jit(jax.grad(loss_data))(A_bcoo.data, A_bcoo.indices, xj)
g_ref = 2 * (A_csr @ x_np)[r_] * x_np[c_]
print("Shape of the gradient w.r.t. data:", g_data.shape, "| relative error vs the analytic formula:", f"{rel(g_data, g_ref):.1e}")
assert rel(g_data, g_ref) < 1e-13

g_sp = sparse.grad(lambda M, x: jnp.sum((M @ x) ** 2))(A_bcoo, xj)
assert isinstance(g_sp, sparse.BCOO) and g_sp.nse == A_bcoo.nse
assert rel(g_sp.data, g_ref) < 1e-13
sparsify result 1.327316e+12  vs  SciPy 1.327316e+12
Shape of the gradient w.r.t. data: (19593,) | relative error vs the analytic formula: 1.1e-16

3.3 BCOO + CG, and the state of sparse direct solvers

⚪ Optional: when you need a sparse direct method, or need to solve for many right-hand sides in a batch

A BCOO can be used directly in the matvec of cg. For direct methods, jax.experimental.sparse.linalg.spsolve(data, indices, indptr, b) takes the CSR triple. But its docs are clear: only the CUDA backend has a native implementation; the CPU backend falls back to scipy.sparse.linalg.spsolve (a Python callback), and neither the CPU nor the GPU implementation supports vmap. Let's verify that below.

from jax.experimental.sparse.linalg import spsolve

b = jnp.ones(N * N)
u_cg_bcoo = jax.jit(lambda A, b: jsl.cg(lambda v: A @ v, b, tol=1e-12, maxiter=10 * b.size)[0])(A_bcoo, b)
u_ref = spsl.spsolve(A_csr.tocsc(), np.ones(N * N))
print(f"BCOO + cg vs SciPy spsolve: relative difference {rel(u_cg_bcoo, u_ref):.1e}")
assert rel(u_cg_bcoo, u_ref) < 1e-10

A_bcsr = sparse.BCSR.from_bcoo(A_bcoo)                  # convert to the CSR triple
u_ss = jax.jit(spsolve)(A_bcsr.data, A_bcsr.indices, A_bcsr.indptr, b)
g_ss = jax.grad(lambda b: spsolve(A_bcsr.data, A_bcsr.indices, A_bcsr.indptr, b).sum())(b)
print(f"spsolve (CPU calls back into SciPy) vs SciPy: {rel(u_ss, u_ref):.1e}; works with jit and grad")
assert rel(u_ss, u_ref) < 1e-12
assert rel(g_ss, spsl.spsolve(A_csr.T.tocsc(), np.ones(N * N))) < 1e-12   # gradient = A^{-T} 1
assert "scipy.sparse.linalg.spsolve" in inspect.getsource(sparse.linalg._spsolve_cpu_lowering)
BCOO + cg vs SciPy spsolve: relative difference 1.7e-14
spsolve (CPU calls back into SciPy) vs SciPy: 2.5e-16; works with jit and grad
# ❌ Wrong: vmap over spsolve (solving for several right-hand sides at once); there's no batching rule
B = jnp.stack([b, 2 * b, 3 * b])
try:
    jax.vmap(lambda bb: spsolve(A_bcsr.data, A_bcsr.indices, A_bcsr.indptr, bb))(B)
    vmap_err = None
except NotImplementedError as e:
    vmap_err = str(e)
    print("NotImplementedError:", vmap_err)
assert vmap_err is not None and "spsolve" in vmap_err
NotImplementedError: Batching rule for 'spsolve' not implemented
# ✅ Correct: for batched right-hand sides use vmap(cg) (the matvec is a BCOO, which batches); or factor once in SciPy and back-substitute many times
U = jax.jit(jax.vmap(lambda A, bb: jsl.cg(lambda v: A @ v, bb, tol=1e-12, maxiter=10 * bb.size)[0],
                     in_axes=(None, 0)))(A_bcoo, B)
lu = spsl.splu(A_csr.tocsc())                           # SciPy: one LU, many back-substitutions
U_ref = np.stack([lu.solve(np.asarray(bb)) for bb in B])
print(f"batched solve with vmap(cg) vs SciPy splu: {rel(U, U_ref):.1e}")
assert rel(U, U_ref) < 1e-10
batched solve with vmap(cg) vs SciPy splu: 1.9e-14
# Timing (printed only): the same N=63 five-point operator, three kinds of matvec
u2 = xj.reshape(N, N)
t_bcoo = timeit(matvec, A_bcoo, xj, n=200)
t_mf = timeit(jax.jit(neg_lap), u2, h, n=200)
t0 = time.perf_counter()
for _ in range(200):
    A_csr @ x_np
t_sp = (time.perf_counter() - t0) / 200 * 1e3
print(f"BCOO matvec        : {t_bcoo * 1e3:8.1f} µs")
print(f"matrix-free matvec : {t_mf * 1e3:8.1f} µs")
print(f"SciPy CSR          : {t_sp * 1e3:8.1f} µs   (NumPy side, no JAX dispatch overhead)")
BCOO matvec        :     67.0 µs
matrix-free matvec :     10.3 µs
SciPy CSR          :     10.2 µs   (NumPy side, no JAX dispatch overhead)

The state of sparse support in JAX (0.12, based on the docs and the results above)

AspectStatus
Module locationStill in jax.experimental.sparse; the API may change
FormatsBCOO (the main one, best supported), BCSR; the docs for the COO/CSR classes say they "have poor compatibility with transformations like grad; prefer BCOO"
matvec / matmatAvailable, and works with jit, grad (w.r.t. data) and vmap; on CPU it's a generic gather + segment-sum, usually slower than both a matrix-free stencil and SciPy; on GPU some operations go through cuSPARSE
Direct solvesOnly sparse.linalg.spsolve: cuSOLVER (QR) on GPU, calls back into SciPy on CPU; no vmap; no reusable LU/Cholesky factorization object
Iterative solvesjax.scipy.sparse.linalg.cg / gmres / bicgstab accept a function or a BCOO and are differentiable (they use custom_linear_solve internally; see section 4.5)
Eigenvaluessparse.linalg.lobpcg_standard (experimental)
Sparsity structurense is static: a changing sparsity pattern (adaptive refinement) triggers recompilation
jnp functionsMost jnp.* functions don't accept BCOO (for example jnp.linalg.solve raises TypeError right away); you need sparsify or hand-written code

Practical advice: prefer matrix-free operators for structured grids; for unstructured meshes, assemble with BCOO and use iterative methods. When you really need a direct method on CPU, just use SciPy: wrap it with jax.pure_callback and supply the derivative with custom_linear_solve, the same pattern as in section 4.

4 Implicit differentiation of solvers

4.1 The math behind the adjoint method

🟢 Must-read

Write the discrete PDE as a linear system $A(\theta)\,u = f(\theta)$, where $\theta$ are the parameters (for example the diffusion coefficient at each node, thousands of them), and the objective is a scalar $J(u(\theta),\theta)$. Differentiating the constraint:

$$A\,\frac{\partial u}{\partial \theta} = \frac{\partial f}{\partial \theta} - \frac{\partial (A u)}{\partial \theta}\bigg|_{u\ \text{fixed}} \quad\Longrightarrow\quad \frac{\partial u}{\partial \theta} = A^{-1}\Big(\frac{\partial f}{\partial\theta} - \frac{\partial (Au)}{\partial\theta}\Big).$$

So

$$\frac{dJ}{d\theta} = \frac{\partial J}{\partial\theta} + \frac{\partial J}{\partial u}\,A^{-1}\Big(\frac{\partial f}{\partial\theta} - \frac{\partial (Au)}{\partial\theta}\Big).$$

Computing $\partial u/\partial\theta$ directly takes $\dim\theta$ linear solves. The adjoint method changes the order of association: first solve one adjoint equation

$$A^{\top}\lambda = \Big(\frac{\partial J}{\partial u}\Big)^{\top},$$

and then get

$$\boxed{\;\frac{dJ}{d\theta} = \frac{\partial J}{\partial\theta} + \lambda^{\top}\Big(\frac{\partial f}{\partial \theta} - \frac{\partial (A u)}{\partial \theta}\Big)\;}$$

The whole gradient costs one forward solve + one adjoint solve + one VJP of $\lambda^\top \partial(Au)/\partial\theta$, independent of the number of parameters. This is exactly the VJP of reverse-mode AD: given the cotangent $\bar u = \partial J/\partial u$, we have $\bar b = A^{-\top}\bar u$ (the $\lambda$ above) and $\bar\theta = \bar b^{\top}\big(\partial f/\partial\theta - \partial(Au)/\partial\theta\big)$; when $\theta$ appears only in $A$, this is $-\bar b^{\top}\,\partial(Au)/\partial\theta$.

Comparing the two ways to differentiate

Differentiating through the unrolled iterationsImplicit differentiation (implicit / adjoint)
HowWrite CG as a fixed-step fori_loop and let AD pass through every stepTreat the "solution" only as the solution of $A u=f$ and use the formula above
MemoryStores the residual, direction and step size of every step: $O(K\cdot n)$, with $K$ the number of iterationsStores only the solution $u$: $O(n)$, independent of the number of iterations
AccuracyYou get the derivative of "the floating-point iteration"; if it hasn't converged the gradient is inaccurate, and iterating past convergence can also amplify rounding error (see section 4.3)You get the derivative of "the true solution"; accuracy depends only on the tolerances of the forward and adjoint solves
Solver requirementsMust be a fixed-step loop that can be differentiated in reverse modeThe forward solver can be any black box (while_loop, a SciPy callback, a preconditioner…)

JAX provides dedicated primitives for this:

  • lax.custom_linear_solve(matvec, b, solve, transpose_solve=None, symmetric=False, has_aux=False): declares that "solve(matvec, b) returns $A^{-1}b$". When differentiating, JAX only uses matvec (taking JVPs/VJPs with respect to the parameters it captures in its closure) and solve / transpose_solve, and never goes inside solve.
  • lax.custom_root(f, initial_guess, solve, tangent_solve): declares that "solve returns a root of $f(x)=0$" and differentiates with the implicit function theorem; it's for nonlinear solvers (section 4.6).

Compared with hand-writing a fixed-point derivative with custom_vjp as in Advanced 04, these two primitives support both forward and reverse mode, work with vmap, and automatically differentiate with respect to traced values captured in the matvec closure, so you don't have to sort out the argument list yourself.

4.2 A variable-coefficient diffusion operator

⚪ Optional: when you need the discretization details of a variable-coefficient diffusion operator

Generalize section 2 to the variable-coefficient equation $-\nabla\cdot(k\nabla u) = f$: $k$ is defined at the nodes, the coefficient on each face is the arithmetic mean of the two neighboring nodes, and the flux-difference scheme is

$$(A(k)u)_{ij} = -\frac{1}{h^2}\Big[k_{i+\frac12,j}(u_{i+1,j}-u_{ij}) - k_{i-\frac12,j}(u_{ij}-u_{i-1,j}) + k_{i,j+\frac12}(u_{i,j+1}-u_{ij}) - k_{i,j-\frac12}(u_{ij}-u_{i,j-1})\Big].$$

For $k>0$, $A(k)$ is symmetric positive definite. Below we first check three things: with $k\equiv1$ it reduces to neg_lap; symmetry $\langle Au, v\rangle = \langle u, Av\rangle$; and the transposed operator obtained with jax.linear_transpose matches the original.

def apply_A(k, u, h):
    """Matrix-free variable-coefficient diffusion operator -∇·(k∇u); k and u are both N×N node arrays, and k outside the boundary takes the nearest value"""
    kp = jnp.pad(k, 1, mode="edge")
    up = jnp.pad(u, 1)                                    # Dirichlet 0
    kx = 0.5 * (kp[1:, 1:-1] + kp[:-1, 1:-1])            # face coefficients in x (N+1, N)
    ky = 0.5 * (kp[1:-1, 1:] + kp[1:-1, :-1])            # face coefficients in y (N, N+1)
    fx = kx * (up[1:, 1:-1] - up[:-1, 1:-1])              # face fluxes
    fy = ky * (up[1:-1, 1:] - up[1:-1, :-1])
    return -((fx[1:] - fx[:-1]) + (fy[:, 1:] - fy[:, :-1])) / h**2

N = 31; h = 1.0 / (N + 1)
xg = jnp.linspace(h, 1 - h, N)
XX, YY = jnp.meshgrid(xg, xg, indexing="ij")
k0 = 1 + 0.5 * jnp.exp(-((XX - 0.6) ** 2 + (YY - 0.4) ** 2) / 0.02)
ka, kb = jax.random.split(jax.random.key(10))
uu, vv = jax.random.normal(ka, (N, N)), jax.random.normal(kb, (N, N))

assert jnp.allclose(apply_A(jnp.ones((N, N)), uu, h), neg_lap(uu, h), rtol=1e-13, atol=1e-9)
lhs, rhs_ = jnp.vdot(apply_A(k0, uu, h), vv), jnp.vdot(uu, apply_A(k0, vv, h))
print(f"<Au,v> = {float(lhs):.10e}, <u,Av> = {float(rhs_):.10e}")
assert abs(lhs - rhs_) / abs(lhs) < 1e-12
A_T = jax.linear_transpose(lambda v: apply_A(k0, v, h), uu)
assert jnp.allclose(A_T(vv)[0], apply_A(k0, vv, h), rtol=1e-12, atol=1e-8)
assert jnp.vdot(uu, apply_A(k0, uu, h)) > 0              # spot-check positive definiteness
print("Variable-coefficient operator: reduction check, symmetry and linear_transpose consistency all pass")
<Au,v> = -1.2009564054e+05, <u,Av> = -1.2009564054e+05
Variable-coefficient operator: reduction check, symmetry and linear_transpose consistency all pass

4.3 The two ways to differentiate: implementation and cross-check

🟢 Must-read

  • cg_unrolled: standard CG written as a fori_loop with a static number of steps (it lowers to scan internally and can be differentiated in reverse mode);
  • cg_while: the same CG, written with while_loop and stopping on the residual. It can't be differentiated in reverse mode directly (section 4.8 of Advanced 04). But it's perfectly fine as the solve of custom_linear_solve, because AD never goes through it;
  • u_implicit: lax.custom_linear_solve(matvec, f, solve, symmetric=True). symmetric=True means $A^\top=A$, so transpose_solve just reuses solve.

The objective is the data misfit $J(k) = \tfrac12 h^2\sum (u(k) - u_{\text{obs}})^2$.

def cg_unrolled(matvec, b, n_iter):
    """CG with a fixed n_iter steps (static-length fori_loop → reverse-mode differentiable, but stores every step)"""
    rs0 = jnp.vdot(b, b)
    def body(i, c):
        x, r, p, rs = c
        Ap = matvec(p)
        alpha = rs / jnp.vdot(p, Ap)
        x, r = x + alpha * p, r - alpha * Ap
        rs_new = jnp.vdot(r, r)
        return x, r, r + (rs_new / rs) * p, rs_new
    return lax.fori_loop(0, n_iter, body, (jnp.zeros_like(b), b, b, rs0))[0]

def cg_while(matvec, b, tol=1e-12, maxiter=10_000):
    """CG that stops on the relative residual (while_loop; can't be differentiated in reverse mode directly)"""
    bb = jnp.vdot(b, b)
    def cond(c):
        x, r, p, rs, i = c
        return (rs > tol**2 * bb) & (i < maxiter)
    def body(c):
        x, r, p, rs, i = c
        Ap = matvec(p)
        alpha = rs / jnp.vdot(p, Ap)
        x, r = x + alpha * p, r - alpha * Ap
        rs_new = jnp.vdot(r, r)
        return x, r, r + (rs_new / rs) * p, rs_new, i + 1
    return lax.while_loop(cond, body, (jnp.zeros_like(b), b, b, bb, 0))[0]

def u_unrolled(k, f, h, n_iter):
    return cg_unrolled(lambda v: apply_A(k, v, h), f, n_iter)

def u_implicit(k, f, h, tol=1e-12, maxiter=10_000):
    """Implicit differentiation: AD only uses matvec (differentiating w.r.t. the k in its closure) and solve itself"""
    solve = lambda mv, rhs: cg_while(mv, rhs, tol, maxiter)
    return lax.custom_linear_solve(lambda v: apply_A(k, v, h), f, solve, symmetric=True)

def J_unrolled(k, f, u_obs, h, n_iter):
    return 0.5 * h**2 * jnp.sum((u_unrolled(k, f, h, n_iter) - u_obs) ** 2)

def J_implicit(k, f, u_obs, h, tol=1e-12, maxiter=10_000):
    return 0.5 * h**2 * jnp.sum((u_implicit(k, f, h, tol, maxiter) - u_obs) ** 2)

f_src = jnp.ones((N, N))
u_obs = 0.9 * u_implicit(jnp.ones((N, N)), f_src, h)       # a synthetic "observation"

grad_unr = jax.jit(jax.grad(J_unrolled), static_argnums=4)
grad_imp = jax.jit(jax.grad(J_implicit))
K_UNROLL = 300
g_unr = grad_unr(k0, f_src, u_obs, h, K_UNROLL)
g_imp = grad_imp(k0, f_src, u_obs, h)

# Finite differences (central, along 3 random directions); the forward pass uses cg_while with a tight tolerance
J_fd = jax.jit(J_implicit)
eps = 1e-5
print(f"{'dir':>4} {'finite diff':>16} {'implicit':>16} {'unrolled':>16} {'impl rel err':>12} {'unr rel err':>12}")
for i, key in enumerate(jax.random.split(jax.random.key(3), 3)):
    d = jax.random.normal(key, (N, N))
    fd = (J_fd(k0 + eps * d, f_src, u_obs, h) - J_fd(k0 - eps * d, f_src, u_obs, h)) / (2 * eps)
    gi, gu = jnp.vdot(g_imp, d), jnp.vdot(g_unr, d)
    ei, eu = abs(gi - fd) / abs(fd), abs(gu - fd) / abs(fd)
    print(f"{i:>4} {float(fd):>16.10e} {float(gi):>16.10e} {float(gu):>16.10e} {float(ei):>12.1e} {float(eu):>12.1e}")
    assert ei < 1e-7                                       # the implicit gradient matches finite differences
    assert eu < 1e-3                                       # the gradient unrolled for 300 steps also matches finite differences (but several orders of magnitude worse)

GRAD_REL_UNR_IMP = rel(g_unr, g_imp)
print(f"\nUnrolled {K_UNROLL} steps vs implicit: relative gradient difference {GRAD_REL_UNR_IMP:.2e}")
assert GRAD_REL_UNR_IMP < 1e-4
 dir      finite diff         implicit         unrolled impl rel err  unr rel err
   0 -1.0148877623e-05 -1.0148877621e-05 -1.0148875853e-05      1.9e-10      1.7e-07
   1 8.5341417039e-06 8.5341417016e-06 8.5341407137e-06      2.7e-10      1.2e-07
   2 1.6788548777e-06 1.6788548773e-06 1.6788442405e-06      2.1e-10      6.3e-06

Unrolled 300 steps vs implicit: relative gradient difference 1.44e-06
# Memory: read the temporary buffers the compiled XLA program needs for backprop
def temp_mib(fn, *args, static_argnums=()):
    c = jax.jit(jax.grad(fn), static_argnums=static_argnums).lower(*args).compile()
    return c.memory_analysis().temp_size_in_bytes / 2**20

MEM = {}
for K in [100, 300]:
    MEM[f"unrolled {K} steps"] = temp_mib(J_unrolled, k0, f_src, u_obs, h, K, static_argnums=4)
MEM["implicit (custom_linear_solve)"] = temp_mib(J_implicit, k0, f_src, u_obs, h)
for name, m in MEM.items():
    print(f"{name:<28}: {m:8.3f} MiB")
assert MEM["unrolled 300 steps"] > 2.5 * MEM["unrolled 100 steps"]          # grows linearly with the number of iterations
assert MEM["implicit (custom_linear_solve)"] < MEM["unrolled 300 steps"] / 50

print("\nTiming (printed only):")
print(f"  unrolled 300 steps grad: {timeit(grad_unr, k0, f_src, u_obs, h, K_UNROLL):.2f} ms")
print(f"  implicit grad          : {timeit(grad_imp, k0, f_src, u_obs, h):.2f} ms")

print("\nResiduals saved by the implicit version (only the input k and a few same-sized arrays computed from the solution u; nothing stacked per iteration):")
print_saved_residuals(lambda k: J_implicit(k, f_src, u_obs, h), k0)
unrolled 100 steps          :    6.013 MiB
unrolled 300 steps          :   17.849 MiB
implicit (custom_linear_solve):    0.062 MiB

Timing (printed only):
  unrolled 300 steps grad: 3.80 ms
  implicit grad          : 0.86 ms

Residuals saved by the implicit version (only the input k and a few same-sized arrays computed from the solution u; nothing stacked per iteration):
f64[] from a literal
f64[31,31] from the argument k
f64[32,31] output of sub from /tmp/ipykernel_13886/328650634.py:7:15 (apply_A)
f64[31,32] output of sub from /tmp/ipykernel_13886/328650634.py:8:15 (apply_A)
f64[31,31] output of mul from /tmp/ipykernel_13886/229002198.py:40:32 (J_implicit)

Why the implicit gradient is more accurate: below we fix the number of iterations $K$ and compare the errors of "unrolled differentiation" and "implicit differentiation with at most $K$ iterations each for the forward and adjoint solves", relative to the true gradient (the tight-tolerance implicit gradient, already cross-checked against finite differences).

print(f"{'K':>5} {'fwd sol err':>12} {'unrolled grad err':>14} {'implicit grad err':>14}")
scan_rows = []
for K in [20, 40, 60, 70, 80, 90, 100, 150, 300]:
    gu = grad_unr(k0, f_src, u_obs, h, K)
    gi = grad_imp(k0, f_src, u_obs, h, 0.0, K)            # tol=0: exactly K iterations
    ef = rel(u_unrolled(k0, f_src, h, K), u_implicit(k0, f_src, h))
    eu, ei = rel(gu, g_imp), rel(gi, g_imp)
    scan_rows.append((K, ef, eu, ei))
    print(f"{K:>5} {ef:>12.2e} {eu:>14.2e} {ei:>14.2e}")
scan_rows = np.array(scan_rows)

# Same K=80: finite differences of the unrolled function itself give the true gradient; but both the AD jvp (forward) and vjp (reverse) are far from it, and disagree with each other
d = jax.random.normal(jax.random.key(3), (N, N))
_, jvp80 = jax.jvp(lambda k: J_unrolled(k, f_src, u_obs, h, 80), (k0,), (d,))
vjp80 = jnp.vdot(grad_unr(k0, f_src, u_obs, h, 80), d)
fd80 = (J_unrolled(k0 + 1e-6 * d, f_src, u_obs, h, 80) - J_unrolled(k0 - 1e-6 * d, f_src, u_obs, h, 80)) / 2e-6
true80 = jnp.vdot(g_imp, d)
print(f"\nK=80: true gradient {float(true80):.6e} | finite difference of the unrolled function itself {float(fd80):.6e} | "
      f"unrolled jvp {float(jvp80):.6e} | unrolled vjp {float(vjp80):.6e}")
assert abs(fd80 - true80) < 1e-6 * abs(fd80)               # the function value itself has converged, so its finite difference is the true gradient
assert abs(jvp80 - true80) > 100 * abs(true80)             # the forward-mode unrolled derivative is just as wrong → not a reverse-mode bug
assert abs(vjp80 - true80) > 100 * abs(true80)

assert scan_rows[:, 2].max() > 1e-2                          # the unrolled gradient gets badly distorted around convergence
assert np.all(scan_rows[scan_rows[:, 0] >= 80][:, 3] < 1e-5)  # the implicit gradient converges steadily along with the forward solve
    K  fwd sol err unrolled grad err implicit grad err
   20     5.60e-03       1.95e+00       2.92e-02
   40     5.10e-04       2.31e-02       3.32e-03
   60     1.07e-05       3.56e-05       5.36e-05
   70     6.58e-07       6.26e-01       2.20e-06
   80     4.85e-08       2.31e+05       2.62e-07
   90     2.83e-09       6.32e+05       1.26e-08
  100     1.06e-10       5.24e+01       4.96e-10
  150     1.80e-14       1.18e-04       9.13e-14
  300     1.80e-14       1.44e-06       9.13e-14
K=80: true gradient 1.406032e-06 | finite difference of the unrolled function itself 1.406031e-06 | unrolled jvp 4.633285e+00 | unrolled vjp 3.779440e-01

What the results show:

  1. Before convergence ($K\le 40$), both gradients are inaccurate, but the implicit gradient's error always stays on the same order as the forward solution's error;
  2. Right around convergence ($K\approx 70\sim 100$), the forward solution error has already dropped to $10^{-6}\sim10^{-10}$, yet the unrolled gradient can be off by several orders of magnitude. Finite differences of the unrolled function itself equal the true gradient, so the function itself isn't "steep". Both the forward-mode (jvp) and reverse-mode (vjp) unrolled derivatives are far from the true value and disagree with each other, so this isn't a bug in the reverse-mode implementation; it's amplified rounding noise. Differentiating CG's $\alpha=\frac{r^\top r}{p^\top Ap}$ and $\beta=\frac{r_{k+1}^\top r_{k+1}}{r_k^\top r_k}$ produces factors on the order of $1/\lVert r\rVert^2$ (for example $\dot\beta/\beta = 2r_{k+1}^\top\dot r_{k+1}/\lVert r_{k+1}\rVert^2 - 2r_k^\top\dot r_k/\lVert r_k\rVert^2$). The smaller the residual, the bigger these terms get. In exact arithmetic they cancel out; in floating point the cancellation is incomplete, so rounding error gets multiplied. The forward iteration "corrects itself" (once the residual is small it stops changing the solution), but the derivative recurrence can't. The exact numbers are sensitive to how things are compiled and to the direction, and differ a bit from run to run, but the order of magnitude is stable;
  3. Only when iterating far more than needed ($K=300$) does the unrolled gradient come back down, and even then it's several orders of magnitude less accurate than the implicit one, while using hundreds of times more memory.

So, whenever a solver is "solving an equation", differentiate the equation, not the solution process.

4.4 symmetric and transpose_solve: the pitfall with nonsymmetric operators

🟢 Must-read

Reverse mode needs to solve the transposed system $A^\top\lambda = \bar u$. custom_linear_solve solves it with transpose_solve(vecmat, b), where vecmat is $v\mapsto A^\top v$, obtained automatically by JAX via linear_transpose. symmetric=True is a promise: JAX just uses solve and matvec to solve the transposed system, with no checks at all.

Example: 1D convection-diffusion $-u'' + \beta u' = f$ (central differences); for $\beta\neq 0$ the matrix is nonsymmetric. We solve with GMRES and compare the gradient of $J = w^\top u$ with respect to $f$ (the analytic value is $A^{-\top}w$).

n1 = 50; h1 = 1.0 / (n1 + 1)

def conv_diff(beta, u):
    """1D convection-diffusion -u'' + beta u' (central differences, Dirichlet 0); nonsymmetric for beta≠0"""
    up = jnp.pad(u, 1)
    return (2 * u - up[:-2] - up[2:]) / h1**2 + beta * (up[2:] - up[:-2]) / (2 * h1)

gmres_solve = lambda mv, rhs: jsl.gmres(mv, rhs, tol=1e-13, atol=0.0, restart=n1, maxiter=20)[0]
f1 = jnp.sin(jnp.linspace(0, 3, n1))
w1 = jnp.linspace(1, 2, n1)
beta0 = 20.0
A1 = np.asarray(jax.jacfwd(lambda u: conv_diff(beta0, u))(jnp.zeros(n1)))   # small problem: an explicit matrix as the reference
lam_ref = np.linalg.solve(A1.T, np.asarray(w1))                             # dJ/df = A^{-T} w
assert not np.allclose(A1, A1.T)
# ❌ Wrong: declaring symmetric=True for a nonsymmetric operator; the reverse-mode gradient is silently wrong. Leaving out transpose_solve raises an error outright
def J_sym_wrong(f):
    u = lax.custom_linear_solve(lambda v: conv_diff(beta0, v), f, gmres_solve, symmetric=True)
    return jnp.vdot(w1, u)

def J_no_transpose(f):
    u = lax.custom_linear_solve(lambda v: conv_diff(beta0, v), f, gmres_solve)
    return jnp.vdot(w1, u)

try:
    err_sym = rel(jax.grad(J_sym_wrong)(f1), lam_ref)
    print(f"symmetric=True: relative gradient error {err_sym:.3f} (no warning at all)")
except Exception as e:
    err_sym = None
    print(type(e).__name__, e)
try:
    jax.grad(J_no_transpose)(f1)
    msg_tr = None
except TypeError as e:
    msg_tr = str(e)
    print("Without transpose_solve: TypeError:", msg_tr)
assert err_sym > 0.1
assert msg_tr is not None and "transpose_solve" in msg_tr
symmetric=True: relative gradient error 0.838 (no warning at all)
Without transpose_solve: TypeError: transpose_solve required for backwards mode automatic differentiation of custom_linear_solve
# ✅ Correct: symmetric=False and provide transpose_solve (here also using GMRES to solve A^T λ = b)
def u_cd(beta, f):
    return lax.custom_linear_solve(lambda v: conv_diff(beta, v), f, gmres_solve,
                                   transpose_solve=gmres_solve, symmetric=False)

J_cd = lambda beta, f: jnp.vdot(w1, u_cd(beta, f))
g_f = jax.grad(J_cd, argnums=1)(beta0, f1)
g_beta = jax.grad(J_cd, argnums=0)(beta0, f1)
fd_beta = (J_cd(beta0 + 1e-5, f1) - J_cd(beta0 - 1e-5, f1)) / 2e-5
print(f"dJ/df relative error {rel(g_f, lam_ref):.1e}; dJ/dβ: AD {float(g_beta):.10f}  finite difference {float(fd_beta):.10f}")
assert rel(g_f, lam_ref) < 1e-10
assert abs(g_beta - fd_beta) < 1e-8 * abs(g_beta)
# Forward mode only uses solve, so it isn't affected by the symmetric declaration
assert abs(jax.jvp(lambda b: J_cd(b, f1), (beta0,), (1.0,))[1] - g_beta) < 1e-10
dJ/df relative error 8.6e-16; dJ/dβ: AD -0.0598687606  finite difference -0.0598687605

4.5 jax.scipy.sparse.linalg.cg is itself implicit differentiation

🟢 Must-read

One look at the source shows that jsl.cg / gmres / bicgstab all call lax.custom_linear_solve(A, b, solve=..., transpose_solve=...) internally, and the iteration itself uses while_loop. So taking grad of jsl.cg directly gives you the adjoint gradient, with memory independent of the number of iterations. You only need to wrap things in custom_linear_solve explicitly when you hand-write your own iterative solver.

src = inspect.getsource(sys.modules[jsl.cg.__module__]._isolve)
assert "custom_linear_solve" in src and "transpose_solve" in src
print("The internal implementation of jsl.cg calls custom_linear_solve ✓")

def J_jsl(k, f, u_obs, h):
    u = jsl.cg(lambda v: apply_A(k, v, h), f, tol=1e-12, maxiter=10 * f.size)[0]
    return 0.5 * h**2 * jnp.sum((u - u_obs) ** 2)

g_jsl = jax.jit(jax.grad(J_jsl))(k0, f_src, u_obs, h)
print(f"jsl.cg gradient vs hand-written custom_linear_solve: relative difference {rel(g_jsl, g_imp):.1e}; "
      f"temporary memory {temp_mib(J_jsl, k0, f_src, u_obs, h):.3f} MiB")
assert rel(g_jsl, g_imp) < 1e-9
assert temp_mib(J_jsl, k0, f_src, u_obs, h) < MEM["unrolled 100 steps"] / 10
The internal implementation of jsl.cg calls custom_linear_solve ✓
jsl.cg gradient vs hand-written custom_linear_solve: relative difference 0.0e+00; temporary memory 0.093 MiB

4.6 Nonlinear problems: lax.custom_root

⚪ Optional: when you solve a nonlinear equation and need derivatives of the solution

Nonlinear reaction-diffusion $F(u;k,c) = -\nabla\cdot(k\nabla u) + c\,u^3 - f = 0$, solved with Newton–Krylov: each step solves $J_F\,\delta u = -F$, with $J_F\,v$ obtained from jax.jvp (matrix-free) and cg_while as the inner solver.

The contract of lax.custom_root(f, initial_guess, solve, tangent_solve):

  • f: the residual function, which takes only the unknown $u$; the parameters $k, c$ come in through a closure (they're traced values, and JAX differentiates with respect to them);
  • solve(f, x0): any root finder (here Newton; fori_loop or while_loop both work, since AD doesn't go through it);
  • tangent_solve(g, y): solves the linearized system $g(x)=y$, where $g$ is the linearization of $f$ at the root.

By the implicit function theorem, $\dfrac{du^\ast}{dp} = -J_F^{-1}\dfrac{\partial F}{\partial p}$. In reverse mode JAX transposes tangent_solve, so tangent_solve must be a linear function of y that can be transposed. This is an easy place to trip up; let's try it below.

def F_nl(u, k, c, f, h):
    return apply_A(k, u, h) + c * u**3 - f

def newton_krylov(F_u, u0, n_newton=12):
    """Newton's method, with Jacobian-vector products from jvp and inner CG (the Jacobian is symmetric positive definite)"""
    def body(i, u):
        r = F_u(u)
        Jv = lambda v: jax.jvp(F_u, (u,), (v,))[1]
        return u + cg_while(Jv, -r, tol=1e-13)
    return lax.fori_loop(0, n_newton, body, u0)

f_nl = 20.0 * jnp.ones((N, N))

def L_root(k, c, tangent_solve):
    u = lax.custom_root(lambda u: F_nl(u, k, c, f_nl, h), jnp.zeros((N, N)), newton_krylov, tangent_solve)
    return h**2 * jnp.sum(u**2)

u_nl = lax.custom_root(lambda u: F_nl(u, k0, 5.0, f_nl, h), jnp.zeros((N, N)), newton_krylov,
                       lambda g, y: lax.custom_linear_solve(g, y, cg_while, symmetric=True))
res_nl = float(jnp.linalg.norm(F_nl(u_nl, k0, 5.0, f_nl, h)) / jnp.linalg.norm(f_nl))
print(f"Newton–Krylov converged: relative residual {res_nl:.1e}")
assert res_nl < 1e-11
Newton–Krylov converged: relative residual 8.1e-15
# ❌ Wrong: calling a while_loop solver directly inside tangent_solve (not transposable), or calling jsl.cg directly (errors on 0.12.0.1)
bad_tangent = {
    "plain cg_while": lambda g, y: cg_while(g, y),
    "plain jsl.cg":   lambda g, y: jsl.cg(g, y, tol=1e-13)[0],
}
bad_results = {}
for name, ts in bad_tangent.items():
    try:
        jax.grad(L_root, argnums=1)(k0, 5.0, ts)
        bad_results[name] = "no error"
    except Exception as e:
        bad_results[name] = type(e).__name__
        print(f"{name:<14} → {type(e).__name__}: {str(e).splitlines()[0][:90]}")
    # Forward mode doesn't need to transpose tangent_solve, so both work there
    _, t = jax.jvp(lambda c: L_root(k0, c, ts), (5.0,), (1.0,))
    print(f"{'':<14}   but jvp works: dL/dc = {float(t):.8f}")
assert bad_results["plain cg_while"] == "ValueError"
assert bad_results["plain jsl.cg"] == "NotImplementedError"
plain cg_while → ValueError: Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynami
                 but jvp works: dL/dc = -0.02266215
plain jsl.cg   → NotImplementedError: open an issue at https://github.com/google/jax !!
                 but jvp works: dL/dc = -0.02266215
# ✅ Correct: inside tangent_solve, wrap the iterative solver in custom_linear_solve (the linearized operator is symmetric, so symmetric=True is valid)
good_ts = lambda g, y: lax.custom_linear_solve(g, y, cg_while, symmetric=True)

gc = jax.jit(jax.grad(L_root, argnums=1), static_argnums=2)(k0, 5.0, good_ts)
L_j = jax.jit(L_root, static_argnums=2)
fd_c = (L_j(k0, 5.0 + 1e-5, good_ts) - L_j(k0, 5.0 - 1e-5, good_ts)) / 2e-5
gk = jax.grad(L_root, argnums=0)(k0, 5.0, good_ts)
d = jax.random.normal(jax.random.key(7), (N, N))
fd_k = (L_j(k0 + 1e-5 * d, 5.0, good_ts) - L_j(k0 - 1e-5 * d, 5.0, good_ts)) / 2e-5
print(f"dL/dc : AD {float(gc):.10f}  finite difference {float(fd_c):.10f}")
print(f"dL/dk·d: AD {float(jnp.vdot(gk, d)):.10e}  finite difference {float(fd_k):.10e}")
assert abs(gc - fd_c) < 1e-7 * abs(gc)
assert abs(jnp.vdot(gk, d) - fd_k) < 1e-7 * abs(fd_k)
dL/dc : AD -0.0226621510  finite difference -0.0226621510
dL/dk·d: AD -3.2324462676e-02  finite difference -3.2324462684e-02

🔍 Extra: three preconditions for implicit differentiation

⚪ Optional: to check the preconditions before using implicit differentiation for real

  1. The forward solution has to be accurate enough. The implicit formula assumes $A u = f$ (or $F(u)=0$) holds exactly. If the forward solve only gets to $10^{-6}$, the gradient is generally only accurate to about $10^{-6}$ too. The same goes for the tolerance of the adjoint solve. A common compromise in inverse problems: use a relative tolerance around $10^{-10}$ for both the forward and adjoint solves (cheap in float64).
  2. The adjoint operator has to be right. For symmetric problems use symmetric=True; for nonsymmetric ones (convection, non-conservative schemes, some boundary treatments) you must provide transpose_solve. If you're not sure, generate $A^\top$ with jax.linear_transpose and check symmetry numerically as in section 4.2.
  3. The Jacobian has to be invertible. When $A$ or $J_F$ is singular (pure Neumann problems, bifurcation points), the implicit gradient doesn't exist. For pure Neumann problems, pin down the constant mode first (for example add the constraint $\sum u = 0$).

5 Inverse problem: recovering the diffusion coefficient $k(x)$

5.1 Setup

🟢 Must-read

Using the variable-coefficient problem $-\nabla\cdot(k\nabla u)=f$ from section 4: given several source terms $f_s$ and the corresponding (noisy) observed fields $u^{\text{obs}}_s$, recover the spatially varying coefficient field $k(x)$.

  • Parameterization: $k = e^{m}$, with $m$ as the optimization variable ($N^2=961$ of them), which guarantees $k>0$ automatically;
  • Multiple load cases: with a single source term, $k$ can't be identified where $\nabla u = 0$. So we use 5 different source terms (4 localized Gaussian sources + 1 uniform source), and the forward solves are done in one go with vmap;
  • Regularization: the inverse problem is ill-posed, so we add a Tikhonov smoothing term $\tfrac{\alpha}{2}\|\nabla_h m\|^2$;
  • Objective

$$\mathcal{L}(m) = \frac{1}{2}\,\frac{\sum_s \|u_s(e^{m}) - u_s^{\text{obs}}\|^2}{\sum_s\|u^{\text{obs}}_s\|^2} + \frac{\alpha}{2}\|\nabla_h m\|^2 .$$

Each gradient = 5 forward solves + 5 adjoint solves (handled automatically by custom_linear_solve), independent of the number of parameters.

Data and source terms are passed to the jit functions as arguments, not captured in closures.

N = 31; h = 1.0 / (N + 1)
xg = jnp.linspace(h, 1 - h, N)
XX, YY = jnp.meshgrid(xg, xg, indexing="ij")

def gauss(cx, cy, s2):
    return jnp.exp(-((XX - cx) ** 2 + (YY - cy) ** 2) / s2)

k_true = 1 + 1.0 * gauss(0.3, 0.65, 0.015) + 0.6 * gauss(0.7, 0.3, 0.03)
F_src = jnp.stack([10 * gauss(a, b, 0.02) for a, b in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]]
                  + [jnp.ones((N, N))])

solve_many = jax.jit(jax.vmap(u_implicit, in_axes=(None, 0, None)))       # solve all 5 load cases at once
U_clean = solve_many(k_true, F_src, h)
noise_level = 0.002 * float(jnp.max(jnp.abs(U_clean)))
U_obs = U_clean + noise_level * jax.random.normal(jax.random.key(2024), U_clean.shape)

# Cross-check the forward solve against SciPy (the variable-coefficient matrix is computed explicitly with jacfwd; 961×961 is feasible)
A_k = np.asarray(jax.jacfwd(lambda u: apply_A(k_true, u.reshape(N, N), h).ravel())(jnp.zeros(N * N)))
u_ref0 = np.linalg.solve(A_k, np.asarray(F_src[0]).ravel()).reshape(N, N)
print(f"load cases: {F_src.shape[0]}, noise std {noise_level:.2e}; forward solution vs NumPy dense solve {rel(U_clean[0], u_ref0):.1e}")
assert rel(U_clean[0], u_ref0) < 1e-10

def inv_loss(m, F, U_obs, alpha, h):
    U = jax.vmap(u_implicit, in_axes=(None, 0, None))(jnp.exp(m), F, h)
    misfit = 0.5 * jnp.sum((U - U_obs) ** 2) / jnp.sum(U_obs**2)
    mp = jnp.pad(m, 1, mode="edge")
    reg = 0.5 * alpha * (jnp.sum((mp[1:, 1:-1] - mp[:-1, 1:-1]) ** 2) + jnp.sum((mp[1:-1, 1:] - mp[1:-1, :-1]) ** 2))
    return misfit + reg

k_err = lambda m: rel(jnp.exp(m), k_true)
ALPHA = 1e-5

# Cross-check the gradient against finite differences first
m_test = 0.1 * jax.random.normal(jax.random.key(5), (N, N))
d = jax.random.normal(jax.random.key(6), (N, N))
g_inv = jax.grad(inv_loss)(m_test, F_src, U_obs, ALPHA, h)
L_inv = jax.jit(inv_loss)
fd_inv = (L_inv(m_test + 1e-6 * d, F_src, U_obs, ALPHA, h) - L_inv(m_test - 1e-6 * d, F_src, U_obs, ALPHA, h)) / 2e-6
print(f"Inverse-problem gradient: AD {float(jnp.vdot(g_inv, d)):.10e}  finite difference {float(fd_inv):.10e}")
assert abs(jnp.vdot(g_inv, d) - fd_inv) < 1e-6 * abs(fd_inv)
load cases: 5, noise std 2.34e-04; forward solution vs NumPy dense solve 5.7e-14
Inverse-problem gradient: AD -4.3095121626e-04  finite difference -4.3095121720e-04

5.2 Optimizing with Optax's L-BFGS

⚪ Optional: when you want deterministic optimization with Optax's L-BFGS

First confirm that Optax 0.2.9 has optax.lbfgs (it comes with a zoom line search). L-BFGS's update takes a few more keyword arguments than first-order optimizers: value, grad and value_fn (the line search needs to re-evaluate the function). optax.value_and_grad_from_state reuses the function value and gradient already computed during the line search, saving one forward + adjoint pass.

assert hasattr(optax, "lbfgs") and hasattr(optax, "value_and_grad_from_state")
print("optax.lbfgs signature:", inspect.signature(optax.lbfgs))

lbfgs = optax.lbfgs()

@jax.jit
def lbfgs_step(m, state, F, U_obs, alpha, h):
    fun = lambda m: inv_loss(m, F, U_obs, alpha, h)          # the closure holds traced values, not constants
    value, grad = optax.value_and_grad_from_state(fun)(m, state=state)
    updates, state = lbfgs.update(grad, state, m, value=value, grad=grad, value_fn=fun)
    return optax.apply_updates(m, updates), state, value, grad

m = jnp.zeros((N, N)); state = lbfgs.init(m)
err0 = k_err(m)
hist = []
t0 = time.perf_counter()
for it in range(60):
    m, state, val, g = lbfgs_step(m, state, F_src, U_obs, ALPHA, h)
    hist.append((it, float(val), k_err(m), float(jnp.linalg.norm(g))))
t_lbfgs = time.perf_counter() - t0
for it, v, e, gn in hist[::10] + [hist[-1]]:
    print(f"iter {it:3d}: objective {v:.6e}  |k relative error {e:.4f}  |gradient norm {gn:.2e}")
m_lbfgs = m
INV_ERR = k_err(m_lbfgs)
print(f"Initial k error {err0:.4f} → {INV_ERR:.4f} after 60 L-BFGS steps (took {t_lbfgs:.2f} s, printed only)")
assert INV_ERR < 0.03
assert INV_ERR < err0 / 10
optax.lbfgs signature: (learning_rate: float | jax.Array | collections.abc.Callable[[jax.Array | numpy.ndarray | numpy.bool | numpy.number | bool | int | float | complex], jax.Array | numpy.ndarray | numpy.bool | numpy.number | bool | int | float | complex] | None = None, memory_size: int = 10, scale_init_precond: bool = True, linesearch: optax._src.base.GradientTransformationExtraArgs | optax._src.base.GradientTransformation | None = GradientTransformationExtraArgs(init=<function scale_by_zoom_linesearch.<locals>.init_fn at 0x11bd3cf60>, update=<function scale_by_zoom_linesearch.<locals>.update_fn at 0x11bd3d0c0>)) -> optax._src.base.GradientTransformationExtraArgs
iter   0: objective 3.002430e-03  |k relative error 0.1831  |gradient norm 2.53e-03
iter  10: objective 3.827272e-05  |k relative error 0.0339  |gradient norm 3.10e-05
iter  20: objective 2.995403e-05  |k relative error 0.0130  |gradient norm 1.17e-05
iter  30: objective 2.975092e-05  |k relative error 0.0117  |gradient norm 6.07e-07
iter  40: objective 2.974586e-05  |k relative error 0.0117  |gradient norm 1.30e-07
iter  50: objective 2.974552e-05  |k relative error 0.0117  |gradient norm 5.44e-08
iter  59: objective 2.974550e-05  |k relative error 0.0117  |gradient norm 2.66e-08
Initial k error 0.1851 → 0.0117 after 60 L-BFGS steps (took 2.35 s, printed only)
# For comparison: Adam (a first-order method, needs many more steps)
adam = optax.adam(0.05)

@jax.jit
def adam_step(m, s, F, U_obs, alpha, h):
    val, g = jax.value_and_grad(inv_loss)(m, F, U_obs, alpha, h)
    upd, s = adam.update(g, s, m)
    return optax.apply_updates(m, upd), s, val

m_a = jnp.zeros((N, N)); s_a = adam.init(m_a)
t0 = time.perf_counter()
for it in range(300):
    m_a, s_a, val_a = adam_step(m_a, s_a, F_src, U_obs, ALPHA, h)
print(f"Adam, 300 steps: objective {float(val_a):.6e}, k relative error {k_err(m_a):.4f} (took {time.perf_counter() - t0:.2f} s, printed only)")
assert k_err(m_a) < 0.03
Adam, 300 steps: objective 2.974550e-05, k relative error 0.0117 (took 2.79 s, printed only)
fig, axes = plt.subplots(1, 3, figsize=(12, 3.6), constrained_layout=True)
vmin, vmax = float(k_true.min()), float(k_true.max())
ext = [0, 1, 0, 1]
for ax, field, title in [(axes[0], k_true, "true k(x)"), (axes[1], jnp.exp(m_lbfgs), "L-BFGS reconstruction")]:
    im = ax.imshow(np.asarray(field).T, origin="lower", extent=ext, cmap="Blues", vmin=vmin, vmax=vmax)
    ax.set_title(title, fontsize=11)
fig.colorbar(im, ax=axes[:2], shrink=0.85, label="k")
errf = np.asarray(jnp.exp(m_lbfgs) - k_true).T
lim = float(np.abs(errf).max())
im2 = axes[2].imshow(errf, origin="lower", extent=ext, cmap="RdBu_r", vmin=-lim, vmax=lim)
axes[2].set_title(f"error (relative L2 = {INV_ERR:.3f})", fontsize=11)
fig.colorbar(im2, ax=axes[2], shrink=0.85)
for ax in axes:
    ax.set_xlabel("x"); ax.set_ylabel("y")
plt.show()
No description has been provided for this image

🔍 Extra: what matters more than the optimizer in inverse problems

⚪ Optional: when you do inverse problems for real and need to deal with regularization and identifiability

  • Where the error is: the most visible thing in the error map is that the tops of the two Gaussian peaks are underestimated (blue), the classic sign of smoothing regularization flattening sharp peaks. The rest is small random error caused by the noise. L-BFGS and Adam converge to the same objective value and the same error, which shows the 1.2% error comes from "noise + regularization", not from the optimizer failing to converge.
  • Identifiability: near the boundary $u=0$ and $\nabla u$ is small, so the data is insensitive to $k$; with a single source term, $k$ is completely unidentifiable at points where $\nabla u=0$. Multiple load cases (different source terms) are the most effective improvement, far more than switching optimizers.
  • Regularization parameter: if $\alpha$ is too big, the peaks get flattened; too small, and noise gets amplified into high-frequency oscillations. A common choice is Morozov's discrepancy principle: pick $\alpha$ so the misfit is about the noise level.
  • Parameterization: $k=e^m$ both guarantees positivity and turns "multiplicative" changes into additive ones, which improves conditioning. In the discretization you can also put $k$ on the elements instead of the nodes.
  • Second-order information: Gauss–Newton / Newton-CG need Hessian-vector products, which you can get with jax.jvp(jax.grad(loss), ...) (forward-over-reverse; see the HVP section of Basics 07), and this works through custom_linear_solve too.

6 Time-dependent problems: the heat equation

6.1 Explicit and Crank–Nicolson schemes

🟢 Must-read

The 1D heat equation $u_t = \alpha u_{xx}$, $x\in(0,1)$, $u(0,t)=u(1,t)=0$. In space we use central differences $L_h$, and write $r = \alpha\Delta t/h^2$:

  • Explicit Euler: $u^{n+1} = u^n + \alpha\Delta t\,L_h u^n$. First order in time, conditionally stable: it requires $r\le \tfrac12$;
  • Crank–Nicolson: $\big(I - \tfrac{\alpha\Delta t}{2}L_h\big)u^{n+1} = \big(I + \tfrac{\alpha\Delta t}{2}L_h\big)u^n$. Second order in time, unconditionally stable. Each step solves a tridiagonal system with lax.linalg.tridiagonal_solve(dl, d, du, b) (the $O(n)$ Thomas algorithm, differentiable).

Time integration uses lax.scan, and the physical parameter $\alpha$ and the step size are passed in as arguments.

Reference solution: take $u_0 = \sin(\pi x)$, an eigenvector of $L_h$ with eigenvalue $-\lambda_h = -\tfrac{4}{h^2}\sin^2\tfrac{\pi h}{2}$; the exact semi-discrete solution is $u(t) = e^{-\alpha\lambda_h t}u_0$. Using it as the reference isolates the time error.

def lap1d(u, h):
    up = jnp.pad(u, 1)
    return (up[:-2] - 2 * u + up[2:]) / h**2

def explicit_step(u, alpha, dt, h):
    return u + dt * alpha * lap1d(u, h)

def cn_step(u, alpha, dt, h):
    n = u.shape[0]
    r = alpha * dt / h**2
    rhs = u + 0.5 * dt * alpha * lap1d(u, h)
    off = -0.5 * r * jnp.ones(n - 1)
    dl = jnp.concatenate([jnp.zeros(1), off])                 # convention: dl[0] = 0, du[-1] = 0
    du = jnp.concatenate([off, jnp.zeros(1)])
    d = (1 + r) * jnp.ones(n)
    return lax.linalg.tridiagonal_solve(dl, d, du, rhs[:, None])[:, 0]

@partial(jax.jit, static_argnames=("step", "n_steps"))
def integrate(u0, alpha, dt, h, step, n_steps):
    def body(u, _):
        return step(u, alpha, dt, h), None
    return lax.scan(body, u0, None, length=n_steps)[0]

Nx = 99; hx = 1.0 / (Nx + 1)
x1 = jnp.linspace(hx, 1 - hx, Nx)
u0 = jnp.sin(jnp.pi * x1)
alpha0, T_end = 0.5, 0.2
lam_h = 4 / hx**2 * jnp.sin(jnp.pi * hx / 2) ** 2
u_semi = jnp.exp(-alpha0 * lam_h * T_end) * u0              # exact semi-discrete solution

# Cross-check the tridiagonal solve against SciPy
dt_ = T_end / 50
r_ = alpha0 * dt_ / hx**2
ab = np.zeros((3, Nx)); ab[0, 1:] = -0.5 * r_; ab[1] = 1 + r_; ab[2, :-1] = -0.5 * r_
rhs_ = np.asarray(u0 + 0.5 * dt_ * alpha0 * lap1d(u0, hx))
assert rel(cn_step(u0, alpha0, dt_, hx), scipy.linalg.solve_banded((1, 1), ab, rhs_)) < 1e-13

for name, step, steps_list in [("Explicit Euler", explicit_step, [4000, 8000, 16000]),
                               ("Crank–Nicolson", cn_step, [50, 100, 200, 400])]:
    e = np.array([float(jnp.max(jnp.abs(integrate(u0, alpha0, T_end / ns, hx, step, ns) - u_semi)))
                  for ns in steps_list])
    ratio = e[:-1] / e[1:]
    print(f"{name:<15} r = {[round(alpha0 * T_end / ns / hx**2, 3) for ns in steps_list]}")
    print(f"{'':<15} time error {np.array2string(e, precision=3)}  error ratio {np.round(ratio, 3)}")
    if step is explicit_step:
        assert np.all(np.abs(ratio - 2) < 0.05)               # first order
    else:
        assert np.all(np.abs(ratio - 4) < 0.05)               # second order
Explicit Euler  r = [0.25, 0.125, 0.062]
                time error [4.538e-05 2.269e-05 1.134e-05]  error ratio [2. 2.]
Crank–Nicolson  r = [20.0, 10.0, 5.0, 2.5]
                time error [1.194e-05 2.986e-06 7.464e-07 1.866e-07]  error ratio [4. 4. 4.]
# ❌ Wrong: using r > 1/2 with the explicit scheme; no error is raised, and high-frequency modes in the rounding error grow exponentially by a factor of |1-4r| per step
n_big = 100; dt_big = T_end / n_big
r_big = alpha0 * dt_big / hx**2
u0_pert = u0 + 1e-12 * jnp.cos(37 * x1)                      # simulate a perturbation at rounding level
try:
    u_exp_bad = integrate(u0_pert, alpha0, dt_big, hx, explicit_step, n_big)
    blow = float(jnp.max(jnp.abs(u_exp_bad)))
    print(f"r = {r_big:.1f}: explicit scheme max|u(T)| = {blow:.3e} (the true solution is about {float(u_semi.max()):.3f})")
except Exception as e:
    blow = None
    print(type(e).__name__, e)
assert blow > 1e10
r = 10.0: explicit scheme max|u(T)| = 3.123e+144 (the true solution is about 0.373)
# ✅ Correct: keep r ≤ 1/2 for the explicit scheme; or switch to the unconditionally stable Crank–Nicolson (same large step)
u_cn_big = integrate(u0_pert, alpha0, dt_big, hx, cn_step, n_big)
err_cn_big = float(jnp.max(jnp.abs(u_cn_big - u_semi)))
print(f"r = {r_big:.1f}: Crank–Nicolson error {err_cn_big:.2e}")
assert err_cn_big < 1e-4
n_ok = int(np.ceil(alpha0 * T_end / (0.5 * hx**2)))         # the minimum number of steps that satisfies r ≤ 1/2
u_exp_ok = integrate(u0_pert, alpha0, T_end / n_ok, hx, explicit_step, n_ok)
print(f"The explicit scheme needs ≥ {n_ok} steps to be stable; the error is then {float(jnp.max(jnp.abs(u_exp_ok - u_semi))):.2e}")
assert float(jnp.max(jnp.abs(u_exp_ok - u_semi))) < 1e-3
r = 10.0: Crank–Nicolson error 2.99e-06
The explicit scheme needs ≥ 2000 steps to be stable; the error is then 9.08e-05

6.2 Sensitivities: gradients with respect to physical parameters and initial conditions

🟢 Must-read

Take the observation functional $Q = h\sum_i w_i\,u_i(T)$ with $w = x(1-x)$. grad goes through scan, and a single backward pass gives both $\partial Q/\partial\alpha$ and $\partial Q/\partial u_0$ (a 99-dimensional vector). The latter can also be checked analytically: the CN propagator $P=(I-\tfrac{\alpha\Delta t}{2}L_h)^{-1}(I+\tfrac{\alpha\Delta t}{2}L_h)$ is symmetric, so $\partial Q/\partial u_0 = h\,(P^{\top})^{n}w = h\,P^n w$, which is the same as integrating once more with $w$ as the initial value.

w_obs = x1 * (1 - x1)
N_STEPS = 400

def Q(u0, alpha):
    return hx * jnp.sum(w_obs * integrate(u0, alpha, T_end / N_STEPS, hx, cn_step, N_STEPS))

dQ_du0, dQ_dalpha = jax.jit(jax.grad(Q, argnums=(0, 1)))(u0, alpha0)
eps = 1e-6
fd_alpha = (Q(u0, alpha0 + eps) - Q(u0, alpha0 - eps)) / (2 * eps)
du_dir = jax.random.normal(jax.random.key(11), (Nx,))
fd_u0 = (Q(u0 + eps * du_dir, alpha0) - Q(u0 - eps * du_dir, alpha0)) / (2 * eps)
print(f"dQ/dα   : AD {float(dQ_dalpha):.10f}  finite difference {float(fd_alpha):.10f}")
print(f"dQ/du0·d: AD {float(jnp.vdot(dQ_du0, du_dir)):.10f}  finite difference {float(fd_u0):.10f}")
assert abs(dQ_dalpha - fd_alpha) < 1e-7 * abs(dQ_dalpha)
assert abs(jnp.vdot(dQ_du0, du_dir) - fd_u0) < 1e-7 * abs(fd_u0)

adj = hx * integrate(w_obs, alpha0, T_end / N_STEPS, hx, cn_step, N_STEPS)   # adjoint = integrate w forward once more
print(f"dQ/du0 vs analytic adjoint: relative error {rel(dQ_du0, adj):.1e}")
assert rel(dQ_du0, adj) < 1e-12
dQ/dα   : AD -0.0949092662  finite difference -0.0949092675
dQ/du0·d: AD -0.0036884556  finite difference -0.0036884556
dQ/du0 vs analytic adjoint: relative error 6.9e-15

6.3 Memory for long time integration: checkpoint and $O(\sqrt{T})$ segmentation

⚪ Optional: when backprop through a long time integration runs out of memory (theory in Advanced 06 §6.6)

When you differentiate scan in reverse mode, residuals have to be saved for every step. A CN step also has intermediates from the tridiagonal solve, so by default memory is about "number of steps × several state vectors". Three ways to write it:

  1. No checkpoint: save all residuals at every step;
  2. Per-step checkpoint scan(jax.checkpoint(step)): save only the carry (the state $u^n$) at each step, and recompute the within-step intermediates in the backward pass;
  3. $\sqrt{T}$ segmentation: an outer scan runs $T/B$ blocks, each an inner scan ($B$ steps) wrapped in checkpoint. The forward pass stores only the start of each block ($T/B$ states), and the backward pass recomputes block by block, storing $B$ steps inside the block. With $B\approx\sqrt T$, memory is $O(\sqrt T)$, at the cost of about one extra forward pass.

For the theory and policies, see section 6.6 of Advanced 06 and the 4.8 extra in Advanced 04; here we only look at the effect on PDE time stepping. Below, $N_x=999$ and $T=4096$ steps.

def loss_plain(u0, alpha, dt, h, n):
    body = lambda u, _: (cn_step(u, alpha, dt, h), None)
    return jnp.sum(lax.scan(body, u0, None, length=n)[0] ** 2)

def loss_ckpt(u0, alpha, dt, h, n):
    body = lambda u, _: (cn_step(u, alpha, dt, h), None)
    return jnp.sum(lax.scan(jax.checkpoint(body), u0, None, length=n)[0] ** 2)

def loss_sqrt(u0, alpha, dt, h, n, block=64):
    body = lambda u, _: (cn_step(u, alpha, dt, h), None)
    @jax.checkpoint
    def outer(u, _):
        return lax.scan(body, u, None, length=block)[0], None
    return jnp.sum(lax.scan(outer, u0, None, length=n // block)[0] ** 2)

Nx2 = 999; hx2 = 1.0 / (Nx2 + 1)
u02 = jnp.sin(jnp.pi * jnp.linspace(hx2, 1 - hx2, Nx2))
NT = 4096; dt2 = T_end / NT
CK = {}
for name, fn in [("no checkpoint", loss_plain), ("per-step checkpoint", loss_ckpt), ("√T segments (B=64)", loss_sqrt)]:
    comp = jax.jit(jax.grad(fn, argnums=(0, 1)), static_argnums=4).lower(u02, alpha0, dt2, hx2, NT).compile()
    mem = comp.memory_analysis().temp_size_in_bytes / 2**20
    t_ms = timeit(comp, u02, alpha0, dt2, hx2, n=3)
    CK[name] = (mem, comp(u02, alpha0, dt2, hx2))
    print(f"{name:<16}: temporary memory {mem:8.2f} MiB   time {t_ms:7.1f} ms (printed only)")

g_ref_ck = CK["no checkpoint"][1]
for name, (mem, g) in CK.items():
    assert rel(g[0], g_ref_ck[0]) < 1e-12 and abs(g[1] - g_ref_ck[1]) <= 1e-12 * abs(g_ref_ck[1])  # gradients agree
assert CK["per-step checkpoint"][0] < CK["no checkpoint"][0] / 2
assert CK["√T segments (B=64)"][0] < CK["no checkpoint"][0] / 20

print("\nResiduals saved per step without checkpointing (a small 10-step example):")
print_saved_residuals(lambda u, a: loss_plain(u, a, dt2, hx, 10), u0, alpha0)
print("\nPer-step checkpoint:")
print_saved_residuals(lambda u, a: loss_ckpt(u, a, dt2, hx, 10), u0, alpha0)
no checkpoint   : temporary memory   124.87 MiB   time   134.9 ms (printed only)
per-step checkpoint: temporary memory    31.29 MiB   time   183.6 ms (printed only)
√T segments (B=64): temporary memory     2.51 MiB   time   166.8 ms (printed only)

Residuals saved per step without checkpointing (a small 10-step example):
f64[] output of jitted function '_pad' from /tmp/ipykernel_13886/4042209892.py:2:9 (lap1d)
f64[] output of convert_element_type from /tmp/ipykernel_13886/4042209892.py:11:14 (cn_step)
f64[98] output of broadcast_in_dim from /tmp/ipykernel_13886/4042209892.py:12:21 (cn_step)
f64[99] output of concatenate from /tmp/ipykernel_13886/4042209892.py:13:9 (cn_step)
f64[1] output of broadcast_in_dim from /tmp/ipykernel_13886/4042209892.py:13:9 (cn_step)
f64[99] output of concatenate from /tmp/ipykernel_13886/4042209892.py:14:9 (cn_step)
f64[1] output of broadcast_in_dim from /tmp/ipykernel_13886/4042209892.py:14:9 (cn_step)
f64[99] output of broadcast_in_dim from /tmp/ipykernel_13886/4042209892.py:15:18 (cn_step)
f64[99] output of mul from /tmp/ipykernel_13886/4042209892.py:15:8 (cn_step)
i32[1] output of broadcast_in_dim from /tmp/ipykernel_13886/4042209892.py:16:11 (cn_step)
i32[1] output of broadcast_in_dim from /tmp/ipykernel_13886/4042209892.py:16:11 (cn_step)
f64[10,99] output of scan from /tmp/ipykernel_13886/3908257432.py:3:19 (loss_plain)
f64[10,99,1] output of scan from /tmp/ipykernel_13886/3908257432.py:3:19 (loss_plain)
f64[10,98,1] output of scan from /tmp/ipykernel_13886/3908257432.py:3:19 (loss_plain)
f64[10,98,1] output of scan from /tmp/ipykernel_13886/3908257432.py:3:19 (loss_plain)
f64[99] output of mul from /tmp/ipykernel_13886/3908257432.py:3:19 (loss_plain)

Per-step checkpoint:
f64[] from the argument a
f64[10,99] output of scan from /tmp/ipykernel_13886/3908257432.py:7:19 (loss_ckpt)
f64[99] output of mul from /tmp/ipykernel_13886/3908257432.py:7:19 (loss_ckpt)

As you can see: without checkpointing, each step saves the state, the right-hand side and several intermediate arrays from the tridiagonal solve (shapes [10, 99], [10, 98, 1] and so on); with per-step checkpointing only the [10, 99] state trajectory is left; $\sqrt{T}$ segmentation compresses that further to $T/B + B$ states.

🔍 Extra: "discretize then differentiate" vs "differentiate then discretize"

⚪ Optional: when choosing between the discrete adjoint and the continuous adjoint

Differentiating scan gives the discrete adjoint (discretize-then-optimize). It's the exact gradient of the discrete objective, and its relative difference from finite differences is around $10^{-8}$ (see section 6.2; mostly limited by the error of the finite differences themselves). The continuous adjoint (optimize-then-discretize) from Extra exercise C in Advanced 04 discretizes the adjoint ODE separately and stores only the endpoint, with $O(1)$ memory. But its gradient agrees with the discrete objective only up to $O(\Delta t^p)$, which can make line searches fail when used in optimization. Also, "store only the endpoint and integrate the state backward in the reverse pass" doesn't work for dissipative problems like the heat equation: the heat equation backward in time is ill-posed, and high-frequency errors get amplified exponentially. So continuous adjoints for PDEs usually have to store the forward trajectory too, or add checkpointing. In PDE-constrained optimization, unless memory is really too tight, prefer the discrete adjoint + checkpointing.

7 Differentiable mesh optimization

7.1 Problem: mapping a structured mesh onto a non-convex domain

⚪ Optional: if you work on mesh generation or mesh optimization

The computational domain is the L-shaped domain $\Omega = [0,1]^2 \setminus [0.5,1]\times[0.5,1]$, which has a $270^\circ$ reentrant corner at $(0.5, 0.5)$. We use an $n\times n$ structured quadrilateral mesh, and map the four sides of the parameter square $(s,t)\in[0,1]^2$ onto the boundary of $\Omega$:

  • bottom $t=0$ → $(s, 0)$; left $s=0$ → $(0, t)$; right $s=1$ → $(1, t/2)$;
  • top $t=1$ → follows the polyline $(0,1)\to(0.5,1)\to(0.5,0.5)\to(1,0.5)$ by arc length, so the reentrant corner lands exactly on a node ($n$ a multiple of 3).

The initial mesh is generated with transfinite interpolation (TFI):

$$\mathbf x(s,t) = (1-t)\mathbf B(s) + t\,\mathbf T(s) + (1-s)\mathbf L(t) + s\,\mathbf R(t) - \big[(1-s)(1-t)\mathbf P_{00} + s(1-t)\mathbf P_{10} + (1-s)t\,\mathbf P_{01} + st\,\mathbf P_{11}\big].$$

TFI works well on convex domains, but near a reentrant corner it interpolates nodes outside the domain, producing inverted elements.

7.2 Mesh quality metrics

⚪ Optional: when you need to assess mesh quality

At each corner of a quadrilateral element, take the two edge vectors $\mathbf e_1, \mathbf e_2$ leaving that corner in counterclockwise order (multiplied by $n$ to normalize to parameter space), and form the corner Jacobian $J = [\mathbf e_1\ \mathbf e_2]$:

MetricDefinitionIdeal valueMeaning
Jacobian determinant$\det J$$>0$$\le 0$ means the element is inverted or degenerate
Scaled Jacobian$\dfrac{\det J}{\lvert\mathbf e_1\rvert\,\lvert\mathbf e_2\rvert} = \sin\theta$1Angle quality, in $[-1,1]$
Orthogonality$\lvert\cos\theta\rvert = \dfrac{\lvert\mathbf e_1\cdot\mathbf e_2\rvert}{\lvert\mathbf e_1\rvert\,\lvert\mathbf e_2\rvert}$0How far the angle between mesh lines deviates from $90^\circ$
Condition number$\kappa = \dfrac{\lVert J\rVert_F^2}{2\,\lvert\det J\rvert} = \dfrac{\sigma_1^2+\sigma_2^2}{2\sigma_1\sigma_2}$1Measures angles and aspect ratio together; $\kappa\ge1$, with equality iff $J$ is a similarity transform

7.3 Two smoothing energies

⚪ Optional: if you want to compare the Laplace and Winslow smoothing energies

  • Laplace type: $E_L = \sum_{\text{edges}} \lvert\mathbf x_a - \mathbf x_b\rvert^2$. It's quadratic, and its minimizer satisfies "each interior node equals the average of its four neighbors", i.e. a discrete harmonic map. The Radó–Kneser–Choquet theorem only guarantees that a harmonic map is injective onto a convex target domain, so on a non-convex domain Laplace smoothing can fold.
  • Winslow type: $E_W = \dfrac{1}{4n^2}\sum_{\text{corners}} \dfrac{\lVert J\rVert_F^2}{2\det J}$ (the mean corner condition number). In the continuous setting it's equivalent to Winslow's "inverse harmonic map"; as $\det J\to0^+$ the energy goes to $+\infty$, so it has a built-in barrier that keeps elements from inverting.

Both optimize only the interior nodes, with boundary nodes fixed.

def top_path(s):
    """Top side: follows the polyline (0,1)→(0.5,1)→(0.5,0.5)→(1,0.5) by arc length, total length 1.5"""
    L = 1.5 * s
    x = np.where(L <= 0.5, L, np.where(L <= 1.0, 0.5, L - 0.5))
    y = np.where(L <= 0.5, 1.0, np.where(L <= 1.0, 1.5 - L, 0.5))
    return np.stack([x, y], -1)

def l_shape_tfi(n):
    """(n+1)×(n+1) structured mesh on the L-shaped domain, generated by transfinite interpolation (TFI), NumPy float64"""
    s = np.linspace(0, 1, n + 1)
    B, T = np.stack([s, 0 * s], -1), top_path(s)
    Lf, R = np.stack([0 * s, s], -1), np.stack([np.ones_like(s), 0.5 * s], -1)
    S, Tt = np.meshgrid(s, s, indexing="ij")
    S3, T3 = S[..., None], Tt[..., None]
    X = ((1 - T3) * B[:, None] + T3 * T[:, None] + (1 - S3) * Lf[None] + S3 * R[None]
         - (1 - S3) * (1 - T3) * B[0] - S3 * (1 - T3) * B[-1] - (1 - S3) * T3 * T[0] - S3 * T3 * T[-1])
    return X

def corner_jacobians(X):
    """Jacobians at the 4 corners of each element, shape (4, n, n, 2, 2); the columns are the two counterclockwise-adjacent edges (multiplied by n to normalize)"""
    n = X.shape[0] - 1
    p00, p10, p11, p01 = X[:-1, :-1], X[1:, :-1], X[1:, 1:], X[:-1, 1:]
    Js = [jnp.stack([(nxt - c) * n, (prv - c) * n], -1)
          for c, nxt, prv in [(p00, p10, p01), (p10, p11, p00), (p11, p01, p10), (p01, p00, p11)]]
    return jnp.stack(Js, 0)

def det2(J):
    return J[..., 0, 0] * J[..., 1, 1] - J[..., 0, 1] * J[..., 1, 0]

def mesh_quality(X):
    J = corner_jacobians(X)
    det = det2(J)
    e1, e2 = J[..., :, 0], J[..., :, 1]
    l1, l2 = jnp.linalg.norm(e1, axis=-1), jnp.linalg.norm(e2, axis=-1)
    sj = det / (l1 * l2)
    cos = jnp.abs(jnp.sum(e1 * e2, -1)) / (l1 * l2)
    kappa = jnp.sum(J**2, axis=(-1, -2)) / (2 * jnp.abs(det))
    n_inv = int(jnp.sum(jnp.min(det, axis=0) <= 0))
    return dict(inverted=n_inv, min_det=float(det.min()), min_sJ=float(sj.min()),
                mean_sJ=float(sj.mean()), max_cos=float(cos.max()), mean_cos=float(cos.mean()),
                max_cond=float(kappa.max()) if n_inv == 0 else float("inf"))

n_mesh = 24
X_tfi = jnp.asarray(l_shape_tfi(n_mesh))
# Boundary check: the four corners and the reentrant corner are all in the right place
assert np.allclose(X_tfi[0, 0], [0, 0]) and np.allclose(X_tfi[-1, 0], [1, 0])
assert np.allclose(X_tfi[-1, -1], [1, 0.5]) and np.allclose(X_tfi[0, -1], [0, 1])
assert np.allclose(X_tfi[2 * n_mesh // 3, -1], [0.5, 0.5])          # the reentrant-corner node
# A single square element: det=1, scaled J=1, cos=0, condition number=1
q_sq = mesh_quality(jnp.stack(jnp.meshgrid(jnp.array([0.0, 1.0]), jnp.array([0.0, 1.0]), indexing="ij"), -1))
assert np.isclose(q_sq["min_sJ"], 1) and np.isclose(q_sq["max_cos"], 0) and np.isclose(q_sq["max_cond"], 1)
Q_TFI = mesh_quality(X_tfi)
print("Initial TFI mesh:", {k: (round(v, 4) if isinstance(v, float) else v) for k, v in Q_TFI.items()})
assert Q_TFI["inverted"] > 0
Initial TFI mesh: {'inverted': 23, 'min_det': -0.25, 'min_sJ': -0.3162, 'mean_sJ': 0.833, 'max_cos': 1.0, 'mean_cos': 0.3173, 'max_cond': inf}
def assemble(Xb, Y):
    """Write the interior nodes Y back into the full mesh; Xb supplies the (fixed) boundary nodes and is passed in as an argument"""
    return Xb.at[1:-1, 1:-1].set(Y)

def laplace_energy(Y, Xb):
    Z = assemble(Xb, Y)
    return jnp.sum((Z[1:] - Z[:-1]) ** 2) + jnp.sum((Z[:, 1:] - Z[:, :-1]) ** 2)

def run_lbfgs(energy, Y0, Xb, n_iter, *extra):
    opt = optax.lbfgs()
    @jax.jit
    def step(Y, st, Xb, *extra):
        fun = lambda Y: energy(Y, Xb, *extra)
        v, g = optax.value_and_grad_from_state(fun)(Y, state=st)
        upd, st = opt.update(g, st, Y, value=v, grad=g, value_fn=fun)
        return optax.apply_updates(Y, upd), st, v, g
    Y, st = Y0, opt.init(Y0)
    for _ in range(n_iter):
        Y, st, v, g = step(Y, st, Xb, *extra)
    return Y, float(v), float(jnp.linalg.norm(g))

Y_lap, E_lap, gn_lap = run_lbfgs(laplace_energy, X_tfi[1:-1, 1:-1], X_tfi, 200)
X_lap = assemble(X_tfi, Y_lap)
avg4 = 0.25 * (X_lap[2:, 1:-1] + X_lap[:-2, 1:-1] + X_lap[1:-1, 2:] + X_lap[1:-1, :-2])
print(f"Laplace energy minimization: gradient norm {gn_lap:.1e}, max deviation from the four-neighbor average {float(jnp.abs(avg4 - Y_lap).max()):.1e}")
assert float(jnp.abs(avg4 - Y_lap).max()) < 1e-10          # the minimizer = a discrete harmonic map
Q_LAP = mesh_quality(X_lap)
print("After Laplace smoothing:", {k: (round(v, 4) if isinstance(v, float) else v) for k, v in Q_LAP.items()})
assert Q_LAP["inverted"] > 0                                 # the harmonic map folds on the non-convex domain
Laplace energy minimization: gradient norm 5.8e-15, max deviation from the four-neighbor average 1.1e-16
After Laplace smoothing: {'inverted': 18, 'min_det': -1.9509, 'min_sJ': -0.9992, 'mean_sJ': 0.8953, 'max_cos': 1.0, 'mean_cos': 0.2633, 'max_cond': inf}

7.4 A numerically stable barrier: double where

⚪ Optional: when writing barriers, 1/x and other functions that blow up at a boundary (how double where works: Basics 03 §3.8)

$\lVert J\rVert_F^2/\det J$ only makes sense for $\det J>0$. But the initial mesh already has inverted elements, and the optimizer may also probe points with $\det J\le 0$, so the barrier has to be extended to the whole real line. We pick a threshold $\delta>0$:

$$\phi(d) = \begin{cases} 1/d, & d > \delta \\ \dfrac1\delta - \dfrac{d-\delta}{\delta^2} + \dfrac{(d-\delta)^2}{\delta^3}, & d \le \delta \end{cases}$$

The second piece is the second-order Taylor expansion of $1/d$ at $\delta$, so $\phi$ is continuously differentiable and still grows as $d\to-\infty$, which can "push inverted elements back" (untangling).

Writing jnp.where(det > δ, 1/det, extension) directly is fine for the forward pass. But as soon as one corner has exactly $\det J = 0$ (a degenerate element, for example three collinear nodes), the derivative of the unselected branch $1/d$ is $-1/d^2 = -\infty$, multiplied by the 0 that where assigns to it, giving $0\times\infty=\text{NaN}$, which pollutes the whole gradient. For how this works, see the "double where" extra in Basics 03.

DELTA = 0.05

def barrier_ext(d, delta):
    return 1 / delta - (d - delta) / delta**2 + (d - delta) ** 2 / delta**3

# A small mesh of 2×2 elements: move the center node onto the bottom boundary at (0.25, 0), collinear with (0,0) and (0.5,0) → some corner has det exactly 0
s3 = jnp.array([0.0, 0.5, 1.0])
X_deg = jnp.stack(jnp.meshgrid(s3, s3, indexing="ij"), -1).at[1, 1].set(jnp.array([0.25, 0.0]))
det_deg = det2(corner_jacobians(X_deg))
print("Corner dets of the degenerate mesh: number exactly 0 =", int(jnp.sum(det_deg == 0)), ", number negative =", int(jnp.sum(det_deg < 0)))
assert int(jnp.sum(det_deg == 0)) >= 1
Corner dets of the degenerate mesh: number exactly 0 = 2 , number negative = 1
# ❌ Wrong: a single where; the forward value is finite, but a corner with det=0 makes the derivative of the 1/det branch 0×inf = NaN
def winslow_single_where(Y, Xb, delta):
    J = corner_jacobians(assemble(Xb, Y))
    det = det2(J)
    phi = jnp.where(det > delta, 1 / det, barrier_ext(det, delta))
    return jnp.mean(0.5 * jnp.sum(J**2, axis=(-1, -2)) * phi)

try:
    v_bad, g_bad = jax.value_and_grad(winslow_single_where)(X_deg[1:-1, 1:-1], X_deg, DELTA)
    print(f"energy = {float(v_bad):.4f} (finite), gradient = {np.asarray(g_bad).ravel()}")
except Exception as e:
    g_bad = None
    print(type(e).__name__, e)
assert bool(jnp.isfinite(v_bad)) and bool(jnp.any(jnp.isnan(g_bad)))
energy = 130.6745 (finite), gradient = [nan nan]
# ✅ Correct: double where; first replace dangerous inputs with the safe value δ, then select the output; the 1/d branch never sees d ≤ δ
def winslow_energy(Y, Xb, delta):
    J = corner_jacobians(assemble(Xb, Y))
    det = det2(J)
    ok = det > delta
    det_safe = jnp.where(ok, det, delta)                    # 1st where: mask the dangerous inputs
    phi = jnp.where(ok, 1 / det_safe, barrier_ext(det, delta))  # 2nd where: select the output
    return jnp.mean(0.5 * jnp.sum(J**2, axis=(-1, -2)) * phi)

v_ok, g_ok = jax.value_and_grad(winslow_energy)(X_deg[1:-1, 1:-1], X_deg, DELTA)
print(f"energy = {float(v_ok):.4f}, gradient = {np.asarray(g_ok).ravel()}")
assert bool(jnp.all(jnp.isfinite(g_ok)))
assert jnp.allclose(v_ok, winslow_single_where(X_deg[1:-1, 1:-1], X_deg, DELTA))   # identical forward value
# The gradient matches finite differences (checked on the TFI initial mesh of the large grid, which has both regular and extended corners)
Yt = X_tfi[1:-1, 1:-1]
dY = jax.random.normal(jax.random.key(21), Yt.shape)
gW = jax.grad(winslow_energy)(Yt, X_tfi, DELTA)
fdW = (winslow_energy(Yt + 1e-7 * dY, X_tfi, DELTA) - winslow_energy(Yt - 1e-7 * dY, X_tfi, DELTA)) / 2e-7
print(f"Winslow gradient on the TFI mesh: AD {float(jnp.vdot(gW, dY)):.8e}  finite difference {float(fdW):.8e}")
assert abs(jnp.vdot(gW, dY) - fdW) < 1e-5 * abs(fdW)
energy = 130.6745, gradient = [ -870.75416667 -1384.54982639]
Winslow gradient on the TFI mesh: AD 5.93730275e+01  finite difference 5.93730271e+01

7.5 Winslow optimization: starting from the inverted TFI mesh

⚪ Optional: if you want to see the full effect of mesh untangling and smoothing

t0 = time.perf_counter()
Y_w, E_w, gn_w = run_lbfgs(winslow_energy, X_tfi[1:-1, 1:-1], X_tfi, 300, DELTA)
t_mesh = time.perf_counter() - t0
X_w = assemble(X_tfi, Y_w)
Q_W = mesh_quality(X_w)
print(f"Winslow optimization, 300 L-BFGS steps: energy {E_w:.6f}, gradient norm {gn_w:.1e}, took {t_mesh:.2f} s (printed only)\n")

rows = [("TFI", Q_TFI), ("Laplace", Q_LAP), ("Winslow", Q_W)]
keys = list(Q_W.keys())
print(f"{'mesh':<9}" + "".join(f"{k:>11}" for k in keys))
for name, q in rows:
    print(f"{name:<9}" + "".join(f"{q[k]:>11d}" if isinstance(q[k], int) else f"{q[k]:>11.4f}" for k in keys))

assert np.allclose(X_w[0], X_tfi[0]) and np.allclose(X_w[-1], X_tfi[-1])            # boundary nodes didn't move
assert np.allclose(X_w[:, 0], X_tfi[:, 0]) and np.allclose(X_w[:, -1], X_tfi[:, -1])
assert Q_W["inverted"] == 0 and Q_W["min_det"] > 0                                  # no inverted elements
assert Q_W["min_det"] > DELTA                                                        # the optimum is in the true barrier region; the extension isn't active
assert Q_W["min_sJ"] > Q_TFI["min_sJ"] + 0.5                                   # the worst angle quality improves a lot
assert Q_W["mean_sJ"] > Q_TFI["mean_sJ"] and Q_W["max_cos"] < Q_TFI["max_cos"]   # overall angle quality improves too
assert Q_W["max_cond"] < 10
MESH_Q = rows
Winslow optimization, 300 L-BFGS steps: energy 1.302910, gradient norm 5.9e-03, took 0.47 s (printed only)

mesh        inverted    min_det     min_sJ    mean_sJ    max_cos   mean_cos   max_cond
TFI               23    -0.2500    -0.3162     0.8330     1.0000     0.3173        inf
Laplace           18    -1.9509    -0.9992     0.8953     1.0000     0.2633        inf
Winslow            0     0.0656     0.3112     0.9177     0.9504     0.3306     5.7324
def plot_mesh(ax, X, title):
    X = np.asarray(X)
    det = np.asarray(jnp.min(det2(corner_jacobians(jnp.asarray(X))), axis=0))
    bad = np.argwhere(det <= 0)
    for i, j in bad:                                   # fill the inverted elements
        quad = np.array([X[i, j], X[i + 1, j], X[i + 1, j + 1], X[i, j + 1]])
        ax.fill(quad[:, 0], quad[:, 1], color="#d1495b", alpha=0.55, lw=0)
    for i in range(X.shape[0]):
        ax.plot(X[i, :, 0], X[i, :, 1], color="#4a4a4a", lw=0.5)
        ax.plot(X[:, i, 0], X[:, i, 1], color="#4a4a4a", lw=0.5)
    outline = np.array([[0, 0], [1, 0], [1, 0.5], [0.5, 0.5], [0.5, 1], [0, 1], [0, 0]])
    ax.plot(outline[:, 0], outline[:, 1], color="black", lw=1.4)
    ax.set_aspect("equal"); ax.set_xlim(-0.05, 1.05); ax.set_ylim(-0.05, 1.05)
    ax.set_title(f"{title}\ninverted elements: {len(bad)} (red)", fontsize=11)
    ax.set_xticks([]); ax.set_yticks([])

fig, axes = plt.subplots(1, 3, figsize=(13, 4.6), constrained_layout=True)
for ax, (name, X) in zip(axes, [("initial TFI mesh", X_tfi), ("Laplace smoothing", X_lap), ("Winslow + barrier optimization", X_w)]):
    plot_mesh(ax, X, name)
plt.show()
No description has been provided for this image

Interpretation: both TFI and Laplace smoothing "pull" mesh lines out of the domain near the reentrant corner, producing inverted elements. Laplace smoothing even drives the minimum scaled Jacobian close to $-1$ (an element flipped completely over). Starting from the same inverted initial mesh, the Winslow energy first "untangles" the elements using the extension piece; once they're in the $\det J>\delta$ region, the barrier keeps them from inverting again, and in the end every corner has $\det J>0$. The two elements on either side of the reentrant corner have to share the $270^\circ$ interior angle, so the minimum scaled Jacobian can't get close to 1; that's dictated by the topology.

Also note that the Winslow energy optimizes the condition number (a combination of angles and aspect ratio), not orthogonality directly. In the table, the mean $\lvert\cos\theta\rvert$ is actually a little worse than TFI, because mesh lines near the reentrant corner have to bend to keep elements from inverting. If orthogonality matters more (for example in boundary-layer meshes), you can add a weighted penalty term $\sum\cos^2\theta$ to the energy.

🔍 Extra: other barrier forms and practical tips

⚪ Optional: when you do mesh optimization for real and need to choose the barrier form and scale

  • Escobar regularization: replace $\det J$ with $h(d) = \tfrac12\big(d + \sqrt{d^2 + 4\delta^2}\big)$, which is positive and smooth everywhere and $h\approx d$ for $d\gg\delta$; it untangles and smooths at the same time (Escobar et al., 2003). The argument of $\sqrt{\cdot}$ is always positive, so you don't need double where; then decrease $\delta$ step by step.
  • The log barrier $-\log\det J$ grows slowly as $\det J\to0^+$ and is often used together with "step-size control (guaranteeing $\det J>0$ after each step)"; with a line search, returning inf for the function value in the infeasible region makes the zoom line search shorten the step automatically, but the gradient still needs double-where protection.
  • The scale of the barrier: $\delta$ should be chosen relative to a typical $\det J$. Here we use the $n$-normalized Jacobian ($\det J\approx$ domain area on a regular mesh), so $\delta=0.05$ is dimensionless.
  • Mesh optimization has $2(n-1)^2$ variables, just a medium-sized smooth optimization problem, which suits L-BFGS well; for larger meshes you can do Gauss–Seidel-style local optimization node by node, running independent node sets in parallel with vmap.

8 Parameter sweeps: solving a batch of PDEs at once with vmap

Write a solver as a pure function "single parameter → solution", and after vmap it handles a whole batch of parameters at once. Below we sweep the amplitude $a$ of the Gaussian bump in the variable-coefficient diffusion problem: $k = 1 + a\,g(x)$, with 32 values of $a$. The bump shape and the source term are passed in as arguments.

Note that jsl.cg uses while_loop internally, so after vmap the whole batch keeps iterating until the slowest one converges (see the 1.4 extra in Advanced 01). So when the parameters differ a lot, a batched solve doesn't necessarily save time over solving one by one.

N = 31; h = 1.0 / (N + 1)
xg = jnp.linspace(h, 1 - h, N)
XX, YY = jnp.meshgrid(xg, xg, indexing="ij")
bump = jnp.exp(-((XX - 0.5) ** 2 + (YY - 0.5) ** 2) / 0.02)
f_sw = jnp.ones((N, N))

def solve_amp(a, bump, f, h):
    k = 1 + a * bump
    return jsl.cg(lambda v: apply_A(k, v, h), f, tol=1e-12, maxiter=10 * f.size)[0]

a_batch = jnp.linspace(0.0, 3.0, 32)
solve_batch = jax.jit(jax.vmap(solve_amp, in_axes=(0, None, None, None)))
solve_one = jax.jit(solve_amp)

U_b = solve_batch(a_batch, bump, f_sw, h)
U_l = jnp.stack([solve_one(a, bump, f_sw, h) for a in a_batch])
print("Batched solution shape:", U_b.shape, "| max relative difference, vmap vs one by one:",
      f"{max(rel(U_b[i], U_l[i]) for i in range(len(a_batch))):.1e}")
assert all(rel(U_b[i], U_l[i]) < 1e-10 for i in range(len(a_batch)))
# Cross-check one of the parameters against SciPy
A_mid = np.asarray(jax.jacfwd(lambda u: apply_A(1 + a_batch[16] * bump, u.reshape(N, N), h).ravel())(jnp.zeros(N * N)))
assert rel(U_b[16].ravel(), np.linalg.solve(A_mid, np.ones(N * N))) < 1e-10

t_b = timeit(solve_batch, a_batch, bump, f_sw, h, n=5)
t_l = timeit(lambda a_s, b_, f_, h_: [solve_one(a, b_, f_, h_) for a in a_s], a_batch, bump, f_sw, h, n=5)
print(f"Timing (printed only): vmap batch {t_b:.1f} ms | Python loop one by one {t_l:.1f} ms")
Batched solution shape: (32, 31, 31) | max relative difference, vmap vs one by one: 6.8e-15
Timing (printed only): vmap batch 6.2 ms | Python loop one by one 7.9 ms
# Sensitivity sweep: vmap(grad) gives dQ/da for every parameter at once, with Q = mean temperature (implicit gradient, memory independent of the number of iterations)
Q_amp = lambda a, bump, f, h: jnp.mean(solve_amp(a, bump, f, h))
dQ_batch = jax.jit(jax.vmap(jax.grad(Q_amp), in_axes=(0, None, None, None)))(a_batch, bump, f_sw, h)
Q_j = jax.jit(Q_amp)
fd_batch = jnp.array([(Q_j(a + 1e-6, bump, f_sw, h) - Q_j(a - 1e-6, bump, f_sw, h)) / 2e-6 for a in a_batch[::8]])
print("dQ/da (every 8th):  AD", np.round(np.asarray(dQ_batch[::8]), 8), "\n                    FD", np.round(np.asarray(fd_batch), 8))
assert jnp.allclose(dQ_batch[::8], fd_batch, rtol=1e-6)
assert bool(jnp.all(dQ_batch < 0))                       # bigger coefficient, faster diffusion, lower mean temperature
dQ/da (every 8th):  AD [-0.00034518 -0.00025683 -0.0002101  -0.00018015] 
                    FD [-0.00034518 -0.00025683 -0.0002101  -0.00018015]

9 A checklist of practical rules

9.1 Measure first: does the closure-constant bug exist under float64?

⚪ Optional: if you want to know whether the closure-constant bug shows up under float64

For the symptoms, trigger conditions and workarounds of the closure-constant bug, see 00 Overview, "Known issues in this environment"; for the root-cause analysis, see Advanced 02 §2.3. The conclusions there were all obtained under float32. This section answers just one question: once x64 is on, do float64 arrays still trigger it?

Below, in the current kernel (x64 on), we probe with a float64 matrix and an explicitly float32 matrix, using NumPy float64 for the reference values.

def closure_probe(dtype, n):
    W = (jax.random.normal(jax.random.key(1), (n, n)) / jnp.sqrt(n)).astype(dtype)
    X = jax.random.normal(jax.random.key(2), (n, 50)).astype(dtype)
    Wn, Xn = np.asarray(W, np.float64), np.asarray(X, np.float64)
    ref_sum, ref_max = (Wn @ Xn).sum(0), (Wn @ Xn).max(0)
    out = {
        "vmap closure sum": jax.jit(jax.vmap(lambda x: jnp.sum(W @ x), in_axes=1))(X) - ref_sum,
        "mat closure sum": jax.jit(lambda X: jnp.sum(W @ X, axis=0))(X) - ref_sum,
        "mat closure max": jax.jit(lambda X: jnp.max(W @ X, axis=0))(X) - ref_max,
        "mat arg sum": jax.jit(lambda W, X: jnp.sum(W @ X, axis=0))(W, X) - ref_sum,
        "vec closure": jax.jit(lambda x: jnp.sum(W @ x))(X[:, 0]) - ref_sum[0],
    }
    return {k: float(np.abs(np.asarray(v, np.float64)).max()) for k, v in out.items()}

probe_res = {(dt.__name__, n): closure_probe(dt, n) for dt in (jnp.float32, jnp.float64) for n in (128, 256)}
cols = list(next(iter(probe_res.values())).keys())
print(f"{'dtype':<8}{'n':>5}" + "".join(f"{c:>14}" for c in cols))
for (dt, n), r in probe_res.items():
    print(f"{dt:<8}{n:>5}" + "".join(f"{r[c]:>14.2e}" for c in cols))

BUG_F32 = any(r["vmap closure sum"] > 1 for (dt, _), r in probe_res.items() if dt == "float32")
import platform, sys
if sys.platform == "darwin" and platform.machine() == "arm64":
    assert BUG_F32, "Not reproduced on macOS arm64: it may be fixed in a newer jaxlib, so update 00 Overview and this section"
if not BUG_F32:
    print("This machine doesn't trigger the bug even in float32 (it reproduces on macOS / Apple Silicon and did not show up in our Linux x86 test); the float64 conclusion below still holds.")
for (dt, n), r in probe_res.items():
    if dt == "float64":
        assert all(v < 1e-10 for v in r.values())          # float64: every variant is correct
    else:
        assert r["mat arg sum"] < 1e-3 and r["vec closure"] < 1e-3   # passed as an argument / single vector: correct
        if BUG_F32:
            # If these assertions fail after upgrading jaxlib, the bug has been fixed; update 00 Overview and this section's conclusion accordingly
            assert r["vmap closure sum"] > 1 and r["mat closure sum"] > 1 and r["mat closure max"] > 0.1
CLOSURE_BUG_F64 = any(v > 1e-10 for (dt, _), r in probe_res.items() if dt == "float64" for v in r.values())
print("\nDoes the closure-constant bug reproduce under float64:", CLOSURE_BUG_F64)

# The float64 version is also compiled into __ynn_fusion, it just gives correct results: the problem isn't "whether it's fused", but how float32 is handled inside the fusion
W64 = jax.random.normal(jax.random.key(1), (128, 128))
hlo64 = jax.jit(lambda X: jnp.sum(W64 @ X, axis=0)).lower(jnp.ones((128, 50))).compile().as_text()
print("HLO of the float64 closure version contains __ynn_fusion:", "__ynn_fusion" in hlo64)
assert W64.dtype == jnp.float64
if BUG_F32:
    assert "__ynn_fusion" in hlo64
dtype       nvmap closure summat closure summat closure max   mat arg sum   vec closure
float32   128      6.39e+01      6.39e+01      2.09e+00      8.40e-06      9.34e-07
float32   256      1.73e+02      1.73e+02      2.43e+00      1.17e-05      1.13e-06
float64   128      2.13e-14      2.13e-14      0.00e+00      2.13e-14      1.33e-15
float64   256      1.78e-14      1.78e-14      0.00e+00      1.78e-14      3.55e-15

Does the closure-constant bug reproduce under float64: False
HLO of the float64 closure version contains __ynn_fusion: True

Conclusion: on this machine's jaxlib 0.12.0.1, float64 matrices are correct in every variant; the bug does not reproduce under float64. The float64 version is also compiled into __ynn_fusion (we checked the HLO above), it just gives correct results. The problem lies in how float32 is handled inside the fusion; we haven't dug into the exact cause here. But turning on x64 does not remove the risk: as long as some array is explicitly float32 (for example lowered in precision to save memory), capturing it in a closure still gives wrong results, with errors up to around $10^2$. So the rule stands: matrices, coefficients and data are always passed in as arguments.

9.2 The checklist

🟢 Must-read

#RuleReason / where
1Turn on jax_enable_x64 on the first line of the first cell, and assert the default dtypeIf turned on late, old arrays stay float32 and spread through weakly typed operations (section 1.2)
2Use float64 for convergence studies, gradient checks and inverse problemsUnder float32, second-order convergence "disappears" on fine meshes, and finite differences give at best 4 to 5 significant digits (only 2 to 3 with the usual step size)
3Prefer matrix-free operators (pad + slicing) on structured gridsLittle memory, fusable, differentiable, easy to vmap; roll gives periodic boundaries by default (section 2.2)
4The info of jsl.cg is always None; check the residual yourselfIt doesn't raise an error even when it hasn't converged (section 2.3)
5Assemble with BCOO on unstructured meshes; leave direct solves on CPU to SciPyspsolve is a callback on CPU and doesn't support vmap (section 3)
6Prefer implicit differentiation: custom_linear_solve / custom_root, or just use jsl.cg$O(n)$ memory and exact gradients; unrolled differentiation uses $O(Kn)$ memory, and its gradient can be badly distorted around convergence (section 4.3)
7Don't write symmetric=True for nonsymmetric operators; provide transpose_solveOtherwise the reverse-mode gradient silently comes out wrong (section 4.4)
8In custom_root, wrap the iterative solver in tangent_solve with custom_linear_solveReverse mode transposes tangent_solve; using while_loop or jsl.cg directly both raise errors (section 4.6)
9Check the CFL / diffusion number first for explicit schemes; use implicit schemes like CN for stiff problemsInstability doesn't raise an error; you just get garbage around $10^{100}$ (section 6.1)
10Use checkpoint / $\sqrt T$ segmentation for long time integrationGradients are identical; per-step checkpointing cuts memory to about 1/4, $\sqrt T$ segmentation to about 1/50 (section 6.3)
11Always use double where for barriers, 1/x, log and sqrtA degenerate element with $\det J=0$ turns the gradient into NaN (section 7.4)
12Pass operator matrices, coefficient fields and observed data as function arguments; don't capture them in jit closuresThe closure-constant bug (reproduces under float32), constants embedded in the HLO slow down compilation, and you can't differentiate with respect to them (section 9.1, section 1.7 of Advanced 01)
13Cross-check every solver against NumPy/SciPy float64, and every gradient against finite differencesThat's how every conclusion in this chapter was verified

Exercises

The reference solution for each exercise is in the "Appendix: reference solutions" at the end of this chapter (the first line of the code cell is # Reference solution: Exercise N). Try writing your own first, then compare.

Exercise 1: the Poisson equation with a reaction term

⚪ Optional: practice with matrix-free operators and verifying the convergence order

Write a matrix-free operator apply_H(u, c, h) for $-\Delta_h u + c\,u$ ($c>0$ a constant, homogeneous Dirichlet boundary).

  1. Use jax.linear_transpose to verify that it's a symmetric operator;
  2. Using the manufactured solution $u^\ast$ from section 2.3 (the f returned by manufactured is $-\Delta u^\ast$), build this problem's source term $f + c\,u^\ast$, solve on two meshes $N=31$ and $N=63$ with jsl.cg, and assert that the error ratio is about 4;
  3. Cross-check on $N=31$ against SciPy spsolve (matrix $=$ scipy_neg_lap $+\,c I$).

Self-check interface: apply_H(u, c, h), where u is an N×N array; returns an array of the same shape.

# ✍️ Your answer: Exercise 1
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 1
try:
    apply_H
except NameError as e:
    print(f"⏳ Exercise 1: not answered yet: {e}")
else:
    _N, _c = 15, 3.7
    _h = 1.0 / (_N + 1)
    _u = np.random.default_rng(5).standard_normal((_N, _N))
    _A = scipy_neg_lap(_N, _h) + _c * sps.identity(_N * _N)     # reference matrix assembled with SciPy
    _out = apply_H(jnp.asarray(_u), _c, _h)
    assert _out.shape == (_N, _N), f"apply_H should return a {(_N, _N)} array, got {_out.shape}"
    assert rel(_out.ravel(), _A @ _u.ravel()) < 1e-12, "apply_H doesn't match the matrix product with -Δ_h + cI"
    assert rel(jax.jit(apply_H)(jnp.asarray(_u), _c, _h), _out) < 1e-14, "apply_H gives a different result under jit (with c and h as dynamic arguments)"
    print("✅ Exercise 1: self-check passed")
⏳ Exercise 1: not answered yet: name 'apply_H' is not defined

Exercise 2: the sensitivity to the source term is the adjoint solution

⚪ Optional: practice with the adjoint method: sensitivity to the source term

On the variable-coefficient problem from section 4 ($N=31$, coefficient k0), take $J(f) = h^2\sum_{ij} w_{ij}\,u_{ij}(f)$ with $w = x\,y$.

  1. Compute $\partial J/\partial f$ with u_implicit and jax.grad;
  2. By the adjoint method, $\partial J/\partial f = A^{-\top}(h^2 w) = A^{-1}(h^2 w)$. Get the explicit matrix with jax.jacfwd and cross-check with a NumPy dense solve;
  3. Run a finite-difference check along a random direction.

Self-check interface: J_src(f, k, w, h) returns the scalar $J$; f, k and w are all N×N arrays. The self-check uses a new f and w to check the function value and the gradient.

# ✍️ Your answer: Exercise 2
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 2
try:
    J_src
except NameError as e:
    print(f"⏳ Exercise 2: not answered yet: {e}")
else:
    _N = k0.shape[0]
    _h = 1.0 / (_N + 1)
    _rng = np.random.default_rng(8)
    _f, _w, _fb = (jnp.asarray(_rng.standard_normal((_N, _N))) for _ in range(3))
    _AT = np.asarray(jax.vmap(lambda e: apply_A(k0, e.reshape(_N, _N), _h).ravel())(jnp.eye(_N * _N)))
    _u = np.linalg.solve(_AT.T, np.asarray(_f).ravel())         # dense solve of A u = f
    _J_ref = _h**2 * float(np.sum(np.asarray(_w).ravel() * _u))
    _J = float(J_src(_f, k0, _w, _h))
    assert abs(_J - _J_ref) < 1e-9 * abs(_J_ref), f"Wrong value of J_src: {_J:.6e}, reference {_J_ref:.6e}"
    _g = jax.grad(J_src)(_f, k0, _w, _h)
    _Jb = float(J_src(_fb, k0, _w, _h))
    assert abs(float(jnp.vdot(_g, _fb)) - _Jb) < 1e-8 * abs(_Jb), "J is linear in f, so J(f_b) = <∂J/∂f, f_b> should hold; the gradient is wrong"
    print("✅ Exercise 2: self-check passed")
⏳ Exercise 2: not answered yet: name 'J_src' is not defined

Exercise 3: recovering the thermal diffusivity from the final state

⚪ Optional: practice with jacfwd and Gauss–Newton inversion

Use the Crank–Nicolson integrator from section 6 ($N_x=99$, $T=0.2$, 400 steps). Generate the final-state observation $u^{\text{obs}} = u(T;\alpha^\ast)$ with $\alpha^\ast=0.5$ (no noise), with initial value $u_0 = \sin(\pi x) + 0.5\sin(3\pi x)$. Starting from $\alpha=0.2$, recover $\alpha$ with the Gauss–Newton iteration

$$\alpha \leftarrow \alpha - \frac{J^\top r}{J^\top J},\qquad r(\alpha) = u(T;\alpha) - u^{\text{obs}},\quad J = \frac{\partial r}{\partial\alpha}$$

(compute $J$ with jax.jacfwd; forward mode is the cheapest for a scalar parameter), and assert that the result is within $10^{-10}$ of the true value.

Self-check interface: forward_T(alpha, u0) returns $u(T;\alpha)$; gn_step(alpha, u0, u_obs) does one Gauss–Newton step and returns (new alpha, residual norm); alpha_hat is the final recovered value. The self-check reruns the inversion with a different true value and initial guess.

# ✍️ Your answer: Exercise 3
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 3
try:
    forward_T, gn_step, alpha_hat
except NameError as e:
    print(f"⏳ Exercise 3: not answered yet: {e}")
else:
    assert abs(float(alpha_hat) - 0.5) < 1e-10, f"alpha_hat should equal 0.5 within 1e-10, got {float(alpha_hat):.14f}"
    # The CN amplification factor for the discrete eigenvector sin(kπx) has a closed form; use it to check forward_T independently
    _a, _dt = 0.37, T_end / 400
    _lam = -4.0 / hx**2 * np.sin(2 * np.pi * hx / 2) ** 2
    _g = (1 + 0.5 * _dt * _a * _lam) / (1 - 0.5 * _dt * _a * _lam)
    _u0 = jnp.sin(2 * jnp.pi * x1)
    assert rel(forward_T(_a, _u0), _g**400 * _u0) < 1e-10, "forward_T doesn't match the closed-form Crank–Nicolson result (400 steps, T = 0.2)"
    _u0b = jnp.sin(jnp.pi * x1) + 0.3 * jnp.sin(2 * jnp.pi * x1)
    _obs = forward_T(0.3, _u0b)
    _al = 0.15
    for _ in range(12):
        _al, _rn = gn_step(_al, _u0b, _obs)
    assert abs(float(_al) - 0.3) < 1e-9, f"Recovering the true value 0.3 starting from 0.15, after 12 steps got {float(_al):.12f}"
    assert float(gn_step(_al, _u0b, _obs)[1]) < 1e-10, "After convergence the residual norm should be close to 0"
    print("✅ Exercise 3: self-check passed")
⏳ Exercise 3: not answered yet: name 'forward_T' is not defined

Exercise 4: verifying the corner Jacobians with jacfwd

⚪ Optional: practice checking mesh Jacobians with jacfwd

The bilinear map of a quadrilateral element is

$$\mathbf x(\xi,\eta) = (1-\xi)(1-\eta)\mathbf p_{00} + \xi(1-\eta)\mathbf p_{10} + \xi\eta\,\mathbf p_{11} + (1-\xi)\eta\,\mathbf p_{01},\qquad (\xi,\eta)\in[0,1]^2 .$$

For the optimized mesh X_w from section 7.5, use jax.jacfwd to compute $\partial\mathbf x/\partial(\xi,\eta)$ for each element at the four corners $(0,0),(1,0),(1,1),(0,1)$ (loop over elements with vmap), and verify that its determinant times $n^2$ equals the $\det J$ given by corner_jacobians. Think about it: why do we compare determinants rather than the matrices themselves?

Self-check interface: bilinear(P, q), where P has shape (4, 2) holding $p_{00},p_{10},p_{11},p_{01}$ in order and q = (ξ, η); det_bil has shape (n_mesh**2, 4): elements in row-major (i, j) order (element (i, j) has $p_{00}$ = X_w[i, j] and $p_{10}$ = X_w[i+1, j]), with the four columns being corners $(0,0),(1,0),(1,1),(0,1)$ in order, already multiplied by $n^2$.

# ✍️ Your answer: Exercise 4
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 4
try:
    bilinear, det_bil
except NameError as e:
    print(f"⏳ Exercise 4: not answered yet: {e}")
else:
    _rng = np.random.default_rng(3)
    _P = _rng.standard_normal((4, 2))
    for _q, _pt in [((0.0, 0.0), _P[0]), ((1.0, 0.0), _P[1]), ((1.0, 1.0), _P[2]), ((0.0, 1.0), _P[3])]:
        assert np.allclose(bilinear(jnp.asarray(_P), jnp.array(_q)), _pt, atol=1e-12), f"bilinear at corner {_q} should equal the corresponding vertex"
    _xi, _eta = 0.3, 0.8
    _ref = ((1 - _xi) * (1 - _eta) * _P[0] + _xi * (1 - _eta) * _P[1] + _xi * _eta * _P[2] + (1 - _xi) * _eta * _P[3])
    assert np.allclose(bilinear(jnp.asarray(_P), jnp.array([_xi, _eta])), _ref, atol=1e-12), "bilinear gives the wrong value inside the element"
    # Compute the determinants independently from the two edges at each corner (NumPy)
    _X = np.asarray(X_w)
    _p00, _p10, _p11, _p01 = _X[:-1, :-1], _X[1:, :-1], _X[1:, 1:], _X[:-1, 1:]
    _cr = lambda a, b: a[..., 0] * b[..., 1] - a[..., 1] * b[..., 0]
    _dets = np.stack([_cr(_p10 - _p00, _p01 - _p00), _cr(_p10 - _p00, _p11 - _p10),
                      _cr(_p11 - _p01, _p11 - _p10), _cr(_p11 - _p01, _p01 - _p00)], -1).reshape(-1, 4) * n_mesh**2
    assert np.shape(det_bil) == _dets.shape, f"det_bil should have shape {_dets.shape}, got {np.shape(det_bil)}"
    assert np.allclose(det_bil, _dets, rtol=1e-12, atol=1e-12), "det_bil doesn't match the determinants computed from the corner edge vectors"
    print("✅ Exercise 4: self-check passed")
⏳ Exercise 4: not answered yet: name 'bilinear' is not defined

Quiz

1. Why must jax.config.update("jax_enable_x64", True) go at the very top of the first code cell? What happens if you turn it on later?

Answer

The switch only affects arrays created and traces started afterwards. Arrays created before it's turned on are still float32, and a float32 array combined with a Python float (weakly typed) is still float32 (verified in section 1.2: a * 1.0 is still float32), so single precision quietly sneaks into the computation: convergence orders can't be measured reliably and gradient checks fail, with no error raised.

2. What goes wrong if you write the five-point operator with jnp.roll for a homogeneous Dirichlet problem? How do you fix it?

Answer

roll wraps values from the opposite edge around, which amounts to periodic boundary conditions, so the results at nodes next to the boundary are wrong (result from section 2.2: interior nodes match exactly, the first row has a relative error of about 0.35), and no error is raised. The fix: pad a ring of zeros with jnp.pad(u, 1) first, then take neighbors (slicing or roll both work), and finally crop the outer ring.

3. When taking the gradient of a linear solver, what are the memory and accuracy characteristics of "differentiating through the unrolled CG iterations" versus "custom_linear_solve implicit differentiation"?

Answer

Unrolled differentiation has to save the intermediates of every step, so memory is $O(K n)$ and grows linearly with the number of iterations; it computes the derivative of the floating-point iteration, which can be badly distorted around convergence (in section 4.3 the relative error near $K=80$ reaches around $10^5$), and only comes back down after many more iterations. Implicit differentiation stores only the solution, with $O(n)$ memory; the gradient = one adjoint solve, its accuracy depends only on the forward and adjoint tolerances, and it agrees with finite differences to around $10^{-10}$.

4. What happens if you call lax.custom_linear_solve(..., symmetric=True) on a nonsymmetric operator? Are forward mode and reverse mode affected?

Answer

symmetric=True is an unchecked promise. Reverse mode uses $A$ instead of $A^\top$ to solve the adjoint system, so the gradient silently comes out wrong (in section 4.4 the relative error is about 0.84); forward mode (jvp) only uses solve, so its result is correct. The right approach is symmetric=False plus a transpose_solve.

5. What does lax.custom_root require of tangent_solve? Why does calling a CG written with while_loop directly inside it raise an error under grad?

Answer

tangent_solve(g, y) must return the solution of $g(x)=y$. In reverse mode JAX has to transpose tangent_solve with respect to y, so it must be a transposable linear function. while_loop can't be transposed (it raises ValueError); on this machine's 0.12.0.1, using jsl.cg directly also raises NotImplementedError. Wrapping it with lax.custom_linear_solve(g, y, cg_while, symmetric=True) fixes it (when the linearized operator is symmetric).

6. On the L-shaped domain, why does Laplace smoothing produce inverted elements while the Winslow energy doesn't? Why do you need double where when writing the Winslow barrier?

Answer

The minimizer of the Laplace energy is a discrete harmonic map, which is only guaranteed to be injective when the target domain is convex (Radó–Kneser–Choquet); the L-shaped domain has a reentrant corner, nodes get averaged outside the domain, and elements invert. The Winslow energy $\lVert J\rVert_F^2/(2\det J)$ goes to infinity as $\det J\to0^+$, so it has a built-in barrier. To handle the initially inverted elements, it switches to a polynomial extension for $\det J\le\delta$; with only one where, on a degenerate element ($\det J=0$) the derivative of the unselected branch $1/d$ is infinite, and $0\times\infty$ gives a NaN gradient. Using a first where to replace $d$ with a safe value avoids this.

Chapter summary

  • float64 is the default for scientific computing: turn it on in the first line, then confirm with an assert. float32 makes second-order convergence "disappear" on fine meshes and breaks finite-difference checks.
  • A matrix-free operator plus jsl.cg (with the operator passed as a function) is the simplest and fastest combination on structured grids. The five-point scheme gives convergence orders of 1.99 to 2.00 (error ratios 3.97 to 4.00).
  • BCOO can assemble, do matvecs, sparsify, differentiate with respect to data, and agrees with SciPy CSR to rounding error. But JAX has no native sparse direct solver on CPU: spsolve calls back into SciPy on CPU and doesn't support vmap.
  • The math behind implicit differentiation is the adjoint method: one adjoint solve gives the full gradient. lax.custom_linear_solve and lax.custom_root turn it into one line of code (jsl.cg already does this internally). Compared with unrolled differentiation, temporary memory is about two orders of magnitude smaller (in section 4.3, unrolling 100 / 300 steps uses about 100 / 290 times the memory of the implicit version), and the gradient is exact up to the solver tolerance; the unrolled gradient can be badly distorted around convergence.
  • Inverse problems = implicit differentiation + vmap over load cases + regularization + L-BFGS (optax.lbfgs comes with a zoom line search).
  • Time stepping uses scan: explicit schemes must satisfy $r\le\tfrac12$, while CN is unconditionally stable and second order in time. Differentiating scan gives the discrete adjoint; for long time integration, $\sqrt T$ segmented checkpointing cuts memory to about 1/50 with the same gradient.
  • Mesh optimization: Laplace smoothing folds on non-convex domains, while the Winslow energy + an extended barrier can untangle and smooth starting from an inverted initial mesh; the barrier must be written with double where.
  • Engineering rules: pass data and operators in as arguments (the closure-constant bug doesn't reproduce under float64, but still exists for float32), and cross-check every conclusion against SciPy / finite differences.

Cheat sheet

APIWhat it does
jax.config.update("jax_enable_x64", True)Turn on float64 / int64 (must run first)
jnp.pad(u, 1) + slicingDirichlet boundary for a matrix-free stencil
jax.scipy.sparse.linalg.cg(A, b, tol=, atol=, maxiter=, M=)CG; A and M can be functions; info is always None; already implicit differentiation internally
jax.scipy.sparse.linalg.gmres(..., restart=, solve_method=)GMRES for nonsymmetric systems
sparse.BCOO((data, indices), shape=)Assemble a sparse matrix from COO triplets (duplicate indices allowed)
sparse.BCOO.from_scipy_sparse(A) / .todense() / .sum_duplicates()Convert to/from SciPy / dense, merge duplicates
sparse.BCSR.from_bcoo(A)Convert to CSR (data, indices, indptr)
sparse.sparsify(f)Let a function written for dense matrices accept sparse ones
sparse.grad(f)Gradient with respect to a BCOO argument, keeping the sparsity structure
sparse.linalg.spsolve(data, indices, indptr, b)Sparse direct solve (GPU: cuSOLVER; CPU: calls back into SciPy; no vmap)
lax.custom_linear_solve(matvec, b, solve, transpose_solve=, symmetric=)Declare a linear solve; differentiated with the adjoint method, never going through solve
lax.custom_root(f, x0, solve, tangent_solve)Declare a nonlinear root find, differentiated with the implicit function theorem; tangent_solve must be transposable
jax.linear_transpose(f, x)Get the transpose of a linear operator (check symmetry, build adjoints)
lax.linalg.tridiagonal_solve(dl, d, du, b)Differentiable tridiagonal solve (CN, 1D implicit schemes)
lax.scan(step, u0, None, length=T)Time stepping
jax.checkpoint(step) / nested scanPer-step checkpointing / $\sqrt T$ segmented checkpointing
jit(grad(f)).lower(...).compile().memory_analysis()Read the temporary memory of backprop
jax.ad_checkpoint.print_saved_residuals(f, *args)See what backprop saves
optax.lbfgs() + optax.value_and_grad_from_stateL-BFGS (zoom line search); update needs value=, grad=, value_fn=
jax.vmap(solve, in_axes=(0, None, ...))Parameter sweeps / batched solves over load cases
jnp.where(ok, x, safe) → jnp.where(ok, f(x_safe), g(x))Double where: the safe way to write barriers, 1/x, log, sqrt

Appendix: reference solutions

Write your own answer in the "✍️ Your answer" cell in the main text first and check it with the "🧪 Self-check". Come here to compare once the self-check passes, or if you're really stuck. Running the solutions below in order overwrites any functions with the same names that you defined in the answer cells.

Exercise 1

See "Exercise 1: the Poisson equation with a reaction term" in the main text for the task.

# Reference solution: Exercise 1
def apply_H(u, c, h):
    return neg_lap(u, h) + c * u

c_r = 10.0
uu1 = jax.random.normal(jax.random.key(31), (31, 31))
vv1 = jax.random.normal(jax.random.key(32), (31, 31))
HT = jax.linear_transpose(lambda v: apply_H(v, c_r, 1 / 32), uu1)
assert jnp.allclose(HT(vv1)[0], apply_H(vv1, c_r, 1 / 32), rtol=1e-12, atol=1e-8)

@jax.jit
def solve_H(f, c, h):
    return jsl.cg(lambda v: apply_H(v, c, h), f, tol=1e-12, maxiter=10 * f.size)[0]

errs_h = []
for Nh in [31, 63]:
    hh, Xh, Yh, ueh, f_lap = manufactured(Nh)
    fh = f_lap + c_r * ueh                                   # -Δu* + c u*
    uh = solve_H(fh, c_r, hh)
    errs_h.append(float(jnp.max(jnp.abs(uh - ueh))))
    if Nh == 31:
        Asp = scipy_neg_lap(Nh, hh) + c_r * sps.identity(Nh * Nh)
        assert rel(uh.ravel(), spsl.spsolve(Asp.tocsc(), np.asarray(fh).ravel())) < 1e-10
print("Errors:", errs_h, "error ratio:", errs_h[0] / errs_h[1])
assert abs(errs_h[0] / errs_h[1] - 4) < 0.1
Errors: [0.0010492670098729562, 0.00026240931280829827] error ratio: 3.9985890692815946

Exercise 2

See "Exercise 2: the sensitivity to the source term is the adjoint solution" in the main text for the task.

# Reference solution: Exercise 2
N = 31; h = 1.0 / (N + 1)
xg = jnp.linspace(h, 1 - h, N)
XX, YY = jnp.meshgrid(xg, xg, indexing="ij")
w2 = XX * YY

def J_src(f, k, w, h):
    return h**2 * jnp.sum(w * u_implicit(k, f, h))

f2 = jnp.ones((N, N))
g_src = jax.jit(jax.grad(J_src))(f2, k0, w2, h)
A_k0 = np.asarray(jax.jacfwd(lambda u: apply_A(k0, u.reshape(N, N), h).ravel())(jnp.zeros(N * N)))
lam2 = np.linalg.solve(A_k0.T, h**2 * np.asarray(w2).ravel()).reshape(N, N)
print(f"dJ/df vs the adjoint solution: relative error {rel(g_src, lam2):.1e}")
assert rel(g_src, lam2) < 1e-10
d2 = jax.random.normal(jax.random.key(41), (N, N))
fd2 = (J_src(f2 + 1e-4 * d2, k0, w2, h) - J_src(f2 - 1e-4 * d2, k0, w2, h)) / 2e-4
assert abs(jnp.vdot(g_src, d2) - fd2) < 1e-8 * abs(fd2)
dJ/df vs the adjoint solution: relative error 3.4e-14

Exercise 3

See "Exercise 3: recovering the thermal diffusivity from the final state" in the main text for the task.

# Reference solution: Exercise 3
u0_3 = jnp.sin(jnp.pi * x1) + 0.5 * jnp.sin(3 * jnp.pi * x1)

def forward_T(alpha, u0):
    return integrate(u0, alpha, T_end / 400, hx, cn_step, 400)

u_obs3 = forward_T(0.5, u0_3)

@jax.jit
def gn_step(alpha, u0, u_obs):
    r = forward_T(alpha, u0) - u_obs
    Jr = jax.jacfwd(forward_T)(alpha, u0)
    return alpha - jnp.vdot(Jr, r) / jnp.vdot(Jr, Jr), jnp.linalg.norm(r)

alpha_hat = 0.2
for it in range(8):
    alpha_hat, rn = gn_step(alpha_hat, u0_3, u_obs3)
    print(f"iter {it}: α = {float(alpha_hat):.14f}, residual norm {float(rn):.2e}")
assert abs(alpha_hat - 0.5) < 1e-10
iter 0: α = 0.42036314994158, residual norm 2.13e+00
iter 1: α = 0.49405554085177, residual norm 4.49e-01
iter 2: α = 0.49996526217103, residual norm 3.11e-02
iter 3: α = 0.49999999880912, residual norm 1.81e-04
iter 4: α = 0.49999999999997, residual norm 6.19e-09
iter 5: α = 0.49999999999999, residual norm 1.10e-13
iter 6: α = 0.49999999999996, residual norm 1.70e-13
iter 7: α = 0.49999999999995, residual norm 2.93e-14

Exercise 4

See "Exercise 4: verifying the corner Jacobians with jacfwd" in the main text for the task.

# Reference solution: Exercise 4
def bilinear(P, q):
    """P: (4, 2) holding p00, p10, p11, p01 in order; q = (ξ, η)"""
    xi, eta = q
    Nshape = jnp.array([(1 - xi) * (1 - eta), xi * (1 - eta), xi * eta, (1 - xi) * eta])
    return Nshape @ P

Xw = jnp.asarray(X_w)
P_cells = jnp.stack([Xw[:-1, :-1], Xw[1:, :-1], Xw[1:, 1:], Xw[:-1, 1:]], axis=2).reshape(-1, 4, 2)
corners = jnp.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
jac = jax.vmap(lambda P: jax.vmap(lambda q: jax.jacfwd(bilinear, argnums=1)(P, q))(corners))(P_cells)  # (cells, 4, 2, 2)
det_bil = det2(jac) * n_mesh**2                                         # (cells, 4)
det_ref = jnp.moveaxis(det2(corner_jacobians(Xw)), 0, -1).reshape(-1, 4)
print(f"Max difference {float(jnp.abs(det_bil - det_ref).max()):.1e}")
assert jnp.allclose(det_bil, det_ref, rtol=1e-12, atol=1e-12)
# The matrices themselves differ only by a column permutation / sign flip (for example at corner p10 it's [∂η, -∂ξ]), which leaves the determinant unchanged
Max difference 8.9e-16