目录

  • ILP的基本概念与“拦路虎”
  • 软件的智慧:基本流水线调度与循环展开
  • 预测未来的魔法:高级分支预测
  • 硬件的智慧:Tomasulo动态调度算法
  • 硬件推测执行(ROB)与多发射(超标量/VLIW)
  • 指令提取带宽与BTB技术
  • 压榨CPU的极限:多线程技术与真实CPU案例

1 ILP的基本概念与“拦路虎”

1.1 什么是指令级并行

程序里的指令原本是一条一条排队执行的。但是CPU觉得这样太慢了,指令级并行(ILP)就是让多条指令在流水线中“重叠”执行。实现ILP宏观上有两种方式:

  • 软件派(编译器): 在程序运行前(静态),把代码重新排版,让指令更好并行。
  • 硬件派(CPU硬件): 在程序运行中(动态),CPU自己发现哪些指令可以一起执行。

1.2 谁在阻挡指令级并行

要想让指令并行,最大的阻碍就是指令之间有“关系”。相关可以分为三种:

  • 数据相关(真相关)。例如指令 \(i\) 计算出一个结果,但指令 \(i+1\) 要用到这个结果。
  • 名称相关(假相关)。它是指两条指令其实没有任何数据交流,只是碰巧用到了同一个寄存器。名称相关又可细分为以下两种:
    • 反相关。例如指令 \(i+1\) 写了一个寄存器,而指令 \(i\) 正好要读它。(先读再写)
    • 输出相关。例如指令 \(i\) 和指令 \(i+1\) 都要写同一个寄存器。
  • 控制相关。这由分支指令(比如 if-else)引起。在判断条件没出来之前,CPU不知道接下来该执行哪条指令。

1.3 数据冒险

如果一段汇编代码发生了相关,就会在流水线中引发冲突,导致流水线停顿,这就是冒险。针对数据流(数据相关+名称相关),有三种冒险:

  • RAW(写后读): 对应数据真相关。
  • WAW(写后写): 对应输出相关。
  • WAR(读后写): 对应反相关。

在最基础的5级流水线中,只有RAW会发生;但是当我们允许指令“乱序执行”来提高性能时,就会出现WAW和WAR。

1.4 例子

1
2
3
1. ADD.D  F2, F4, F6   // 把F4和F6相加,结果写入F2
2. SUB.D F8, F2, F10 // 把F2减去F10,结果写入F8
3. MUL.D F2, F12, F14 // 把F12和F14相乘,结果写入F2
  • 指令1和指令2之间是数据相关,对应写后读;
  • 指令1和指令3之间是输出相关,对应写后写;
  • 指令2和指令3之间是反相关,对应读后写。

如果要消除指令2和指令3之间的冲突,可以使用寄存器换名的策略。例如,把指令3的F2换成F20,它和指令2就不存在任何冲突了。

2 软件的智慧:基本流水线调度与循环展开

这一节要解决的问题是,编译器如何在软件层面(静态)实现指令级并行?

本节将会以一个浮点数计算的循环体为例,介绍基本流水线调度与循环展开。规定处理器的浮点延迟如下:

产生结果的指令 使用结果的指令 延迟(时钟周期数)
浮点计算 浮点计算 3
浮点计算 浮点store 2
浮点load 浮点计算 1
浮点load 浮点store 0

2.1 原始代码

下面是一段经典的循环代码,把数组元素加上标量 s:

1
2
3
4
5
Loop: L.D    F0, 0(R1)      // 取数组元素到F0
ADD.D F4, F0, F2 // F0 + F2 -> F4。注意:这里距离上一条L.D需要1个停顿
S.D F4, 0(R1) // 存回内存。注意:这里距离上一条ADD.D需要2个停顿
DADDIU R1, R1, -8 // 数组指针递减8字节
BNE R1, R2, Loop // 循环分支。注意:由于RAW冒险,这里距离上一条DADDIU需要1个停顿

由于上述的流水线延迟,在 L.D 和 ADD.D 之间会产生1个 Stall,在 ADD.D 和 S.D 之间会产生2个 Stall。加上两条循环控制指令,处理1个数组元素总共需要 9 个时钟周期,性能极其低下。

1
2
3
4
5
6
7
8
9
Loop:  	L.D     F0, 0(R1)      ; 指令流出时钟 1
stall (停顿) ; 指令流出时钟 2
ADD.D F4, F0, F2 ; 指令流出时钟 3
stall (停顿) ; 指令流出时钟 4
stall (停顿) ; 指令流出时钟 5
S.D F4, 0(R1) ; 指令流出时钟 6
DADDIU R1, R1, #-8 ; 指令流出时钟 7
stall ; 指令流出时钟 8(RAW冒险)
BNE R1, R2, Loop ; 指令流出时钟 9

2.2 初级优化:指令调度

编译器的第一步优化,是把毫无关联的指令“塞”进这些停顿(Stall)中。例如,编译器发现 DADDIU R1, R1, -8 和浮点计算完全没有数据相关,可以把它提前到 L.D 和 ADD.D 之间。但是因为 R1 提前减去了8,后面的 S.D 如果还是用 0(R1) 就会存错地址。所以编译器必须同时补偿偏移量,将 S.D 修改为 S.D F4, 8(R1)。最终得到的汇编代码如下:

1
2
3
4
5
6
7
Loop:  	L.D     F0, 0(R1)
DADDIU R1, R1, #-8
ADD.D F4, F0, F2
stall
stall
S.D F4, 8(R1)
BNE R1, R2, Loop

此时周期数从9降到了7个周期,但依然存在2个停顿周期无法消除。

2.3 中级优化:循环展开

单次循环内的指令太少,调度空间见底了。怎么办?

编译器采用循环展开技术,直接把循环体复制多次。这里展开了4次,即把4次小的迭代合并成1次大的迭代。

image-20260414223211143

  • 把【L.D, ADD.D, S.D】的组合复制4次。原本需要4次 DADDIU 和4次 BNE,现在只需要在最末尾保留1次即可,极大地消除了循环控制开销。

  • 如果4个循环体中的寄存器全用 F0 和 F4,会产生严重的写后写(WAW)和读后写(WAR)冒险。因此编译器给第1个元素用 F0/F4,第2个用 F6/F8,第3个用 F10/F12,第4个用 F14/F16。

  • 现在我们有了4条 L.D,4条 ADD.D,4条 S.D,且它们之间完全没有数据相关。编译器把所有的 L.D 排在一起,接着排所有的 ADD.D,最后排所有的 S.D。

  • 这个循环每遍共使用了27个时钟周期,平均每个元素使用27/4=6.75个时钟周期,小于原来的9个时钟周期。

Q:循环展开把 L.D, ADD.D, S.D 的组合复制4次,但如果循环次数不是四的倍数怎么办?

A:在编译器优化中,通常使用条带开采(Strip Mining)技术解决这一问题。假设我们要循环 \(N\) 次,展开因子是 \(K\)(比如 \(K=4\))。编译器会把代码分成两个循环来生成:

  1. 主体展开循环: 每次处理 \(K\) 个元素,总共执行 \(\lfloor N/K \rfloor\) 次。
  2. 尾部清理循环: 这是一个没有展开的普通循环,用来处理剩下除不尽的 \(N \bmod K\) 个元素。

2.4 终极优化:循环展开+指令调度

对循环展开后的指令序列还可以进行优化调度,以减少空转周期:

image-20260414223852270

整个循环仅仅使用了14个时钟周期,平均每个循环仅使用14/4=3.5个时钟周期。

3 预测未来的魔法:高级分支预测

控制相关会阻碍流水线。为了不让流水线等,CPU只能“猜”分支往哪走。

3.1 传统预测的局限

传统的2位预测器只看单个分支自身的历史记录。比如一个 for 循环的条件判断,它自己跳了99次,第100次大概率还是跳。但这有局限性:有些分支的结果,根本不是由它自己决定的,而是由它前面的其他分支决定的。

例如以下代码:

1
2
3
/* 分支 b1 */  if (aa == 2)  aa = 0;
/* 分支 b2 */ if (bb == 2) bb = 0;
/* 分支 b3 */ if (aa != bb) { ... }

如果分支b1和b2的条件都成立,那么此时 aa 必然等于0,bb 也必然等于0。这就意味着,到达第三个分支(b3)时,aa != bb 这个条件绝对不可能成立。

  • 如果使用基础的局部预测器,b3 只会傻傻地看自己过去的记录。

  • 如果使用相关预测(也叫全局历史预测),硬件会记录一条全局历史移位寄存器(Global History Register, GHR)。当CPU看到全局历史是“b1成立,b2成立”时,它能直接推断出“b3绝对不成立”。

3.2 常用的分支预测器

3.2.1 局部预测器

在局部预测器中,每一条分支指令都有自己的历史记录,记录该分支最近几次是跳转还是不跳转,从而用这个历史去预测下一次是否跳转。

例如,有一个分支指令 if (i % 2 == 0),这个分支过去几次的跳转情况为 T, T, T, N, T, T …,那么下一次大概率跳转。

硬件结构

第一级:局部历史表(Local History Table, LHT)

  • key:一条分支指令(用 PC 地址的低位索引)
  • value:一个 k 位移位寄存器,记录该指令最近 k 次的结果,例如 110 表示最近三次:跳、跳、不跳

第二级:模式历史表(Pattern History Table, PHT)

  • key:第一级输出的局部历史记录(k 位)

  • value:一个饱和计数器数组(通常是 2-bit),共 \(2^k\) 个条目。计数器计的“数”的含义是,00=强不跳,01=弱不跳,10=弱跳,11=强跳,相当于一个状态机

预测流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    PC低位


┌─────────┐
│ LHT │ (存储每条指令的最近k次历史)
└────┬────┘
│ k位历史 (例如: 110 表示 跳,跳,不跳)

┌─────────┐
│ PHT │ (2^k个条目,每个是2-bit饱和计数器)
└────┬────┘
│ 2-bit计数值 (00/01=不跳, 10/11=跳)

预测结果

这种结构擅长捕捉有规律的局部模式,比如循环内”T T T T N”的规律跳转。

3.2.2 全局预测器

全局预测器使用一个全局历史寄存器(GHR, Global History Register)记录最近 N 条分支的结果(例如:TNTTNT…),用这串历史去预测当前分支指令是否需要跳转。

硬件结构

第一级:全局历史寄存器(Global History Register, GHR)

  • 一个 k 位移位寄存器,记录最近 k 条分支(不管是哪条指令)的结果
  • 每执行一条分支,就将结果移入 GHR,最旧的移出

第二级:模式历史表(PHT)

  • key:第一级输出的全局历史记录(k位)
  • value:一个饱和计数器数组(通常是 2-bit),共 \(2^k\) 个条目。计数器计的“数”的含义是,00=强不跳,01=弱不跳,10=弱跳,11=强跳,相当于一个状态机

预测流程:

1
2
3
4
5
6
7
8
9
10
11
┌─────────┐
│ GHR │ (全局的最近k次历史)
└────┬────┘
│ k位历史 (例如: 110 表示 跳,跳,不跳)

┌─────────┐
│ PHT │ (2^k个条目,每个是2-bit饱和计数器)
└────┬────┘
│ 2-bit计数值 (00/01=不跳, 10/11=跳)

预测结果

全局预测器擅长捕捉跨分支的相关性,例如”如果前面的 if 条件成立,后面的 if 也很可能成立”。

3.2.3 竞赛预测器

局部预测器对某些分支有效,全局预测器对另一些分支有效。既然各有千秋,硬件设计师决定把这两个东西结合起来,就变成了竞赛预测器

硬件结构

  • 部件 A: 局部预测器(Local Predictor),同上

  • 部件 B: 全局预测器(Global Predictor),同上

  • 部件 C: 选择器(Selector / Choice Predictor)

    • key:分支指令的地址
    • value:2位计数器,具体含义见下面

选择器不是用来预测分支跳还是不跳,而是用来预测“这次我该信A还是信B”。对于每一条分支指令,选择器里都有一个专用的 2位饱和计数器(2-bit Saturating Counter)。它的状态机如下:

  • 状态 00 (0): 强烈倾向使用 A(局部预测器)。
  • 状态 01 (1): 较弱倾向使用 A(局部预测器)。
  • 状态 10 (2): 较弱倾向使用 B(全局预测器)。
  • 状态 11 (3): 强烈倾向使用 B(全局预测器)。

也就是说,数值越小越相信 A,数值越大越相信 B:

1
2
3
相信A							相信B
<------------------------>
00 01 10 11

当一条分支指令实际执行完毕,

  • 如果 A猜对了,B猜错了:选择器的计数器减1(状态向 0 移动,变得更信任 A)。
  • 如果 B猜对了,A猜错了:选择器的计数器加1(状态向 3 移动,变得更信任 B)。
  • 如果 A和B都猜对,或者都猜错:没分出胜负,计数器状态保持不变

例如,对于一条给定的分支指令,如果在最近的运行中,全局预测(B)总是比局部预测(A)准,选择器就会被训练到状态 3。下次遇到这条指令,无论 A 怎么说,CPU都直接采纳 B 的预测结果。

3.3 局限性

假设程序里有一个非常随机的数据序列(比如伪随机数生成器产生的一千万个0和1),然后代码是:

1
2
3
if (random_array[i] == 1) { 
// 执行操作
}

无论是局部预测器、全局预测器还是竞赛预测器,面对这段代码时,它们的预测准确率大概是50%。

  • 对局部预测器而言: 它通过 PC 地址查到了这条 if 指令专属的 2 位计数器。但是数据是随机的,导致上一次跳了,这一次可能不跳。计数器的值在 0、1、2、3 之间毫无规律地随机震荡(专业术语叫 Thrashing 颠簸)。预测结果完全是随机瞎蒙(50%)。
  • 对全局预测器而言: GHR 里记录的历史(比如最近5次分支的结果 01101)也是由随机数据产生的随机噪声。用一个随机噪声去查表,查出来的计数器状态同样是噪音,预测率依然是 50%。
  • 对竞赛选择器而言: A 和 B 都在瞎蒙,所以 A 和 B 猜对的概率都是 50%。选择器的计数器也失去了方向,在 A 和 B 之间随机横跳。

这揭示了分支预测的根本局限性:分支预测的本质是利用程序运行中的“数据相关性规律”和“代码结构模式规律”。如果输入数据具有极高的信息熵(Entropy,即纯随机),硬件预测逻辑将彻底失效。

在真实的工业界开发中,如果程序员写出了这种针对高频随机数据的分支,会导致 CPU 流水线因为疯狂预测错误而反复清空,性能极速下降。这也是为什么资深的 C++ 工程师在处理这种核心代码时,会刻意使用 无分支编程(Branchless Programming) 或者 条件传送指令(如 x86 的 CMOV,ARM 的 CSEL) 来消除分支指令,从而避开分支预测器的软肋。

3.4 Intel Core i7 使用的分支预测技术

Intel Core i7 将竞赛预测器的思想发扬光大。它的分支预测器包含:

  1. 局部预测器
  2. 全局预测器
  3. 循环退出预测器(专门针对for/while循环结束的那一次跳跃进行特化识别)

然后通过复杂的选择器网络(融合了竞赛机制),把这三个预测器的结果进行整合决策。这就使得 Core i7 能够将分支预测准确率推高到 95% 以上。

4 硬件的智慧:Tomasulo动态调度算法

4.1 动态调度的思想与困难

经典的5级流水线是按序发射、按序执行的。一旦某条指令因为数据没准备好(数据冒险)而停顿(Stall),它后面的所有指令,哪怕是不相关的指令,也全都被堵在后面。

为了实现乱序执行(Out-of-Order Execution),可以把经典的译码段(ID)拆成两半:

  1. 发射(Issue, IS): 这一步依然是按序的。指令在这里译码,并检查有没有结构冒险(比如加法器够不够用)。
  2. 读操作数(Read Operands, RO): 这一步开始乱序!如果指令需要的数据还没算出来(数据冒险),它就在这儿等。但关键是,它等它的,不妨碍后面的指令先去读操作数并执行

但一旦允许乱序执行,就会出现WAR冒险和WAW冒险。例如

1
2
3
DIV.D F0, F2, F4
ADD.D F6, F0, F8
SUB.D F8, F10, F14 <-- 注意这条

如果 SUB.D 跑得比 ADD.D 快,它先去把 F8 给写了,那后面 ADD.D 读到的 F8 就是错的!这就是反相关引起的 WAR 冒险

而动态调度还有一大麻烦:不精确异常精确异常是指,假设程序按顺序跑,在指令 \(i\) 发生除零错误(异常)时,前面的指令全执行完了,\(i\)​ 后面的指令都没产生任何影响。现场保护得清清楚楚。而因为乱序,当指令 \(i\) 发生异常时,它后面的指令 \(i+1\) 可能已经跑完了并且把结果写进了寄存器。这时候你再去捕捉异常状态,现场已经被破坏了,这叫不精确异常

4.2 Tomasulo 算法

见【Tomasulo-原始例子-来自b站】

5 硬件推测执行(ROB)与多发射(超标量/VLIW)

在原始的 Tomasulo 算法中,虽然实现了乱序执行,但存在一个致命的体系结构缺陷:指令乱序完成(Out-of-order Completion)。当功能部件计算完毕后,结果通过 CDB 直接写入了物理寄存器或存储器。

如果发生分支预测错误异常(Exception),由于后续的指令可能已经提前算完并修改了寄存器,处理器的真实状态(体系结构状态)已经被破坏,无法回滚到异常发生或分支跳转前的精确状态(即不精确异常)。

为了解决这个问题并支持推测执行,现代体系结构在Tomasulo算法的基础上引入了重排序缓冲区(Reorder Buffer, ROB)

5.1 ROB:重排序缓冲区

ROB是一个先进先出(FIFO)的环形队列,用于临时存放乱序执行产生的结果。引入ROB后,处理器的执行逻辑被严格定义为:乱序执行,按序提交(Out-of-order Execution, In-order Commit)

在引入ROB的架构中,原有的硬件机制发生了重要改变:

  1. 寄存器重命名主体的转移: 在原始Tomasulo中,寄存器重命名(消除WAR/WAW冒险)是通过保留站(RS)的站号完成的。在推测架构中,寄存器换名由ROB的编号(Tag)来完成
  2. 寄存器状态表(Qi)的改变: 状态表不再记录保留站的编号,而是记录即将写入该寄存器的指令在ROB中的项号

5.2 加入ROB后的Tomasulo算法

5.2.1 算法步骤

两个关键点:

写回之后就可以广播

提交之后才可以修改寄存器状态

引入ROB后,指令的执行周期从原始Tomasulo的3步扩展为4步:

1.发射/流出:从浮点指令队列的头部取一条指令。如果有空闲的保留站(设为r)且有空闲的ROB项(设为b),就流出该指令,并把相应的信息放入保留站r和ROB项b。如果保留站或ROB全满,便停止流出指令,直到它们都有空闲的项。

2.执行:如果有操作数尚未就绪,就等待,并不断地监测CDB。(这一步检测RAW冒险)当两个操作数都已在保留站中就绪后,就可以执行该指令的操作。

3.写结果:当结果产生后,将该结果连同本指令在流出段所分配到的ROB项的编号放到CDB上,经CDB写到ROB以及所有等待该结果的保留站。(广播)然后释放产生该结果的保留站。

store指令在本阶段执行的操作为:

  • 如果要写入存储器的数据已经就绪,就把该数据写入分配给该store指令的ROB项(value字段)。

  • 否则,就监测CDB,直到那个数据在CDB上播送出来,这时才将之写入分配给该store指令的ROB项(value字段)。

4.提交

对分支指令、 store指令以及其他指令的处理不同:

  • 其他指令(除分支指令和store指令):当该指令到达 ROB 队列的头部而且其结果已经就绪时,就把该结果写入该指令的目标寄存器,并从ROB中删除该指令。

  • store指令:处理与上面类似,只是它把结果写入存储器。

  • 分支指令:

    • 当预测错误的分支指令到达ROB队列的头部时,清空ROB,并从分支指令的另一个分支重新开始执行。(错误的推测执行)
    • 当预测正确的分支指令到达ROB队列的头部时,该指令执行完毕。

5.2.2 例子

见【加入ROB的Tomasulo例子-来自cc】

总之,加入ROB后的Tomasulo算法通过ROB将“执行”与“提交”解耦。Tomasulo算法负责在前端和中端最大化乱序执行的吞吐量,而ROB负责在后端把关,确保体系结构状态的更新严格遵循程序原本的串行语义。这是现代高性能处理器(如Intel Core、ARM Cortex-A系列)实现精确异常和高效分支推测的核心基石。

5.3 多发射技术

在前面的学习中,无论是基础的5级流水线,还是Tomasulo动态调度,处理器的发射宽度始终是 1(即每个时钟周期最多只能从指令队列中提取并发射一条指令)。因此,其理想 CPI(每指令周期数)的极限只能趋近于 1。

为了打破这个理论极限,实现 CPI < 1(或者说 IPC,每周期指令数 > 1),体系结构引入了多发射技术,即在一个时钟周期内向执行部件发射多条指令。根据依赖检测是由硬件动态完成还是编译器静态完成,多发射技术主要分为两大流派:超标量(Superscalar)超长指令字(VLIW)

image-20260415164437639

5.3.1 超标量架构

超标量处理器的核心特征是:硬件在每个时钟周期内动态地决定能发射多少条指令。发射的指令条数是不固定的(受限于硬件的最大发射宽度,如4发射或8发射),具体取决于当前的指令序列是否存在冒险。下面介绍静态的超标量架构。

以一个4发射的静态调度超标量处理器为例,其发射逻辑极其复杂。在取指阶段,流水线一次性提取1~4条指令,构成一个发射包(Issue Packet)。为了确保按序发射且无数据/结构冒险,发射部件必须在一个时钟周期内完成两阶段的冒险检测逻辑

  • 第一阶段(包内冒险检测 / Intra-packet Detection): 检查发射包内部的这几条指令之间是否存在数据依赖。例如,包内第2条指令是否使用了包内第1条指令的结果;同时还要检查包内的指令是否请求了相同的物理功能单元(结构冒险)。
  • 第二阶段(包间冒险检测 / Inter-packet Detection): 将当前发射包中初步判定无内部冲突的指令,与流水线中正在执行的指令进行比对,检查是否存在 RAW 等数据冒险。

只有同时通过这两阶段检测的指令才能在当前周期被真正发射。如果包中第 \(n\) 条指令存在冒险,则只能发射前 \(n-1\) 条指令,剩余指令必须停顿到下一个周期重新尝试。

5.3.2 超长指令字(VLIW)

超标量架构的硬件冒险检测逻辑极其复杂,随着发射宽度的增加,硬件开销和功耗呈指数级上升。为了降低硬件复杂度,VLIW 架构将寻找指令级并行性(ILP)的重任完全交给了编译器(软件)。

1.VLIW的指令结构

在传统的MIPS架构中,一条指令是 32 位,只包含一个操作(比如一次加法)。在 VLIW 架构中,一条指令可能长达 128 位甚至 256 位以上。

这条极长的指令字并不是漫无目的的比特流,它在物理上被严格划分为多个固定的“操作槽(Operation Slots)”。假设一个处理器有 5 个物理功能部件(2个访存部件、2个浮点部件、1个整数部件),那么它的 VLIW 指令结构就严格包含 5 个对应的槽位:

1
[ 访存槽1 | 访存槽2 | 浮点槽1 | 浮点槽2 | 整数/分支槽 ]

当处理器取到这一条 VLIW 长指令时,硬件不做任何冒险检测,直接进行物理线路的“硬连线”分发——把访存槽1的控制信号直接送给访存部件1,把浮点槽1的控制信号直接送给浮点部件1。这就强制这 5 个部件在同一个时钟周期内严格并行执行

2.如何生成VLIW指令

因为硬件不负责检测冲突,如果放在同一个 VLIW 指令里的操作有数据依赖(比如访存槽1要 Load 数据,浮点槽1就要 Add 这个数据),程序直接算错。因此,编译器的任务是:在编译代码时,找到完全没有依赖关系的指令,把它们硬塞进同一个 VLIW 指令的各个槽位中。

例如,对于目标循环 x[i] = x[i] + s,可以将其展开为

image-20260415165605104

经过编译器的精妙编排,这 9 行代码(9 个时钟周期)完成了 7 次原始循环的工作,总共发射了 23 个有效子指令,期间没有发生任何数据冲突,极其高效地压榨了硬件的并发能力。

3.VLIW的缺陷

看起来 VLIW 既省了复杂的 Tomasulo 硬件调度电路,又能达到极高的并行度,为什么现在除了部分 DSP(数字信号处理器),主流的 CPU 都不用纯 VLIW 了呢?

  1. 代码长度暴增:正如刚才的例子,为了让 VLIW 的槽填满,编译器必须做大规模的循环展开。原本几行代码的循环,展开后变成了几十行。这导致编译出来的二进制文件体积巨大,极易耗尽指令 Cache 的容量,引发频繁的 Cache Miss,反而拉低了性能。
  2. 多余的位浪费:并不是所有代码都像科学计算那样有规律。如果某段代码中,无论编译器怎么努力,当前周期只能找到 1 个加法可以执行,那么 VLIW 的其他 4 个槽位就必须填入全 0 的无用编码(NOP,空操作)。这不仅浪费了宝贵的内存和缓存空间,还增加了取指令的带宽压力。一个现代的解决办法是:在内存中压缩存储,剔除 NOP,取到 CPU 译码阶段时再由硬件展开恢复。
  3. 死板的锁步机制:VLIW 强调同生共死。在一条 VLIW 指令中,如果“访存槽1”的 Load 操作遭遇了 Cache Miss,需要去主存拿数据(可能要停顿 100 个周期),那么即便是完全不依赖该数据的“浮点槽1”的指令,也必须陪着一起停顿。整个处理器会完全冻结。它没有动态调度那种“你卡住我先执行别人”的灵活性。
  4. 机器代码的向下/向上不兼容(最致命):假设第一代芯片 A 有 5 个槽。它的 VLIW 代码是按照 5 个槽的格式编译的。过了两年,芯片公司升级了架构,推出了有 8 个槽的芯片 B。此时,旧芯片 A 的二进制程序完全无法在芯片 B 上运行。因为机器码的物理位宽和字段定义彻底变了。所有的软件生态必须由开发者使用新编译器重新编译一次。这在通用计算机市场(如 Windows/PC 生态)是绝对无法被容忍的。

5.4 动态调度+多发射+推测

可以把动态调度、多发射和推测的思想结合起来,实现 ILP。

5.4.1 前提条件

假设处理器的硬件规则如下:

  • 每个时钟周期,处理器最多只能从队列提取并发射(Issue)2条指令进入保留站。

  • 必须严格按照代码写的顺序发射。

  • 所有的计算(地址计算、加法、分支比较)都有独立的硬件,不会发生结构冲突。

5.4.2 要执行的指令

1
2
3
4
5
Loop: LD    R2, 0(R1)
DADDIU R2, R2, #1 // 必须等 LD R2 算完(RAW依赖)
SD R2, 0(R1) // 必须等 DADDIU R2 算完才能把数据写进内存(RAW依赖)
DADDIU R1, R1, #8
BNE R2, R3, Loop

5.4.3 无推测VS有推测

无推测

周期 4,第2次迭代的 LD 被发射进入保留站,但是直到周期 8 才能进入执行阶段。这是因为在没有推测的架构下,跨基本块的控制依赖是绝对的物理隔离墙。第 1 次迭代的 BNE 在 周期 7 才执行完毕,得出到底跳不跳转的结论(这里是需要跳转的),因此第 2 次迭代的 LD 才开始执行,即使所需的数据(R1)早就准备好了。

在没有推测的机器上,控制依赖导致了严重的流水线气泡。在周期 5、6、7 这三个周期里,后端的功能部件几乎都在闲置等待。

有推测

引入ROB后,

  • 周期 4: 第2次迭代的 LD 被发射进入保留站和 ROB。
  • 周期 5: 第2次迭代的 LD 直接开始执行,因为硬件启动了分支预测,假设 BNE 会跳转。它所需的操作数是 R1;第 1 遍的 DADDIU R1 在周期 4 已经把新 R1 写到了 CDB 上。所以,它在周期 5 立刻抓取 R1 开始计算内存地址,周期 6 访存,周期 7 写 CDB。
  • 周期 5: 第2次迭代的 DADDIU R1 被发射。
  • 周期 6: 第2次迭代的 DADDIU R1 直接开始执行!(因为上一次的 R1 在周期 4 就好了)。

不过,虽然第 2 次迭代在周期 5、6 就乱序执行完了,但推测可能是错的,所以结果只暂存在 ROB 中,绝对不写入真实寄存器。此时,流水线必须遵循按序提交规则。在表格的“提交”列中,

  • 周期 5-7: 第 1 次迭代的 LD、DADDIU R2 依次安全提交。

  • 周期 8: 第 1 次迭代的 DADDIU R1 和 BNE 提交。在这一刻,硬件最终确认分支预测是正确的!

  • 周期 9: 既然周期 8 确认了预测正确,那么一直在 ROB 里憋着、早在周期 7 就已经算完并把结果放在 CDB 上的第 2 次迭代的 LD,终于在周期 9 被允许提交(将其临时结果正式写入物理寄存器 R2)。

6 指令提取带宽与BTB技术

6.1 痛点

在多发射超标量架构中,处理器每周期需要从指令Cache中提取4到8条指令。由于基本块(Basic Block)的平均长度通常只有5到6条指令,这意味着在每一个取指周期内,处理器几乎必然会遇到至少一条分支指令

只要遇到分支指令,下一条该取什么指令就成了未知数。如果等待分支方向和目标地址在后续流水级(如EX段或ID段)计算出来,取指阶段将出现严重的阻塞(Fetch Stall),导致后端强大的多发射功能部件无指令可执行(Starvation)。

因此,必须在取指(IF)的同一周期,不仅预测出分支是否跳转,还要直接提供跳转的目标PC值,实现“零周期延迟”的指令流拼接。

6.2 BTB:分支目标缓冲区

为了实现“零周期延迟”的跳转,体系结构引入了专门的硬件结构——分支目标缓冲区(Branch-Target Buffer, BTB 或 Branch-Target Cache)

6.2.1 BTB 的物理结构

BTB 本质上是一个采用硬件关联查找的高速缓存表,其每一项至少包含两个关键字段:

  • 地址标识(Tag): 记录曾经执行过且发生过跳转(Taken)的分支指令的PC地址。
  • 预测的分支目标地址(Target PC): 记录该分支指令上一次跳转的目标地址。

6.2.2 BTB 的工作机制

  • 在IF(取指)阶段,处理器用当前的PC值去取指令Cache的同时,并行地将该PC值送入BTB进行匹配
  • 如果匹配命中(Hit): 硬件假定当前正在提取的这条指令是一条分支指令,且预测它会跳转。BTB直接将其记录的“预测目标地址”送入PC寄存器。这样,在下一个时钟周期,处理器就可以直接从目标地址继续取指。
  • 如果未命中(Miss): 硬件将其视为普通顺序指令(或预测不跳转的分支),PC正常递增(PC + 4),按顺序提取下一条指令。

6.3 BTB 在五级流水线中的流转

因为BTB的预测是在 IF 段极早期进行的,此时处理器甚至还没对指令进行译码(不知道它到底是不是分支指令),所以必须在后续流水级进行验证与修正

  1. IF 段(推测发起): PC送入BTB。若命中,更新PC为目标地址;若未命中,PC正常递增。
  2. ID 段(指令验证): 对取到的指令进行译码。
    • 如果此前BTB命中了,但译码发现这根本不是一条分支指令(只是普通指令的PC碰巧和BTB里的残留Tag撞车了),硬件将取消推测,恢复PC,并正常执行。
  3. EX 段(结果决议与BTB更新): 实际计算分支条件和目标地址。
    • 预测正确: 流水线无缝继续执行,零周期停顿。
    • 预测错误: 清除由于错误预测而提前提取入流水线的指令。将正确的PC送入取指段重新取指。同时,更新BTB(修改目标地址,或从BTB中删除该项)。
    • 未命中但实际发生跳转: 说明遇到了一个新的跳转分支,硬件会将其PC和计算出的目标地址作为一个新项写入BTB

7 压榨CPU的极限:多线程技术与真实CPU案例

在此之前,我们所有的努力(流水线、分支预测、动态调度、多发射)都是为了最大程度地让指令并行。但到了2000年左右(如Pentium 4时代),ILP的开发遭遇了瓶颈。为了进一步提升性能,体系结构引入了线程级并行(TLP, Thread-Level Parallelism)技术。

7.1 线程的好处

进程切换需要陷入操作系统内核,保存大量的内存管理单元(MMU)状态,通常消耗成百上千个处理器周期。而硬件线程仅包含少量的处理器状态(程序计数器PC、一组物理寄存器集、栈指针等)。在处理器内部实现硬件级别的线程切换,只需要切换PC和对应的寄存器映射关系,开销可以降低到几个甚至零个时钟周期。

7.2 实现多线程的三种主要硬件方法

7.2.1 细粒度多线程(Fine-Grained Multithreading)

  • 机制: 处理器在每个时钟周期强制进行线程切换(通常基于时间片轮转调度算法)。如果某个线程遇到停顿(如Cache Miss或数据依赖),硬件调度器会直接跳过该线程。
  • 优点: 能够彻底掩盖长周期停顿(访存延迟)和短周期停顿(指令间数据冒险)导致的吞吐率损失。
  • 缺点: 严重牺牲了单线程的执行延迟(Latency)。因为即便某个线程完全没有停顿,它也必须等待其他线程轮转完毕后才能再次获得发射权,导致单个线程的完成时间被大幅拉长。

7.2.2 粗粒度多线程(Coarse-Grained Multithreading)

  • 机制: 处理器默认连续执行同一个线程,只有当当前线程遭遇长延迟事件(如L2 Cache Miss)时,才会触发线程切换
  • 优点: 不会降低单线程在无停顿状态下的执行速度。
  • 缺点: 无法掩盖短周期的流水线停顿。更致命的是流水线排空开销(Pipeline Drain Overhead):当发生线程切换时,处理器必须排空或暂停当前流水线中残留的旧线程指令,新线程需要重新填充流水线建立执行流,这会导致若干个完全空闲的时钟周期。

7.2.3 同时多线程(Simultaneous Multithreading, SMT)

  • 机制: 这是多发射超标量架构与多线程技术的终极融合(Intel称之为超线程 Hyper-Threading)。它允许在同一个时钟周期内,将来自多个不同线程的指令同时发射到处理器的流出槽(Issue Slots)中
  • 底层支撑: SMT 强依赖于我们之前学习的寄存器重命名动态调度(Tomasulo)机制。硬件通过为不同线程分配独立的逻辑寄存器堆,并映射到共享的庞大物理寄存器池中,使得多线程指令可以混合进入保留站,由动态调度硬件统一进行无冲突的数据流分发。

7.2.4 发射槽利用率对比

我们可以用垂直浪费(Vertical Waste)水平浪费(Horizontal Waste)来严谨地评估发射槽的使用效率。

  • 垂直浪费: 若干个时钟周期没有任何指令发射
  • 水平浪费: 在一个时钟周期内,多发射能力(如4发射)没有被填满

image-20260415171326679

8 总结与未来

理想情况下的研究表明,如果有无限的寄存器、完美的预测和缓存,ILP可以达到单周期发射18-150条指令。但在真实的受限硬件模型(有限ROB、物理寄存器和有限预测率)下,大幅增加发射宽度(如超过4发射或8发射),硬件的复杂度、功耗和时钟周期延迟将呈现非线性爆炸式增长,而性能的提升幅度却微乎其微。

在开发ILP的道路走到尽头后(以高功耗、低能效的 Pentium 4 为标志结束),从 2005 年开始,主流处理器制造商全面转向了多核架构(Multicore),将设计重点从“压榨单线程指令级并行(ILP)”彻底转向了“开发多线程/任务级并行(TLP)”。