📘 第13章 分布式计算
📄 一句话概括

硬件的账只有三个数:算力、带宽、容量。 多数人只盯算力,可瓶颈常常在搬数据那一段。 这一节用一台没有显卡的机器,把「算力受限」和「带宽受限」分开算清楚。

📝 本节和前面几节的关系

13.1 讲怎么把活拆开,这一节讲单台机器的马力。拆得再漂亮,单机不够也是白搭。 上一节说「分布式多了一层网络」,这一节就把那层的账算出来: 多卡之间同步梯度走的是 PCIe,带宽只有十几 GB/s。

一、三个数,一个瓶颈

挑机器只看一个数,早晚会买错。硬件的账是三笔:

指标 单位 管什么
算力 FLOPS 每秒能算多少次
带宽 GB/s 每秒能搬多少字节
容量 GB 一次能装下多大的模型
这三个数里,只有一个卡住你。另外两个再高,也换不来速度。
麻烦的是:卡住你的是哪个,不看机器,看你要跑什么。
下面两段就是用来判断这件事的。

二、算力是怎么量出来的

办法很朴素:两个 512 乘 512 的单精度矩阵相乘。 一次要做 2 乘 n 的三次方次浮点运算,也就是 2.6844 亿次。 连跑二十次取最快:0.0011 秒。算下来 237.05 GFLOPS,也就是 0.24 TFLOPS。 GFLOPS 就是每秒十亿次浮点运算,这个数才是硬件的马力。

器件 算力 带宽 功耗
这台机器的 CPU 0.24 TFLOPS 约 50 GB/s 65 瓦
入门级独显 约 8 TFLOPS 约 300 GB/s 120 瓦
训练级加速卡 约 20 TFLOPS 约 900 GB/s 300 瓦
沙箱里没有显卡,后两行是厂商规格,不是实测。
写清楚这一点,比编一个漂亮数字诚实。
但量级足够说明问题:算力差几十倍,带宽差十几倍。
所以「买张显卡就快了」这句话,只在瓶颈真的在算的时候成立。

三、算术强度:算一次要搬多少

算术强度=浮点运算次数除以搬运的字节数。这个比值决定瓶颈在算还是在搬。

操作 运算次数 搬的字节 算术强度
矩阵乘 512 乘 512 2.6844 亿 3.15 MB 85.33 次/字节
向量加(逐元素) 0.0026 亿 3.15 MB 0.08 次/字节
逐元素比较大小 0.0026 亿 2.10 MB 0.12 次/字节
矩阵乘 85.33 次每字节,向量加只有 0.08 次——差一千倍。
前者搬一个数能算很多次,搬运被计算掩盖;后者搬一个数只算一次,时间全花在路上。
拿算力除以带宽,得到这台机器的平衡点:4.74 次每字节。
算术强度高过它,算力是瓶颈;低过它,带宽是瓶颈。
所以同一张卡,跑矩阵乘是「算力受限」,跑逐元素操作是「带宽受限」。
调优之前先算这一个比值,能省掉一大半瞎试。

四、搬运的时间账

训练时每个 batch 都要从主机内存拷到显存,走的是 PCIe 3.0 乘 16,实测约 12 GB/s。 为了比得清楚,假设一个轻量模型每张图约一亿次浮点运算——粗估,只为看比例。

batch 数据量 拷贝 CPU 算这批 拷贝占比 换 20 TFLOPS 卡
32 张 19.3 MB 1.61 毫秒 13.50 毫秒 10.6% 90.9%
128 张 77.1 MB 6.42 毫秒 54.00 毫秒 10.6% 90.9%
512 张 308.3 MB 25.69 毫秒 215.99 毫秒 10.6% 90.9%
拷贝占比三行都是 10.6%:数据量和计算量都随 batch 线性涨,比值不变。
所以调 batch 治不了这个瓶颈。能动的只有两件事:搬得更快,或者每张算得更多。
换成 20 TFLOPS 的卡,同一个比例跳到九成以上。
卡越快,数据管道越容易卡住——那些数据加载线程、预取、锁页内存,全是为这一行服务的。

五、动手 Demo:算得快不快,先看瓶颈在算还是在搬

五段:量 CPU 算力、显卡按规格算、算术强度与平衡点、PCIe 搬运的时间账、 配硬件时该问的三个问题。 没有显卡,所以第二段只能算不能量。 但「瓶颈在算还是在搬」这个问题,用 CPU 一样能讲透。

n = 512
A = np.random.default_rng(46).random((n, n)).astype(np.float32)
B = np.random.default_rng(47).random((n, n)).astype(np.float32)
best = 1e9
C = A @ B                         # 先空跑一次,把底层库初始化摊掉
for _ in range(20):               # 连跑二十次取最快
    t0 = time.perf_counter()
    C = A @ B
    t1 = time.perf_counter()
    best = min(best, t1 - t0)

flops = 2.0 * n ** 3              # 一次乘加算两次运算
print(u"%.2f GFLOPS" % (flops / best / 1e9))

实跑输出:

--------------------------------------------------------------------
算得快不快,先看瓶颈在算还是在搬
--------------------------------------------------------------------
==============================================================

[1] 先量一下这台机器 CPU 的浮点算力

  办法是最朴素的那个:拿两个 512 乘 512 的单精度矩阵相乘,数它做了多少次浮点运算。
  一次 n 乘 n 乘 n 的矩阵乘,浮点运算次数是 2 乘 n 的三次方。

    矩阵大小        512 乘 512
    一次运算量      2.6844 亿次浮点运算
    最快一次耗时    0.0011 秒
    算力            237.05 GFLOPS

  **GFLOPS 就是「每秒十亿次浮点运算」。这个数才是硬件的「马力」。**
  **注意这是单精度、单线程、靠 numpy 调底层库跑出来的,**
  **真实训练框架还能再快一些,但量级差不多。**

[2] 显卡的账:按规格算,不是实测

  沙箱里没有显卡,所以这一段只能算,不能量。**按厂商公布的规格:**

    器件                  单精度算力    显存带宽      功耗
    这台机器的 CPU        0.24 TFLOPS   约 50 GB/s    65 瓦
    入门级独显            约 8 TFLOPS   约 300 GB/s   120 瓦
    训练级加速卡          约 20 TFLOPS  约 900 GB/s   300 瓦

  **算力差几十倍,带宽也差十几倍。**
  **所以「买张显卡就快了」这句话,只在瓶颈真的在算的时候成立。**
  **下面两段就来看瓶颈到底在哪。**

[3] 算术强度:算一次要搬多少数据

  算术强度=浮点运算次数除以搬运的字节数。**这个比值决定瓶颈在算还是在搬。**

    操作                      运算次数      搬的字节    算术强度
    矩阵乘 512 乘 512         2.6844 亿     3.15 MB     85.33 次/字节
    向量加(逐元素)          0.0026 亿     3.15 MB     0.08 次/字节
    逐元素比较大小            0.0026 亿     2.10 MB     0.12 次/字节

  **矩阵乘是 85.3 次每字节,向量加只有 0.08 次——差一千倍。**
  **差别在这里:矩阵乘每搬一个数要算很多次,搬运可以被计算掩盖;**
  **向量加搬一个数只算一次,时间全花在搬运上。**

  用 CPU 的带宽(约 50 GB/s)和算力(237.05 GFLOPS)算一下「每字节能算几次才打平」:
    平衡点          4.74 次/字节
    矩阵乘          远高于平衡点,算得动
    向量加          远低于平衡点,等数据

  **所以同一张卡,跑矩阵乘是「算力受限」,跑逐元素操作是「带宽受限」。**
  **调优之前先算这个比值,能省掉一大半瞎试。**

[4] 数据从内存搬到显存要多久

  训练时每个 batch 都要从主机内存拷到显存。按 PCIe 3.0 乘 16 的实测带宽约 12 GB/s 算:
  为了比得清楚,假设一个轻量模型每张图约 1 亿次浮点运算(粗估,只为看比例)。

    batch     数据量      拷贝        CPU 算这批    拷贝占比    换 20 TFLOPS 卡
    32 张     19.3 MB     1.61 毫秒   13.50 毫秒    10.6%       90.9%
    128 张    77.1 MB     6.42 毫秒   54.00 毫秒    10.6%       90.9%
    512 张    308.3 MB    25.69 毫秒  215.99 毫秒   10.6%       90.9%

  **拷贝占比那一列三行都是 10.6%——数据量和计算量都随 batch 线性涨,比值不变。**
  **所以调 batch 治不了这个瓶颈。能动的只有两件事:搬得更快,或者每张算得更多。**
  **换成快几十倍的卡,同一个比例会跳到九成以上——这就是「卡越快,数据管道越容易卡住」。**
  **真实训练里那些数据加载线程、预取、锁页内存,全是为这一段服务的。**

[5] 配置硬件时该问的三个问题

  **第一问:瓶颈在算还是在搬?**
  拿算术强度和平衡点一比就知道。**逐元素多、矩阵乘少的模型,先加带宽不是加算力。**

  **第二问:卡和卡之间怎么连?**
  多卡训练时梯度要同步。**PCIe 走主机内存,带宽十几 GB/s;专用互联能到几百 GB/s。**
  **八个卡挤一条 PCIe,同步时间能把加速比吃掉一半——这就是 8.4 那道通信墙。**

  **第三问:内存和显存够不够装下模型?**
  一个十亿参数的模型,单精度要 4 GB,**加上梯度和优化器状态,实际要三到四倍。**
  **装不下就只能切分,而切分又要通信。三个问题是连在一起的。**

  五段连起来:**先量 CPU 的算力当标尺**;**显卡按规格算,差几十倍**;
  **算术强度决定瓶颈在算还是在搬**;**拷贝的时间会随卡变快而变得显眼**;
  **所以配硬件不是挑最贵的,是先把瓶颈找准。**

第 1 段那个 0.0011 秒是「最快一次」,不是平均。 矩阵乘这种活受系统干扰大,取最快才接近这机器真实的上限。 第 3 段表格第三列(搬的字节)最容易被忽略。 同样一批数,矩阵乘每个数要读好几遍,算术强度的差别就差在这个分母上。 第 4 段的「拷贝占比」三行一模一样,这是这一节的核心: 比例跟 batch 无关,只跟「每字节能算几次」有关。 最后一段那三个问题是有顺序的。 先问瓶颈、再问卡怎么连、最后问装不装得下——装不下才需要切分,切分才引出通信。

30 秒实验

  1. 把第 1 段的 n 从 512 改成 1024,重跑。 运算量涨八倍,耗时不涨八倍——盘子越大越能喂饱底层库,GFLOPS 会往上走一点。
  2. 把第 3 段的向量加换成「同一个数组连做一千次逐元素乘法」,重跑。 算术强度会抬上去,因为搬一次能算很多次——这就从带宽受限挪回了算力受限。
  3. 把第 4 段的带宽从 12 GB/s 改成 300 GB/s(相当于卡内显存带宽),重跑。 拷贝占比掉到 1% 以下,瓶颈重新回到算力上,这时候加算力才有意义。

常见误区

  1. 以为「GPU 一定比 CPU 快」。逐元素的小操作在 GPU 上可能更慢—— 数据搬过去的时间比算的时间还长,算术强度太低。
  2. 以为「算力翻倍时间就减半」。先看算术强度。带宽受限的活,算力翻十倍也一秒不省。
  3. 以为「batch 越大越好」。大 batch 不改变拷贝占比,还会吃掉显存、影响收敛。 它是权衡,不是越大越优。
  4. 以为「显存只装权重就够了」。梯度和优化器状态常要三到四倍空间。 装不下就得切分,而切分又要通信——三个问题是连在一起的。
自测三问(点开对答案) 1. 为什么同一张卡跑矩阵乘快、跑向量加慢? —— 因为**算术强度不同**。矩阵乘 85.33 次每字节,远高于平衡点 4.74,算力是瓶颈; **向量加 0.08,远低于平衡点,主要在等数据。** **同一个硬件,两种瓶颈。** 2. 拷贝占比为什么调 batch 治不了? —— 因为**数据量和计算量都随 batch 线性涨,比值不变**,三行都是 10.6%。 **要动它只能提高带宽,或者让每张图算得更多。** 3. 「平衡点」这个数怎么用? —— **拿模型里主要操作的算术强度和它比。** **高于它就加算力,低于它就加带宽。** **先算这一个比值,比上来就换卡靠谱得多。**

硬件配好了,接下来是装在上面的那层软件。 下一节:同一份代码为什么在两个人机器上跑出两种结果。 答案在依赖、版本和镜像里。

相关笔记