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

这一章回答一个很实际的问题:一台机器算不完,怎么办。 答案不是「多买几台」,而是先看这件事能不能拆开。 四节从一行代码讲到一整层调度:拆法、硬件、软件、平台。

📝 本节和前面几节的关系

上一章把知识拆成表和三元组,这一章换到机器那一侧。12.1 讲「数据怎么存」, 这一章讲「算力怎么攒」。两者其实是同一条纪律:把一个大东西切成互不依赖的小块。 8.4 讲过分布式人工智能, 那边说的是「多个智能体怎么分工」,这边说的是「多个 CPU 怎么分活」。

一、三个词,三种问题

并行、并发、分布式,日常说话常混着用,可它们解决的是三个不同的麻烦:

说法 一句话 典型场景
并行 同一时刻真的在同时算 多核跑矩阵乘
并发 交替推进,看起来同时在跑 单核上切换多个任务
分布式 多台机器通过网络协作 一批服务器训练一个大模型
并行要的是核,并发要的是不阻塞,分布式要的是「一台真不够」。
这一节的实验全在一台机器上做,严格说只能叫并行。
但分布式只是在并行外面多包了一层网络——多出来的那层账叫通信,13.2 会算它的量。

二、拆得开,才有加速比

Demo 挑的活是数质数:2 到 400000 之间有多少个质数。 选它是因为它笨得干净——没有文件、没有网络,时间全花在算上。 串行跑一遍:33860 个质数,0.913 秒。这个数就是后面所有加速比的分母。

进程数 耗时 加速比 效率
2 0.575 秒 1.59 倍 79%
4 0.376 秒 2.43 倍 61%
8 0.340 秒 2.68 倍 34%
八个进程跑出 2.68 倍,效率只剩 34%。这一列才是真相:多出来的核,一大半在等。
等什么?每片算完要把结果送回来,主进程还得把它们加起来。
这部分活谁也帮不上忙,它不随进程数变少。

三、阿姆达尔:那点串行决定上限

如果有一部分工作必须串行(汇总、写结果),占比 s, 那么 p 个核的理论上限就是 1 除以 s 加上 (1-s) 除以 p。

串行占比 s 八核上限
0% 8.00 倍
1% 7.48 倍
5% 5.93 倍
10% 4.71 倍
20% 3.33 倍
50% 1.78 倍
s 只占一成,八核的天花板就从 8 倍掉到 4.71 倍。
这就是为什么并行程序总在抠那「一点点串行」——它定上限,加机器救不回来。
反过来拿实测值倒推:2.68 倍对应 28.3% 的串行占比。
可这段代码里没有一行是「必须串行」的——
这 28.3% 全是开进程、分任务、回传结果、最后加总的开销。

四、分片不是越细越好

那把片切得更细,是不是更快?不会。 每片变小,可每片的固定开销不变:一次进程启动、一次任务分发、一次结果回传。

进程数 每片个数 纯计算 含启动 启动开销
2 200000 0.584 秒 1.252 秒 0.668 秒
4 100000 0.424 秒 1.071 秒 0.647 秒
8 50000 0.344 秒 1.369 秒 1.026 秒
16 25000 0.681 秒 2.442 秒 1.761 秒
32 12500 1.162 秒 4.468 秒 3.305 秒
「纯计算」那一列到 8 片见底(0.344 秒),再往上就回升了。
Windows 上开一个进程要重新加载整个解释器,将近 0.3 秒;
片里只剩一万多个数的时候,算的时间已经压不住这笔开销。
并行有下限:活小到不值得拆,就别拆。
这一条比「核越多越快」有用得多,因为它是真的会让人白忙一场。

五、动手 Demo:一件事拆给多个人做,要看它能不能拆

五段:串行打底、2/4/8 个进程、同样的活交给线程、阿姆达尔上限、分片越细越慢。 没有用任何分布式框架。开进程用标准库,切分就是算除法。这样每一笔开销都看得见、数得出来。

def jobs_for(p):                  # 把 2 到 N 切成 p 段,一段一个进程
    step = (N - 2) // p + 1
    return [(2 + i * step, min(N, 2 + (i + 1) * step)) for i in range(p)]


def run_pool(p):                  # 建池、预热、再计时
    pool = mp.Pool(p)             # 开 p 个独立的解释器
    pool.map(count_range, [(2, 3)])
    t0 = time.perf_counter()      # 预热之后才开始计时
    parts = pool.map(count_range, jobs_for(p))
    t1 = time.perf_counter()
    pool.close()
    pool.join()
    return parts, t1 - t0

实跑输出:

--------------------------------------------------------------------
一件事拆给多个人做,要看它能不能拆
--------------------------------------------------------------------
==============================================================

[1] 先串行跑一遍,当作标尺

  任务:数 2 到 400000 之间有多少个质数(纯 Python 试除,故意选计算密集的活)。
  计时只算「算」的那段;开进程的固定开销先预热掉,第 5 段再把它加回来。

  串行结果:33860 个质数,耗时 0.913 秒。

  **这个数就是后面所有加速比的分母。**

[2] 拆给 2、4、8 个进程

  每块算一段,结果回来再相加。进程是真的分开的(各有各的解释器):

    进程数    耗时(秒)    加速比      效率
    2         0.575         1.59 倍     79%
    4         0.376         2.43 倍     61%
    8         0.340         2.68 倍     34%

  **结果和串行一模一样(都对上了),说明拆分没算错。**
  **加速比也不是线性的:8 个进程 2.68 倍,效率只有 34%。**
  **每片算完要把结果送回来,主进程还要合并——这部分活谁也帮不上忙。**

[3] 同样的活交给线程:几乎不动

  Python 有个全局解释器锁(GIL),同一时刻只有一个线程能执行字节码。
  所以纯计算的活,开线程等于没开:

    方式          耗时(秒)    加速比    说明
    4 个线程      0.892         1.02 倍   被 GIL 串起来了
    8 个线程      0.915         1.00 倍   被 GIL 串起来了

  **8 个线程 1.00 倍,8 个进程 2.68 倍。**
  **同一台机器、同一份代码,差别只在「谁真的同时在算」。**

  **线程版和串行几乎一样快,甚至有时还慢一点。**
  **GIL 下线程数再多也没用:真正在算的永远只有一个。**

[4] 阿姆达尔:有多少活根本没法拆

  假设有一小部分工作必须串行(读文件、汇总、写结果),占比 s。
  8 核机器上,理论加速比的上限是 1 / (s + (1-s)/8):

    串行占比 s    理论上限      8 进程实测
    0%            8.00 倍       2.68 倍
    1%            7.48 倍       2.68 倍
    5%            5.93 倍       2.68 倍
    10%           4.71 倍       2.68 倍
    20%           3.33 倍       2.68 倍
    50%           1.78 倍       2.68 倍

  **s 只占一成,八核的天花板就从 8 倍掉到 4.71 倍。**
  **这就是为什么并行程序总在追那「一点点串行」——它决定上限,加机器救不回来。**

  反过来用实测值反推:8 进程拿到 2.68 倍,相当于有 28.3% 的活是串行的。
  **可这段代码里没有一行是「必须串行」的。**
  **这 28.3% 全是进程启动、任务分发、结果回传、最后加总的开销。**

[5] 分片不是越细越好

  进程数继续往上加,每片变小,可每片的固定开销不变。
  这次连「开进程」的时间一起算进去,看真实的墙钟时间:

    进程数    每片个数    纯计算      含启动      启动开销
    2         200000      0.584       1.252       0.668 秒
    4         100000      0.424       1.071       0.647 秒
    8         50000       0.344       1.369       1.026 秒
    16        25000       0.681       2.442       1.761 秒
    32        12500       1.162       4.468       3.305 秒

  **「纯计算」那一列到 8 片见底(0.344 秒),再往上反而回升——**
  **因为分片太细,任务分发本身也成了开销;**
  **加上 Windows 上开一个进程要重新加载整个解释器,一个就将近 0.3 秒。**
  **每片只剩一万多个数的时候,算的时间已经压不住这笔开销。**
  **并行有下限:活小到不值得拆,就别拆。**

  五段连起来:**串行有个标尺,才知道快了多少**;
  **进程真并行,线程被 GIL 串起来**;
  **加速比的天花板由那点串行决定**;
  **分片有固定开销,切太细反而更慢**;
  **所以「分布式」不是多开几个进程,是让活真的能拆开。**

第 1 段那个 0.913 秒比什么都重要。没有标尺,「快了 2.68 倍」这句话没有意义。 第 3 段最该记住:4 个线程 1.02 倍、8 个线程 1.00 倍。同一台机器、同一份代码, 差别只在「谁真的同时在算」。 第 4 段那张表可以把 s 当旋钮拧:s 从 0 拧到 50%,上限从 8 倍掉到 1.78 倍。 注意最后一行:就算一半的活能并行,八核也只值 1.78 倍。 第 5 段「启动开销」那一列一直在涨,从 0.668 秒涨到 3.305 秒。 这就是为什么真实的并行程序会先问一句「这批活有多大」,再决定切几片。

30 秒实验

  1. 把 N 从 400000 改成 40000,重跑第 2 段。 加速比会明显缩水——活变少了,开进程那笔固定开销摊不开,效率一栏会掉得很难看。
  2. 把第 3 段的线程改成「每个线程 sleep 一秒」,重跑。 这次线程真能并行——sleep 不占 GIL。GIL 挡的是计算,不挡等待。
  3. 拿第 4 段倒推的 28.3% 去预测 2 进程:1 除以 0.283 加 0.717 的一半,约 1.56 倍; 实测 1.59 倍。两个数对得上,说明这个模型在这一档是可信的。

常见误区

  1. 以为「多开几个进程就是分布式」。分布式多出来的那件事是「机器之间隔着网络」, 通信的代价可能比算的还大——所以才有「能不加机器就不加」这一说。
  2. 以为「核越多越快」。阿姆达尔那条曲线告诉你上限在哪。 八核之后再加到六十四核,多数任务翻不动一倍。
  3. 以为「Python 线程能加速计算」。GIL 之下纯计算的活开线程等于没开,实测 1.00 倍。 要并行就得开进程,或者把计算交给不占 GIL 的库。
  4. 以为「并行一定比串行快」。切太细、活太小、开销占比高, 都可能更慢——第 5 段 32 片的含启动时间是串行的四倍多。
自测三问(点开对答案) 1. 为什么 8 个进程只有 2.68 倍? —— 因为**有一部分活没法分:开进程、分发任务、回传结果、最后加总**。 **按阿姆达尔倒推,相当于 28.3% 的串行占比。** **核再多也压不掉这一块,它决定上限。** 2. 线程为什么不行? —— 因为**GIL 让同一时刻只有一个线程能执行字节码**。 **Demo 第 3 段:8 个线程 1.00 倍,8 个进程 2.68 倍。** **要真并行只能开进程,或者用底层释放了 GIL 的库。** 3. 分片为什么不能太细? —— 因为**每片有固定开销,片越小这笔开销占比越大**。 **第 5 段到 8 片就见底,16、32 片反而回升。** **并行的前提是「每片的活远大于分它所需的代价」。**

13.1 讲的是「怎么拆」。 下一节换到机器本身:这台机器的马力到底有多大,瓶颈在算还是在搬。 拆得再漂亮,单机不够也是白搭。

相关笔记