Know Your Hardware - Modern CPU Architecture

Micro-Architecture of a Modern CPU

CPU 是整个计算机系统的核心,它负责接收数据、执行运算、并输出结果。传统上,CPU 就是处理器,处理器就是 CPU,CPU 也简单地分为算术逻辑单元 ALU 和控制单元 CU。其中 ALU 负责执行算术和逻辑运算,CU 负责指令的解码,执行流程控制以及协调处理器内部的各个组件。

但随着半导体技术和计算架构 (computing architecture) 数十载的发展,CPU 早已突破课本中传统的 ALU+CU 的简单结构,形成了复杂的架构,包含更多的模块。广义上,如今的 CPU 不仅仅包含 CPU 核心 (CPU cores),还把内存管理、IO管理、图形显示(集成显卡)都集成在一颗芯片上,我们把这种设计称为片上系统 (System-on-Chip, Soc)。

在 SoC 结构中,CPU 核心仍然是整个系统的中心,而且当代 CPU 微架构除了传统上的 ALU 和 CU 外,还集成许多关键模块一提升计算效率。我们会在本节简单地看看现代 CPU 微架构上什么样子。

Modern CPU Building Blocks

本节,我们会简单的了解一下现代 CPU 的流水线技术、分支预测器、缓存、超标量计算和指令的乱序执行。

Four Stages of an Instruction Cycle

我们知道,高级语言源程序是没有办法在机器(CPU)上运行的,CPU 能读懂的只有用 01 表示的机器语言代码,所以运行源文本程序前我们有一步编译操作。

你可以把 CPU 想象成一个小机器人,你传递给它一条机器语言指令并期望得到它的回复。每次在回复你传递的每一条指令前,小机器人会完成一系列的操作,包括:取指令 (Fetch)、解码指令 (Decode)、执行微指令 (Execute) 和回写结果 (Write Back)。

在取指令阶段,CPU 这个小机器人会从缓存或内存中接收读取机器指令并将其存放在一个指令寄存器.(Instruction Register) 中。随后的解码指令阶段,CPU 这个小机器人可能会接收一条或多条指令并输出一条或多条微指令(一般为 1 对 1)。微指令 (Micro-Operations, μOps) 是 CPU 执行阶段能够直接执行的基本操作。

将解码完成机器指令后,CPU 就开始了执行阶段,这个阶段中,CPU 会执行这些微操作。在 CPU 这个黑匣子里面的计算任务完成后,它还需要把结果存入寄存器供后续指令使用或是存回内存反馈给我们,这是指令周期的最后一步——回写阶段。

CPU Pipeline

如果我们有许多要执行的机器指令,现代 CPU 并不会逐条地执行指令,而是以一种流水线的方式并行化指令执行,这就是指令流水线 (pipeline)。就像生产流水线一样,前面的工人正在组装零件,后面的工人已经开始测试产品。指令流水线技术使得一个 CPU 核心可以同时执行多条不同阶段的指令,可以显著地提高吞吐量。

随着时间的推移,流水线的阶段已不仅仅是指令周期的四个阶段了,每个阶段(如取指令阶段)还会进一步的拆分成更小的子阶段,用于更好的提高 CPU 的吞吐量,现代 CPU 通常采用深流水线设计 (deep pipeline depth),深度通常为 15-20 级。

流水线级数的增加源于 CPU 主频的停滞不前,因为 CPU 是时钟驱动的,一个 stage 到另一个 stage 需要一个时钟周期。而深流水线设计可以让每一个时钟周期同时执行 15-20 条指令,也就是即使 CPU 的频率恒定,IPC 也能够提升。

在深流水线中,取指令和解码阶段通常包含 6-10 个阶段,因为这两个阶段位于 CPU 流水线的前端,所以 CPU 中负责处理这两个阶段的 infra 也被称作处理器前端 (front-end)。执行和写回阶段也通常包含 6-10 个阶段,负责这两个阶段的处理器 infra 被称为后端 (back-end)。

但这种深流水线的设计还可能导致性能下降,比如说当 CPU 遇到判断语句的时候,

Speculative Execution

前面提到了 CPU 级数增多可能导致性能下降,这是什么意思?在 CPU 指令流水线中,最开始前端会从内存或缓存中 fetch 一条指令,经过 decode 并在 execute 阶段执行这条指令。然而,ISA 给我们提供了一些跳转指令 (branches)。当 CPU 在 execute 阶段碰到 branches 时,它所面临的就是一个双叉路口,就相当于:

if condition_is_true:
	this_branch_of_code
else:
	another_branch_of_code

这些跳转指令决定了程序的执行路径。在早期的流水线处理器中,由于没有分支预测机制,fetch 阶段在遇到跳转指令时,通常会等待后端 execute 阶段处理的结果。这种设计会导致流水线停顿 (pipeline stall),直到 execute 阶段明确跳转目标。如果代码中包含大量跳转指令,CPU 的性能就会严重受损。(The deeper the pipeline stage depth, the greater the penalty you incur)

为了优化这种等待问题,现代处理器微架构引入了分支预测和投机执行(也就是 prefetch)。分支预测由一个前端部件——分支预测器提供,用于判断程序的执行分支路径,减少流水线停顿,通常情况下,准确率可以达到 90% 以上。

投机执行就是在分支预测的基础上,CPU 提前执行预测路径上的指令。如果预测正确,那么计算结果直接使用,如果预测错误,那么丢弃前面流水线阶段中的计算结果,重新执行正确的路径。同时更新分支预测器的算法。所以分支预测错误的惩罚实际上是很高的,我们将错误预测的情况称为分支预测错误惩罚 (Branch Misprediction Penalty)。

尽管平均 90% 的分支预测准确率看上去已经很高了,但实际上这个准确率是很差的,对于想要优化性能的软件工程师,他们会考虑尽量剔除程序中有关分支的代码,也就是无分支编程,尽可能的将分支预测率提高到 99.9% 以上。详见:Branchless Programming Intro in C++ (ENG)

Caches

缓存技术,用于解决 CPU 和内存速度不匹配的问题。现代 CPU 的运行速度远远快于主存(差距达百倍)。通常 CPU 的时钟周期是纳秒级别的,而内存的访问延迟可能是几十到几百纳秒,这种情况下,对内存的访问就会成为性能瓶颈。

Cache 是 CPU 前端和内存之间的高速存储层,用于临时存储经常性访问的数据。现代 CPU 会采用多级缓存来优化访问速度,而且缓存会预取 (prefetch) CPU 前端可能 fetch 的指令数据,进一步减少访问延迟。

在 fetch 阶段,CPU 会尝试先从 cache 中读取指令和数据,如果 cache 命中(数据和指令在缓存中)则 CPU 无需访问主存。若未命中,CPU 就需要访问 RAM 并把指令和数据加载到缓存中,以便后续访问。现代计算机系统的 cache 命中率通常能够达到 95% 以上。

Superscalar Execution & Out-of-Order Execution

ALU 负责一些微指令的执行,在过去(如 8086 ),每个时钟周期只能按顺序执行一条指令。我们把这种指令执行方式叫做标量执行。随着 CPU 进入微架构时代,每个 CPU 时钟周期都可以执行多条指令,这主要依赖于现代 CPU 核心后端的许多 ALU。此外,随着 CPU 核心数的增加,处理器可以并行地执行更多指令,从而显著提高吞吐量。所有的现代 CPU 都是超标量的。

由于每个核心都有许多个 ALU,所以每次 CPU 后端进行计算时,都可以同时(并行地)进行多个算数逻辑运算,在现代 CPU 眼中,这两段代码所耗费的时间是一样的:

...
std::vector<int> vec1[N], vec2[N];
... // Initilization
int a = 0;

for(size_t i = 0; i < N; ++i) {
	a = vec1[i] + vec2[i];
}
...
std::vector<int> vec1[N], vec2[N];
... // Initilization
int a = 0, b = 0, c = 0, d = 0;

for(size_t i = 0; i < N; ++i) {
	a = vec1[i] + vec2[i];
	b = vec1[i] - vec2[i];
	c = vec1[i] * vec2[i];
	d = vec1[i] / vec2[i];
	... // Even more!!
}

上面的实例中并不存在数据依赖(比如要得到 b,我们先得求出 a),而在大多数的情况下,我们的程序都难免存在数据依赖。为了最大化地利用后端的执行部件,并保证后端的执行部件的并行执行不会影响运算结果,现代 CPU 会采用乱序执行来优化指令调度,让指令不必按照编写顺序执行,而是根据数据可用性和执行单元状态进行调度。假如我们有:

...
std::vector<int> vec1[N], vec2[N];
... // Initilization
int a = 0, b = 0, c = 0, d = 0, e = 0, f = 0, g = 0, h = 0;

for(size_t i = 0; i < N; ++i) {
	a = vec1[i] + vec2[i];
	e += a;
	b = vec1[i] - vec2[i];
	f += b;
	c = vec1[i] * vec2[i];
	g += c;
	d = vec1[i] / vec2[i];
	h += d;
	... // Even more!!
}

在指令调度后,CPU 后端会先执行:

	a = vec1[i] + vec2[i];
	b = vec1[i] - vec2[i];
	c = vec1[i] * vec2[i];
	d = vec1[i] / vec2[i];

随后执行:

	e += a;
	f += b;
	g += c;
	h += d;

在指令乱序执行的过程中,后端涉及到的组件有:重排序缓冲区 (Reorder Buffer, ROB), 调度器 (Scheduler), 储存缓存器 (Store) 以及缓存子系统 (包括 L1 缓存、L2 缓存和相关的队列)。

ROB 负责跟踪所有正在执行的指令状态,确保指令的退休严格按照原始程序的顺序进行。当某条指令完成,而且这条指令在 ROB 中是当前 latest 的指令(其他的所有指令都已完成),那么该指令就可以退休 (retire),表示该指令的计算结果可以正式的生效。

调度器负责监视已分配的指令的操作数是否已经就绪并检查对应的 ALU 是否空闲,一旦指令操作数就绪的同时 ALU 可用,那么调度器就会把指令分派给对应的 ALU 乱序执行。

存储缓存器用于缓存已退休但未写入缓存的存储操作,因为对缓存/内存的写入是不可逆的,为了保证最后的写入是一致性的,我们就需要在存储缓存器中对这些存储操作进行排序。

最后写入缓存子系统或是内存中。