the jax path · 0/12
start the path

the jax path · Autodiff · lesson 02 of 4

Which direction costs what

Forward and reverse compute the same derivative and send you different bills. Both bills are readable before you run a training step: one as a flop count off the compiled program, one as a list of arrays.

the goal Predict from a function’s input and output widths which of jacfwd and jacrev costs less, say what reverse mode holds in memory and how that grows with depth, and measure both for a function of your own.

mastery work · this chapter0/3
  1. go →
  2. go →
manual items are your word; auto items complete from your streaks, labs, and can-you ticks · stored in your browser only
§ 01

The gradient stays at twice the forward pass

A compiled JAX program will tell you what it thinks it costs. jax.jit(fn).lower(x).compile().cost_analysis() hands back a list with one dictionary in it, and flops is one of its keys. It is XLAThe compiler: brilliant at fusing along dataflow edges, structurally unable to change your algorithm. That gap is why kernels exist.taught in /l/xla →'s own cost model rather than a hardware counter, so treat it as the compiler's arithmetic and not as a measurement of your machine. It repeats exactly across runs, which makes it a good instrument for comparing two programs.

Run it on the same function at three input widths and the shape of the cost model appears. The gradient sits at almost exactly twice the forward pass every time: 2.03, then 2.01, then 2.00. Widen the input by four and the ratio does not move, because reverse mode makes one backward sweep no matter how many inputs feed it.

jacfwd on the same function tells the opposite story. It costs 19 forward passes at 16 inputs, 68 at 64, and 260 at 256. That is the same tangent program run once per input direction, and the table below is what the chapter's cost argument looks like when you make the compiler count it.

run it (verified, jax 0.4.38 CPU): flop counts off the compiled program, at one width
import jax
import jax.numpy as jnp

def flops(fn, *args):
    return jax.jit(fn).lower(*args).compile().cost_analysis()[0]['flops']

n = 64
W = jnp.ones((n, n)) * 0.01

def f(x):
    return jnp.sum(jnp.tanh(x @ W))

x = jnp.ones(n)
print(flops(f, x), flops(jax.grad(f), x))
print(flops(jax.jacfwd(f), x), flops(jax.jacrev(f), x))

# 8255.0 16576.0
# 557056.0 16576.0
inputsforwardgradgrad / forwardjacfwdjacfwd / forward
1652710722.031024019.4
648255165762.0155705667.5
2561313272629122.0034078720259.5
f(x) = sum(tanh(x @ W)) with W square at each width; flops as XLA’s cost model reports them (jax 0.4.38 CPU), ratios computed from those numbers
§ 02

The aspect ratio picks the direction

Turn the function around and the winner turns around with it. A map from 4 inputs to 256 outputs costs 13,600 flops through jacfwd and 788,736 through jacrev, a factor of 58 in favour of the direction that was hopeless a moment ago.

The rule behind both tables is one sentence about basis vectors. Forward mode runs once per input direction, reverse mode runs once per output direction, and the Jacobian needs every direction on one side or the other. Count inputs, count outputs, pick the smaller one.

Deep learning sits at one extreme of that rule and never moves: a billion parameters in, one loss out. The interesting cases are the ones that are not training steps, where a small parameter vector drives a large simulated trajectory and forward mode is the cheap direction by two orders of magnitude.

run it (verified, jax 0.4.38 CPU): 4 inputs, 256 outputs, and the two Jacobian builders
import jax
import jax.numpy as jnp

def flops(fn, *args):
    return jax.jit(fn).lower(*args).compile().cost_analysis()[0]['flops']

W = jnp.ones((4, 256)) * 0.01

def g(x):
    return jnp.tanh(x @ W)

x = jnp.ones(4)
print(flops(g, x))
print(flops(jax.jacfwd(g), x), flops(jax.jacrev(g), x))
print(jax.jacfwd(g)(x).shape)

# 2048.0
# 13600.0 788736.0
# (256, 4)
§ 03

Both Jacobians are one direction, batched

Open the wheel and the two builders are four lines each. jacfwd partially applies jvp at the point you passed, then vmaps it over _std_basis(dyn_args). jacrev calls _vjp, keeps the pullback, and vmaps that over _std_basis(y). Same shape of code, one built from the forward primitive and one from the reverse one.

_std_basis is where the flop ratios come from. It flattens the tree, counts the elements, and builds jnp.eye(ndim): an identity basis with one row per element of the thing it was handed. jacfwd gets the basis of the inputs, so it runs n times. jacrev gets the basis of the outputs, so it runs m times. The docs put the same fact in four words each, calling jacfwd column-by-column and jacrev row-by-row.

The out_axes=(None, -1) on the forward one is worth a second look. The primal output is shared across the batch rather than stacked, and the tangents stack on the last axis, which is what puts a Jacobian's columns where columns belong.

verbatim, jax/_src/api.py in the jax 0.4.38 wheel: the decisive line of jacfwd at 581-582, of jacrev at 669 and 673, and _std_basis at 763-769, joined here under added headings
# jax/_src/api.py:581-582, inside jacfwd
      pushfwd: Callable = partial(_jvp, f_partial, dyn_args)
      y, jac = vmap(pushfwd, out_axes=(None, -1))(_std_basis(dyn_args))

# jax/_src/api.py:669 and 673, inside jacrev
      y, pullback = _vjp(f_partial, *dyn_args)
    jac = vmap(pullback)(_std_basis(y))

# jax/_src/api.py:763-769, the basis both of them batch over
def _std_basis(pytree):
  import jax.numpy as jnp
  leaves, _ = tree_flatten(pytree)
  ndim = sum(map(np.size, leaves))
  dtype = dtypes.result_type(*leaves)
  flat_basis = jnp.eye(ndim, dtype=dtype)
  return _unravel_array_into_pytree(pytree, 1, None, flat_basis)
§ 04

What reverse mode holds while it waits

The flop side of the bill favours reverse mode. The memory side is where it pays, and lesson one already showed where to look: the constvars of the linearized program. Count them for a block of tanh layers and the growth is exactly linear, 3 arrays at one layer and 17 at eight.

Print the shapes rather than the count and the arithmetic stops being abstract. One 128 by 128 weight matrix at 64 KiB, held once, plus two 32 by 128 activations per layer at 16 KiB each. Every layer you add costs 32 KiB at this batch size and nothing else, so a batch of 512 instead of 32 would cost 512 KiB per layer by the same rule.

Forward mode holds none of this, which is the other half of the trade the previous sections measured in flops. A tangent is consumed by the next equation; a residual has to survive until the backward sweep reaches it. LAB·J3 takes the next step from here, trading those bytes back for recomputation.

run it (verified, jax 0.4.38 CPU): the residual arrays of a tanh block, counted and sized
import jax
import jax.numpy as jnp

W = jnp.ones((128, 128)) * 0.01
x = jnp.ones((32, 128))

def residuals(depth):
    def block(x):
        for _ in range(depth):
            x = jnp.tanh(x @ W)
        return jnp.sum(x)
    _, lin = jax.linearize(block, x)
    avals = [v.aval for v in jax.make_jaxpr(lin)(x).jaxpr.constvars]
    return len(avals), sum(a.size * a.dtype.itemsize for a in avals)

for depth in (1, 2, 4, 8):
    print(depth, residuals(depth))

# 1 (3, 98304)
# 2 (5, 131072)
# 4 (9, 196608)
# 8 (17, 327680)
depthresidual arraysbyteswhat they are
1398304W, plus one layer’s input and its tanh output
25131072W once, two arrays per layer
49196608same rule: 2 * depth + 1
81732768065536 for W, 32768 per layer
residuals of depth layers of tanh(x @ W), W float32[128,128], x float32[32,128] (jax 0.4.38 CPU); the shapes column is the aval list printed for depth 2
before you move on

Check yourself

01 Your function maps 8 inputs to 4096 outputs and you need the whole Jacobian. Which builder, and what are you avoiding?

jacfwd, because forward mode runs once per input direction and reverse mode once per output direction. On a 4-input, 256-output function the measured gap was 13,600 flops against 788,736, a factor of 58, and it widens with the output width.

02 Why does the gradient of a scalar loss cost about two forward passes whatever the input width?

Because one cotangent sweeps backward once, regardless of how many inputs feed it. Measured at 16, 64 and 256 inputs the grad-to-forward flop ratio was 2.03, 2.01 and 2.00, while jacfwd went from 19 forward passes to 260.

03 You linearize a 12-layer block of the shape measured here. How many residual arrays, and what sets the bytes?

25, from the rule of two per layer plus the shared weight matrix. The bytes are one 64 KiB weight matrix plus 32 KiB per layer at batch 32, since each layer keeps its input and its tanh output at 16 KiB each.

assigned

Readings