第 5 章 用 pmap 跨设备并行

课程:《JAX 入门》第 5 章 · 原课程链接:https://apxml.com/zh/courses/getting-started-with-jax/chapter-5-parallelization-across-devices-pmap 笔记环境:Python 3.14 · JAX 0.12.0.1 · 仅 CPU(用 XLA_FLAGS 模拟 8 个设备)

jax.jit 负责把单设备上的代码编译优化,jax.vmap 负责批处理。但这两个都不会把计算分到多个设备上。本章讲的是多设备并行:同一个函数同时在多块 GPU/TPU 上跑,每块设备处理不同的一片数据。

📌 关于 pmap:本章以 jax.pmap 为主线。在 JAX 0.12 里,pmap 照样能用,调用时也没有警告。不过官方文档已经把它叫作"旧写法",并说明它内部是用 jit + shard_map 实现的(5.2 节会验证)。所以这里的安排是:

  • 每个 pmap 示例都保留,标为旧接口;
  • 每个主要示例后面紧跟一个 # ✅ 现代写法 单元,用 jax.jit + NamedSharding 或 jax.shard_map 重写,再用 assert 证明两种写法结果一致;
  • 需要特别留意的地方用 > ⚠️ 注意 标出,章末「本章更新说明」做了汇总。

学习目标

  1. 理解 SPMD(单程序多数据)并行的原理;
  2. 会用 pmap(旧接口)和 jit + sharding / shard_map(现代写法)让同一函数在多设备上处理不同数据;
  3. 会控制数据怎样分发到设备、结果怎样收集回来(in_axes/out_axes ↔ PartitionSpec/in_specs/out_specs);
  4. 会在并行函数里用集体操作(lax.psum、lax.pmean 等)做跨设备通信;
  5. 会写数据并行训练,并能验证它与单设备训练结果一致;
  6. 能避开 pmap → shard_map 迁移时"梯度变成 N 倍"的头号陷阱。

小节目录

小节内容原文
0环境设置:用 1 个 CPU 模拟 8 个设备—
5.1数据并行 (SPMD) 介绍链接
5.2介绍 jax.pmap链接
5.3将数据映射到设备(in_axes、out_axes)链接
5.4设备网格与轴名称链接
5.5集体操作(lax.psum、lax.pmean 等)链接
5.6将 pmap 与其他变换结合使用链接
⚠️ 专题shard_map 中梯度变成 N 倍(迁移头号陷阱)—
5.7调试 pmap 化的函数链接
5.8动手实践:并行计算(含完整数据并行训练与对照验证)链接
5.9第 5 章测验链接
5.10本章小结与速查表—
—本章更新说明—

阅读说明:讲解文字按课程的要点重新组织,所有代码都在本机实际运行过。过时的写法放在 ❌ 单元里,后面紧跟 ✅ 推荐写法。

0 环境设置:用 1 个 CPU 模拟 8 个设备

本机只有 1 个 CPU 设备。只有一个设备时,pmap 只能映射长度为 1 的轴,完全看不出并行效果。XLA 有一个调试开关,可以把主机 CPU 拆成多个"虚拟设备":

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

这几点很重要:

  • 这个环境变量只在 JAX 后端初始化时读一次,所以必须在 import jax 之前设置。最稳妥的做法是放在 notebook 的第一个单元。
  • 如果已经 import jax 并且跑过计算,再改这个变量不会生效。这时要先重启内核,然后第一个运行下面这个单元。
  • 这 8 个"设备"共用同一块 CPU 和内存,所以不会变快。它的用处是在笔记本电脑上验证多设备代码对不对,比如形状、分片、集体通信。
# 必须在 import jax 之前设置!否则需要重启内核
import os
os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=8"

import io, contextlib, time, warnings
warnings.simplefilter("default")  # 让 DeprecationWarning 等警告可见,便于检查 API 状态

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 版本:", jax.__version__)
print("设备列表:", jax.devices())
assert jax.device_count() == 8, "没有得到 8 个设备:请重启内核后先运行本单元"

def same(a, b, **kw):
    """比较两个数组是否一致:先转成 numpy,避免不同网格上的数组无法直接运算。"""
    return np.allclose(np.asarray(a), np.asarray(b), **kw)
JAX 版本: 0.12.0.1
设备列表: [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 数据并行 (SPMD) 介绍

jit 和 vmap 基本都只在单个计算设备上工作。手头有多块加速器时,最直接的想法是数据并行:把数据分给各个处理单元,每个单元对自己那一块数据做相同的计算。

SPMD:单程序,多数据

SPMD(Single Program, Multiple Data)模型有三个要素:

  1. 单程序:只写一个函数来定义计算,比如神经网络的一次前向传播,或者模拟中的一个时间步;
  2. 多数据:输入数据(比如一批图像、一组模拟参数)被切成若干分片(shard);
  3. 并行执行:每个设备(一块 GPU 或一个 TPU 核心)都跑完全相同的程序,但只处理分给自己的那一片。

对比一下 MIMD(多指令多数据):不同处理器可以跑完全不同的程序。SPMD 只需要考虑一个程序的结构,想清楚要简单得多。

举个例子:有函数 process_data(x) 和大数据集 X,一共 4 个设备:

            X(完整数据)
     ┌───────┬───────┬───────┬───────┐
     │ X[0]  │ X[1]  │ X[2]  │ X[3]  │   ← 切成 4 片
     └───┬───┴───┬───┴───┬───┴───┬───┘
         ▼       ▼       ▼       ▼
      设备0    设备1    设备2    设备3      ← 每个设备都运行 process_data
         └───────┴───┬───┴───────┘
                     ▼
               合并后的结果

JAX 为什么选择 SPMD

  • SPMD 和 JAX 的函数式风格、函数变换的思路很合拍:你写一个只处理一份数据的普通函数,JAX 把它变换成能在多设备上跑的 SPMD 程序;
  • 好处有三个。一是代码复用:单设备的逻辑不用改。二是可扩展:能训练更大的模型、处理更多数据。三是快:比单设备一份一份地处理,总时间短得多。

设备间通信

并行计算时,设备之间常常要交换信息,比如聚合各设备的梯度,或者交换模拟区域的边界数据。JAX 用集体操作(collective operations)来做这件事,例如跨设备求和的 jax.lax.psum(5.5 节)。

JAX 0.12 中实现 SPMD 的三种写法

风格API谁来决定通信适用场景
自动并行jax.jit + NamedSharding 标注输入XLA 编译器根据输入的分片方式自动插入通信绝大多数训练和推理代码,首选
显式分片jax.make_mesh 默认的 AxisType.Explicit 网格,分片成为数组类型的一部分用户在类型层面看到并控制分片想精确掌控分片
手动并行jax.shard_map(以及旧接口 jax.pmap)用户站在单个设备的视角,自己写 psum 等需要自定义通信模式;和 pmap 的思路最接近

原文参考:JAX 官方 jax.pmap 文档;Pacheco《An Introduction to Parallel Programming》(2011);Hao 等《Distributed Deep Learning: A Review》(2020);JAX「Distributed Arrays and Sharding」文档。

5.2 介绍 jax.pmap

jit 为单个加速器编译代码,vmap 做自动向量化。想把计算分到多个设备上,这一节用的工具是 jax.pmap(parallel map,并行映射)。它的用法和 jit、vmap 很像:可以当装饰器用,也可以直接套在函数上。

概念:pmap 的执行模型

  1. 编译:和 jit 一样,用 XLA 编译函数;
  2. 复制:把编译好的程序复制到每个设备上;
  3. 分配数据:沿指定的轴(默认轴 0)切分输入。被映射轴上的每一个元素对应一个设备,所以这个轴的长度不能超过设备数;
  4. 并行执行:所有设备同时对各自的切片跑同一个程序(SPMD);
  5. 收集结果:结果默认留在各个设备上,合起来是一个分布式的 jax.Array。
input (N, 3)                     output (N, 3)
 ┌─────────┐   切片 i    ┌──────────┐   结果 i    ┌─────────┐
 │ 行 0    │ ─────────▶ │ 设备 0: f │ ─────────▶ │ 行 0    │
 │ 行 1    │ ─────────▶ │ 设备 1: f │ ─────────▶ │ 行 1    │
 │  ...    │            │   ...    │            │  ...    │
 │ 行 N-1  │ ─────────▶ │ 设备N-1:f │ ─────────▶ │ 行 N-1  │
 └─────────┘            └──────────┘            └─────────┘

pmap vs vmap

vmappmap
作用把处理单个样本的函数变成处理一批样本的函数把计算复制到多个物理设备上
并行发生在哪里单个设备内部的向量化指令多个加速器之间
被映射轴的长度任意不能超过设备数

在多 GPU 机器或 TPU pod 上,jax.devices() 会列出多个设备。设备数大于 1 时,pmap 才有意义。

⚠️ 注意:只有 1 个设备时,pmap 并不会"退化成 vmap"。这时被映射轴的长度只能是 1,没法像 vmap 那样处理任意长度的批次(长度超过设备数会报错,见 5.3)。

pmap 写法

# 课程示例:检查设备
import jax
import jax.numpy as jnp

print(jax.devices())
print("设备数量:", 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)]
设备数量: 8
# 课程示例(旧接口 pmap):输入前导维度 = 设备数
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("结果:\n", result)
    print("类型:", type(result))
    print("形状:", result.shape)
    print("所在设备数:", len(result.devices()))
except Exception as e:
    print("出错:", e)
结果:
 [[ 0  2  4]
 [ 6  8 10]
 [12 14 16]
 [18 20 22]
 [24 26 28]
 [30 32 34]
 [36 38 40]
 [42 44 46]]
类型: <class 'jaxlib._jax.ArrayImpl'>
形状: (8, 3)
所在设备数: 8

要点:

  1. 输入需要一个前导维度,长度等于(或小于)设备数。pmap 默认沿轴 0 切分,每个切片(这里是 (3,))送到一个设备;
  2. 每个设备同时对自己的切片执行 simple_computation;
  3. 输出是一个普通的 jax.Array,形状和输入相同,数据分布在各个设备上。可以用 result.devices() 和 result.sharding 查看它怎么分布。

⚠️ 注意:旧资料里 pmap 的输出类型是 ShardedDeviceArray,这个类型早就移除了,现在所有数组都是统一的 jax.Array。pmap 的输出带一个 NamedSharding,它的网格只有一个内部轴 _internal_pmap_axis_name(下面会打印出来)。

现代写法

  • jax.make_mesh(axis_shapes, axis_names):创建逻辑设备网格;
  • PartitionSpec(简写为 P):说明数组的每一维对应网格的哪个轴,None 表示这一维不切分;
  • NamedSharding(mesh, P(...)):网格加上分区规则,就是一个具体的分片方案;
  • jax.device_put(x, sharding):按分片方案把数据放到各个设备上;
  • jax.shard_map(f, mesh=, in_specs=, out_specs=):函数 f 看到的是每个设备上的局部块。
# ✅ 现代写法:jit + NamedSharding —— 不用手动 reshape,像写单设备代码一样
mesh = jax.make_mesh((8,), ("data",))
x_sharded = jax.device_put(jnp.arange(8 * 3), NamedSharding(mesh, P("data")))   # (24,) 第 0 维切到 'data'
print("每设备局部形状:", [s.data.shape for s in x_sharded.addressable_shards])

y_jit = jax.jit(simple_computation)(x_sharded)
print("结果分片:", y_jit.sharding.spec)

# ✅ 现代写法:shard_map —— 写法与 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 与 pmap 结果一致")
每设备局部形状: [(3,), (3,), (3,), (3,), (3,), (3,), (3,), (3,)]
结果分片: P('data',)
✓ jit+sharding、shard_map 与 pmap 结果一致

pmap 和 shard_map 有一个关键区别:pmap 会去掉被映射的那一维,shard_map 会保留这一维(只是变小了):

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("设备内看到的局部形状:", shapes)
assert shapes == {"pmap": (3,), "shard_map": (1, 3)}
设备内看到的局部形状: {'pmap': (3,), 'shard_map': (1, 3)}

🔍 补充:pmap 在 JAX 0.12 中的真实状态

🟢 必读

下面直接检查一下:调用 pmap 会不会有警告?官方 docstring 是怎么写的?

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    out = jax.pmap(lambda x: x * 2)(jnp.arange(8.0))
print("调用 pmap 产生的警告数:", len(caught))
print("\n--- jax.pmap.__doc__ 开头 ---")
print(jax.pmap.__doc__[:640])
print("--- 返回值 ---")
print("是否 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)
调用 pmap 产生的警告数: 0

--- 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
--- 返回值 ---
是否 jax.Array: True
sharding: NamedSharding(mesh=Mesh('_internal_pmap_axis_name': 8, axis_types=(Auto,)), spec=P('_internal_pmap_axis_name',), memory_kind=device)

结论:在 0.12.0.1 里调用 jax.pmap 不会产生 DeprecationWarning,可以正常使用。但 docstring 第一行写着 "Old way of doing parallel map. Use jax.shard_map instead.",还说明 pmap 现在是用 jit 和 shard_map 实现的(见迁移指南 migrate_pmap.html)。也就是说它处在"软弃用"状态:能用,但新代码不推荐。

旧资料里常见的 shard_map 导入路径也过时了:

# ❌ 旧写法(过时):旧资料常用的 jax.experimental.shard_map 自 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("导入时的警告:", [str(w.message) for w in caught])
assert any(issubclass(w.category, DeprecationWarning) for w in caught)
导入时的警告: ['jax.experimental.shard_map is deprecated in v0.8.0. Used jax.shard_map instead.']
# ✅ 现代写法:直接用 jax.shard_map
assert hasattr(jax, "shard_map")
print(jax.shard_map)
<function shard_map at 0x115a58460>

三种写法对比

jax.pmap(旧接口)jax.jit + shardingjax.shard_map
输入准备必须 reshape 成 (设备数, ...)任意形状,用 NamedSharding 标注任意形状,用 in_specs 标注
函数看到什么去掉映射轴的单个切片全局数组保留维度的局部块
集体通信手写 psum 等,用 axis_name编译器自动插入手写 psum 等,用网格轴名
多维并行嵌套 pmap(0.12 已不可用,见 5.4)多维网格多维网格
官方态度旧写法推荐推荐

5.3 将数据映射到设备(in_axes、out_axes)

pmap 用的是 SPMD 模型:同一个函数在多个设备上执行,每个设备处理输入的不同部分。这就带来两个问题:哪些数据去哪个设备?各设备的结果怎么拼起来? in_axes 和 out_axes 就是回答这两个问题的。in_axes 说明输入沿哪个轴拆开,out_axes 说明输出沿哪个轴堆起来。

概念:用 in_axes 分发输入

in_axes 可以是整数、None,也可以是和函数参数结构(pytree)对应的元组/列表/字典(可以嵌套):

  • 整数(如 0):这个参数沿第 0 轴分到各设备上。轴上的第 $i$ 个元素交给第 $i$ 个设备,设备内部这一维会被去掉。所以这个轴的长度必须 ≤ 设备数。写 1 就是映射第二个轴,以此类推;
  • None:整个参数被复制到每个设备上,适合模型参数、共享配置这类东西;
  • pytree 结构:例如 def my_func(x, y) 配 in_axes=(0, None),意思是 x 沿第 0 轴映射,y 复制到所有设备。

如果有 $B$ 个样本、$N$ 个设备,你得自己 reshape 成 $(N, B/N, \dots)$,这样每个设备拿到一个 $(B/N, \dots)$ 的小批次(per-device batch)。如果某个参数根本没有可映射的轴(比如 0 维标量却写了 in_axes=0),或者映射轴比设备数长,JAX 都会报错。

⚠️ 注意:pmap 不会把 (B, ...) 的数组自动切成 N 块、每块 (B/N, ...)。它是让映射轴的每个元素对应一个设备,轴长超过设备数就直接报错。"自动切成 B/N 块"其实是 NamedSharding 的行为(见本节的现代写法)。

例如有 4 个设备、data 形状为 (4, 2, 100),in_axes=0 时设备 $i$ 拿到 data[i],形状是 (2, 100)。

概念:用 out_axes 收集输出

  • 整数 0:各设备的输出沿一个新的第 0 轴堆起来。如果每个设备输出 (S, ...),结果就是 (N, S, ...);
  • None:意思是"我保证各设备的输出都一样,只要一份",常用在已经用 lax.psum/lax.pmean 聚合过的值上。这个保证 pmap 不会检查;
  • pytree 结构:函数返回多个值时(比如 return loss, accuracy),可以写 out_axes=(0, None)。

⚠️ 注意:out_axes=None 时,JAX 只是假定各设备输出相同、直接取一份,并不会检查。如果值没有聚合过,就会悄悄地返回某个设备的局部值(本节有 assert 演示)。

pmap 写法

# 课程示例(旧接口 pmap):in_axes=(0, None) —— data 沿轴 0 映射,scalar 复制到每个设备
import numpy as np

def scale(x, k):
    # 按标量因子 k 缩放 x
    return x * k

data = jnp.arange(12, dtype=jnp.float32).reshape((4, 3))   # 4 个条目,特征大小为 3
scalar = jnp.float32(10.0)

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

print("设备数量:", jax.local_device_count())
print("原始数据形状:", data.shape)
print("pmap 结果形状:", result.shape)
print("pmap 结果:\n", result)
print("实际参与计算的设备数:", len(result.devices()))
assert len(result.devices()) == 4   # 只用了 4 个设备:每个设备一行 (3,)
设备数量: 8
原始数据形状: (4, 3)
pmap 结果形状: (4, 3)
pmap 结果:
 [[  0.  10.  20.]
 [ 30.  40.  50.]
 [ 60.  70.  80.]
 [ 90. 100. 110.]]
实际参与计算的设备数: 4

这个例子在 8 个设备上能跑,因为映射轴长 4 ≤ 8:pmap 用了 4 个设备,每个设备拿到一行 (3,) 并乘以 10,out_axes=0 再把 4 个 (3,) 堆成 (4, 3)。

⚠️ 注意:容易以为只有 2 个设备时,"设备 0 拿到 data[0:2]、设备 1 拿到 data[2:4],各为 (2, 3)"。实际上只有 2 个设备时,这段代码会直接报错。下面对照看一下:

# ❌ 错误写法:2 个设备上直接对 (4, 3) 做 pmap(用 devices= 模拟"只有 2 个设备")
two = jax.devices()[:2]
try:
    jax.pmap(scale, in_axes=(0, None), devices=two)(data, scalar)
    raise AssertionError("不应运行成功")
except ValueError as e:
    print("实测报错:", str(e)[:120])

# 同理:映射轴长 16 > 8 个设备也会报错(0.12 的报错信息并不直观)
try:
    jax.pmap(lambda x: x * 2)(jnp.arange(16.0))
    raise AssertionError("不应运行成功")
except ValueError as e:
    print("16 > 8 报错:", str(e)[:120])
实测报错: cannot select an axis to squeeze out which has size not equal to one, got shape=(2, 3) and dimensions=(0,)
16 > 8 报错: cannot select an axis to squeeze out which has size not equal to one, got shape=(2,) and dimensions=(0,)
# ✅ 修正代码:先 reshape 成 (N, B/N, ...),每个设备拿到一个 (B/N, ...) 的小批次
def scale_shape(x, k):
    print("  设备内 x 的形状:", x.shape)   # 追踪时打印一次
    return x * k

per_device = data.reshape(2, 2, 3)                                   # (4, 3) -> (2 设备, 2 行, 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("✓ reshape 后在 2 个设备上运行正确")
  设备内 x 的形状: (2, 3)
✓ reshape 后在 2 个设备上运行正确
# ✅ 现代写法:jit + NamedSharding —— 这才是"自动切成 B/N 块"的语义,不需要 reshape
mesh2 = jax.make_mesh((2,), ("data",), devices=two)
data_sh = jax.device_put(data, NamedSharding(mesh2, P("data", None)))
print("每设备局部形状:", data_sh.addressable_shards[0].data.shape)   # (2, 3):每个设备拿到 2 行

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 与 pmap 结果一致")
每设备局部形状: (2, 3)
✓ jit+sharding 与 pmap 结果一致

out_axes=None 不做检查

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

# 旧接口 pmap:先 pmean 再 out_axes=None(正确用法)
ok = jax.pmap(lambda x: lax.pmean(x.sum(), "d"), axis_name="d", out_axes=None)(x8)

# ❌ 忘了聚合 + out_axes=None:pmap 不报错,默默返回一个设备的局部值 0+1=1
bad = jax.pmap(lambda x: x.sum(), out_axes=None)(x8)
print("正确(pmean 后):", ok, "   忘记聚合:", bad)
assert float(ok) == 15.0                 # 全局:各设备局部和的平均
assert float(bad) == float(x8[0].sum())  # 静默地等于设备 0 的局部值,没有任何报错
正确(pmean 后): 15.0    忘记聚合: 1.0
# ✅ 现代写法:shard_map 的 out_specs=P() 会静态检查"是否真的复制",没聚合就直接报错
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("应当报错")
except ValueError as e:
    print("shard_map 报错(好事):", str(e).splitlines()[0][:110], "...")
shard_map 报错(好事): shard_map applied to the function '_rem_singleton' was given out_specs which require replication which can't b ...

in_axes 的 pytree 与非 0 轴用法

实战中,参数通常是嵌套字典,数据是元组。in_axes 的结构要和参数结构前缀匹配:

# 旧接口 pmap:params 整体复制,x 沿轴 1 映射
params = {"w": jnp.ones((3,)), "b": jnp.float32(0.5)}
xb = jnp.arange(3 * 8, dtype=jnp.float32).reshape(3, 8)   # 设备维放在轴 1

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

out_p = jax.pmap(affine, in_axes=(None, 1))(params, xb)     # 设备 i 拿到 xb[:, i],形状 (3,)
print("pmap 输出形状:", out_p.shape)                         # (8, 3):设备维堆叠到轴 0

# 也可以对字典内部分别指定:w 复制、b 映射
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 输出形状: (8, 3)
# ✅ 现代写法:jit + NamedSharding —— 直接把 xb 的第 1 维切到 'data' 轴,参数复制
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 映射:b 也按 'data' 切分
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("✓ 两种 pytree in_axes 的现代写法与 pmap 一致")
✓ 两种 pytree in_axes 的现代写法与 pmap 一致

5.4 设备网格与轴名称

只沿一个轴分数据,做基础的数据并行就够了。但如果硬件拓扑比较复杂,或者要用更高级的并行策略,就不够用了。这时需要引入设备网格和命名轴。

概念:设备网格

硬件加速器在逻辑上可以排成一个网格,而不只是一个扁平的列表。以 8 个设备为例:

一维 (8,)                     二维 (4, 2)            二维 (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]]

普通的 pmap 调用把设备看成一个一维网格。如果要协调某几组设备之间的通信,或者要把模型并行和数据并行结合起来,就需要显式的多维网格。现代 JAX 用 jax.make_mesh 创建它。

⚠️ 注意:旧资料里的显式网格工具 jax.experimental.maps.Mesh 已经用不了了,jax.experimental.maps 模块在 0.12 中已删除(ModuleNotFoundError)。请改用 jax.make_mesh 或 jax.sharding.Mesh。

概念:命名轴

网格的哪一维对应拆开的数据?沿网格某一维排开的设备之间怎么协调?答案是给轴起名字。命名轴有三个好处:

  1. 好读:比如用 'data_parallel_axis' 表示数据并行,用 'model_parallel_axis' 表示模型并行;
  2. 抽象:代码只引用逻辑上的名字,不用关心底下是 4 块 GPU 还是 8 个 TPU 核心;
  3. 集体操作:lax.psum(x, 'data_parallel_axis') 用名字指定"和哪一组设备通信"。

pmap 用 axis_name= 参数给它唯一的那一维网格起名;现代写法里,轴名就是 jax.make_mesh 的 axis_names。哪怕只有一个并行维度,也建议起个名字。一旦需要设备间通信,名字就是必需的。

pmap 写法

# 课程示例:查看设备(输出因硬件而异)
print(jax.devices())
# 单 CPU: [CpuDevice(id=0)]   多 GPU: [CudaDevice(id=0), ...](旧版 JAX 显示为 GpuDevice)   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)]
# 课程示例(旧接口 pmap):用 axis_name 给并行轴命名
def simple_computation(x):
    return x * 2

data = jnp.arange(4 * 5).reshape((4, 5))   # 每个设备一行(共 4 行)

# pmap 隐式创建一个大小为 4 的一维网格;data 的第 0 轴映射到名为 '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]]
# ✅ 现代写法:shard_map —— 轴名直接写在网格上
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 与 pmap 一致;网格:", mesh4)
✓ shard_map 与 pmap 一致;网格: Mesh('data_parallel_axis': 4, axis_types=(Explicit,))
# ❌ 旧写法(过时):jax.experimental.maps 已在 0.12 中删除
try:
    from jax.experimental.maps import Mesh as OldMesh
    raise AssertionError("不应导入成功")
except ModuleNotFoundError as e:
    print("实测:", e)
实测: No module named 'jax.experimental.maps'
# ✅ 现代写法:jax.make_mesh(推荐,会按硬件拓扑排列设备)或 jax.sharding.Mesh(手动排列)
mesh2d = jax.make_mesh((4, 2), ("data", "model"))
mesh2d_manual = Mesh(np.array(jax.devices()).reshape(4, 2), ("data", "model"))
print(mesh2d)
print("设备排列:\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}   类型 {jax.typeof(a)}")
Mesh('data': 4, 'model': 2, axis_types=(Explicit, Explicit))
设备排列:
 [[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')           每设备局部形状 (2, 2)   类型 float32[8@data,4@model]
P('data', None)              每设备局部形状 (2, 4)   类型 float32[8@data,4]
P(None, 'model')             每设备局部形状 (8, 2)   类型 float32[8,4@model]
P()                          每设备局部形状 (8, 4)   类型 float32[8,4]
P(('data', 'model'), None)   每设备局部形状 (1, 4)   类型 float32[8@(data,model),4]

🔍 补充:jax.make_mesh 在 0.12 中默认是 Explicit 轴

⚪ 选读:用 shard_map 或 jit 遇到 “does not match the specs” 之类的分片报错时读。

jax.make_mesh 现在默认创建 AxisType.Explicit 轴,分片信息会成为数组类型的一部分(就是上面的 float32[8@data,4@model])。如果把一个没分片的数组直接传给 in_specs=P('data') 的 shard_map,会报 "does not match the specs of the input"。有两个解决办法:先 device_put 到对应的 sharding 上;或者建网格时传 axis_types=(AxisType.Auto,)。

mesh_e = jax.make_mesh((8,), ("data",))                              # 默认 Explicit
mesh_a = jax.make_mesh((8,), ("data",), axis_types=(AxisType.Auto,))  # 显式指定 Auto
assert mesh_e.axis_types == (AxisType.Explicit,)

f = lambda x: lax.psum(x.sum(), "data")
raw = jnp.arange(16.0)        # 没有 device_put,未分片
try:
    jax.shard_map(f, mesh=mesh_e, in_specs=P("data"), out_specs=P())(raw)
    raise AssertionError("应当报错")
except ValueError as e:
    print("Explicit 网格 + 未分片输入 报错:", 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("✓ 方案 1(先 device_put)与方案 2(Auto 网格)都得到 120")
Explicit 网格 + 未分片输入 报错: in_specs passed to shard_map: P('data',) does not match the specs of the input: P(None,) for arg: fl ...
✓ 方案 1(先 device_put)与方案 2(Auto 网格)都得到 120

🔍 补充:查看分片布局

⚪ 选读:想直观查看数组在各设备上怎样切分时读。

jax.debug.visualize_array_sharding(x) 能画出每个设备持有数组的哪一块。不过它依赖 rich 库,而 rich 并不是 jax 的依赖。本环境装 flax 时顺带装上了 rich,所以下面能直接画出来。在只装了 jax 的环境里,它会报 ValueError: visualize_sharding requires rich。所以下面还给了一个不依赖 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 不可用:", e)

def show_sharding(x):
    """不依赖 rich 的简易版:列出每个设备持有的切片范围。"""
    print(f"数组形状 {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  
                  
数组形状 (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]

嵌套 pmap → 二维网格

旧教程常用 pmap(pmap(f, axis_name='j'), axis_name='i') 来构造二维设备网格,嵌套 pmap 时 axis_name 也就格外重要。

⚠️ 注意:pmap 改成基于 shard_map 实现之后,0.12 中嵌套 pmap 会直接报错。请改用二维网格 + shard_map。

# ❌ 旧写法(过时):嵌套 pmap 在 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("不应运行成功")
except ValueError as e:
    print("实测报错:", str(e)[:130])
实测报错: The context mesh AbstractMesh('i': 2, axis_types=(Manual,), device_kind=cpu, num_cores=None, platform=cpu) should match the mesh p
# ✅ 现代写法:(2, 4) 网格 + shard_map,可以只沿某一个轴,或同时沿多个轴做集体操作
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]])   # 每行 4 个 1 相加
assert same(psum_ij(xs_sh), [[8.0]])         # 全部 8 个 1 相加
print("✓ 沿 j 求和:", psum_j(xs_sh).ravel(), "  沿 (i, j) 求和:", psum_ij(xs_sh).ravel())
✓ 沿 j 求和: [4. 4.]   沿 (i, j) 求和: [8.]

5.5 集体操作(lax.psum、lax.pmean 等)

每个设备执行同一个函数(SPMD 里的"单程序"),处理各自的那片数据("多数据")。但各个设备之间还是需要通信和聚合。比如分布式训练时,每个设备根据本地批次算出梯度,更新参数之前必须把所有设备的梯度合起来。集体操作就是干这个的。

它们都在 jax.lax 模块里,只能在绑定了对应轴名的变换内部运行。多设备场景下就是 pmap / shard_map。带 axis_name 的 vmap 也行,适合先在单设备上验证集体操作的逻辑(见 第 4 章 速查表的示例)。

概念:psum 与 pmean

lax.psum(并行求和)的要点:

  1. 输入:每个设备上本地算出来的值;
  2. 操作:在 axis_name 指定的轴上,把所有参与设备的值加起来;
  3. axis_name:一个字符串,把集体操作和对应的并行轴关联起来(pmap 的 axis_name=,或者 shard_map 网格上的轴名)。两边必须一致;
  4. 输出:每个设备都拿到同一个总和。
设备0: 3  ─┐                  ┌─▶ 设备0: 66
设备1: 12 ─┤                  ├─▶ 设备1: 66
设备2: 21 ─┼─▶  Σ = 66  ──────┼─▶ 设备2: 66
设备3: 30 ─┘                  └─▶ 设备3: 66

lax.pmean 求平均,等价于 lax.psum(value, axis_name) / N:

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

数据并行训练中,它最常用来对梯度求平均。

现代写法有什么不同:shard_map 里每个设备看到的是保留维度的局部块,所以要先 jnp.sum 求块内的和,再 psum 求全局的和。shard_map 还会追踪每个值"是不是随设备变化"(vma)。psum/pmean 的结果是不变的,可以用 out_specs=P() 只输出一份;在 jax.typeof 里,随设备变化的值带 {V:轴名} 标记。如果用 jit + sharding,连 psum 都不用写:jnp.sum 本身就是全局和,编译器会自动插入跨设备归约。

pmap 写法:psum

# 课程示例(旧接口 pmap):lax.psum
num_devices = 4   # 示例按 4 个设备写(本机 8 个,pmap 只用前 4 个)

def calculate_local_sum(x):
    local_value = jnp.sum(x)                                  # 每个设备先算本地数据的和
    total_sum = lax.psum(local_value, axis_name="devices")    # 在所有设备上求和
    return total_sum

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

pmapped_calculate_sum = jax.pmap(calculate_local_sum, axis_name="devices")   # axis_name 必须与 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]
# ✅ 现代写法:shard_map + psum(以及 jit 自动并行)
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: 每个设备上的 (1, 3) 块
    local = jnp.sum(x)
    print("  local 的类型:", jax.typeof(local), "  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)
# 想和 pmap 一样每个设备一份:输出保留一维,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 自动并行:直接写全局 sum

assert int(psum_one) == int(psum_jit) == 66 and same(psum_each, result_psum)
print("✓ shard_map(out_specs=P()) =", psum_one, ";每设备一份 =", psum_each, ";jit =", psum_jit)
  local 的类型: int32[]{V:devices}   psum 后: int32[]
✓ shard_map(out_specs=P()) = 66 ;每设备一份 = [66 66 66 66] ;jit = 66

pmap 写法:pmean

# 课程示例(旧接口 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))
# 本地平均值: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]
# ✅ 现代写法:shard_map + pmean,以及 jit 自动并行
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

概念:其他常用集体操作

  • lax.pmax(value, axis_name) / lax.pmin(value, axis_name):跨设备求最大值 / 最小值;
  • lax.all_gather(value, axis_name):收集所有设备的 value,沿新的前导轴拼起来,每个设备都拿到完整的一套值;
  • lax.axis_index(axis_name):当前设备在这个轴上的编号(5.7 会用到,这里先介绍);
  • lax.ppermute(value, axis_name, perm):按 (源, 目标) 对做点对点发送,比如环形移位(实现 ring 通信、流水线并行时少不了它);
  • 小技巧:lax.psum(1, axis_name) 就等于这个轴上的设备数。

正确使用集体操作:

  1. 必须在绑定了这个轴名的 pmap / shard_map(或带 axis_name 的 vmap)里面调用,在外面调用会报 NameError: unbound axis name;
  2. 集体操作用的轴名必须和并行轴的名字一致;
  3. 集体操作是同步点:所有设备都执行到这里,计算才能往下走;
  4. 被聚合的值在各个设备上的类型和形状应该一致(psum、pmean 需要数值类型)。

pmap 写法

# 旧接口 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"),        # 每个设备都拿到长度为 8 的完整向量
        "axis_index": lax.axis_index("i"),           # 当前设备编号 0..7
        "axis_size": lax.psum(1, "i"),               # psum(1) = 轴上设备数
        "ppermute": lax.ppermute(v, "i", perm=[(j, (j + 1) % n) for j in range(n)]),   # 设备 j 发给 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"形状 {v.shape},设备 0 上: {v[0]}")
all_gather 形状 (8, 8),设备 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.]
# ✅ 现代写法:shard_map —— 每个设备的局部块是 (1,),输出用 out_specs=P('i') 拼回 (8,) 以便与 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),拼回后为 (8, 8)
        "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("✓ 6 种集体操作的 shard_map 写法与 pmap 完全一致")

# jit 自动并行的等价物:全局 max/min 直接写,all_gather 就是"把数组变成复制的"
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()))   # 传 P() 需要先 with jax.set_mesh(mesh_i):
assert gathered.sharding.is_fully_replicated and same(gathered, xv)
print("✓ jit: jnp.max/jnp.min 自动跨设备;reshard 到复制分片相当于 all_gather")
✓ 6 种集体操作的 shard_map 写法与 pmap 完全一致
✓ jit: jnp.max/jnp.min 自动跨设备;reshard 到复制分片相当于 all_gather

5.6 将 pmap 与其他变换结合使用

JAX 的变换是可以组合的。并行变换很少单独用,常常和 jit、grad、vmap 搭配。

概念

和 jit 组合:一般不需要写 pmap(jit(f)),因为 pmap 自己就会用 XLA 编译。例外情况是:被 jit 的函数只是并行主函数里的一个组件,而你想单独控制它的编译。在现代写法里,jit 本身就是并行的入口,两者合二为一。

和 grad 组合(数据并行训练):把一批数据分到多个设备上,每个设备各算各的梯度,然后汇总:

  1. 定义损失函数,输入是参数和一批数据;
  2. 用 jax.grad / jax.value_and_grad 得到梯度函数;
  3. 对包含梯度计算的函数做并行变换;
  4. 在并行函数里用 lax.pmean 对各设备的梯度求平均,这样所有设备拿到的都是同一个全局平均梯度:

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

每个设备批量相同时,它就等于整批数据上的梯度。用 jit + sharding 时,第 3、4 步都不用写:直接对分片数据做 jax.grad,编译器会自动插入梯度的跨设备归约。

⚠️ 用 shard_map 写第 4 步时,照搬 pmap 的 pmean(grad(...)) 会得到 N 倍的梯度,详见下一个专题小节。

和 vmap 组合:vmap 通常用在并行函数的内部,做设备内的向量化(vectorization,注意不是"量化" quantization)。pmap 负责跨设备的 SPMD,vmap 负责单个设备内的向量化。

嵌套顺序:vmap、grad 在内层定义核心计算,并行变换(pmap / shard_map)在外层负责跨设备执行和集体通信。例如 pmap(grad(my_loss))、pmap(vmap(my_kernel))。

pmap 写法:与 jit

# ❌ 错误写法:jax.device_put(data, 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("不应运行成功")
except ValueError as e:
    print("实测报错:", str(e).splitlines()[0])
# 原因:device_put 的第二个参数要么是单个设备 / Sharding,要么是与数据结构对应的 pytree,传设备列表不会做"分片"
实测报错: device_put device specification must be a tree prefix of the corresponding value; 
# ✅ 修正代码:直接把 numpy 数组交给 pmap(它自己负责分发),并先预热再计时
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()                  # 预热:排除首次编译时间
    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/次")
print(f"pmap(jit(f)): {bench(jit_then_pmap, data) * 1e6:.1f} µs/次(外层 pmap 已负责编译,显式 jit 不带来收益;微秒级差异来自调度开销和测量噪声)")
assert same(result_pj, jit_then_pmap(data))
pmap        : 62.4 µs/次
pmap(jit(f)): 72.5 µs/次(外层 pmap 已负责编译,显式 jit 不带来收益;微秒级差异来自调度开销和测量噪声)

⚠️ 注意:比较 pmap(f) 和 pmap(jit(f)) 的速度时,如果把首次编译时间也算进去,结论就没有意义。正确做法是先预热一次(block_until_ready()),再多跑几次取平均,就像上面那样。另外,在模拟出来的设备上计时,本来就反映不了真实的加速效果。

# ✅ 现代写法:jit + NamedSharding
mesh_dev2 = jax.make_mesh((2,), ("devices",), devices=devices)
data_sh = jax.device_put(data, NamedSharding(mesh_dev2, P("devices")))   # 正确的"分片版 device_put"
result_jit = jax.jit(complex_computation)(data_sh)
assert same(result_jit, result_pj, atol=1e-6)
print("✓ jit+sharding 与 pmap 一致;输出分片:", result_jit.sharding.spec)
✓ jit+sharding 与 pmap 一致;输出分片: P('devices', None)

pmap 写法:与 grad

# 课程示例(旧接口 pmap):pmap + value_and_grad + pmean —— 一次数据并行的梯度计算
def predict(params, x):
    w, b = params
    return w * x + b                     # 线性模型 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)          # 均方误差

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)   # 每个设备算本地梯度
    avg_grads = lax.pmean(grads, axis_name="devices")             # 跨设备平均梯度
    avg_loss = lax.pmean(loss, axis_name="devices")
    return avg_loss, avg_grads

# params 复制(None),x / y 沿轴 0 映射;两个输出都已聚合,所以 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)
# 用 jax.random.key 生成噪声(不用未设种子的 np.random.randn),保证结果可复现
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 + 噪声

avg_loss, avg_grads = pmap_update_step(params, x_data, y_data)
print(f"设备间的平均损失: {avg_loss:.4f}")
print(f"平均梯度 (dw, db): ({avg_grads[0]:.4f}, {avg_grads[1]:.4f})")

# 验证:与单设备整批计算一致
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])
设备间的平均损失: 25.7115
平均梯度 (dw, db): (-41.6569, -9.2923)
# ✅ 现代写法 1:jit + NamedSharding —— 直接对分片数据求 value_and_grad,不写任何集体操作
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)

# ✅ 现代写法 2:shard_map —— 对"全局损失 pmean(本地损失)"求导(不要照搬 pmean(grad),见下一专题)
@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 与 pmap 的损失和梯度完全一致")
✓ jit+sharding、shard_map 与 pmap 的损失和梯度完全一致

pmap 写法:与 vmap

# 课程示例(旧接口 pmap):在 pmap 内部使用 vmap
def process_item(item):
    return jnp.tanh(item) * 2.0

def process_batch_of_items(batch):      # 在每个设备上运行
    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("每个设备的输入数据形状:", data.shape[1:])   # (3, 4)
print("输出结果形状:", result_pv.shape)            # (2, 3, 4)
# pmap 沿大小为 2 的最外层维分发;每个设备拿到 (3, 4),内部 vmap 沿大小为 3 的轴向量化
每个设备的输入数据形状: (3, 4)
输出结果形状: (2, 3, 4)
# ✅ 现代写法:jit(vmap(...)) + 分片输入 —— 外层 vmap 替代 pmap 的映射,分片决定放到哪个设备
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("✓ 一致;每设备局部形状:", result_mv.addressable_shards[0].data.shape)
✓ 一致;每设备局部形状: (1, 3, 4)

⚠️ 专题:shard_map 中梯度变成 N 倍(pmap → shard_map 迁移头号陷阱)

🟢 必读

把 pmap 训练步"一对一"翻译成 shard_map 时,最容易犯的错是:梯度悄悄变成了设备数 $N$ 倍。程序不报错,损失也照样下降(相当于学习率被放大了 $N$ 倍),所以很难发现。很多教程都没提到这一点。

1. 现象

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)                                  # 单设备全局梯度(正确值)
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]
# ❌ 照搬 pmap 的写法:grad 后 pmean
g_naive = smap(lambda w, x: lax.pmean(jax.grad(loss_w)(w, x), "data"))(w0, xs_sh)

print(f"单设备正确值 : {float(ref):.6f}")
print(f"pmap         : {float(g_pmap):.6f}")
print(f"shard_map 照搬: {float(g_naive):.6f}   比值 = {float(g_naive / ref):.4f}")
assert same(g_pmap, ref)
assert np.isclose(float(g_naive / ref), N_DEV)    # 恰好是设备数倍
单设备正确值 : -0.634766
pmap         : -0.634766
shard_map 照搬: -5.078125   比值 = 8.0000

2. 原理

  • 在 pmap 里,in_axes=None 的参数在每个设备上被当作普通的局部值,grad 得到的是本地梯度,所以需要 pmean;
  • 在 shard_map(默认 check_vma=True)里,in_specs=P() 的参数被标记为不变的(invariant),数据 x 是变化的(varying)。两者相乘时,JAX 会自动插入一次 pvary,把不变的值转成变化的值;
  • pvary 的转置(也就是反向传播规则)就是 psum。道理很直接:不变的参数 $w$ 被所有设备共用,它的梯度自然要把所有设备的贡献加起来。所以 jax.grad 返回的已经是 $\sum_i \nabla L_i$;
  • 再对一个已经不变的值做 pmean,结果不会变。最后得到:

$$ \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 $$

证据:看看梯度函数的 jaxpr。开头的 pvary[axes=('data',)] a 把参数变成了变化值,结尾的 psum_invariant[axes=('data',)] 就是 JAX 自动插入的求和:

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)   # 去掉终端颜色控制符
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. 三种正确写法

# ✅ 写法 1(推荐):对"全局损失" pmean(本地损失) 求导 —— 语义最清楚,不依赖自动 psum 的细节
g1 = smap(lambda w, x: jax.grad(lambda w: lax.pmean(loss_w(w, x), "data"))(w))(w0, xs_sh)

# ✅ 写法 2:grad 已自动求和,再除以设备数
g2 = smap(lambda w, x: jax.grad(loss_w)(w, x) / lax.axis_size("data"))(w0, xs_sh)

# ✅ 写法 3:先用 pcast 把参数显式转成 varying,恢复 pmap 的"本地梯度"语义,再 pmean
g3 = smap(lambda w, x: lax.pmean(jax.grad(loss_w)(lax.pcast(w, "data", to="varying"), x), "data"))(w0, xs_sh)

# 附:jit + sharding 根本不存在这个问题
g4 = jax.jit(jax.grad(loss_w))(w0, xs_sh)

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

4. 一句话判断规则

在 shard_map 里,对 in_specs=P() 的复制参数求 grad,得到的已经是所有设备梯度之和。要么对 pmean 之后的全局损失求导,要么别再对梯度做 pmean(改成除以 N)。

另外,check_vma=False 可以关掉这套机制,恢复 pmap 那样的语义。但这样也就没有了 out_specs 的复制性检查,不推荐。

5. pmap → shard_map 迁移检查清单

#pmapshard_map检查要点
1in_axes=0in_specs=P('data')数据不用再 reshape 成 (N, ...);(B, ...) 会被均分成 (B/N, ...)
2in_axes=Nonein_specs=P()参数变成 invariant,对它求 grad 会自动 psum
3out_axes=0out_specs=P('data')局部块沿这一维拼接,而不是新建一维堆起来
4out_axes=Noneout_specs=P()shard_map 会静态检查复制性,没聚合就报错
5axis_name='i'jax.make_mesh((N,), ('i',))轴名写在网格上;make_mesh 默认 Explicit,输入要先 device_put
6pmean(grad(loss))grad(lambda p: pmean(loss(p)))最重要:不改就是 N 倍梯度
7映射轴被去掉,x.shape == (...)映射轴保留,x.shape == (B/N, ...)函数里依赖形状的代码(比如 x[0]、reshape)要检查
8嵌套 pmap多维网格 + psum(x, 'j')0.12 中嵌套 pmap 已不可用
9迁移后—拿单设备结果把梯度 assert 一遍,这是发现 N 倍问题最靠谱的办法

5.7 调试 pmap 化的函数

并行程序比单设备程序难调试。错误可能出在函数逻辑本身,也可能出在数据分发或结果收集上。而且并行函数同样要经过 XLA 编译,调试编译后代码的那些麻烦它一个不少。

概念:常见问题

一、形状不匹配(最常见)

  • 输入:映射轴的长度不能超过设备数(通常就取 jax.local_device_count());
  • 输出:各设备的输出沿 out_axes 堆起来,形状要符合预期;
  • 把复制和分片搞混:in_axes 里为 None 的参数是复制,不是切分。把本该分片的数据设成 None(或者反过来),设备里的形状就会不对。

⚠️ 注意:in_axes 指定的轴长度不必等于设备数,只要 ≤ 设备数就行。小于设备数时,pmap 只用前面几个设备(5.3 节的 (4, 3) 示例在 8 个设备上只用了 4 个)。

二、集体操作

  • 轴名必须一致:lax.psum(x, 'my_devices') 必须和 pmap(..., axis_name='my_devices') 完全一样,拼错很常见;
  • 卡住不动:如果不是所有设备都执行到集体操作,程序会一直等下去。所以不要把集体操作放进按设备分叉的分支里。包含 psum 等集体操作的 lax.cond,条件必须在所有设备上取值相同(只依赖复制的值)。像 5.7 的 jax.debug.print 示例那样按 lax.axis_index 分支时,分支里只能放不需要通信的操作。在 JAX 0.12 上用 4 个模拟设备试过:lax.cond(lax.axis_index('d') == 0, lambda u: lax.psum(u, 'd'), lambda u: u, x) 在 pmap 和 shard_map 下都会卡住,大约 40 秒后进程被 XLA 强制终止;
  • 结果不对:各设备传给集体操作的值类型或形状不一致,可能报错,也可能得到错误的结果。

三、追踪与编译错误

  • 抽象追踪器:追踪期间不能依赖输入的实际值。比如用 Python if 判断 JAX 数组(包括 lax.axis_index(...))会报 TracerBoolConversionError。应该改用 jax.lax.cond 或 jnp.where,或者让条件只依赖静态参数;
  • 副作用:普通 print、修改外部变量这类副作用只在追踪时执行一次,不会每个设备、每次调用都执行。

概念:调试策略

  1. 先去掉并行:取出一个分片,不用 pmap(甚至不用 jit)单独跑,先把逻辑错误修好;
  2. 减少设备数:先用 1 个或 2 个设备。如果只在多设备时出错,问题多半在分发、收集或集体操作上;
  3. 仔细核对形状:调用前核对输入形状、in_axes 和 jax.local_device_count(),调用后核对输出形状;
  4. 用 jax.debug.print 代替 print:它在运行时打印,能看到每个设备上的值。配合 lax.axis_index 和 lax.cond 可以只在某个设备上打印。各设备打印的顺序没有保证,建议把设备编号一起打出来。jax.debug.breakpoint() 会让所有设备暂停并进入 pdb,用的时候要小心;
  5. 单独测试集体操作:写一个只做这个集体操作的最小函数,核对输入、输出和轴名;
  6. 最后的办法:在局部范围内用 with jax.disable_jit():,有时能拿到更好读的 Python 报错(0.12 中对 pmap 仍然有效)。

pmap 写法:形状检查

# ❌ 错误写法:函数体用了 lax.axis_index,却只写了 `from jax import pmap`,没有导入 lax
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, {})          # 在干净的命名空间中运行这段代码
    raise AssertionError("不应运行成功")
except NameError as e:
    print("实测报错: NameError -", e)
实测报错: NameError - name 'lax' is not defined
# ✅ 修正代码:补上 from jax import lax
from jax import pmap, lax

num_devices = 4   # 实际使用时应写 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 是分片的(每个设备形状 (10,)),y 是复制的(标量)
    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)      # (4, 10)
assert sharded_data.shape[0] <= jax.local_device_count()
print("复制数据形状:", replicated_data.shape)   # ()
output_a = pmapped_func(sharded_data, replicated_data)
print("输出形状:", output_a.shape)              # (4, 10)
assert output_a.shape == (4, 10)
assert same(output_a, sharded_data * 5.0 + jnp.arange(4)[:, None])   # 第 i 行加上设备编号 i
分片数据形状: (4, 10)
复制数据形状: ()
输出形状: (4, 10)
# ✅ 现代写法:shard_map —— 局部块是 (1, 10),lax.axis_index 用法相同
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 与 pmap 一致")
✓ shard_map 与 pmap 一致

pmap 写法:jax.debug.print

下面这个示例的原始写法有两处问题:一是用 Python if 判断 lax.axis_index,二是 psum 之后的预期结果很容易算错。先运行有问题的写法,再给出修正。

# ❌ 错误写法:用 Python if 判断追踪值 lax.axis_index(...) == 0
def func_with_debug_print(x):
    intermediate = x * 2
    if lax.axis_index("data_axis") == 0:
        jax.debug.print("设备 0 中间值: {val}", val=intermediate)
    result = lax.psum(intermediate, axis_name="data_axis")
    jax.debug.print("设备 {id} 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("不应运行成功")
except jax.errors.TracerBoolConversionError as e:
    print("实测报错: TracerBoolConversionError -", str(e).splitlines()[0])
实测报错: TracerBoolConversionError - Attempted boolean conversion of traced array with shape bool[].
# ✅ 修正代码:用 lax.cond 根据设备编号决定是否打印
def func_with_debug_print_fixed(x):
    intermediate = x * 2
    # 把 intermediate 作为操作数显式传给分支(不要靠闭包捕获),在 shard_map 中也能用
    lax.cond(lax.axis_index("data_axis") == 0,
             lambda v: jax.debug.print("设备 0 中间值: {val}", val=v),
             lambda v: None,
             intermediate)
    result = lax.psum(intermediate, axis_name="data_axis")
    jax.debug.print("设备 {id} 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()   # 等待 jax.debug.print 的异步输出全部打印完,再打印返回值
print("返回值:\n", output_b)
# 设备 0 的中间值 [0 2 4] + 设备 1 的中间值 [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)       # 用单设备计算核对预期值
设备 1 psum 后结果: [ 6 10 14]
设备 0 中间值: [0 2 4]
设备 0 psum 后结果: [ 6 10 14]
返回值:
 [[ 6 10 14]
 [ 6 10 14]]

⚠️ 注意:psum 之后两个设备的结果都是 [ 6 10 14],而不是 [ 6 8 10]。设备 0 的中间值是 [0 2 4],设备 1 是 [6 8 10],逐元素相加得到 [0+6, 2+8, 4+10] = [6, 10, 14]。一个常见的错误是把设备 1 的中间值当成了 psum 的结果。并行代码的预期值,一定要用单设备计算核对一遍(比如上面的 data.sum(0) * 2)。

# ✅ 现代写法:shard_map 中 jax.debug.print / lax.cond / lax.axis_index 的用法完全相同
mesh_d = jax.make_mesh((2,), ("data_axis",), devices=jax.devices()[:2])
# 注意外面要包 jax.jit:0.12 中不加 jit、逐个算子即时执行的 shard_map 遇到 lax.cond 会报 NotImplementedError
out_dbg = jax.jit(jax.shard_map(lambda x: func_with_debug_print_fixed(x[0])[None],   # 局部块 (1, 3) -> 取出 (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("✓ shard_map 版本结果一致:", out_dbg.tolist())
设备 1 psum 后结果: [ 6 10 14]
设备 0 中间值: [0 2 4]
设备 0 psum 后结果: [ 6 10 14]
✓ shard_map 版本结果一致: [[6, 10, 14], [6, 10, 14]]

🔍 补充:0.12 中常见错误长什么样

⚪ 选读:调试并行代码、需要认出报错信息时查阅。

调试时最有用的本事是认出报错。下面把本章最常见的几类错误集中跑一遍:

def show_err(title, fn):
    try:
        fn()
        print(f"[{title}] 没有报错")
        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("轴名拼写不一致     ", lambda: jax.pmap(lambda x: lax.psum(x, "dev"), axis_name="devices")(x8)),
    show_err("在并行函数外调用 psum", lambda: lax.psum(x8, "i")),
    show_err("映射轴长 16 > 8 设备 ", lambda: jax.pmap(lambda x: x)(jnp.arange(16.0))),
    show_err("标量却写 in_axes=0   ", lambda: jax.pmap(lambda x, y: x * y, in_axes=(0, 0))(x8, 2.0)),
    show_err("Python if 判断追踪值 ", 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✓ disable_jit 下 pmap 仍可运行:", r)
[轴名拼写不一致     ] NameError: Found an unbound axis name: dev. To fix this, please call psum under `jax.shard_map`.
[在并行函数外调用 psum] NameError: unbound axis name: i
[映射轴长 16 > 8 设备 ] ValueError: cannot select an axis to squeeze out which has size not equal to one, got shape=(2,) and dimensions=(0,)
[标量却写 in_axes=0   ] ValueError: One of pjit arguments with pytree key path flat_args[1] is incompatible with its sharding annotation NamedSharding(mesh=Mesh('_int
[Python if 判断追踪值 ] TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].

✓ disable_jit 下 pmap 仍可运行: [6. 6. 6. 6.]

怎么看这些报错:

  • 轴名写错时报 NameError: unbound axis name,提示里还提到了 shard_map,这也说明 pmap 底层就是 shard_map;
  • 映射轴比设备数长时,0.12 报的是底层的 cannot select an axis to squeeze out...,从字面上看不出是"设备不够"。遇到这个报错,先检查映射轴的长度和 jax.local_device_count()。

5.8 动手实践:并行计算

概念

并行函数遵循 SPMD:同一个 Python 函数在所有设备上运行,每个设备拿到输入的不同部分。JAX 默认使用所有可用的设备(没有 GPU/TPU 时用 CPU),用 jax.device_count() 可以查看设备数。只有 CPU 时它通常返回 1,代码照样能跑,只是没有跨设备并行(本教程已经模拟出了 8 个设备)。

下面三个示例分别练习:简单的数据并行、用 in_axes 复制参数、用 lax.psum 做集体操作。有两个现象要留意:

  • 函数里的普通 print 只在追踪(编译)时执行一次,并不是每个设备打印一次。同一个函数再次调用时命中编译缓存,就不再打印了;
  • 输出是分布在各设备上的 jax.Array,形状和输入相同,每一行对应一个设备上的计算结果。

⚠️ 注意:容易以为 "Compiling and running..." 这条消息会每个设备打印一次。实际上普通 print 只打印一次(下面用 assert 验证),输出类型也是 jax.Array。另外,jax.process_index() 返回的是进程编号(多机训练时才有意义,单机上永远是 0),不是设备编号,不能拿它来打印 "on device ..."。设备编号要用 lax.axis_index。

pmap 写法

# 设备检查
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)]
# 课程示例 1(旧接口 pmap):简单数据并行
def scale_by_two(x):
    print("Compiling and running scale_by_two...")   # 只在追踪时执行
    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]]
# 验证:普通 print 在 8 个设备上只打印 1 次;第二次调用命中缓存,0 次
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("第一次调用打印次数:", buf1.getvalue().count("Compiling"), "  第二次:", buf2.getvalue().count("Compiling"))
assert buf1.getvalue().count("Compiling") == 1 and buf2.getvalue().count("Compiling") == 0
第一次调用打印次数: 1   第二次: 0
# ✅ 现代写法:jit + NamedSharding —— 不需要 reshape
mesh_dev = jax.make_mesh((num_devices,), ("devices",))
g_sh = jax.device_put(global_data, NamedSharding(mesh_dev, P("devices")))   # (32,) 每设备 4 个
result1_m = jax.jit(lambda x: x * 2)(g_sh)
assert same(result1_m, result1.ravel())
print("✓ 一致;每设备局部形状:", result1_m.addressable_shards[0].data.shape)
✓ 一致;每设备局部形状: (4,)
# 课程示例 2(旧接口 pmap):in_axes=(0, None)
def scale_and_add(x, y):
    # process_index 是进程编号(单机恒为 0),不是设备编号,所以这里写 "in process" 而不是 "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)    # 每个元素 = 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.]]
# ✅ 现代写法:jit + NamedSharding —— 标量不需要任何标注,自动在各设备上复制
result2_m = jax.jit(lambda x, y: x * 2 + y)(g_sh, scalar_y)
assert same(result2_m, result2.ravel())
print("✓ 一致")
✓ 一致
# 课程示例 3(旧接口 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)   # 仍然是分片的
print("Output sum shape:", result_sum.shape)               # 每个设备一份(复制)
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
# ✅ 现代写法 A:jit + sharding —— 全局 sum,编译器自动插入跨设备归约
@jax.jit
def scale_and_sum_jit(x):
    scaled = x * 2
    return scaled, jnp.sum(scaled)

# ✅ 现代写法 B:shard_map —— 与 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: 每个设备上的 (4,) 局部块
    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("✓ 两种现代写法与 pmap 一致,全局和 =", int(expected_sum))
✓ 两种现代写法与 pmap 一致,全局和 = 992

完整的数据并行训练循环,并与单设备对照

前面只演示了一步梯度计算。下面完整地训练一个线性回归 $y = wx + b$(真值 $w=3,\ b=2$),并验证:

每个设备的批量相同时,数据并行训练和单设备整批训练在数学上是等价的,参数轨迹应该在浮点误差范围内完全一致。

设置:64 个样本、8 个设备(每个设备 8 个样本),学习率 0.1,全批量梯度下降 200 步。参数和数据都作为函数参数传进去,不在 jit 的闭包里捕获数组。

让参数常驻设备:pmap 时代常用 jax.device_put_replicated 把参数复制到每个设备上,这个 API 在 0.12 中已经删除了:

# ❌ 旧写法(过时):pmap 时代的配套 API device_put_replicated / device_put_sharded 已删除(旧资料常见)
for name in ["device_put_replicated", "device_put_sharded"]:
    try:
        getattr(jax, name)
        raise AssertionError(f"{name} 不应存在")
    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 ...
# ✅ 现代写法:device_put 配合复制的 NamedSharding(P())/ 分片的 NamedSharding(P('batch'))
N_DEV = jax.local_device_count()
mesh = jax.make_mesh((N_DEV,), ("batch",))
repl_sharding = NamedSharding(mesh, P())          # 每个设备一份完整拷贝
data_sharding = NamedSharding(mesh, P("batch"))   # 沿第 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("✓ 参数已复制到", N_DEV, "个设备")
✓ 参数已复制到 8 个设备
import matplotlib.pyplot as plt

N_SAMPLES, LR, STEPS = 64, 0.1, 200

# 构造数据:x ~ U(-1, 1),y = 3x + 2 + 噪声
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) 单设备基准 ----------
@jax.jit
def step_single(params, x, y):
    loss, grads = jax.value_and_grad(mse)(params, x, y)
    return sgd(params, grads), loss

# ---------- 1) 旧接口 pmap:参数复制,梯度 pmean 同步 ----------
def step_pmap_fn(params, x, y):
    loss, grads = jax.value_and_grad(mse)(params, x, y)   # 本地梯度
    grads = lax.pmean(grads, axis_name="batch")            # 同步:平均所有设备的梯度
    loss = lax.pmean(loss, axis_name="batch")
    return sgd(params, grads), loss                       # 各设备用相同的梯度更新 -> 参数保持一致

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

# ---------- 2) ✅ jit + sharding:与单设备是同一个函数,并行策略完全由输入的 sharding 决定 ----------
step_jit = step_single

# ---------- 3) ✅ shard_map:手写集体操作;对"全局损失"求导,避开 N 倍梯度的坑 ----------
@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 = {
    "单设备": 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"{'方法':14s} {'w':>10s} {'b':>10s} {'最终损失':>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}")
方法                      w          b         最终损失
单设备              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
# 验证:三种并行写法与单设备结果一致(参数 + 整条损失曲线)
p_ref, l_ref = runs["单设备"]
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} 参数与损失曲线均与单设备一致")
assert abs(float(p_ref["w"]) - 3) < 0.1 and abs(float(p_ref["b"]) - 2) < 0.1   # 收敛到真值附近
assert runs["jit+sharding"][0]["w"].sharding.is_fully_replicated               # 参数始终复制在所有设备上

plt.figure(figsize=(6, 3.5))
styles = {"单设备": 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           参数与损失曲线均与单设备一致
✓ jit+sharding   参数与损失曲线均与单设备一致
✓ shard_map      参数与损失曲线均与单设备一致
No description has been provided for this image

几点实战经验:

  • 四条曲线完全重合。梯度经过 pmean 同步,保证了所有设备上的参数始终一样。数据并行的本质就是:每一步的全局梯度相同 → 参数更新相同;
  • 如果每个设备的批量不一样大(比如最后一个不完整的批次),"先求本地均值再平均"不等于全局均值。这时应该用 psum(本地损失之和) / psum(本地样本数)(见补充练习 4);
  • jit + sharding 的训练步和单设备的一模一样,这正是官方推荐它的原因:并行策略和模型代码是分开的。

课程练习回顾

这一节的动手部分就是上面的示例 1–3。建议自己改改 data_size_per_device、去掉 axis_name,或者故意把 in_axes 写错,看看会报什么错。下面再补充几道更有分量的练习。

✍️ 补充练习 1:跨设备标准化(分布式均值与方差)

数据 (64, 5) 沿第 0 维分布在 8 个设备上。用 shard_map 写一个函数,对每一列做全局标准化 $(x-\mu)/\sigma$,其中 $\mu, \sigma$ 必须是全局统计量。要求只用 psum,并和单设备的结果对比。

提示:$\mu = \frac{\sum x}{n}$,$\sigma^2 = \frac{\sum x^2}{n} - \mu^2$,$\sum$ 和 $n$ 都要跨设备求和。这就是 SyncBN(跨设备同步 BatchNorm) 的原理。

自检接口:global_standardize(x) 接收沿第 0 维分布在 8 个设备上(NamedSharding(mesh, P("data", None)),mesh = jax.make_mesh((8,), ("data",)))的二维数组,返回逐列全局标准化的结果;行数是 8 的任意倍数都应该适用。

# ✍️ 作答区:补充练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 1
try:
    global_standardize
except NameError as e:
    print(f"⏳ 补充练习 1 尚未作答:{e}")
else:
    _mesh = jax.make_mesh((8,), ("data",))
    # 新数据 (40, 3):每个设备上的 5 行均值不同,只用本地统计量必然算错
    _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"输出形状应与输入相同 (40, 3),得到 {_out.shape}"
    _ref = (_X - _X.mean(0)) / np.sqrt(_X.var(0) + 1e-6)
    assert np.allclose(_out, _ref, atol=1e-3), "与单设备全局标准化不一致:均值、方差以及样本数都要跨设备汇总"
    print("✅ 补充练习 1 自检通过")
⏳ 补充练习 1 尚未作答:name 'global_standardize' is not defined

✍️ 补充练习 2:用 ppermute 手写环形 all-reduce

psum 在底层常用"环形"通信实现。请只用 lax.ppermute(每个设备把数据传给右边邻居),实现一个与 lax.psum 结果相同的 ring_allreduce。分别用 pmap 和 shard_map 运行,并与 psum 对比。

提示:传 $N-1$ 轮,每轮把"刚收到的值"继续往右传,同时累加到本地。

自检接口:ring_allreduce(x, axis_name, n) 在映射轴 axis_name(共 n 个设备)内使用,返回与 lax.psum(x, axis_name) 相同的结果;自检会在一个轴名不是 "i" 的网格上用 shard_map 调用它,并检查其中没有用到 psum。

# ✍️ 作答区:补充练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 2
try:
    ring_allreduce
except NameError as e:
    print(f"⏳ 补充练习 2 尚未作答:{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), \
        "每个设备都应得到所有设备数据之和(检查传递的轮数和累加的是哪个值)"
    _jx = str(jax.make_jaxpr(_f)(_vs))
    assert "ppermute" in _jx, "应当用 lax.ppermute 在设备间传递数据"
    assert "psum" not in _jx, "本题要求只用 ppermute,不能调用 psum / pmean"
    print("✅ 补充练习 2 自检通过")
⏳ 补充练习 2 尚未作答:name 'ring_allreduce' is not defined

✍️ 补充练习 3:二维网格上的"数据并行 × 张量并行"矩阵乘

在 (4, 2) 网格 ('data', 'model') 上计算 $Y = XW$,其中 $X$ 为 (16, 8),$W$ 为 (8, 6):

  • $X$ 的行沿 data 切分,列(收缩维)沿 model 切分:P('data', 'model');
  • $W$ 的行(收缩维)沿 model 切分,列不切:P('model', None)。

每个设备先算局部乘积 $X_{ij} W_j$,这只是部分和,需要沿哪个轴 psum?out_specs 应该是什么?请分别用 shard_map 和 jit + sharding 实现。(附加观察:在 0.12 默认的 Explicit 网格上,jit 版本会遇到什么问题?)

注意:$X$、$W$ 都作为函数参数传入,不要在 jit 函数的闭包里捕获它们。

自检接口:matmul_dp_tp(x, w) 是用 shard_map 写的版本(已 jit),输入分别按 P("data", "model") 和 P("model", None) 分布在 (4, 2) 网格 ("data", "model") 上,返回完整的 $XW$;自检会换一组形状不同的 $X$、$W$,只检查 shard_map 版本。

# ✍️ 作答区:补充练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 3
try:
    matmul_dp_tp
except NameError as e:
    print(f"⏳ 补充练习 3 尚未作答:{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)
    _W = jax.random.normal(_kw, (10, 3))       # 每设备 (5, 3)
    _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"结果形状应为 (12, 3),得到 {np.asarray(_Y).shape};检查 out_specs"
    assert np.allclose(np.asarray(_Y), _ref, atol=1e-4), "结果与 X @ W 不一致:局部乘积只是部分和,想想应沿哪个轴求和"
    print("✅ 补充练习 3 自检通过")
⏳ 补充练习 3 尚未作答:name 'matmul_dp_tp' is not defined

✍️ 补充练习 4:不等长批次下的正确梯度平均

8 个设备,但每个设备的有效样本数不同(用掩码 mask 表示,1 为有效、0 为填充)。写出 MSE 梯度计算,使结果等于"只在所有有效样本上"计算的全局梯度。先说明为什么"本地均值 + pmean"在这里是错的。

答:设设备 $i$ 有 $n_i$ 个有效样本,本地均值损失 $L_i = \frac{1}{n_i}\sum_{k} \ell_k$。$\frac{1}{N}\sum_i L_i$ 对每个设备等权,而正确的全局损失 $\frac{\sum_i \sum_k \ell_k}{\sum_i n_i}$ 对每个样本等权,只有所有 $n_i$ 相等时两者才相同。

自检接口:correct_grad(p, x, y, m) 在 jax.pmap(correct_grad, axis_name="i", in_axes=(None, 0, 0, 0), out_axes=None) 下运行,p = {"w": 标量, "b": 标量},模型为 $\hat y = wx + b$;返回只在全部有效样本上计算的 MSE 梯度(与 p 同结构)。

# ✍️ 作答区:补充练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 4
try:
    correct_grad
except NameError as e:
    print(f"⏳ 补充练习 4 尚未作答:{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)            # 填充位置放上离谱的值:没被掩掉就会露馅
    _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                  # MSE 梯度解析式: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), "w 的梯度与全体有效样本上的 MSE 梯度不一致"
    assert np.isclose(float(_g["b"]), 2 * np.mean(_r), atol=1e-4), "b 的梯度与全体有效样本上的 MSE 梯度不一致"
    print("✅ 补充练习 4 自检通过")
⏳ 补充练习 4 尚未作答:name 'correct_grad' is not defined

5.9 第 5 章测验

说明:课程的官方测验见测验页(需要登录)。下面 8 道题重点考察本章的易错点,答案和解析在每题下方的折叠块里。

第 1 题 有 8 个设备,x.shape == (32, 10),直接调用 jax.pmap(f)(x) 会怎样?

A. 每个设备拿到 (4, 10),正常运行 B. 报错,因为映射轴长度 32 超过了设备数 8 C. 只用前 8 行,其余丢弃 D. 自动退化为 vmap

答案与解析

B。pmap 映射轴上的每个元素对应一个设备,长度不能超过设备数。正确做法是 x.reshape(8, 4, 10),或者改用 NamedSharding(mesh, P('data')),后者才会把 32 行均分成每个设备 4 行。

第 2 题 jax.pmap(f, in_axes=(0, None)) 中的 None 表示什么?对应的现代写法是?

A. 该参数不参与计算 B. 该参数沿最后一维切分 C. 该参数完整复制到每个设备,对应 P() D. 该参数只放在设备 0

答案与解析

C。None 表示复制,常用于模型参数。现代写法是 sharding 里的 P(),或 shard_map 的 in_specs=P()。

第 3 题 下面代码的输出是?

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. 报错

答案与解析

C。psum 把同一个总和 0+1+2+3=6 返回给每个设备,默认的 out_axes=0 再把 4 份结果堆起来。用 out_axes=None 就只拿一份。

第 4 题 关于 pmap 的 out_axes=None,正确的是?

A. 会检查各设备输出是否相同,不同就报错 B. 不做检查,输出不同时会悄悄返回某个设备的值 C. 会自动对各设备输出求平均 D. 表示不返回任何值

答案与解析

B。5.3 节用 assert 验证过。shard_map 的 out_specs=P() 则会静态检查复制性。

第 5 题 在 JAX 0.12 中,关于 jax.pmap 的说法正确的是?

A. 已被删除 B. 调用时会产生 DeprecationWarning C. 能正常使用、无警告,但官方称其为"旧写法",内部由 jit + shard_map 实现 D. 输出类型是专门的分片数组类,不是 jax.Array

答案与解析

C。实际运行可以看到:没有警告;docstring 写着 "Old way of doing parallel map. Use jax.shard_map instead.";输出是 jax.Array。

第 6 题 从 pmap 迁移到 shard_map,原代码是 pmean(grad(local_loss)(params)),params 用 in_specs=P()。不做其他修改时,得到的梯度是?

A. 正确的平均梯度 B. 设备数倍的梯度 C. 1/设备数倍的梯度 D. 报错

答案与解析

B。不变的参数和变化的数据一起运算时,会被隐式地 pvary,而 pvary 的反向传播是 psum,所以 grad 已经是"本地梯度之和"了;再对一个不变的值做 pmean,结果不变。修正方法:对 pmean(local_loss) 求导,或者 grad 之后除以设备数。

第 7 题 在并行函数中写 if lax.axis_index('i') == 0: ... 会怎样?

A. 只在设备 0 上执行 B. 所有设备都执行 C. 报 TracerBoolConversionError D. 程序挂起

答案与解析

C。axis_index 是追踪值,不能用在 Python 的 if 里,要用 lax.cond 或 jnp.where。

第 8 题 用 jit + sharding 写数据并行训练,下面哪一项是不需要的?

A. jax.make_mesh 建网格 B. 把数据 device_put 到 NamedSharding(mesh, P('data')) C. 在损失函数里手写 lax.pmean 同步梯度 D. 把参数 device_put 为复制(P())

答案与解析

C。在 jit + sharding 下,函数看到的是全局数组,jnp.mean 本身就是全局均值,编译器会自动插入跨设备归约。lax.pmean 只能在绑定了轴名的 pmap/shard_map(或带 axis_name 的 vmap)里用。(D 不是必需的,但推荐这么做。)

5.10 本章小结

要点清单

  • SPMD:同一个程序处理不同的数据分片,设备之间用集体操作通信;
  • pmap 语义(旧接口):映射轴上的每个元素对应一个设备,设备内这一维被去掉;映射轴长度 ≤ 设备数。大批量要先 reshape 成 (N, B/N, ...);
  • in_axes:整数表示沿该轴映射,None 表示复制,也可以是 pytree;out_axes:整数表示堆叠,None 表示只取一份(不做检查!);
  • 轴名:给并行轴命名,集体操作通过轴名找到通信组,两边必须一致;
  • 集体操作:psum、pmean、pmax、pmin、all_gather、ppermute、axis_index,只能在绑定了轴名的 pmap/shard_map(或带 axis_name 的 vmap)内调用,都是同步点,不要放进按设备分叉的分支;
  • 数据并行训练:每个设备算本地梯度 → 跨设备平均 → 各设备做相同的更新 → 参数保持一致,结果与单设备整批训练等价(已 assert 验证);
  • 调试:先去掉并行跑通单个分片;核对形状与设备数;用 jax.debug.print + lax.axis_index + lax.cond;预期值用单设备计算核对;
  • JAX 0.12 现状:pmap 能用、无警告,但官方称其为旧写法,内部由 jit + shard_map 实现;嵌套 pmap、device_put_replicated/sharded、jax.experimental.maps 都已不可用,jax.experimental.shard_map 已弃用;
  • 现代写法:首选 jit + NamedSharding(编译器自动插入通信);需要手写通信时用 jax.shard_map;jax.make_mesh 默认是 Explicit 轴;
  • 迁移头号陷阱:shard_map 中对复制参数求 grad 会自动 psum,照搬 pmean(grad) 会得到 N 倍梯度。

速查表

本章 API

API作用
jax.devices() / jax.device_count() / jax.local_device_count()查看设备 / 设备总数 / 本进程的设备数
XLA_FLAGS=--xla_force_host_platform_device_count=8在 CPU 上模拟 8 个设备(须在 import jax 前设置)
jax.pmap(f, axis_name=, in_axes=, out_axes=, devices=)旧接口:多设备并行映射
lax.psum(x, axis) / lax.pmean(x, axis)跨设备求和 / 求平均,结果在各设备上相同
lax.pmax / lax.pmin跨设备求最大 / 最小值
lax.all_gather(x, axis, tiled=)每个设备都收集到所有设备的值
lax.ppermute(x, axis, perm)按 (源, 目标) 对做点对点发送
lax.axis_index(axis) / lax.axis_size(axis)当前设备在轴上的编号 / 轴的大小
lax.pcast(x, axis, to="varying")shard_map 中把不变值显式转成变化值
jax.make_mesh(shape, names, axis_types=)创建逻辑设备网格(0.12 默认 Explicit 轴)
NamedSharding(mesh, P(...))分片方案:网格 + 每一维映射到哪个网格轴
jax.device_put(x, sharding)按分片方案放置数据(P() 为复制)
jax.reshard(x, NamedSharding(...))改变数组的分片方式(传 P(...) 需在 jax.set_mesh 上下文中)
x.sharding / x.addressable_shards / jax.typeof(x)查看分片 / 各设备局部块 / 带分片信息的类型
jax.debug.visualize_array_sharding(x)画出分片布局(需要 rich;它不是 jax 的依赖,flax 会带上)
jax.shard_map(f, mesh=, in_specs=, out_specs=)手动并行:函数看到局部块,手写集体操作
jax.debug.print在编译函数中运行时打印
jax.disable_jit()禁用编译,作为调试的最后手段

pmap ↔ 现代写法对照

pmap 写法(旧接口)jit + shardingshard_map
x.reshape(N, B//N, ...)device_put(x, NamedSharding(mesh, P('data'))),无需 reshape同左
in_axes=0输入 sharding 为 P('data')in_specs=P('data')
in_axes=None输入 sharding 为 P()(或不标注)in_specs=P()
out_axes=0输出自动沿用 / out_shardings=out_specs=P('data')
out_axes=None(不检查)输出为 P()out_specs=P()(静态检查复制性)
axis_name='i'网格轴名 jax.make_mesh((N,), ('i',))同左
lax.psum(x, 'i')直接写 jnp.sum(x)(全局)lax.psum(局部值, 'i')
pmean(grad(loss))grad(loss)(全局损失)grad(lambda p: pmean(local_loss(p), 'i')),注意 N 倍梯度
嵌套 pmap(pmap(...))二维网格 make_mesh((a, b), ('i', 'j'))二维网格 + psum(x, 'j') / psum(x, ('i','j'))
jax.device_put_replicated(x, devs)(已删除)device_put(x, NamedSharding(mesh, P()))同左
设备内形状:去掉映射轴全局形状保留维度的局部块

本章更新说明

下面汇总本章在 JAX 0.12 下需要注意的写法和结论,每条都在正文对应位置用 assert 验证过(环境:JAX 0.12.0.1)。

编号小节要点位置
15.2jax.pmap 仍可用且调用时没有警告,但官方称它为"旧写法",内部由 jit + shard_map 实现;新代码首选 jit + NamedSharding 或 jax.shard_map5.2 介绍 jax.pmap
25.2 / 5.8pmap 的输出是统一的 jax.Array(带 NamedSharding),旧的 ShardedDeviceArray 已经移除5.2 介绍 jax.pmap;5.8 动手实践
35.2只有 1 个设备时,pmap 的映射轴长度只能是 1,不能像 vmap 那样处理任意长度的批次5.2 介绍 jax.pmap
45.3pmap 让映射轴的每个元素对应一个设备,轴长 ≤ 设备数;大批量要先 reshape 成 (N, B/N, ...),"自动均分成 B/N 块"是 NamedSharding 的行为5.3 将数据映射到设备
55.3只有 2 个设备时,直接对 (4, 3) 数组做 pmap 会报错;要先 reshape(2, 2, 3),或者用 P('data', None) 分片5.3 将数据映射到设备
65.3pmap 的 out_axes=None 不检查各设备输出是否相同,没聚合时会悄悄返回某个设备的值;shard_map 的 out_specs=P() 会直接报错5.3 将数据映射到设备
75.4jax.experimental.maps(含 Mesh)已删除;建网格用 jax.make_mesh / jax.sharding.Mesh5.4 设备网格与轴名称
85.4jax.make_mesh 在 0.12 默认 AxisType.Explicit,没分片的输入直接交给 shard_map 会报错5.4 设备网格与轴名称
95.4 / 5.50.12 中嵌套 pmap 会报错;多维并行改用多维网格 + shard_map5.4 设备网格与轴名称
105.2jax.experimental.shard_map 自 v0.8.0 起弃用,改用 jax.shard_map5.2 介绍 jax.pmap
115.8jax.device_put_replicated / device_put_sharded 已删除;改用 jax.device_put(x, NamedSharding(mesh, P())) 等5.8 动手实践
125.6jax.device_put(data, devices) 传设备列表会报 ValueError,不会分片;直接把数组交给 pmap,或者用 device_put(x, NamedSharding(...))5.6 将 pmap 与其他变换结合使用
135.6比较 pmap(f) 与 pmap(jit(f)) 的耗时,要先预热、再多次计时取平均,不能把首次编译时间算进去5.6 将 pmap 与其他变换结合使用
145.6示例数据的噪声用 jax.random.key(0) 生成(不用未设种子的 np.random.randn),结果可复现5.6 将 pmap 与其他变换结合使用
155.6pmap 内部的 vmap 是一层向量化(vectorization),不是"量化"(quantization)5.6 将 pmap 与其他变换结合使用
165.6迁移到 shard_map 时照搬 pmean(grad) 会得到 N 倍梯度;给出三种正确写法和迁移清单⚠️ 专题:shard_map 中梯度变成 N 倍
175.7pmap 的映射轴长度只要求 ≤ 设备数,不必相等;小于时只用前几个设备5.7 调试 pmap 化的函数
185.7形状检查示例用到了 lax.axis_index,必须 from jax import lax,否则报 NameError5.7 调试 pmap 化的函数
195.7不能用 Python if lax.axis_index(...) == 0 控制打印,会报 TracerBoolConversionError;改用 lax.cond5.7 调试 pmap 化的函数
205.7jax.debug.print 示例里 psum 后的结果是 [ 6 10 14]([0 2 4] + [6 8 10]),不是 [ 6 8 10]5.7 调试 pmap 化的函数
215.8pmap 函数里的普通 print 只在追踪时打印一次(不是每个设备一次),命中缓存后不再打印5.8 动手实践:并行计算
225.8jax.process_index() 是进程编号(单机恒为 0),设备编号要用 lax.axis_index5.8 动手实践:并行计算
235.8完整的数据并行训练循环(pmap / jit+sharding / shard_map),并用 assert 验证与单设备结果一致5.8 动手实践:并行计算

附录:练习参考答案

先在正文的「✍️ 作答区」自己写,用「🧪 自检」检查;自检通过或者实在卡住了,再来这里对照。 下面的答案按顺序运行,会覆盖你在作答区定义的同名函数。

补充练习 1

题目见正文「✍️ 补充练习 1:跨设备标准化(分布式均值与方差)」。

# 参考答案
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)
    n = lax.psum(x.shape[0], "data")                  # 全局样本数
    s1 = lax.psum(jnp.sum(x, axis=0), "data")         # 全局一阶和
    s2 = lax.psum(jnp.sum(x ** 2, axis=0), "data")    # 全局二阶和
    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)

# 对照:只用"本地"统计量(错误做法)
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("✓ 全局统计量与单设备一致;只用本地统计量则不一致")
✓ 全局统计量与单设备一致;只用本地统计量则不一致

补充练习 2

题目见正文「✍️ 补充练习 2:用 ppermute 手写环形 all-reduce」。

# 参考答案
def ring_allreduce(x, axis_name, n):
    perm = [(j, (j + 1) % n) for j in range(n)]   # 每个设备发给右边邻居
    def body(_, carry):
        acc, msg = carry
        msg = lax.ppermute(msg, axis_name, perm)  # 接收左边邻居传来的值
        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))

# 旧接口 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)

# ✅ 现代写法: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"))))
# 注意:Explicit 网格上的分片数组不能直接写 ring_s[0] 取行(会报 ShardingTypeError,要求指定输出分片),先转 numpy
assert same(ring_s, ref_p, atol=1e-5) and same(np.asarray(ring_s)[0], vals.sum(0), atol=1e-5)
print("✓ 环形 all-reduce(pmap 与 shard_map)与 psum 一致")
✓ 环形 all-reduce(pmap 与 shard_map)与 psum 一致

补充练习 3

题目见正文「✍️ 补充练习 3:二维网格上的"数据并行 × 张量并行"矩阵乘」。

# 参考答案
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)          # 用 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              # 局部部分和
    return lax.psum(partial, "model")   # 沿收缩维所在的 'model' 轴求和;结果沿 data 分片、在 model 上复制

Y1 = matmul_dp_tp(Xm_sh, Wm_sh)
assert same(Y1, ref, atol=1e-4)
print("✓ shard_map 正确,输出分片:", Y1.sharding.spec)

# jit + sharding(Explicit 网格):收缩维两边都被切分时,输出分片有歧义,JAX 要求明确指定
try:
    jax.jit(jnp.matmul)(Xm_sh, Wm_sh)
    raise AssertionError("应当报错")
except Exception as e:
    print("Explicit 网格下不指定输出分片:", type(e).__name__, "-", str(e)[:80], "...")

# out_sharding 要传 NamedSharding;若只写 P(...),需要先用 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 网格:由编译器自行决定输出分片和通信
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、Auto 网格 jit 均正确;编译器选择的输出分片:", Y3.sharding.spec)
✓ shard_map 正确,输出分片: P('data', None)
Explicit 网格下不指定输出分片: ShardingTypeError - Contracting dimensions are sharded and it is ambiguous how the output should be  ...
✓ Explicit + out_sharding、Auto 网格 jit 均正确;编译器选择的输出分片: P('data',)

补充练习 4

题目见正文「✍️ 补充练习 4:不等长批次下的正确梯度平均」。

# 参考答案
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])                    # 每个设备的有效样本数
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)

# 旧接口 pmap
def correct_grad(p, x, y, m):
    g_sum = jax.grad(sq_err_sum)(p, x, y, m)                    # 本地"误差平方和"的梯度
    cnt = lax.psum(jnp.sum(m), "i")                             # 全局有效样本数
    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)   # 本地均值的梯度
    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)

# 单设备参考:只取有效样本(数据作为参数传入)
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)

# ✅ 现代写法:jit + sharding —— 写"全局"的加权损失,编译器自动处理跨设备求和
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("参考梯度     :", {k: round(float(v), 5) for k, v in ref.items()})
print("psum/计数 ✓  :", {k: round(float(v), 5) for k, v in g_ok.items()})
print("jit 全局 ✓   :", {k: round(float(v), 5) for k, v in g_jit.items()})
print("本地均值+pmean ✗:", {k: round(float(v), 5) for k, v in g_bad.items()})
参考梯度     : {'b': -1.57462, 'w': -4.87911}
psum/计数 ✓  : {'b': -1.57462, 'w': -4.87912}
jit 全局 ✓   : {'b': -1.57462, 'w': -4.87912}
本地均值+pmean ✗: {'b': -1.97572, 'w': -4.1223}