性能与 Amdahl 定律
1. 双核变四核,为什么没快 4 倍
老板说"加机器就能快",于是核心数从 1 涨到 4、到 64。可你一测:1 核跑 100 秒的任务,4 核跑了 30 秒——不是 25 秒;64 核竟然还有 20 秒。钱花出去了,倍数却越来越"不划算"。
这不是编译器偷懒,而是一条冷冰冰的物理/数学上限在起作用——Amdahl 定律。它回答的是:一个程序里只有一部分能并行,整体到底最多能快多少?
本质一句话:程序里跑不并行的那一块,决定了它再怎么加核也快不到哪去。
2. 加速比:先定义"快了多少"
记:
- 原串行总耗时
T₁(1 个核心跑完)。 - 并行化比例
P(0~1):原本可以并行执行的那部分时间占比。 - 串行比例
1 − P:怎么都并行不了的硬骨头(初始化、I/O、临界区、依赖链)。 - 用
N个核心跑,并行部分耗时按N等分缩成P·T₁ / N,串行部分纹丝不动,仍是(1 − P)·T₁。
于是 N 核总耗时:
1 | T(N) = (1 − P)·T₁ + P·T₁ / N |
加速比(speedup) 定义为"省了多少倍时间":
1 | S(N) = T₁ / T(N) = 1 / ( (1 − P) + P / N ) |
这就是 Amdahl 定律的标准形式。N → ∞ 时分母里 P/N → 0,加速比被串行部分钉死:
1 | S_max = lim S(N) = 1 / (1 − P) |
只要还有 5% 的代码跑不了并行,你再堆 1000 个核,整体最快也只能快 20 倍。 这就是定律最扎心的结论。
3. 一张图看收敛(数据真实可复算)
下面这张曲线,是把上面公式按不同 P 画出来:X 轴是核数 N(对数刻度),Y 轴是加速比。注意蓝线(P=50%)几乎贴着地面——一半代码跑不了并行,加核基本白加;红线(P=99%)起初猛涨,但到 128 核也才 ~56 倍,离 128 差得远。
同口径的数值表(行=并行比例 P,列=核数 N;右上角是串行部分钉死的上限 1/(1−P)):
| P \ N | 1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 |
|---|---|---|---|---|---|---|---|---|
| 50% | 1.00 | 1.33 | 1.60 | 1.78 | 1.88 | 1.94 | 1.97 | 1.98 |
| 90% | 1.00 | 1.82 | 3.08 | 4.71 | 6.40 | 7.80 | 8.77 | 9.34 |
| 95% | 1.00 | 1.90 | 3.48 | 5.93 | 9.14 | 12.55 | 15.42 | 17.41 |
| 99% | 1.00 | 1.98 | 3.88 | 7.48 | 13.91 | 24.43 | 39.26 | 56.39 |
读这张表的三条直觉:
- P 决定天花板:P=50% 再怎么加核也到不了 2 倍;P=99% 理论上限是 100 倍。
- 越往后越不划算:从 32 核翻到 64 核,P=99% 只从 24.4 涨到 39.3(多了 15),而 1→2 核直接 1→1.98。并行收益是边际递减的。
- N 很大时曲线变平:因为分母里
P/N已经小到可以忽略,剩下全是(1−P)在说了算。
4. 可运行代码:自己复算这张表
下面这段 Python 把公式原样落地,跑出来就是上表的数字。你可以改 P 和 N 任意组合:
1 | #!/usr/bin/env python3 |
真实输出:
1 | S = 1 / ((1-P) + P/N) (行=并行比例 P,列=核数 N) |
5. 时间拆分:为什么串行部分"纹丝不动"
把一次运行的总时间画成一条横条——红色是死活并行不了的串行段,蓝色是能并行的段。加核时,红色那段的长度一点儿没变,只是蓝色被越切越薄:
看懂这张图,定律就不需要背了:加速比的瓶颈不是"你并了多少",而是"你还剩多少没法并"。
6. 反例与边界:Amdahl 不是唯一真相
Amdahl 有一个隐含前提——问题规模固定(总工作量不变,只是把它分掉)。可现实中常常是反过来:核多了,你就想算更大的问题。这就引出了它的"对偶"——Gustafson 定律(弱缩放)。
Gustafson 说:若随核心数 N 把问题规模同比放大,并行部分的时间也随之放大,串行部分占比被稀释。其缩放加速比为:
1 | S_weak(N) = N − (1 − N)·(1 − P) = P·N + (1 − P) |
当 P=0.95、N=128 时,Amdahl(强缩放)只给 17.4 倍,而 Gustafson(弱缩放)给 0.95×128 + 0.05 = 121.65 倍——几乎线性。这不是定律打架,而是两个不同假设:
| 假设 | Amdahl(强缩放) | Gustafson(弱缩放) |
|---|---|---|
| 问题规模 | 固定 | 随核数放大 |
| 问的是 | 同一任务最快多快 | 同样时间能算多大 |
| 上限 | 被串行部分钉死 1/(1−P) |
趋近 N(线性) |
| 典型场景 | 跑完一个已知任务省时间 | 天气预报、渲染,吃满算力算更细 |
所以别被"Amdahl 说并行没用"误导——它只在"任务大小不变"时成立。工程上两条都要看:想省时间看 Amdahl,想扩规模看 Gustafson。
7. 工程启示:先量,再并行
Amdahl 给开发者的最实用忠告,不是公式,而是方法论:
- 先 profile,再动手。没量化
P就盲目parallel_for,八成加速比难看。串行瓶颈常藏在 I/O、锁、初始化、依赖链里,肉眼看不出。 - 优化串行部分收益最高。把
1−P从 10% 压到 5%,上限从 10 倍跳到 20 倍——这比再买一倍机器划算。 - 并行收益边际递减,别无脑堆核。N 从 16→32 的边际加速,远低于 1→2。超出拐点后,加核的性价比骤降。
- 区分强/弱缩放定目标。要"省时间"就盯 Amdahl;要"算更大"就按 Gustafson 规划规模。
C++ / Java 对照:两个世界的并行原语都逃不开这条定律。Java 的 parallelStream() 看着一行就并行,但若中间有 synchronized 块或共享 mutable 状态,那部分就是 1−P;C++ 的 std::for_each(par, ...) 同理,临界区、false sharing 都会把 P 压低。定律不挑语言——它量的是"能真并行的时间占比"这个客观事实。
8. 对比表与小结
| 维度 | 结论 | 一句话 |
|---|---|---|
| 加速比公式 | S = 1 / ((1−P) + P/N) |
串行项 + 并行项被 N 除 |
| 极限上限 | S_max = 1/(1−P) |
5% 串行 → 最多 20 倍 |
| 收益曲线 | 边际递减 | 越往后加核越不划算 |
| 对偶定律 | Gustafson 弱缩放 S≈N |
问题放大时近线性 |
| 工程顺序 | 量 P → 压 1−P → 再并行 |
别无脑堆核 |
🐾 小结:
- Amdahl 定律:S(N) = 1 / ((1−P) + P/N),上限 1/(1−P)。跑不掉并行的那块,就是整体速度的天花板。
- 加速比随 N 边际递减;想"省时间"看强缩放 Amdahl,想"算更大"看弱缩放 Gustafson,两者假设不同、不矛盾。
- 工程上先 profile 量化 P,优先压缩串行部分
1−P,再考虑并行与加核——盲目parallel只是把蓝色段切开,红色段纹丝不动。 - 定律与语言无关:Java
parallelStream、C++ 并行算法,只要存在锁 / 共享状态 / I/O,那部分就是1−P。
相关阅读:上一篇《硬件、软件接口》讲"CPU 怎么访问设备",本篇讲"多核下整体能快多少"——一个是横向的接口边界,一个是纵向的性能边界。

