先看一个我当年死活想不通的实验。对同一个数组求和,循环里带一个分支:
for (int i = 0; i < 262144; ++i) {
if (data[i] >= 128) sum += data[i];
}
两份数据,内容一模一样,只是一份有序、一份乱序,同一台机器上测出来的典型结果:
排序后的数组: 0.32 秒
乱序的数组: 1.14 秒
差了三倍多。算法一样,数据规模一样,循环次数一样,比较运算也不挑数据的顺序,时间从哪多出来的?答案在 CPU 的流水线和它背后的「赌徒」:分支预测器。本章讲清楚流水线怎么把 CPU 变快、三种会打乱它的「冒险」,以及为什么一个 if 的数据分布能决定性能差几倍。这是全课第二个建议慢读的章节。
洗衣服有四个阶段:洗衣机洗、烘干机烘、叠、收。一个人洗四批衣服,可以一批做完再做下一批,也可以第一批进烘干机时就把第二批塞进洗衣机。后者不省任何单批的时间,但四批衣服的总时间砍到接近四分之一。CPU 执行指令同理:第 5 章把一条指令拆成五拍(取指 IF、译码 ID、执行 EX、访存 MEM、写回 WB),流水线给每拍配一套专属硬件,让五条指令同时在场、各占一拍:
周期: 1 2 3 4 5 6 7
指令1 IF ID EX MEM WB
指令2 IF ID EX MEM WB
指令3 IF ID EX MEM WB
指令4 IF ID EX MEM WB
从第 5 拍起,每一拍都有一条指令完成,吞吐逼近 1 IPC(每周期一条指令)。n 条指令走 k 级流水线,总时间约 (k + n - 1) 拍,对比多周期的 n×k 拍,快了接近 k 倍。代价是硬件翻倍、时序更紧张,以及本章主角:指令之间开始互相干扰。
流水线的理想是「每拍稳稳完成一条」,但指令是会互相使绊子的,教材称冒险(hazard),共三类。
结构冒险:两条指令要抢同一件硬件。经典案例是取指和访存都要走内存:第 4 条指令在 IF 取指令时,第 1 条正好在 MEM 读数据。解法干脆:拆两个存储口,指令 Cache 和数据 Cache 各一个(第 7 章的 L1 就是这么分的)。
数据冒险:后面的指令要用前面那条还没算完的结果。真依赖(RAW)是唯一在顺序流水线里真实存在的:
add a0, a1, a2 ; a0 在 EX 末尾才算出,WB 才写回
sub a3, a0, a4 ; 它在 ID 就想读 a0,晚了三拍
(WAR、WAW 在顺序流水线不会出事,那是乱序执行时代的课题,知道名字即可。)
控制冒险:分支指令要到 EX 甚至更晚才知道「跳不跳、跳到哪」,但流水线等不了,IF 每一拍都必须取出下一条指令。顺序执行时「下一条」当然就是 PC+4,可分支一出现,这个假设随时作废。
转发(forwarding) 是主力药。意识到一个问题后它几乎是免费的:ALU 算出的 a0 其实已经躺在 EX 级的输出锁存器里了,何必等它绕道寄存器文件?直接拉一根线,把 EX/MEM 级间锁存器里的值旁路给下一条指令的 ALU 输入:
add a0,a1,a2: IF ID EX MEM WB
│
sub a3,a0,a4: ID EX ◄┘ a0 直接从锁存器喂进来
大多数 RAW 一转发就没了,零停顿。
停顿(stall) 是兜底药,转发救不了的才轮到它。典型是 load-use:load 的数据要到 MEM 末尾才到手,紧接着的下一条指令 EX 就要用,转发线也追不上:
ld a0, 0(a1)
add a3, a0, a4 ; 只能插一个气泡,空转一拍
编译器的对策是指令调度:把一条不相干的指令塞到 load 和它的使用者中间,把气泡填满。你在第 10 章读 gcc 输出的汇编时会发现指令顺序经常和源码不一样,不少就是这类调度。
现在回到实验。那个 if 每轮循环都要「问一次方向」,流水线等不起真答案,于是赌:预测跳还是不跳。赌对了零代价;赌错了,已经进了流水线的后续指令全是废牌,得整体冲刷,损失等于流水线深度(现代 CPU 十几到二十拍)。
预测器是流水线时代的隐形发动机,两层结构值得认识:
现在解释实验:有序数组里,比较结果先是一长串「不跳」,再是一长串「跳」,两位计数器在两个边界各错一次,接近 100% 命中;乱序数组里跳与不跳五五开掷硬币,预测器命中率塌到 50%,等于每隔两条指令冲刷一次流水线。三倍的时间差,全部来自这两次「赌局胜率」的区别。
这个实验还有个后续值得知道:2018 年闹得沸沸扬扬的 Spectre 漏洞,攻击面正是预测执行的副作用。CPU 赌错后会冲刷指令,但冲刷前的访存已经在 Cache 里留下了痕迹,攻击者测 Cache 访问时间就能把「本不该读的数据」侧信道偷走。分支预测这条性能生命线,也是安全史的转折点。
a[i] 刚取出来立刻参与运算,在深流水机器上可能吃一个停顿;适当拉开距离(或交给编译器 -O2)。gcc -O1 或 -O2,数据 26 万规模),再给循环里加一句「把数组先排序」的版本,验证时间差;顺便用 std::sort/qsort 的时间,算算「排序 + 快求和」和「乱序慢求和」谁赢。