37.5 加速比与阿姆达尔定律
一句话
加机器只能加速可并行的那部分,串行占比 (1−p) 决定天花板 1/(1−p);先测出 p 再扩容,否则加的钱全花在排队上。
公式:三行推完
把单线程总时间归一化成 1,其中可并行部分占 p、串行部分占 1−p。n 个执行单元时:
T(n) = (1−p) + p/n 串行段谁也帮不上,并行段被 n 等分
S(n) = T(1)/T(n) = 1 / ((1−p) + p/n)
n→∞ 时 S → 1/(1−p) 这就是天花板,加多少机器都过不去
手推一遍。某个请求要 4 个步骤,其中 1 个必须串行(比如最后写一次单行汇总),每步耗时 t:
- p = 3/4 = 0.75,单线程总时间 4t。
- n 个执行单元:
T(n) = t + 3t/n,加速比S(n) = 4t / (t + 3t/n) = 4n/(n+3)。 - n=2 → 8/5 = 1.60;n=4 → 16/7 = 2.29;n=8 → 32/11 = 2.91;n=16 → 64/19 = 3.37;n→∞ → 4.00。
四步里只有一步串行,“75% 可并行”听起来很高,16 核也只拿到 3.37 倍。这一个例子就够看出:p 决定天花板,n 只决定你离天花板还有多远。
加速比矩阵
下表按 S(n) = 1/((1−p)+p/n) 逐格算出,保留两位小数(∞ 列即 1/(1−p)):
| p \ n | 1 | 2 | 4 | 8 | 16 | 64 | 256 | ∞ |
|---|---|---|---|---|---|---|---|---|
| 0.50 | 1.00 | 1.33 | 1.60 | 1.78 | 1.88 | 1.97 | 1.99 | 2.00 |
| 0.75 | 1.00 | 1.60 | 2.29 | 2.91 | 3.37 | 3.82 | 3.95 | 4.00 |
| 0.90 | 1.00 | 1.82 | 3.08 | 4.71 | 6.40 | 8.77 | 9.66 | 10.00 |
| 0.95 | 1.00 | 1.90 | 3.48 | 5.93 | 9.14 | 15.42 | 18.62 | 20.00 |
| 0.99 | 1.00 | 1.98 | 3.88 | 7.48 | 13.91 | 39.26 | 72.11 | 100.00 |
三个读数,一个结论
- p=0.9,n=16 只到 6.40 倍,天花板 10.00。16 核买到了理论值的 64%,剩下 36% 卡在那 10% 的串行段上。
- p=0.9,n 从 16 加到 256(机器多 16 倍):6.40 → 9.66,加速比只多了 3.26(绝对量),相对涨幅 51%。顺带纠一个常被引错的数:说“16 到 256 只多 1.2 倍”的那组对应 p≈0.95(9.14 → 18.62,正好翻倍),不是 p=0.9。
- p=0.99,n=256 才 72.11 倍,还没摸到 100。串行只剩 1%,依然要 256 个执行单元才走完 72% 的天花板。
结论:p 比 n 重要。 把 p 从 0.90 提到 0.95,16 核下 6.40 → 9.14(+43%);靠加机器拿到同样的 43%,要从 16 加到 96 台(1/(0.1+0.9/96)=9.14)。而削串行段往往只是改一次锁粒度或加一次批量提交。
🔑 扩容前先问一句:现在的 p 是多少,测过吗?没测过,容量规划就是许愿。
并行化降 s 不降 n:给服务粒度那条结论补数学
服务粒度里有个结论:并行化降低串行跳数 s,但不降低必须成功的服务数 n,所以延迟降、可用性 p^n 不动。Amdahl 正好给出这个结论的数学形式。
设一条关键路径有 s 跳、每跳 t。把其中 k 跳改成并行(扇出 k 路,只算一跳):
延迟 = (s − k + 1) × t
加速比 = s / (s − k + 1) k = s 时上限 = s
可用性 = p^n n 不变,一点没动
把每个“跳”当成一个执行单元,只有最后归并那 1 跳必须串行,于是 1−p = 1/s、天花板 1/(1−p) = s——并行化的收益上限就是串行跳数 s(见 30.2 服务粒度)。这也解释了为什么“再并行一次”经常没有收益:s 已经降到 2,天花板就是 2 倍,继续扇出只是把尾延迟放大。
怎么测 p:两条路
路线 ① 固定负载,测不同并发下的吞吐,反推 p
由 1/S = (1−p) + p/n = 1 − p(1 − 1/n) 解出:
p = (1 − 1/S) / (1 − 1/n) = n(S−1) / ((n−1)S)
n=2: p = 2(S−1) / S
n=4: p = 4(S−1) / (3S)
两个点各解一次,得到的 p 应当接近;差得远就说明模型不成立——要么存在 Amdahl 之外的协调开销,要么测量被噪声污染。要更稳就做固定截距的最小二乘:令 x_i = 1 − 1/n_i、y_i = 1/S_i,模型是 y = 1 − p·x,于是
p = Σ x_i(1 − y_i) / Σ x_i²
下面代码里 p 真值 0.8,由 S(2)=1.68 反推 0.808、由 S(4)=2.51 反推 0.802,两个估计相差 0.006,说明模型成立。
路线 ② 直接量串行段
单次请求按阶段打点,串行段累计耗时 ÷ 总耗时 = 1−p。麻烦在于串行段常常不是“一段代码”,而是“等锁 / 等 DB / 等 fsync”——打点会把等待算进去,而等待本来就是串行,这正是想要的效果。路线 ① 测的是结果,路线 ② 测的是构成,两者对不上时以 ① 为准。
古斯塔夫森:规模随核数长
Amdahl 假设问题规模固定;Gustafson 反过来假设时间固定、规模随 n 增长:
S = n − (1−p)(n−1)
p=0.9, n=16 → 16 − 0.1×15 = 14.5
同样 p=0.9、16 个执行单元,Amdahl 给 6.40,Gustafson 给 14.5。超算能接近线性,是因为算例规模跟着核数一起涨,串行段的绝对耗时没变、占比被稀释了。
在线服务不适用:请求量不会因为你加了机器就自动变大,规模是固定的,Amdahl 才是对的模型。误用 Gustafson 做容量规划,等于假设“扩容之后业务量会自动涨上来把机器填满”。
USL 一句
节点多到一定程度,跨节点一致性开销(κ 项)会让吞吐掉头向下,峰值在 32 节点附近那条曲线见 37.1 垂直扩展与水平扩展;Amdahl 只有串行争用(σ)这一项,解释不了掉头。
串行段通常是什么,怎么削
| 串行段 | 典型形态 | 怎么削 | 量级 |
|---|---|---|---|
| 锁 | 全局互斥、行锁、分布式锁 | 缩小临界区、分段锁、无锁 CAS、按 key 分片 | 争用从 O(n) 降到 O(1),p 提升最直接 |
| 单主 DB 写 | 库存扣减、余额更新、单行计数 | 分片、批量合并、异步化 + 最终一致 | 单行热点从每秒几千次到按片线性增长 |
| 全局计数器 | 自增 ID、限流器 | 号段模式一次领 1000 个、分片计数后汇总 | 一次网络往返换 1000 次本地自增 |
| 日志 fsync | 每条提交刷一次盘 | 组提交(group commit)、批量落盘 | 吞吐提升一个数量级,单条延迟略升 |
| 协调开销 | 共识、跨节点 RPC、缓存同步 | 本地缓存、减少跳数、批量合并 | 只能降不能消,属于 κ 项 |
这五类里,前四类才是 Amdahl 的 1−p,第五类是 USL 的 κ:前者决定天花板,后者决定过峰之后掉多快。优化手段的细节见 34.4 优化手段。
100ms 的接口:Amdahl 算的是吞吐,不是单请求延迟
一个接口 100ms,其中 20ms 是串行(单行更新加锁),p = 0.8:
| n | T(n) = 20 + 80/n | 加速比 |
|---|---|---|
| 1 | 100ms | 1.00 |
| 2 | 60ms | 1.67 |
| 4 | 40ms | 2.50 |
| 8 | 30ms | 3.33 |
| 16 | 25ms | 4.00 |
| ∞ | 20ms | 5.00 |
16 个执行单元是 4.00 倍、25ms,不是 3.05 倍、32.8ms。3.05 倍对应的是有效并行度 6.26:1/(0.2+0.8/6.26) = 3.05,100/3.05 = 32.8ms。也就是说“名义 16 个线程,实际只有 6.26 个在干活”,多出来的在等锁、等调度、等 GC。这个数不是错的,但它描述的是有效的 n,不是“买了 16 台机器”。
更要紧的是第二层:这 4 倍是吞吐的 4 倍,不是单请求延迟的 4 倍。
- 吞吐视角(16 个副本,各自处理完整请求):单副本 10 QPS,16 个副本理论上 160 QPS,但串行资源每秒只放行
1/0.02 = 50个请求,实测会卡在 50 QPS 附近——5 倍,不是 16 倍。这个 5 倍正好是天花板1/(1−p)。 - 协作视角(16 个执行单元一起处理同一个请求):RT 才是上表的 25ms,单槽吞吐 40 QPS,也就是 Amdahl 的 4 倍。
- 两个视角不矛盾:4 倍是“协作处理一个请求”的加速比,5 倍是“串行资源被压满”的吞吐上限。而无论哪个视角,单个请求自己那 20ms 串行段都没变;副本数上来后还要加排队时间,P99 反而更差。
所以“加 16 台机器,接口从 100ms 变 25ms”这句话在两种情况下都不成立:无状态服务加副本,每个请求仍是 100ms,涨的是并发数;请求内部切 16 份并行,则要先付切分与归并的钱,而归并那一步本身就是串行。加机器买的是并发度,不是单次速度——单次速度只能靠削串行段(异步化、批量、缓存)换(延迟与吞吐两套指标的区别见 34.1 性能指标,排队模型见 34.2 性能建模)。
可运行的实测:同步批 vs 流水线
package main
import (
"fmt"
"math"
"sync"
"sync/atomic"
"time"
)
const (
tasks = 120
serialIter = 60_000 // 串行段 ≈ 总工作量的 1/5 → p = 0.8
parIter = 240_000
reps = 5 // 每组跑 5 次取最快,压掉被抢占的样本
)
var sink float64
// 固定迭代数的 CPU 工作:工作量可复现,不依赖 sleep 的调度粒度
func work(iters int) float64 {
x := 0.0
for i := 0; i < iters; i++ {
x += math.Sqrt(float64(i))
}
return x
}
// 模式 A 同步批:先串行做完所有串行段,再开 n 个 worker 做并行段。
// 串行段执行时并行资源全部空转 —— 这正是 Amdahl 的假设。
func barrierMode(n int) time.Duration {
start := time.Now()
acc := 0.0
for i := 0; i < tasks; i++ {
acc += work(serialIter)
}
var wg sync.WaitGroup
var pulled int64
res := make(chan float64, n)
for w := 0; w < n; w++ {
wg.Add(1)
go func() {
defer wg.Done()
local := 0.0
for atomic.AddInt64(&pulled, 1) <= tasks {
local += work(parIter)
}
res <- local
}()
}
wg.Wait()
close(res)
for v := range res {
acc += v
}
sink += acc
return time.Since(start)
}
// 模式 B 流水线:每个任务自己"先抢锁做串行段,再做并行段"。
// 任务 i 的串行段可以和任务 i-1 的并行段重叠,串行资源只是限流,不是全员空转。
func pipelineMode(n int) time.Duration {
var mu sync.Mutex
var wg sync.WaitGroup
jobs := make(chan struct{}, tasks)
for i := 0; i < tasks; i++ {
jobs <- struct{}{}
}
close(jobs)
res := make(chan float64, n)
start := time.Now()
for w := 0; w < n; w++ {
wg.Add(1)
go func() {
defer wg.Done()
local := 0.0
for range jobs {
mu.Lock()
local += work(serialIter) // 全局唯一的串行资源
mu.Unlock()
local += work(parIter)
}
res <- local
}()
}
wg.Wait()
close(res)
acc := 0.0
for v := range res {
acc += v
}
sink += acc
return time.Since(start)
}
func minOf(k int, f func() time.Duration) time.Duration {
best := time.Duration(1<<62 - 1)
for i := 0; i < k; i++ {
if d := f(); d < best {
best = d
}
}
return best
}
func amdahl(p float64, n int) float64 { return 1 / ((1 - p) + p/float64(n)) }
// 由 S(n) 反推 p:S = 1/((1-p)+p/n) => p = n(S-1)/((n-1)S)
func inferP(s float64, n int) float64 { return float64(n) * (s - 1) / (float64(n-1) * s) }
func main() {
p := float64(parIter) / float64(serialIter+parIter)
ns := []int{1, 2, 4, 8, 16}
tb := map[int]time.Duration{}
tp := map[int]time.Duration{}
for _, n := range ns {
tb[n] = minOf(reps, func() time.Duration { return barrierMode(n) })
tp[n] = minOf(reps, func() time.Duration { return pipelineMode(n) })
}
fmt.Printf("M=%d 个任务,每任务 串行段 %d 迭代 + 并行段 %d 迭代 → p = %.3f,1/(1-p) = %.2f\n\n",
tasks, serialIter, parIter, p, 1/(1-p))
fmt.Printf("%-4s %-24s %-24s %s\n", "n", "同步批(实测/Amdahl)", "流水线(实测/上限)", "流水线上限")
for _, n := range ns {
sb := float64(tb[1]) / float64(tb[n])
sp := float64(tp[1]) / float64(tp[n])
bound := math.Min(1/(1-p), float64(n)/p) // 串行段与并行段可重叠时的上界
fmt.Printf("%-4d %7v %.2f / %.2f %7v %.2f / %.2f %.2f\n",
n, tb[n].Round(time.Millisecond), sb, amdahl(p, n),
tp[n].Round(time.Millisecond), sp, bound, bound)
}
fmt.Println("\n== 从 n=2、4 反推串行占比 p ==")
for _, m := range []struct {
name string
t map[int]time.Duration
}{{"同步批", tb}, {"流水线", tp}} {
s2 := float64(m.t[1]) / float64(m.t[2])
s4 := float64(m.t[1]) / float64(m.t[4])
fmt.Printf(" %s:S(2)=%.2f → p=%.3f S(4)=%.2f → p=%.3f\n",
m.name, s2, inferP(s2, 2), s4, inferP(s4, 4))
}
fmt.Println("\n理论 p 真值 0.800:同步批能反推到真值附近;流水线会把 p 反推得偏高(它违反了 Amdahl 的假设)。")
}
本机实跑(cd /tmp/luban37/amdahl3 && GO111MODULE=off go run main.go,Apple M1 / 8 核 / Go 1.27.1):
M=120 个任务,每任务 串行段 60000 迭代 + 并行段 240000 迭代 → p = 0.800,1/(1-p) = 5.00
n 同步批(实测/Amdahl) 流水线(实测/上限) 流水线上限
1 56ms 1.00 / 1.00 57ms 1.00 / 1.25 1.25
2 34ms 1.68 / 1.67 28ms 2.01 / 2.50 2.50
4 22ms 2.51 / 2.50 16ms 3.63 / 5.00 5.00
8 17ms 3.26 / 3.33 15ms 3.90 / 5.00 5.00
16 17ms 3.27 / 4.00 13ms 4.45 / 5.00 5.00
== 从 n=2、4 反推串行占比 p ==
同步批:S(2)=1.68 → p=0.808 S(4)=2.51 → p=0.802
流水线:S(2)=2.01 → p=1.003 S(4)=3.63 → p=0.966
理论 p 真值 0.800:同步批能反推到真值附近;流水线会把 p 反推得偏高(它违反了 Amdahl 的假设)。
五条解读:
- 同步批几乎贴着 Amdahl 走:1.68 / 1.67、2.51 / 2.50、3.26 / 3.33,偏差都在 3% 以内。因为同步批正是 Amdahl 的前提——串行段跑的时候,并行资源全在空转。
- n=16 的 3.27 与 n=8 的 3.26 持平,比理论值 4.00 少了 0.73。这台机器只有 8 个物理核,16 个 goroutine 要分时复用,调度与上下文切换把多出来的 8 个执行单元吃掉了。执行单元超过物理核数之后,再买机器换不到加速比,这是 Amdahl 公式里没有的一项。
- 反推 p 命中真值:S(2)=1.68 → 0.808,S(4)=2.51 → 0.802,真值 0.800,两个估计只差 0.006。这条路线可以当生产工具用。
- 流水线超过了 Amdahl 的预测(n=4 是 3.63 > 2.50),但没超过 5.00 的天花板。原因是任务之间存在重叠:任务 i 的串行段可以和任务 i−1 的并行段同时进行,串行资源变成“限流器”而不是“全员空转”。此时的上界是
max(T_serial, T_parallel/n)换算出的min(1/(1−p), n/p),n=4 时为 5.00。 - 流水线反推出来的 p 甚至大于 1(1.003),真值只有 0.8。反推公式只对同步批模型成立:Amdahl 下
S(n) ≤ n,而流水线的 S(2)=2.01 已经超过 2,模型被违反,反推自然失效。用错模型会把 p 算得系统性偏高,按它做容量规划会低估瓶颈。
波动来源交代清楚:这台机器当时 load average 在 10–26 之间波动(有其他进程在抢核),每组跑 5 次取最快才压到稳定;如果不取最快,同一组数据能差 30% 以上。生产上测 p 也要取分位数、避开业务高峰,否则测出来的是邻居的负载,不是自己的 p。
反模式
| 反模式 | 后果 |
|---|---|
| 线性外推扩容 | “1 台 1000 QPS,10 台就 1 万”,第 5 台起不达标,大促当天才发现 |
| 用 CPU 利用率当加速比 | CPU 打满只说明忙,不说明有用;等锁、空转重试也会把利用率拉满 |
| 把 Amdahl 用在单请求延迟上 | 按副本数换算单请求延迟:加 16 个副本,每个请求仍是 100ms,涨的只有并发数;能降到 25ms 的只有“请求内部切 16 份并行”那一种,且要付切分归并的钱 |
| 忽略协调开销 | Amdahl 只有 σ 项,节点多了还有 κ 项,预测值必然偏高 |
| p 用拍脑袋值 | “我们系统 90% 可并行”没有测量支撑,据此算出的容量是许愿 |
| 扩容后不重测 p | 分片、缓存上线后 p 变了,还按老 p 排容量,要么浪费要么翻车 |
| 用 Gustafson 给在线服务做规划 | 规模不随核数增长,假设不成立,规划值高得离谱 |
| 只看平均延迟不看 P99 | 均值被并行度掩盖,尾延迟被扇出和排队放大(见 37.7 扩展瓶颈识别) |
关联
自检
Q1 有人说“我们把接口改成 16 线程并行之后,单次响应从 100ms 降到了 6.25ms”。这句话哪里有问题?
▸ 答案:混淆了吞吐和单请求延迟,还漏了串行段。6.25ms = 100/16,等于假设 16 个执行单元能完整切分同一个请求且没有任何串行部分。真实情况是:这个请求里 20ms 串行段无法并行,Amdahl 上限 1/(1−0.8)=5,最好也就 20ms;而且 16 个线程处理同一个请求要先切分、再归并,归并本身是串行。16 线程真正买到的是“同时处理 16 个请求”的吞吐,不是“一个请求快 16 倍”。
Q2 由实测 S(2)=1.68 和 S(4)=2.51 反推 p,各是多少?两个值为什么应当接近,差得远说明什么?
▸ 答案:p = n(S−1)/((n−1)S)。n=2:2×0.68/(1×1.68)=0.810;n=4:4×1.51/(3×2.51)=0.802(用取整后的 S 手算得 0.810 / 0.802,程序用未取整的实际耗时算得 0.808 / 0.802)。两个值应当接近,因为同一个 p 必须同时解释两个点。差得远说明模型不成立:要么存在 Amdahl 之外的协调开销(锁交接、调度、GC),要么测量被噪声污染,要么任务之间发生了流水重叠。
Q3 p=0.9,机器从 16 台加到 256 台(16 倍),加速比从多少到多少?如果把这 16 倍机器预算换成“把 p 提到 0.95”,哪个更划算?
▸ 答案:p=0.9 时 S(16)=6.40、S(256)=9.66,只涨 51%。p 提到 0.95 后 S(16)=9.14,比 16 台时的 6.40 高 43%,且只用了 16 台机器。也就是说:削串行段用 1/16 的机器拿到了接近的收益。更极端地看天花板:p=0.9 的上限是 10,p=0.95 是 20——加机器永远碰不到 p 决定的那条线。
Q4 超算用 Gustafson 能算出接近线性的加速比,为什么同一套算法搬到在线服务上就不成立?
▸ 答案:Gustafson 的前提是“时间固定、问题规模随 n 增长”,核数翻倍时算例也跟着变大,串行段的绝对耗时不变,占比被稀释,所以 S = n − (1−p)(n−1) 能接近 n。在线服务的请求量由用户决定,不会因为加了机器就自动变大,规模是固定的——此时适用 Amdahl,天花板是 1/(1−p)。把 Gustafson 套到在线服务上,等于默认“扩容之后流量会自己涨上来填满机器”,这是容量规划里最贵的一个假设。