the jax path · 0/12
start the path

the jax path · Vmap · lesson 03 of 3

Composed with grad and jit

Every ordering of vmap, grad and jit compiles. They do not all compute the same thing, and the jaxprThe traced program: one equation per primitive in single-assignment form, every shape and dtype stated.taught in /l/jaxpr → says which one you asked for before you have to reason about it.

the goal Read an ordering of vmap, grad and jit off a jaxprThe traced program: one equation per primitive in single-assignment form, every shape and dtype stated.taught in /l/jaxpr →, say whether it yields per-example gradients or a summed one, explain what vmap does when it meets a pjit equation, and name the refusals that belong to vmap alone.

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 ordering decides what you get

Put vmap outside grad and the whole program grows a batch axis, gradient equations included, and what comes back is a (4, 3) with one row per example. Put grad outside vmap and the batch axis is still there through the middle of the program, but the result is a (3,), one gradient for the batch.

The difference lands in three equations at the end of the second jaxprThe traced program: one equation per primitive in single-assignment form, every shape and dtype stated.taught in /l/jaxpr →, and they are worth finding by eye: reduce_sum, then reshape, then reduce_sum again. That tail is the transpose of the broadcast that the batching rule inserted for the unmapped w on the way in, collapsing the batch axis back out of the cotangent.

Which means the two orderings are related by exactly that sum, and you can check it in one line: the rows of the per-example result add up to the batch gradient, to the last bit on this machine. If you only ever need the sum, either ordering works; if you need the rows, only one does.

run it (verified, jax 0.4.38 CPU): the two orderings, their jaxpr tails, and the identity between them; the equations before each tail are elided at the marked line, and the full dumps run eleven and fifteen equations
>>> loss = lambda w, x: jnp.sum(jnp.tanh(x * w))
>>> jax.make_jaxpr(jax.vmap(jax.grad(loss), in_axes=(None, 0)))(jnp.ones(3), jnp.ones((4, 3)))
{ lambda ; a:f32[3] b:f32[4,3]. let
    ...
    i:f32[4,3] = mul h f
    j:f32[4,3] = mul i e
    k:f32[4,3] = add_any i j
    l:f32[4,3] = mul b k
  in (l,) }

>>> batched = lambda w, xs: jnp.sum(jax.vmap(lambda x: jnp.sum(jnp.tanh(x * w)))(xs))
>>> jax.make_jaxpr(jax.grad(batched))(jnp.ones(3), jnp.ones((4, 3)))
{ lambda ; a:f32[3] b:f32[4,3]. let
    ...
    m:f32[4,3] = mul b l
    n:f32[3] = reduce_sum[axes=(0,)] m
    o:f32[1,3] = reshape[dimensions=None new_sizes=(1, 3) sharding=None] n
    p:f32[3] = reduce_sum[axes=(np.int64(0),)] o
  in (p,) }

>>> xs = jnp.arange(12.0).reshape(4, 3)
>>> per = jax.vmap(jax.grad(loss), in_axes=(None, 0))(jnp.ones(3), xs)
>>> per.shape, per.sum(0)
((4, 3), Array([0.02974708, 0.4253622 , 0.14220996], dtype=float32))
>>> jax.grad(batched)(jnp.ones(3), xs)
Array([0.02974708, 0.4253622 , 0.14220996], dtype=float32)
§ 02

The broadcast comes back as a sum

That tail is not a special case anyone wrote for gradients of batched functions. It is the fourth branch of matchaxis from the last lesson, the one guarded by sum_match, doing what the transpose of a broadcast has to do.

Read it as a pair and it stops needing memorising. An unmapped input gets broadcast on the way forward, so its cotangent gets summed on the way back, because the derivative of copying a value into N places is adding the N gradients up. The batch axis you introduced with in_axes=None is exactly the axis that disappears in the reverse pass.

This also tells you where per-example gradients stop being free of assumptions. They exist because vmap sat outside grad and the sum never happened, not because JAX kept N separate tapes. There is one program, one backward pass, and an axis that was never collapsed.

§ 03

Vmap walks into a pjit, not around it

pjit is one of the twenty-one primitives with a rule in the second table, and its rule batches the jaxprThe traced program: one equation per primitive in single-assignment form, every shape and dtype stated.taught in /l/jaxpr → held in the equation's parameters rather than calling the equation once per example. So an inner jit does not hide its contents from the batching interpreter, and it does not force a per-example dispatch either.

The consequence is easier to state as a measurement. vmap(jit(f)) and jit(vmap(f)) trace to the same jaxprThe traced program: one equation per primitive in single-assignment form, every shape and dtype stated.taught in /l/jaxpr →, text for text, on this machine. Both hold one pjit equation whose inner jaxpr is already batched, and the string comparison of the two dumps is True.

That is a useful thing to know when you inherit code with jit decorators scattered through it. Wrapping the outside in vmap does not defeat them and is not defeated by them; the batch axis goes through the call boundary and comes out the other side.

run it (verified, jax 0.4.38 CPU): the two orderings compared as text, and the jaxpr they share
>>> f = lambda x: jnp.sin(x) * 2.0
>>> x = jnp.ones((4, 3))
>>> str(jax.make_jaxpr(jax.vmap(jax.jit(f)))(x)) == str(jax.make_jaxpr(jax.jit(jax.vmap(f)))(x))
True
>>> jax.make_jaxpr(jax.vmap(jax.jit(f)))(x)
{ lambda ; a:f32[4,3]. let
    b:f32[4,3] = pjit[
      name=<lambda>
      jaxpr={ lambda ; c:f32[4,3]. let
          d:f32[4,3] = sin c
          e:f32[4,3] = mul d 2.0
        in (e,) }
    ] a
  in (b,) }
§ 04

The four refusals vmap owns by itself

Four errors come from the vmap machinery and nowhere else, and all four are about the axis specs rather than about your function. Every one of them fires before any tracing happens, which is why they are quick to fix: the message already contains the two things that disagree.

The first has an answer the error does not mention. Mapping nothing is legal if you say how wide the batch is, and axis_size=4 with in_axes=None runs the body once at its original shape and broadcasts the result, which is a cheap way to shape a program for a batch it does not read yet.

Two other failures look like vmap's and are not. An in_axes that names a batch of 32 against an argument whose axis is 8 raises the inconsistent-sizes error, which the museum keeps as its own exhibit. And a function whose output shape depends on its values fails under vmap for the same reason it fails under jit, since that is a rule about tracing, not about batching, and the message you get is the tracing one.

run it (verified, jax 0.4.38 CPU): the all-None refusal and the axis_size that answers it
>>> jax.vmap(lambda x: x * 2.0, in_axes=None)(jnp.ones(3))
ValueError: vmap must have at least one non-None value in in_axes

>>> jax.vmap(lambda x: x * 2.0, in_axes=None, axis_size=4)(jnp.ones(3)).shape
(4, 3)
>>> jax.make_jaxpr(jax.vmap(lambda x: x * 2.0, in_axes=None, axis_size=4))(jnp.ones(3))
{ lambda ; a:f32[3]. let
    b:f32[3] = mul a 2.0
    c:f32[4,3] = broadcast_in_dim[
      broadcast_dimensions=(np.int64(1),)
      shape=(4, 3)
      sharding=None
    ] b
  in (c,) }
what you wrotethe messagethe fix
in_axes=None on every argumentvmap must have at least one non-None value in in_axespass axis_size=n, or map something
out_axes=None on a batched outputat vmap out_axes, got axis spec None but output was batched on axis 0give it an integer, or stop batching what feeds it
in_axes=2 on a rank-2 arrayvmap was requested to map its argument along axis 2, which implies that its rank should be at least 3, but is only 2 (its shape is (4, 3))axis integers live in [-ndim, ndim)
in_axes={"w": None} against a two-key dictvmap in_axes specification must be a tree prefix of the corresponding value, got specification ({'w': None}, 0) for value tree PyTreeDef(({'b': *, 'w': *}, *))name every key, or replace the dict spec with one None
the four vmap-only refusals, messages quoted verbatim from runs on jax 0.4.38 CPU
§ 05

When the primitive is yours

Everything above rests on the primitives already having rules, and the moment you add a primitive of your own that stops being true. A Pallas kernel is batchable because pallas_call is a key in the first rule table, a custom_vjp because its call primitive is a key in the second. Neither is batchable because vmap can see inside it.

So the question to ask of any new primitive is the one lesson one ended on. Does it have a rule, is that rule the elementwise one, and does the elementwise one actually describe what the primitive does to an extra axis. Get that wrong and nothing raises; you get a program that runs and computes the wrong thing.

The kernel path's Pallas arc is where that stops being hypothetical, since a kernel with a block spec has real opinions about which axis is which. The rule table is the same table.

before you move on

Check yourself

01 One jaxpr ends in reduce_sum, reshape, reduce_sum and returns a (3,); another ends in a mul and returns a (4, 3). Which is grad of vmap and which is vmap of grad?

The (3,) one is grad of vmap: that tail is the transpose of the broadcast the batching rule inserted for the unmapped argument, collapsing the batch axis out of the cotangent. The (4, 3) one is vmap of grad, where the axis is never collapsed and each row is one example’s gradient.

02 You wrap vmap around a function that already has a jit inside it. What does the batching interpreter do with the pjit equation?

It looks pjit up in the fancy rule table and batches the jaxpr stored in the equation’s parameters. The result is the same jaxpr you would get from jit(vmap(f)), text for text: one pjit equation whose inner jaxpr already carries the batch axis.

03 Which vmap error does axis_size answer, and what does the resulting jaxpr look like?

The one raised when every in_axes entry is None. With axis_size=n the body traces once at its unbatched shape and a single broadcast_in_dim at the end gives the result its batch axis, so no work is repeated per example.

assigned

Readings