Reversible Backprop as a custom_vjp in JAX

· 7 min read

#integrators#reversibility#memory#jax#flax#nnx#implementation

Part 4 of 5Networks as Integrators
  1. 1Your Skip Connection Is Half of Newton
  2. 2Transformers With a Velocity Ledger
  3. 3A Network That Conserves Energy
  4. 4Backprop Without the Memorythis post's explainer
  5. 5Depth on Demand
Explainer companionBackprop Without the MemoryWant the full intuition first? This is the runnable companion to the explainer.Read the explainer

The explainer argues that a momentum residual network can be trained without storing its forward pass, because the block inverts. This is the implementation: the exact inverse, the jax.custom_vjp whose backward pass walks the trajectory backward, the XLA instrument that measured 674 MB against a flat 3.2, and the float32 cliff reproduced in twenty lines of numpy. Everything below is scripts/reversible_memory.py, the script the Kaggle run executed (bundle kgl_blog-revmem-v1); code blocks are quoted from it, not paraphrased.

The block and its undo

One momentum layer, and the two lines that reverse it:

def block(p, x):
    return jnp.tanh(x @ p["w1"] + p["b1"]) @ p["w2"] + p["b2"]

# forward:  v' = MU * v + block(p, x);  x' = x + H_STEP * v'
# inverse:  x  = x' - H_STEP * v';      v = (v' - block(p, x)) / MU

The inverse needs nothing but the endpoint and the same block. That is the entire storage story: if the backward pass can call these two lines, it does not need the forward pass to have banked anything.

The custom_vjp that recomputes instead of remembering

JAX lets a function declare its own backward rule. The forward rule saves only the output state as residuals, a constant amount of memory at any depth. The backward rule then reconstructs each layer’s input by inversion, and having just recovered it, backprops through that one layer with an ordinary jax.vjp, accumulating parameter gradients as it walks:

@jax.custom_vjp
def rev_forward(p, x0, v0, L_arr):
    L = L_arr.shape[0]
    return fwd_scan(p, x0, v0, L)

def rev_fwd(p, x0, v0, L_arr):
    out = rev_forward(p, x0, v0, L_arr)
    # residuals: ONLY the output state (constant in depth), never the trajectory
    return out, (p, out[0], out[1], L_arr)

def rev_bwd(res, g):
    p, xL, vL, L_arr = res
    gx, gv = g

    def back(carry, _):
        x_next, v_next, gx_n, gv_n, gp = carry
        # invert one layer to recover its input
        x_prev = x_next - H_STEP * v_next
        f_val, f_vjp = jax.vjp(lambda pp, xx: block(pp, xx), p, x_prev)
        v_prev = (v_next - f_val) / MU
        # backprop the same layer given recovered inputs
        gv_local = gv_n + H_STEP * gx_n
        gp_f, gx_f = f_vjp(gv_local)
        gx_p = gx_n + gx_f
        gv_p = MU * gv_local
        gp = jax.tree.map(jnp.add, gp, gp_f)
        return (x_prev, v_prev, gx_p, gv_p, gp), None

    gp0 = jax.tree.map(jnp.zeros_like, p)
    (x0r, v0r, gx0, gv0, gp), _ = jax.lax.scan(
        back, (xL, vL, gx, gv, gp0), None, length=L_arr.shape[0])
    return gp, gx0, gv0, None

rev_forward.defvjp(rev_fwd, rev_bwd)

Two details carry the memory claim. The residual tuple in rev_fwd holds the endpoint and the parameters, nothing indexed by depth. And the backward lax.scan re-derives x_prev before calling f_vjp, so the local VJP always has the input it needs without anyone having stored it. The bookkeeping through the ledger, gv_local = gv_n + H_STEP * gx_n and gv_p = MU * gv_local, is just the chain rule of the two forward lines, written by hand once.

Two lanes of layer cells animated through one training step: the standard lane banks an activation per layer and its memory meter climbs; the reversible lane carries a single moving state and its meter stays flat; meters labeled with the measured 674.0 and 3.2 MB at depth 512
The two disciplines, one training step. The standard lane’s bank fills during the forward sweep and drains during the backward; the reversible lane never holds more than one state. Meters carry the run’s measured activation memory at depth 512.

Measuring memory honestly

Peak memory is easy to measure wrong. Two disciplines from the run. First, each (depth, mode) point runs in a fresh process, because the allocator’s peak_bytes_in_use is a process-lifetime high-water mark and later measurements inherit earlier peaks. Second, alongside the runtime allocator, the run reads XLA’s static analysis of the compiled step, which reports exactly how much scratch the executable reserves:

compiled = g.lower(p).compile()
temp_mb = compiled.memory_analysis().temp_size_in_bytes / 1e6

The two instruments agree on the story:

depthstandard tempreversible tempstandard stepreversible step
813.4 MB3.2 MB1.9 ms2.3 ms
3244.8 MB3.2 MB6.1 ms7.4 ms
128170.7 MB3.2 MB22.7 ms27.2 ms
512674.0 MB3.2 MB83.0 ms103.1 ms
Log-log plot of activation memory against depth: the standard line climbs linearly from 13.4 to 674 MB while the reversible line is flat at 3.2 MB
The wall, measured by XLA’s own analysis of each compiled step. Standard backprop’s scratch grows linearly in depth; the reversible pass does not know how deep the network is.

The time column is the price: the backward pass runs block once more per layer, and at depth 512 that costs 24%. Recompute for memory, the same trade RevNets built into their architecture (Gomez et al., 2017), paid here by the integrator’s own inverse (Sander et al., 2021).

The cliff, in numpy float32

The explainer’s noise budget says the rewind amplifies float error by (1/μ)(1/\mu) per layer. You do not need a GPU to watch that happen; you need np.float32 and the same twenty lines:

def rewind(F, xL, vL, mu, L):
    mu = np.float32(mu)
    x, v = xL.astype(np.float32), vL.astype(np.float32)
    for _ in range(L):
        x = (x - v).astype(np.float32)
        v = ((v - field(F, x)) / mu).astype(np.float32)
Three panels, mu 0.9, 0.6, 0.3: blue forward trajectories through 64 float32 momentum layers, then orange reconstructed states rewound from the endpoint; at 0.9 the dots land on the trails, at 0.6 they deviate visibly, at 0.3 they scatter with error 1e10
The same rewind at three frictions, real float32 arithmetic. At mu = 0.9 the reconstruction lands on every footprint (error 4e-05 over 64 layers). At 0.6 it visibly shears. At 0.3 the error reaches ten billion: the algebra is exact and the arithmetic is not.

And the growth is not vaguely exponential, it is the predicted exponential. Plotting the reconstruction error as the rewind proceeds, against the budget ε32(1/μ)\varepsilon_{32} (1/\mu)^{\ell} drawn dashed:

Log-scale reconstruction error growing with each backward step for mu 0.9, 0.6, 0.3, each measured curve running parallel to its dashed predicted budget line
Reconstruction error, measured at every backward step (solid) against the noise budget (dashed), one color per friction. Each curve rides its prediction’s slope. The budget is not a bound, it is the mechanism.

On the full network the run measures gradient agreement (cosine between the rewind gradient and the stored-activation gradient on identical weights and batch): 1.000000 everywhere the budget is small, 0.30 at μ=0.6\mu = 0.6, depth 32, where the budget crosses one, and nan at μ=0.3\mu = 0.3, depth 128, where the budget is 106610^{66}.

Gradient cosine against depth for three mus with dashed verticals at each mu's predicted death depth; measured cliffs align with predictions
Where the gradient dies, measured against the napkin prediction L* = ln(1/eps)/ln(1/mu) drawn before looking. Inside the budget the rewind gradient is exact to float precision; outside it there is nothing to rescue.

Training on the rewind

Inside the budget the two gradients are the same numbers, so training should be indistinguishable up to noise, and the run checks at depth 64, μ=0.9\mu = 0.9: standard 81.6% test accuracy, reversible 79.7%, one seed each, reversible 17% slower on the wall. The explainer closes the loop with the architectural moral: the amplification exists because μ<1\mu < 1, and the frictionless leapfrog block rewinds by subtraction alone, no division, no budget, no cliff.

Every number above is from scripts/reversible_memory.py run on Kaggle (bundle kgl_blog-revmem-v1); the float32 demonstrations are rendered by scripts/render_revmem_gifs.py from the same arithmetic the panels run live.

Cite as

Bouhsine, T. (). Reversible Backprop as a custom_vjp in JAX. Records of the !mmortal Data Scientist. https://tahabouhsine.com/blog/backprop-without-the-memory-jax-flax-nnx/

BibTeX
@misc{bouhsine2026backpropwithoutthememoryjaxflaxnnx,
  author       = {Bouhsine, Taha},
  title        = {Reversible Backprop as a custom_vjp in JAX},
  year         = {2026},
  month        = {jul},
  howpublished = {\url{https://tahabouhsine.com/blog/backprop-without-the-memory-jax-flax-nnx/}},
  note         = {Blog post, Records of the !mmortal Data Scientist}
}

References

  1. Gomez, A. N., Ren, M., Urtasun, R., Grosse, R. B. (2017). The Reversible Residual Network: Backpropagation Without Storing Activations. arXiv:1707.04585
  2. Sander, M. E., Ablin, P., Blondel, M., Peyré, G. (2021). Momentum Residual Neural Networks. arXiv:2102.07870