指令系统与流水线

冯·诺依曼结构

┌──────────┐
│   CPU    │  ← 控制器 + 运算器 + 寄存器
└────┬─────┘
     │ 总线
┌────┴─────────────────────┐
│  存储器(内存)           │  指令和数据放一起
└──────────────────────────┘

核心特征:存储程序——指令和数据都存在同一个内存里,可以被同样地读写。

冯·诺依曼瓶颈:CPU 和内存之间的带宽是有限的,而 CPU 速度远快于内存 → CPU 经常在等数据。

这就是整个存储层次结构存在的理由。

指令执行

取指-译码-执行循环

1. 取指(Fetch):     PC → 内存,取指令
2. 译码(Decode):    解析操作码和操作数
3. 执行(Execute):   运算
4. 访存(Memory):    读写内存(如需)
5. 写回(Writeback): 结果写回寄存器
6. PC += 指令长度

一条指令要 5 步,但流水线让多条指令的不同阶段重叠:

周期:  1   2   3   4   5   6   7
指令1: F   D   E   M   W
指令2:     F   D   E   M   W
指令3:         F   D   E   M   W
       ↑ 5 个周期后,每个周期完成一条指令(吞吐 5 倍)

流水线的三种冒险(Hazard)

类型原因解法
结构冒险硬件资源冲突(两条指令同时要访存)多端口存储器、分离指令/数据缓存
数据冒险后一条指令需要前一条的结果转发(forwarding)、插入气泡(stall)
控制冒险分支跳转,不知道下一条取哪分支预测、延迟槽

数据冒险的例子:

add r1, r2, r3     ; r1 = r2 + r3
sub r4, r1, r5     ; 需要 r1,但 r1 还没写回

转发(bypassing):不等写回,直接把 ALU 的输出接到下一条指令的输入。

Load-use 冒险无法完全转发(数据从内存来,太晚):

lw  r1, [r2]       ; 从内存加载
add r3, r1, r4     ; 必须等一个周期(stall)

这就是编译器要做指令调度的原因——在 lw 和 add 之间塞入无关指令。

分支预测

分支错误预测的代价 = 清空流水线 ≈ 15~20 个周期。

预测器演进:

类型思路
静态永远预测不跳转 / 向后跳预测跳转(循环)
2-bit 饱和计数器记录每个分支的历史,连续错两次才改变预测
局部历史(BHT)每个分支自己的历史模式
全局历史(GShare)用全局的分支历史索引
TAGE / 感知机现代高性能预测器,多个历史长度的表 + 投票

准确率:现代预测器 > 95%。

对程序员的意义:

// 慢:随机分支,预测失败率 ~50%
for (int i = 0; i < n; i++)
    if (data[i] < 128) sum += data[i];      // data 随机

// 快:排序后前一半都小于 128,预测器几乎全对
std::sort(data, data + n);
for (int i = 0; i < n; i++)
    if (data[i] < 128) sum += data[i];

这个著名的例子:排序后的数组遍历比未排序快 2~3 倍,纯粹因为分支预测。

无分支写法(编译器 -O2 有时会自己做):

sum += (data[i] < 128) ? data[i] : 0;     // 可能被编译成 cmov
// 或
sum += data[i] * (data[i] < 128);         // 算术消除分支