📘 第8章 强化学习和分布式人工智能
📄 一句话概括

这一节的题目听起来像工程,实际上是两条线:一条数学线、一条算术线。 数学线上数据并行是白送的——八份梯度加权平均跟全量梯度一模一样,差只有 1e-16。 算术线上它一点也不白送。加卡换来的速度有天花板,而天花板由「不干活的那部分」决定。 这一节最后会把这个天花板算成一个具体的倍数。

📝 本节和前面几节的关系

这是全书唯一一节不讲「怎么算」,只讲「怎么把算连起来」。前面所有章节都默认一台机器、 一份内存;这里把这些前提全部抽掉。 它跟 8.1 有一个很漂亮的呼应: 集成学习是「多个模型投票」,分布式是「一个模型多地算」,两者都栽在同一个地方——汇总。 前者汇总的是票,后者汇总的是梯度,而汇总都要通信。

一、一台机器不够的三道墙

先把「不够」拆开看。它不是一件事,是三件事,解法也完全不同:

墙 具体表现 靠什么绕过去
算力 一块卡要算几个月 数据并行:把样本切开
显存 一个模型装不进一块卡 模型并行:把层切开
数据量 一块盘读不完、读得慢 分片:把数据散到多台
这三件事常被混着说,其实差别很大。算力不够,每一块卡都能装下整个模型,
所以可以把样本切分给不同的卡;显存不够时切的是模型,不是样本。
这一节只讲数据并行,因为它最常用,而且它的「不划算」最容易被算清楚。
模型并行要处理的是层与层之间的依赖,那是另一套麻烦。
数据并行的全部逻辑只有三步:每张卡拿一份数据、各自算梯度、把梯度合起来。
第一步和第二步都是纯赚的,问题全在第三步。

二、数学上白送:八份梯度等于一份

先说好消息,而且它比想象中更彻底。训练一版逻辑回归,全量梯度是所有样本梯度的平均; 而平均可以拆成「分组算、再加权平均」。

算的方式 结果 跟全量的差
单机一次算完 2000 条 G_ALL —
八台各算 250 条再加权平均 G_SUM 最大分量 3.053e-16
梯度本身的量级是 2.117e-01,差是 3.053e-16——中间差着 15 个数量级。
这已经不是「近似」,就是同一个数,剩下的只是浮点数的舍入。
所以数据并行在数学上跟单机完全等价。这条结论很重要,因为它把问题彻底挪走了:
既然算出来的东西一样,那么「分布式慢」就只能是别的原因。
但等价有一个前提,而且这个前提极容易被写错:每一份必须按样本数加权。
如果八份梯度直接相加再除 8,那就是给样本少的机器偷偷加了权。Demo 里八份刚好都是 250 条,
看不出差别;真实集群里各卡负载不齐,这个 bug 会让收敛变慢,而且极难查。
这是分布式训练最常见的第一个坑:它不报错,只是效果差一点。

三、通信:真正的瓶颈

既然算得没错,那慢在哪儿?慢在「把梯度合起来」这一步。先算一笔账, 假设模型有一百万个参数(float32):一份权重 3.8 MB。

做法 8 卡每轮传 64 卡每轮传 随卡数怎么变
参数服务器 61.0 MB 488.3 MB 线性增长
环形全归约 6.7 MB 7.5 MB 几乎不变
同一个任务,两种汇总方式差了 65 倍。原因在图的结构里:参数服务器是星形,
所有卡都跟中心那台传,中心那台的带宽就是总瓶颈;
环形全归约是环,每张卡只跟左右邻居传两次,转一圈就齐了。
环形全归约的总量公式是 2 × (n−1)/n × 权重。
n 越大,那个 (n−1)/n 越接近 1,所以总量收敛到「权重的两倍」,跟卡数无关。
这就是为什么 8 卡 6.7 MB、64 卡还是 7.5 MB。
换成大模型,这件事就变得致命。一百二十亿参数,一份权重就是 48 GB,八卡一轮要传 84 GB。
而这个传输每一轮都要做一次,一轮的算只花几百毫秒。
换句话说:算得快,通信没跟上,卡就是在等网线。
所以大模型训练里,通信优化比算子优化更值钱。
算子优化是把 100 毫秒压到 80 毫秒;通信优化是把 500 毫秒压到 50 毫秒。

四、同步的代价:每轮都在等最慢的那张

通信之外还有第二笔账,而且这笔账更隐蔽。同步数据并行要求所有卡都算完,才能开始下一轮—— 于是每轮的时间不是平均,是最慢那张卡。 假设一轮的活单卡要算 30 秒,k 张卡平分。 另外每张卡都可能被耽搁(GC 停顿、抢 CPU、网络抖一下),最多 2 秒。跑 4000 次取平均:

卡数 每卡要算 最长耽搁 一轮总耗时 耽搁占比
1 30.00 秒 0.98 秒 30.98 秒 3%
8 3.75 秒 1.78 秒 5.53 秒 32%
64 0.47 秒 1.97 秒 2.44 秒 81%
512 0.06 秒 2.00 秒 2.05 秒 97%
512 卡那一轮 2.05 秒,其中 2.00 秒在等一张被耽搁的卡——而它自己只算了 0.06 秒。
这不是网络慢,是「必须一起走」这条规矩的成本。
卡越多,摊到每张卡上的活越少,可「最长的那次耽搁」只会越来越接近上限。
算得越少,等得越显得长。
三条出路,代价各不相同:
做法 怎么绕 代价
异步训练 谁算完谁就更新,不等 每张卡用的权重版本不一样,会「陈旧」
允许落后 s 步 只等最慢的那一小撮 要调 s,调不好照样慢
弹性训练 慢的直接踢掉,换新的补上 要能容错,工程复杂
异步不是免费的。某张卡拿着三轮前的权重在算梯度,
这份梯度是「过期」的,直接拿去更新会把模型往过时的方向推。
所以真实系统里最常见的是中间那条:「允许落后 s 步」的折中。

五、分片:加一台机器要搬多少数据

第三笔账不在训练里,在数据摆放上。集群要按 key 把数据分到不同节点, 最直觉的做法是取模:节点号 = 哈希 % N。 它有个致命毛病:N 一变,几乎所有的 key 都要换位置。真算一遍(两万个 key):

做法 8 台加到 9 台要搬多少 为什么
取模 88.5% 分母变了,几乎每个余数都变
一致性哈希 11.4% 只有新节点接管的那一段环动
取模搬了 89%,一致性哈希只搬了 11%——而理论上应该接近 1/9 = 11.1%。
实测 11.4%,误差来自哈希本身的随机性。
一致性哈希的想法很朴素:把节点和 key 都哈希到一个环上,
key 顺时针找到的第一个节点就是它的家。
加一个节点,它只从环上「抢走」一段,别的都不动。
代价是负载不均。8 个节点各放一个点,环上的段长短必然参差。
解法是虚拟节点:一个物理节点在环上撒 120 个点。
Demo 实测最忙 2955 个 key、最闲 2052 个,比值 1.44。
虚拟节点就是用来压这个比值的。
这一段的教训是可以搬到别处的:分布式里凡是「按 N 取模」的设计,都要先问一句「N 会变吗」。
会变,就不能取模。

六、加速比的天花板

把第 4 段那笔账换算成「加卡到底换多少速度」。加速比 = 单卡一轮时间 ÷ k 卡一轮时间:

卡数 理论加速比 实际加速比 并行效率
1 1.00 倍 1.00 倍 100.0%
2 2.00 倍 1.90 倍 94.8%
4 4.00 倍 3.40 倍 85.1%
8 8.00 倍 5.60 倍 70.0%
16 16.00 倍 8.25 倍 51.5%
64 64.00 倍 12.71 倍 19.9%
256 256.00 倍 14.69 倍 5.7%
512 512.00 倍 15.08 倍 2.9%
从 1 卡加到 512 卡,理论 512 倍,实际只有 15.1 倍。
注意它最后稳住了,不再往下掉——因为耽搁时间有上限 2 秒,不会再涨。
天花板 = 单卡 30 秒 ÷ 耽搁上限 2 秒 = 15.0 倍。再多卡也超不过。
实测 15.08 倍,跟这个估算几乎重合。
换成阿姆达尔定律的说法就更好记:耽搁 2 秒占单卡 30 秒的 6.7%,而 1 ÷ 0.067 = 15.0。
这就是「堆卡有尽头」的算术原因:那段不随卡数缩小的部分,决定了上限。
它的名字叫串行部分。在真实集群里,它可能是同步等待、可能是通信、
也可能是数据加载——反正一定有那么一段。
所以「多堆卡」从来不是线性变快,真正该优化的是那个不干活的部分。
这一节的落点也在这里:分布式的全部难点,都不在「算」,在「怎么把算连起来」。

七、动手 Demo:一台机器不够用的时候

五段:等价性、通信量、同步等待、分片搬迁、加速比天花板。 第 1 段是「数学上白送」,后四段是「工程上要付账」。 这一段的 Demo 里没有神经网络,也没有大数据——全部是算术。 因为分布式训练的瓶颈本来就不在算,把那笔账算清楚,比跑一个真集群有用得多。

def grad(idx):                        # 只算自己那一份样本的梯度
    Xi = Xb[idx]
    p = 1.0 / (1.0 + np.exp(-Xi.dot(w)))
    return Xi.T.dot(p - Y[idx]) / len(idx)

G_ALL = grad(np.arange(N))             # 单机:全部样本一次算完
G_SUM = np.zeros(D + 1)
for pp in PARTS:                       # 八台机器:各算一份
    G_SUM += grad(pp) * (len(pp) / float(N))   # 关键:按样本数加权

# 通信量:同一份权重,两种汇总方式差一个卡数
ps = 2.0 * BYTES * k                   # 参数服务器:星形,中心扛全部
ring = 2.0 * (k - 1) / k * BYTES       # 环形全归约:只跟左右邻居传

# 同步一轮 = 每卡算 + 那一次最长的耽搁;加速比 = 单卡时间 ÷ k 卡时间
WAIT = dict((k, delay(k)) for k in KS) # 每档卡数都量一遍「最长耽搁」
T1 = base + WAIT[1]
for k in KS:
    real = T1 / (base / k + WAIT[k])   # 天花板 = base ÷ 耽搁上限

实跑输出:

--------------------------------------------------------------------
一台机器不够用的时候
--------------------------------------------------------------------
==============================================================

[1] 数据并行:把「算」拆开,数学上一点没变

  训练一版逻辑回归。全量梯度是**所有样本的梯度求和再平均**,
  而求和可以拆:**八台机器各算一份、再把八份平均起来,结果应该完全一样。**

    全量梯度算一遍,跟「八份加权平均」比:
    最大分量差 3.053e-16
    梯度本身的量级 2.117e-01

  **差在 1e-16 量级,就是浮点数的舍入误差。**也就是说数据并行不是「近似」,
  **它在数学上跟单机全量完全等价**——前提是每一份都按样本数加权。
  如果直接「八份梯度简单平均」,那就不等价了:
  **每台机器分到的样本数不一样,简单平均等于给样本少的机器偷偷加权。**
  这一点在小数据上无所谓,**在上万张卡的集群里就是会拖慢收敛的实打实的 bug。**

[2] 通信量:分布式真正的瓶颈

  假设模型有一百万个参数(float32)。**一份权重 = 3.8 MB。**

    做法                      每轮传输总量      怎么算的
    ------------------------------------------------------------------------
    参数服务器,8 卡          61.0 MB           2 × 权重 × 8
    环形全归约,8 卡          6.7 MB            2 × (n−1)/n × 权重
    参数服务器,64 卡         488.3 MB          2 × 权重 × 64
    环形全归约,64 卡         7.5 MB            2 × (n−1)/n × 权重

  **环形全归约的漂亮之处:总量跟卡数几乎无关。**8 卡 6.7 MB,64 卡 7.5 MB——
  **它每张卡只跟左右邻居传两次,转一圈就齐了。**
  参数服务器是星形,卡越多,中心那台的带宽压力越大。

  一百二十亿参数的大模型呢?**一份权重 48 GB,八卡每轮要传 84 GB。**
  **而这个传输每一轮都要做一次,一轮的算只花几百毫秒。**
  **这就是为什么大模型训练里,通信优化比算子优化更值钱。**

[3] 同步训练的代价:每轮都在等最慢的那张卡

  一轮的活,单卡要算 30 秒;k 张卡平分,每张只算 30/k 秒。
  但每张卡都可能被耽搁——GC 停顿、抢 CPU、网络抖一下,最多 2 秒。
  **同步训练要求所有卡都算完才开下一轮,所以一轮的时间 = 最慢那张的时间。**

  卡数    每卡要算      最长耽搁      一轮总耗时      耽搁占比
  ----------------------------------------------------------------
  1       30.00 秒      0.98 秒       30.98 秒        3%
  8       3.75 秒       1.78 秒       5.53 秒         32%
  64      0.47 秒       1.97 秒       2.44 秒         81%
  512     0.06 秒       2.00 秒       2.05 秒         97%

  **512 卡那一轮 2.05 秒,其中 2.00 秒在等一张被耽搁的卡——**
  **而它自己只算了 0.06 秒。**算得越少,等得越显得长。

  **这就是异步训练、弹性训练存在的理由:宁可各走各的,也不等。**
  **但异步也有代价——每张卡用的权重版本不一样,学出来的东西会「陈旧」。**
  **所以真实系统里常常是「允许落后 s 步」的折中,而不是完全放开。**

[4] 分片:加一台机器,要搬多少数据

  集群里常常要按 key 把数据分到不同节点。
  **最直觉的做法是取模:节点号 = 哈希 % N。**
  它有个致命毛病:**N 一变,几乎所有的 key 都要换位置。**真算一遍:

    取模:8 个节点加到 9 个节点,搬动 88.5% 的 key
    一致性哈希:同样的扩容,搬动 11.4% 的 key

  **取模搬了 89%,一致性哈希只搬了 11%——理论上应该接近 1/9 = 11.1%。**
  **取模的「节点号」几乎每个 key 都被改了。**
  **一致性哈希只把「新节点接管的那一段环」上的 key 挪走,别的都不动。**

    顺带看一眼负载均衡(一致性哈希,每节点 120 个虚拟节点):
    最忙 2955 个 key,最闲 2052 个 key,比值 1.44
  **虚拟节点就是用来压这个比值的**:物理节点只在环上放一个点,分得很不均;
  **一个物理节点撒 120 个点,负载就摊平了。**

[5] 加速比的天花板

  拿第 3 段的账算「加卡到底换多少速度」。
  **加速比 = 单卡一轮时间 ÷ k 卡一轮时间。**

  卡数    理论加速比      实际加速比      并行效率
  ----------------------------------------------------------
  1       1.00 倍         1.00 倍         100.0%
  2       2.00 倍         1.90 倍         94.8%
  4       4.00 倍         3.40 倍         85.1%
  8       8.00 倍         5.60 倍         70.0%
  16      16.00 倍        8.25 倍         51.5%
  64      64.00 倍        12.71 倍        19.9%
  256     256.00 倍       14.69 倍        5.7%
  512     512.00 倍       15.08 倍        2.9%

  **从 1 卡加到 512 卡,理论 512 倍,实际只有 15.1 倍。**
  **注意它最后稳住了,不再往下掉——因为耽搁时间有上限 2 秒,不会再涨。**
  **天花板 = 单卡 30 秒 ÷ 耽搁上限 2 秒 = 15.0 倍**,再多卡也超不过。
  **换成「串行占比」的说法:耽搁 2 秒占单卡 30 秒的 6.7%。**
  **按阿姆达尔定律,上限就是 15.0 倍**——这就是「堆卡有尽头」的算术原因。

  五段连起来:**数据并行在数学上白送**,但要付通信费;
  **同步还要等最慢的那张**,分片不当还要搬数据;
  **加速比最后被耽搁时间卡住。**
  **分布式的全部难点,都不在「算」,在「怎么把算连起来」。**

第 1 段那个 3.053e-16 跟 2.117e-01 差着 15 个数量级,这是本节的地基。 它说明数据并行算出来的东西跟单机是同一个数——所以后面所有的问题都不在「算得对不对」。 第 2 段那张表要横着读:参数服务器那两行从 61.0 涨到 488.3(八倍卡、八倍量), 环形全归约那两行从 6.7 只到 7.5。同一个任务, 两种汇总量差 65 倍——这就是拓扑结构的价值。 第 3 段最扎眼的是 97% 那个数。512 卡的每一轮,97% 的时间在等一张被耽搁的卡。 而这张卡其实只干了 0.06 秒的活。这不是浪费,是同步这一条规矩的必然结果。 第 4 段那个 11.4% 对 11.1%(理论值)很值得看。 它说明一致性哈希不是「大概省一点」, 而是精确地只搬「该搬的那一段」——被抢走的那一段正好是新节点的份额 1/9。 第 5 段那个 15.08 倍是整节最该记住的数。 算它只用了两个数:单卡 30 秒、耽搁上限 2 秒,30 ÷ 2 = 15。 下次看到「我们用了 512 张卡」,就该反问一句:单卡一轮刷多久、最慢那张拖多久?

30 秒实验

  1. 把第 3 段的 jit 从 2.0 改成 0.5,重跑第 5 段。 天花板会从 15 倍抬到 60 倍——这就是「把最长耽搁压下去」为什么比「多买卡」值钱。
  2. 把第 4 段的 vnode 从 120 改成 1,重跑负载均衡那两行。 最忙比最闲的比值会明显变大——这一改就说明了虚拟节点不是装饰,是必需品。
  3. 把第 2 段那个 P 从一百万改成十亿,重跑通信量那四行。 参数服务器会到几百 GB 量级, 而环形全归约只翻一千倍——这个差距就是「为什么大模型一定要用环」的答案。

常见误区

  1. 以为「卡多就一定快」。Demo 第 5 段:512 卡的效率只有 2.9%, 从 1 卡加到 512 卡,真正到手的只有 15.1 倍。
  2. 以为分布式的主要成本是「算得慢」。算的东西跟单机一模一样(差 1e-16), 成本全在通信和等待上。
  3. 以为「梯度直接取平均」最简单。八份样本数不一样时,简单平均等于给样本少的卡加权, 必须按样本数加权。这个 bug 不报错,只让效果差一点。
  4. 以为异步训练是免费的加速。它省掉的是等待, 付出的是「陈旧梯度」——每张卡拿着不同版本的权重在算,学出来的东西会偏。
自测三问(点开对答案) 1. 为什么说「数据并行在数学上是白送的」?这个结论有什么用? —— 因为**求和可以拆**:八份样本各算一份梯度、再按样本数加权平均, **结果跟全量梯度在数学上完全相等**(Demo 实测最大分量差 3.053e-16, 而梯度量级是 2.117e-01)。 **它的用处是把问题挪走**:既然算出来是同一个数, **那么「分布式慢」就只能是通信和等待造成的**,**不用再怀疑算法本身。** 2. 为什么参数服务器和环形全归约的通信量差了 65 倍? —— **差在图的结构。**参数服务器是星形,**每张卡都要跟中心传,中心那台的带宽就是总瓶颈**, 总量是 2 × 权重 × 卡数,**随卡数线性涨**; 环形全归约每张卡只跟左右邻居传两次,总量是 2 × (n−1)/n × 权重, **n 越大越接近「权重的两倍」,跟卡数几乎无关。**Demo 里 8 卡是 61.0 MB 对 6.7 MB, 64 卡是 488.3 MB 对 7.5 MB。 3. 跑 512 张卡只能快 15 倍,这个 15 是怎么来的? —— **它是「不随卡数缩小的那部分」决定的。**Demo 里单卡一轮算 30 秒, **而每张卡都可能被耽搁最多 2 秒,卡越多「最长那次耽搁」越接近 2 秒。** **天花板 = 30 ÷ 2 = 15。**换成阿姆达尔定律:耽搁占单卡的 6.7%,1 ÷ 0.067 = 15。 **所以想突破这个天花板,唯一的办法是把那 2 秒压小,不是继续买卡。**

第 8 章到这儿结束。这一章三节其实是一条线:8.1 说「一个模型不够就养一群」, 8.3 说「一个任务数据不够就借别的任务」,8.4 说「一台机器不够就分到很多台」。 三个「不够」,三种借法。 下一章进入深度学习——那里的「不够」变成了另一件事:手工设计的特征不够用了。

相关笔记