第 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证明两种写法结果一致;- 需要特别留意的地方用
> ⚠️ 注意标出,章末「本章更新说明」做了汇总。
学习目标
- 理解 SPMD(单程序多数据)并行的原理;
- 会用
pmap(旧接口)和jit + sharding/shard_map(现代写法)让同一函数在多设备上处理不同数据; - 会控制数据怎样分发到设备、结果怎样收集回来(
in_axes/out_axes↔PartitionSpec/in_specs/out_specs); - 会在并行函数里用集体操作(
lax.psum、lax.pmean等)做跨设备通信; - 会写数据并行训练,并能验证它与单设备训练结果一致;
- 能避开 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)
5.1 数据并行 (SPMD) 介绍¶
jit 和 vmap 基本都只在单个计算设备上工作。手头有多块加速器时,最直接的想法是数据并行:把数据分给各个处理单元,每个单元对自己那一块数据做相同的计算。
SPMD:单程序,多数据¶
SPMD(Single Program, Multiple Data)模型有三个要素:
- 单程序:只写一个函数来定义计算,比如神经网络的一次前向传播,或者模拟中的一个时间步;
- 多数据:输入数据(比如一批图像、一组模拟参数)被切成若干分片(shard);
- 并行执行:每个设备(一块 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 的执行模型¶
- 编译:和
jit一样,用 XLA 编译函数; - 复制:把编译好的程序复制到每个设备上;
- 分配数据:沿指定的轴(默认轴 0)切分输入。被映射轴上的每一个元素对应一个设备,所以这个轴的长度不能超过设备数;
- 并行执行:所有设备同时对各自的切片跑同一个程序(SPMD);
- 收集结果:结果默认留在各个设备上,合起来是一个分布式的
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
vmap | pmap | |
|---|---|---|
| 作用 | 把处理单个样本的函数变成处理一批样本的函数 | 把计算复制到多个物理设备上 |
| 并行发生在哪里 | 单个设备内部的向量化指令 | 多个加速器之间 |
| 被映射轴的长度 | 任意 | 不能超过设备数 |
在多 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()))
# 课程示例(旧接口 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)
要点:
- 输入需要一个前导维度,长度等于(或小于)设备数。pmap 默认沿轴 0 切分,每个切片(这里是
(3,))送到一个设备; - 每个设备同时对自己的切片执行
simple_computation; - 输出是一个普通的
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 结果一致")
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)}
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)
结论:在 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.shard_map
assert hasattr(jax, "shard_map")
print(jax.shard_map)
三种写法对比
jax.pmap(旧接口) | jax.jit + sharding | jax.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 ≤ 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])
# ✅ 修正代码:先 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 个设备上运行正确")
# ✅ 现代写法: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 结果一致")
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 的局部值,没有任何报错
# ✅ 现代写法: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], "...")
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)
# ✅ 现代写法: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 一致")
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。
概念:命名轴¶
网格的哪一维对应拆开的数据?沿网格某一维排开的设备之间怎么协调?答案是给轴起名字。命名轴有三个好处:
- 好读:比如用
'data_parallel_axis'表示数据并行,用'model_parallel_axis'表示模型并行; - 抽象:代码只引用逻辑上的名字,不用关心底下是 4 块 GPU 还是 8 个 TPU 核心;
- 集体操作:
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), ...]
# 课程示例(旧接口 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)
# ✅ 现代写法: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)
# ❌ 旧写法(过时):jax.experimental.maps 已在 0.12 中删除
try:
from jax.experimental.maps import Mesh as OldMesh
raise AssertionError("不应导入成功")
except ModuleNotFoundError as e:
print("实测:", e)
# ✅ 现代写法: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)}")
🔍 补充: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")
🔍 补充:查看分片布局¶
⚪ 选读:想直观查看数组在各设备上怎样切分时读。
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)
嵌套 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])
# ✅ 现代写法:(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())
5.5 集体操作(lax.psum、lax.pmean 等)¶
每个设备执行同一个函数(SPMD 里的"单程序"),处理各自的那片数据("多数据")。但各个设备之间还是需要通信和聚合。比如分布式训练时,每个设备根据本地批次算出梯度,更新参数之前必须把所有设备的梯度合起来。集体操作就是干这个的。
它们都在 jax.lax 模块里,只能在绑定了对应轴名的变换内部运行。多设备场景下就是 pmap / shard_map。带 axis_name 的 vmap 也行,适合先在单设备上验证集体操作的逻辑(见 第 4 章 速查表的示例)。
概念:psum 与 pmean¶
lax.psum(并行求和)的要点:
- 输入:每个设备上本地算出来的值;
- 操作:在
axis_name指定的轴上,把所有参与设备的值加起来; axis_name:一个字符串,把集体操作和对应的并行轴关联起来(pmap 的axis_name=,或者 shard_map 网格上的轴名)。两边必须一致;- 输出:每个设备都拿到同一个总和。
设备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
# ✅ 现代写法: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)
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
# ✅ 现代写法: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)
概念:其他常用集体操作¶
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)就等于这个轴上的设备数。
正确使用集体操作:
- 必须在绑定了这个轴名的
pmap/shard_map(或带axis_name的vmap)里面调用,在外面调用会报NameError: unbound axis name; - 集体操作用的轴名必须和并行轴的名字一致;
- 集体操作是同步点:所有设备都执行到这里,计算才能往下走;
- 被聚合的值在各个设备上的类型和形状应该一致(
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]}")
# ✅ 现代写法: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")
5.6 将 pmap 与其他变换结合使用¶
JAX 的变换是可以组合的。并行变换很少单独用,常常和 jit、grad、vmap 搭配。
概念¶
和 jit 组合:一般不需要写 pmap(jit(f)),因为 pmap 自己就会用 XLA 编译。例外情况是:被 jit 的函数只是并行主函数里的一个组件,而你想单独控制它的编译。在现代写法里,jit 本身就是并行的入口,两者合二为一。
和 grad 组合(数据并行训练):把一批数据分到多个设备上,每个设备各算各的梯度,然后汇总:
- 定义损失函数,输入是参数和一批数据;
- 用
jax.grad/jax.value_and_grad得到梯度函数; - 对包含梯度计算的函数做并行变换;
- 在并行函数里用
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,传设备列表不会做"分片"
# ✅ 修正代码:直接把 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(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)
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])
# ✅ 现代写法 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 的损失和梯度完全一致")
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 的轴向量化
# ✅ 现代写法: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)
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) # 恰好是设备数倍
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
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}")
4. 一句话判断规则¶
在 shard_map 里,对
in_specs=P()的复制参数求grad,得到的已经是所有设备梯度之和。要么对pmean之后的全局损失求导,要么别再对梯度做pmean(改成除以 N)。
另外,check_vma=False 可以关掉这套机制,恢复 pmap 那样的语义。但这样也就没有了 out_specs 的复制性检查,不推荐。
5. pmap → shard_map 迁移检查清单¶
| # | pmap | shard_map | 检查要点 |
|---|---|---|---|
| 1 | in_axes=0 | in_specs=P('data') | 数据不用再 reshape 成 (N, ...);(B, ...) 会被均分成 (B/N, ...) |
| 2 | in_axes=None | in_specs=P() | 参数变成 invariant,对它求 grad 会自动 psum |
| 3 | out_axes=0 | out_specs=P('data') | 局部块沿这一维拼接,而不是新建一维堆起来 |
| 4 | out_axes=None | out_specs=P() | shard_map 会静态检查复制性,没聚合就报错 |
| 5 | axis_name='i' | jax.make_mesh((N,), ('i',)) | 轴名写在网格上;make_mesh 默认 Explicit,输入要先 device_put |
| 6 | pmean(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、修改外部变量这类副作用只在追踪时执行一次,不会每个设备、每次调用都执行。
概念:调试策略¶
- 先去掉并行:取出一个分片,不用 pmap(甚至不用 jit)单独跑,先把逻辑错误修好;
- 减少设备数:先用 1 个或 2 个设备。如果只在多设备时出错,问题多半在分发、收集或集体操作上;
- 仔细核对形状:调用前核对输入形状、
in_axes和jax.local_device_count(),调用后核对输出形状; - 用
jax.debug.print代替print:它在运行时打印,能看到每个设备上的值。配合lax.axis_index和lax.cond可以只在某个设备上打印。各设备打印的顺序没有保证,建议把设备编号一起打出来。jax.debug.breakpoint()会让所有设备暂停并进入 pdb,用的时候要小心; - 单独测试集体操作:写一个只做这个集体操作的最小函数,核对输入、输出和轴名;
- 最后的办法:在局部范围内用
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)
# ✅ 修正代码:补上 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
# ✅ 现代写法: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 一致")
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])
# ✅ 修正代码:用 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) # 用单设备计算核对预期值
⚠️ 注意: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())
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: 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..." 这条消息会每个设备打印一次。实际上普通
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()}")
# 课程示例 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)
# 验证:普通 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
# ✅ 现代写法: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)
# 课程示例 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
# ✅ 现代写法: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)
# ✅ 现代写法 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))
完整的数据并行训练循环,并与单设备对照¶
前面只演示了一步梯度计算。下面完整地训练一个线性回归 $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]}...")
# ✅ 现代写法: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, "个设备")
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}")
# 验证:三种并行写法与单设备结果一致(参数 + 整条损失曲线)
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()
几点实战经验:
- 四条曲线完全重合。梯度经过
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 自检通过")
✍️ 补充练习 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 自检通过")
✍️ 补充练习 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 自检通过")
✍️ 补充练习 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 自检通过")
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 + sharding | shard_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)。
| 编号 | 小节 | 要点 | 位置 |
|---|---|---|---|
| 1 | 5.2 | jax.pmap 仍可用且调用时没有警告,但官方称它为"旧写法",内部由 jit + shard_map 实现;新代码首选 jit + NamedSharding 或 jax.shard_map | 5.2 介绍 jax.pmap |
| 2 | 5.2 / 5.8 | pmap 的输出是统一的 jax.Array(带 NamedSharding),旧的 ShardedDeviceArray 已经移除 | 5.2 介绍 jax.pmap;5.8 动手实践 |
| 3 | 5.2 | 只有 1 个设备时,pmap 的映射轴长度只能是 1,不能像 vmap 那样处理任意长度的批次 | 5.2 介绍 jax.pmap |
| 4 | 5.3 | pmap 让映射轴的每个元素对应一个设备,轴长 ≤ 设备数;大批量要先 reshape 成 (N, B/N, ...),"自动均分成 B/N 块"是 NamedSharding 的行为 | 5.3 将数据映射到设备 |
| 5 | 5.3 | 只有 2 个设备时,直接对 (4, 3) 数组做 pmap 会报错;要先 reshape(2, 2, 3),或者用 P('data', None) 分片 | 5.3 将数据映射到设备 |
| 6 | 5.3 | pmap 的 out_axes=None 不检查各设备输出是否相同,没聚合时会悄悄返回某个设备的值;shard_map 的 out_specs=P() 会直接报错 | 5.3 将数据映射到设备 |
| 7 | 5.4 | jax.experimental.maps(含 Mesh)已删除;建网格用 jax.make_mesh / jax.sharding.Mesh | 5.4 设备网格与轴名称 |
| 8 | 5.4 | jax.make_mesh 在 0.12 默认 AxisType.Explicit,没分片的输入直接交给 shard_map 会报错 | 5.4 设备网格与轴名称 |
| 9 | 5.4 / 5.5 | 0.12 中嵌套 pmap 会报错;多维并行改用多维网格 + shard_map | 5.4 设备网格与轴名称 |
| 10 | 5.2 | jax.experimental.shard_map 自 v0.8.0 起弃用,改用 jax.shard_map | 5.2 介绍 jax.pmap |
| 11 | 5.8 | jax.device_put_replicated / device_put_sharded 已删除;改用 jax.device_put(x, NamedSharding(mesh, P())) 等 | 5.8 动手实践 |
| 12 | 5.6 | jax.device_put(data, devices) 传设备列表会报 ValueError,不会分片;直接把数组交给 pmap,或者用 device_put(x, NamedSharding(...)) | 5.6 将 pmap 与其他变换结合使用 |
| 13 | 5.6 | 比较 pmap(f) 与 pmap(jit(f)) 的耗时,要先预热、再多次计时取平均,不能把首次编译时间算进去 | 5.6 将 pmap 与其他变换结合使用 |
| 14 | 5.6 | 示例数据的噪声用 jax.random.key(0) 生成(不用未设种子的 np.random.randn),结果可复现 | 5.6 将 pmap 与其他变换结合使用 |
| 15 | 5.6 | pmap 内部的 vmap 是一层向量化(vectorization),不是"量化"(quantization) | 5.6 将 pmap 与其他变换结合使用 |
| 16 | 5.6 | 迁移到 shard_map 时照搬 pmean(grad) 会得到 N 倍梯度;给出三种正确写法和迁移清单 | ⚠️ 专题:shard_map 中梯度变成 N 倍 |
| 17 | 5.7 | pmap 的映射轴长度只要求 ≤ 设备数,不必相等;小于时只用前几个设备 | 5.7 调试 pmap 化的函数 |
| 18 | 5.7 | 形状检查示例用到了 lax.axis_index,必须 from jax import lax,否则报 NameError | 5.7 调试 pmap 化的函数 |
| 19 | 5.7 | 不能用 Python if lax.axis_index(...) == 0 控制打印,会报 TracerBoolConversionError;改用 lax.cond | 5.7 调试 pmap 化的函数 |
| 20 | 5.7 | jax.debug.print 示例里 psum 后的结果是 [ 6 10 14]([0 2 4] + [6 8 10]),不是 [ 6 8 10] | 5.7 调试 pmap 化的函数 |
| 21 | 5.8 | pmap 函数里的普通 print 只在追踪时打印一次(不是每个设备一次),命中缓存后不再打印 | 5.8 动手实践:并行计算 |
| 22 | 5.8 | jax.process_index() 是进程编号(单机恒为 0),设备编号要用 lax.axis_index | 5.8 动手实践:并行计算 |
| 23 | 5.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 一致")
补充练习 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)
补充练习 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()})