感觉标题有点太大了,但是先这样定着吧,后面看看要如何修改。

CPU 是计算机的核心组件,想必会阅读这篇文章的或许都是计算机专业的学生,计算机组织结构里应该对 CPU 有过一定的了解,但是根据笔者的经验,教学阶段的 CPU 更多是停留在一个相对简化的模型,通常到多周期 CPU/五级流水线就结束了,对于现代的高性能处理器并不会有太多的涉及。本文将从 CPU 的基本工作原理开始,逐步深入到现代 CPU 的复杂结构和优化技术。在讲解之前,需要建立一套对 CPU 性能的评价指标体系。

性能评价指标

对于 CPU 来说,对于其性能评估就是能够更快的完成我们的计算任务,我们可以这样来计算一个程序的执行时间:

1
time = inst_cnt * CPI * \frac{1}{freq}

其中,CPI 为每条指令的平均时钟周期数,即 IPC 的倒数;freq 为 CPU 的时钟频率;inst_cnt 为程序执行的指令数。通过分析这个公式,我们可以看到提升 CPU 性能的三个主要途径:

  1. 减少指令数(inst_cnt):通过优化编译器和指令集设计,减少程序执行所需的指令数量;通常来说,对于某一个固定的 workloadISA,指令数是相对固定的,因此这个指标的提升空间有限。
  2. 提高时钟频率(freq):通过改进制造工艺和电路设计,提高 CPU 的时钟频率,从而减少每个时钟周期的时间。
  3. 降低每条指令的时钟周期数(CPI):通过改进 CPU 的微架构设计,减少每条指令所需的时钟周期数。

本文对 CPU 性能主要关注在后两部分,即通过提高时钟频率和降低 CPI 来提升 CPU 性能。对于指令数,通常是软件领域优化的更多,或者是部分较为常见的组合操作会被设计成单条指令来减少指令数。

单周期处理器

这一阶段是计算机组成原理课程的入门内容,单周期处理器的设计思想是让每条指令在一个时钟周期内完成。我们可以简单的认为 qemu 这样的模拟器所作的工作就是模拟一个单周期处理器;具体来说,单周期 CPU 的工作流程可以大概用如下的 C 代码来描述:

1
2
3
4
5
6
7
while(1) {
inst = fetch(PC); // 取指令
decoded_inst = decode(inst); // 译码
result = execute(decoded_inst); // 执行
write_back(result); // 写回
PC = update_pc(PC, decoded_inst, result); // 更新 PC,实际上在执行阶段更新,这里只是方便展示
}

这是一个很简单的 CPU 模型,每个周期内 CPU 都会完成取值,解码,执行,写回和更新 PC 循环。但是这种设计有一个很大的问题,就是所有指令都必须在一个周期内完成,这就导致了时钟周期必须足够长,以适应最慢的指令执行时间。比如说,乘法指令可能需要更多的时间来完成,而简单的加法指令则可以很快完成。为了适应乘法指令,整个 CPU 的时钟周期必须设置得很长,这样就浪费了很多时间在执行简单指令上;同时,从物理实现的角度来看,单周期由于一个周期内需要完成一条指令的所有操作,导致其很难达到较高的时钟频率,因为所有的逻辑电路都必须在一个周期内完成工作,这限制了时钟频率的提升。

关于时钟频率,我们可以简单的认为,一个周期内需要完成的逻辑越多,时钟频率就越难提升,因为需要保证所有逻辑在一个周期内完成工作。

但是这样的设计也有它的优点,设计简单,易于理解和实现。对于教学来说,单周期处理器是一个很好的起点,可以帮助学生理解 CPU 的基本工作原理。同时,他还有另外的一个优点,不存在数据冒险和控制冒险的问题,因为每条指令都在一个周期内完成,不会有指令之间的依赖关系。

性能数据分析

  1. IPC: 由于单周期处理器每条指令都在一个周期内完成,因此 IPC(每周期指令数)为 1。
  2. 时钟周期: 受限于最慢指令的执行时间,时钟周期会较长

多周期处理器

多周期处理器的设计思路是将指令的执行分为多个阶段,每个阶段都只完成指令执行的一部分,这样每个阶段的时钟周期可以更短,从而提高整体的时钟频率。多周期处理器通常将指令执行分为以下几个阶段:

  1. 取指令(Fetch):从内存中取出指令。
  2. 译码(Decode):解析指令,确定操作类型和操作数。
  3. 执行(Execute):执行指令的操作。
  4. 访存(Memory Access):如果指令需要访问内存,则在此阶段进行。
  5. 写回(Write Back):将结果写回寄存器。

实现上通常会采用状态机来控制各个阶段的切换,每个阶段对应一个或多个时钟周期。这样,每条指令可能需要多个时钟周期才能完成,但是每个时钟周期可以更短,从而提高了整体的时钟频率,对应的一个可能的 C 代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
while(1) {
switch(state) {
case FETCH:
inst = fetch(PC);
state = DECODE;
break;
case DECODE:
decoded_inst = decode(inst);
state = EXECUTE;
break;
case EXECUTE:
result = execute(decoded_inst);
if (needs_memory_access(decoded_inst)) {
state = MEMORY_ACCESS;
} else {
state = WRITE_BACK;
}
if (is_branch_instruction(decoded_inst)) {
PC = calculate_branch_target(PC, decoded_inst, result);
}
break;
case MEMORY_ACCESS:
memory_result = memory_access(result);
state = WRITE_BACK;
break;
case WRITE_BACK:
write_back(memory_result ? memory_result : result);
state = FETCH;
break;
}
}

多周期处理器相比较之下,每个时钟周期所需要进行的逻辑操作更少,相比较之下可以实现更高的处理器频率。但是同时也能看到,硬件资源的利用率是不高的,每个周期只有一部分硬件单元在工作,其他的单元处于闲置状态,这就给后续的流水线设计留下了空间。

性能数据分析

  1. IPC: 由于每条指令需要多个周期才能完成,因此 IPC 通常小于 1,具体数值取决于指令的平均周期数,例如上面的理论最高 IPC 为 1/5 = 0.2。
  2. 时钟周期: 由于每个阶段的逻辑操作较少,时钟周期可以更短,因此整体时钟频率较高。

流水线处理器设计(以五级流水线为例)

流水线处理器的设计思想是将指令的执行过程划分为多个阶段,并且允许多条指令同时在不同的阶段进行处理。这样可以提高指令的吞吐量,因为在任何给定的时刻,处理器都可以同时处理多条指令。以经典的五级流水线为例,其五个阶段分别是:

  1. 取指 (IF):从指令存储器中读取指令,同时更新 PC (PC + 4)。
  2. 译码 (ID):从寄存器堆读取源操作数,同时对指令进行解码以产生控制信号。
  3. 执行 (EX):ALU 进行计算(如加减运算、地址计算)或分支判断。
  4. 访存 (MEM):如果是 Load/Store 指令,在此阶段访问数据存储器。
  5. 写回 (WB):将结果写回寄存器堆。

与多周期处理器不同,流水线处理器在理想情况下,每一个时钟周期都有一条指令完成(即 IPC 接近 1),同时因为逻辑被拆分,时钟频率依然可以保持在较高水平。

然而,流水线设计并非没有代价。虽然它提高了吞吐率,但并没有降低单条指令的延迟(Latency)。更重要的是,它引入了冒险(Hazard) 问题。

流水线冒险 (Pipeline Hazards)

当流水线同时处理多条指令的时候,就引入了冒险问题,大体上可以分为如下的几类

结构冒险 (Structural Hazard):硬件资源冲突。

最常见的场景就是访存和取值之间对存储器的竞争,例如一条 load 指令在 MEM 阶段访问数据存储器的同时,下一条指令在 IF 阶段也需要访问指令存储器。这种竞争是我们不希望看到的,因为这可能会导致流水线执行出错或者停顿(例如,load 执行的时候 IF 会暂停,直到 load 访问完成)。

解决方案也很直接,增加对应的硬件资源,这也能很好的解释为什么当今处理器通常会有独立的指令缓存和数据缓存 (Harvard Architecture):通过分离指令和数据存储器,避免取指和访存之间的结构冒险。

数据冒险 (Data Hazard):下一条指令依赖于上一条指令的计算结果,而该结果尚未写回。

单独看起来会略有抽象,我们通过例子来进行说明:

1
2
add r1, r2, r3  // inst 1
add r4, r1, r5 // inst 2

上面两条指令,其中 1 号指令的结果写回寄存器 r1 需要在 WB 阶段完成,而 2 号指令在 ID 阶段就需要读取寄存器 r1 的值,这就导致了数据冒险。如下图

1
2
add1: IF -> ID -> EX -> MEM -> WB (write r1)
add2: IF -> ID(read r1) -> EX -> MEM -> WB

能够看到,add2ID 阶段就需要读取 r1 的值,而此时 add1 还没有完成写回操作,这会导致 add2 读取到错误的值,导致执行出错。

对于数据冒险,常见的解决方案有两种:

流水线停顿 (Pipeline Stall)

当检测到数据冒险时,暂停后续指令的执行,直到数据准备好为止。这样可以确保指令按照正确的顺序执行,但会降低流水线的效率;通常有两种方法可以实现停顿:

  1. 软件检测(例如编译器):编译器在生成代码时,插入 NOP 指令来避免数据冒险。
  2. 硬件检测(例如流水线控制逻辑):处理器在运行时检测到数据冒险,自动插入停顿周期。

相比较之下,方法 2 是更实际的做法,方法 1 实际上隐含了编译器需要了解底层的硬件设计,为什么这么说呢,我们依然拿上面的例子举例:我们需要插入 nop,但是需要多少个 nop 才能保证数据正确呢?答案是取决于流水线的设计的,如果是五级流水线,那么我们需要插入 2 个 nop,如下所示:

1
2
3
4
add r1, r2, r3  // inst 1
nop // stall 1
nop // stall 2
add r4, r1, r5 // inst 2

这样对应过来

1
2
3
4
add1: IF -> ID -> EX -> MEM -> WB (write r1)
nop : IF -> ID -> EX -> MEM -> WB
nop : IF -> ID -> EX -> MEM -> WB
add2: IF -> ID(read r1) -> EX -> MEM -> WB

假如流水线行为发生了变化,例如新硬件平台引入了更多的流水线阶段,那么编译器就需要重新计算需要插入多少个 nop,这显然是不现实的,也违背了编译器与硬件解耦的设计原则。

而对于硬件实现的 Stall,我们只需要解码阶段检测所需要读取的寄存器编号,后续流水级中如果有指令正在写回相同的寄存器编号,那么就插入停顿周期,直到数据准备好为止。

数据前推 (Data Forwarding)

数据前推实际上是更为高效的解决方案。它通过在流水线中增加数据通路,将尚未写回的数据直接传递给需要它的指令,而不必等待写回阶段完成。这样可以大大减少停顿的次数,提高流水线的效率。简单来说,数据前推就是在执行阶段直接从后续阶段获取数据,而不是等待写回阶段。

例如对五级流水线来说,如果想要做到理论上的无 Stall,则我们至少需要实现如下几种前推路径:

  1. WB->EX 前推路径:将写回阶段的结果直接传递给执行阶段。
  2. MEM->EX 前推路径:将访存阶段的结果直接传递给执行阶段。

通过这些前推路径,处理器可以在执行阶段获取到最新的数据,而不必等待写回阶段完成,从而避免了数据冒险带来的停顿。

控制冒险 (Control Hazard):遇到跳转指令时,处理器不知道下一条该取哪里的指令。

我们通过一个简单的示例就可以说明这个问题:

1
2
3
4
5
beq r1, r2, target  // inst 1
add r3, r4, r5 // inst 2
...
target:
sub r3, r6, r7 // inst 3

上面的伪代码流水图如下

1
2
beq: IF -> ID -> EX -> MEM -> WB
add: IF -> ID -> EX -> MEM -> WB

可以看到,beq 指令只有在 EX 阶段才能确定是否跳转以及跳转目标地址,而在 beq 指令的 IFID 阶段,处理器已经开始取下一条指令 add 了。如果 beq 指令决定跳转,那么 add 指令就不应该被执行,这就导致了控制冒险。

对于这个问题,解决方案也有几种:

流水线停顿 (Pipeline Stall)

依然是最直接的解决方案,当遇到跳转指令时,暂停后续指令的执行,直到跳转目标地址确定为止。这样可以确保指令按照正确的顺序执行,但会降低流水线的效率。同样的,也可以通过软件和硬件两种方式实现停顿。但是基本上,控制冒险的停顿通常是通过硬件实现的,硬件解码阶段检测到跳转指令时,自动插入停顿周期。

分支预测 (Branch Prediction)

分支预测是一种更为先进的技术,通过预测跳转指令的结果,提前加载可能需要执行的指令,从而减少停顿的次数。分支预测器可以基于历史执行情况来做出预测,例如使用简单的静态预测(总是预测跳转或不跳转)或者动态预测(基于过去的跳转行为进行预测)。对于分支预测这个话题,后续会在超标量处理器中进行更充分的讨论,这里只需要简单了解有这个技术即可。

性能数据分析

  1. IPC: 理论上,流水线处理器的 IPC 可以接近 1,但实际中由于冒险和停顿,IPC 通常低于 1,具体数值取决于程序的指令特性和流水线设计。
  2. 时钟周期: 由于流水线将指令执行分为多个阶段,每个阶段的逻辑操作较少,时钟周期可以更短,因此整体时钟频率较高。

总结

总结与展望

回顾 CPU 架构的演进历程,我们经历了一个从“时间”到“空间”的权衡过程:

  1. 单周期处理器:结构最简单,但受限于最长路径,时钟频率(Freq) 极低,性能受限。
  2. 多周期处理器:通过切分状态机提高了频率,但导致 IPC 大幅下降,且硬件单元在大部分时间处于闲置状态。
  3. 流水线处理器:结合了前两者的优点,利用指令级并行(ILP) 的思想,让多条指令在时间上重叠执行。在理想情况下,它能达到 IPC \approx 1 的吞吐率,同时保持较高的时钟频率。

然而,经典的五级流水线依然存在两个巨大的瓶颈,限制了它成为现代高性能处理器的核心:

  1. 标量限制(Scalar Limit):无论流水线切分得多么细致,顺序流水线每个周期最多只能取指和退休一条指令,这意味着 IPC 的理论上限被死死锁在 1.0。要想突破这个天花板(IPC > 1),我们需要超标量(Superscalar) 技术,即一次“发射”多条指令。
  2. 顺序阻塞(In-Order Blocking):虽然我们有了数据前推(Forwarding),但在面对长延时操作(如 Cache Miss 导致的访存)时,顺序流水线必须全线停顿(Stall),后续即使有与当前无关的指令也无法执行。为了压榨硬件性能,我们需要乱序执行(Out-of-Order Execution),允许指令“插队”执行。

顺序标量迈向乱序超标量,是计算机体系结构从“基本能用”到“极致性能”的跨越。这涉及到了重排序缓冲区(ROB)、保留站(Reservation Station)、寄存器重命名(Register Renaming)等更为复杂的微架构设计。

在下一篇文章中,我们将推开现代高性能处理器的大门,探究 CPU 是如何在纳秒级的尺度下,通过这些复杂的动态调度逻辑,将性能推向极限的。