Chapter 5: Parallelization Across Devices with pmap

Course: "Getting Started with JAX", Chapter 5 · Original course page: https://apxml.com/courses/getting-started-with-jax/chapter-5-parallelization-across-devices-pmap Environment for these notes: Python 3.14 · JAX 0.12.0.1 · CPU only (8 devices simulated with XLA_FLAGS)

jax.jit compiles and optimizes code for a single device, and jax.vmap handles batching. Neither of them spreads work across multiple devices. This chapter is about multi-device parallelism: the same function runs on several GPUs/TPUs at once, and each device handles a different slice of the data.

📌 About pmap: this chapter is built around jax.pmap. In JAX 0.12, pmap still works and raises no warning when you call it. But the official docs now call it the "old way" and say it is implemented internally with jit + shard_map (we verify this in 5.2). So the chapter is organized like this:

  • every pmap example is kept and labeled as the old API;
  • each major example is followed right away by a # ✅ Modern style cell that rewrites it with jax.jit + NamedSharding or jax.shard_map, plus an assert showing both versions give the same result;
  • points that need extra care are marked with > ⚠️ Note, and the "Version notes" section at the end of the chapter collects them.

Learning goals

  1. Understand how SPMD (single program, multiple data) parallelism works;
  2. Use pmap (old API) and jit + sharding / shard_map (modern style) to run the same function on different data across devices;
  3. Control how data is distributed to devices and how results are collected (in_axes/out_axes ↔ PartitionSpec/in_specs/out_specs);
  4. Use collective operations (lax.psum, lax.pmean, and so on) inside a parallel function to communicate across devices;
  5. Write data-parallel training and verify that it matches single-device training;
  6. Avoid the number one pitfall when migrating from pmap to shard_map: gradients that come out N times too large.

Sections

SectionContentOriginal
0Setup: simulating 8 devices on 1 CPU—
5.1Introduction to data parallelism (SPMD)link
5.2Introducing jax.pmaplink
5.3Mapping data to devices (in_axes, out_axes)link
5.4Device meshes and axis nameslink
5.5Collective operations (lax.psum, lax.pmean, etc.)link
5.6Combining pmap with other transformationslink
⚠️ Special topicGradients become N times larger in shard_map (the top migration pitfall)—
5.7Debugging pmapped functionslink
5.8Hands-on: parallel computation (with full data-parallel training and a cross-check)link
5.9Chapter 5 quizlink
5.10Summary and cheat sheet—
—Version notes—

How to read this: the explanations reorganize the course's key points, and all code has actually been run. Outdated patterns sit in ❌ cells, each followed right away by the ✅ recommended version.

0 Setup: simulating 8 devices on 1 CPU

This machine has only 1 CPU device. With a single device, pmap can only map an axis of length 1, so you can't see any parallelism at all. XLA has a debug flag that splits the host CPU into several "virtual devices":

os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=8"

These points matter:

  • The environment variable is read only once, when the JAX backend initializes, so you must set it before import jax. The safest place is the first cell of the notebook.
  • If you have already run import jax and done some computation, changing the variable has no effect. In that case, restart the kernel first and run the cell below before anything else.
  • These 8 "devices" share the same CPU and memory, so nothing gets faster. What they're good for is checking that multi-device code is correct on a laptop: shapes, sharding, collective communication.
# Must be set before import jax! Otherwise you need to restart the kernel
import os
os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=8"

import io, contextlib, time, warnings
warnings.simplefilter("default")  # Make DeprecationWarning and friends visible so we can check API status

import jax
import jax.numpy as jnp
from jax import lax
import numpy as np
from jax.sharding import Mesh, NamedSharding, PartitionSpec as P, AxisType

print("JAX version:", jax.__version__)
print("Devices:", jax.devices())
assert jax.device_count() == 8, "Did not get 8 devices: restart the kernel and run this cell first"

def same(a, b, **kw):
    """Check whether two arrays match: convert to numpy first, since arrays on different meshes can't be combined directly."""
    return np.allclose(np.asarray(a), np.asarray(b), **kw)
JAX version: 0.12.0.1
Devices: [CpuDevice(id=0), CpuDevice(id=1), CpuDevice(id=2), CpuDevice(id=3), CpuDevice(id=4), CpuDevice(id=5), CpuDevice(id=6), CpuDevice(id=7)]

5.1 Introduction to data parallelism (SPMD)

jit and vmap mostly work on a single compute device. When you have several accelerators, the most direct idea is data parallelism: split the data across the processing units, and have each unit run the same computation on its own piece.

SPMD: single program, multiple data

The SPMD (Single Program, Multiple Data) model has three parts:

  1. Single program: you write one function that defines the computation, for example one forward pass of a neural network or one time step of a simulation;
  2. Multiple data: the input data (a batch of images, a set of simulation parameters) is cut into several shards;
  3. Parallel execution: every device (a GPU or a TPU core) runs exactly the same program, but only on the shard assigned to it.

Compare this with MIMD (multiple instruction, multiple data), where different processors can run completely different programs. With SPMD you only have to think about the structure of one program, which is much easier to reason about.

An example: you have a function process_data(x), a large dataset X, and 4 devices:

            X (full data)
     ┌───────┬───────┬───────┬───────┐
     │ X[0]  │ X[1]  │ X[2]  │ X[3]  │   ← cut into 4 shards
     └───┬───┴───┬───┴───┬───┴───┬───┘
         ▼       ▼       ▼       ▼
     device0  device1  device2  device3     ← each device runs process_data
         └───────┴───┬───┴───────┘
                     ▼
               combined result

Why JAX chose SPMD

  • SPMD fits JAX's functional style and its idea of function transformations: you write an ordinary function that handles one piece of data, and JAX transforms it into an SPMD program that runs on many devices;
  • There are three benefits. First, code reuse: the single-device logic doesn't change. Second, scalability: you can train bigger models and process more data. Third, speed: the total time is much shorter than processing piece by piece on one device.

Communication between devices

During parallel computation, devices often need to exchange information, for example to aggregate gradients from all devices, or to swap boundary data between simulation regions. JAX does this with collective operations, such as jax.lax.psum for summing across devices (section 5.5).

Three ways to write SPMD in JAX 0.12

StyleAPIWho decides the communicationWhen to use
Automatic parallelismjax.jit + inputs annotated with NamedShardingThe XLA compiler inserts communication automatically based on how the inputs are shardedMost training and inference code, the first choice
Explicit shardingA mesh from jax.make_mesh with its default AxisType.Explicit; sharding becomes part of the array typeThe user sees and controls sharding at the type levelWhen you want precise control over sharding
Manual parallelismjax.shard_map (and the old API jax.pmap)The user takes the view of a single device and writes psum etc. by handCustom communication patterns; closest to how pmap thinks

References from the original: the official JAX jax.pmap docs; Pacheco, An Introduction to Parallel Programming (2011); Hao et al., Distributed Deep Learning: A Review (2020); the JAX "Distributed Arrays and Sharding" docs.

5.2 Introducing jax.pmap

jit compiles code for a single accelerator, and vmap does automatic vectorization. To split computation across multiple devices, the tool in this section is jax.pmap (parallel map). It's used much like jit and vmap: you can use it as a decorator or wrap a function with it directly.

Concept: pmap's execution model

  1. Compile: like jit, it compiles the function with XLA;
  2. Replicate: it copies the compiled program to every device;
  3. Distribute data: it splits the input along the given axis (axis 0 by default). Each element of the mapped axis goes to one device, so the length of this axis can't exceed the number of devices;
  4. Run in parallel: all devices run the same program on their own slices at the same time (SPMD);
  5. Collect results: by default the results stay on their devices, and together they form one distributed jax.Array.
input (N, 3)                     output (N, 3)
 ┌─────────┐   slice i   ┌──────────┐  result i   ┌─────────┐
 │ row 0   │ ─────────▶ │ dev 0: f  │ ─────────▶ │ row 0   │
 │ row 1   │ ─────────▶ │ dev 1: f  │ ─────────▶ │ row 1   │
 │  ...    │            │   ...    │            │  ...    │
 │ row N-1 │ ─────────▶ │ dev N-1:f │ ─────────▶ │ row N-1 │
 └─────────┘            └──────────┘            └─────────┘

pmap vs vmap

vmappmap
What it doesTurns a function for one example into a function for a batchReplicates the computation across multiple physical devices
Where the parallelism happensVectorized instructions inside one deviceAcross multiple accelerators
Length of the mapped axisAnythingNo more than the number of devices

On a multi-GPU machine or a TPU pod, jax.devices() lists several devices. pmap only makes sense when there is more than one device.

⚠️ Note: with only 1 device, pmap does not "fall back to vmap". The mapped axis can then only have length 1, so it can't handle batches of arbitrary length the way vmap does (a length larger than the device count raises an error, see 5.3).

The pmap way

# Course example: check the devices
import jax
import jax.numpy as jnp

print(jax.devices())
print("Number of devices:", len(jax.devices()))
[CpuDevice(id=0), CpuDevice(id=1), CpuDevice(id=2), CpuDevice(id=3), CpuDevice(id=4), CpuDevice(id=5), CpuDevice(id=6), CpuDevice(id=7)]
Number of devices: 8
# Course example (old API pmap): leading input dimension = number of devices
num_devices = len(jax.devices())

def simple_computation(x):
    return x * 2

pmapped_computation = jax.pmap(simple_computation)
input_data = jnp.arange(num_devices * 3).reshape((num_devices, 3))

try:
    result = pmapped_computation(input_data)
    print("Result:\n", result)
    print("Type:", type(result))
    print("Shape:", result.shape)
    print("Number of devices it lives on:", len(result.devices()))
except Exception as e:
    print("Error:", e)
Result:
 [[ 0  2  4]
 [ 6  8 10]
 [12 14 16]
 [18 20 22]
 [24 26 28]
 [30 32 34]
 [36 38 40]
 [42 44 46]]
Type: <class 'jaxlib._jax.ArrayImpl'>
Shape: (8, 3)
Number of devices it lives on: 8

Key points:

  1. The input needs a leading dimension whose length equals (or is less than) the number of devices. pmap splits along axis 0 by default and sends each slice (here (3,)) to one device;
  2. Each device runs simple_computation on its slice at the same time;
  3. The output is an ordinary jax.Array with the same shape as the input, with its data spread across the devices. You can see how it's distributed with result.devices() and result.sharding.

⚠️ Note: in older material, pmap's output type is ShardedDeviceArray. That type was removed long ago; all arrays are now the unified jax.Array. pmap's output carries a NamedSharding whose mesh has a single internal axis, _internal_pmap_axis_name (printed below).

The modern way

  • jax.make_mesh(axis_shapes, axis_names): create a logical device mesh;
  • PartitionSpec (shortened to P): says which mesh axis each dimension of an array maps to; None means that dimension isn't split;
  • NamedSharding(mesh, P(...)): a mesh plus a partitioning rule, which together make a concrete sharding;
  • jax.device_put(x, sharding): place data on the devices according to a sharding;
  • jax.shard_map(f, mesh=, in_specs=, out_specs=): the function f sees the local block on each device.
# ✅ Modern style: jit + NamedSharding, no manual reshape, written just like single-device code
mesh = jax.make_mesh((8,), ("data",))
x_sharded = jax.device_put(jnp.arange(8 * 3), NamedSharding(mesh, P("data")))   # (24,) dimension 0 split over 'data'
print("Local shape per device:", [s.data.shape for s in x_sharded.addressable_shards])

y_jit = jax.jit(simple_computation)(x_sharded)
print("Result sharding:", y_jit.sharding.spec)

# ✅ Modern style: shard_map, which maps one-to-one onto pmap
y_sm = jax.jit(jax.shard_map(simple_computation, mesh=mesh, in_specs=P("data"), out_specs=P("data")))(x_sharded)

assert same(y_jit, result.ravel()) and same(y_sm, result.ravel())
print("✓ jit+sharding, shard_map and pmap give the same result")
Local shape per device: [(3,), (3,), (3,), (3,), (3,), (3,), (3,), (3,)]
Result sharding: P('data',)
✓ jit+sharding, shard_map and pmap give the same result

There is one key difference between pmap and shard_map: pmap removes the mapped dimension, while shard_map keeps it (just smaller):

x83 = jnp.arange(24).reshape(8, 3)
shapes = {}
jax.pmap(lambda x: (shapes.__setitem__("pmap", x.shape), x)[1])(x83)
jax.shard_map(lambda x: (shapes.__setitem__("shard_map", x.shape), x)[1],
              mesh=mesh, in_specs=P("data"), out_specs=P("data"))(jax.device_put(x83, NamedSharding(mesh, P("data"))))
print("Local shape seen inside the device:", shapes)
assert shapes == {"pmap": (3,), "shard_map": (1, 3)}
Local shape seen inside the device: {'pmap': (3,), 'shard_map': (1, 3)}

🔍 Extra: the actual status of pmap in JAX 0.12

🟢 Must-read

Let's check directly: does calling pmap raise a warning? What does the official docstring say?

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    out = jax.pmap(lambda x: x * 2)(jnp.arange(8.0))
print("Warnings raised by calling pmap:", len(caught))
print("\n--- start of jax.pmap.__doc__ ---")
print(jax.pmap.__doc__[:640])
print("--- return value ---")
print("Is a jax.Array:", isinstance(out, jax.Array))
print("sharding:", out.sharding)
assert len(caught) == 0 and isinstance(out, jax.Array)
assert "Old way" in jax.pmap.__doc__ and "_internal_pmap_axis_name" in str(out.sharding)
Warnings raised by calling pmap: 0

--- start of jax.pmap.__doc__ ---
Old way of doing parallel map. Use :py:func:`jax.shard_map` instead.

.. note::
  While :py:func:`jax.pmap` works, you should probably use
  :py:func:`jax.shard_map` or ``jax.smap`` instead. shard_map supports more
  efficient autodiff, and is more composable in the multi-controller setting.
  See https://docs.jax.dev/en/latest/201/shard-map.html for examples.

.. note::
  :py:func:`pmap` is now implemented in terms of :py:func:`jit` and
  :py:func:`shard_map`. Please see the `migration
  guide <https://docs.jax.dev/en/latest/migrate_pmap.html>`_ for
  more information.

The purpose of :py:func:`pmap` is to express single-program mu
--- return value ---
Is a jax.Array: True
sharding: NamedSharding(mesh=Mesh('_internal_pmap_axis_name': 8, axis_types=(Auto,)), spec=P('_internal_pmap_axis_name',), memory_kind=device)

Conclusion: in 0.12.0.1, calling jax.pmap does not raise a DeprecationWarning, and it works fine. But the first line of its docstring says "Old way of doing parallel map. Use jax.shard_map instead.", and it explains that pmap is now implemented with jit and shard_map (see the migration guide migrate_pmap.html). In other words, it's "soft-deprecated": it works, but it isn't recommended for new code.

The shard_map import path common in older material is outdated too:

# ❌ Old style (outdated): jax.experimental.shard_map, common in older material, is deprecated since v0.8.0
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    from jax.experimental.shard_map import shard_map as old_shard_map
print("Warnings on import:", [str(w.message) for w in caught])
assert any(issubclass(w.category, DeprecationWarning) for w in caught)
Warnings on import: ['jax.experimental.shard_map is deprecated in v0.8.0. Used jax.shard_map instead.']
# ✅ Modern style: use jax.shard_map directly
assert hasattr(jax, "shard_map")
print(jax.shard_map)
<function shard_map at 0x11781c510>

The three styles side by side

jax.pmap (old API)jax.jit + shardingjax.shard_map
Input preparationMust reshape to (num_devices, ...)Any shape, annotated with NamedShardingAny shape, annotated with in_specs
What the function seesA single slice with the mapped axis removedThe global arrayA local block with its dimensions kept
Collective communicationHand-written psum etc., using axis_nameInserted automatically by the compilerHand-written psum etc., using mesh axis names
Multi-dimensional parallelismNested pmap (no longer works in 0.12, see 5.4)Multi-dimensional meshMulti-dimensional mesh
Official stanceOld wayRecommendedRecommended

5.3 Mapping data to devices (in_axes, out_axes)

pmap uses the SPMD model: the same function runs on several devices, and each device handles a different part of the input. That raises two questions: which data goes to which device, and how are the results from each device put back together? in_axes and out_axes answer them. in_axes says which axis the inputs are split along, and out_axes says which axis the outputs are stacked along.

Concept: distributing inputs with in_axes

in_axes can be an integer, None, or a tuple/list/dict (possibly nested) that mirrors the structure (pytree) of the function's arguments:

  • An integer (such as 0): the argument is split along axis 0 across devices. Element $i$ of that axis goes to device $i$, and that dimension disappears inside the device. So the axis length must be ≤ the number of devices. Writing 1 maps the second axis, and so on;
  • None: the whole argument is replicated to every device. Good for model parameters, shared config, and the like;
  • A pytree structure: for example def my_func(x, y) with in_axes=(0, None) means x is mapped along axis 0 and y is replicated to all devices.

If you have $B$ examples and $N$ devices, you have to reshape it yourself to $(N, B/N, \dots)$, so that each device gets a $(B/N, \dots)$ per-device batch. If an argument has no axis to map at all (say a 0-d scalar with in_axes=0), or the mapped axis is longer than the number of devices, JAX raises an error.

⚠️ Note: pmap does not automatically cut a (B, ...) array into N blocks of (B/N, ...). It assigns each element of the mapped axis to one device, and an axis longer than the device count is simply an error. "Automatically cutting into B/N blocks" is actually what NamedSharding does (see the modern style in this section).

For example, with 4 devices and data of shape (4, 2, 100), in_axes=0 gives device $i$ the slice data[i], with shape (2, 100).

Concept: collecting outputs with out_axes

  • The integer 0: the outputs from all devices are stacked along a new axis 0. If each device outputs (S, ...), the result is (N, S, ...);
  • None: means "I promise every device's output is the same; just keep one copy". It's commonly used for values already aggregated with lax.psum/lax.pmean. pmap does not check this promise;
  • A pytree structure: when the function returns several values (say return loss, accuracy), you can write out_axes=(0, None).

⚠️ Note: with out_axes=None, JAX simply assumes all device outputs are equal and takes one copy, without checking. If the value hasn't been aggregated, it silently returns one device's local value (this section demonstrates it with an assert).

The pmap way

# Course example (old API pmap): in_axes=(0, None) means data is mapped along axis 0 and scalar is replicated to every device
import numpy as np

def scale(x, k):
    # Scale x by the scalar factor k
    return x * k

data = jnp.arange(12, dtype=jnp.float32).reshape((4, 3))   # 4 entries, feature size 3
scalar = jnp.float32(10.0)

pmapped_scale = jax.pmap(scale, in_axes=(0, None), out_axes=0)
result = pmapped_scale(data, scalar)

print("Number of devices:", jax.local_device_count())
print("Original data shape:", data.shape)
print("pmap result shape:", result.shape)
print("pmap result:\n", result)
print("Devices that actually took part:", len(result.devices()))
assert len(result.devices()) == 4   # Only 4 devices used: one row (3,) per device
Number of devices: 8
Original data shape: (4, 3)
pmap result shape: (4, 3)
pmap result:
 [[  0.  10.  20.]
 [ 30.  40.  50.]
 [ 60.  70.  80.]
 [ 90. 100. 110.]]
Devices that actually took part: 4

This example runs on 8 devices because the mapped axis has length 4 ≤ 8: pmap uses 4 devices, each gets one row (3,) and multiplies it by 10, and out_axes=0 stacks the four (3,) results back into (4, 3).

⚠️ Note: it's easy to assume that with only 2 devices, "device 0 gets data[0:2] and device 1 gets data[2:4], each (2, 3)". In fact, with only 2 devices this code raises an error right away. Compare below:

# ❌ Wrong: pmap over a (4, 3) array on 2 devices (devices= simulates "only 2 devices")
two = jax.devices()[:2]
try:
    jax.pmap(scale, in_axes=(0, None), devices=two)(data, scalar)
    raise AssertionError("Should not have succeeded")
except ValueError as e:
    print("Actual error:", str(e)[:120])

# Likewise: a mapped axis of length 16 > 8 devices also errors (the 0.12 error message isn't very clear)
try:
    jax.pmap(lambda x: x * 2)(jnp.arange(16.0))
    raise AssertionError("Should not have succeeded")
except ValueError as e:
    print("16 > 8 error:", str(e)[:120])
Actual error: cannot select an axis to squeeze out which has size not equal to one, got shape=(2, 3) and dimensions=(0,)
16 > 8 error: cannot select an axis to squeeze out which has size not equal to one, got shape=(2,) and dimensions=(0,)
# ✅ Fix: reshape to (N, B/N, ...) first, so each device gets a (B/N, ...) per-device batch
def scale_shape(x, k):
    print("  shape of x inside the device:", x.shape)   # printed once, during tracing
    return x * k

per_device = data.reshape(2, 2, 3)                                   # (4, 3) -> (2 devices, 2 rows, 3)
r_fixed = jax.pmap(scale_shape, in_axes=(0, None), devices=two)(per_device, scalar)
assert r_fixed.shape == (2, 2, 3)
assert same(r_fixed.reshape(4, 3), data * scalar)
print("✓ Runs correctly on 2 devices after the reshape")
  shape of x inside the device: (2, 3)
✓ Runs correctly on 2 devices after the reshape
# ✅ Modern style: jit + NamedSharding. This is the real "cut into B/N blocks automatically" behavior, no reshape needed
mesh2 = jax.make_mesh((2,), ("data",), devices=two)
data_sh = jax.device_put(data, NamedSharding(mesh2, P("data", None)))
print("Local shape per device:", data_sh.addressable_shards[0].data.shape)   # (2, 3): each device gets 2 rows

r_jit = jax.jit(scale)(data_sh, scalar)
assert data_sh.addressable_shards[0].data.shape == (2, 3)
assert same(r_jit, result) and same(r_jit, r_fixed.reshape(4, 3))
print("✓ jit+sharding matches pmap")
Local shape per device: (2, 3)
✓ jit+sharding matches pmap

out_axes=None doesn't check anything

x8 = jnp.arange(16.0).reshape(8, 2)

# Old API pmap: pmean first, then out_axes=None (correct usage)
ok = jax.pmap(lambda x: lax.pmean(x.sum(), "d"), axis_name="d", out_axes=None)(x8)

# ❌ Forgot to aggregate + out_axes=None: pmap doesn't complain and quietly returns one device's local value 0+1=1
bad = jax.pmap(lambda x: x.sum(), out_axes=None)(x8)
print("Correct (after pmean):", ok, "   forgot to aggregate:", bad)
assert float(ok) == 15.0                 # Global: the mean of each device's local sum
assert float(bad) == float(x8[0].sum())  # Silently equals device 0's local value, with no error at all
Correct (after pmean): 15.0    forgot to aggregate: 1.0
# ✅ Modern style: shard_map's out_specs=P() statically checks "is this really replicated"; without aggregation it errors out
xs16 = jax.device_put(jnp.arange(16.0), NamedSharding(mesh, P("data")))
ok_sm = jax.shard_map(lambda x: lax.pmean(x.sum(), "data"), mesh=mesh, in_specs=P("data"), out_specs=P())(xs16)
assert same(ok_sm, ok)
try:
    jax.shard_map(lambda x: x.sum(), mesh=mesh, in_specs=P("data"), out_specs=P())(xs16)
    raise AssertionError("Should have raised an error")
except ValueError as e:
    print("shard_map error (a good thing):", str(e).splitlines()[0][:110], "...")
shard_map error (a good thing): shard_map applied to the function '_rem_singleton' was given out_specs which require replication which can't b ...

in_axes with pytrees and non-zero axes

In practice, parameters are usually nested dicts and data comes as tuples. The structure of in_axes has to be a prefix match of the argument structure:

# Old API pmap: params replicated as a whole, x mapped along axis 1
params = {"w": jnp.ones((3,)), "b": jnp.float32(0.5)}
xb = jnp.arange(3 * 8, dtype=jnp.float32).reshape(3, 8)   # device dimension on axis 1

def affine(p, x):
    return x * p["w"] + p["b"]

out_p = jax.pmap(affine, in_axes=(None, 1))(params, xb)     # device i gets xb[:, i], shape (3,)
print("pmap output shape:", out_p.shape)                         # (8, 3): device dimension stacked on axis 0

# You can also specify each entry of the dict separately: w replicated, b mapped
params2 = {"w": jnp.ones((3,)), "b": jnp.arange(8.0)}
out_p2 = jax.pmap(affine, in_axes=({"w": None, "b": 0}, 1))(params2, xb)
pmap output shape: (8, 3)
# ✅ Modern style: jit + NamedSharding. Split dimension 1 of xb over the 'data' axis and replicate the parameters
xb_sh = jax.device_put(xb, NamedSharding(mesh, P(None, "data")))
out_m = jax.jit(lambda p, x: (x * p["w"][:, None] + p["b"]).T)(params, xb_sh)
assert same(out_m, out_p)

# b mapped inside the dict: b is split over 'data' as well
b_sh = jax.device_put(params2["b"], NamedSharding(mesh, P("data")))
out_m2 = jax.jit(lambda w, b, x: x.T * w + b[:, None])(params2["w"], b_sh, xb_sh)
assert same(out_m2, out_p2)
print("✓ Modern versions of both pytree in_axes examples match pmap")
✓ Modern versions of both pytree in_axes examples match pmap

5.4 Device meshes and axis names

Splitting data along a single axis is enough for basic data parallelism. It stops being enough when the hardware topology is more complex or when you want more advanced parallel strategies. That's where device meshes and named axes come in.

Concept: device meshes

Hardware accelerators can be arranged logically as a mesh, not just a flat list. With 8 devices, for example:

1-D (8,)                      2-D (4, 2)             2-D (2, 4)
[0 1 2 3 4 5 6 7]             [[0 1]                [[0 1 2 3]
                               [2 3]                 [4 5 6 7]]
                               [4 5]
                               [6 7]]

A plain pmap call treats the devices as a 1-D mesh. When you need to coordinate communication between certain groups of devices, or combine model parallelism with data parallelism, you need an explicit multi-dimensional mesh. Modern JAX creates one with jax.make_mesh.

⚠️ Note: the explicit mesh tool from older material, jax.experimental.maps.Mesh, no longer works: the jax.experimental.maps module was removed in 0.12 (ModuleNotFoundError). Use jax.make_mesh or jax.sharding.Mesh instead.

Concept: named axes

Which mesh dimension corresponds to the split data? How do devices laid out along one mesh dimension coordinate? The answer is to give the axes names. Named axes have three benefits:

  1. Readability: for example 'data_parallel_axis' for data parallelism and 'model_parallel_axis' for model parallelism;
  2. Abstraction: the code refers only to logical names and doesn't care whether there are 4 GPUs or 8 TPU cores underneath;
  3. Collective operations: lax.psum(x, 'data_parallel_axis') uses the name to say "which group of devices to communicate with".

pmap uses its axis_name= argument to name its single mesh dimension; in the modern style, the axis names are the axis_names of jax.make_mesh. Even with only one parallel dimension, it's a good idea to name it. Once devices need to communicate, the name is required.

The pmap way

# Course example: list the devices (output depends on the hardware)
print(jax.devices())
# Single CPU: [CpuDevice(id=0)]   Multi-GPU: [CudaDevice(id=0), ...] (shown as GpuDevice in older JAX)   TPU: [TpuDevice(id=0), ...]
[CpuDevice(id=0), CpuDevice(id=1), CpuDevice(id=2), CpuDevice(id=3), CpuDevice(id=4), CpuDevice(id=5), CpuDevice(id=6), CpuDevice(id=7)]
# Course example (old API pmap): name the parallel axis with axis_name
def simple_computation(x):
    return x * 2

data = jnp.arange(4 * 5).reshape((4, 5))   # one row per device (4 rows in total)

# pmap implicitly creates a 1-D mesh of size 4; axis 0 of data maps to the device axis named 'data_parallel_axis'
parallel_computation = jax.pmap(simple_computation, axis_name="data_parallel_axis", in_axes=0)
result = parallel_computation(data)
print(result.shape)   # (4, 5)
print(result)
(4, 5)
[[ 0  2  4  6  8]
 [10 12 14 16 18]
 [20 22 24 26 28]
 [30 32 34 36 38]]
# ✅ Modern style: shard_map, with the axis name written directly on the mesh
mesh4 = jax.make_mesh((4,), ("data_parallel_axis",), devices=jax.devices()[:4])
result_sm = jax.shard_map(simple_computation, mesh=mesh4,
                          in_specs=P("data_parallel_axis"), out_specs=P("data_parallel_axis"))(
    jax.device_put(data, NamedSharding(mesh4, P("data_parallel_axis"))))
assert same(result_sm, result)
print("✓ shard_map matches pmap; mesh:", mesh4)
✓ shard_map matches pmap; mesh: Mesh('data_parallel_axis': 4, axis_types=(Explicit,))
# ❌ Old style (outdated): jax.experimental.maps was removed in 0.12
try:
    from jax.experimental.maps import Mesh as OldMesh
    raise AssertionError("Import should not have succeeded")
except ModuleNotFoundError as e:
    print("Actual result:", e)
Actual result: No module named 'jax.experimental.maps'
# ✅ Modern style: jax.make_mesh (recommended, arranges devices to match the hardware topology) or jax.sharding.Mesh (manual arrangement)
mesh2d = jax.make_mesh((4, 2), ("data", "model"))
mesh2d_manual = Mesh(np.array(jax.devices()).reshape(4, 2), ("data", "model"))
print(mesh2d)
print("Device layout:\n", mesh2d.devices)
assert mesh2d.shape == mesh2d_manual.shape == {"data": 4, "model": 2}

A = jnp.arange(8 * 4, dtype=jnp.float32).reshape(8, 4)
for spec, local in [(P("data", "model"), (2, 2)), (P("data", None), (2, 4)), (P(None, "model"), (8, 2)),
                    (P(), (8, 4)), (P(("data", "model"), None), (1, 4))]:
    a = jax.device_put(A, NamedSharding(mesh2d, spec))
    assert a.addressable_shards[0].data.shape == local
    print(f"{str(spec):28s} local shape per device {local}   type {jax.typeof(a)}")
Mesh('data': 4, 'model': 2, axis_types=(Explicit, Explicit))
Device layout:
 [[CpuDevice(id=0) CpuDevice(id=1)]
 [CpuDevice(id=2) CpuDevice(id=3)]
 [CpuDevice(id=4) CpuDevice(id=5)]
 [CpuDevice(id=6) CpuDevice(id=7)]]
P('data', 'model')           local shape per device (2, 2)   type float32[8@data,4@model]
P('data', None)              local shape per device (2, 4)   type float32[8@data,4]
P(None, 'model')             local shape per device (8, 2)   type float32[8,4@model]
P()                          local shape per device (8, 4)   type float32[8,4]
P(('data', 'model'), None)   local shape per device (1, 4)   type float32[8@(data,model),4]

🔍 Extra: jax.make_mesh defaults to Explicit axes in 0.12

⚪ Optional: read this when shard_map or jit gives you a sharding error like "does not match the specs".

jax.make_mesh now creates AxisType.Explicit axes by default, so sharding information becomes part of the array's type (that's the float32[8@data,4@model] above). If you pass an unsharded array straight to a shard_map with in_specs=P('data'), you get "does not match the specs of the input". There are two fixes: device_put the array onto the matching sharding first, or pass axis_types=(AxisType.Auto,) when building the mesh.

mesh_e = jax.make_mesh((8,), ("data",))                              # Explicit by default
mesh_a = jax.make_mesh((8,), ("data",), axis_types=(AxisType.Auto,))  # Auto, set explicitly
assert mesh_e.axis_types == (AxisType.Explicit,)

f = lambda x: lax.psum(x.sum(), "data")
raw = jnp.arange(16.0)        # no device_put, so it's unsharded
try:
    jax.shard_map(f, mesh=mesh_e, in_specs=P("data"), out_specs=P())(raw)
    raise AssertionError("Should have raised an error")
except ValueError as e:
    print("Explicit mesh + unsharded input error:", str(e)[:100], "...")

r1 = jax.shard_map(f, mesh=mesh_e, in_specs=P("data"), out_specs=P())(jax.device_put(raw, NamedSharding(mesh_e, P("data"))))
r2 = jax.shard_map(f, mesh=mesh_a, in_specs=P("data"), out_specs=P())(raw)
assert float(r1) == float(r2) == 120.0
print("✓ Fix 1 (device_put first) and fix 2 (Auto mesh) both give 120")
Explicit mesh + unsharded input error: in_specs passed to shard_map: P('data',) does not match the specs of the input: P(None,) for arg: fl ...
✓ Fix 1 (device_put first) and fix 2 (Auto mesh) both give 120

🔍 Extra: inspecting the sharding layout

⚪ Optional: read this if you want to see how an array is split across devices.

jax.debug.visualize_array_sharding(x) draws which block of the array each device holds. It depends on the rich library, though, and rich is not a jax dependency. In this environment rich came along when flax was installed, so the drawing below works. In an environment with only jax installed, it raises ValueError: visualize_sharding requires rich. That's why there's also a replacement function below that doesn't need rich:

x2d = jax.device_put(A, NamedSharding(mesh2d, P("data", "model")))
try:
    jax.debug.visualize_array_sharding(x2d)
except Exception as e:
    print("visualize_array_sharding unavailable:", e)

def show_sharding(x):
    """A simple version without rich: list the slice ranges each device holds."""
    print(f"array shape {x.shape}, spec = {x.sharding.spec}")
    for dev, idx in sorted(x.sharding.devices_indices_map(x.shape).items(), key=lambda kv: kv[0].id):
        rng = ", ".join(f"{s.start or 0}:{s.stop if s.stop is not None else n}" for s, n in zip(idx, x.shape))
        print(f"  {dev}: [{rng}]")

show_sharding(x2d)
                  
  CPU 0    CPU 1  
                  
                  
  CPU 2    CPU 3  
                  
                  
  CPU 4    CPU 5  
                  
                  
  CPU 6    CPU 7  
                  
array shape (8, 4), spec = P('data', 'model')
  cpu:0: [0:2, 0:2]
  cpu:1: [0:2, 2:4]
  cpu:2: [2:4, 0:2]
  cpu:3: [2:4, 2:4]
  cpu:4: [4:6, 0:2]
  cpu:5: [4:6, 2:4]
  cpu:6: [6:8, 0:2]
  cpu:7: [6:8, 2:4]

Nested pmap → 2-D mesh

Older tutorials often build a 2-D device mesh with pmap(pmap(f, axis_name='j'), axis_name='i'), which is why axis_name matters so much with nested pmap.

⚠️ Note: now that pmap is implemented on top of shard_map, nested pmap raises an error in 0.12. Use a 2-D mesh + shard_map instead.

# ❌ Old style (outdated): nested pmap errors in 0.12
xs = jnp.ones((2, 4))
try:
    jax.pmap(jax.pmap(lambda x: lax.psum(x, "j"), axis_name="j"), axis_name="i")(xs)
    raise AssertionError("Should not have succeeded")
except ValueError as e:
    print("Actual error:", str(e)[:130])
Actual error: The context mesh AbstractMesh('i': 2, axis_types=(Manual,), device_kind=cpu, num_cores=None, platform=cpu) should match the mesh p
# ✅ Modern style: a (2, 4) mesh + shard_map; collectives can run along just one axis or along several at once
mesh_ij = jax.make_mesh((2, 4), ("i", "j"))
xs_sh = jax.device_put(xs, NamedSharding(mesh_ij, P("i", "j")))
psum_j = jax.shard_map(lambda x: lax.psum(x, "j"), mesh=mesh_ij, in_specs=P("i", "j"), out_specs=P("i", None))
psum_ij = jax.shard_map(lambda x: lax.psum(x, ("i", "j")), mesh=mesh_ij, in_specs=P("i", "j"), out_specs=P())
assert same(psum_j(xs_sh), [[4.0], [4.0]])   # each row adds up four 1s
assert same(psum_ij(xs_sh), [[8.0]])         # adds up all eight 1s
print("✓ sum along j:", psum_j(xs_sh).ravel(), "  sum along (i, j):", psum_ij(xs_sh).ravel())
✓ sum along j: [4. 4.]   sum along (i, j): [8.]

5.5 Collective operations (lax.psum, lax.pmean, etc.)

Every device runs the same function (the "single program" in SPMD) on its own slice of data (the "multiple data"). But the devices still need to communicate and aggregate. In distributed training, for example, each device computes gradients from its local batch, and those gradients from all devices must be combined before the parameters are updated. That's what collective operations are for.

They all live in the jax.lax module and can only run inside a transformation that binds the corresponding axis name. With multiple devices, that means pmap / shard_map. A vmap with axis_name also works, which is handy for checking collective logic on a single device first (see the example in the cheat sheet of Chapter 4).

Concept: psum and pmean

Key points about lax.psum (parallel sum):

  1. Input: the value computed locally on each device;
  2. Operation: add up the values from all participating devices along the axis given by axis_name;
  3. axis_name: a string that ties the collective to the matching parallel axis (pmap's axis_name=, or an axis name on the shard_map mesh). The two must match;
  4. Output: every device gets the same total.
device0: 3  ─┐                  ┌─▶ device0: 66
device1: 12 ─┤                  ├─▶ device1: 66
device2: 21 ─┼─▶  Σ = 66  ──────┼─▶ device2: 66
device3: 30 ─┘                  └─▶ device3: 66

lax.pmean takes the mean, which is the same as lax.psum(value, axis_name) / N:

$$ \mathrm{pmean}(x) = \frac{1}{N}\sum_{i=1}^{N} x_i $$

In data-parallel training, it's most often used to average gradients.

What's different in the modern style: in shard_map each device sees a local block with its dimensions kept, so you first jnp.sum within the block and then psum for the global sum. shard_map also tracks whether each value "varies across devices" (vma). The result of psum/pmean is invariant, so you can output a single copy with out_specs=P(); in jax.typeof, device-varying values carry a {V:axis_name} marker. With jit + sharding you don't even write psum: jnp.sum is already the global sum, and the compiler inserts the cross-device reduction for you.

The pmap way: psum

# Course example (old API pmap): lax.psum
num_devices = 4   # the example is written for 4 devices (this machine has 8; pmap uses only the first 4)

def calculate_local_sum(x):
    local_value = jnp.sum(x)                                  # each device first sums its local data
    total_sum = lax.psum(local_value, axis_name="devices")    # sum across all devices
    return total_sum

data = jnp.arange(num_devices * 3).reshape((num_devices, 3))
# device 0: [0 1 2] -> 3; device 1: [3 4 5] -> 12; device 2: [6 7 8] -> 21; device 3: [9 10 11] -> 30

pmapped_calculate_sum = jax.pmap(calculate_local_sum, axis_name="devices")   # axis_name must match the one in psum
result_psum = pmapped_calculate_sum(data)
print(result_psum)
assert same(result_psum, [66] * 4)   # 3 + 12 + 21 + 30 = 66
[66 66 66 66]
# ✅ Modern style: shard_map + psum (and jit automatic parallelism)
mesh4 = jax.make_mesh((4,), ("devices",), devices=jax.devices()[:4])
data_sh = jax.device_put(data, NamedSharding(mesh4, P("devices")))

def local_sum_then_psum(x):          # x: the (1, 3) block on each device
    local = jnp.sum(x)
    print("  type of local:", jax.typeof(local), "  after psum:", jax.typeof(lax.psum(local, "devices")))
    return lax.psum(local, "devices")

psum_one = jax.jit(jax.shard_map(local_sum_then_psum, mesh=mesh4, in_specs=P("devices"), out_specs=P()))(data_sh)
# To get one copy per device like pmap: keep one dimension in the output and use out_specs=P('devices')
psum_each = jax.shard_map(lambda x: lax.psum(jnp.sum(x), "devices")[None],
                          mesh=mesh4, in_specs=P("devices"), out_specs=P("devices"))(data_sh)
psum_jit = jax.jit(jnp.sum)(data_sh)   # jit automatic parallelism: just write the global sum

assert int(psum_one) == int(psum_jit) == 66 and same(psum_each, result_psum)
print("✓ shard_map(out_specs=P()) =", psum_one, "; one copy per device =", psum_each, "; jit =", psum_jit)
  type of local: int32[]{V:devices}   after psum: int32[]
✓ shard_map(out_specs=P()) = 66 ; one copy per device = [66 66 66 66] ; jit = 66

The pmap way: pmean

# Course example (old API pmap): lax.pmean
def calculate_local_mean(x):
    local_value = jnp.mean(x)
    global_mean = lax.pmean(local_value, axis_name="devices")
    return global_mean

data = jnp.arange(num_devices * 3, dtype=jnp.float32).reshape((num_devices, 3))
# local means: 1.0, 4.0, 7.0, 10.0
pmapped_calculate_mean = jax.pmap(calculate_local_mean, axis_name="devices")
result_pmean = pmapped_calculate_mean(data)
print(result_pmean)
assert same(result_pmean, [5.5] * 4)   # (1 + 4 + 7 + 10) / 4 = 5.5
[5.5 5.5 5.5 5.5]
# ✅ Modern style: shard_map + pmean, and jit automatic parallelism
data_sh = jax.device_put(data, NamedSharding(mesh4, P("devices")))
pmean_sm = jax.jit(jax.shard_map(lambda x: lax.pmean(jnp.mean(x), "devices"),
                                 mesh=mesh4, in_specs=P("devices"), out_specs=P()))(data_sh)
pmean_jit = jax.jit(jnp.mean)(data_sh)
assert float(pmean_sm) == float(pmean_jit) == float(result_pmean[0]) == 5.5
print("✓ shard_map pmean =", pmean_sm, "; jit mean =", pmean_jit)
✓ shard_map pmean = 5.5 ; jit mean = 5.5

Concept: other common collectives

  • lax.pmax(value, axis_name) / lax.pmin(value, axis_name): maximum / minimum across devices;
  • lax.all_gather(value, axis_name): gathers value from all devices and stacks them along a new leading axis, so every device gets the full set of values;
  • lax.axis_index(axis_name): the index of the current device along this axis (used in 5.7, introduced here);
  • lax.ppermute(value, axis_name, perm): point-to-point sends given as (source, destination) pairs, for example a ring shift (essential for ring communication and pipeline parallelism);
  • A small trick: lax.psum(1, axis_name) equals the number of devices on that axis.

Using collectives correctly:

  1. They must be called inside a pmap / shard_map (or a vmap with axis_name) that binds the axis name; calling them outside raises NameError: unbound axis name;
  2. The axis name used by the collective must match the name of the parallel axis;
  3. A collective is a synchronization point: computation can only move on once every device has reached it;
  4. The value being aggregated should have the same type and shape on every device (psum and pmean need numeric types).

The pmap way

# Old API pmap: pmax / pmin / all_gather / axis_index / ppermute
n = 8
xv = jnp.array([3.0, 7.0, 1.0, 5.0, 2.0, 8.0, 6.0, 4.0])

def collectives(v):
    return {
        "pmax": lax.pmax(v, "i"),
        "pmin": lax.pmin(v, "i"),
        "all_gather": lax.all_gather(v, "i"),        # every device gets the full length-8 vector
        "axis_index": lax.axis_index("i"),           # current device index 0..7
        "axis_size": lax.psum(1, "i"),               # psum(1) = number of devices on the axis
        "ppermute": lax.ppermute(v, "i", perm=[(j, (j + 1) % n) for j in range(n)]),   # device j sends to j+1
    }

out_pmap = jax.pmap(collectives, axis_name="i")(xv)
for k, v in out_pmap.items():
    print(f"{k:10s}", v if v.ndim == 1 else f"shape {v.shape}, on device 0: {v[0]}")
all_gather shape (8, 8), on device 0: [3. 7. 1. 5. 2. 8. 6. 4.]
axis_index [0 1 2 3 4 5 6 7]
axis_size  [8 8 8 8 8 8 8 8]
pmax       [8. 8. 8. 8. 8. 8. 8. 8.]
pmin       [1. 1. 1. 1. 1. 1. 1. 1.]
ppermute   [4. 3. 7. 1. 5. 2. 8. 6.]
# ✅ Modern style: shard_map. Each device's local block is (1,); out_specs=P('i') stitches the output back to (8,) for comparison with pmap
mesh_i = jax.make_mesh((8,), ("i",))
xv_sh = jax.device_put(xv, NamedSharding(mesh_i, P("i")))

@jax.jit
@jax.shard_map(mesh=mesh_i, in_specs=P("i"), out_specs=P("i"))
def collectives_sm(v):                       # v: (1,)
    return {
        "pmax": lax.pmax(v, "i"),
        "pmin": lax.pmin(v, "i"),
        "all_gather": lax.all_gather(v, "i", tiled=True)[None, :],   # (1, 8), becomes (8, 8) once stitched back
        "axis_index": lax.axis_index("i")[None],
        "axis_size": jnp.full((1,), lax.axis_size("i")),
        "ppermute": lax.ppermute(v, "i", perm=[(j, (j + 1) % n) for j in range(n)]),
    }

out_sm = collectives_sm(xv_sh)
for k in out_pmap:
    assert same(out_sm[k], out_pmap[k]), k
print("✓ The shard_map versions of all 6 collectives match pmap exactly")

# The jit automatic-parallelism equivalents: write global max/min directly; all_gather just means "make the array replicated"
assert float(jax.jit(jnp.max)(xv_sh)) == 8.0 and float(jax.jit(jnp.min)(xv_sh)) == 1.0
gathered = jax.reshard(xv_sh, NamedSharding(mesh_i, P()))   # passing a bare P() requires with jax.set_mesh(mesh_i): first
assert gathered.sharding.is_fully_replicated and same(gathered, xv)
print("✓ jit: jnp.max/jnp.min work across devices automatically; resharding to a replicated sharding is the same as all_gather")
✓ The shard_map versions of all 6 collectives match pmap exactly
✓ jit: jnp.max/jnp.min work across devices automatically; resharding to a replicated sharding is the same as all_gather

5.6 Combining pmap with other transformations

JAX transformations are composable. Parallel transformations are rarely used alone; they're usually paired with jit, grad and vmap.

Concepts

Combining with jit: you usually don't need to write pmap(jit(f)), because pmap already compiles with XLA. The exception is when the jitted function is just one component inside the main parallel function and you want to control its compilation separately. In the modern style, jit itself is the entry point for parallelism, so the two become one.

Combining with grad (data-parallel training): split a batch across several devices, let each device compute its own gradients, then combine them:

  1. Define a loss function that takes the parameters and a batch of data;
  2. Get a gradient function with jax.grad / jax.value_and_grad;
  3. Apply a parallel transformation to the function that computes the gradients;
  4. Inside the parallel function, average the gradients across devices with lax.pmean, so every device ends up with the same global average gradient:

$$ g = \frac{1}{N}\sum_{i=1}^{N} \nabla_\theta L(\theta;\ \text{batch}_i) $$

When every device has the same batch size, this equals the gradient over the whole batch. With jit + sharding you skip steps 3 and 4 entirely: just call jax.grad on sharded data, and the compiler inserts the cross-device gradient reduction for you.

⚠️ When you write step 4 with shard_map, copying pmap's pmean(grad(...)) as is gives you gradients N times too large; see the next special-topic section.

Combining with vmap: vmap is usually used inside the parallel function, for vectorization within a device (vectorization, not quantization). pmap handles SPMD across devices, and vmap handles vectorization within a single device.

Nesting order: vmap and grad sit on the inside and define the core computation; the parallel transformation (pmap / shard_map) sits on the outside and handles cross-device execution and collective communication. For example pmap(grad(my_loss)) or pmap(vmap(my_kernel)).

The pmap way: with jit

# ❌ Wrong: jax.device_put(data, devices), trying to spread data over a list of devices
num_devices = 2
devices = jax.local_devices()[:num_devices]
data = np.arange(8.0).reshape(num_devices, -1)   # (2, 4)
try:
    sharded_data = jax.device_put(data, devices)
    raise AssertionError("Should not have succeeded")
except ValueError as e:
    print("Actual error:", str(e).splitlines()[0])
# Why: device_put's second argument is either a single device / Sharding or a pytree matching the data; a list of devices does not "shard" anything
Actual error: device_put device specification must be a tree prefix of the corresponding value; 
# ✅ Fix: hand the numpy array straight to pmap (it does the distribution itself), and warm up before timing
def complex_computation(x):
    y = jnp.sin(x) * jnp.cos(x)
    z = jnp.tanh(y) + jnp.sqrt(jnp.abs(x))
    return z * 2.0

pmap_complex_computation = jax.pmap(complex_computation, devices=devices)
jit_then_pmap = jax.pmap(jax.jit(complex_computation), devices=devices)

def bench(fn, x, reps=50):
    fn(x).block_until_ready()                  # warm-up: exclude the first compilation time
    t0 = time.perf_counter()
    for _ in range(reps):
        fn(x).block_until_ready()
    return (time.perf_counter() - t0) / reps

result_pj = pmap_complex_computation(data)
print(f"pmap        : {bench(pmap_complex_computation, data) * 1e6:.1f} µs/call")
print(f"pmap(jit(f)): {bench(jit_then_pmap, data) * 1e6:.1f} µs/call (the outer pmap already compiles, so an explicit jit brings no gain; microsecond differences come from dispatch overhead and measurement noise)")
assert same(result_pj, jit_then_pmap(data))
pmap        : 81.4 µs/call
pmap(jit(f)): 90.9 µs/call (the outer pmap already compiles, so an explicit jit brings no gain; microsecond differences come from dispatch overhead and measurement noise)

⚠️ Note: when comparing the speed of pmap(f) and pmap(jit(f)), including the first compilation time makes the comparison meaningless. The right way is to warm up once (block_until_ready()) and then average over several runs, as above. Also, timings on simulated devices can't reflect real speedups in the first place.

# ✅ Modern style: jit + NamedSharding
mesh_dev2 = jax.make_mesh((2,), ("devices",), devices=devices)
data_sh = jax.device_put(data, NamedSharding(mesh_dev2, P("devices")))   # the correct "sharded device_put"
result_jit = jax.jit(complex_computation)(data_sh)
assert same(result_jit, result_pj, atol=1e-6)
print("✓ jit+sharding matches pmap; output sharding:", result_jit.sharding.spec)
✓ jit+sharding matches pmap; output sharding: P('devices', None)

The pmap way: with grad

# Course example (old API pmap): pmap + value_and_grad + pmean, one data-parallel gradient computation
def predict(params, x):
    w, b = params
    return w * x + b                     # linear model y = w x + b

def loss_fn(params, x_batch, y_batch):
    predictions = predict(params, x_batch)
    error = predictions - y_batch
    return jnp.mean(error ** 2)          # mean squared error

value_and_grad_fn = jax.value_and_grad(loss_fn)

def parallel_update_step(params, x_shards, y_shards):
    loss, grads = value_and_grad_fn(params, x_shards, y_shards)   # each device computes local gradients
    avg_grads = lax.pmean(grads, axis_name="devices")             # average gradients across devices
    avg_loss = lax.pmean(loss, axis_name="devices")
    return avg_loss, avg_grads

# params replicated (None), x / y mapped along axis 0; both outputs are already aggregated, so out_axes=None
pmap_update_step = jax.pmap(parallel_update_step, axis_name="devices",
                            in_axes=(None, 0, 0), out_axes=(None, None), devices=devices)

params = (jnp.array(2.0), jnp.array(1.0))                 # (w, b)
x_data = np.arange(8.0).reshape(num_devices, -1)          # (2, 4)
# Generate the noise with jax.random.key (not an unseeded np.random.randn) so results are reproducible
noise = jax.random.normal(jax.random.key(0), x_data.shape) * 0.5
y_data = 3 * x_data + 2 + np.asarray(noise)               # y = 3x + 2 + noise

avg_loss, avg_grads = pmap_update_step(params, x_data, y_data)
print(f"Average loss across devices: {avg_loss:.4f}")
print(f"Average gradient (dw, db): ({avg_grads[0]:.4f}, {avg_grads[1]:.4f})")

# Check: matches the single-device full-batch computation
full_loss, full_grads = value_and_grad_fn(params, x_data.ravel(), y_data.ravel())
assert same(avg_loss, full_loss) and same(avg_grads[0], full_grads[0]) and same(avg_grads[1], full_grads[1])
Average loss across devices: 25.7115
Average gradient (dw, db): (-41.6569, -9.2923)
# ✅ Modern style 1: jit + NamedSharding. Call value_and_grad directly on sharded data, with no collectives at all
xs_sh = jax.device_put(x_data.ravel(), NamedSharding(mesh_dev2, P("devices")))
ys_sh = jax.device_put(y_data.ravel(), NamedSharding(mesh_dev2, P("devices")))
l1, g1 = jax.jit(value_and_grad_fn)(params, xs_sh, ys_sh)

# ✅ Modern style 2: shard_map. Differentiate the "global loss pmean(local loss)" (don't copy pmean(grad); see the next special topic)
@jax.jit
@jax.shard_map(mesh=mesh_dev2, in_specs=(P(), P("devices"), P("devices")), out_specs=(P(), P()))
def update_step_sm(params, x, y):
    return jax.value_and_grad(lambda p: lax.pmean(loss_fn(p, x, y), "devices"))(params)

l2, g2 = update_step_sm(params, xs_sh, ys_sh)
for l, g in [(l1, g1), (l2, g2)]:
    assert same(l, avg_loss) and same(g[0], avg_grads[0]) and same(g[1], avg_grads[1])
print("✓ jit+sharding, shard_map and pmap give exactly the same loss and gradients")
✓ jit+sharding, shard_map and pmap give exactly the same loss and gradients

The pmap way: with vmap

# Course example (old API pmap): using vmap inside pmap
def process_item(item):
    return jnp.tanh(item) * 2.0

def process_batch_of_items(batch):      # runs on each device
    vectorized_processor = jax.vmap(process_item)
    return vectorized_processor(batch)

pmap_process_batches = jax.pmap(process_batch_of_items, in_axes=0)

data = np.arange(2 * 3 * 4.0).reshape(num_devices, 3, 4)   # (2, 3, 4)
result_pv = pmap_process_batches(data)
print("Input shape per device:", data.shape[1:])   # (3, 4)
print("Output shape:", result_pv.shape)            # (2, 3, 4)
# pmap distributes along the outermost dimension of size 2; each device gets (3, 4), and the inner vmap vectorizes over the axis of size 3
Input shape per device: (3, 4)
Output shape: (2, 3, 4)
# ✅ Modern style: jit(vmap(...)) + sharded input. The outer vmap replaces pmap's mapping, and the sharding decides which device holds what
data_sh = jax.device_put(data, NamedSharding(mesh_dev2, P("devices")))
result_mv = jax.jit(jax.vmap(process_batch_of_items))(data_sh)
assert same(result_mv, result_pv)
print("✓ Same result; local shape per device:", result_mv.addressable_shards[0].data.shape)
✓ Same result; local shape per device: (1, 3, 4)

⚠️ Special topic: gradients become N times larger in shard_map (the top pmap → shard_map migration pitfall)

🟢 Must-read

When you translate a pmap training step "one-to-one" into shard_map, the easiest mistake to make is that the gradients quietly get multiplied by the number of devices $N$. Nothing errors out and the loss still goes down (it's as if the learning rate were multiplied by $N$), so it's hard to notice. Many tutorials don't mention it.

1. The symptom

N_DEV = jax.device_count()
mesh8 = jax.make_mesh((N_DEV,), ("data",))

def loss_w(w, x):
    return jnp.mean((x * w - 1.0) ** 2)

xs = jnp.arange(16.0) / 16
xs_sh = jax.device_put(xs, NamedSharding(mesh8, P("data")))
w0 = jnp.array(0.5)

def smap(f):
    return jax.jit(jax.shard_map(f, mesh=mesh8, in_specs=(P(), P("data")), out_specs=P()))

ref = jax.grad(loss_w)(w0, xs)                                  # single-device global gradient (the correct value)
g_pmap = jax.pmap(lambda w, x: lax.pmean(jax.grad(loss_w)(w, x), "d"),
                  axis_name="d", in_axes=(None, 0))(w0, xs.reshape(N_DEV, -1))[0]
# ❌ Copying the pmap pattern: pmean after grad
g_naive = smap(lambda w, x: lax.pmean(jax.grad(loss_w)(w, x), "data"))(w0, xs_sh)

print(f"single-device correct value: {float(ref):.6f}")
print(f"pmap                       : {float(g_pmap):.6f}")
print(f"shard_map, copied as is    : {float(g_naive):.6f}   ratio = {float(g_naive / ref):.4f}")
assert same(g_pmap, ref)
assert np.isclose(float(g_naive / ref), N_DEV)    # exactly the number of devices
single-device correct value: -0.634766
pmap                       : -0.634766
shard_map, copied as is    : -5.078125   ratio = 8.0000

2. Why it happens

  • In pmap, an argument with in_axes=None is treated as an ordinary local value on each device, so grad gives the local gradient, and you need pmean;
  • In shard_map (with the default check_vma=True), an argument with in_specs=P() is marked invariant, while the data x is varying. When the two are multiplied, JAX automatically inserts a pvary that turns the invariant value into a varying one;
  • The transpose of pvary (that is, its backward-pass rule) is psum. The reasoning is simple: the invariant parameter $w$ is shared by all devices, so its gradient naturally has to add up the contributions from every device. So what jax.grad returns is already $\sum_i \nabla L_i$;
  • Taking pmean of a value that is already invariant doesn't change it. The end result is:

$$ \texttt{pmean}\big(\texttt{grad}(L_i)\big) = \sum_{i=1}^N \nabla L_i = N \cdot \nabla L, \qquad L = \frac{1}{N}\sum_i L_i $$

Evidence: look at the jaxpr of the gradient function. The pvary[axes=('data',)] a at the start turns the parameter into a varying value, and the psum_invariant[axes=('data',)] at the end is the sum JAX inserted automatically:

import re
jaxpr_text = str(jax.make_jaxpr(
    jax.shard_map(lambda w, x: jax.grad(loss_w)(w, x), mesh=mesh8, in_specs=(P(), P("data")), out_specs=P())
)(w0, xs_sh))
jaxpr_text = re.sub(r"\x1b\[[0-9;]*m", "", jaxpr_text)   # strip terminal color codes
lines = jaxpr_text.splitlines()
for ln in lines[:3] + ["    ..."] + [l for l in lines if "psum" in l]:
    print(ln)
assert "pvary" in jaxpr_text and "psum_invariant" in jaxpr_text
let jaxpr = { lambda ; a:f32[] b:f32[2]{V:data}. let
    c:f32[]{V:data} = pvary[axes=('data',)] a
    d:f32[]{V:data} = convert_element_type[new_dtype=float32 weak_type=False] c
    ...
    v:f32[] = psum_invariant[axes=('data',)] u

3. Three correct ways to write it

# ✅ Option 1 (recommended): differentiate the "global loss" pmean(local loss). Clearest meaning, and it doesn't depend on the details of the automatic psum
g1 = smap(lambda w, x: jax.grad(lambda w: lax.pmean(loss_w(w, x), "data"))(w))(w0, xs_sh)

# ✅ Option 2: grad already summed automatically, so divide by the number of devices
g2 = smap(lambda w, x: jax.grad(loss_w)(w, x) / lax.axis_size("data"))(w0, xs_sh)

# ✅ Option 3: first use pcast to make the parameter explicitly varying, restoring pmap's "local gradient" meaning, then pmean
g3 = smap(lambda w, x: lax.pmean(jax.grad(loss_w)(lax.pcast(w, "data", to="varying"), x), "data"))(w0, xs_sh)

# Also: jit + sharding doesn't have this problem at all
g4 = jax.jit(jax.grad(loss_w))(w0, xs_sh)

for name, g in [("Option 1", g1), ("Option 2", g2), ("Option 3", g3), ("jit+sharding", g4)]:
    assert same(g, ref), name
    print(f"✓ {name:12s} {float(g):.6f}")
✓ Option 1     -0.634766
✓ Option 2     -0.634766
✓ Option 3     -0.634766
✓ jit+sharding -0.634766

4. The rule in one sentence

In shard_map, taking grad with respect to a replicated parameter with in_specs=P() already gives you the sum of the gradients from all devices. Either differentiate the global loss after pmean, or don't pmean the gradient again (divide by N instead).

You can also turn this machinery off with check_vma=False, which restores pmap-like semantics. But then you also lose the replication check on out_specs, so it's not recommended.

5. pmap → shard_map migration checklist

#pmapshard_mapWhat to check
1in_axes=0in_specs=P('data')No more reshaping data to (N, ...); (B, ...) is split evenly into (B/N, ...)
2in_axes=Nonein_specs=P()The parameter becomes invariant, and grad with respect to it does an automatic psum
3out_axes=0out_specs=P('data')Local blocks are concatenated along this dimension, not stacked along a new one
4out_axes=Noneout_specs=P()shard_map checks replication statically and errors if you didn't aggregate
5axis_name='i'jax.make_mesh((N,), ('i',))The axis name goes on the mesh; make_mesh defaults to Explicit, so device_put inputs first
6pmean(grad(loss))grad(lambda p: pmean(loss(p)))Most important: if you don't change it, you get N-times gradients
7The mapped axis is removed, x.shape == (...)The mapped axis is kept, x.shape == (B/N, ...)Check shape-dependent code in the function (such as x[0] or reshape)
8Nested pmapMulti-dimensional mesh + psum(x, 'j')Nested pmap no longer works in 0.12
9After migrating—assert the gradients against the single-device result; it's the most reliable way to catch the N-times problem

5.7 Debugging pmapped functions

Parallel programs are harder to debug than single-device ones. A bug might be in the function's logic itself, or in how data is distributed or results are collected. And parallel functions also go through XLA compilation, so they come with every headache of debugging compiled code.

Concept: common problems

1. Shape mismatches (the most common)

  • Inputs: the length of the mapped axis can't exceed the number of devices (usually you just use jax.local_device_count());
  • Outputs: the outputs from all devices are stacked along out_axes, and the shape should be what you expect;
  • Mixing up replication and sharding: an argument with None in in_axes is replicated, not split. If you set data that should be sharded to None (or the other way around), the shapes inside the device will be wrong.

⚠️ Note: the length of the axis given by in_axes doesn't have to equal the number of devices; it only needs to be ≤ the number of devices. When it's smaller, pmap uses only the first few devices (the (4, 3) example in 5.3 used only 4 of the 8 devices).

2. Collective operations

  • Axis names must match: lax.psum(x, 'my_devices') must match pmap(..., axis_name='my_devices') exactly, and typos are common;
  • Hangs: if not every device reaches the collective, the program waits forever. So don't put collectives inside branches that differ per device. For a lax.cond that contains a collective like psum, the condition must have the same value on every device (depend only on replicated values). When you branch on lax.axis_index, as in the jax.debug.print example in 5.7, the branches may only contain operations that need no communication. Tested on JAX 0.12 with 4 simulated devices: lax.cond(lax.axis_index('d') == 0, lambda u: lax.psum(u, 'd'), lambda u: u, x) hangs under both pmap and shard_map, and after about 40 seconds XLA forcibly kills the process;
  • Wrong results: if the values each device passes to a collective differ in type or shape, you may get an error, or you may get a wrong result.

3. Tracing and compilation errors

  • Abstract tracers: during tracing you can't depend on the actual values of inputs. For example, testing a JAX array (including lax.axis_index(...)) with a Python if raises TracerBoolConversionError. Use jax.lax.cond or jnp.where instead, or make the condition depend only on static arguments;
  • Side effects: side effects like a plain print or modifying an outside variable run only once, during tracing, not on every device and every call.

Concept: debugging strategies

  1. Remove the parallelism first: take one shard and run it on its own without pmap (even without jit), and fix the logic bugs first;
  2. Use fewer devices: start with 1 or 2 devices. If the error only shows up with multiple devices, the problem is most likely in distribution, collection or collectives;
  3. Check shapes carefully: before the call, check the input shapes, in_axes and jax.local_device_count(); after the call, check the output shapes;
  4. Use jax.debug.print instead of print: it prints at run time, so you can see the values on each device. Combined with lax.axis_index and lax.cond, you can print on just one device. The order of prints across devices is not guaranteed, so it's a good idea to print the device index too. jax.debug.breakpoint() pauses all devices and drops into pdb, so use it with care;
  5. Test collectives on their own: write a minimal function that only does that collective, and check its inputs, outputs and axis name;
  6. Last resort: use with jax.disable_jit(): in a limited scope; sometimes it gets you a more readable Python error (it still works for pmap in 0.12).

The pmap way: checking shapes

# ❌ Wrong: the function body uses lax.axis_index, but only `from jax import pmap` was written and lax was never imported
course_src = """
import jax
import jax.numpy as jnp
from jax import pmap

num_devices = 4
sharded_data = jnp.arange(4 * 10).reshape((num_devices, 10))
replicated_data = jnp.array(5.0)

def my_func(x, y):
    return x * y + lax.axis_index('batch')

pmapped_func = pmap(my_func, in_axes=(0, None), out_axes=0, axis_name='batch')
output = pmapped_func(sharded_data, replicated_data)
"""
try:
    exec(course_src, {})          # run this code in a clean namespace
    raise AssertionError("Should not have succeeded")
except NameError as e:
    print("Actual error: NameError -", e)
Actual error: NameError - name 'lax' is not defined
# ✅ Fix: add from jax import lax
from jax import pmap, lax

num_devices = 4   # in real code, use jax.local_device_count()
sharded_data = jnp.arange(4 * 10).reshape((num_devices, 10))
replicated_data = jnp.array(5.0)

def my_func(x, y):
    # x is sharded (shape (10,) on each device), y is replicated (a scalar)
    return x * y + lax.axis_index("batch")

pmapped_func = pmap(my_func, in_axes=(0, None), out_axes=0, axis_name="batch")

print("Sharded data shape:", sharded_data.shape)      # (4, 10)
assert sharded_data.shape[0] <= jax.local_device_count()
print("Replicated data shape:", replicated_data.shape)   # ()
output_a = pmapped_func(sharded_data, replicated_data)
print("Output shape:", output_a.shape)              # (4, 10)
assert output_a.shape == (4, 10)
assert same(output_a, sharded_data * 5.0 + jnp.arange(4)[:, None])   # row i gets device index i added
Sharded data shape: (4, 10)
Replicated data shape: ()
Output shape: (4, 10)
# ✅ Modern style: shard_map. The local block is (1, 10); lax.axis_index works the same way
mesh_b = jax.make_mesh((4,), ("batch",), devices=jax.devices()[:4])
out_sm = jax.shard_map(my_func, mesh=mesh_b, in_specs=(P("batch"), P()), out_specs=P("batch"))(
    jax.device_put(sharded_data, NamedSharding(mesh_b, P("batch"))), replicated_data)
assert same(out_sm, output_a)
print("✓ shard_map matches pmap")
✓ shard_map matches pmap

The pmap way: jax.debug.print

The original version of this example has two problems: it uses a Python if on lax.axis_index, and the expected result after psum is easy to get wrong. We first run the broken version, then show the fix.

# ❌ Wrong: using a Python if on the traced value lax.axis_index(...) == 0
def func_with_debug_print(x):
    intermediate = x * 2
    if lax.axis_index("data_axis") == 0:
        jax.debug.print("device 0 intermediate: {val}", val=intermediate)
    result = lax.psum(intermediate, axis_name="data_axis")
    jax.debug.print("device {id} result after psum: {res}", id=lax.axis_index("data_axis"), res=result)
    return result

data = jnp.arange(2 * 3).reshape((2, 3))     # [[0 1 2], [3 4 5]]
try:
    pmap(func_with_debug_print, axis_name="data_axis")(data)
    raise AssertionError("Should not have succeeded")
except jax.errors.TracerBoolConversionError as e:
    print("Actual error: TracerBoolConversionError -", str(e).splitlines()[0])
Actual error: TracerBoolConversionError - Attempted boolean conversion of traced array with shape bool[].
# ✅ Fix: use lax.cond to decide whether to print based on the device index
def func_with_debug_print_fixed(x):
    intermediate = x * 2
    # Pass intermediate to the branches explicitly as an operand (don't rely on closure capture), so this also works in shard_map
    lax.cond(lax.axis_index("data_axis") == 0,
             lambda v: jax.debug.print("device 0 intermediate: {val}", val=v),
             lambda v: None,
             intermediate)
    result = lax.psum(intermediate, axis_name="data_axis")
    jax.debug.print("device {id} result after psum: {res}", id=lax.axis_index("data_axis"), res=result)
    return result

output_b = pmap(func_with_debug_print_fixed, axis_name="data_axis")(data)
jax.effects_barrier()   # wait for all asynchronous jax.debug.print output to finish before printing the return value
print("Return value:\n", output_b)
# device 0 intermediate [0 2 4] + device 1 intermediate [6 8 10] = [6 10 14]
assert same(output_b, [[6, 10, 14], [6, 10, 14]])
assert same(output_b[0], data.sum(0) * 2)       # check the expected value with a single-device computation
device 1 result after psum: [ 6 10 14]
device 0 intermediate: [0 2 4]
device 0 result after psum: [ 6 10 14]
Return value:
 [[ 6 10 14]
 [ 6 10 14]]

⚠️ Note: after psum, both devices hold [ 6 10 14], not [ 6 8 10]. Device 0's intermediate value is [0 2 4] and device 1's is [6 8 10]; adding them element by element gives [0+6, 2+8, 4+10] = [6, 10, 14]. A common mistake is to take device 1's intermediate value as the result of the psum. Always check the expected values of parallel code against a single-device computation (like data.sum(0) * 2 above).

# ✅ Modern style: jax.debug.print / lax.cond / lax.axis_index work exactly the same way in shard_map
mesh_d = jax.make_mesh((2,), ("data_axis",), devices=jax.devices()[:2])
# Note the outer jax.jit: in 0.12, a shard_map run eagerly op by op without jit raises NotImplementedError when it hits lax.cond
out_dbg = jax.jit(jax.shard_map(lambda x: func_with_debug_print_fixed(x[0])[None],   # local block (1, 3) -> take out (3,)
                                mesh=mesh_d, in_specs=P("data_axis"), out_specs=P("data_axis")))(
    jax.device_put(data, NamedSharding(mesh_d, P("data_axis"))))
assert same(out_dbg, output_b)
print("✓ The shard_map version gives the same result:", out_dbg.tolist())
device 0 intermediate: [0 2 4]
device 0 result after psum: [ 6 10 14]
device 1 result after psum: [ 6 10 14]
✓ The shard_map version gives the same result: [[6, 10, 14], [6, 10, 14]]

🔍 Extra: what common errors look like in 0.12

⚪ Optional: look here when you're debugging parallel code and need to recognize an error message.

The most useful debugging skill is recognizing the error. Below, the most common errors from this chapter are run together in one place:

def show_err(title, fn):
    try:
        fn()
        print(f"[{title}] no error")
        return None
    except Exception as e:
        print(f"[{title}] {type(e).__name__}: {str(e).splitlines()[0][:130]}")
        return e

x8 = jnp.arange(8.0)
errs = [
    show_err("axis name mismatch          ", lambda: jax.pmap(lambda x: lax.psum(x, "dev"), axis_name="devices")(x8)),
    show_err("psum outside a parallel fn  ", lambda: lax.psum(x8, "i")),
    show_err("mapped axis 16 > 8 devices  ", lambda: jax.pmap(lambda x: x)(jnp.arange(16.0))),
    show_err("in_axes=0 on a scalar       ", lambda: jax.pmap(lambda x, y: x * y, in_axes=(0, 0))(x8, 2.0)),
    show_err("Python if on a traced value ", lambda: jax.pmap(lambda x: x if x > 0 else -x)(x8)),
]
assert all(e is not None for e in errs)
assert isinstance(errs[0], NameError) and isinstance(errs[1], NameError)

with jax.disable_jit():
    r = jax.pmap(lambda x: lax.psum(x, "i"), axis_name="i")(jnp.arange(4.0))
assert same(r, [6.0] * 4)
print("\n✓ pmap still runs under disable_jit:", r)
[axis name mismatch          ] NameError: Found an unbound axis name: dev. To fix this, please call psum under `jax.shard_map`.
[psum outside a parallel fn  ] NameError: unbound axis name: i
[mapped axis 16 > 8 devices  ] ValueError: cannot select an axis to squeeze out which has size not equal to one, got shape=(2,) and dimensions=(0,)
[in_axes=0 on a scalar       ] ValueError: One of pjit arguments with pytree key path flat_args[1] is incompatible with its sharding annotation NamedSharding(mesh=Mesh('_int
[Python if on a traced value ] TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
✓ pmap still runs under disable_jit: [6. 6. 6. 6.]

How to read these errors:

  • A misspelled axis name gives NameError: unbound axis name, and the message even mentions shard_map, which is another sign that pmap is built on shard_map underneath;
  • When the mapped axis is longer than the number of devices, 0.12 reports the low-level cannot select an axis to squeeze out..., which doesn't literally say "not enough devices". When you see this error, check the length of the mapped axis and jax.local_device_count() first.

5.8 Hands-on: parallel computation

Concepts

A parallel function follows SPMD: the same Python function runs on all devices, and each device gets a different part of the input. By default JAX uses all available devices (the CPU if there is no GPU/TPU), and you can check the count with jax.device_count(). With only a CPU it usually returns 1; the code still runs, just without cross-device parallelism (this tutorial already simulates 8 devices).

The three examples below practice simple data parallelism, replicating arguments with in_axes, and collectives with lax.psum. Watch for two things:

  • A plain print inside the function runs only once, during tracing (compilation), not once per device. When the same function is called again it hits the compilation cache, and nothing is printed;
  • The output is a jax.Array distributed across the devices, with the same shape as the input; each row is the result computed on one device.

⚠️ Note: it's easy to assume the "Compiling and running..." message is printed once per device. In fact a plain print prints only once (verified with an assert below), and the output type is jax.Array. Also, jax.process_index() returns the process index (meaningful only for multi-host training; always 0 on a single machine), not the device index, so you can't use it to print "on device ...". Use lax.axis_index for the device index.

The pmap way

# Check the devices
num_devices = jax.device_count()
print(f"Number of devices available: {num_devices}")
print(f"Available devices: {jax.devices()}")
Number of devices available: 8
Available devices: [CpuDevice(id=0), CpuDevice(id=1), CpuDevice(id=2), CpuDevice(id=3), CpuDevice(id=4), CpuDevice(id=5), CpuDevice(id=6), CpuDevice(id=7)]
# Course example 1 (old API pmap): simple data parallelism
def scale_by_two(x):
    print("Compiling and running scale_by_two...")   # runs only during tracing
    return x * 2

data_size_per_device = 4
total_data_size = num_devices * data_size_per_device
global_data = jnp.arange(total_data_size)
sharded_data = global_data.reshape((num_devices, data_size_per_device))
print("Global data shape:", global_data.shape)
print("Sharded data shape:", sharded_data.shape)

parallel_scale = jax.pmap(scale_by_two)
result1 = parallel_scale(sharded_data)
print("\nOutput shape:", result1.shape)
print("Output type:", type(result1))
print("Output content:\n", result1)
Global data shape: (32,)
Sharded data shape: (8, 4)
Compiling and running scale_by_two...

Output shape: (8, 4)
Output type: <class 'jaxlib._jax.ArrayImpl'>
Output content:
 [[ 0  2  4  6]
 [ 8 10 12 14]
 [16 18 20 22]
 [24 26 28 30]
 [32 34 36 38]
 [40 42 44 46]
 [48 50 52 54]
 [56 58 60 62]]
# Check: a plain print fires only once across 8 devices; the second call hits the cache and prints 0 times
def scale_by_two_counted(x):
    print("Compiling and running...")
    return x * 2

p = jax.pmap(scale_by_two_counted)
buf1, buf2 = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(buf1):
    p(sharded_data)
with contextlib.redirect_stdout(buf2):
    p(sharded_data)
print("Prints on the first call:", buf1.getvalue().count("Compiling"), "  second call:", buf2.getvalue().count("Compiling"))
assert buf1.getvalue().count("Compiling") == 1 and buf2.getvalue().count("Compiling") == 0
Prints on the first call: 1   second call: 0
# ✅ Modern style: jit + NamedSharding, no reshape needed
mesh_dev = jax.make_mesh((num_devices,), ("devices",))
g_sh = jax.device_put(global_data, NamedSharding(mesh_dev, P("devices")))   # (32,), 4 per device
result1_m = jax.jit(lambda x: x * 2)(g_sh)
assert same(result1_m, result1.ravel())
print("✓ Same result; local shape per device:", result1_m.addressable_shards[0].data.shape)
✓ Same result; local shape per device: (4,)
# Course example 2 (old API pmap): in_axes=(0, None)
def scale_and_add(x, y):
    # process_index is the process index (always 0 on one machine), not the device index, so this says "in process" rather than "on device"
    print(f"Compiling and running scale_and_add in process {jax.process_index()}...")
    return x * 2 + y

scalar_y = jnp.float32(100.0)
parallel_scale_add = jax.pmap(scale_and_add, in_axes=(0, None))
result2 = parallel_scale_add(sharded_data, scalar_y)
print("\nInput x shape:", sharded_data.shape, "  Input y:", scalar_y)
print("Output shape:", result2.shape)
print("Output content:\n", result2)    # each element = x * 2 + 100
Compiling and running scale_and_add in process 0...
Input x shape: (8, 4)   Input y: 100.0
Output shape: (8, 4)
Output content:
 [[100. 102. 104. 106.]
 [108. 110. 112. 114.]
 [116. 118. 120. 122.]
 [124. 126. 128. 130.]
 [132. 134. 136. 138.]
 [140. 142. 144. 146.]
 [148. 150. 152. 154.]
 [156. 158. 160. 162.]]
# ✅ Modern style: jit + NamedSharding. The scalar needs no annotation; it's replicated to every device automatically
result2_m = jax.jit(lambda x, y: x * 2 + y)(g_sh, scalar_y)
assert same(result2_m, result2.ravel())
print("✓ Same result")
✓ Same result
# Course example 3 (old API pmap): lax.psum
def scale_and_sum(x):
    scaled_x = x * 2
    local_sum = jnp.sum(scaled_x)
    global_sum = lax.psum(local_sum, axis_name="devices")
    return scaled_x, global_sum

parallel_scale_sum = jax.pmap(scale_and_sum, axis_name="devices")
result_scaled, result_sum = parallel_scale_sum(sharded_data)
expected_sum = jnp.sum(jnp.arange(total_data_size) * 2)

print("Output scaled data shape:", result_scaled.shape)   # still sharded
print("Output sum shape:", result_sum.shape)               # one copy per device (replicated)
print("Output sum (replicated across devices):", result_sum)
print("Expected global sum:", expected_sum)
assert same(result_sum, [expected_sum] * num_devices)
Output scaled data shape: (8, 4)
Output sum shape: (8,)
Output sum (replicated across devices): [992 992 992 992 992 992 992 992]
Expected global sum: 992
# ✅ Modern style A: jit + sharding. A global sum; the compiler inserts the cross-device reduction automatically
@jax.jit
def scale_and_sum_jit(x):
    scaled = x * 2
    return scaled, jnp.sum(scaled)

# ✅ Modern style B: shard_map, which maps one-to-one onto pmap
@jax.jit
@jax.shard_map(mesh=mesh_dev, in_specs=P("devices"), out_specs=(P("devices"), P()))
def scale_and_sum_sm(x):                   # x: the (4,) local block on each device
    scaled = x * 2
    return scaled, lax.psum(jnp.sum(scaled), "devices")

for s, t in [scale_and_sum_jit(g_sh), scale_and_sum_sm(g_sh)]:
    assert same(s, result_scaled.ravel()) and int(t) == int(expected_sum)
print("✓ Both modern versions match pmap; global sum =", int(expected_sum))
✓ Both modern versions match pmap; global sum = 992

A full data-parallel training loop, cross-checked against a single device

So far we've only shown one step of gradient computation. Below we fully train a linear regression $y = wx + b$ (true values $w=3,\ b=2$) and verify that:

when every device has the same batch size, data-parallel training and single-device full-batch training are mathematically equivalent, so the parameter trajectories should match exactly, up to floating-point error.

Setup: 64 examples, 8 devices (8 examples per device), learning rate 0.1, 200 steps of full-batch gradient descent. Parameters and data are all passed in as function arguments; no arrays are captured in jit closures.

Keeping parameters resident on the devices: in the pmap era, jax.device_put_replicated was the usual way to copy parameters to every device. That API was removed in 0.12:

# ❌ Old style (outdated): the pmap-era helper APIs device_put_replicated / device_put_sharded were removed (common in older material)
for name in ["device_put_replicated", "device_put_sharded"]:
    try:
        getattr(jax, name)
        raise AssertionError(f"{name} should not exist")
    except AttributeError as e:
        print(f"jax.{name}: {str(e)[:70]}...")
jax.device_put_replicated: jax.device_put_replicated is deprecated; use jax.device_put instead. S...
jax.device_put_sharded: jax.device_put_sharded is deprecated; use jax.device_put instead. See ...
# ✅ Modern style: device_put with a replicated NamedSharding (P()) / a sharded NamedSharding (P('batch'))
N_DEV = jax.local_device_count()
mesh = jax.make_mesh((N_DEV,), ("batch",))
repl_sharding = NamedSharding(mesh, P())          # a full copy on every device
data_sharding = NamedSharding(mesh, P("batch"))   # split evenly along dimension 0

init_params = {"w": jnp.array(0.0), "b": jnp.array(0.0)}
params_repl = jax.device_put(init_params, repl_sharding)
assert all(v.sharding.is_fully_replicated and len(v.addressable_shards) == N_DEV
           for v in jax.tree.leaves(params_repl))
print("✓ Parameters replicated to", N_DEV, "devices")
✓ Parameters replicated to 8 devices
import matplotlib.pyplot as plt

N_SAMPLES, LR, STEPS = 64, 0.1, 200

# Build the data: x ~ U(-1, 1), y = 3x + 2 + noise
kx, kn = jax.random.split(jax.random.key(42))
X = jax.random.uniform(kx, (N_SAMPLES,), minval=-1.0, maxval=1.0)
Y = 3.0 * X + 2.0 + 0.1 * jax.random.normal(kn, (N_SAMPLES,))

def mse(params, x, y):
    return jnp.mean((params["w"] * x + params["b"] - y) ** 2)

def sgd(params, grads):
    return jax.tree.map(lambda p, g: p - LR * g, params, grads)

# ---------- 0) single-device baseline ----------
@jax.jit
def step_single(params, x, y):
    loss, grads = jax.value_and_grad(mse)(params, x, y)
    return sgd(params, grads), loss

# ---------- 1) old API pmap: parameters replicated, gradients synced with pmean ----------
def step_pmap_fn(params, x, y):
    loss, grads = jax.value_and_grad(mse)(params, x, y)   # local gradients
    grads = lax.pmean(grads, axis_name="batch")            # sync: average the gradients from all devices
    loss = lax.pmean(loss, axis_name="batch")
    return sgd(params, grads), loss                       # every device updates with the same gradients -> parameters stay identical

step_pmap = jax.pmap(step_pmap_fn, axis_name="batch", in_axes=(None, 0, 0), out_axes=(None, None))

# ---------- 2) ✅ jit + sharding: the very same function as single-device; the parallel strategy comes entirely from the input sharding ----------
step_jit = step_single

# ---------- 3) ✅ shard_map: hand-written collectives; differentiate the "global loss" to avoid the N-times gradient trap ----------
@jax.jit
@jax.shard_map(mesh=mesh, in_specs=(P(), P("batch"), P("batch")), out_specs=(P(), P()))
def step_shmap(params, x, y):
    loss, grads = jax.value_and_grad(lambda p: lax.pmean(mse(p, x, y), "batch"))(params)
    return sgd(params, grads), loss

def train(step, params, x, y):
    losses = []
    for _ in range(STEPS):
        params, loss = step(params, x, y)
        losses.append(float(loss))
    return params, np.array(losses)

X_sh, Y_sh = jax.device_put(X, data_sharding), jax.device_put(Y, data_sharding)
runs = {
    "single device": train(step_single, init_params, X, Y),
    "pmap": train(step_pmap, init_params, X.reshape(N_DEV, -1), Y.reshape(N_DEV, -1)),
    "jit+sharding": train(step_jit, params_repl, X_sh, Y_sh),
    "shard_map": train(step_shmap, params_repl, X_sh, Y_sh),
}

print(f"{'method':14s} {'w':>10s} {'b':>10s} {'final loss':>12s}")
for name, (p, l) in runs.items():
    print(f"{name:14s} {float(p['w']):10.6f} {float(p['b']):10.6f} {l[-1]:12.8f}")
method                  w          b   final loss
single device    3.021347   1.981414   0.00690815
pmap             3.021347   1.981414   0.00690814
jit+sharding     3.021347   1.981414   0.00690814
shard_map        3.021347   1.981414   0.00690814
# Check: all three parallel versions match the single-device result (parameters + the whole loss curve)
p_ref, l_ref = runs["single device"]
for name in ["pmap", "jit+sharding", "shard_map"]:
    p, l = runs[name]
    assert same(p["w"], p_ref["w"], atol=1e-6) and same(p["b"], p_ref["b"], atol=1e-6), name
    assert np.allclose(l, l_ref, rtol=1e-5, atol=1e-7), name
    print(f"✓ {name:14s} parameters and loss curve both match single device")
assert abs(float(p_ref["w"]) - 3) < 0.1 and abs(float(p_ref["b"]) - 2) < 0.1   # converged near the true values
assert runs["jit+sharding"][0]["w"].sharding.is_fully_replicated               # parameters stay replicated on all devices

plt.figure(figsize=(6, 3.5))
styles = {"single device": dict(lw=3, alpha=0.4), "pmap": dict(ls="--"), "jit+sharding": dict(ls=":"), "shard_map": dict(ls="-.")}
for name, (_, l) in runs.items():
    plt.plot(l, label=name, **styles[name])
plt.yscale("log"); plt.xlabel("step"); plt.ylabel("MSE (log)")
plt.title("Data-parallel training vs single device")
plt.legend(prop={"family": ["Arial Unicode MS", "PingFang SC", "Heiti TC", "sans-serif"]})
plt.tight_layout(); plt.show()
✓ pmap           parameters and loss curve both match single device
✓ jit+sharding   parameters and loss curve both match single device
✓ shard_map      parameters and loss curve both match single device
No description has been provided for this image

A few lessons from practice:

  • The four curves overlap exactly. Syncing the gradients with pmean guarantees that the parameters on every device stay the same. That's the essence of data parallelism: the same global gradient at every step → the same parameter update;
  • If the devices have batches of different sizes (say, an incomplete last batch), "take the local mean, then average" is not the global mean. In that case use psum(sum of local losses) / psum(number of local examples) (see Extra exercise 4);
  • The jit + sharding training step is exactly the same as the single-device one, and that's precisely why it's the officially recommended approach: the parallel strategy is kept separate from the model code.

Course exercises recap

The hands-on part of this section is examples 1–3 above. Try changing data_size_per_device, removing axis_name, or deliberately getting in_axes wrong, and see what errors you get. Below are a few more substantial exercises.

✍️ Extra exercise 1: cross-device standardization (distributed mean and variance)

Data of shape (64, 5) is distributed across 8 devices along dimension 0. Use shard_map to write a function that globally standardizes each column, $(x-\mu)/\sigma$, where $\mu, \sigma$ must be global statistics. Use only psum, and compare against the single-device result.

Hint: $\mu = \frac{\sum x}{n}$, $\sigma^2 = \frac{\sum x^2}{n} - \mu^2$, and both the $\sum$ terms and $n$ have to be summed across devices. This is how SyncBN (cross-device synchronized BatchNorm) works.

Self-check interface: global_standardize(x) takes a 2-D array distributed across 8 devices along dimension 0 (NamedSharding(mesh, P("data", None)), mesh = jax.make_mesh((8,), ("data",))) and returns the column-wise globally standardized result; it should work for any number of rows that is a multiple of 8.

# ✍️ Your answer: Extra 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: Extra exercise 1
try:
    global_standardize
except NameError as e:
    print(f"⏳ Extra exercise 1: not answered yet: {e}")
else:
    _mesh = jax.make_mesh((8,), ("data",))
    # New data (40, 3): the 5 rows on each device have different means, so local statistics alone are guaranteed to be wrong
    _X = (np.asarray(jax.random.normal(jax.random.key(2029), (40, 3)), np.float64) * np.array([1., 2., 5.])
          + np.repeat(np.arange(8.), 5)[:, None])
    _out = np.asarray(global_standardize(jax.device_put(jnp.asarray(_X, jnp.float32), NamedSharding(_mesh, P("data", None)))))
    assert _out.shape == (40, 3), f"Output shape should match the input (40, 3), got {_out.shape}"
    _ref = (_X - _X.mean(0)) / np.sqrt(_X.var(0) + 1e-6)
    assert np.allclose(_out, _ref, atol=1e-3), "Does not match single-device global standardization: the mean, the variance and the example count all need to be aggregated across devices"
    print("✅ Extra exercise 1: self-check passed")
⏳ Extra exercise 1: not answered yet: name 'global_standardize' is not defined

✍️ Extra exercise 2: a hand-written ring all-reduce with ppermute

Under the hood, psum is often implemented with "ring" communication. Using only lax.ppermute (each device sends its data to its right-hand neighbor), implement a ring_allreduce that gives the same result as lax.psum. Run it with both pmap and shard_map, and compare against psum.

Hint: pass values around for $N-1$ rounds. In each round, forward "the value you just received" to the right, and add it to your local total.

Self-check interface: ring_allreduce(x, axis_name, n) is used inside the mapped axis axis_name (with n devices) and returns the same result as lax.psum(x, axis_name); the self-check calls it with shard_map on a mesh whose axis name is not "i", and checks that it doesn't use psum.

# ✍️ Your answer: Extra 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: Extra exercise 2
try:
    ring_allreduce
except NameError as e:
    print(f"⏳ Extra exercise 2: not answered yet: {e}")
else:
    _n = jax.device_count()
    _mesh = jax.make_mesh((_n,), ("ring",))
    _v = (jnp.arange(_n * 4, dtype=jnp.float32).reshape(_n, 4) + 1.0) ** 1.5
    _f = jax.jit(jax.shard_map(lambda v: ring_allreduce(v, "ring", _n), mesh=_mesh,
                               in_specs=P("ring"), out_specs=P("ring")))
    _vs = jax.device_put(_v, NamedSharding(_mesh, P("ring")))
    _out = np.asarray(_f(_vs))
    assert np.allclose(_out, np.tile(np.asarray(_v).sum(0), (_n, 1)), rtol=1e-5), \
        "Every device should get the sum of all devices' data (check the number of rounds and which value you accumulate)"
    _jx = str(jax.make_jaxpr(_f)(_vs))
    assert "ppermute" in _jx, "You should pass data between devices with lax.ppermute"
    assert "psum" not in _jx, "This exercise asks you to use only ppermute; psum / pmean are not allowed"
    print("✅ Extra exercise 2: self-check passed")
⏳ Extra exercise 2: not answered yet: name 'ring_allreduce' is not defined

✍️ Extra exercise 3: a "data parallel × tensor parallel" matmul on a 2-D mesh

On a (4, 2) mesh ('data', 'model'), compute $Y = XW$, where $X$ is (16, 8) and $W$ is (8, 6):

  • the rows of $X$ are split along data and its columns (the contraction dimension) along model: P('data', 'model');
  • the rows of $W$ (the contraction dimension) are split along model, and its columns are not split: P('model', None).

Each device first computes its local product $X_{ij} W_j$. That's only a partial sum: along which axis do you need to psum? What should out_specs be? Implement it with both shard_map and jit + sharding. (Bonus observation: on the default Explicit mesh in 0.12, what problem does the jit version run into?)

Note: pass $X$ and $W$ in as function arguments; don't capture them in the closure of a jitted function.

Self-check interface: matmul_dp_tp(x, w) is the shard_map version (already jitted). Its inputs are distributed on the (4, 2) mesh ("data", "model") as P("data", "model") and P("model", None) respectively, and it returns the full $XW$; the self-check uses a different pair of $X$, $W$ with different shapes and checks only the shard_map version.

# ✍️ Your answer: Extra 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: Extra exercise 3
try:
    matmul_dp_tp
except NameError as e:
    print(f"⏳ Extra exercise 3: not answered yet: {e}")
else:
    _mesh = jax.make_mesh((4, 2), ("data", "model"))
    _kx, _kw = jax.random.split(jax.random.key(2030))
    _X = jax.random.normal(_kx, (12, 10))      # (3, 5) per device
    _W = jax.random.normal(_kw, (10, 3))       # (5, 3) per device
    _Y = matmul_dp_tp(jax.device_put(_X, NamedSharding(_mesh, P("data", "model"))),
                      jax.device_put(_W, NamedSharding(_mesh, P("model", None))))
    _ref = np.asarray(_X, np.float64) @ np.asarray(_W, np.float64)
    assert np.asarray(_Y).shape == (12, 3), f"The result should have shape (12, 3), got {np.asarray(_Y).shape}; check out_specs"
    assert np.allclose(np.asarray(_Y), _ref, atol=1e-4), "Does not match X @ W: the local product is only a partial sum; think about which axis to sum along"
    print("✅ Extra exercise 3: self-check passed")
⏳ Extra exercise 3: not answered yet: name 'matmul_dp_tp' is not defined

✍️ Extra exercise 4: correct gradient averaging with unequal batches

There are 8 devices, but each has a different number of valid examples (given by a mask, 1 for valid and 0 for padding). Write the MSE gradient computation so that the result equals the global gradient computed "over all valid examples only". First explain why "local mean + pmean" is wrong here.

Answer: say device $i$ has $n_i$ valid examples and local mean loss $L_i = \frac{1}{n_i}\sum_{k} \ell_k$. $\frac{1}{N}\sum_i L_i$ gives each device equal weight, while the correct global loss $\frac{\sum_i \sum_k \ell_k}{\sum_i n_i}$ gives each example equal weight. The two agree only when all the $n_i$ are equal.

Self-check interface: correct_grad(p, x, y, m) runs under jax.pmap(correct_grad, axis_name="i", in_axes=(None, 0, 0, 0), out_axes=None), with p = {"w": scalar, "b": scalar} and the model $\hat y = wx + b$; it returns the MSE gradient computed over all valid examples only (same structure as p).

# ✍️ Your answer: Extra 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: Extra exercise 4
try:
    correct_grad
except NameError as e:
    print(f"⏳ Extra exercise 4: not answered yet: {e}")
else:
    _n = jax.local_device_count()
    _k1, _k2 = jax.random.split(jax.random.key(2031))
    _x = np.asarray(jax.random.normal(_k1, (_n, 6)), np.float64)
    _y = -2 * _x + 0.7 + 0.3 * np.asarray(jax.random.normal(_k2, (_n, 6)))
    _valid = np.resize(np.array([6, 1, 4, 6, 2, 5, 3, 6]), _n)
    _m = (np.arange(6)[None, :] < _valid[:, None]).astype(np.float32)
    _x = np.where(_m > 0, _x, 50.0)            # put absurd values in the padding: if they aren't masked out, it shows
    _p = {"w": jnp.array(0.2), "b": jnp.array(-0.1)}
    _g = jax.pmap(correct_grad, axis_name="i", in_axes=(None, 0, 0, 0), out_axes=None)(
        _p, jnp.asarray(_x, jnp.float32), jnp.asarray(_y, jnp.float32), jnp.asarray(_m))
    _sel = _m.ravel() > 0
    _xv, _yv = _x.ravel()[_sel], _y.ravel()[_sel]
    _r = 0.2 * _xv - 0.1 - _yv                  # closed-form MSE gradient: d/dw = 2·mean(r·x), d/db = 2·mean(r)
    assert np.isclose(float(_g["w"]), 2 * np.mean(_r * _xv), atol=1e-4), "The gradient for w does not match the MSE gradient over all valid examples"
    assert np.isclose(float(_g["b"]), 2 * np.mean(_r), atol=1e-4), "The gradient for b does not match the MSE gradient over all valid examples"
    print("✅ Extra exercise 4: self-check passed")
⏳ Extra exercise 4: not answered yet: name 'correct_grad' is not defined

5.9 Chapter 5 quiz

About this quiz: the course's official quiz is on the quiz page (login required). The 8 questions below focus on the easy-to-miss points of this chapter; the answer and explanation are in the collapsible block under each question.

Question 1 With 8 devices and x.shape == (32, 10), what happens if you call jax.pmap(f)(x) directly?

A. Each device gets (4, 10) and it runs normally B. An error, because the mapped axis length 32 exceeds the 8 devices C. Only the first 8 rows are used and the rest are dropped D. It automatically falls back to vmap

Answer and explanation

B. Each element of pmap's mapped axis corresponds to one device, so the length can't exceed the number of devices. The right way is x.reshape(8, 4, 10), or switch to NamedSharding(mesh, P('data')); only the latter splits the 32 rows evenly into 4 rows per device.

Question 2 What does the None in jax.pmap(f, in_axes=(0, None)) mean? What's the modern equivalent?

A. The argument isn't used in the computation · B. The argument is split along its last dimension · C. The argument is copied in full to every device, which corresponds to P() · D. The argument is placed only on device 0

Answer and explanation

C. None means replication, and it's commonly used for model parameters. The modern equivalent is P() in a sharding, or in_specs=P() in shard_map.

Question 3 What does the following code output?

jax.pmap(lambda x: lax.psum(x, 'i'), axis_name='i')(jnp.arange(4.))

A. 6.0 · B. [0. 1. 2. 3.] · C. [6. 6. 6. 6.] · D. An error

Answer and explanation

C. psum returns the same total 0+1+2+3=6 to every device, and the default out_axes=0 stacks the 4 copies. With out_axes=None you get just one copy.

Question 4 Which statement about pmap's out_axes=None is correct?

A. It checks whether every device's output is the same and errors if not B. It doesn't check; when outputs differ, it quietly returns one device's value C. It automatically averages the outputs of all devices D. It means nothing is returned

Answer and explanation

B. Section 5.3 verifies this with an assert. shard_map's out_specs=P(), on the other hand, checks replication statically.

Question 5 In JAX 0.12, which statement about jax.pmap is correct?

A. It has been removed · B. Calling it raises a DeprecationWarning · C. It works normally with no warning, but the docs call it the "old way" and it's implemented internally with jit + shard_map · D. Its output type is a special sharded-array class, not jax.Array

Answer and explanation

C. Running it shows: no warning; the docstring says "Old way of doing parallel map. Use jax.shard_map instead."; and the output is a jax.Array.

Question 6 You're migrating from pmap to shard_map. The original code is pmean(grad(local_loss)(params)), and params uses in_specs=P(). Without any other changes, what gradient do you get?

A. The correct average gradient · B. The gradient times the number of devices · C. The gradient divided by the number of devices · D. An error

Answer and explanation

B. When an invariant parameter is combined with varying data, it is implicitly pvary-ed, and the backward pass of pvary is psum, so grad is already the "sum of the local gradients"; taking pmean of an invariant value leaves it unchanged. The fix: differentiate pmean(local_loss), or divide by the number of devices after grad.

Question 7 What happens if you write if lax.axis_index('i') == 0: ... inside a parallel function?

A. It runs only on device 0 · B. It runs on all devices · C. It raises TracerBoolConversionError · D. The program hangs

Answer and explanation

C. axis_index is a traced value and can't be used in a Python if; use lax.cond or jnp.where.

Question 8 When writing data-parallel training with jit + sharding, which of the following do you not need?

A. Build a mesh with jax.make_mesh · B. device_put the data onto NamedSharding(mesh, P('data')) · C. Write lax.pmean by hand in the loss function to sync gradients · D. device_put the parameters as replicated (P())

Answer and explanation

C. Under jit + sharding, the function sees global arrays, jnp.mean is already the global mean, and the compiler inserts the cross-device reduction automatically. lax.pmean can only be used inside a pmap/shard_map that binds the axis name (or a vmap with axis_name). (D isn't required, but it's recommended.)

5.10 Summary

Key points

  • SPMD: the same program processes different shards of data, and devices communicate through collective operations;
  • pmap semantics (old API): each element of the mapped axis goes to one device, and that dimension disappears inside the device; the mapped axis length must be ≤ the number of devices. Large batches must first be reshaped to (N, B/N, ...);
  • in_axes: an integer maps along that axis, None replicates, and it can also be a pytree; out_axes: an integer stacks, None keeps just one copy (without checking!);
  • Axis names: name the parallel axes; collectives find their communication group by axis name, and both sides must match;
  • Collectives: psum, pmean, pmax, pmin, all_gather, ppermute, axis_index can only be called inside a pmap/shard_map that binds the axis name (or a vmap with axis_name); they are all synchronization points, so don't put them in branches that differ per device;
  • Data-parallel training: each device computes local gradients → average across devices → every device makes the same update → parameters stay identical, and the result is equivalent to single-device full-batch training (verified with asserts);
  • Debugging: first get a single shard working without parallelism; check shapes against the device count; use jax.debug.print + lax.axis_index + lax.cond; check expected values with a single-device computation;
  • Status in JAX 0.12: pmap works with no warning, but the docs call it the old way and it's implemented internally with jit + shard_map; nested pmap, device_put_replicated/sharded and jax.experimental.maps no longer work, and jax.experimental.shard_map is deprecated;
  • Modern style: prefer jit + NamedSharding (the compiler inserts communication automatically); use jax.shard_map when you need to write communication by hand; jax.make_mesh defaults to Explicit axes;
  • The top migration pitfall: in shard_map, grad with respect to replicated parameters does an automatic psum, so copying pmean(grad) gives N-times gradients.

Cheat sheet

APIs in this chapter

APIWhat it does
jax.devices() / jax.device_count() / jax.local_device_count()List devices / total device count / device count for this process
XLA_FLAGS=--xla_force_host_platform_device_count=8Simulate 8 devices on the CPU (must be set before import jax)
jax.pmap(f, axis_name=, in_axes=, out_axes=, devices=)Old API: parallel map across devices
lax.psum(x, axis) / lax.pmean(x, axis)Sum / mean across devices; the result is the same on every device
lax.pmax / lax.pminMaximum / minimum across devices
lax.all_gather(x, axis, tiled=)Every device gathers the values from all devices
lax.ppermute(x, axis, perm)Point-to-point sends given as (source, destination) pairs
lax.axis_index(axis) / lax.axis_size(axis)Index of the current device on the axis / size of the axis
lax.pcast(x, axis, to="varying")In shard_map, explicitly turn an invariant value into a varying one
jax.make_mesh(shape, names, axis_types=)Create a logical device mesh (Explicit axes by default in 0.12)
NamedSharding(mesh, P(...))A sharding: a mesh + which mesh axis each dimension maps to
jax.device_put(x, sharding)Place data according to a sharding (P() means replicated)
jax.reshard(x, NamedSharding(...))Change how an array is sharded (passing P(...) requires a jax.set_mesh context)
x.sharding / x.addressable_shards / jax.typeof(x)Inspect the sharding / the local block on each device / the type with sharding info
jax.debug.visualize_array_sharding(x)Draw the sharding layout (needs rich; it's not a jax dependency, but flax brings it in)
jax.shard_map(f, mesh=, in_specs=, out_specs=)Manual parallelism: the function sees local blocks and you write collectives by hand
jax.debug.printPrint at run time inside a compiled function
jax.disable_jit()Disable compilation, as a last resort when debugging

pmap ↔ modern style

pmap (old API)jit + shardingshard_map
x.reshape(N, B//N, ...)device_put(x, NamedSharding(mesh, P('data'))), no reshape neededSame as left
in_axes=0Input sharding P('data')in_specs=P('data')
in_axes=NoneInput sharding P() (or no annotation)in_specs=P()
out_axes=0Output follows automatically / out_shardings=out_specs=P('data')
out_axes=None (not checked)Output is P()out_specs=P() (replication checked statically)
axis_name='i'Mesh axis name jax.make_mesh((N,), ('i',))Same as left
lax.psum(x, 'i')Just write jnp.sum(x) (global)lax.psum(local_value, 'i')
pmean(grad(loss))grad(loss) (global loss)grad(lambda p: pmean(local_loss(p), 'i')), watch out for N-times gradients
Nested pmap(pmap(...))2-D mesh make_mesh((a, b), ('i', 'j'))2-D mesh + psum(x, 'j') / psum(x, ('i','j'))
jax.device_put_replicated(x, devs) (removed)device_put(x, NamedSharding(mesh, P()))Same as left
Shape inside the device: mapped axis removedGlobal shapeLocal block with its dimensions kept

Version notes

Below is a summary of the patterns and conclusions in this chapter that need attention under JAX 0.12. Each one is verified with an assert at the corresponding place in the text (environment: JAX 0.12.0.1).

No.SectionKey pointWhere
15.2jax.pmap still works and raises no warning, but the docs call it the "old way" and it's implemented internally with jit + shard_map; for new code, prefer jit + NamedSharding or jax.shard_map5.2 Introducing jax.pmap
25.2 / 5.8pmap's output is the unified jax.Array (with a NamedSharding); the old ShardedDeviceArray has been removed5.2 Introducing jax.pmap; 5.8 Hands-on
35.2With only 1 device, pmap's mapped axis can only have length 1; it can't handle batches of arbitrary length the way vmap does5.2 Introducing jax.pmap
45.3pmap assigns each element of the mapped axis to one device, with axis length ≤ device count; large batches must first be reshaped to (N, B/N, ...); "splitting evenly into B/N blocks automatically" is what NamedSharding does5.3 Mapping data to devices
55.3With only 2 devices, pmap over a (4, 3) array directly raises an error; reshape(2, 2, 3) first, or shard with P('data', None)5.3 Mapping data to devices
65.3pmap's out_axes=None doesn't check whether device outputs are equal, and without aggregation it quietly returns one device's value; shard_map's out_specs=P() raises an error right away5.3 Mapping data to devices
75.4jax.experimental.maps (including Mesh) has been removed; build meshes with jax.make_mesh / jax.sharding.Mesh5.4 Device meshes and axis names
85.4jax.make_mesh defaults to AxisType.Explicit in 0.12, so passing an unsharded input straight to shard_map raises an error5.4 Device meshes and axis names
95.4 / 5.5Nested pmap raises an error in 0.12; for multi-dimensional parallelism use a multi-dimensional mesh + shard_map5.4 Device meshes and axis names
105.2jax.experimental.shard_map is deprecated since v0.8.0; use jax.shard_map5.2 Introducing jax.pmap
115.8jax.device_put_replicated / device_put_sharded have been removed; use jax.device_put(x, NamedSharding(mesh, P())) and the like5.8 Hands-on
125.6jax.device_put(data, devices) with a list of devices raises ValueError and doesn't shard anything; hand the array straight to pmap, or use device_put(x, NamedSharding(...))5.6 Combining pmap with other transformations
135.6When timing pmap(f) against pmap(jit(f)), warm up first and then average over several runs; don't include the first compilation time5.6 Combining pmap with other transformations
145.6The noise in the example data is generated with jax.random.key(0) (not an unseeded np.random.randn), so results are reproducible5.6 Combining pmap with other transformations
155.6The vmap inside pmap is a layer of vectorization, not quantization5.6 Combining pmap with other transformations
165.6Copying pmean(grad) when migrating to shard_map gives N-times gradients; three correct versions and a migration checklist are given⚠️ Special topic: gradients become N times larger in shard_map
175.7pmap only requires the mapped axis length to be ≤ the device count, not equal; when it's smaller, only the first few devices are used5.7 Debugging pmapped functions
185.7The shape-checking example uses lax.axis_index, so it needs from jax import lax; otherwise it raises NameError5.7 Debugging pmapped functions
195.7You can't control printing with a Python if lax.axis_index(...) == 0; it raises TracerBoolConversionError; use lax.cond5.7 Debugging pmapped functions
205.7In the jax.debug.print example, the result after psum is [ 6 10 14] ([0 2 4] + [6 8 10]), not [ 6 8 10]5.7 Debugging pmapped functions
215.8A plain print in a pmapped function prints only once, during tracing (not once per device), and not at all after a cache hit5.8 Hands-on: parallel computation
225.8jax.process_index() is the process index (always 0 on one machine); use lax.axis_index for the device index5.8 Hands-on: parallel computation
235.8A full data-parallel training loop (pmap / jit+sharding / shard_map), verified with asserts to match the single-device result5.8 Hands-on: parallel computation

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 you're really stuck. Running the solutions below in order overwrites any functions with the same names that you defined in the answer cells.

Extra exercise 1

See "✍️ Extra exercise 1: cross-device standardization (distributed mean and variance)" in the main text for the problem.

# Reference solution
mesh = jax.make_mesh((8,), ("data",))
Xn = jax.random.normal(jax.random.key(1), (64, 5)) * jnp.arange(1, 6) + jnp.arange(5)
Xn_sh = jax.device_put(Xn, NamedSharding(mesh, P("data", None)))

@jax.jit
@jax.shard_map(mesh=mesh, in_specs=P("data", None), out_specs=P("data", None))
def global_standardize(x):                            # x: (8, 5) on each device
    n = lax.psum(x.shape[0], "data")                  # global example count
    s1 = lax.psum(jnp.sum(x, axis=0), "data")         # global first-order sum
    s2 = lax.psum(jnp.sum(x ** 2, axis=0), "data")    # global second-order sum
    mu = s1 / n
    var = s2 / n - mu ** 2
    return (x - mu) / jnp.sqrt(var + 1e-6)

out = global_standardize(Xn_sh)
ref = (Xn - Xn.mean(0)) / jnp.sqrt(Xn.var(0) + 1e-6)
assert same(out, ref, atol=1e-5)

# For comparison: using only "local" statistics (the wrong way)
local_only = jax.shard_map(lambda x: (x - x.mean(0)) / jnp.sqrt(x.var(0) + 1e-6),
                           mesh=mesh, in_specs=P("data", None), out_specs=P("data", None))(Xn_sh)
assert not same(local_only, ref, atol=1e-5)
print("✓ Global statistics match single device; local-only statistics do not")
✓ Global statistics match single device; local-only statistics do not

Extra exercise 2

See "✍️ Extra exercise 2: a hand-written ring all-reduce with ppermute" in the main text for the problem.

# Reference solution
def ring_allreduce(x, axis_name, n):
    perm = [(j, (j + 1) % n) for j in range(n)]   # each device sends to its right-hand neighbor
    def body(_, carry):
        acc, msg = carry
        msg = lax.ppermute(msg, axis_name, perm)  # receive the value from the left-hand neighbor
        return acc + msg, msg
    acc, _ = lax.fori_loop(0, n - 1, body, (x, x))
    return acc

n = jax.local_device_count()
vals = jax.random.normal(jax.random.key(2), (n, 3))

# Old API pmap
ring_p = jax.pmap(lambda v: ring_allreduce(v, "i", n), axis_name="i")(vals)
ref_p = jax.pmap(lambda v: lax.psum(v, "i"), axis_name="i")(vals)
assert same(ring_p, ref_p, atol=1e-5)

# ✅ Modern style: shard_map
mesh_i = jax.make_mesh((n,), ("i",))
ring_s = jax.jit(jax.shard_map(lambda v: ring_allreduce(v, "i", n), mesh=mesh_i, in_specs=P("i"), out_specs=P("i")))(
    jax.device_put(vals, NamedSharding(mesh_i, P("i"))))
# Note: you can't index a row with ring_s[0] on a sharded array on an Explicit mesh (it raises ShardingTypeError and asks for an output sharding), so convert to numpy first
assert same(ring_s, ref_p, atol=1e-5) and same(np.asarray(ring_s)[0], vals.sum(0), atol=1e-5)
print("✓ Ring all-reduce (pmap and shard_map) matches psum")
✓ Ring all-reduce (pmap and shard_map) matches psum

Extra exercise 3

See "✍️ Extra exercise 3: a "data parallel × tensor parallel" matmul on a 2-D mesh" in the main text for the problem.

# Reference solution
mesh2d = jax.make_mesh((4, 2), ("data", "model"))
kx, kw = jax.random.split(jax.random.key(3))
Xm = jax.random.normal(kx, (16, 8))
Wm = jax.random.normal(kw, (8, 6))
ref = np.asarray(Xm) @ np.asarray(Wm)          # compute the reference value with numpy
Xm_sh = jax.device_put(Xm, NamedSharding(mesh2d, P("data", "model")))
Wm_sh = jax.device_put(Wm, NamedSharding(mesh2d, P("model", None)))

@jax.jit
@jax.shard_map(mesh=mesh2d, in_specs=(P("data", "model"), P("model", None)), out_specs=P("data", None))
def matmul_dp_tp(x, w):          # x: (4, 4)  w: (4, 6)
    partial = x @ w              # local partial sum
    return lax.psum(partial, "model")   # sum along 'model', the axis holding the contraction dimension; the result is sharded along data and replicated over model

Y1 = matmul_dp_tp(Xm_sh, Wm_sh)
assert same(Y1, ref, atol=1e-4)
print("✓ shard_map is correct, output sharding:", Y1.sharding.spec)

# jit + sharding (Explicit mesh): when the contraction dimension is split on both sides, the output sharding is ambiguous, and JAX asks you to specify it
try:
    jax.jit(jnp.matmul)(Xm_sh, Wm_sh)
    raise AssertionError("Should have raised an error")
except Exception as e:
    print("Explicit mesh without an output sharding:", type(e).__name__, "-", str(e)[:80], "...")

# out_sharding takes a NamedSharding; if you write just P(...), enter the mesh context first with with jax.set_mesh(mesh2d):
Y2 = jax.jit(lambda x, w: jnp.matmul(x, w, out_sharding=NamedSharding(mesh2d, P("data", None))))(Xm_sh, Wm_sh)
assert same(Y2, ref, atol=1e-4)

# Auto mesh: the compiler decides the output sharding and the communication itself
mesh2d_auto = jax.make_mesh((4, 2), ("data", "model"), axis_types=(AxisType.Auto,) * 2)
Y3 = jax.jit(jnp.matmul)(jax.device_put(Xm, NamedSharding(mesh2d_auto, P("data", "model"))),
                         jax.device_put(Wm, NamedSharding(mesh2d_auto, P("model", None))))
assert same(Y3, ref, atol=1e-4)
print("✓ Explicit + out_sharding and jit on an Auto mesh are both correct; output sharding chosen by the compiler:", Y3.sharding.spec)
✓ shard_map is correct, output sharding: P('data', None)
Explicit mesh without an output sharding: ShardingTypeError - Contracting dimensions are sharded and it is ambiguous how the output should be  ...
✓ Explicit + out_sharding and jit on an Auto mesh are both correct; output sharding chosen by the compiler: P('data',)

Extra exercise 4

See "✍️ Extra exercise 4: correct gradient averaging with unequal batches" in the main text for the problem.

# Reference solution
n = jax.local_device_count()
k1, k2 = jax.random.split(jax.random.key(4))
xp = jax.random.normal(k1, (n, 8))
yp = 3 * xp + 1 + 0.1 * jax.random.normal(k2, (n, 8))
valid = jnp.array([8, 3, 8, 5, 1, 8, 6, 2])                    # valid example count on each device
mask = (jnp.arange(8)[None, :] < valid[:, None]).astype(jnp.float32)
params = {"w": jnp.array(0.5), "b": jnp.array(0.0)}

def sq_err_sum(p, x, y, m):
    return jnp.sum(m * (p["w"] * x + p["b"] - y) ** 2)

# Old API pmap
def correct_grad(p, x, y, m):
    g_sum = jax.grad(sq_err_sum)(p, x, y, m)                    # gradient of the local "sum of squared errors"
    cnt = lax.psum(jnp.sum(m), "i")                             # global valid example count
    return jax.tree.map(lambda g: lax.psum(g, "i") / cnt, g_sum)

def wrong_grad(p, x, y, m):
    g = jax.grad(lambda p: sq_err_sum(p, x, y, m) / jnp.sum(m))(p)   # gradient of the local mean
    return lax.pmean(g, "i")

g_ok = jax.pmap(correct_grad, axis_name="i", in_axes=(None, 0, 0, 0), out_axes=None)(params, xp, yp, mask)
g_bad = jax.pmap(wrong_grad, axis_name="i", in_axes=(None, 0, 0, 0), out_axes=None)(params, xp, yp, mask)

# Single-device reference: valid examples only (data passed in as arguments)
sel = np.asarray(mask).ravel() > 0
xv, yv = xp.ravel()[sel], yp.ravel()[sel]
ref = jax.grad(lambda p, x, y: jnp.mean((p["w"] * x + p["b"] - y) ** 2))(params, xv, yv)

# ✅ Modern style: jit + sharding. Write the "global" weighted loss and the compiler handles the cross-device sums
mesh_i = jax.make_mesh((n,), ("i",))
shard = lambda a: jax.device_put(a, NamedSharding(mesh_i, P("i", None)))
g_jit = jax.jit(jax.grad(lambda p, x, y, m: sq_err_sum(p, x, y, m) / jnp.sum(m)))(params, shard(xp), shard(yp), shard(mask))

for k in ("w", "b"):
    assert same(g_ok[k], ref[k], atol=1e-5) and same(g_jit[k], ref[k], atol=1e-5)
    assert not same(g_bad[k], ref[k], atol=1e-3)
print("reference gradient   :", {k: round(float(v), 5) for k, v in ref.items()})
print("psum/count ✓         :", {k: round(float(v), 5) for k, v in g_ok.items()})
print("jit global ✓         :", {k: round(float(v), 5) for k, v in g_jit.items()})
print("local mean + pmean ✗ :", {k: round(float(v), 5) for k, v in g_bad.items()})
reference gradient   : {'b': -1.57462, 'w': -4.87911}
psum/count ✓         : {'b': -1.57462, 'w': -4.87912}
jit global ✓         : {'b': -1.57462, 'w': -4.87912}
local mean + pmean ✗ : {'b': -1.97572, 'w': -4.1223}