这一章回答一个很实际的问题:一台机器算不完,怎么办。 答案不是「多买几台」,而是先看这件事能不能拆开。 四节从一行代码讲到一整层调度:拆法、硬件、软件、平台。
上一章把知识拆成表和三元组,这一章换到机器那一侧。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 秒实验
- 把 N 从 400000 改成 40000,重跑第 2 段。 加速比会明显缩水——活变少了,开进程那笔固定开销摊不开,效率一栏会掉得很难看。
- 把第 3 段的线程改成「每个线程 sleep 一秒」,重跑。 这次线程真能并行——sleep 不占 GIL。GIL 挡的是计算,不挡等待。
- 拿第 4 段倒推的 28.3% 去预测 2 进程:1 除以 0.283 加 0.717 的一半,约 1.56 倍; 实测 1.59 倍。两个数对得上,说明这个模型在这一档是可信的。
常见误区
- 以为「多开几个进程就是分布式」。分布式多出来的那件事是「机器之间隔着网络」, 通信的代价可能比算的还大——所以才有「能不加机器就不加」这一说。
- 以为「核越多越快」。阿姆达尔那条曲线告诉你上限在哪。 八核之后再加到六十四核,多数任务翻不动一倍。
- 以为「Python 线程能加速计算」。GIL 之下纯计算的活开线程等于没开,实测 1.00 倍。 要并行就得开进程,或者把计算交给不占 GIL 的库。
- 以为「并行一定比串行快」。切太细、活太小、开销占比高, 都可能更慢——第 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 讲的是「怎么拆」。 下一节换到机器本身:这台机器的马力到底有多大,瓶颈在算还是在搬。 拆得再漂亮,单机不够也是白搭。
- ☐ (本节完,下一节 13.2 硬件配置)