周五晚上,餐厅接到一张 1000 份宫保鸡丁的团购订单。主厨一个人就算手脚再快,也要忙到天亮。聪明的做法是把订单按份数拆成四堆,交给四位帮厨,每人在自己的灶台上同时开工——四倍灶台,四倍出锅速度。可问题也随之而来:帮厨们各炒各的,最后整桌菜的口味怎么统一?酱汁要怎么保证都是一个味儿?这份“把大工作拆开、同时干、再对齐结果”的智慧,正是 JAX 里 pmap 多设备并行计算的核心。本文的主角 pmap,就像那个“派单的老板”:host(宿主)负责接单和分堆,GPU/TPU 这些设备(帮厨)在自己的灶台上翻炒数据,而 lax.pmean 这类通信原语,就是那口把酱汁混匀再分回去的盆。读完这篇文章,你会彻底搞懂多设备训练是怎么切数据、怎么通信、怎么跟 jit 组合的。
掌握 pmap 就等于学会给 JAX 的分布式训练“派单”,能在多卡环境中把训练吞吐提升数倍,是走向大规模 AI 工程化的必修课。
目录
可视化演示
展示JAX pmap如何实现跨多GPU/TPU设备的数据并行计算,包括设备映射、数据分片、all-reduce/all-gather通信原语与多设备训练流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
JAX pmap多设备并行计算 设备映射:pmap将函数编译为多设备并行版本,数据沿并行轴分片到各设备 数据并行:输入数据按设备数切分,每设备独立计算前向/反向传播 梯度同步:all-reduce聚合各设备梯度,确保参数同步更新 pmap(f)(x_sharded) → [f(x₀), f(x₁), ..., f(xₙ)] pmap实现数据并行计算与梯度同步
并行基础:订单太大,分给帮厨做
周五晚上,餐厅接到一张 1000 份宫保鸡丁的团购订单。主厨一个人就算手脚再快,也要忙到天亮。最简单的解法,是把订单按份数拆成四堆,让四位帮厨各拿一堆,在自己的灶台上同时开工——四倍的灶台,四倍的出锅速度。这种“把一份大工作拆成几份小工作、同时干”的思路,放在 AI 训练里就叫数据并行(data parallelism)。JAX 里负责实现这一步的 API,就是今天的主角:pmap。
在 JAX 的世界里,程序永远分为两种角色。一种是 host(宿主),通常指 CPU 和主内存,它像餐厅老板:负责接单、派单、收账,但绝不亲自下厨。另一种是 device(设备),也就是 GPU 或 TPU,它们像后厨的帮厨:重活全在它们手里。pmap 这个名字拆开是 parallel + map,意思是“把一个函数并行地映射到多台设备上执行”。你写一个函数 f,pmap 会把这个函数复制到每一台设备上,大家步调一致地跑同一段代码。
不过帮厨开工前,得先把食材分好。pmap 默认沿着输入数组的第一个维度——也就是样本(batch)维度——把数据切成 D 份,每台设备只拿到一份。切分之后每台设备处理多少样本,由一个非常简单的除法决定:
订单拆成几堆,帮厨谁也不用等谁,各自锅里的菜互不干扰。这就是数据并行最舒服的地方:切分是自动的,计算是独立的。唯一需要大家坐下来商量的事,到第三节再讲。先把“分堆”这个动作刻在脑子里——pmap 的一切,都从这一刀开始。
- 样本总数批次中全部样本的数量,例如一批图片有 1000 张。
- 设备数量参与计算的 GPU/TPU 台数,等于后厨帮厨的人数。
- 子批次大小每台设备分到的样本数,它必须是一个整数,所以批次大小最好能被设备数整除。
设备映射:帮厨各就各位
开饭前,老板要做的第一件事是数人头:今天几位帮厨上班?各站哪口灶台?在 JAX 里,对应两行代码:jax.devices() 查看当前可见的全部设备,jax.local_device_count() 数出本机一共有几台。它们返回的信息,就是 pmap 分配任务时手里那张“排班表”。
有了排班表,pmap 开始干活。它做了一件很有意思的事:把“设备”这个概念,变成数组的一个新维度。当函数被 pmap 包装后,输入数组的第一个维度被解释为设备轴(parallel axis):下标为 d 的那一块数据,交给第 d 台设备去算。你可以把这个轴想象成帮厨工位上贴的编号——1 号灶台拿第 1 堆食材,2 号灶台拿第 2 堆。
计算结束后,pmap 会把所有设备的输出按设备编号堆叠起来,返回一个前导维度大小为 D 的数组。这个数组长得和普通 JAX 数组一模一样,却暗藏玄机:每一层切片都来自不同的设备。
从外部看,你只是调用了 pmap(f);但从设备的角度看,每个帮厨都守着自己那口灶,锅里的菜只属于自己。设备映射的本质,就是让“多台设备”这件事在数组形状上显形——看一眼 shape 的第一个维度,就知道老板今天派了几位帮厨。
- 设备下标d 表示第几台设备,取值从 0 到 D-1,相当于灶台的编号。
- 子批次输入x_d 是第 d 台设备拿到的数据分片,形状由每台样本数决定。
- 计算函数f 是你要并行的函数,比如一层神经网络或一个损失函数,所有设备执行同一份代码。
- 结果堆叠每台设备的 y_d 按 d 顺序堆叠成输出数组,前导维度恰好等于设备数。
通信原语:那口混酱汁的盆
帮厨们各炒各的,可宫保鸡丁讲究“整桌一个味儿”。于是主厨定下规矩:所有人把调好的酱汁倒进一个大盆,搅匀,再各舀一勺回去。这个“倒进去—搅匀—舀回来”的动作,在并行计算里叫 all-reduce(全归约):所有设备先把自己的数值贡献出来,汇总成一个结果,再广播给每一台设备。
JAX 里最常用的通信原语有两个。lax.pmean(mean 是平均)做的事正如其名:跨设备求平均值再广播回所有设备,是 all-reduce 家族里专做“平均”的那一款。lax.all_gather 则像拼盘环节:每个帮厨把自己做的特色配菜交出来互相分享,最后每台设备手里都有一份拼好的完整数据。前者让数值变得一致,后者让数据变得完整。
为什么训练离不开 pmean?因为梯度更新要求所有设备使用同一个平均梯度。假设设备 d 算出了梯度 g_d,全局平均梯度就是:
那口盆就是 pmean,规矩就是 axis_name——所有帮厨得先知道“往哪口盆里倒”,动作才能统一。没有这口盆,每个帮厨按自己的口味调酱,整桌菜就成了大杂烩。
- 局部梯度g_d 是第 d 台设备在自己的子批次上算出的梯度,代表这台设备对更新方向的“个人意见”。
- 跨设备求和把 D 台设备的梯度逐分量相加,相当于所有帮厨把酱汁倒进同一口盆。
- 取平均总和除以设备数,得到全局平均梯度,这是大家最终认可的“统一口味”。
- 广播回去pmean 把 ḡ 复制到每台设备上,大家拿着同一份梯度更新参数。
手动并行:老板亲自下场分堆
大多数时候 pmap 会自动切分数据,但有些时候帮厨太忙,需要老板先把食材按堆分好再递过去。这种“亲自下场分堆”的做法叫手动并行。它通常只有一步:把输入 reshape 成(设备数,每台样本数)的形状,像把 1000 份食材装进 4 个贴着编号的筐里。
手动并行还要求你理解一个隐藏的规矩:通信即同步。当某台设备执行到 lax.pmean 或 lax.all_gather 时,它必须停下来,等其他所有设备都执行到同样的位置,交换完数据,才能继续往前走。这个“必须一起到齐”的点叫同步屏障(synchronization barrier)——就像混酱汁时,最后一个帮厨没到,谁也不能先开饭。
同步是要花时间的。并行训练一个 step 的总时间可以拆成两块:
手动并行的精髓,是让老板对“分多少、传什么、等多久”心里有数。数据分得越细,计算越轻;设备越多,通信越重。懂得在两者之间找平衡,才算真正会派单。
- 本地计算时间每台设备炒自己那份菜的时间,理想情况下是串行时间的 1/D。
- 通信固定开销α 是每台设备参与一次同步的固定成本,设备越多累计越大。
- 数据传输时间β 是传输每字节数据的时间,M 是要交换的数据量,数据越大传得越久。
- 总时间一个训练 step 的墙钟时间等于计算与通信之和。
jit组合:主厨的预制菜谱
假设帮厨做菜全凭记忆,每炒一份都要停下来回忆下一步——太慢了。高明的做法是,主厨把菜谱提前印好,贴在每口灶台前,帮厨照方抓药,流畅得像肌肉记忆。这个“提前印好的菜谱”,在 JAX 里就是 jit 编译。
jit 会把函数编译成底层的 XLA 程序,跳过 Python 的解释开销。和 pmap 组合时,标准写法是把 jit 放在里层:pmap(jit(f))。意思是:先把菜谱印好(jit 编译一份 XLA 程序),再分发给所有设备(pmap 分发到 D 个灶台)。每台设备执行的,都是同一份编译好的程序——完全符合“先印菜谱,再派给帮厨”的顺序。
组合之后,自动微分依然畅通。要在 pmap(jit(f)) 里用 grad,JAX 会在每台设备上独立计算损失对参数的梯度,再靠 pmean 跨设备取平均。要保证这一切成立,函数必须是纯函数:不能读写外部变量,就像帮厨只看自己灶台上的食材,不能偷看别人的锅——一切输入输出都通过参数和返回值传递。
预制菜谱加帮厨分发,再加酱汁共识,这套组合拳让多设备训练兼顾了编译速度和并行效率。jit 负责“无脑快”,pmap 负责“人多力量大”,pmean 负责“心往一处想”。
- 局部损失L_d 是第 d 台设备用自己的子批次算出的损失,表示这台设备觉得现在的模型差多少。
- 局部梯度对参数 θ 求导,得到该设备对更新方向的建议。
- 平均融合跨设备取平均,得到大家共识的更新方向。
训练实战:一整桌宴席的演练
最后,把所有环节串成一次完整的宴席。后厨收到订单(一批数据),第一步永远是确认灶台数量,然后把订单按灶台数分堆:x.reshape(设备数, 每台样本数)。准备妥当,pmap 包装的训练函数就开火了。
每轮训练 step 里发生的事,与前面几节完全吻合:每台设备用自己的子批次做前向计算得到损失,反向传播算出梯度;pmean 把梯度平均并广播;所有设备用同一个平均梯度,更新自己手里那份参数副本。因为每台设备都持有参数副本,且每步都用相同的梯度更新,所以参数始终保持同步。
最后一件事是验收。衡量并行训练值不值,看两个指标:吞吐量(每秒处理多少样本)和扩展比(D 台设备相对 1 台设备的加速倍数)。理想扩展比是线性,实际因为通信开销总会差一点。更新参数的公式长这样:
从派单、分堆、炒菜、混酱,到按同一份菜谱改良口味,一整桌宴席就这样有条不紊地出锅了。pmap 把“人多”这件事变得安全可预期,而你要做的,只是记住那口盆(pmean)和那张菜谱(jit),其余交给 JAX。
- 参数副本每台设备持有一份完全相同的参数 θ,保证大家从同一起点出发。
- 学习率η 是超参数,决定每次挪动参数的步子迈多大。
- 同步更新用平均梯度同步更新所有设备上的参数,模型在任何时刻都保持一致。
AI 应用场景
多卡图像分类训练加速
阶段:模型训练
解决的问题:单张 GPU 显存和算力有限,大批次训练耗时;手写多卡同步逻辑(切分、梯度同步)容易出错。用 pmap 把 batch 沿第一维切到多张卡,每个 step 用 lax.pmean 平均梯度,核心代码只要几行。
具体案例:某团队在 8 张 A100 上用 JAX 训练 ResNet-50,将每批 1024 张图 reshape 成 (8, 128) 传入 pmap 包装的训练函数,梯度经 all-reduce 平均后同步更新参数,吞吐从约 600 张/秒提升到 4000+ 张/秒。
多环境并行采样(强化学习)
阶段:数据采集
解决的问题:强化学习里模型要不断与环境互动采集样本,单环境串行太慢。用 pmap 让每台设备跑一个环境副本,并行执行 step,再用 all-gather 把各环境的(状态,动作,奖励)收集回 host 组成训练批次。
具体案例:机器人仿真训练中,用 pmap 在 4 台 TPU 上并行跑 4 个环境副本,每轮采样时间从约 2 秒降到 0.55 秒,让同一批数据更快喂给策略网络。
大规模批量推理提速
阶段:模型推理
解决的问题:线上批量推理(如视频审核、批量翻译)时单卡吞吐不足,请求排队严重。用 pmap 把待推理的样本按设备数切分,每个设备独立做前向,一次完成整批推理。
具体案例:在 4 张 V100 上对 1 万条文本批量翻译,pmap 并行推理整体耗时约为单卡的 1/3.5,把深夜积压的翻译任务缩短到分钟级。
Python 演示
import jaximport jax.numpy as jnpfrom jax import grad, pmap, jitfrom jax import lax def main(): device_count = jax.local_device_count() print("设备数量:", device_count) print("设备清单:", jax.devices()) # 1. 模拟一批 8 条样本的特征数据 x = jnp.arange(8.0) y = 3.0 * x + 1.0 print("原始批次 x:", x) # 2. pmap: 沿第一个维度把批次切分到各设备 def compute(v): return v * 2 + 1 mapped = pmap(compute)(x) print("各设备计算结果:", mapped) # 3. all-reduce: 跨设备求平均并广播 def compute_with_mean(v): return lax.pmean(v, axis_name="data") averaged = pmap(compute_with_mean, axis_name="data")(x) print("all-reduce 全局平均:", averaged) # 4. all-gather: 收齐所有分片并拼接成完整批次 def collect(v): return lax.all_gather(v, axis_name="data", tiled=True) gathered = pmap(collect, axis_name="data")(x) print("all-gather 拼接形状:", gathered.shape) # 5. 数据并行训练: 每台设备只看自己的子批次 per = x.shape[0] // device_count x_shards = x[:device_count * per].reshape(device_count, per) y_shards = y[:device_count * per].reshape(device_count, per) w = jnp.array([0.0]) def loss_fn(w, x_local, y_local): return jnp.mean((w * x_local - y_local) ** 2) def train_step(w, x_local, y_local): g = grad(loss_fn)(w, x_local, y_local) avg_g = lax.pmean(g, axis_name="data") return w - 0.01 * avg_g step = pmap(train_step, axis_name="data") for _ in range(300): w = step(w, x_shards, y_shards) print("训练后的参数 w:", w) if __name__ == "__main__": main()
device_count = jax.local_device_count()数一数后厨有几口灶:查询本机可用的加速设备数量。pmap(compute)(x)把 compute 函数映射到所有设备上,并沿第一个维度把 x 切成设备数份。lax.pmean(v, axis_name="data")混酱汁:跨设备求平均,再把平均值广播回每台设备。lax.all_gather(v, axis_name="data", tiled=True)拼盘:把各设备的分片拼回完整批次,让每台设备都持有全量数据。x_shards = x[:device_count * per].reshape(device_count, per)手动分堆:把批次切成“设备数 × 每台样本数”的块。g = grad(loss_fn)(w, x_local, y_local)每台设备只用自己的子批次算出梯度,互不偷看别人的数据。avg_g = lax.pmean(g, axis_name="data")梯度跨设备平均,保证所有设备用同一个“共识梯度”更新参数。
常见误区
误区pmap 会把一份数据完整复制到每台设备上,这样每台设备都能看到全部样本,训练更充分。
正解pmap 默认沿第一个维度把数据切成互不重叠的多份,每台设备只拿到属于自己的子批次,像把订单分堆而不是复印订单。真正被“复制”的是模型参数:每台设备都持有一份参数副本,而数据是切分的。二者一结合,才实现“数据不同、模型同步”的数据并行。
为何常见:容易把“并行”直觉成“复制几份分别算”,忽略了切片和广播是两种完全不同的语义。
误区只要 GPU 数量翻倍,训练速度就翻倍;上 8 张卡一定比 4 张卡好,设备越多越好。
正解每个 step 的时间 = 计算 + 通信。设备多了,本地计算变少,但梯度平均、参数同步的通信开销会上升;如果总批次固定,每台设备分到的样本太少,GPU 利用率反而下降。扩展比要实测,通常 8 卡能到 6~7 倍就算不错。
为何常见:只盯着算力看,忽略了设备间通信是分布式计算绕不开的固定成本。
误区在 pmap 函数里可以随便用 lax.pmean,不用额外声明任何东西,它会自动知道在哪条轴上通信。
正解通信原语靠 axis_name 识别“在哪条轴上做通信”。必须把 axis_name 传给 pmap,并保证两边名字一致,否则 JAX 会直接报错。它就像后厨暗号——“往哪口盆里倒”要先说清楚,动作才能统一。
为何常见:很多示例代码里 axis_name 恰好同名,新手照抄时没意识到 pmean 和 pmap 的这两个参数是绑定的。
误区pmap 和 jit 只能二选一,或者必须把 pmap 包在 jit 外面才有效。
正解二者可以组合,常见写法是把 jit 放在里层:pmap(jit(f)),每个设备执行的都是编译好的 XLA 程序;jit(pmap(f)) 同样可行。先用 jit 编译再用 pmap 分发,更符合“先印菜谱再派给帮厨”的思路。
为何常见:对两者的分层关系不清楚,误以为编译优化和并行分发是互斥的两条路。
面试题
Q1. pmap 会把输入批次按哪个维度切分到多台设备?
- A.沿第一个(批次)维度切分← 正确答案
- B.沿最后一个特征维度切分
- C.随机挑选某个维度切分
- D.不切分而是复制到所有设备
答案:A。pmap 默认沿输入数组的第一个维度(样本维度)切分,每台设备拿到互不重叠的一个子批次;这与把数据广播复制给所有设备完全不同,因此 B、C、D 均错误。
Q2. pmap 计算完成后,返回数组的第一个维度代表什么?
- A.代表样本特征的维度
- B.代表设备数量的维度← 正确答案
- C.代表训练轮数的维度
- D.代表标签类别的维度
答案:B。pmap 会为每台设备的结果保留一个前导维度,其大小等于设备数,就像帮厨把菜按灶台编号排成一排。样本特征、训练轮数、标签类别都不是 pmap 输出的第一维含义。
Q3. 在 pmap 函数体内调用 lax.pmean 之前,必须完成哪一步?
- A.在 pmap 中声明 axis_name← 正确答案
- B.先调用 lax.all_gather
- C.把数据拷贝回主机内存
- D.先关闭 jit 编译功能
答案:A。lax.pmean 这类通信原语靠 axis_name 识别并行轴,必须把 axis_name 传给 pmap 且两边名字一致,否则 JAX 报错。pmean 不需要先 all-gather,也不需要拷贝回主机或关闭 jit。
Q4. 在 JAX 中,jax.devices() 的作用是什么?
- A.创建虚拟的加速设备
- B.查看当前可见的设备← 正确答案
- C.修改 GPU 的显存大小
- D.设置默认的数据类型
答案:B。jax.devices() 返回当前可见的加速设备列表,相当于老板看排班表。它不创建设备、不改显存、不设数据类型,因此 A、C、D 都超出了它的职责。
Q5. 关于 all-reduce 与 all-gather 的异同,说法正确的有哪些?
- A.都包含跨设备的数据通信← 正确答案
- B.都要求所有设备同步参与← 正确答案
- C.都让每台设备获得聚合结果← 正确答案
- D.都用来减少通信的数据量
答案:ABC。二者都是集合通信,需要全员同步参与并相互交换数据,最终每台设备都能拿到结果,所以 A、B、C 正确。all-gather 是拼接,数据量不会减少反而变大,D 错误。
Q6. 关于 pmap 与 jit 的组合,哪种说法正确?
- A.可以先编译再分发到设备← 正确答案
- B.两者互斥只能选其一
- C.先分发再编译效果更好
- D.组合后梯度无法再传播
答案:A。pmap(jit(f)) 是把函数先编译成 XLA 程序再分发到各设备,jit(pmap(f)) 也可行。B 认为互斥是错的;C 的“效果更好”没有依据;D 错在组合后自动微分依然正常工作。
Q7. pmap 数据并行训练中,正确的做法包括哪些?
- A.批次大小能被设备总数整除← 正确答案
- B.各设备独立计算本地梯度← 正确答案
- C.用 pmean 聚合梯度再同步更新← 正确答案
- D.每台设备采用不同的模型结构
答案:ABC。数据并行要求批次能被设备数整除、各设备独立算梯度、梯度平均后同步更新,因此 A、B、C 正确。所有设备必须使用相同的模型结构,D 错误。
Q8. 总批次不变而设备数翻倍时,单设备批次规模和通信开销占比如何变化?
- A.批次变小,通信占比变大← 正确答案
- B.批次变大,通信占比变小
- C.批次不变,通信占比不变
- D.批次变小,通信占比变小
答案:A。总批次固定时,设备越多每台分到的样本越少,本地计算时间变短;而梯度平均、参数同步的通信开销仍在,甚至因参与设备更多而增大,所以通信占比变大。
Q9. 关于多设备训练中梯度聚合与参数更新,正确的流程描述有哪些?
- A.每个设备独立算出自己的梯度← 正确答案
- B.pmean 生成全局平均梯度← 正确答案
- C.所有设备用同一平均梯度更新← 正确答案
- D.每台设备用本地梯度单独更新
答案:ABC。标准数据并行流程是:各设备先算本地梯度,pmean 求全局平均并广播,所有设备再用同一个平均梯度更新参数,因此 A、B、C 正确。D 会让各设备参数发散,模型失去同步。
Q10. 在 pmap 函数内调用 lax.pmean 却未声明 axis_name,会发生什么?
- A.抛出异常并中止执行← 正确答案
- B.静默跳过该通信操作
- C.自动回退到单设备运行
- D.把结果变成随机数值
答案:A。通信原语必须知道在哪条轴上通信,pmap 没有收到 axis_name 就遇到 pmean 时,JAX 会抛出异常明确提示轴名不匹配。它不会静默跳过、不会回退单设备,更不会产出随机数。