第 3 章 JAX 分布式计算
课程:《JAX 高级编程:性能、优化与规模化》第 3 章 · 原课程链接:https://apxml.com/zh/courses/advanced-jax/chapter-3-distributed-computing-jax 运行环境:Python 3.14 · JAX / jaxlib 0.12.0.1 · 仅 CPU(用
XLA_FLAGS模拟 8 个设备;多主机演示用 2 个子进程 × 4 个设备)
学习目标
- 分清数据并行、模型(张量)并行、流水线并行,以及 ZeRO / FSDP 各自的内存和通信代价;
- 掌握设备对象、数组放置,以及 committed 与 uncommitted 数组的规则;
- 读懂用
pmap写的示例代码,并能把每个示例改写成 0.12 推荐的jit + NamedSharding或jax.shard_map; - 准确理解
psum / pmean / pmax / all_gather / psum_scatter / all_to_all / ppermute的语义和通信量; - 在二维网格(数据 × 模型)上使用
PartitionSpec、AxisType、with_sharding_constraint、reshard,并用jax.typeof查看分片类型; - 理解多主机编程模型,并在本机真正跑一个 2 进程演示(跨进程
psum和数据并行训练); - 写出和单设备结果逐位对拍的分布式数据并行训练,并从编译产物里读出梯度同步的通信量。
小节目录
| 小节 | 原课程页面 |
|---|---|
| 3.1 并行处理原理介绍 | introduction-parallelism-concepts |
| 3.2 JAX 中的设备管理 | device-management-jax |
| 3.3 使用 pmap 进行单一程序多数据 (SPMD) 运算 | spmd-with-pmap |
| 3.4 使用 pmap 实现数据并行 | data-parallelism-pmap |
| 3.5 集合通信原语 (psum, pmean 等) | collective-communication-primitives |
| 3.6 pmap 中轴名称的处理 | handling-axis-names-pmap |
| 3.7 嵌套 pmap 与进阶分区 | nested-pmap-advanced-partitioning |
| 3.8 多主机编程简介 | multi-host-programming-overview |
| 3.9 实践:分布式数据并行训练 | practice-distributed-data-parallel-training |
| — | 动手练习 · 测验 · 本章更新说明 · 小结 · 速查表 |
阅读说明
- 示例代码按课程各页的结构整理,变量名、形状、超参数、
in_axes/axis_name等都和课程保持一致。 - 代码一律用 0.12 的推荐写法(
jax.random.key、jax.tree.map、x.device等)。过时或会出错的写法放在# ❌ 旧写法单元里,并附上运行结果,后面紧跟推荐写法。 - 课程用的是
jax.pmap。它在 0.12 里已经是旧接口:docstring 第一句就是 "Old way of doing parallel map. Use jax.shard_map instead."(3.3 节会用 assert 验证)。所以每个主要的 pmap 示例后面,都跟着一个# ✅ 现代写法单元,并 assert 两者结果一致。
与入门课的关联(这里只做交叉引用,不重复讲)
- 入门 05 多设备并行pmap 已经讲过:pmap 的基本用法、
in_axes/out_axes、pmap 在 0.12 中由 shard_map 实现、make_mesh默认用 Explicit 轴、vma(varying/invariant)类型、用ppermute手写环形 all-reduce,还有那个**「shard_map 里梯度变成 N 倍」的坑**。本章默认你已经掌握这些,在此基础上讲二维分区、all_to_all/psum_scatter、FSDP、显式分片类型、多主机和通信量分析。 - 入门 02 JIT编译:
jit的追踪和编译。本章的「现代写法」都建立在jit之上。 - 入门 03 自动微分grad:3.7 节会用到一个性质:
all_gather的转置(反向)就是psum_scatter。 - 入门 04 自动向量化vmap:
vmap也接受axis_name,可以在单设备上测试集合通信(3.6 节补充)。
⚠️ 运行要求:第一个代码单元必须在
import jax之前设置XLA_FLAGS,所以请重启内核,从头执行。如果内核里已经导入过 jax,设备数会是 1,第一个单元的 assert 会失败。
# 必须在 import jax 之前设置:用 1 个 CPU 模拟 8 个设备(要求重启内核后执行)
import os
os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=8"
import re, sys, json, subprocess, tempfile, socket, warnings
import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
from jax.sharding import Mesh, NamedSharding, PartitionSpec as P, AxisType
import optax
assert jax.device_count() == 8, "设备数不是 8:请重启内核后从第一个单元开始执行"
N = jax.device_count()
print("JAX", jax.__version__, "| 设备数:", N, "| 平台:", jax.devices()[0].platform)
def f64(a):
"""转成 NumPy float64,用来和分布式结果对拍"""
return np.asarray(a, dtype=np.float64)
def collectives(compiled):
"""从编译后的 HLO 文本中找出用到的集合通信算子"""
return sorted(set(re.findall(r"\b(all-reduce|reduce-scatter|all-gather|all-to-all|collective-permute)\(",
compiled.as_text())))
3.1 并行处理原理介绍¶
现代机器学习的规模经常超出单个设备的能力:要么数据集特别大,要么模型有几十亿参数,计算量只能分摊到多个设备上。并行处理就是把任务切成更小的、独立或半独立的部分,让多个处理单元同时执行。训练和推理选哪种并行策略,要看瓶颈在数据量还是模型大小。
数据并行(Data Parallelism)¶
这是加速训练最常用的策略,课程把它分成五步:
- 模型复制:把同样的模型结构和当前参数复制到每个设备(GPU 或 TPU 核心)上;
- 数据分片:把全局批次分成几个小批次,每个设备拿一份,互不重叠;
- 并行前向和反向传播:每个设备用自己的模型副本和小批次,算出本地梯度;
- 梯度聚合:用平均(
pmean)或求和(psum)把本地梯度合起来,结果和在全局批次上算出的梯度一样; - 参数更新:所有设备用同一份聚合梯度更新参数,各个副本就保持同步了。
第 4 步为什么成立,可以写成公式。设全局批次 $B$ 被均分成 $N$ 份 $B_1,\dots,B_N$,每份大小都是 $|B|/N$,损失取批内平均 $L_B(\theta)=\frac{1}{|B|}\sum_{x\in B}\ell(x;\theta)$,那么
$$ \nabla_\theta L_B(\theta) \;=\; \frac{1}{N}\sum_{i=1}^{N} \nabla_\theta L_{B_i}(\theta), $$
也就是说,每个设备先算本地平均损失的梯度,再在设备间取平均(pmean),得到的就是全局梯度。这样相当于加大了每一步的总批次,通常能更好地利用加速器。数据并行最适合「模型放得进单个设备、数据集很大」的情况。课程用 pmap 来实现这种 SPMD 数据并行。
模型并行(Model Parallelism)¶
如果参数、激活或中间状态在单个加速器上放不下,就得把模型本身切到多个设备上。数据依次流过各个部分:前向传播要在设备间传激活,反向传播要传梯度,通信量很大。这种方式能训练极大的模型,但切分和通信管理起来比较复杂。常见做法有两种:张量并行(切分单个权重矩阵)和流水线并行。
流水线并行(Pipeline Parallelism)¶
这是更精细的一种模型并行,目的是让设备别闲着。朴素的模型并行里,批次按顺序流过各个设备,同一时刻大部分设备都在空等。流水线并行是这么做的:
- 分阶段:把模型切成几个顺序阶段,每个阶段交给一个设备(或一组设备);
- 微批次:把输入批次再切成更小的微批次;
- 流水线执行:设备 1 做完微批次 1 的阶段 1、开始做微批次 2 时,设备 2 就可以开始做微批次 1 的阶段 2。这样同时干活的设备就多了。
它能减少朴素模型并行里的空闲「气泡」,但依赖关系、调度和状态(比如反向传播要用的激活)都得仔细管理。
本章重点¶
本章主要讲数据并行:pmap 把函数映射到多个设备上,自动分发数据,还提供梯度聚合之类的集合通信。模型并行和流水线并行在这里只作为背景介绍,课程第 6 章(超大模型)还会再讲。
# 验证上面的公式:N 份等大分片上「本地平均损失的梯度」取平均 == 全局批次的梯度(NumPy float64 对拍)
rng = np.random.default_rng(0)
Xg = rng.normal(size=(64, 5)); yg = rng.normal(size=64); w0 = rng.normal(size=5)
def grad_mse(w, X, y): # 线性回归 MSE 的解析梯度:2/|B| * X^T (Xw - y)
return 2.0 / len(X) * X.T @ (X @ w - y)
g_global = grad_mse(w0, Xg, yg)
g_local = [grad_mse(w0, Xs, ys) for Xs, ys in zip(np.split(Xg, 8), np.split(yg, 8))]
assert np.allclose(np.mean(g_local, axis=0), g_global) # pmean 本地梯度 == 全局梯度
assert np.allclose(np.sum(g_local, axis=0), 8 * g_global) # psum 本地梯度 == N 倍全局梯度
print("pmean(本地梯度) == 全局梯度 ✓;psum(本地梯度) == 8 × 全局梯度 ✓")
🔍 补充:三种并行的内存账和通信账¶
🟢 必读
上面是定性的介绍。真正选策略时,要算两笔账:每个设备要放多少内存,每一步要通信多少数据。设模型有 $\Phi$ 个参数,设备数为 $N$。
内存:以混合精度 Adam 为例,每个参数大约占 16 字节。fp16 参数 2 字节,fp16 梯度 2 字节,再加上 fp32 主参数、一阶矩、二阶矩各 4 字节。
| 方案 | 每设备的参数 / 梯度 / 优化器状态 | 每设备内存 |
|---|---|---|
| 数据并行(DP) | 全部复制 | $16\Phi$ |
| ZeRO-1(切分优化器状态) | 只切分优化器状态 | $4\Phi + 12\Phi/N$ |
| ZeRO-2(再切分梯度) | 切分梯度和优化器状态 | $2\Phi + 14\Phi/N$ |
| ZeRO-3 / FSDP(全部切分) | 全部切分,用到某层时临时 all-gather | $16\Phi/N$ |
通信:用环形算法时,每个设备的收发量如下($S$ 是张量的字节数):
$$ \text{all-reduce}: 2\tfrac{N-1}{N}S,\qquad \text{reduce-scatter}: \tfrac{N-1}{N}S,\qquad \text{all-gather}: \tfrac{N-1}{N}S . $$
一次环形 all-reduce 就是一次 reduce-scatter 加一次 all-gather(3.5 节会用 assert 验证)。所以 ZeRO-1/2 用「梯度 reduce-scatter + 更新后参数 all-gather」代替 DP 的「梯度 all-reduce」时,通信量没变,每个设备长期保存的状态却少了很多。
ZeRO-3 / FSDP 连参数也切开,长期保存的参数、梯度和优化器状态都降到 $1/N$。但为了不在前向和反向之间一直留着完整参数,反向时通常要再 all-gather 一次,所以通信量大约是 DP 的 1.5 倍($3\tfrac{N-1}{N}S$ 对 $2\tfrac{N-1}{N}S$)。如果前向 gather 出来的完整参数一直留到反向(3.7 节的简化实现就是这样),通信量就和 DP 一样,代价是计算期间要临时放下完整参数。这就是 ZeRO / FSDP 的核心思路,3.7 节会真正实现一遍,并用 memory_analysis 验证。
流水线气泡:GPipe 式调度有 $p$ 个阶段、$m$ 个微批次时,空闲比例是
$$ \text{bubble} = \frac{p-1}{m+p-1}. $$
微批次越多,气泡越小。所以流水线并行的关键是让微批次数 $m \gg p$。
# 流水线气泡比例 (p-1)/(m+p-1):p=4 个阶段时,微批次越多气泡越小
def bubble(p, m):
return (p - 1) / (m + p - 1)
for m in [1, 4, 16, 64]:
print(f"p=4, m={m:3d}: 气泡比例 = {bubble(4, m):.3f}")
assert bubble(4, 1) == 0.75 and bubble(4, 64) < 0.05 # m=1 即朴素模型并行,3/4 的时间在等待
3.2 JAX 中的设备管理¶
用 pmap 之前,先要了解 JAX 怎么识别和管理加速器。JAX 会自动检测系统里(或者分布式环境分给它的)CPU、GPU、TPU,并提供查看和操作它们的工具。
查看可用设备¶
jax.devices():JAX 能用的全部设备。多主机环境下是所有进程的全局设备列表,单进程时就是本机设备;jax.device_count():全局设备数;jax.local_devices()/jax.local_device_count():直接连在当前 Python 进程上的设备,以及它们的数量;- 设备对象的属性:
platform('cpu'、'gpu'、'tpu')、id(在同一平台内唯一)、process_index(属于哪个进程)、device_kind。
课程特别提醒:在多主机环境(比如 TPU Pod)里,要分清 jax.devices()(全局视图)和 jax.local_devices()(只有本进程的设备)。单主机的 GPU 或 CPU 环境下,两者通常一样。3.8 节会在真正的 2 进程环境里看到它们的区别。
# 课程示例:查看设备
print("jax.devices(): ", jax.devices())
print("jax.device_count(): ", jax.device_count())
print("jax.local_devices(): ", jax.local_devices()[:3], "...")
print("jax.local_device_count():", jax.local_device_count())
d0 = jax.local_devices()[0]
print(f"第一个本地设备: ID={d0.id}, Platform={d0.platform}, process_index={d0.process_index}, kind={d0.device_kind}")
assert jax.local_devices() == jax.devices() # 单进程:本地 == 全局
assert all(d.process_index == 0 for d in jax.devices())
assert [d.id for d in jax.devices()] == list(range(8))
默认设备放置¶
数组的创建和运算,默认放在 jax.local_devices() 的第一个设备上(cpu:0、gpu:0 或 tpu:0)。单设备时这很方便,分布式场景下通常就要自己显式控制了。
⚠️ 注意:
device是属性,要写x.device。旧资料里常见的x.device()会报TypeError: 'Device' object is not callable。另外还有两种查看方式:x.devices()(方法,返回一个集合)和x.sharding。
# ❌ 旧写法(过时):x.device()。在 0.12 中 device 是属性,不是方法
x = jnp.ones((3, 3))
try:
x.device()
raised = False
except TypeError as e:
raised = True
print("x.device() 报错:", type(e).__name__, "-", e)
assert raised
# ✅ 推荐写法:x.device 属性 / x.devices() / x.sharding
x = jnp.ones((3, 3))
print("x.device :", x.device)
print("x.devices():", x.devices())
print("x.sharding :", x.sharding)
assert x.device == jax.devices()[0]
assert x.devices() == {jax.devices()[0]}
用 jax.device_put 显式放置¶
签名是 jax.device_put(x, device=None)。x 是要放置的数据(NumPy 数组、Python 标量、列表或 pytree);第二个参数是目标设备(从 jax.devices() 或 jax.local_devices() 里取),为 None 时用默认设备。在 0.12 里,第二个参数也可以是一个 Sharding,这是多设备放置的主要方式,3.4 节起会大量用到。
课程列出了 device_put 的三个用途:
- 数据传输:把数据从主机内存搬到加速器内存,主要就靠它;
- 为并行计算做准备:在主机上准备好分片,必要时把初始参数显式放好;
- 控制初始位置:避免后面做不必要的传输。
⚠️ 注意:容易误以为「计算在哪里执行由
jit决定,device_put只影响数据的初始位置」。其实jit函数在哪个设备上执行,是由输入数组放在哪决定的,也就是「计算跟着数据走」。把输入device_put到cpu:1,jit 的计算和结果就都在cpu:1上。下面 committed 补充里的单元,以及 3.7 节jax.jit(device=...)那条注意后面的 ✅ 单元,都用 assert 验证了这一点。所以device_put不只决定数据的初始位置,也间接决定了后面的计算在哪里执行。
# 课程示例:本地设备多于 1 个时才演示显式放置
num_local_devices = jax.local_device_count()
if num_local_devices > 1:
host_array = np.random.default_rng(0).random((2, 2))
print("放置前类型:", type(host_array))
target = jax.local_devices()[1]
dev_array = jax.device_put(host_array, target)
print("放置后类型:", type(dev_array).__name__, "| 所在设备:", dev_array.device)
assert dev_array.device == target
elif num_local_devices == 1:
print("只有一个本地设备可用,跳过明确放置示例。")
else:
print("未找到 JAX 设备。")
设备数据驻留¶
数组放到某个设备上以后,JAX 会尽量让用到它的计算也留在这个设备上,减少传输。那么,参与运算的数组如果在不同设备上,JAX 是自动搬数据,还是报错?规则其实是确定的,见下面的补充。
🔍 补充:committed 与 uncommitted 数组¶
🟢 必读
入门 01 §1.6「跨设备运算」 已经讲过这条规则,这里结合多设备再复习一遍。到底是「搬数据」还是「报错」,要看数组是不是 committed(已经绑定了设备):
jnp.ones(...)这类没指定设备的数组是 uncommitted 的,它会自动跟着别的输入走;jax.device_put(x, device_or_sharding)得到的是 committed 数组;- 一个运算的输入里如果只有一个 committed 设备,计算就在那个设备上执行,uncommitted 的输入会被搬过去;
- 如果有两个输入 committed 在不同设备上,会直接报
ValueError: Received incompatible devices,JAX 不会替你搬数据。
u = jnp.ones(3) # uncommitted(默认在 cpu:0)
c1 = jax.device_put(jnp.ones(3), jax.devices()[1]) # committed 到 cpu:1
c2 = jax.device_put(jnp.ones(3), jax.devices()[2]) # committed 到 cpu:2
r = u + c1
print("uncommitted + committed(cpu:1) → 结果在", r.device)
assert r.device == jax.devices()[1]
try:
c1 + c2
raised = False
except ValueError as e:
raised = True
print("committed(cpu:1) + committed(cpu:2) 报错:", str(e)[:110], "...")
assert raised
# 需要跨设备运算时,要显式搬运
r2 = c1 + jax.device_put(c2, jax.devices()[1])
assert r2.device == jax.devices()[1]
设备可见性的环境变量¶
在导入 JAX 之前,可以用环境变量控制它能看到哪些设备:
CUDA_VISIBLE_DEVICES=1 python my_jax_script.py:脚本里jax.local_devices()只会列出 1 个 GPU 设备,id 是 0。可见设备会从 0 开始重新编号,所以这个 id=0 其实是物理上的 GPU 1;CUDA_VISIBLE_DEVICES=""通常会隐藏所有 GPU,强制用 CPU;JAX_PLATFORMS=cpu(或gpu、tpu):只初始化指定平台的后端。有多个后端可用时,用它来强制选一个。
小结一下:设备管理是分布式计算的基础。你得知道有哪些设备可用、数据放在哪(x.device)、怎么控制放置(jax.device_put),这些都是用 pmap 的前提。
🔍 补充:模拟多设备的两种方式,以及「必须在初始化前设置」¶
⚪ 选读:要在本机模拟多设备,或者遇到「设备数是 1」时再看
本机没有 GPU,本章用 XLA_FLAGS=--xla_force_host_platform_device_count=8 模拟 8 个 CPU 设备。0.12 还有一个配置项 jax_num_cpu_devices(入门 01 §1.6 用过),作用一样。但它和环境变量一样,必须在后端初始化(第一次执行任何 JAX 运算)之前设置,不然会报 RuntimeError。这个内核的后端已经初始化过了,所以下面用子进程来演示。
# 在干净的子进程中演示 jax_num_cpu_devices:初始化前设置有效,初始化后设置报 RuntimeError
probe = r"""
import jax
jax.config.update('jax_num_cpu_devices', 3) # 紧跟在 import jax 之后
print(jax.device_count())
try:
jax.config.update('jax_num_cpu_devices', 5) # 此时后端已初始化
except RuntimeError as e:
print('RuntimeError')
"""
env = {k: v for k, v in os.environ.items() if k != "XLA_FLAGS"} # 去掉本内核的 XLA_FLAGS
out = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, env=env, timeout=120)
print(out.stdout)
assert out.stdout.split() == ["3", "RuntimeError"]
assert jax.config.jax_platforms is None # 未设置 JAX_PLATFORMS 时为 None(自动选择后端)
3.3 使用 pmap 进行单一程序多数据 (SPMD) 运算¶
SPMD(Single Program, Multiple Data) 是很常用的并行计算模型,特别适合 GPU 和 TPU:同一个程序同时在多个处理器上跑,每个实例处理数据的不同部分。和它相对的是 MPMD,也就是不同处理器跑不同的程序。
课程里实现 SPMD 的主要工具是 jax.pmap。它把为单设备写的函数,变成能在多个设备上并行执行的函数,复制和数据分片都自动处理。可以拿 Python 内置的 map 类比:pmap 就是把函数「并行地映射到各个设备上」。每个设备执行同一个编译好的函数,但拿到的是各自的那片输入。
pmap 如何实现 SPMD(以 4 个 TPU 核心为例)¶
- 编写单设备代码:按单设备的思路写函数,比如一层网络或一次损失计算;
- 套上
jax.pmap; - 整理数据分片:输入的主轴对应设备。比如形状
(128, 50)(批大小 128、特征 50)的数据配 4 个设备,要整理成(4, 32, 50),第 0 维的 4 就是设备轴; - 执行:JAX 在需要时用 XLA 编译原函数,把编译结果复制到 4 个设备上,再把对应的切片发给各个设备(设备 0 拿切片 0,依此类推)。所有设备同时算自己的切片,结果收回来以后沿新的主轴堆起来。
所以 pmap 的要点是:你只写单设备逻辑,pmap 负责把输入的主轴映射到设备上,处理复制和并行。底层由 XLA 针对目标硬件做优化。
# pmap 在 0.12 中是旧接口:docstring 第一句就让你改用 jax.shard_map
first_line = jax.pmap.__doc__.strip().splitlines()[0]
print(first_line)
assert first_line.startswith("Old way of doing parallel map")
# 课程示例:最基本的 pmap SPMD 示例
n_local = jax.local_device_count()
print("可用设备数:", n_local)
num_devices_to_use = 4 if n_local >= 4 else n_local
print(f"pmap 将使用 {num_devices_to_use} 个设备。")
per_device_batch_size = 8
feature_size = 16
global_batch_size = num_devices_to_use * per_device_batch_size
# 主轴 = 设备轴:(设备数, 每设备批大小, 特征数)
sharded_data = jnp.arange(global_batch_size * feature_size, dtype=jnp.float32).reshape(
(num_devices_to_use, per_device_batch_size, feature_size))
print("输入形状:", sharded_data.shape)
def simple_computation(x):
return x * 2.0 + 1.0 # 缩放再加常量
parallel_computation = jax.pmap(simple_computation)
result = parallel_computation(sharded_data)
print("输出形状:", result.shape, "| result[0, 0, 0] =", result[0, 0, 0]) # 原值 0 → 0*2+1 = 1
print("实际用到的设备:", sorted(d.id for d in result.sharding.device_set))
assert result.shape == sharded_data.shape and float(result[0, 0, 0]) == 1.0
assert len(result.sharding.device_set) == 4 # 主轴长 4 < 8 个设备时,只用前 4 个设备
# ✅ 现代写法:jit + NamedSharding。数据保持全局形状 (32, 16),分片信息写在 sharding 里,不需要手动 reshape 出设备轴
mesh4 = jax.make_mesh((4,), ("data",), devices=jax.devices()[:4]) # 和原示例一样,只用 4 个设备
flat = sharded_data.reshape(global_batch_size, feature_size)
flat_s = jax.device_put(flat, NamedSharding(mesh4, P("data")))
res_jit = jax.jit(simple_computation)(flat_s)
print("jit 版类型:", jax.typeof(res_jit))
# ✅ 现代写法:jax.shard_map。函数看到的是每个设备的局部块 (8, 16)
res_sm = jax.jit(jax.shard_map(simple_computation, mesh=mesh4, in_specs=P("data"), out_specs=P("data")))(flat_s)
assert np.array_equal(np.asarray(res_jit).reshape(result.shape), np.asarray(result))
assert np.array_equal(np.asarray(res_sm), np.asarray(res_jit))
print("pmap / jit+sharding / shard_map 结果一致 ✓")
🔍 补充:同一个 SPMD 程序的三种「视角」¶
🟢 必读
三种写法编译出来都是 SPMD 程序,区别在于函数体里看到的形状:
| 写法 | 函数体内 x 的形状 | 通信怎么写 |
|---|---|---|
pmap | 去掉设备轴的单个切片 (8, 16) | 手写集合通信,指定 axis_name |
shard_map | 保留维度的局部块 (8, 16)(全局 (32, 16) 按 4 份切) | 手写集合通信,用网格轴名 |
jit + sharding | 全局形状 (32, 16),分片写在类型里 | 编译器自动插入 |
下面在追踪时打印 jax.typeof。在 Explicit 网格下,jit 视角的类型直接显示 @data;shard_map 视角显示 {V:data},意思是这个值在各个设备上不一样(varying);pmap 视角只看得到单个切片。
views = {}
def spy(name):
def f(x):
views[name] = str(jax.typeof(x)) # 追踪时记录类型
return x * 2.0 + 1.0
return f
jax.pmap(spy("pmap"))(sharded_data)
jax.jit(jax.shard_map(spy("shard_map"), mesh=mesh4, in_specs=P("data"), out_specs=P("data")))(flat_s)
jax.jit(spy("jit"))(flat_s)
for k, v in views.items():
print(f"{k:10s} 视角: {v}")
assert views["jit"] == "float32[32@data,16]"
assert views["shard_map"].startswith("float32[8,16]") and "V:data" in views["shard_map"]
assert views["pmap"].startswith("float32[8,16]")
3.4 使用 pmap 实现数据并行¶
数据并行的基本做法是:把模型复制到多个设备上,每个设备用同一份参数处理输入批次的不同切片。课程用一个简单的预测函数,说明 pmap 怎么做到这两点:
- 模型复制:被 pmap 的函数(通常是前向计算或整个训练步)在所有设备上执行。参数用
in_axes=None广播,每个设备拿到一样的副本; - 数据分片:主轴长度等于设备数的输入,会沿这个轴拆开,第一片给第一个设备,依此类推。
in_axes 的含义:None 表示广播,同一个值发给所有设备,常用于模型参数;0 表示沿第 0 轴映射,数据沿第 0 轴切开,数据并行里输入数据一般都这么处理。
⚠️ 注意:pmap 的
in_axes默认是 0,没有映射主轴的参数不会自动广播。必须显式写in_axes=(None, 0),不然参数也会被当成要沿第 0 轴切开(下面的 ❌ 单元会直接报错)。这里的示例写了in_axes=(None, 0),所以能跑通;3.6 节梯度示例的 ❌ 单元漏写了它,就跑不通。
⚠️ 注意:
jax.random.PRNGKey是旧接口,0.12 推荐用jax.random.key。另外,JAX 的约定是每个 key 只用一次:不要拿同一个key(0)同时生成w、b和数据,要先split。下面的代码就是这么写的。key 的入门用法见 入门 01,深入讨论见 进阶 10。
# 课程示例(用 jax.random.key + split):准备工作
num_devices = jax.local_device_count()
print("Number of devices:", num_devices)
def predict(params, inputs):
return jnp.dot(inputs, params["w"]) + params["b"] # 一个简单的线性变换
key = jax.random.key(0)
k_w, k_b, k_x = jax.random.split(key, 3)
input_dim, output_dim = 10, 5
params = {"w": jax.random.normal(k_w, (input_dim, output_dim)),
"b": jax.random.normal(k_b, (output_dim,))}
global_batch_size = 32 * num_devices
dummy_data = jax.random.normal(k_x, (global_batch_size, input_dim))
print(f"Parameter shapes: w={params['w'].shape}, b={params['b'].shape}")
print("Global data batch shape:", dummy_data.shape)
# 为 pmap 重塑数据:[设备数, 每设备批大小, 特征数]
batch_per_device = global_batch_size // num_devices
sharded_data = dummy_data.reshape((num_devices, batch_per_device, input_dim))
print("Sharded data shape:", sharded_data.shape)
# params 广播(None),数据沿第 0 轴切开(0)
parallel_predict = jax.pmap(predict, in_axes=(None, 0))
sharded_predictions = parallel_predict(params, sharded_data)
sharded_predictions.block_until_ready()
print("Output predictions shape:", sharded_predictions.shape)
assert sharded_predictions.shape == (num_devices, batch_per_device, output_dim)
# ❌ 旧写法(错误):省略 in_axes,以为参数会「自动广播」——默认 in_axes=0,w 的第 0 维 10 会被当成设备轴
import contextlib, io
try:
with contextlib.redirect_stdout(io.StringIO()): # JAX 报错前会往 stdout 打印一行源码位置,这里屏蔽掉
jax.pmap(predict)(params, sharded_data)
raised = False
except ValueError as e:
raised = True
msg = str(e)
print("省略 in_axes 报错:", type(e).__name__)
print(" ", msg[msg.find("implies"):msg.find("(full shape")].strip())
# b 的第 0 维是 5,被当成「5 个设备」的映射轴;数据的第 0 维却是 8,于是无法整除
assert raised and "partitioned 5 times" in msg
# ✅ 推荐写法 / 现代写法:参数用 P() 复制、数据用 P('data') 分片;函数用全局形状,不需要 reshape(pmap 写法见上一单元的 in_axes=(None, 0))
mesh = jax.make_mesh((N,), ("data",))
params_rep = jax.device_put(params, NamedSharding(mesh, P()))
data_sh = jax.device_put(dummy_data, NamedSharding(mesh, P("data")))
print("参数类型:", jax.typeof(params_rep["w"]), "| 数据类型:", jax.typeof(data_sh))
pred_modern = jax.jit(predict)(params_rep, data_sh)
print("输出类型:", jax.typeof(pred_modern))
# 与 pmap 结果一致,并与 NumPy float64 对拍
assert np.allclose(np.asarray(pred_modern).reshape(sharded_predictions.shape), np.asarray(sharded_predictions), atol=1e-6)
ref = f64(dummy_data) @ f64(params["w"]) + f64(params["b"])
assert np.allclose(f64(pred_modern), ref, atol=1e-5)
print("pmap == jit+sharding == NumPy float64 ✓")
总结一下数据流:全局批次在设备间拆开,参数复制到每个设备,各设备独立计算,输出沿设备轴堆起来。sharded_predictions[i] 就是设备 i 用分片 sharded_data[i] 和复制的参数算出来的结果。
真正训练时,predict 只是更大的 train_step 的一部分,后者还要算损失和梯度。更新参数之前,各设备的梯度必须跨设备合并(通常取平均),这就要用到下一节的集合通信原语。
🔍 补充:数据并行的三个实战要点¶
⚪ 选读:真正搭数据并行训练循环、调学习率和处理尾批时参考
- 参数要常驻设备。训练循环里,参数应该一次性
device_put成复制的(P())。每一步输出的仍是分片方式相同的参数,下一步直接传回去就行。用 pmap 时,常见做法是让参数带上设备轴、用in_axes=0传入(见 3.9 节),这样参数一直留在各个设备上。 - 全局批次与学习率。设备数从 1 加到 $N$、每设备批大小不变时,全局批次扩大 $N$ 倍,梯度噪声变小。常见经验是线性放大学习率($\eta \to N\eta$),再配合 warmup。批次很大时线性放大往往不再管用,通常改用 LARS / LAMB 这类按层自适应学习率的优化器。这不影响「pmean 后等于全局梯度」的结论,只影响收敛相关的超参数。
- 分片维度必须能整除。
P('data')要求第 0 维能被网格轴的大小整除。最后一个不完整的批次,要么丢掉,要么填充后配合掩码(入门课 05 的补充练习 4 讲过不等长批次该怎么正确求平均)。
3.5 集合通信原语 (psum, pmean 等)¶
在 pmap 里,每个设备在自己那片数据上执行同一个程序。可分布式算法离不开设备之间的通信和同步。比如数据并行训练中,各设备用本地批次算出梯度以后,必须先在所有设备间汇总(通常取平均),才能更新参数。这一步靠的就是集合通信。
jax.lax 里的集合操作,让分布在各个设备上(沿 pmap 映射轴)的数组一起参与同一个计算,结果通常会复制回所有参与的设备。集合操作必须在绑定了对应轴名的变换里面调用。除了 pmap,0.12 里 shard_map 和带 axis_name 的 vmap 也可以(3.6 节补充里演示了 vmap)。
用 axis_name 指定通信轴:集合操作要用 axis_name 说明沿哪个轴通信,这个名字必须和外层 pmap(..., axis_name=...) 起的名字一致。显式命名能避免歧义,多维并行时尤其重要。
核心归约:psum、pmean、pmax、pmin¶
设沿轴 'i' 有 $N$ 个设备,设备 $j$ 持有 $x_j$。下面的式子都是逐元素的,而且每个设备得到的结果都一样:
$$ \texttt{psum}: y = \sum_{j=0}^{N-1} x_j,\qquad \texttt{pmean}: y = \frac{1}{N}\sum_{j} x_j,\qquad \texttt{pmax}: y = \max_j x_j,\qquad \texttt{pmin}: y = \min_j x_j . $$
psum:常用来在优化器更新前,对数据并行的梯度求和。举个例子:4 个设备分别持有 2、3、1、4,psum之后每个设备都得到 $2+3+1+4=10$;pmean:数据并行里平均梯度的标准做法;pmax/pmin:可以用来同步标志(比如「有没有哪个设备出错了」),或者找出整个批次里最大的损失。
⚠️ 注意:
DeviceArray早就移除了。旧资料里的示例输出常写成DeviceArray([6., 6., 6., 6.], dtype=float32),现在所有数组都是统一的jax.Array,打印出来是Array([...], dtype=float32)。
# 课程示例:psum / pmean / pmax。示例假设有 4 个设备,pmap 主轴长 4 时只用前 4 个设备
def sum_across_devices(local_value):
return lax.psum(local_value, axis_name="batch_axis") # 'batch_axis' 必须与 pmap 的 axis_name 一致
def average_across_devices(local_value):
return lax.pmean(local_value, axis_name="batch_axis")
def get_max_value(local_value):
return lax.pmax(local_value, axis_name="data_split")
values = jnp.arange(4.) # 每个设备一个值:[0, 1, 2, 3]
r_sum = jax.pmap(sum_across_devices, axis_name="batch_axis")(values)
r_mean = jax.pmap(average_across_devices, axis_name="batch_axis")(values)
r_max = jax.pmap(get_max_value, axis_name="data_split")(jnp.array([2., 5., 1., 4.]))
r_text = jax.pmap(sum_across_devices, axis_name="batch_axis")(jnp.array([2., 3., 1., 4.])) # 正文里的例子
print("psum :", repr(r_sum)) # 每个设备都得到 0+1+2+3 = 6
print("pmean:", r_mean) # (0+1+2+3)/4 = 1.5
print("pmax :", r_max)
print("psum([2,3,1,4]):", r_text)
assert type(r_sum).__name__ == "ArrayImpl" and isinstance(r_sum, jax.Array) # 不再是 DeviceArray
assert np.array_equal(r_sum, [6.] * 4) and np.array_equal(r_mean, [1.5] * 4)
assert np.array_equal(r_max, [5.] * 4) and np.array_equal(r_text, [10.] * 4)
# ✅ 现代写法:shard_map 中直接用网格轴名做集合通信;out_specs=P() 表示结果在各设备上相同(invariant)
mesh4c = jax.make_mesh((4,), ("batch_axis",), devices=jax.devices()[:4])
vals4 = jax.device_put(jnp.arange(4.), NamedSharding(mesh4c, P("batch_axis")))
def reduce_all(x): # x 是每个设备的局部块,形状 (1,)
return lax.psum(x, "batch_axis"), lax.pmean(x, "batch_axis"), lax.pmax(x, "batch_axis")
s, m, mx = jax.jit(jax.shard_map(reduce_all, mesh=mesh4c, in_specs=P("batch_axis"), out_specs=P()))(vals4)
assert float(s[0]) == float(r_sum[0]) == 6.0
assert float(m[0]) == float(r_mean[0]) == 1.5
assert float(mx[0]) == 3.0 # max(0,1,2,3)
# ✅ 现代写法:jit + sharding,连集合通信都不用写,jnp.sum 就是全局和,编译器自动插入 all-reduce
f_sum = jax.jit(jnp.sum)
assert float(f_sum(vals4)) == 6.0
print("jit 版用到的通信:", collectives(f_sum.lower(vals4).compile()))
⚠️ 注意:容易以为直接用
pmean比先psum再手动除以设备数更高效,因为后端能专门优化它。其实pmean本身就是psum加一次除法。下面的 jaxpr 里只有psum_invariant和div,并没有专门的pmean原语,两种写法编译出来的通信完全一样。推荐pmean,只是因为它更好读,也不容易忘记除以 $N$。
mesh8 = jax.make_mesh((N,), ("i",))
v8 = jax.device_put(jnp.arange(8.), NamedSharding(mesh8, P("i")))
jaxpr_txt = str(jax.make_jaxpr(jax.shard_map(lambda x: lax.pmean(x, "i"), mesh=mesh8,
in_specs=P("i"), out_specs=P()))(v8))
print(jaxpr_txt)
assert "psum" in jaxpr_txt and "div" in jaxpr_txt and "pmean" not in jaxpr_txt
# 两种写法编译出的通信相同:都只有一个 all-reduce
f_pmean = jax.jit(jax.shard_map(lambda x: lax.pmean(x, "i"), mesh=mesh8, in_specs=P("i"), out_specs=P()))
f_psum = jax.jit(jax.shard_map(lambda x: lax.psum(x, "i") / N, mesh=mesh8, in_specs=P("i"), out_specs=P()))
assert collectives(f_pmean.lower(v8).compile()) == collectives(f_psum.lower(v8).compile()) == ["all-reduce"]
assert np.allclose(f_pmean(v8), f_psum(v8))
其他集合操作¶
还有三个常用的:
jax.lax.all_gather:把所有设备的输入收集起来,沿映射轴拼接,每个设备都拿到完整的数组。要注意,输出大小随设备数线性增长,可能很占内存;jax.lax.ppermute:按perm参数给出的 (源, 目标) 对交换数据。它更通用,是实现环形 all-reduce 和模型并行通信的基础,但索引要仔细管理(入门课 05 的补充练习 2 已经用它手写过环形 all-reduce);jax.lax.axis_index(axis_name):严格说它不是通信原语。它返回当前设备在指定轴上的整数编号,可以让不同位置的设备执行不同的逻辑。
总的来说,集合通信原语是 pmap 内部协调各个设备的基本构件,梯度聚合、同步点、数据交换都靠它们。JAX 和 XLA 会在 GPU/TPU 上利用 NVLink 或芯片间互联(ICI)优化执行,但通信总是有开销的。
🔍 补充:四个「重排型」集合操作的精确语义¶
🟢 必读
psum_scatter 和 all_to_all 是 ZeRO/FSDP、MoE、序列并行的核心。设轴上有 $N$ 个设备,设备 $j$ 持有 $x_j$,把 $x_j$ 沿某一维均分成 $N$ 块,记作 $x_j^{(0)},\dots,x_j^{(N-1)}$:
| 操作 | 设备 $i$ 得到 | 每设备通信量(环形) | 典型用途 |
|---|---|---|---|
all_gather(x, tiled=True) | $[x_0; x_1; \dots; x_{N-1}]$ | $\frac{N-1}{N}S_\text{out}$ | FSDP 前向前收集参数;张量并行收集激活 |
psum_scatter(x, tiled=True) | $\sum_j x_j^{(i)}$(总和的第 $i$ 块) | $\frac{N-1}{N}S_\text{in}$ | FSDP / ZeRO-2 的梯度 reduce-scatter |
all_to_all(x, split_axis, concat_axis) | $[x_0^{(i)}; x_1^{(i)}; \dots; x_{N-1}^{(i)}]$ | $\frac{N-1}{N}S$ | 分布式转置、MoE token 路由、Ulysses 序列并行 |
ppermute(x, perm) | $x_{\pi^{-1}(i)}$;没有人发给它时为 0 | $S$ | 环形算法、halo 交换、流水线传激活 |
还有一个恒等式:all-reduce = reduce-scatter + all-gather。下面用 8 个设备逐个验证(8×8 矩阵按行切分,设备 i 持有第 i 行)。
X8 = np.arange(64.).reshape(8, 8)
X8s = jax.device_put(jnp.asarray(X8), NamedSharding(mesh8, P("i"))) # 设备 i 持有第 i 行,局部形状 (1, 8)
def smap(f, in_specs=P("i"), out_specs=P("i")):
return jax.jit(jax.shard_map(f, mesh=mesh8, in_specs=in_specs, out_specs=out_specs))
# all_gather:tiled=False 新增一个长度 N 的轴,局部 (8,1,8);tiled=True 沿已有轴拼接,局部 (8,8)
ag = smap(lambda x: lax.all_gather(x, "i", tiled=True))(X8s)
assert np.array_equal(np.asarray(ag), np.tile(X8, (8, 1))) # 每个设备都拿到完整的 X
ag_shape = jax.eval_shape(jax.shard_map(lambda x: lax.all_gather(x, "i"), mesh=mesh8, in_specs=P("i"), out_specs=P("i")), X8s).shape
print("all_gather 非 tiled 全局形状:", ag_shape, "| tiled:", ag.shape)
# psum_scatter:先逐元素求和(列和),再把结果沿第 1 维切成 8 块,设备 i 只留第 i 块
rs = smap(lambda x: lax.psum_scatter(x, "i", scatter_dimension=1, tiled=True))(X8s)
assert np.array_equal(np.asarray(rs).ravel(), X8.sum(0))
print("psum_scatter 全局形状:", rs.shape, "= 列和", X8.sum(0)[:4], "...")
# all_to_all:每个设备把自己的行切成 8 块分别发给 8 个设备 → 由「按行切分」变成「按列切分」
a2a = smap(lambda x: lax.all_to_all(x, "i", split_axis=1, concat_axis=0, tiled=True), out_specs=P(None, "i"))(X8s)
assert np.array_equal(np.asarray(a2a), X8)
print("all_to_all 前:", jax.typeof(X8s), "→ 后:", jax.typeof(a2a), "(数据不变,分片从行换成列)")
assert a2a.addressable_shards[3].data.shape == (8, 1)
assert np.array_equal(np.asarray(a2a.addressable_shards[3].data).ravel(), X8[:, 3]) # 设备 3 持有第 3 列
# ppermute:环形右移,设备 j 发给 j+1
pp = smap(lambda x: lax.ppermute(x, "i", [(j, (j + 1) % 8) for j in range(8)]))(X8s)
assert np.array_equal(np.asarray(pp), np.roll(X8, 1, axis=0))
# 恒等式:reduce-scatter + all-gather == all-reduce
rsag = smap(lambda x: lax.all_gather(lax.psum_scatter(x, "i", scatter_dimension=1, tiled=True), "i", axis=1, tiled=True))(X8s)
ar = smap(lambda x: lax.psum(x, "i"))(X8s)
assert np.array_equal(np.asarray(rsag), np.asarray(ar))
print("reduce-scatter + all-gather == all-reduce ✓")
xs1d = np.arange(64.) ** 1.5
xs1d_s = jax.device_put(jnp.asarray(xs1d), NamedSharding(mesh8, P("i")))
def stencil(x): # x:本设备的 8 个元素
to_right = [(j, j + 1) for j in range(7)] # 设备 j 把最后一个元素发给 j+1(设备 0 收不到 → 0)
to_left = [(j + 1, j) for j in range(7)] # 设备 j+1 把第一个元素发给 j(设备 7 收不到 → 0)
left_halo = lax.ppermute(x[-1:], "i", to_right)
right_halo = lax.ppermute(x[:1], "i", to_left)
padded = jnp.concatenate([left_halo, x, right_halo])
return padded[:-2] + padded[1:-1] + padded[2:]
y_st = smap(stencil)(xs1d_s)
ref_st = np.convolve(np.pad(xs1d, 1), np.ones(3), mode="valid") # NumPy float64 参考
assert np.allclose(f64(y_st), ref_st, rtol=1e-6)
print("halo 交换模板计算 == NumPy 卷积 ✓;用到的通信:", collectives(smap(stencil).lower(xs1d_s).compile()))
🔍 补充:all_gather 之后的值仍是 varying¶
⚪ 选读:shard_map 中
out_specs=P()报复制性错误时再看
shard_map 默认 check_vma=True,会追踪每个值是不是随设备而不同(入门课 05 讲过)。psum / pmean / pmax 的结果是 invariant(各设备相同),可以用 out_specs=P() 只输出一份。容易踩坑的是:在 0.12 的类型系统里,all_gather 的结果仍然标成 varying({V:i}),所以 all_gather 之后直接写 out_specs=P() 会报错。有两个办法:保持 out_specs=P('i')(在外面看到的是 N 份拼在一起);或者确实需要复制的输出时,改用 jit + sharding,交给编译器去做。
types = {}
def probe_vma(x):
types["local"] = str(jax.typeof(x))
types["psum"] = str(jax.typeof(lax.psum(x, "i")))
types["all_gather"] = str(jax.typeof(lax.all_gather(x, "i", tiled=True)))
return x
smap(probe_vma)(X8s)
for k, v in types.items():
print(f"{k:11s}: {v}")
assert "V:i" in types["local"] and "V:i" not in types["psum"] and "V:i" in types["all_gather"]
try:
smap(lambda x: lax.all_gather(x, "i", tiled=True), out_specs=P())(X8s)
raised = False
except ValueError as e:
raised = True
print("all_gather + out_specs=P() 报错:", str(e)[:90], "...")
assert raised
3.6 pmap 中轴名称的处理¶
写要用 pmap 并行的函数时,特别是用到 lax.psum、lax.pmean 这类集合操作时,必须说清楚通信发生在哪一组设备之间。办法是用 axis_name 给 pmap 映射的轴起个名字,然后在集合操作里引用这个名字。
⚠️ 注意:
axis_name是psum的必需参数。容易以为简单情况下不写它,psum也会隐式地对映射轴求和;其实省略会直接报TypeError: psum() missing 1 required positional argument: 'axis_name'。JAX 没有「自动推断集合操作沿哪个轴」这种行为。
# ❌ 旧写法(错误):以为 psum 不写 axis_name 也能隐式工作
try:
jax.pmap(lambda x: lax.psum(x * 2.0))(jnp.arange(8.))
raised = False
except TypeError as e:
raised = True
print("省略 axis_name 报错:", e)
assert raised
# ✅ 推荐写法 / 课程示例:给映射轴起名 'devices',psum 显式引用它
def scaled_sum(x):
scaled_x = x * 2.0
return lax.psum(scaled_x, axis_name="devices")
n_devices = jax.local_device_count()
print(f"使用 {n_devices} 个设备。")
data = jnp.arange(n_devices, dtype=jnp.float32)
parallel_computation = jax.pmap(scaled_sum, axis_name="devices") # 名字绑定到输入的第 0 轴
result = parallel_computation(data)
print("输入数据:", data)
print("结果(所有设备上相同):", result)
expected_sum = jnp.sum(data * 2.0)
print("预期总和:", expected_sum)
assert jnp.allclose(result[0], expected_sum)
# 两个例子:[0,1] → 2;[0,1,2,3] → 12
assert np.array_equal(jax.pmap(scaled_sum, axis_name="devices")(jnp.arange(2.)), [2., 2.])
assert np.array_equal(jax.pmap(scaled_sum, axis_name="devices")(jnp.arange(4.)), [12.] * 4)
# ✅ 现代写法:轴名 'devices' 写在网格上;shard_map 中先求块内和再 psum,jit 版直接写全局求和
mesh_dev = jax.make_mesh((N,), ("devices",))
data_s = jax.device_put(data, NamedSharding(mesh_dev, P("devices")))
res_sm = jax.jit(jax.shard_map(lambda x: lax.psum(jnp.sum(x * 2.0), "devices"), mesh=mesh_dev,
in_specs=P("devices"), out_specs=P()))(data_s)
res_jit = jax.jit(lambda x: jnp.sum(x * 2.0))(data_s)
assert float(res_sm) == float(res_jit) == float(result[0]) == 56.0
print("pmap / shard_map / jit 的结果都是", float(res_jit))
课程列出了命名轴的三个好处:
- 清晰:
lax.psum(..., axis_name='batch')一看就知道是沿批次维度求和; - 适合多维并行:并行有好几个层级时,每层有自己的名字,集合操作靠名字指定作用在哪一层。比如有
'data_shards'和'model_replicas'两层:沿'data_shards'的集合操作,发生在「数据分片不同、模型副本相同」的设备之间;沿'model_replicas'的,发生在「模型副本不同、数据分片相同」的设备之间。0.12 里这对应二维网格的两个轴(见 3.7 节); - 可组合:一个沿
'spatial'轴做归约的函数,可以放进任何映射了这个名字的并行上下文里复用。
第二个示例是数据并行中的梯度平均:每个设备对本地分片算梯度,更新参数前用 pmean 在所有设备间取平均。
⚠️ 注意:这个示例里
params的两个叶子都是标量(形状())。调用pmap(parallel_update_step, axis_name='data_parallel_axis')时如果不写in_axes,就按默认的in_axes=0处理,而标量没有第 0 轴可以映射,会报IndexError: tuple index out of range。要写成in_axes=(None, 0)。另外,最后取设备 0 的参数时不能用jax.tree_map:它在 0.12 里已经删除了(AttributeError),要改用jax.tree.map。
# 课程示例:模型与更新步
def loss_fn(params, local_batch):
predictions = params["w"] * local_batch["x"] + params["b"]
error = predictions - local_batch["y"]
return jnp.mean(error ** 2)
def compute_gradients(params, local_batch):
return jax.grad(loss_fn)(params, local_batch)
def parallel_update_step(params, sharded_batch):
local_grads = compute_gradients(params, sharded_batch) # 每个设备局部计算梯度
avg_grads = lax.pmean(local_grads, axis_name="data_parallel_axis") # 沿命名轴平均
learning_rate = 0.01
return jax.tree.map(lambda p, g: p - learning_rate * g, params, avg_grads)
n_devices = jax.local_device_count()
params_s = {"w": jnp.ones(()), "b": jnp.zeros(())}
xs_b = jnp.arange(n_devices * 4, dtype=jnp.float32).reshape((n_devices, 4, 1))
ys_b = xs_b * 2.0 + 0.5 # 真实 w=2.0, b=0.5
sharded_batch = {"x": xs_b, "y": ys_b}
# ❌ 旧写法(错误):省略 in_axes(标量参数无法按默认 in_axes=0 映射),并使用已删除的 jax.tree_map
try:
jax.pmap(parallel_update_step, axis_name="data_parallel_axis")(params_s, sharded_batch)
err1 = None
except IndexError as e:
err1 = e
print("省略 in_axes:", type(e).__name__, "-", e)
try:
jax.tree_map(lambda x: x, params_s)
err2 = None
except AttributeError as e:
err2 = e
print("jax.tree_map:", type(e).__name__, "-", e)
assert isinstance(err1, IndexError) and isinstance(err2, AttributeError)
# ✅ 推荐写法(仍用 pmap):params 用 None 广播,数据沿 0 轴映射;用 jax.tree.map 取设备 0 的结果
p_update_step = jax.pmap(parallel_update_step, axis_name="data_parallel_axis", in_axes=(None, 0))
new_params = p_update_step(params_s, sharded_batch)
new_params_0 = jax.tree.map(lambda x: x[0], new_params)
print("原始参数:", params_s)
print("更新后的参数(设备 0):", new_params_0)
# pmean 之后各设备的参数完全相同
assert all(np.all(np.asarray(v) == np.asarray(v)[0]) for v in jax.tree.leaves(new_params))
# ✅ 现代写法:jit + NamedSharding。函数里不出现任何轴名,用全局批次的损失,编译器自动插入梯度 all-reduce
mesh_dp = jax.make_mesh((N,), ("data_parallel_axis",))
batch_global = jax.device_put({"x": xs_b.reshape(-1, 1), "y": ys_b.reshape(-1, 1)},
NamedSharding(mesh_dp, P("data_parallel_axis")))
params_rep_s = jax.device_put(params_s, NamedSharding(mesh_dp, P()))
@jax.jit
def update_step_global(params, batch):
grads = jax.grad(loss_fn)(params, batch) # 全局平均损失的梯度 == 各设备本地梯度的 pmean
return jax.tree.map(lambda p, g: p - 0.01 * g, params, grads)
new_params_jit = update_step_global(params_rep_s, batch_global)
print("通信:", collectives(update_step_global.lower(params_rep_s, batch_global).compile()))
# NumPy float64 对拍:解析梯度 dL/dw = 2·mean(err·x),dL/db = 2·mean(err)
xx, yy = f64(xs_b).ravel(), f64(ys_b).ravel()
err = 1.0 * xx + 0.0 - yy
w_ref, b_ref = 1.0 - 0.01 * 2 * np.mean(err * xx), 0.0 - 0.01 * 2 * np.mean(err)
for k, ref_v in [("w", w_ref), ("b", b_ref)]:
assert np.allclose(float(new_params_0[k]), ref_v, rtol=1e-6)
assert np.allclose(float(new_params_jit[k]), ref_v, rtol=1e-6)
print(f"pmap == jit+sharding == NumPy:w={w_ref:.6f}, b={b_ref:.6f} ✓")
所以,显式写 axis_name 不只是推荐做法:上面已经看到,集合操作根本不能省略轴名。给轴起清楚的名字,分布式代码更好读,不容易出隐蔽的问题,也更容易维护和组合。
🔍 补充:0.12 中轴名的「归宿」¶
⚪ 选读:需要多轴归约,或者想在单设备上用 vmap 测试通信逻辑时
- 轴名写在网格上。
jax.make_mesh((4, 2), ('data', 'model'))定义了轴名;PartitionSpec用这些名字描述分片;shard_map里的集合操作也引用这些名字。pmap 的axis_name只是一个「一维的、临时的」网格轴。 - 可以同时对多个轴做归约:
lax.psum(x, ('data', 'model'))对整个二维网格求和;lax.axis_index('data')返回本设备在这个轴上的坐标。 - 在单设备上测试集合通信逻辑:
vmap同样接受axis_name,psum / all_gather / ppermute在vmap下都有定义,批维就相当于「设备」。写复杂的通信模式时,可以先用vmap在 CPU 上做单元测试,再换成shard_map。
# vmap 也接受 axis_name:在单设备上验证集合通信的语义
vx = jnp.arange(4.)
v_psum = jax.vmap(lambda x: lax.psum(x, "b"), axis_name="b")(vx)
v_perm = jax.vmap(lambda x: lax.ppermute(x, "b", [(0, 1), (1, 2), (2, 3), (3, 0)]), axis_name="b")(vx)
v_idx = jax.vmap(lambda x: lax.axis_index("b"), axis_name="b")(vx)
print("vmap psum:", v_psum, "| ppermute:", v_perm, "| axis_index:", v_idx)
assert np.array_equal(v_psum, [6.] * 4) and np.array_equal(v_perm, [3., 0., 1., 2.]) and np.array_equal(v_idx, [0, 1, 2, 3])
# 与 shard_map 版本对照(Explicit 网格:输入要先 device_put 成匹配 in_specs 的分片)
mesh_b = jax.make_mesh((4,), ("b",), devices=jax.devices()[:4])
sm_perm = jax.jit(jax.shard_map(lambda x: lax.ppermute(x, "b", [(0, 1), (1, 2), (2, 3), (3, 0)]),
mesh=mesh_b, in_specs=P("b"), out_specs=P("b")))(
jax.device_put(vx, NamedSharding(mesh_b, P("b"))))
assert np.array_equal(np.asarray(sm_perm), np.asarray(v_perm))
print("vmap 与 shard_map 的 ppermute 结果一致 ✓")
3.7 嵌套 pmap 与进阶分区¶
单个 pmap 擅长把计算分到一维排列的设备上。更复杂的并行(比如数据并行 + 模型并行)需要多维的加速器网格。课程用的办法是嵌套 pmap:以 4 个 GPU 排成 2×2 网格为例,行做数据并行,列做模型并行;外层 pmap(axis_name='data') 的函数里调用内层 pmap(axis_name='model') 的函数。
嵌套时要注意三点:
- 轴的作用域:内层 pmap 定义的轴名,只在内层调用和它涉及的设备里有效;外层的
axis_name指的是外层各设备之间的集合操作; - 数据映射:输入形状是
(外层大小, 内层大小, ...),第 0 维对应外层的'data'轴,第 1 维对应内层的'model'轴; - 集合操作必须指定
axis_name,由它决定哪一组设备之间通信。
⚠️ 注意:嵌套 pmap 的这套写法在 JAX 0.12 里有好几处跑不通:
from jax.experimental import maps:这个模块已经删除(ModuleNotFoundError);jnp.ones(16.):形状必须是整数,会报TypeError,要写jnp.ones(16);jax.device_put(...).devices:devices是方法,不加括号拿到的是一个绑定方法对象。变量名device_mesh也容易误导,代码并没有构造网格。jnp.arange(num_devices).reshape(2, 2)只在恰好有 4 个设备时成立,8 个设备就报错。回退分支jax.local_devices()[:1] * 4把同一个设备重复了 4 次,也没法用来并行;- 最根本的问题是:嵌套 pmap 在 0.12 里直接报
ValueError(pmap 现在基于 shard_map 实现,内层的网格会和外层冲突)。应该改用二维网格 + shard_map。
# ❌ 旧写法(过时/错误):jax.experimental.maps、jnp.ones(16.)、.devices 不加括号、reshape(2,2)、嵌套 pmap
problems = {}
try:
import jax.experimental.maps # noqa: F401
except ModuleNotFoundError as e:
problems["experimental.maps"] = f"{type(e).__name__}: {e}"
try:
jnp.ones(16.)
except TypeError as e:
problems["jnp.ones(16.)"] = f"{type(e).__name__}: {str(e)[:70]}"
try:
jnp.arange(jax.local_device_count()).reshape(2, 2)
except TypeError as e:
problems["arange(8).reshape(2,2)"] = f"{type(e).__name__}: {str(e)[:70]}"
arr = jax.device_put(jnp.arange(4).reshape(2, 2), jax.devices()[0])
problems[".devices 不加括号"] = f"得到 {type(arr.devices).__name__},而不是设备网格"
from functools import partial
@partial(jax.pmap, axis_name="model") # 原示例写作 @jax.pmap(axis_name='model');这里用 partial 写法,等价而且更通用
def inner_op(x_model_shard, params_model_shard):
return lax.psum(x_model_shard * params_model_shard, axis_name="model") * 2
@partial(jax.pmap, axis_name="data")
def outer_op(x_data_shard, params_data_shard):
return lax.psum(inner_op(x_data_shard, params_data_shard), axis_name="data")
data_simple = jnp.arange(4.).reshape(2, 2)
params_simple = jnp.ones(4).reshape(2, 2) * 0.5 # 已把 ones(4.) 修正为 ones(4),只看嵌套 pmap 本身
try:
outer_op(data_simple, params_simple)
except ValueError as e:
problems["嵌套 pmap"] = f"{type(e).__name__}: {str(e)[:110]}"
for k, v in problems.items():
print(f"- {k:24s} → {v}")
assert len(problems) == 5
# ✅ 推荐写法 / 现代写法:嵌套 pmap → 2×2 二维网格 + shard_map。同一个函数里同时引用两个轴名
mesh22 = jax.make_mesh((2, 2), ("data", "model"), devices=jax.devices()[:4])
print(mesh22)
def inner(x, p):
return lax.psum(x * p, "model") * 2 # 沿 'model' 轴(同一行的 2 个设备)求和
def outer(x, p):
return lax.psum(inner(x, p), "data") # 沿 'data' 轴(同一列的 2 个设备)求和
put22 = lambda a: jax.device_put(a, NamedSharding(mesh22, P("data", "model")))
f22 = jax.jit(jax.shard_map(outer, mesh=mesh22, in_specs=(P("data", "model"),) * 2, out_specs=P("data", "model")))
out_simple = np.asarray(f22(put22(data_simple), put22(params_simple)))
print("简化示例输出:\n", out_simple)
assert np.array_equal(out_simple, np.full((2, 2), 6.0)) # 不是 12.0
# 容易算出的 12 是怎么来的:把内层结果(在 model 方向已经复制)再沿两个轴一起 psum,同一个值被数了 2 次
# vma 类型检查会拒绝这种重复计数(对 model 方向不变的值再沿 model 求和),要关掉 check_vma 才能复现 12
try:
jax.jit(jax.shard_map(lambda x, p: lax.psum(inner(x, p), ("data", "model")), mesh=mesh22,
in_specs=(P("data", "model"),) * 2, out_specs=P()))(put22(data_simple), put22(params_simple))
vma_raised = False
except ValueError as e:
vma_raised = True
print("check_vma=True 拒绝重复求和:", str(e)[:80], "...")
f_double = jax.jit(jax.shard_map(lambda x, p: lax.psum(inner(x, p), ("data", "model")), mesh=mesh22,
in_specs=(P("data", "model"),) * 2, out_specs=P(), check_vma=False))
twelve = float(np.asarray(f_double(put22(data_simple), put22(params_simple))).ravel()[0])
print("关闭 check_vma、沿两个轴 psum(1,1,5,5) =", twelve, "← 12.0 是重复计数的结果")
assert vma_raised and twelve == 12.0
# 4 维示例:data (2,2,2,2),每个设备拿 (1,1,2,2) 的块
data4 = jnp.arange(16.).reshape(2, 2, 2, 2)
params4 = jnp.ones(16).reshape(2, 2, 2, 2) * 0.5
out4 = np.asarray(f22(put22(data4), put22(params4)))
print("4 维示例 output[0, 0]:\n", out4[0, 0])
assert np.array_equal(out4[0, 0], np.asarray(data4).sum(axis=(0, 1))) # 2·0.5·Σ_ij x_ij = Σ_ij x_ij
assert all(np.array_equal(out4[i, j], out4[0, 0]) for i in range(2) for j in range(2))
⚠️ 注意:简化示例(
data_simple = arange(4).reshape(2,2)、params = 0.5)的output_simple[0]很容易被算成12.0,推导是「外层 psum(1, 1, 5, 5) = 12」。用上面二维网格 + shard_map 的等价实现跑出来,结果其实是[6., 6.]。原因是外层psum(..., 'data')只对'data'轴求和,参与的只有同一列的 2 个设备,不是全部 4 个:
$$ \text{inner}_{i} = 2\sum_{j} x_{ij}p_{ij} = \begin{cases}2(0+0.5)=1 & i=0\\ 2(1+1.5)=5 & i=1\end{cases}, \qquad \text{outer} = \sum_i \text{inner}_i = 1+5 = 6 . $$
「psum(1, 1, 5, 5)」的算法,是把内层结果沿两个轴又求了一次和。可内层结果在 'model' 方向上已经是复制的(同一行的两个设备都等于 1 或 5),再沿 'model' 求和,同一个值就被数了两次。0.12 的 vma 类型检查会直接拒绝这种写法(上面已经验证过)。4 维示例也是同样的道理,正确结果是 output[i, j] = data.sum(axis=(0, 1)),上面已用 NumPy 对拍。
进阶分区策略¶
pmap 仍然遵循 SPMD,分区方式由输入沿映射轴的分片隐式决定。常见的两种模式:
- 数据并行 + 模型并行:外层轴上
psum梯度,内层轴上all_gather激活; - 2D/3D 张量并行:把大矩阵乘法切到二维或三维的设备网格上。
可以想象一个 2×2 网格:外层 pmap 映射到「数据」轴(行),内层映射到「模型」轴(列)。
要手动分配设备,可以用 jax.device_put 预先放好输入,或者用 pmap(devices=...) 限定设备。更复杂的场景,以前会用 jax.jit(device=...)、jax.experimental.shard_map 或基于 jax.Array 的框架。
⚠️ 注意:
jax.jit(device=...)在 0.12 里已经弃用,会发出DeprecationWarning,建议改成对输入做device_put。jax.experimental.shard_map从 v0.8.0 起弃用,要改用jax.shard_map。
# ❌ 旧写法(过时):jax.jit(device=...) 与 jax.experimental.shard_map 都会发出 DeprecationWarning
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
jax.jit(lambda x: x + 1, device=jax.devices()[1])(1.0)
from jax.experimental.shard_map import shard_map as _old_shard_map # noqa: F401
msgs = [str(x.message) for x in w if issubclass(x.category, DeprecationWarning)]
for m_ in msgs:
print("DeprecationWarning:", m_[:110])
assert any("device argument on jit is deprecated" in m_ for m_ in msgs)
assert any("jax.experimental.shard_map is deprecated" in m_ for m_ in msgs)
# ✅ 推荐写法:用 device_put 把输入放到目标设备,计算就会跟着数据走;用 jax.shard_map 代替 experimental 版本
y_dev = jax.jit(lambda x: x + 1)(jax.device_put(1.0, jax.devices()[1]))
assert y_dev.device == jax.devices()[1]
print("计算在", y_dev.device, "上执行")
🔍 补充:二维网格与 PartitionSpec 的组合¶
🟢 必读
下面是 0.12 里多维分区的整套工具。先把 8 个设备排成 (data=4, model=2) 的网格。PartitionSpec 的第 k 项,说明数组第 k 维沿哪个(或哪些)网格轴切分:
PartitionSpec | 含义 | (8, 4) 数组的每设备块 |
|---|---|---|
P() | 完全复制 | (8, 4) |
P('data') | 第 0 维沿 data 切 4 份,在 model 方向复制 | (2, 4) |
P(None, 'model') | 第 1 维沿 model 切 2 份 | (8, 2) |
P('data', 'model') | 两维分别切分 | (2, 2) |
P('model', 'data') | 交换对应关系 | (4, 1) |
P(('data', 'model')) | 第 0 维沿两个轴合起来切 8 份 | (1, 4) |
用 jax.typeof(x) 可以看到分片类型,比如 float32[8@data,4@model];用 jax.debug.visualize_array_sharding 可以把切分方式画出来。
mesh2d = jax.make_mesh((4, 2), ("data", "model"))
print(mesh2d)
A = jnp.arange(32.).reshape(8, 4)
cases = [(P(), (8, 4)), (P("data"), (2, 4)), (P(None, "model"), (8, 2)), (P("data", "model"), (2, 2)),
(P("model", "data"), (4, 1)), (P(("data", "model")), (1, 4))]
for spec, local in cases:
a = jax.device_put(A, NamedSharding(mesh2d, spec))
print(f"{str(spec):26s} 局部块 {a.addressable_shards[0].data.shape} 类型 {jax.typeof(a)}")
assert a.addressable_shards[0].data.shape == local
assert np.array_equal(np.asarray(a), np.asarray(A)) # 分片只改变存放位置,不改变数值
print("P('data', 'model') 的切分方式:")
jax.debug.visualize_array_sharding(jax.device_put(A, NamedSharding(mesh2d, P("data", "model"))))
🔍 补充:AxisType —— Explicit / Auto / Manual¶
🟢 必读
jax.make_mesh 在 0.12 里默认创建 AxisType.Explicit 轴(入门课 05 提到过)。三种轴类型的区别:
| 轴类型 | 分片是否写进类型 | 分片由谁决定 | 出现在哪里 |
|---|---|---|---|
Explicit | 是,jax.typeof 显示 8@data | 按类型规则逐个运算传播;有歧义就报错,要你指定 | make_mesh 默认 |
Auto | 否 | 编译器(GSPMD / Shardy)自动传播,可以用 with_sharding_constraint 提示 | axis_types=(AxisType.Auto, ...) |
Manual | — | 你自己写集合通信 | shard_map 函数体内部 |
Explicit 模式的好处是分片在追踪时就定下来了,所见即所得。代价是遇到歧义时必须说清楚。最典型的例子是收缩维两边都被切分的矩阵乘:输出该 all-reduce 成复制的,还是 reduce-scatter 成分片的?JAX 会报 ShardingTypeError,要你用 out_sharding= 说明。
xs2 = jax.device_put(jnp.ones((8, 4)), NamedSharding(mesh2d, P("data", "model")))
ws2 = jax.device_put(jnp.ones((4, 6)), NamedSharding(mesh2d, P("model", None)))
print("网格轴类型:", mesh2d.axis_types)
assert mesh2d.axis_types == (AxisType.Explicit, AxisType.Explicit)
@jax.jit
def trace_types(x):
print(" 追踪时 x :", jax.typeof(x))
print(" 追踪时 x.T:", jax.typeof(x.T)) # 转置后分片也跟着交换
print(" 追踪时 sum(0):", jax.typeof(x.sum(0))) # 被归约掉的 data 维消失
return x.T
trace_types(xs2)
# 收缩维 (4) 两边都沿 model 切分 → 输出分片有歧义 → ShardingTypeError
try:
xs2 @ ws2
raised = False
except Exception as e:
raised = type(e).__name__ == "ShardingTypeError"
print("歧义矩阵乘:", type(e).__name__, "-", str(e)[:95], "...")
assert raised
# 用 out_sharding 明确指定:结果按行沿 data 切分(编译器插入沿 model 的 all-reduce)
r_mm = jnp.matmul(xs2, ws2, out_sharding=NamedSharding(mesh2d, P("data", None)))
print("指定 out_sharding 后:", jax.typeof(r_mm))
assert str(jax.typeof(r_mm)) == "float32[8@data,6]" and np.allclose(np.asarray(r_mm), 4.0)
# Auto 网格:类型中不显示分片,由编译器决定,矩阵乘不会报错
mesh2d_auto = jax.make_mesh((4, 2), ("data", "model"), axis_types=(AxisType.Auto,) * 2)
xa2 = jax.device_put(jnp.ones((8, 4)), NamedSharding(mesh2d_auto, P("data", "model")))
wa2 = jax.device_put(jnp.ones((4, 6)), NamedSharding(mesh2d_auto, P("model", None)))
ra = jax.jit(jnp.matmul)(xa2, wa2)
print("Auto 网格: 类型", jax.typeof(ra), "| 编译器选择的分片", ra.sharding.spec)
assert str(jax.typeof(ra)) == "float32[8,6]" and np.allclose(np.asarray(ra), 4.0)
# Manual:shard_map 函数体内部,网格轴变成 Manual
seen = {}
def peek(x):
seen["types"] = jax.sharding.get_abstract_mesh().axis_types
return x
jax.jit(jax.shard_map(peek, mesh=mesh2d, in_specs=P("data", "model"), out_specs=P("data", "model")))(xs2)
print("shard_map 内部的轴类型:", seen["types"])
assert seen["types"] == (AxisType.Manual, AxisType.Manual)
🔍 补充:with_sharding_constraint 与 reshard¶
⚪ 选读:需要在 jit 内部固定或改变中间值的分片时
jax.lax.with_sharding_constraint(x, sharding) 用来约束 jit 内部中间值的分片。在两种轴类型下,它的行为不一样:
- Auto 轴:它是给编译器的提示。编译器会在这里把数据重新分布成指定的分片,需要时插入通信。常用来固定激活的分片,免得编译器选了低效的方案;
- Explicit 轴:它变成一个断言。当前分片和给定的不一致,就报
AssertionError,并提示你改用jax.sharding.reshard。在 Explicit 模式下,要改变分片,正确的工具是reshard。
# Auto 网格:with_sharding_constraint 是提示,中间值被重排成 P(None, 'model')
@jax.jit
def f_hint(x):
y = x * 2
y = lax.with_sharding_constraint(y, NamedSharding(mesh2d_auto, P(None, "model")))
return y + 1
xa = jax.device_put(jnp.arange(32.).reshape(8, 4), NamedSharding(mesh2d_auto, P("data", "model")))
r_hint = f_hint(xa)
print("Auto: 输入", xa.sharding.spec, "→ 输出", r_hint.sharding.spec)
assert r_hint.sharding.spec == P(None, "model")
assert np.allclose(np.asarray(r_hint), np.arange(32.).reshape(8, 4) * 2 + 1)
# Explicit 网格:分片不一致时 with_sharding_constraint 当作断言失败
xe = jax.device_put(jnp.arange(32.).reshape(8, 4), NamedSharding(mesh2d, P("data", "model")))
try:
with jax.set_mesh(mesh2d):
jax.jit(lambda x: lax.with_sharding_constraint(x * 2, P(None, "model")))(xe)
raised = False
except AssertionError as e:
raised = True
print("Explicit: with_sharding_constraint 报 AssertionError:", str(e)[:100], "...")
assert raised
# Explicit 网格:用 reshard 改变分片,类型随之改变
with jax.set_mesh(mesh2d):
r_re = jax.jit(lambda x: jax.sharding.reshard(x * 2, P(None, "model")))(xe)
print("Explicit: reshard 后类型", jax.typeof(r_re))
assert str(jax.typeof(r_re)) == "float32[8,4@model]"
assert np.allclose(np.asarray(r_re), np.arange(32.).reshape(8, 4) * 2)
🔍 补充:数据 × 模型二维并行(Megatron 式 MLP 块)¶
⚪ 选读:做张量并行时;完整训练见进阶 06 §6.8
张量并行最经典的切法来自 Megatron-LM。两层 MLP $y = \mathrm{relu}(xW_1)W_2$ 里,$W_1$ 按列切(P(None, 'model')),$W_2$ 按行切(P('model', None))。这样:
- 第一层,每个设备算出隐藏层的一部分列 $h_{[:, k]} = \mathrm{relu}(xW_{1[:, k]})$。relu 是逐元素的,所以不用通信;
- 第二层,每个设备得到一个部分和 $h_{[:, k]}W_{2[k, :]}$,只需要沿
'model'做一次psum。
再加上 batch 沿 'data' 切分,就是数据 × 模型二维并行。整个块的前向只要一次 all-reduce。接上优化器的完整二维并行训练,见 进阶 06 §6.8。
k1, k2, k3 = jax.random.split(jax.random.key(42), 3)
Xm = jax.random.normal(k1, (16, 8))
W1 = jax.random.normal(k2, (8, 32)) / np.sqrt(8)
W2 = jax.random.normal(k3, (32, 8)) / np.sqrt(32)
put2 = lambda a, spec: jax.device_put(a, NamedSharding(mesh2d, spec))
def megatron_block(x, w1, w2): # x:(4,8) w1:(8,16) w2:(16,8) —— 每个设备的局部块
h = jax.nn.relu(x @ w1) # 隐藏层的一部分列,不需要通信
return lax.psum(h @ w2, "model") # 部分和 → 沿 model 轴求和
f_meg = jax.jit(jax.shard_map(megatron_block, mesh=mesh2d,
in_specs=(P("data", None), P(None, "model"), P("model", None)),
out_specs=P("data", None)))
args_meg = (put2(Xm, P("data", None)), put2(W1, P(None, "model")), put2(W2, P("model", None)))
y_meg = f_meg(*args_meg)
ref_meg = np.maximum(f64(Xm) @ f64(W1), 0) @ f64(W2)
assert np.allclose(f64(y_meg), ref_meg, atol=1e-5)
print("shard_map 版: 输出", jax.typeof(y_meg), "| 通信", collectives(f_meg.lower(*args_meg).compile()))
# 同一个块的 Explicit jit 写法:中间激活的类型是 [16@data, 32@model],第二个矩阵乘用 out_sharding 指定输出
@jax.jit
def megatron_explicit(x, w1, w2):
h = jax.nn.relu(x @ w1)
print(" 中间激活 h 的类型:", jax.typeof(h))
return jnp.matmul(h, w2, out_sharding=NamedSharding(mesh2d, P("data", None)))
y_meg2 = megatron_explicit(*args_meg)
assert np.allclose(f64(y_meg2), ref_meg, atol=1e-5)
assert collectives(megatron_explicit.lower(*args_meg).compile()) == ["all-reduce"]
print("Explicit jit 版与 shard_map 版一致,前向只有一次 all-reduce ✓")
🔍 补充:ZeRO-3 / FSDP —— reduce-scatter + all-gather¶
⚪ 选读:参数大到单设备放不下、需要 FSDP 时
FSDP(Fully Sharded Data Parallel,等价于 ZeRO-3)的做法是:参数、梯度和优化器状态全都按设备切分,每个设备只长期保存 $1/N$。一步训练的流程是:
- 前向之前 all-gather:临时收集完整参数 $\theta = \mathrm{all\_gather}(\theta^{(i)})$;
- 用完整参数和本地 batch 算出本地梯度 $g_i = \nabla L_{B_i}(\theta)$;
- reduce-scatter 梯度:$g^{(i)} = \frac{1}{N}\sum_j g_j^{(i)}$,每个设备只拿到自己那一片的平均梯度;
- 用 $g^{(i)}$ 更新本地的参数分片 $\theta^{(i)}$。
JAX 里有一个很漂亮的性质:all_gather 的转置就是 psum_scatter。所以只要把「all-gather 参数」写进损失函数,再对参数分片求导,自动微分就会自己生成第 3 步的 reduce-scatter,不用手写。下面把手写版和自动版都实现出来,和单设备梯度对拍,再用 memory_analysis 比较每个设备的参数内存。
注意 入门课 05「shard_map 中梯度变成 N 倍」专题讲过的坑:在 shard_map 里对「本地平均损失」求导、再经过
psum_scatter(求和)以后,必须再除以 $N$ 才是全局平均梯度。下面两个版本都有/ N。
def init_mlp(key, sizes):
ks = jax.random.split(key, len(sizes) - 1)
return [{"w": jax.random.normal(k, (a, b)) / np.sqrt(a), "b": jnp.zeros(b)}
for k, a, b in zip(ks, sizes[:-1], sizes[1:])]
def mlp(p, x):
for layer in p[:-1]:
x = jnp.tanh(x @ layer["w"] + layer["b"])
return x @ p[-1]["w"] + p[-1]["b"]
def mlp_loss(p, x, y):
return jnp.mean((mlp(p, x) - y) ** 2)
# 所有参数的第 0 维都能被 8 整除,便于沿 data 切分
mlp_params = init_mlp(jax.random.key(0), [16, 64, 64, 8])
Xf = jax.random.normal(jax.random.key(1), (256, 16))
Yf = jnp.sin(Xf @ jnp.ones((16, 8)) / 4)
g_single = jax.grad(mlp_loss)(mlp_params, Xf, Yf) # 单设备参考梯度
dmesh = jax.make_mesh((N,), ("data",))
pspec = jax.tree.map(lambda a: P("data"), mlp_params)
p_fsdp = jax.device_put(mlp_params, jax.tree.map(lambda s: NamedSharding(dmesh, s), pspec))
Xd = jax.device_put(Xf, NamedSharding(dmesh, P("data")))
Yd = jax.device_put(Yf, NamedSharding(dmesh, P("data")))
print("FSDP 参数分片类型:", jax.tree.map(lambda a: str(jax.typeof(a)), p_fsdp[0]))
def gather_params(p_shard):
return jax.tree.map(lambda a: lax.all_gather(a, "data", tiled=True), p_shard)
def fsdp_grad_manual(p_shard, x, y):
g_full = jax.grad(mlp_loss)(gather_params(p_shard), x, y) # 本地梯度(完整形状)
return jax.tree.map(lambda g: lax.psum_scatter(g, "data", scatter_dimension=0, tiled=True) / N, g_full)
def fsdp_grad_auto(p_shard, x, y):
# 对参数分片求导:all_gather 的转置自动变成 psum_scatter
g = jax.grad(lambda q: mlp_loss(gather_params(q), x, y))(p_shard)
return jax.tree.map(lambda a: a / N, g)
results = {}
for fn in [fsdp_grad_manual, fsdp_grad_auto]:
sm = jax.jit(jax.shard_map(fn, mesh=dmesh, in_specs=(pspec, P("data"), P("data")), out_specs=pspec))
g = sm(p_fsdp, Xd, Yd)
ok = jax.tree.all(jax.tree.map(lambda a, b: bool(np.allclose(np.asarray(a), np.asarray(b), atol=1e-5)), g, g_single))
comp = sm.lower(p_fsdp, Xd, Yd).compile()
results[fn.__name__] = (comp.memory_analysis().argument_size_in_bytes, collectives(comp))
print(f"{fn.__name__:17s} 与单设备梯度一致: {ok} | 通信: {collectives(comp)}")
assert ok and collectives(comp) == ["all-gather", "reduce-scatter"]
# 对照:普通数据并行(参数完全复制)
p_rep = jax.device_put(mlp_params, NamedSharding(dmesh, P()))
dp_comp = jax.jit(jax.grad(mlp_loss)).lower(p_rep, Xd, Yd).compile()
param_bytes = sum(a.size * a.dtype.itemsize for a in jax.tree.leaves(mlp_params))
data_bytes_local = (Xf.size + Yf.size) * 4 // N
print(f"\n参数总量 {param_bytes} 字节;每设备的本地数据 {data_bytes_local} 字节")
print(f"DP 每设备输入内存: {dp_comp.memory_analysis().argument_size_in_bytes:6d} 字节 | 通信: {collectives(dp_comp)}")
print(f"FSDP 每设备输入内存: {results['fsdp_grad_auto'][0]:6d} 字节 | 通信: {results['fsdp_grad_auto'][1]}")
assert dp_comp.memory_analysis().argument_size_in_bytes == param_bytes + data_bytes_local
assert results["fsdp_grad_auto"][0] == param_bytes // N + data_bytes_local # 参数内存降到 1/N
3.8 多主机编程简介¶
pmap 常用在一台主机的多个加速器上,做数据并行就能明显加速。可训练几十亿乃至几万亿参数的基础模型,需要的算力超出了单台主机,这就到了多主机编程:通过网络协调多台独立的机器。
扩展到多个节点时面临的问题¶
- 网络通信:主机内部走 PCIe / NVLink,主机之间靠以太网、InfiniBand 或专用的 TPU 互联。主机之间的延迟和带宽通常比主机内部差几个数量级,跨主机通信很容易成为瓶颈,所以需要高效的集合通信算法;
- 同步与协调:每台机器上跑的是独立的 Python 进程。它们要启动、互相发现,还要在集合操作等时刻同步;
- 基础设施与作业启动:要在所有主机上启动同一个脚本、配置网络、处理故障,常用 SLURM 或云厂商的工具(比如管理 TPU Pod 切片的工具)。JAX 本身不提供这些基础设施,只提供运行时的编程原语。
多主机环境中的 JAX 原语¶
- 全局设备视图:在配置正确的多主机环境里,
jax.devices()返回所有主机上的设备; - 跨主机的集合操作:
psum / pmean / all_gather等会透明地处理网络通信,在 TPU Pod 上走芯片间的专用高速互联; - 进程识别:
jax.process_index()是当前进程的编号(0 到process_count-1),jax.process_count()是进程总数(通常每台主机一个进程)。常用来让每台主机从磁盘读不同的数据分片。
可以把多主机编程理解成「SPMD 的扩展」:每台主机跑完全相同的脚本,数据加载按 process_index 区分,集合操作通过轴名处理所有主机设备之间的数据交换。以数据并行为例:每台主机加载全局批次的一部分,在本地设备上算梯度,用 pmean 在全局所有设备间平均梯度,再执行优化器步骤。Flax 等框架在这些原语之上还提供了更方便的 API。
⚠️ 注意:别忘了
jax.distributed.initialize。多主机 JAX 程序必须在执行任何 JAX 计算之前调用它,让各个进程互相发现、交换拓扑信息。在 TPU、SLURM、Open MPI 环境下,参数可以自动检测;其他情况要显式给出coordinator_address(进程 0 的host:port)、num_processes、process_id(必要时还有local_device_ids)。
⚠️ 注意:容易以为 pmap 作用在全局设备列表上,为单主机写的 pmap 代码可以直接拿到多主机上用,数据按全局设备总数切分。实际在 2 进程 × 4 设备下跑一下就知道:每个进程调用 pmap 时,输入主轴的长度是本进程的设备数(4),输出形状也是
(4,)。只有集合操作(psum等)才跨越全部 8 个设备。所以 pmap 的数据要按本地设备数切分。
🔍 补充:多主机的几个核心概念¶
⚪ 选读:要在多台机器上运行时
| 概念 | 说明 |
|---|---|
jax.devices() vs jax.local_devices() | 全局设备 vs 本进程能直接访问的设备;d.process_index 表示设备属于哪个进程 |
| 设备 id | 多进程时 id 不一定连续。在 CPU 上跑出来,进程 1 的设备 id 是 2048–2051 |
| 全局数组 | jax.Array 可以跨进程分片;x.is_fully_addressable 为 False 时,本进程只能访问 x.addressable_shards 里的那部分 |
jax.make_array_from_process_local_data(sharding, local_data) | 每个进程只提供自己那部分数据,拼成一个全局数组(多主机数据加载的标准做法) |
jax.experimental.multihost_utils.process_allgather | 把各进程的数据收集到每个进程的主机内存里(常用于评估指标、日志) |
| CPU 集合通信 | 配置项 jax_cpu_collectives_implementation,可选 'gloo' / 'mpi' / 'megascale'。0.12 的默认值已经是 'gloo',本机演示不用额外设置 |
还有几条实战纪律:所有进程必须按相同的顺序调用同样的 jit 函数和集合操作,不然可能死锁;只在 process_index() == 0 时打日志、写检查点元数据;随机数的 key 在各进程之间要么完全相同(比如参数初始化),要么按 process_index 区分(比如数据增强),详见 进阶 10 §5.6。
先看单进程下的情况:
print("process_index:", jax.process_index(), "| process_count:", jax.process_count())
assert jax.process_index() == 0 and jax.process_count() == 1
assert jax.local_devices() == jax.devices()
# 单进程时 make_array_from_process_local_data 的「本地数据」就是全部数据
mesh_mh = jax.make_mesh((N,), ("data",))
local_np = np.arange(16.).reshape(8, 2)
garr = jax.make_array_from_process_local_data(NamedSharding(mesh_mh, P("data")), local_np)
print("全局数组类型:", jax.typeof(garr), "| fully addressable:", garr.is_fully_addressable)
assert garr.is_fully_addressable and np.array_equal(np.asarray(garr), local_np)
assert jax.config.jax_cpu_collectives_implementation == "gloo" # 0.12 默认值
🔍 补充:在本机真正运行 2 进程的「多主机」演示¶
⚪ 选读:想亲手验证多进程行为时(只看输出也可以)
下面用 subprocess 启动 2 个进程,coordinator 设为 localhost,每个进程模拟 4 个 CPU 设备,合起来是 8 个全局设备。每个进程跑同一份 worker 脚本(SPMD),依次做三件事:
- 调用
jax.distributed.initialize(...),然后看看process_index / process_count / devices / local_devices; - 每个进程只提供自己的 4 个数(进程 0 是 1–4,进程 1 是 5–8),用
make_array_from_process_local_data拼成全局数组,再分别用shard_map + psum、jit + jnp.sum、旧接口pmap + psum做跨进程求和,结果应该都是 $1+\dots+8=36$; - 跨进程数据并行训练:每个进程只加载属于自己的那一半数据(按
process_index切片),训练 30 步线性回归。最后在 notebook 里和 NumPy float64 的单进程梯度下降对拍。
WORKER = """
import os, sys, json
pid, nproc, port = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=4" # 每个进程模拟 4 个 CPU 设备
import jax, jax.numpy as jnp, numpy as np
from jax import lax
from jax.sharding import NamedSharding, PartitionSpec as P
# 必须在任何 JAX 计算之前调用
jax.distributed.initialize(coordinator_address=f"localhost:{port}", num_processes=nproc, process_id=pid)
out = {"process_index": jax.process_index(), "process_count": jax.process_count(),
"device_count": jax.device_count(), "local_device_count": jax.local_device_count(),
"global_ids": [d.id for d in jax.devices()], "local_ids": [d.id for d in jax.local_devices()],
"cpu_collectives": jax.config.jax_cpu_collectives_implementation}
mesh = jax.make_mesh((jax.device_count(),), ("data",))
# 1) 跨进程求和:每个进程只提供自己的 4 个数
local = np.arange(4, dtype=np.float32) + 4 * pid + 1 # 进程 0: 1..4,进程 1: 5..8
g = jax.make_array_from_process_local_data(NamedSharding(mesh, P("data")), local)
out["global_shape"] = list(g.shape)
out["fully_addressable"] = g.is_fully_addressable
out["addressable_ids"] = [s.device.id for s in g.addressable_shards]
psum_fn = jax.jit(jax.shard_map(lambda x: lax.psum(x, "data"), mesh=mesh, in_specs=P("data"), out_specs=P()))
out["shard_map_psum"] = float(np.asarray(psum_fn(g).addressable_shards[0].data)[0])
out["jit_sum"] = float(jax.jit(jnp.sum)(g))
pm = jax.pmap(lambda x: lax.psum(x, "i"), axis_name="i")(jnp.asarray(local)) # 旧接口:主轴 = 本地设备数
out["pmap_out_shape"] = list(pm.shape)
out["pmap_psum"] = np.asarray(pm).tolist()
# 2) 跨进程数据并行训练:每个进程只加载自己的那一半数据
rng = np.random.default_rng(0)
X_all = rng.normal(size=(64, 5)).astype(np.float32)
y_all = (X_all @ np.arange(1, 6, dtype=np.float32) + 0.5).astype(np.float32)
per = len(X_all) // jax.process_count()
sl = slice(jax.process_index() * per, (jax.process_index() + 1) * per)
dsh = NamedSharding(mesh, P("data"))
Xg = jax.make_array_from_process_local_data(dsh, X_all[sl])
yg = jax.make_array_from_process_local_data(dsh, y_all[sl])
params = jax.device_put((jnp.zeros(5), jnp.zeros(())), NamedSharding(mesh, P()))
@jax.jit
def step(params, X, y):
def loss(p):
w, b = p
return jnp.mean((X @ w + b - y) ** 2)
l, grads = jax.value_and_grad(loss)(params)
return jax.tree.map(lambda p, g: p - 0.1 * g, params, grads), l
for _ in range(30):
params, l = step(params, Xg, yg)
out["final_w"] = np.asarray(params[0]).tolist()
out["final_b"] = float(params[1])
out["final_loss"] = float(l)
print("RESULT " + json.dumps(out), flush=True)
jax.distributed.shutdown()
"""
workdir = tempfile.mkdtemp(prefix="jax_multihost_")
worker_path = os.path.join(workdir, "worker.py")
with open(worker_path, "w") as fh:
fh.write(WORKER)
with socket.socket() as s_: # 找一个空闲端口给 coordinator
s_.bind(("localhost", 0))
port = s_.getsockname()[1]
procs = [subprocess.Popen([sys.executable, worker_path, str(i), "2", str(port)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=workdir)
for i in range(2)]
mh = {}
for i, p_ in enumerate(procs):
stdout, stderr = p_.communicate(timeout=300)
assert p_.returncode == 0, stderr[-2000:]
line = [l for l in stdout.splitlines() if l.startswith("RESULT ")][0]
mh[i] = json.loads(line[len("RESULT "):])
for i, r_ in mh.items():
print(f"进程 {i}: process_index={r_['process_index']}/{r_['process_count']}, 全局设备 {r_['device_count']} 个, "
f"本地设备 id {r_['local_ids']}, CPU 集合通信 = {r_['cpu_collectives']}")
print(f" 全局数组形状 {r_['global_shape']}, fully_addressable={r_['fully_addressable']}, "
f"本进程可访问的分片在设备 {r_['addressable_ids']}")
print(f" shard_map psum = {r_['shard_map_psum']}, jit sum = {r_['jit_sum']}, "
f"pmap 输出形状 {r_['pmap_out_shape']} 值 {r_['pmap_psum']}")
# 对拍:跨进程求和 == 36;pmap 主轴 == 本地设备数;两个进程训练出的参数相同,且等于单进程 NumPy float64 梯度下降
for i, r_ in mh.items():
assert r_["process_index"] == i and r_["process_count"] == 2
assert r_["device_count"] == 8 and r_["local_device_count"] == 4
assert len(r_["global_ids"]) == 8 and set(r_["local_ids"]) <= set(r_["global_ids"])
assert r_["global_shape"] == [8] and r_["fully_addressable"] is False
assert r_["shard_map_psum"] == 36.0 and r_["jit_sum"] == 36.0
assert r_["pmap_out_shape"] == [4] and r_["pmap_psum"] == [36.0] * 4 # 每个进程只喂 4 个,psum 跨 8 个设备
assert mh[0]["local_ids"] != mh[1]["local_ids"]
assert mh[0]["final_w"] == mh[1]["final_w"] and mh[0]["final_b"] == mh[1]["final_b"]
rng = np.random.default_rng(0)
X_all = rng.normal(size=(64, 5)).astype(np.float32).astype(np.float64)
y_all = X_all @ np.arange(1, 6) + 0.5
w_np, b_np = np.zeros(5), 0.0
for _ in range(30):
err_ = X_all @ w_np + b_np - y_all
w_np, b_np = w_np - 0.1 * 2 * X_all.T @ err_ / 64, b_np - 0.1 * 2 * err_.mean()
print("2 进程训练的 w:", np.round(mh[0]["final_w"], 4))
print("单进程 NumPy w:", np.round(w_np, 4))
assert np.allclose(mh[0]["final_w"], w_np, atol=1e-4) and abs(mh[0]["final_b"] - b_np) < 1e-4
print("✓ 2 进程多主机演示:跨进程 psum、数据并行训练均与单进程参考一致")
3.9 实践:分布式数据并行训练¶
这一节用 pmap 搭一个完整的数据并行训练循环:每个设备处理一部分数据,在本地算梯度,再对梯度取平均,同步更新参数。模型是线性回归 $\hat y = Xw + b$,损失是均方误差
$$ L(w, b) = \frac{1}{|B|}\sum_{(x, y)\in B}\big(x^\top w + b - y\big)^2 . $$
设置¶
旧资料里有时会用 jax.config.update("jax_cpu_device_count", 4) 来模拟多设备,而且写在 jax.local_device_count() 调用之后。这样写有两个问题,见下面的注意。
⚠️ 注意:0.12 里没有
jax_cpu_device_count这个配置项,会报AttributeError: Unrecognized config option。正确做法是在导入 jax 之前设置XLA_FLAGS=--xla_force_host_platform_device_count=N(本 notebook 第一个单元就是这么做的),或者在import jax之后、任何运算之前调用jax.config.update('jax_num_cpu_devices', N)(3.2 节演示过)。两种方式都必须在后端初始化之前完成,所以放在local_device_count()之后也不会生效。
# ❌ 旧写法(过时/错误):jax_cpu_device_count 不是有效的配置项
try:
jax.config.update("jax_cpu_device_count", 4)
raised = False
except AttributeError as e:
raised = True
print("报错:", type(e).__name__, "-", e)
assert raised
# ✅ 推荐写法:本 notebook 第一个单元已用 XLA_FLAGS 模拟 8 个设备;也可用 jax_num_cpu_devices(须在后端初始化前)
num_devices = jax.local_device_count()
print("Number of available devices:", num_devices)
assert num_devices == 8
模型、损失与数据¶
数据:特征维度 5,样本数 100 * num_devices(保证能被设备数整除)。真实参数随机生成,标签加上 0.1 倍的高斯噪声,然后 reshape 成 (设备数, 每设备样本数, 特征数)。
def linear_model(params, x):
w, b = params
return jnp.dot(x, w) + b
def mean_squared_error(params, x_batched, y_batched):
predictions = linear_model(params, x_batched)
return jnp.mean((predictions - y_batched) ** 2)
feature_dim = 5
num_samples = 100 * num_devices
key = jax.random.key(0)
key, w_key, b_key, x_key, noise_key = jax.random.split(key, 5)
true_w = jax.random.normal(w_key, (feature_dim,))
true_b = jax.random.normal(b_key, ())
X = jax.random.normal(x_key, (num_samples, feature_dim))
noise = jax.random.normal(noise_key, (num_samples,)) * 0.1
y = jnp.dot(X, true_w) + true_b + noise
batch_size_per_device = num_samples // num_devices
sharded_X = X.reshape((num_devices, batch_size_per_device, feature_dim))
sharded_y = y.reshape((num_devices, batch_size_per_device))
print("Total samples:", num_samples, "| Samples per device:", batch_size_per_device)
print("Shape of sharded X:", sharded_X.shape, "| Shape of sharded y:", sharded_y.shape)
分布式训练步¶
训练步分四步:(1) 本地 value_and_grad;(2) 用 pmean 平均梯度;(3) 用平均梯度做普通的梯度下降;(4) 用 pmean 平均损失,方便打印。pmap 的参数设成 in_axes=(0, 0, 0, None),再用 static_broadcasted_argnums=(3,) 把学习率当作静态参数。
⚠️ 注意:
pmap没有jit参数,传jit=True会报TypeError。pmap 本身就会编译函数,不需要、也没法再「打开 jit」。
⚠️ 注意:
in_axes=(0, 0, 0, None)的意思是参数按第 0 轴映射,而不是复制(None)。这么写是对的:传进去的replicated_params已经带了设备轴,形状是(num_devices, ...),每个设备取自己那一份。
⚠️ 注意:另一种复制参数的写法
pmap(broadcast, in_axes=None, out_axes=0)(params)行不通。所有输入都是in_axes=None时,pmap 确定不了映射轴有多长,会报ValueError: pmap requires at least one argument with a mapped axis(加axis_size也不行)。用jax.tree_map的写法也不行,因为它已经删除了。推荐写成jax.tree.map(lambda x: jnp.broadcast_to(x, (n,) + x.shape), params)。
compute_loss_and_grads = jax.value_and_grad(mean_squared_error)
def distributed_train_step(params, x_shard, y_shard, learning_rate):
loss, grads = compute_loss_and_grads(params, x_shard, y_shard) # 1. 本地梯度
avg_grads = lax.pmean(grads, axis_name="batch") # 2. 跨设备平均
new_params = jax.tree.map(lambda p, g: p - learning_rate * g, params, avg_grads) # 3. 梯度下降
avg_loss = lax.pmean(loss, axis_name="batch") # 4. 平均损失
return new_params, avg_loss
key, w_init_key, b_init_key = jax.random.split(key, 3)
initial_w = jax.random.normal(w_init_key, (feature_dim,))
initial_b = jax.random.normal(b_init_key, ())
params = (initial_w, initial_b)
# ❌ 旧写法(错误):pmap 没有 jit 参数;全部 in_axes=None 的「广播」pmap 无法确定设备数
errs = {}
try:
jax.pmap(distributed_train_step, axis_name="batch", jit=True)
except TypeError as e:
errs["pmap(jit=True)"] = f"{type(e).__name__}: {e}"
def broadcast(x):
return x
try:
jax.pmap(broadcast, in_axes=None, out_axes=0)(params)
except ValueError as e:
errs["pmap(in_axes=None)"] = f"{type(e).__name__}: {e}"
try:
jax.pmap(broadcast, in_axes=None, out_axes=0, axis_size=num_devices)(params)
except ValueError as e:
errs["pmap(in_axes=None, axis_size=8)"] = f"{type(e).__name__}: {e}"
for k, v in errs.items():
print(f"{k:32s} → {v[:100]}")
assert len(errs) == 3
# ✅ 推荐写法(pmap 版):用 broadcast_to 给参数加设备轴,in_axes=(0, 0, 0, None)
p_train_step = jax.pmap(distributed_train_step, axis_name="batch",
in_axes=(0, 0, 0, None), out_axes=0, static_broadcasted_argnums=(3,))
replicated_params = jax.tree.map(lambda x: jnp.broadcast_to(x, (num_devices,) + x.shape), params)
print("Shape of initial w:", initial_w.shape, "→ replicated:", replicated_params[0].shape)
print("Shape of initial b:", initial_b.shape, "→ replicated:", replicated_params[1].shape)
assert replicated_params[0].shape == (num_devices, feature_dim) and replicated_params[1].shape == (num_devices,)
learning_rate = 0.05
num_epochs = 50 # 每个 epoch 用一次全部数据,即全批次梯度下降,每个 epoch 只更新一次
print("\n开始分布式训练...")
current_params = replicated_params
for epoch in range(num_epochs):
current_params, loss = p_train_step(current_params, sharded_X, sharded_y, learning_rate)
if (epoch + 1) % 10 == 0:
print(f"Epoch {epoch + 1}/{num_epochs}, Loss: {float(loss[0]):.4f}")
final_params_pmap = jax.tree.map(lambda x: x[0], current_params)
print("\n训练完成。")
print("Learned w:", final_params_pmap[0], "\nTrue w: ", true_w)
print("Learned b:", final_params_pmap[1], "| True b:", true_b)
# 检查一下:设备 0 和设备 1 上的 w 相同
assert jnp.allclose(current_params[0][0], current_params[0][1])
# 与单设备训练对拍,并与 NumPy float64 梯度下降对拍
@jax.jit
def single_step(params, X, y, lr):
loss, grads = compute_loss_and_grads(params, X, y)
return jax.tree.map(lambda p, g: p - lr * g, params, grads), loss
p_single = params
for _ in range(num_epochs):
p_single, _ = single_step(p_single, X, y, learning_rate)
Xn, yn = f64(X), f64(y)
w_np, b_np = f64(initial_w), float(initial_b)
for _ in range(num_epochs):
e_ = Xn @ w_np + b_np - yn
w_np, b_np = w_np - learning_rate * 2 * Xn.T @ e_ / len(Xn), b_np - learning_rate * 2 * e_.mean()
assert np.allclose(final_params_pmap[0], p_single[0], atol=1e-5) and np.allclose(final_params_pmap[1], p_single[1], atol=1e-5)
assert np.allclose(f64(final_params_pmap[0]), w_np, atol=1e-4) and abs(float(final_params_pmap[1]) - b_np) < 1e-4
assert np.allclose(final_params_pmap[0], true_w, atol=0.05) # 50 步后已接近真实参数
print("pmap 版 == 单设备版 == NumPy float64 ✓")
# ✅ 现代写法:jit + NamedSharding。参数 device_put 成复制的 P(),数据沿 data 切分,代码与单设备完全相同
mesh_dp = jax.make_mesh((num_devices,), ("data",))
params_m = jax.device_put(params, NamedSharding(mesh_dp, P()))
X_m = jax.device_put(X, NamedSharding(mesh_dp, P("data")))
y_m = jax.device_put(y, NamedSharding(mesh_dp, P("data")))
for epoch in range(num_epochs):
params_m, loss_m = single_step(params_m, X_m, y_m, learning_rate) # 复用单设备的 single_step
print("参数类型:", jax.typeof(params_m[0]), "| 最终损失:", float(loss_m))
print("通信:", collectives(single_step.lower(params_m, X_m, y_m, learning_rate).compile()))
assert np.allclose(np.asarray(params_m[0]), final_params_pmap[0], atol=1e-5)
assert np.allclose(float(params_m[1]), float(final_params_pmap[1]), atol=1e-5)
print("现代写法 == pmap 版 ✓")
课程最后总结了六点:数据分片、SPMD 函数、用 pmean 做集合通信、同步更新、pmap 与 in_axes 的设置、显式复制参数。还建议把模型换成神经网络、加上 Adam 等优化器。下面就做这个扩展,顺便从编译产物里读出梯度同步的通信量。
🔍 补充:MLP + Adam 的数据并行,以及梯度同步的通信量¶
⚪ 选读:想从编译产物估算通信量时
用 optax 时,优化器状态(Adam 的一阶矩、二阶矩)的分片会跟着参数走:参数是复制的,状态也就是复制的。训练步的代码和单设备完全一样,只是输入带了分片。
通信量怎么算?本例编译后的 HLO 里(CPU 后端),编译器把所有梯度和损失合并成了一个 all-reduce。这是 XLA 的 all-reduce 合并优化,换个后端或者模型更大时,可能会按大小阈值拆成几个。它的字节数就是参数总字节数再加一个标量损失。对环形 all-reduce 来说,每个设备每一步的收发量是 $2\frac{N-1}{N}S$。
opt = optax.adam(1e-2)
mlp_p0 = init_mlp(jax.random.key(7), [16, 64, 64, 8])
Xt = jax.random.normal(jax.random.key(8), (512, 16))
Yt = jnp.sin(Xt @ jnp.ones((16, 8)) / 4)
@jax.jit
def adam_step(params, opt_state, x, y):
loss, grads = jax.value_and_grad(mlp_loss)(params, x, y)
updates, opt_state = opt.update(grads, opt_state, params)
return optax.apply_updates(params, updates), opt_state, loss
def train(params, x, y, steps=100):
state = opt.init(params)
for _ in range(steps):
params, state, loss = adam_step(params, state, x, y)
return params, state, loss
# 单设备参考
p_ref, _, loss_ref = train(mlp_p0, Xt, Yt)
# 数据并行:参数复制,数据切分
p_dp, s_dp, loss_dp = train(jax.device_put(mlp_p0, NamedSharding(dmesh, P())),
jax.device_put(Xt, NamedSharding(dmesh, P("data"))),
jax.device_put(Yt, NamedSharding(dmesh, P("data"))))
print(f"100 步后损失:单设备 {float(loss_ref):.6f} | 数据并行 {float(loss_dp):.6f}")
print("Adam 一阶矩的类型:", jax.typeof(s_dp[0].mu[0]["w"]))
max_diff = max(float(jnp.max(jnp.abs(np.asarray(a) - np.asarray(b)))) for a, b in zip(jax.tree.leaves(p_dp), jax.tree.leaves(p_ref)))
print("参数最大差异:", max_diff)
assert max_diff < 1e-4 and abs(float(loss_dp) - float(loss_ref)) < 1e-5
# 从编译后的 HLO 中读出 all-reduce 的字节数
comp = adam_step.lower(jax.device_put(mlp_p0, NamedSharding(dmesh, P())),
s_dp, jax.device_put(Xt, NamedSharding(dmesh, P("data"))),
jax.device_put(Yt, NamedSharding(dmesh, P("data")))).compile()
DTYPE_BYTES = {"f32": 4, "bf16": 2, "f16": 2, "s32": 4, "f64": 8}
ar_bytes, n_ar = 0, 0
for line in comp.as_text().splitlines():
m_ = re.search(r"=\s*(\(.*\)|\S+)\s+all-reduce\(", line)
if m_:
n_ar += 1
for dt, dims in re.findall(r"(f32|bf16|f16|s32|f64)\[([\d,]*)\]", m_.group(1)):
ar_bytes += DTYPE_BYTES[dt] * int(np.prod([int(d) for d in dims.split(",") if d] or [1]))
param_bytes = sum(a.size * a.dtype.itemsize for a in jax.tree.leaves(mlp_p0))
ring = 2 * (N - 1) / N * ar_bytes
ma_ = comp.memory_analysis()
print(f"参数总字节数 : {param_bytes}")
print(f"all-reduce 个数 / 总字节数 : {n_ar} / {ar_bytes}(= 全部梯度 + 1 个 f32 损失)")
print(f"环形 all-reduce 每设备收发 : 2·(N-1)/N·S = {ring:.0f} 字节/步")
print(f"memory_analysis(每设备) : 输入 {ma_.argument_size_in_bytes} B,输出 {ma_.output_size_in_bytes} B,临时 {ma_.temp_size_in_bytes} B")
assert n_ar >= 1 and ar_bytes == param_bytes + 4
动手练习¶
下面 3 道题覆盖本章的三个核心:重排型集合操作、FSDP 下的全局量计算、二维分区训练。每道题后面都有参考答案和 assert。
✍️ 练习 1:用 all_to_all 实现分布式矩阵转置¶
X 是 16×16 矩阵,按行切分到 8 个设备上(P('i', None),每个设备持有 2 行)。请在 shard_map 里实现转置,要求输出的 $X^\top$ 同样按行切分。整个过程中不能 all-gather 出完整矩阵。
提示:设备 i 需要的是原矩阵的第 2i:2i+2 列。
自检接口:定义 f_t(x):输入是按 P('i', None) 分到 mesh8 上的 16×16 矩阵,返回同样按行切分的转置(可以 jit 编译)。
# ✍️ 作答区:练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 1
try:
f_t
except NameError as e:
print(f"⏳ 练习 1 尚未作答:{e}")
else:
_A = np.random.default_rng(1).normal(size=(16, 16)).astype(np.float32)
_A_s = jax.device_put(jnp.asarray(_A), NamedSharding(mesh8, P("i", None)))
_AT = f_t(_A_s)
assert np.array_equal(np.asarray(_AT), _A.T), "结果不等于转置"
assert _AT.sharding.is_equivalent_to(NamedSharding(mesh8, P("i", None)), 2), f"输出应按行切分,实际分片是 {_AT.sharding}"
_coll = collectives(jax.jit(f_t).lower(_A_s).compile())
assert "all-gather" not in _coll and "all-reduce" not in _coll, f"不应收集完整矩阵,实际用到的集合通信:{_coll}"
print("✅ 练习 1 自检通过")
✍️ 练习 2:FSDP 下的全局梯度范数裁剪¶
FSDP 里,每个设备只持有梯度的一个分片。全局梯度范数 $\|g\| = \sqrt{\sum_\ell \|g_\ell\|^2}$ 要所有分片一起参与计算。请在 shard_map 里实现 clip_by_global_norm:输入是 3.7 节 FSDP 得到的分片梯度(pspec 分片),返回裁剪后的分片梯度和全局范数,并和 optax.clip_by_global_norm 在完整梯度上的结果对拍。
易错点:只对本地分片求范数,或者在每个分片上各自裁剪,都是错的。
自检接口:定义 f_clip(g, max_norm):g 是按 pspec 分到 dmesh 上、和 mlp_params 结构相同的梯度,max_norm 是标量;返回 (裁剪后的分片梯度, 全局范数),裁剪后的梯度仍按 pspec 分片。
# ✍️ 作答区:练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 2
try:
f_clip
except NameError as e:
print(f"⏳ 练习 2 尚未作答:{e}")
else:
_rng = np.random.default_rng(3)
_g = jax.tree.map(lambda a: _rng.normal(size=a.shape).astype(np.float32), mlp_params) # 一份新的梯度
_g_s = jax.device_put(_g, jax.tree.map(lambda s: NamedSharding(dmesh, s), pspec))
_norm = float(np.sqrt(sum(np.sum(np.asarray(a, np.float64) ** 2) for a in jax.tree.leaves(_g))))
for _ratio in [0.3, 2.0]: # 一次需要裁剪,一次不需要
_mx = _ratio * _norm
_gc, _n = f_clip(_g_s, jnp.float32(_mx))
assert abs(float(_n) - _norm) < 1e-4 * _norm, f"全局范数应为 {_norm:.5f},实际 {float(_n):.5f}:是否只算了本地分片?"
_scale = min(1.0, _mx / _norm)
for _a, _b in zip(jax.tree.leaves(_gc), jax.tree.leaves(_g)):
assert np.allclose(np.asarray(_a), _b * _scale, atol=1e-5), f"max_norm = {_ratio}×范数 时裁剪结果不对"
assert _a.sharding.is_equivalent_to(NamedSharding(dmesh, P("data")), _a.ndim), f"裁剪后的梯度应保持分片,实际 {_a.sharding}"
print("✅ 练习 2 自检通过")
✍️ 练习 3:数据 × 模型二维并行的一步训练¶
用 (data=4, model=2) 的 Auto 网格,把 3.7 节 Megatron MLP 的 W1 按列切(P(None, 'model'))、W2 按行切(P('model', None)),数据按行沿 'data' 切分。请写出一步 SGD(学习率 0.1,损失为 MSE,目标 Y = sin(X)),要求:
- 结果和单设备训练一步完全一致(
atol=1e-5); - 更新后的参数保持原来的分片。参数分片不能在训练中「漂移」,不然下一步会多出额外的通信。在这个小例子里,就算不写
out_shardings,Auto 模式的编译器跑出来也会保持参数分片;但这没有保证,模型一复杂就可能变,所以要显式固定。
自检接口:定义 step_2d(p, x, y):p = {"w1": (8, 32) 按 P(None, 'model'), "w2": (32, 8) 按 P('model', None)},x、y 形状 (16, 8)、按 P('data') 分到 mesh2d_auto;返回一步 SGD(学习率 0.1,MSE 损失)之后的参数 dict。
# ✍️ 作答区:练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 3
try:
step_2d
except NameError as e:
print(f"⏳ 练习 3 尚未作答:{e}")
else:
_rng = np.random.default_rng(11)
_w1 = (_rng.normal(size=(8, 32)) / np.sqrt(8)).astype(np.float32)
_w2 = (_rng.normal(size=(32, 8)) / np.sqrt(32)).astype(np.float32)
_x = _rng.normal(size=(16, 8)).astype(np.float32); _y = np.sin(_x)
_put = lambda a, spec: jax.device_put(jnp.asarray(a), NamedSharding(mesh2d_auto, spec))
_specs = {"w1": P(None, "model"), "w2": P("model", None)}
_new = step_2d({"w1": _put(_w1, _specs["w1"]), "w2": _put(_w2, _specs["w2"])},
_put(_x, P("data")), _put(_y, P("data")))
# float64 手推一步 SGD 作为对照
_X, _W1, _W2, _Y = (np.asarray(a, np.float64) for a in (_x, _w1, _w2, _y))
_pre = _X @ _W1; _h = np.maximum(_pre, 0); _dout = 2 * (_h @ _W2 - _Y) / _Y.size
_ref = {"w2": _W2 - 0.1 * (_h.T @ _dout), "w1": _W1 - 0.1 * (_X.T @ ((_dout @ _W2.T) * (_pre > 0)))}
for _k in ["w1", "w2"]:
assert np.allclose(np.asarray(_new[_k]), _ref[_k], atol=1e-5), f"{_k} 更新后的值与手推的一步 SGD 不一致"
assert _new[_k].sharding.is_equivalent_to(NamedSharding(mesh2d_auto, _specs[_k]), 2), (
f"{_k} 的分片漂移了:应为 {_specs[_k]},实际 {_new[_k].sharding.spec}")
print("✅ 练习 3 自检通过")
测验¶
以下 8 题的答案都已在本 notebook 里运行验证过。
1. jax.pmap(f)(params, data) 中,params 是 {'w': (10, 5), 'b': (5,)},data 是 (8, 32, 10),没有写 in_axes。会怎样?
答案与解析
报错。in_axes 默认是 0,参数也会被当成要沿第 0 轴映射:b 的第 0 维 5 被当成设备数,和数据的 8 对不上。参数必须显式写 in_axes=(None, 0) 才会广播。「没有映射主轴的参数会自动广播」这种说法是错的(3.4 节)。
2. lax.pmean(x, 'i') 和 lax.psum(x, 'i') / N 编译出来有什么区别?
答案与解析
没有区别。pmean 的 jaxpr 就是 psum 加一次 div,两者都只编译出一个 all-reduce。推荐 pmean 只是因为它更好读,而不是「后端能更高效地实现」(3.5 节)。
3. 2×2 网格上,设备 $(i, j)$ 持有 $x_{ij}=2i+j$、$p_{ij}=0.5$。计算 psum(psum(x*p, 'model') * 2, 'data'),结果是多少?
答案与解析
处处为 6。内层沿 'model' 求和,得到 $2(0+0.5)=1$ 和 $2(1+1.5)=5$;外层沿 'data' 求和,只有同一列的 2 个设备参与,结果是 $1+5=6$。如果算成 12,就是把内层已经复制的值又沿 'model' 求了一次和,重复计数了。0.12 的 vma 检查会直接拒绝这种写法(3.7 节)。
4. 在默认(Explicit)网格上,x 的类型是 float32[8@data,4@model],w 是 float32[4@model,6],直接写 x @ w 会怎样?怎么改?
答案与解析
报 ShardingTypeError。收缩维两边都沿 model 切分,输出既可以 all-reduce 成复制的,也可以 reduce-scatter 成分片的,有歧义。改法是 jnp.matmul(x, w, out_sharding=NamedSharding(mesh, P('data', None))),或者改用 Auto 网格,交给编译器决定(3.7 节)。
5. 在 Explicit 网格的 jit 里,对一个 P('data','model') 分片的值调用 with_sharding_constraint(y, P(None,'model')),会发生什么?
答案与解析
报 AssertionError。全部轴都是 Explicit 时,with_sharding_constraint 只是一个断言,不会改变分片。要改变分片,应该用 jax.sharding.reshard。在 Auto 网格下,它才是给编译器的提示(3.7 节)。
6. 和普通数据并行相比,FSDP 每个设备的参数内存和每一步的通信量分别怎么变?JAX 里怎样「免费」得到梯度的 reduce-scatter?
答案与解析
参数(以及梯度、优化器状态)内存降到 $1/N$。在本章的简化实现里,通信从一次 all-reduce($2\frac{N-1}{N}S$)变成一次 all-gather 加一次 reduce-scatter(各 $\frac{N-1}{N}S$),总量不变;实际的 FSDP 为了不在前向和反向之间留着完整参数,反向时会再 all-gather 一次,通信量大约是 DP 的 1.5 倍(3.1 节补充)。至于 reduce-scatter:在损失函数里对参数分片做 all_gather,然后对分片求导。因为 all_gather 的转置就是 psum_scatter,自动微分会自己生成 reduce-scatter。注意结果还要除以 $N$(3.7 节,memory_analysis 的结果:23072 + 3072 → 5956 字节)。
7. 2 个进程,每个进程 4 个设备。在进程 1 里,len(jax.devices())、len(jax.local_devices()) 各是多少?调用 jax.pmap(lambda x: lax.psum(x, 'i'), axis_name='i'),输入主轴应该多长,psum 跨越多少个设备?
答案与解析
分别是 8 和 4。pmap 的输入主轴长度是本地设备数 4,输出形状也是 (4,),但 psum 跨越全部 8 个设备(运行结果是 36 = 1+…+8)。另外,必须先调用 jax.distributed.initialize(3.8 节)。
8. lax.ppermute(x, 'i', perm=[(0, 1), (1, 2)]) 在 4 个设备上运行,设备 0 和设备 3 会得到什么?
答案与解析
都得到全 0,因为没有哪个设备给它们发数据。perm 不必是完整的置换,「收不到就是 0」这个性质正好可以用来实现模板计算的零边界(3.5 节的 halo 交换)。
# 验证测验第 8 题
mesh_q = jax.make_mesh((4,), ("i",), devices=jax.devices()[:4])
q8 = jax.jit(jax.shard_map(lambda x: lax.ppermute(x, "i", [(0, 1), (1, 2)]), mesh=mesh_q,
in_specs=P("i"), out_specs=P("i")))(jax.device_put(jnp.array([10., 20., 30., 40.]), NamedSharding(mesh_q, P("i"))))
print(q8)
assert np.array_equal(np.asarray(q8), [0., 10., 20., 0.])
本章更新说明¶
下面汇总本章在 JAX 0.12 下需要注意的写法和结论,每条都在正文对应位置用 assert 验证过。
| 编号 | 小节 | 要点 | 位置 |
|---|---|---|---|
| 1 | 3.2 | device 是属性,要写 x.device(x.device() 会报错);另有 x.devices()、x.sharding | 3.2 默认设备放置 |
| 2 | 3.2 | 跨设备运算的规则是确定的:uncommitted 数组跟随 committed 数组;两个 committed 输入在不同设备上时报 ValueError | 3.2 补充 |
| 3 | 3.4 | pmap 的 in_axes 默认是 0,没有映射主轴的参数不会自动广播,必须显式写 None | 3.4 |
| 4 | 3.4 | 用 jax.random.key 代替 jax.random.PRNGKey,并先 split,每个 key 只用一次 | 3.4 |
| 5 | 3.5 | DeviceArray 已移除,数组统一是 jax.Array,打印为 Array([...]) | 3.5 |
| 6 | 3.5 | pmean 就是 psum + div,和先 psum 再除以 N 编译出的通信相同,并不更高效 | 3.5 |
| 7 | 3.5 | 补充 psum_scatter、all_to_all 等四种重排型集合操作的语义、通信量,以及恒等式 AR = RS + AG | 3.5 补充 |
| 8 | 3.6 | axis_name 是 psum 的必需参数,省略报 TypeError,不存在隐式推断 | 3.6 |
| 9 | 3.6 | 参数是标量时,pmap 不写 in_axes 会报 IndexError,要写 in_axes=(None, 0) | 3.6 |
| 10 | 3.6 / 3.9 | jax.tree_map 已删除(AttributeError),用 jax.tree.map | 3.6、3.9 |
| 11 | 3.7 | jax.experimental.maps 模块已删除;用 jax.make_mesh / jax.sharding.Mesh | 3.7 |
| 12 | 3.7 | 形状必须是整数:jnp.ones(16.) 报错,要写 jnp.ones(16) | 3.7 |
| 13 | 3.7 | x.devices 是方法,不能当设备网格;arange(num_devices).reshape(2,2) 只在恰好 4 个设备时成立;local_devices()[:1]*4 把同一设备重复 4 次,不能用于并行。构造网格用 jax.make_mesh((2,2), ..., devices=...) | 3.7 |
| 14 | 3.7 | 0.12 中嵌套 pmap 报 ValueError;二维并行改用二维网格 + shard_map | 3.7 |
| 15 | 3.7 | 嵌套归约简化示例的 output_simple[0] 是 [6., 6.],不是 12;12 是把已复制的值重复求和的结果 | 3.7 |
| 16 | 3.7 | jax.jit(device=...)、jax.experimental.shard_map 都已弃用(DeprecationWarning);改为对输入 device_put,并使用 jax.shard_map | 3.7 |
| 17 | 3.7 | 补充 PartitionSpec 组合、AxisType、with_sharding_constraint / reshard、Megatron MLP、FSDP | 3.7 补充 |
| 18 | 3.8 | 多主机程序必须在任何计算之前调用 jax.distributed.initialize;说明参数,并在本机运行 2 进程演示 | 3.8 |
| 19 | 3.8 | 多主机时,每个进程的 pmap 输入主轴长度 = 本地设备数(4),只有集合操作跨越全局设备 | 3.8 |
| 20 | 3.9 | jax_cpu_device_count 配置项不存在;模拟多设备用 XLA_FLAGS 或 jax_num_cpu_devices,都要在后端初始化前设置 | 3.9 |
| 21 | 3.9 | pmap 没有 jit 参数(传入报 TypeError),它本身就会编译 | 3.9 |
| 22 | 3.9 | 训练步的 in_axes=(0,0,0,None) 表示参数带设备轴、按第 0 轴映射,不是复制 | 3.9 |
| 23 | 3.9 | pmap(broadcast, in_axes=None, out_axes=0)(params) 复制参数会报 ValueError(加 axis_size 也不行);用 jnp.broadcast_to 加设备轴,现代写法用 device_put(..., P()) | 3.9 |
| 24 | 3.9 | 训练结果与单设备、NumPy float64 对拍;补充现代写法、MLP+Adam 和 all-reduce 字节数分析 | 3.9 |
| 25 | 3.2 | jit 计算跟着输入数据走:输入放在哪个设备,计算和结果就在哪个设备,device_put 不只影响初始位置 | 3.2 用 jax.device_put 显式放置 |
| 26 | 3.5 | 集合操作只要外层变换绑定了该轴名就能用:pmap、shard_map、带 axis_name 的 vmap 都行 | 3.5、3.6 补充 |
本章小结¶
- 并行策略:数据并行复制模型、切分数据,用
pmean平均梯度;模型/张量并行切分权重;流水线并行切分层,用微批次把气泡 $\frac{p-1}{m+p-1}$ 压小。ZeRO/FSDP 用「reduce-scatter + all-gather」代替 all-reduce,长期保存的参数/梯度/优化器状态降到 $1/N$。ZeRO-1/2 通信量不变;ZeRO-3/FSDP 如果反向时重新 all-gather 参数,通信量大约是 DP 的 1.5 倍。 - 设备与数据:
x.device是属性;committed 与 uncommitted 决定计算放在哪;模拟多设备的设置必须在后端初始化之前完成。 - pmap 是旧接口:
in_axes默认 0,参数要写None;单个 pmap 只能表达一维并行,嵌套 pmap 在 0.12 里报错;多主机时 pmap 的主轴是本地设备数。现代写法首选jit + NamedSharding,要手写通信时用jax.shard_map。 - 集合通信:
psum/pmean/pmax/pmin做归约(pmean=psum/ N);all_gather、psum_scatter、all_to_all、ppermute做重排,AR = RS + AG。ppermute收不到数据的设备得到 0。vma 检查会拒绝重复求和,也会把all_gather的结果当成 varying。 - 多维分区:二维网格 +
PartitionSpec。Explicit 轴把分片写进类型(jax.typeof),有歧义时要求out_sharding,with_sharding_constraint退化成断言,改分片用reshard;Auto 轴交给编译器;shard_map 内部是 Manual。 - 多主机:先调用
jax.distributed.initialize;按process_index加载数据,用make_array_from_process_local_data拼成全局数组;所有进程按相同顺序执行相同的程序。本章已经在本机用 2 个进程真正跑通。 - 验证习惯:分布式结果一律和单设备、NumPy float64 对拍;通信量从编译后的 HLO 和
memory_analysis里读,不靠猜。
速查表¶
| API | 作用 |
|---|---|
jax.devices() / jax.local_devices() | 全局设备 / 本进程设备 |
jax.device_count() / jax.local_device_count() | 全局 / 本地设备数 |
jax.process_index() / jax.process_count() | 当前进程编号 / 进程总数 |
x.device / x.devices() / x.sharding | 数组所在设备(属性)/ 设备集合 / 分片 |
jax.device_put(x, device_or_sharding) | 放置或分片数组(得到 committed 数组) |
jax.config.update('jax_num_cpu_devices', n) | 模拟 n 个 CPU 设备(须在后端初始化前) |
jax.make_mesh(shape, names, axis_types=, devices=) | 创建设备网格(默认 Explicit 轴) |
NamedSharding(mesh, P(...)) | 按网格轴描述分片 |
jax.typeof(x) | 带分片信息的类型,如 float32[8@data,4@model] |
jax.debug.visualize_array_sharding(x) | 画出切分方式 |
jax.shard_map(f, mesh=, in_specs=, out_specs=) | 每设备局部视角 + 手写集合通信 |
jax.pmap(f, axis_name=, in_axes=, static_broadcasted_argnums=) | 旧接口一维 SPMD |
lax.psum / pmean / pmax / pmin(x, axis) | 跨设备归约(axis 可以是名字元组) |
lax.all_gather(x, axis, tiled=) | 收集并拼接 |
lax.psum_scatter(x, axis, scatter_dimension=, tiled=) | 求和后切分(reduce-scatter) |
lax.all_to_all(x, axis, split_axis, concat_axis, tiled=) | 全交换,重排分片维度 |
lax.ppermute(x, axis, perm) | 按 (源, 目标) 对点对点发送,收不到为 0 |
lax.axis_index(axis) | 本设备在该轴上的编号 |
lax.with_sharding_constraint(x, s) | Auto 下是分片提示,Explicit 下是断言 |
jax.sharding.reshard(x, P(...)) | Explicit 下改变分片 |
jnp.matmul(a, b, out_sharding=) | 指定输出分片,消除歧义 |
jax.set_mesh(mesh) | 设置网格上下文(之后可直接写 P(...)) |
jax.distributed.initialize(coordinator_address, num_processes, process_id) | 多主机初始化 |
jax.make_array_from_process_local_data(sharding, local) | 各进程的本地数据拼成全局数组 |
multihost_utils.process_allgather(x) | 收集各进程的数据到主机 |
compiled.memory_analysis() / compiled.as_text() | 每设备内存统计 / 编译后的 HLO(查看通信算子) |
练习 1¶
题目见正文「✍️ 练习 1:用 all_to_all 实现分布式矩阵转置」。
# 参考答案
Xt16 = np.arange(256.).reshape(16, 16)
Xt16_s = jax.device_put(jnp.asarray(Xt16), NamedSharding(mesh8, P("i", None)))
def dist_transpose(x): # x:(2, 16),本设备的 2 行
# 沿列切成 8 块、分别发给 8 个设备;收到的块沿行拼接 → (16, 2) = 原矩阵第 2i:2i+2 列
cols = lax.all_to_all(x, "i", split_axis=1, concat_axis=0, tiled=True)
return cols.T # (2, 16) = 转置矩阵的第 2i:2i+2 行
f_t = jax.jit(jax.shard_map(dist_transpose, mesh=mesh8, in_specs=P("i", None), out_specs=P("i", None)))
XT = f_t(Xt16_s)
assert np.array_equal(np.asarray(XT), Xt16.T)
assert collectives(f_t.lower(Xt16_s).compile()) == ["all-to-all"]
print("分布式转置正确,只用了一次 all-to-all ✓;输出类型", jax.typeof(XT))
练习 2¶
题目见正文「✍️ 练习 2:FSDP 下的全局梯度范数裁剪」。
# 参考答案
g_sharded = jax.jit(jax.shard_map(fsdp_grad_auto, mesh=dmesh, in_specs=(pspec, P("data"), P("data")),
out_specs=pspec))(p_fsdp, Xd, Yd)
true_norm = float(optax.global_norm(g_single))
max_norm = 0.5 * true_norm # 取真实范数的一半,保证裁剪生效
def clip_sharded(g, max_norm):
local_sq = sum(jnp.sum(a ** 2) for a in jax.tree.leaves(g)) # 本地分片的平方和
norm = jnp.sqrt(lax.psum(local_sq, "data")) # 全局平方和 → 全局范数
scale = jnp.minimum(1.0, max_norm / norm)
return jax.tree.map(lambda a: a * scale, g), norm
f_clip = jax.jit(jax.shard_map(clip_sharded, mesh=dmesh, in_specs=(pspec, P()), out_specs=(pspec, P())))
g_clipped, norm_d = f_clip(g_sharded, jnp.float32(max_norm))
ref_clipped, _ = optax.clip_by_global_norm(max_norm).update(g_single, optax.EmptyState())
print(f"全局范数: 分布式 {float(norm_d):.6f} | optax {true_norm:.6f}")
assert abs(float(norm_d) - true_norm) < 1e-5
assert jax.tree.all(jax.tree.map(lambda a, b: bool(np.allclose(np.asarray(a), np.asarray(b), atol=1e-6)), g_clipped, ref_clipped))
assert abs(float(optax.global_norm(ref_clipped)) - max_norm) < 1e-5
print("FSDP 分片梯度的全局范数裁剪 == optax ✓")
练习 3¶
题目见正文「✍️ 练习 3:数据 × 模型二维并行的一步训练」。
# 参考答案
put_a = lambda a, spec: jax.device_put(a, NamedSharding(mesh2d_auto, spec))
Ym = jnp.sin(Xm)
p_meg = {"w1": W1, "w2": W2}
p_meg_sh = {"w1": put_a(W1, P(None, "model")), "w2": put_a(W2, P("model", None))}
out_sh = {k: v.sharding for k, v in p_meg_sh.items()}
def meg_loss(p, x, y):
return jnp.mean((jax.nn.relu(x @ p["w1"]) @ p["w2"] - y) ** 2)
def meg_step(p, x, y):
g = jax.grad(meg_loss)(p, x, y)
return jax.tree.map(lambda a, b: a - 0.1 * b, p, g)
step_2d = jax.jit(meg_step, out_shardings=out_sh) # 用 out_shardings 固定参数分片
new_2d = step_2d(p_meg_sh, put_a(Xm, P("data")), put_a(Ym, P("data")))
new_1 = jax.jit(meg_step)(p_meg, Xm, Ym)
for k in p_meg:
assert np.allclose(np.asarray(new_2d[k]), np.asarray(new_1[k]), atol=1e-5)
assert new_2d[k].sharding.is_equivalent_to(out_sh[k], new_2d[k].ndim)
print("二维并行一步训练 == 单设备 ✓;分片保持:", {k: v.sharding.spec for k, v in new_2d.items()})
print("编译器插入的通信:", collectives(step_2d.lower(p_meg_sh, put_a(Xm, P("data")), put_a(Ym, P("data"))).compile()))