Transformations: grad and vmap#
The heart of JAX is a set of function transformations: higher-order functions that take a numerical function youâve written and return a new function that computes something related. The two most common are:
jax.grad(), which transforms a function into one that computes its gradient via automatic differentiation;jax.vmap(), which transforms a function written for single examples into one that operates efficiently over batches via automatic vectorization.
Thereâs a third canonical transformation, jax.jit(), which compiles a
function to make it run fast. Since jax.jit changes only performance and not
what a function computes, we defer it to the performance and scaling docs (see
Just-in-time compilation), but everything on this page applies to it too.
Transformations compose: you can differentiate a vectorized function, vectorize
a derivative, and compile any of it. This page introduces grad and vmap,
then explains the tracing mechanism that makes all transformations work.
Automatic differentiation with jax.grad#
jax.grad() takes a scalar-valued function and returns a new function
that computes its gradient:
import jax
import jax.numpy as jnp
grad_tanh = jax.grad(jnp.tanh)
print(grad_tanh(2.0))
0.07065082
If f is a Python function that evaluates the mathematical function \(f\), then
jax.grad(f) is a Python function that evaluates \(\nabla f\), so grad(f)(x)
is the gradient value \(\nabla f(x)\).
Since jax.grad maps functions to functions, you can apply it repeatedly to
take higher-order derivatives:
f = lambda x: x**3 + 2*x**2 - 3*x + 1
dfdx = jax.grad(f)
d2fdx = jax.grad(dfdx)
d3fdx = jax.grad(d2fdx)
print(dfdx(1.0)) # 3xÂČ + 4x - 3 evaluated at 1, i.e. 4
print(d2fdx(1.0)) # 6x + 4 evaluated at 1, i.e. 10
print(d3fdx(1.0)) # 6
4.0
10.0
6.0
Differentiating with respect to different arguments#
By default jax.grad differentiates with respect to the first argument. The
argnums parameter selects other arguments, or several at once. Hereâs a
linear logistic regression model, where we might want gradients with respect
to the weights W, the bias b, or both:
def sigmoid(x):
return 0.5 * (jnp.tanh(x / 2) + 1)
# Outputs probability of a label being true.
def predict(W, b, inputs):
return sigmoid(jnp.dot(inputs, W) + b)
# A toy dataset, and some parameter values.
inputs = jnp.array([[0.52, 1.12, 0.77],
[0.88, -1.08, 0.15],
[0.52, 0.06, -1.30],
[0.74, -2.49, 1.39]])
targets = jnp.array([True, True, False, True])
W = jnp.array([0.1, 0.4, -0.3])
b = 0.5
# Training loss is the negative log-likelihood of the training examples.
def loss(W, b):
preds = predict(W, b, inputs)
label_probs = preds * targets + (1 - preds) * (1 - targets)
return -jnp.sum(jnp.log(label_probs))
# Differentiate `loss` with respect to the first positional argument:
W_grad = jax.grad(loss, argnums=0)(W, b)
print(f'{W_grad=}')
# Since argnums=0 is the default, this does the same thing:
W_grad = jax.grad(loss)(W, b)
print(f'{W_grad=}')
# But you can choose different values too, and drop the keyword:
b_grad = jax.grad(loss, 1)(W, b)
print(f'{b_grad=}')
# Including tuple values:
W_grad, b_grad = jax.grad(loss, (0, 1))(W, b)
print(f'{W_grad=}')
print(f'{b_grad=}')
W_grad=Array([-0.72034794, 1.9374751 , -2.226889 ], dtype=float32)
W_grad=Array([-0.72034794, 1.9374751 , -2.226889 ], dtype=float32)
b_grad=Array(-0.7629071, dtype=float32, weak_type=True)
W_grad=Array([-0.72034794, 1.9374751 , -2.226889 ], dtype=float32)
b_grad=Array(-0.7629071, dtype=float32, weak_type=True)
Real models donât keep their parameters in separate positional arguments.
Instead they use nested containers like dictionaries. jax.grad handles those
natively; thatâs the subject of the next page, Pytrees.
value_and_grad and auxiliary outputs#
You usually want the loss value as well as its gradient, for example to log
training progress. jax.value_and_grad() computes both in one pass:
loss_value, Wb_grad = jax.value_and_grad(loss, (0, 1))(W, b)
print(loss_value)
3.5066924
And sometimes a function naturally computes intermediate results worth
returning alongside the scalar being differentiated. Provide a function that
produces a (scalar_output, aux_data) pair as output and pass
has_aux=True:
def loss_and_preds(W, b):
preds = predict(W, b, inputs)
label_probs = preds * targets + (1 - preds) * (1 - targets)
return -jnp.sum(jnp.log(label_probs)), preds
W_grad, preds = jax.grad(loss_and_preds, has_aux=True)(W, b)
print(preds)
[0.68330455 0.5277215 0.7243215 0.30174544]
Checking derivatives numerically#
Derivatives are easy to check against finite differences:
eps = 1e-4
b_grad_numerical = (loss(W, b + eps / 2.) - loss(W, b - eps / 2.)) / eps
print('b_grad_numerical', b_grad_numerical)
print('b_grad_autodiff', jax.grad(loss, 1)(W, b))
b_grad_numerical -0.76293945
b_grad_autodiff -0.7629071
JAX ships a convenience that does this automatically, to any order:
from jax.test_util import check_grads
check_grads(loss, (W, b), order=2) # check up to 2nd order derivatives
jax.grad is one entry point into a much deeper autodiff system built on the
fundamental jax.jvp() and jax.vjp() transformations. Those are
covered in the advanced autodiff docs; see JAX 301: advanced autodiff and extending JAX.
Automatic vectorization with jax.vmap#
jax.vmap() transforms a function written for single inputs into one that
works efficiently on batches of inputs. Consider a function that computes the
convolution of two one-dimensional vectors:
x = jnp.arange(5.0)
w = jnp.array([2., 3., 4.])
def convolve(x, w):
output = []
for i in range(1, len(x) - 1):
output.append(jnp.dot(x[i-1:i+2], w))
return jnp.array(output)
convolve(x, w)
Array([11., 20., 29.], dtype=float32)
Suppose we want to apply this function to a whole batch of xs and ws:
xs = jnp.stack([x, x])
ws = jnp.stack([w, w])
The naive option is a Python loop over the batch:
def manually_batched_convolve(xs, ws):
output = []
for i in range(xs.shape[0]):
output.append(convolve(xs[i], ws[i]))
return jnp.stack(output)
manually_batched_convolve(xs, ws)
Array([[11., 20., 29.],
[11., 20., 29.]], dtype=float32)
This produces the correct result, but it processes one example at a time, which performs poorly on hardware built for array-level parallelism. To batch the computation efficiently youâd normally rewrite the function by hand so that every operation works over the batch dimension. Thatâs manageable here, but messy and error-prone for realistic functions.
jax.vmap does this rewrite automatically:
auto_batch_convolve = jax.vmap(convolve)
auto_batch_convolve(xs, ws)
Array([[11., 20., 29.],
[11., 20., 29.]], dtype=float32)
The transformed function behaves as if convolve were called on each
example, but internally every operation inside it acts on the whole batch at
once, with no Python loop and no manual rewrite.
Choosing which axes to map with in_axes and out_axes#
By default, vmap maps over the leading axis of every input. The in_axes and
out_axes arguments override this. For example, if your data has the batch as
the second axis:
auto_batch_convolve_v2 = jax.vmap(convolve, in_axes=1, out_axes=1)
xst = jnp.transpose(xs)
wst = jnp.transpose(ws)
auto_batch_convolve_v2(xst, wst)
Array([[11., 11.],
[20., 20.],
[29., 29.]], dtype=float32)
An in_axes entry of None means âdonât map this argumentâ; instead, itâs
broadcast to every call. Here we convolve a batch of xs against one shared
w:
batch_convolve_v3 = jax.vmap(convolve, in_axes=[0, None])
batch_convolve_v3(xs, w)
Array([[11., 20., 29.],
[11., 20., 29.]], dtype=float32)
Composing vmap#
Like all JAX transformations, vmap composes. Nesting vmap gives concise
expressions for âall pairsâ computations:
def dist(x, y):
return jnp.sqrt(jnp.sum((x - y) ** 2))
def all_pairs(f):
return jax.vmap(jax.vmap(f, in_axes=(None, 0)), in_axes=(0, None))
points = jnp.array([[0., 0.], [1., 0.], [0., 2.]])
all_pairs(dist)(points, points)
Array([[0. , 1. , 2. ],
[1. , 0. , 2.236068],
[2. , 2.236068, 0. ]], dtype=float32)
Composing vmap with grad answers questions that are awkward to express
otherwise. For example: the gradient of our logistic regression loss for
each example separately (rather than summed over the batch). Write the
per-example loss, differentiate it, then vectorize the derivative:
def example_loss(W, b, x, y):
pred = predict(W, b, x)
label_prob = pred * y + (1 - pred) * (1 - y)
return -jnp.log(label_prob)
per_example_grads = jax.vmap(jax.grad(example_loss, (0, 1)),
in_axes=(None, None, 0, 0))
W_grads, b_grads = per_example_grads(W, b, inputs, targets)
print(W_grads) # one gradient per example
print(b_grads)
[[-0.16468163 -0.3546989 -0.24385549]
[-0.41560507 0.5100608 -0.07084177]
[ 0.3766471 0.04345929 -0.94161785]
[-0.5167084 1.7386539 -0.97057384]]
[-0.31669545 -0.47227848 0.7243214 -0.6982546 ]
Each transformation did one conceptually simple job, and composition did the rest. This is the characteristic JAX pattern: write the mathematically natural, single-example function, and build everything else out of transformations.
How transformations work: tracing#
To transform a function, JAX has to know what the function does. It learns this by tracing: calling your Python function with special tracer objects in place of arrays, and overloading every JAX operation applied to them.
You can see tracers directly by printing an argument inside a transformed function:
def f(x):
print("x =", x)
print("jax.typeof(x) =", jax.typeof(x))
return x * 2
result = jax.vmap(f)(jnp.arange(3))
x = VmapTracer(aval=int32[], batched=int32[3])
jax.typeof(x) = int32[]
The printed value isnât an array, but a tracer, a stand-in for an element of
jnp.arange(3). A tracer always has the same JAX type as the value it
stands for, which you can query with jax.typeof(). Here xâs type is
int32[]: inside the transformed function, x is a rank-0 int32 array
(shape (), hence the empty brackets), exactly as f is written to expect.
The tracerâs printout also reveals vmapâs bookkeeping: a batch of three such
values. As the traced function runs, each operation like x * 2 is
intercepted by the transformation. What happens next depends on the
transformation being applied: vmap replaces each intercepted operation with
a batched version, and grad applies each operationâs derivative rule.
Another thing a transformation can do is record all the operations and
their data dependencies. The result represents the computation performed by
the traced function, specialized to the JAX types of the inputs that were
provided. JAXâs datatype for representing such a computation is a jaxpr. To
see one, we can borrow jax.jit(), the compilation transformation weâll
meet properly in the performance docs, and ask it to run only its tracing
step:
def g(x):
return jnp.sin(x) * 2.0
jax.jit(g).trace(1.0).jaxpr
{ lambda ; a:f32[]. let b:f32[] = sin a; c:f32[] = mul b 2.0:f32[] in (c,) }
Weâll use this .trace(...).jaxpr idiom whenever we want to see what a
function traces to. Notice what appears in the jaxpr: just the JAX operations,
with every variable annotated with its JAX type (f32[] abbreviates
float32[]). Anything else
about your Python function (variable names, comments, and in particular any
non-JAX-intercepted side effects) is not recorded.
Jaxprs also let us see precisely what a transformation does to a program.
Hereâs the recording of convolve from earlier, applied to a single example:
jax.jit(convolve).trace(x, w).jaxpr
{ lambda ; a:f32[5] b:f32[3]. let
c:f32[3] = slice[limit_indices=(3,) start_indices=(0,) strides=None] a
d:f32[] = dot_general[
dimension_numbers=(([0], [0]), ([], []))
preferred_element_type=float32
] c b
e:f32[3] = slice[limit_indices=(4,) start_indices=(1,) strides=None] a
f:f32[] = dot_general[
dimension_numbers=(([0], [0]), ([], []))
preferred_element_type=float32
] e b
g:f32[3] = slice[limit_indices=(5,) start_indices=(2,) strides=None] a
h:f32[] = dot_general[
dimension_numbers=(([0], [0]), ([], []))
preferred_element_type=float32
] g b
i:f32[1] = broadcast_in_dim d
j:f32[1] = broadcast_in_dim f
k:f32[1] = broadcast_in_dim h
l:f32[3] = concatenate[dimension=0] i j k
in (l,) }
Three windows, three dot products, one concatenate. Now the vmaped version,
applied to the batch:
jax.jit(jax.vmap(convolve)).trace(xs, ws).jaxpr
{ lambda ; a:f32[2,5] b:f32[2,3]. let
c:f32[2,3] = slice[limit_indices=(2, 3) start_indices=(0, 0) strides=None] a
d:f32[2] = dot_general[
dimension_numbers=(([1], [1]), ([0], [0]))
preferred_element_type=float32
] c b
e:f32[2,3] = slice[limit_indices=(2, 4) start_indices=(0, 1) strides=None] a
f:f32[2] = dot_general[
dimension_numbers=(([1], [1]), ([0], [0]))
preferred_element_type=float32
] e b
g:f32[2,3] = slice[limit_indices=(2, 5) start_indices=(0, 2) strides=None] a
h:f32[2] = dot_general[
dimension_numbers=(([1], [1]), ([0], [0]))
preferred_element_type=float32
] g b
i:f32[2,1] = broadcast_in_dim[broadcast_dimensions=(0,)] d
j:f32[2,1] = broadcast_in_dim[broadcast_dimensions=(0,)] f
k:f32[2,1] = broadcast_in_dim[broadcast_dimensions=(0,)] h
l:f32[2,3] = concatenate[dimension=1] i j k
in (l,) }
This is the same program, operation for operation. The only change is that
every operation gained a batch axis: each f32[5] became f32[2,5], and each
dot_general picked up a batch dimension. Thatâs what âvmap replaces each
intercepted operation with a batched versionâ means concretely: the batching
happens inside each operation, where array-level hardware parallelism lives,
and the program stays the same size no matter the batch.
Compare the Python-loop version, manually_batched_convolve, whose recording
contains a full copy of the body per batch element: six dot_generals, not
three, and growing linearly with the batch size:
jax.jit(manually_batched_convolve).trace(xs, ws).jaxpr
{ lambda ; a:f32[2,5] b:f32[2,3]. let
c:f32[1,5] = slice[limit_indices=(1, 5) start_indices=(0, 0) strides=None] a
d:f32[5] = squeeze[dimensions=(0,)] c
e:f32[1,3] = slice[limit_indices=(1, 3) start_indices=(0, 0) strides=None] b
f:f32[3] = squeeze[dimensions=(0,)] e
g:f32[3] = slice[limit_indices=(3,) start_indices=(0,) strides=None] d
h:f32[] = dot_general[
dimension_numbers=(([0], [0]), ([], []))
preferred_element_type=float32
] g f
i:f32[3] = slice[limit_indices=(4,) start_indices=(1,) strides=None] d
j:f32[] = dot_general[
dimension_numbers=(([0], [0]), ([], []))
preferred_element_type=float32
] i f
k:f32[3] = slice[limit_indices=(5,) start_indices=(2,) strides=None] d
l:f32[] = dot_general[
dimension_numbers=(([0], [0]), ([], []))
preferred_element_type=float32
] k f
m:f32[1] = broadcast_in_dim h
n:f32[1] = broadcast_in_dim j
o:f32[1] = broadcast_in_dim l
p:f32[3] = concatenate[dimension=0] m n o
q:f32[1,5] = slice[limit_indices=(2, 5) start_indices=(1, 0) strides=None] a
r:f32[5] = squeeze[dimensions=(0,)] q
s:f32[1,3] = slice[limit_indices=(2, 3) start_indices=(1, 0) strides=None] b
t:f32[3] = squeeze[dimensions=(0,)] s
u:f32[3] = slice[limit_indices=(3,) start_indices=(0,) strides=None] r
v:f32[] = dot_general[
dimension_numbers=(([0], [0]), ([], []))
preferred_element_type=float32
] u t
w:f32[3] = slice[limit_indices=(4,) start_indices=(1,) strides=None] r
x:f32[] = dot_general[
dimension_numbers=(([0], [0]), ([], []))
preferred_element_type=float32
] w t
y:f32[3] = slice[limit_indices=(5,) start_indices=(2,) strides=None] r
z:f32[] = dot_general[
dimension_numbers=(([0], [0]), ([], []))
preferred_element_type=float32
] y t
ba:f32[1] = broadcast_in_dim v
bb:f32[1] = broadcast_in_dim x
bc:f32[1] = broadcast_in_dim z
bd:f32[3] = concatenate[dimension=0] ba bb bc
be:f32[2,3] = stack[axis=0] p bd
in (be,) }
Two big consequences follow from this design.
Consequence 1: transformations require traceable functions#
JAX transformations only work on operations that the tracing machinery intercepts. The usual sufficient condition for traceability is functional purity: outputs depend only on inputs (arguments and closed-over values); outputs are produced by applying JAX operations; and no side effects occur.
Purity is valuable even without a tracing implementation: it makes code easier
for the user to reason about, and easier for the compiler to optimize,
parallelize, and scale. It also gives the transformations simple, clear
meanings. A pure function denotes a mathematical function, and the
transformations are mathematical operators on it:
jax.grad(f) means \(\nabla f\); jax.vmap(f) means â\(f\) applied to each
elementâ without worrying about whether or in what order side-effects might
occur; jax.jit(f) can promise to return exactly what f returns, while
caching and optimizing freely.
Tracing then turns this from good advice into a working requirement. Side
effects in your function, like built-in Python print calls, happen at trace
time, not when the transformed computation runs.
Our vmap example above already showed this: the batch had three elements, but
print ran only once, because vmap traces the function a single time,
transforming each operation as itâs intercepted. Under jax.jit, the effect
is even sharper: traces are cached, so a side effect might happen on the first
call and then never again.
If you want to print runtime values from transformed code, thereâs a
purpose-built tool: jax.debug.print(); see Debugging runtime values.
Reading mutable state has the same problem in reverse: a global value is likely baked in at trace time, so later updates to it are silently ignored by transformed code.
For more about traceability, see Stateful computations.
Consequence 2: traced code canât always specialize on data values#
How much does a tracer know about the value it stands in for? That depends on
the transformation. vmapâs tracers actually know quite a lot: they carry
the whole batch of values along as the trace proceeds. We can even peek at
the batch mid-trace, through the tracerâs .val attribute:
# warning: `.val` is unsupported internals, so don't rely on it in real code!
jax.vmap(lambda x: print(x.val))(jnp.arange(3.0))
[0. 1. 2.]
(Reaching into a tracerâs internals like .val is unsafe, since itâs an
implementation detail rather than an API; in real code youâd use
jax.debug.print(). Weâre doing it here only for a look inside.)
So the values may well be present. What traced code canât do is specialize on them. The operations applied must work for every element of the batch, so the traced code canât take one particular control-flow branch based on one particular elementâs value. Each element might want a different branch, and the trace can only record one:
def absolute(x):
if x > 0: # needs one answer, but x stands for a whole batch of values
return x
else:
return -x
jax.vmap(absolute)(jnp.arange(-2.0, 3.0))
TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
This BatchTracer with object id 138189883877200 was created on line:
/tmp/ipykernel_1486/4198118709.py:2:5 (absolute)
See https://docs.jax.dev/en/latest/errors.html#jax.errors.TracerBoolConversionError
The error message links to the fix: express data-dependent choices as array
operations, like jax.numpy.where(), which compute a per-element answer
instead of forcing a single branch:
def absolute(x):
return jnp.where(x > 0, x, -x)
jax.vmap(absolute)(jnp.arange(-2.0, 3.0))
Array([ 2., 1., -0., 1., 2.], dtype=float32)
(For data-dependent loops and more elaborate control flow, JAX provides
structured control-flow operations like jax.lax.cond and jax.lax.scan; see
Control flow and logical operators with jit.)
jax.jit is the extreme case: its tracers carry no values at all, only the
JAX type, because its recorded program must serve every value of that type.
The same constraint follows, for an even stronger reason: there are no values
to consult in the first place.
On the other hand, anything that depends only on JAX types works freely during
tracing, because those are ordinary Python values at trace time.
Python for loops over a fixed range, if statements on shapes, and shape
arithmetic are all fine. Our convolve function above used a Python loop
whose bounds came from len(x): that loop unrolls during tracing, and the
recorded operations are as if weâd written the unrolled version by hand. This
is also why keeping import numpy as np around is useful: np operations on
shapes execute immediately at trace time, separating âcomputations on static
valuesâ from âcomputations being tracedâ (jnp).
grad works with Python control flow#
If a vmap tracer stands for a whole batch of values, and a jit tracer
stands for any value of the right JAX type, jax.gradâs tracers occupy the
opposite pole: applied on its own, jax.grad evaluates your function with
tracers that carry exactly one concrete value alongside the derivative
bookkeeping. With a single value, data-dependent Python control flow is
unambiguous, so if youâre just using grad, you can apply data-dependent if,
while, recursion, or anything else:
def f(x):
if x < 3:
return 3. * x ** 2
else:
return -4. * x
print(jax.grad(f)(2.0)) # differentiates the 3xÂČ branch: 6x at x=2
print(jax.grad(f)(4.0)) # differentiates the -4x branch
12.0
-4.0
Each call differentiates the branch actually taken, giving exactly the piecewise derivative youâd write by hand. Even loops whose trip count depends on the data are fine. Hereâs differentiating through Newtonâs method for square roots, where the number of iterations depends on the input:
def sqrt_newton(a):
x = a
while abs(x * x - a) > 1e-6: # data-dependent loop
x = 0.5 * (x + a / x)
return x
print(sqrt_newton(2.0))
print(jax.grad(sqrt_newton)(2.0)) # 1/(2â2) â 0.3536
1.4142135623746899
0.35355338
This flexibility is part of JAXâs lineage: JAXâs autodiff grew out of Autograd, whose whole point was differentiating ordinary, idiomatic Python and NumPy code, including branches, loops, and closures.
The constraints return the moment you compose with a transformation that traces
abstractly: jax.jit(jax.grad(f)) and jax.vmap(jax.grad(f)) see the if
above fail again, and want it rewritten with jnp.where, lax.cond, and
friends (Control flow and logical operators with jit). That trade is this documentationâs split
in miniature: grad by itself maximizes what you can express, and itâs
compiling for speed that asks you to make control flow explicit.
Where jit fits in#
jax.jit() uses this same tracing machinery, but instead of replacing
each intercepted operation on the fly, it records them all and hands the
recording to the XLA compiler to produce fast fused machine code, caching the
compiled result keyed on the JAX types of its inputs. In use, itâs most often
applied as a decorator:
@jax.jit # equivalent to sum_sq = jax.jit(sum_sq)
def sum_sq(x):
return jnp.sum(x ** 2)
sum_sq(jnp.arange(3.0))
Array(5., dtype=float32)
The first call with a given set of input JAX types pays for tracing and
compilation; later calls skip straight to the compiled code. Whatâs new with
jit is performance: compilation, caching and retracing, static arguments,
asynchronous dispatch. That is where the performance and scaling docs start;
see Just-in-time compilation.
Next steps#
Real programs pass around richer structures than just arrays: dictionaries of parameters, lists of batches, nested configurations. The next page, Pytrees, shows how JAX handles those structures.