现代微处理器架构 90 分钟指南

本文翻译自:Modern Microprocessors A 90-Minute Guide!,,我认为原文是相当好的计算机体系结构方面的概述,与时代相结合是国内计算机课本普遍缺失的一环,本文可作为一个有效的补充,向原作者和其他译者表示感谢。

(阅读 https://cxd2014.github.io/2016/09/15/cpu-architecture/ 时发现他没有翻完整,于是花了点时间翻完然后发现 CSDN 有人也翻过完整的,sigh,也罢也罢,全当是复习和练英语)

现代微处理器架构

警告:本文章非权威,仅仅只是兴趣

好吧,如果你是 CS 专业毕业的并且大学期间学过硬件相关知识,但是不了解近几年中现代处理器的设计细节。那么这篇文章正适合你。

通常你应该不知道近些年来 CPU 发展的几个关键技术。。。

  • 流水线(超标量,OOO,VLIW,分支预测,谓词执行)
  • 多核和同步多线程(simultaneous multi-threading — SMT,超线程)
  • SIMD 向量指令(MMX/SSE/AVX, AltiVec, NEON)
  • 缓存和存储器分层结构

不要害怕,本文将带你快速了解这些概念,让你在任何时候可以像专家一样谈论顺序执行和乱序执行之间的区别,超线程,多核和缓存结构等这些话题。

但要做好心理准备 —— 本文非常简短只会点到为止,不会介绍过多细节。让我们正式开始吧。。。

不仅仅是频率

首先必须搞清楚的第一个问题是时钟频率和处理器性能之间的区别。他们是不一样的,先来看看几年前的处理器的性能和频率(上世纪 90 年代末):

频率 型号 SPECint95 SPECfp95
195 MHz MIPS R10000 11.0 17.0
400 MHz Alpha 21164 12.3 17.2
300 MHz UltraSPARC 12.1 15.5
300 MHz Pentium II 11.6 8.8
300 MHz PowerPC G3 14.8 11.4
135 MHz POWER2 6.2 17.6

表 1 – 1997 年左右的处理器性能

200MHz 的 MIPS R10000 处理器、300MHz 的 UltraSPARC 处理器和 400MHz 的 Alpha 21164 处理器之间的频率都不一样但是在运行大多数程序时的速度是一样的。300MHz 的 Pentium II 处理器在大多数情况下也有相同的速度,但是在处理浮点运算时的速度却只有一半;PowerPC G3 处理器的频率也是 300MHz 它在处理常规整型运算时比其他处理器快,但是浮点运算性能却远远低于前三名。更极端的是 IBM POWER2 处理器仅仅只有 135MHz 的频率,它的浮点运算性能与 400HMz 的 Alpha 21164 处理器相当,而整型运算能力却只有它的一半。

这是怎么回事?很显然这不仅仅只是因为时钟频率的不同,而是主要取决于处理器在每个时钟周期中是怎样工作的。

流水线和指令级并行

指令在处理器中是一个接着一个的执行,这样理解对吗?虽然这很容易理解但是实际情况并不是这样的。事实上从 19 世纪 80 年代中期开始多个指令可以同时并行执行。

想一想是怎样执行的?首先是取指、解码,接着在合适的功能单元中执行,最后将结果写入寄存器。根据这种方式,一个简单的处理器执行一条指令需要 4 个周期(CPI=4)。

图 1 – 顺序处理器的指令流

现代处理器将这些阶段叠加到一条流水线中,就像一条装配流水线。一条指令正在执行的同时下一条指令开始解码,下下一条指令则正在取指。。。

图 2 – 流水线处理器的指令流

现在处理器的每个时钟周期可以执行一条指令(CPI=1)。在完全没有改变时钟频率的情况下将处理器速度提高了 4 倍。还不错吧?

站在硬件的角度,流水线的每个阶段由一些组合逻辑和可能访问寄存器组或者某种形式的高速缓存组成。流水线的每个阶段通过锁存器分开。一个共同的时钟信号来同步每个流水线阶段之间的锁存器,以便于所有锁存器在同一时间捕获流水线的每个阶段产生的结果。也就是说使用时钟来驱动指令在流水线上流动。

在每个时钟周期的开始,流水线中的锁存器保存着当前正在执行指令的数据和控制信息,这些信息构成了该指令输入到流水线下一阶段的逻辑电路。在一个时钟周期中,信号通过这一阶段的组合逻辑传输到下一阶段,在时钟周期的最后每个阶段产生的输出正好被下一阶段的锁存器捕获。。。

图 3 – 流水线微架构

因为每条指令在流水线的执行阶段完成后产生的结果是可用的,下一条指令应该可以马上使用这个结果,而不是等到这个结果在流水线写回阶段被提交到目标寄存器后才使用,为了实现一点,增加了被称为 “旁路” 的转发线路,将结果沿着流水线返回。。。

图 4 – 流水线微架构中的旁路

虽然每个流水线阶段看起来很简单,但是在 执行 这个关键阶段需要制造几组不同的逻辑(多条路径),为处理器必须有的每一种操作制作不同的功能单元。。。

图 5 – 流水线微架构中的更多细节

早期的 RISC 处理器,例如 IBM 的 801 研究原型,MIPS R2000(基于斯坦福大学的 MIPS 架构)和原版的 SPARC(伯克利 RISC 的衍生项目) ,都实现了简单的 5 级流水线和上面介绍的一样。在同一时期主流的 80386, 68030 和 VAX 这些 CISC 处理器的大部分指令还是工作在顺序执行模式 — 因为 RSIC 处理器可以更加简单的实现流水线模式,因为精简指令集意味着这些指令绝大多数都是简单的寄存器到寄存器之间的操作,不像 x86, m68k 或者 VAX 这些复杂指令集。结果导致 20MHz 的流水线模式的 SPARC 处理器比 33MHz 的顺序执行模式的 386 处理器运行速度还要快。从那时开始每个处理器都实现了流水线模式,至少一定程度上的流水线化。David Patterson 的这篇文章 1985 CACM article 对早期 RISC 研究项目做了一个很好的总结。(译注:就是本文末尾提到的 计算机组成原理硬件软件接口 和 计算机体系结构量化方法 的作者)

深度流水线

由于时钟频率限制于(其他因素除外)流水线中最长、最慢的那个阶段(译注:一个时钟周期要大于流水线中最慢的那个阶段),逻辑门可以进一步细分每个流水线阶段,特别是最长的那个阶段,致使流水线变成更加细化的包含大量的短小阶段的超级流水线。因此整个处理器可以运行在一个更高的时钟频率下!当然,此时每条指令需要花费更多的时钟周期来完成(时延),但是处理器仍然是每个周期完成一条指令(吞吐量),由于时钟频率更快所以处理器每秒可以执行更多的指令(实际性能)。。。

图 6 – 超级流水线处理器的指令流

Alpha 架构特别喜欢这种技术,这也是为什么早期 Alphas 处理器拥有深度流水线在当时的时代下可以运行在如此之高的时钟频率下。如今,现代处理器致力于限制逻辑门的数量以减少每个流水线阶段的延迟,每个流水线阶段大概 12-25 级门电路加上另外 3-5 个锁存器,但是大多数处理器都有非常深度的流水线。

Pipeline Depth Processors
6 UltraSPARC T1
7 PowerPC G4e
8 UltraSPARC T2/T3, Cortex-A9
10 Athlon, Scorpion
11 Krait
12 Pentium Pro/II/III, Athlon 64/Phenom, Apple A6
13 Denver
14 UltraSPARC III/IV, Core 2, Apple A7/A8
14/19 Core i*2/i*3 Sandy/Ivy Bridge, Core i*4/i*5 Haswell/Broadwell
15 Cortex-A15/A57
16 PowerPC G5, Core i*1 Nehalem
18 Bulldozer/Piledriver, Steamroller
20 Pentium 4
31 Pentium 4E Prescott

表 2 – 常用处理器的流水线深度

(译注:这位原作者似乎喜欢使用 Core i*[n] 代表第 n 代酷睿,下同,不再赘述)

通常 x86 处理器的流水线级数比 RISCs(同时代的)的更多,因为他们需要额外的工作来解码复杂的 x86 指令集(下面会介绍更多)。UltraSPARC T1/T2/T3 Niagara 处理器是深度化流水线趋势中的例外 — UltraSPARC T1 只有 6 级流水线,T2/T3 是 8 级,这样做的目的是保持处理器核心尽可能的小(下面也会具体介绍)。

多发射:超标量

由于流水线的执行阶段是一组不同的功能单元,每个功能单元完成他们自己的任务,所以可以尝试多条指令在他们自己的功能单元中并行执行,为了实现这一目标,取指和解码 / 调度阶段必须强化使他们可以并行解码多条指令,然后将他们分发到 “执行资源” 中去:

图 7 – 超标量微架构

当然,现在每个功能单元之间的流水线相互独立,他们甚至可以有不同数量的流水线级数。这可以使简单的指令更快的执行,从而减少延迟(我们很快会讲到延迟的概念)。由于这种处理器有很多不同的流水线级数,可以很正常的想到执行整型指令的流水线通常最短,存取指令和浮点指令流水线会有少量其他的流水线阶段。因此,一个 10 级流水线的处理器使用 10 级来执行整型指令,存取指令可能有 12 或者 13 级,浮点指令可能有 14 或者 15 级。流水线内部和流水线之间也会有一堆旁路,但是为了简化上图中省略了旁路。

在上面的例子中,处理器可能在一个时钟周期内发射 3 个不同的指令 — 例如 1 个整型指令,1 个浮点指令和 1 个存取指令。甚至可以添加更多功能单元,实现处理器在每个时钟周期内可以执行 2 个整型指令,或者 2 个浮点指令,或者使目标应用程序可以最高效率运行的任何指令组合。

在一个超标量处理器中,指令流大概像是这样的。。。

图 8 – 超标量处理器中的指令流

这是非常棒的!现在每个时钟周期可以完成 3 个指令(CPI=0.33 或者 IPC=3,也可以写成 ILP=3(instruction-level parallelism)- 指令级并行),处理器在每个时钟周期内可以发射、执行或者完成的指令数量称为处理器的带宽。

注意发射带宽一般小于功能单元的数量,因为不同的代码序列有不同的指令组合,我们的目标是达到每个时钟周期执行 3 条指令但是这些指令不可能总是 1 个整型指令,1 个浮点指令和 1 个内存操作指令,因此功能单元的数量需要大于 3 个。

IBM POWER1 处理器 – PowerPC 的前代是第一个主流超标量处理器。之后大多数 RISCs 处理器(SuperSPARC, Alpha 21064)开始使用超标量。Intel 也试图制造 x86 架构的超标量处理器 – Pentium 处理器的原始版本 – 然而复杂的 x86 指令集成了一个很大的难题。

当然,处理器的深度流水线和多指令发射技术都在发展,所以超级流水线和超标量可以同时出现:

图 9 – 超流水线 – 超标量处理器的指令流

如今实际上每一款处理器都同时是超流水线 – 超标量的,所以被简称为超标量。严格的说超流水线只是表示更深级数的流水线。

现代处理器之间的带宽差别很大:

Issue Width Processors
1 UltraSPARC T1
2 UltraSPARC T2/T3, Scorpion, Cortex-A9
3 Pentium Pro/II/III/M, Pentium 4, Krait, Apple A6, Cortex-A15/A57
4 UltraSPARC III/IV, PowerPC G4e
4/8 Bulldozer/Piledriver, Steamroller
5 PowerPC G5
6 Athlon, Athlon 64/Phenom, Core 2, Core i*1 Nehalem, Core i*2/i*3 Sandy/Ivy Bridge, Apple A7/A8
7 Denver
8 Core i*4/i*5 Haswell/Broadwell

表 3 – 通用处理器的发射带宽

每款处理器的功能单元的实际数量和类型取决于目标市场。某些处理器的浮点运算资源更多(IBM 的 POWER 处理器产品线),其他处理器则更加倾向于整型运算(Pentium Pro/II/III/M),还有一些处理器则将资源投向 SIMD 向量指令(PowerPC G4/G4e),然而总的来说大多数处理器尽量使各种资源均衡。

显示并行:VLIW

在那些不需要考虑向后兼容问题的使用场景中,使指令集本身被设计为可以并行执行的显示分组指令(explicitly group instructions)成为可能,这种方法消除了在调度阶段需要的复杂的依赖性检查逻辑,可以使处理器的设计更加简单,体积更小,更容易的提高时钟频率(至少在理论上)。

在这种类型的处理器中,指令是指被分组过的更小的子指令集合,因此指令本身是非常长的,通常是 128 比特或者更多。所以 VLIW 的意思是超长指令字(very long instruction word),每条指令包含了多个并行操作的信息。

除了解码 / 调度阶段更加简单(因为只需要解码 / 调度每个组中的子指令)外 VLIW 处理器的指令流和超标量的很像。。。

图 10 – VLIW 处理器的指令流

除了简化调度逻辑外 VLIW 处理器和超标量处理器非常相似。尤其是站在编译器的角度来看(下面会讲到)。

值得注意的是,大多数 VLIW 的指令相互之间没有关联。这意味着指令之间不需要依赖性检查,并且当缓存未命中时没有办法停止单独指令的执行只能停止整个 CPU。因此编译器需要在依赖指令之间插入适当数量的时钟周期的间隔时间,如果没有其他指令来填充这个间隔时间,甚至会使用 nops(无操作,空指令)指令来填充。这使得编译器更加复杂化,因为在超标量处理器上通常是在运行时刻做这些操作的,为了节约处理器宝贵的片上资源编译器中这些额外的代码都是最小化的。

非 VLIW 设计仍然是商业领域内的主流 CPUs,但是 Intel 的 IA-64 架构(应用于安腾处理器系列产品中)曾经试图取代 x86 架构。Intel 将 IA-64 称为 EPIC 设计,意思是 “显示并行指令计算”(explicitly parallel instruction computing),但是实际上就是 VLIW 的基础上加上智能分组(保证长期兼容性)和分支预测功能。
图形处理器(GPUs)中的可编程着色器有时候也采用 VLIW 设计,同时还有很多数字信号处理器(DSPs),也有 Transmeta(全美达)这样的公司使用 VLIW。

指令依赖和延迟

流水线和超标量能够发展多久?如果 5 级流水线可以快 5 倍,为什么不制作 20 级流水线?如果超标量每秒发射 4 条指令可以完美运行,为什么不发展为每秒发射 8 条指令?更进一步,为什么不制作一款 50 级流水线并且每个时钟周期发射 20 条指令的 CPU?

好吧,考虑下面俩条语句:

a = b * c;
d = a + 1;

第二条语句依赖于第一条语句的结果——处理器不可能在第一条语句执行完产生结果之前就执行第二条语句。这是一个非常严重的问题,因为相互依赖的指令不能并行执行。因此多发射在这种情况下也不能使用。

如果第一条语句是一个简单的整型加法运算对于单发射的流水线处理器是可行的,因为整型加法的运算非常快,第一条语句的结果可以及时反馈(使用旁路)给下一条语句。但是对于多发射的情况,则会浪费几个周期来完成这个运算,因为没有办法在一个时钟周期的间隔内将第一条指令的结果传送给已经到达执行阶段的第二条指令。所以处理器需要停止执行第二条指令直到第一条指令的结果可用,通过在流水线中插入气泡(bubble)来实现停顿。

当一条指令从执行阶段到它的结果可以被其他指令使用这之间间隔的时钟周期数量称为指令的延迟(latency)。流水线越深、级数越多指令延迟就越长。所以一个很长的流水线并不会比一个短流水线的效率高,由于指令之间的依赖会使越长的流水线中填充越多的气泡(bubble)。

站在编译器的角度来看,现代处理器的典型延迟时间范围从 1 个时钟周期(整型操作)到 3-6 个时钟周期(浮点加法、乘法运算可能相同或者稍微长一点),在到十几个时钟周期(整型除法)。

对于内存加载指令来说延迟是一个非常麻烦的问题,部分原因是他们通常发生在代码序列的早期,导致很难使用有效的指令来填充延迟,另外一个重要的原因是他们都是不可预测的 – 加载延迟的时间很大程度上取决于访问缓存是否命中(我们稍后很提到缓存)。

  • 注:在相关但意义不一样的地方使用 “latency”(延迟)这个词可能会造成误解。我们这里谈论的延迟是从编译器的角度来说的,但是对于硬件工程师来说延迟的意思是一条指令在流水线中执行完成所需要时钟周期(流水线级数),所以硬件工程师会说一个简单的整型流水线的延迟是 5 但是吞吐量是 1。然而从编译器的角度来说他们的延迟是 1 因为他们的结果可以在下一个周期中使用。编译器的角度更加通用并且甚至在硬件手册中也会使用。*

分支和分支预测

流水线的另外一个关键问题是分支。考虑下面这段代码。。。

if (a > 7) {
    b = c;
} 
else {
    b = d;
}

这段代码类似下面这种形式:

cmp a, 7 ; a > 7 ?
ble L1
mov c, b ; b = c
br L2
L1: mov d, b ; b = d
L2: ...

现在想象流水线处理器执行这段代码。当第二行的条件分支在流水线中到了执行阶段,处理器应该已经取指并解码了下一条指令,但是应该是那一条指令?他是取指并解码if分支(3,4 行)还是else 分支(第 5 行)?
一直到条件分支到达执行阶段才能知道答案,但是在一个深度流水线处理器中可能需要间隔几个时钟周期。并且它不能只是等待 — 处理器平均会在每 6 条指令中遇到一个分支,如果在每个分支下都等待几个时钟周期那么在一开始为了提高性能而使用流水线就没有可能。

因此处理器必须做出猜测。处理器会在执行这些指令的开始猜测和推测取指的路径。当然他不会实际提交(写回)这些指令的执行结果直到分支的结果已知。糟糕的是如果猜测错误这些指令不得不取消而这些时钟周期将会被浪费。但是如果猜测正确则处理器可以继续全速运行。

问题的关键是处理器应该怎样进行猜测。两种方案可供选择。第一,编译器应该可以标记这个分支来告诉处理器执行哪一条路径,这被称为静态分支预测。理想的情况是指令中有一个位来标记预测分支,但是对于早期的架构没有这个选项,所以可以使用一个约定来实现,例如将预测会被执行的分支放在后面,不会被执行的分支放在前面。更重要的是这种方法要求编译器足够智能够正确预测,对于循环来说这很容易但是对于其他分支来说就可能非常困难了。

另一种方法是处理器在运行时刻做出判断。通常使用一个片上分支预测表来实现,表中保存着最近执行过的分支的地址并且用一位来标记每个分支在上一次运行中是否被执行。实际上,大多数处理器使用两位来标记,这样可以避免单个偶然事件的发生影响预测结果(尤其是在循环边缘的)。当然这个动态分支预测表需要占用处理器片上的宝贵资源但是分支预测是如此重要所以这点资源是值得的

不幸的是即使是最好的分支预测技术有时也会预测错误,从而导致一个深度流水线上的很多指令都要被取消,这被称为分支预测惩罚。Pentium Pro/II/III 处理器是一个很好的例子——它有 12 级流水线因此错误预测惩罚是 10-15 个时钟周期。即使使用非常智能的动态分支预测器其正确率可以达到惊人的 90%,但是由于高昂的分支预测惩罚也会使 Pentium Pro/II/III 处理器浪费掉 30% 的性能。换句话说 Pentium Pro/II/III 处理器三分之一的时间都在做没有用的工作,而是再说 “哎呀,走错路了”。

现代处理器致力于投入更多的硬件资源到分支预测上,试图提高预测的准确率以减少错误预测惩罚的开销。很多记录每一个分支的方向并不是孤立的,而是由两个分支的执行情况来决定的,这被称为两级自适应预测器。有些处理器保存了一个全局的分支执行历史,而不是每个单独分支的分散的执行记录,以试图检测分支之间的任何关联性即使他们在代码中相隔较远。这被称为 gshare 或者 gselect 预测器。现代最先进的处理器通常实现了多个分支预测器然后在他们之间选择那个看起来在每个单独分支上预测最精准的一个。

然而即使是最先进的处理器使用了最好的、最智能的分支预测器也只能将正确率提升到 95%,仍然会由于预测错误而失去相当一部分的性能。规则非常简单 — 流水线太深则收益会减少,因为流水线越深你就必须预测越多的分支,所以错误的可能性也越大,并且错误预测惩罚也越大。

使用谓词执行消除分支

条件分支是一个大问题所以如果能完全去掉他们那就太好了。但是不可能在编程语言中取消 if 语句,所以怎样才能消除分支了?答案就在一些分支的使用方法中。

再次回到上面的例子中,5 条指令中有两个分支,其中一个是无条件转移分支。如果可以给 mov 指令做一个标记告诉他们只在某个条件下执行,则代码可以简化为:

cmp a, 7 ; a > 7 ?
mov c, b ; b = c
cmovle d, b ; if le, then b = d

这里引入了一条新的指令cmovle -“如果小于或等于则移动”。这条指令会正常执行但是只有在条件为真的时候才会提交执行结果。这种指令称为谓词指令,因为它的执行被一个谓词控制(判断真 / 假)。

使用这种新的谓词移动指令,代码中的两条消耗较大的分支指令都可以被移除。另外聪明的做法是总是首先执行 mov 指令然后如果需要则覆盖 mov 指令的结果。同时也提高了代码的并行度:第 1、2 行的代码现在可以并行执行。结果是提速了 50%(2 个周期而不是 3 个)。更重要的是,消除了分支预测错误时的巨大的错误预测惩罚。

当然如果 ifelse 语句中的代码块非常大,则使用谓词会比使用分支执行更多的指令,因为处理器会将两条路径上的代码都执行一遍。通过多执行几条指令以消除分支是否值得这是一个棘手的决定 —— 如果代码块很小或者很大则这个决定很容易,但是对于那些中等大小的代码块则需要负责的权衡,这种情况在优化时必须考虑到。

Alpha 架构在一开始就有条件移动指令,MIPS、SPARC 和 x86 是后面在加上的。在 IA-64 中 Intel 尽力将几乎所有指令变成谓词指令,试图显著减少循环内部中的分支问题尤其是那些不可预测的分支,例如编译器和 OS 内核中的分支。有趣的是许多手机和平板中使用的 ARM 架构是第一种全谓词指令集的架构。更有趣的是早期的 ARM 处理器只有很短的流水线因此它的错误预测惩罚相对较小。

(译注:谓词执行的本质是将控制依赖转变为数据依赖,也可以说是一种空间换时间的方法。另外,诺基亚时代的 ARM9 只有五级流水线,而到了 iPhone4 的 Cortex-A8 已经有了 14 级,和现代桌面接近)

指令调度,寄存器重命名和 OOO

如果是分支并且指令延迟很长则会在流水线中产生气泡,但是这些空周期应该可以被用来做其他工作。为了实现这一点程序中的指令必须重新排序,当一条指令在等待时可以执行其他指令。例如在上面的那个乘法例子中可以在两条语句之间插入程序中其他的语句。

有两种方法可以实现指令调度,一种方法是在运行时刻在硬件中对指令重新排序。在处理器中实现动态指令调度(重新排序)意味着处理器的指令调度逻辑必须增强,使他可以在指令组中查找并乱序分发指令使处理器功能单元可以得到最好的利用。

不出所料这就是所谓的乱序执行,或者简称为 OOO(有时也被写为 OoO 或者 OOE)。

如果处理器打算乱序执行指令,则它需要记住这些指令之间的依赖性。通过使用一组重命名寄存器而不去处理原始架构定义的寄存器可以很容易实现这个目的:例如将寄存器中的值存储到内存中,接着加载内存中的其他值到相同名字的寄存器中表示不同的值、但是不需要加载到同一个物理寄存器中。更进一步说,如果这些不同的指令映射到不同的物理寄存器中则他们可以并行执行,这就是实现乱序执行的方法。

因此处理器必须时刻保存着指令在执行过程中和指令所使用的物理寄存器之间的映射。这个过程叫做寄存器重命名。一个额外的好处是可以利用更多的寄存器在代码中提取更多的可以并行执行的指令。

所有的这些依赖分析、寄存器重命名和乱序执行都需要在处理器中增加大量的复杂逻辑,使处理器的设计更加困难、芯片面积更大、功率更大。这些额外的处理逻辑特别耗电因为这些晶体管总是处于工作状态,不像处理器中的功能单元至少有时候可以处于闲置甚至完全关闭状态。但是乱序执行的优势在于软件不需要重新编译就可以获得一定的性能提升(虽然通常不是所有软件)。

另一种一劳永逸的方法是通过编译器来重新安排指令的执行顺序。这叫做静态或者编译时刻指令调度。依赖于编译器的指令安排使得重新排序的指令流可以简单有序的被送入处理器的多发射器中。这样就避免了处理器中为了实现乱序执行而增加的复杂逻辑,使得处理器设计简单、功率变小并且芯片面积更小,这就意味着更多的处理核心或者缓存可以放在相同面积大小的芯片中。

使用编译器实现的方法比硬件实现还有另外一个优点是:可以看到程序执行时的更底层细节,并且可以推测分支的多条路径而不只是一条,这对于分支预测来说很重要。但是不能对编译器抱有太大希望,因为编译器也并非全知全能。如果没有支持乱序执行的硬件,当编译器预测失败例如缓存未命中则会导致流水线停滞。

大多数早期超标量处理器都是顺序执行的设计(SuperSPARC, hyperSPARC, UltraSPARC, Alpha 21064 & 21164, 早期的 Pentium)。早期乱序设计的处理器有 MIPS R10000, Alpha 21264 和基本上整个 POWER/PowerPC 产品线。如今几乎所有高性能处理器都是乱序设计,但是有几个明显的例外 UltraSPARC III/IV, POWER6 和 Denver。大多数低功率,低性能的处理器例如 Cortex-A7/A53 和 Atom 都是顺序设计的,因为乱序逻辑消耗了太多的电能但是性能的提升却很有限。(译注:现代 Atom 基本也都有了乱序处理)

Brainiac 和 Speed-Demon 之争

一个必须要问的问题是,代价昂贵的乱序执行是否真的有必要,或者说编译器是否可以在没有乱序执行的情况下就能很好地完成指令调度的任务。这在历史上被称为脑力高手与速度恶魔之争(Brainiac and Speed-Demon)。这种简单也有趣的设计风格分类最早出现在 Linley Gwennap 的 1993 Microprocessor Report editorial 中,Dileep Bhandarkar 的 Alpha Implementations & Architecture 一书使其广为人知。

Brainiac 设计处于智能机器的一端,大量的 OOO 硬件试图从代码中压榨出每一滴指令级并行性,即使为此付出数百万逻辑晶体管和多年的设计努力。相比之下,speed-demon 设计更简单、更小,依靠智能编译器,愿意牺牲一点指令级并行性来换取简单性带来的其他好处。从历史上看,speed-demon 设计往往以更高的时钟速度运行,正是因为它们更简单,所以才有了 “速度恶魔” 这个名字,但今天已经不再是这样了,因为时钟速度主要受限于功率和散热问题。

显然,支持乱序执行的硬件可以提供更多的 ILP 的可能,因为很多无法预测的事情必须在运行时才能知道 —— 尤其是缓存缺失。另一方面,更简单的顺序执行核心会更小,功耗也更低,这意味着你可以将更多的小核放在同一个芯片上。你更愿意拥有哪一种。4 个强大的 brainiac 核心 还是 8 个简单的顺序执行核心?

究竟哪个是更重要的因素,目前还有待商榷。总的来说,对乱序执行的好处和成本似乎在过去都被高估了。在成本方面,对调度和寄存器重命名逻辑进行适当的流水线处理,使得支持乱序执行的处理器在 20 世纪 90 年代末就能达到与简单设计相竞争的时钟速度,而巧妙的工程设计也在近几年大大降低了乱序执行的功率开销,乱序执行所面临的主要问题只剩下了芯片面积成本,这是处理器架构师们杰出工作的证明。

然而不幸的是,在压榨出更多指令级并行的空间这一问题上,乱序执行的有效性令人失望,我们只看到了比较小的改进,可能比同等的顺序内设计提高了 20-40% 左右。乱序执行设计的先驱、Pentium Pro/II/III 的首席架构师之一 Andy Glew 的话说:”乱序执行的肮脏的小秘密是,我们往往根本就不怎么乱序执行”。乱序执行也无法实现原先希望的” 无需重新编译 “的程度,即使在激进的乱序执行处理器上,重新编译仍能产生较大的速度提升。

在这场争论中,很多厂商走了一条路后又改变了主意,转到了另一边… …

(译注:这是一个动态的图,建议去原文查看完整的对比)

图 11 – Brainiacs vs speed-demons.

比如 DEC,前两代的 Alpha 主要走的是 speed-demon 路线,第三代就改成了 brainiacs。MIPS 也是如此。Sun 则是在他们的第一代超标量 SPARC 上就走了 brainiac 路线,然后在最近的设计中改成了 speed-demon。POWER/PowerPC 阵营这些年也逐渐远离了 brainiac 设计,每个功能单元队列内的指令严格按照顺序执行,尽管所有 POWER/PowerPC 设计中的保留站确实提供了不同功能单元之间一定程度的乱序执行。相比之下,ARM 处理器则表现出了一贯的向着更加 braniacs 的设计方向发展,他们一样是从低功耗、低性能的嵌入式世界中走出来的,但仍然是以移动设备为中心,因此无法将时钟速度推得太高(译注:然而 A14 已经峰值 3Ghz 了)。

英特尔是其中最值得关注的。由于 x86 架构的限制,现代 x86 处理器除了 brainiac 别无选择(稍后在 x86 的章节中将对此进行详细介绍),而 Pentium Pro 是 brainiac 设计彻底的实践者。在之后与 AMD 的竞赛中(AMD 在 2000 年 3 月率先达到了 1GHz 主频),英特尔一度改变了他们的关注点,开始不惜一切代价地追求时钟速度,并使 Pentium 4 尽可能地成为解耦 x86 微架构的 speed-demon。通过牺牲一些指令并行度、使用 20 级深层流水线,Pentium 4 成功达到了 2GHz、3GHz,甚至在后来的修订版中依靠惊人的 31 级流水线达到了 3.8GHz。同时,在 IA-64 Itanium(未见上图)上英特尔再次坚定地押注于智能编译器方法,采用简单的设计而完全依靠静态、编译时的调度。面对 IA-64 的失败,奔腾 4 巨大的功耗和发热问题,以及 AMD 时钟更慢的 Athlon 处理器,在 2GHz 范围内的实际代码表现都超过了奔腾 4,英特尔于是再次扭转立场,重新恢复了老款 Pentium Pro/II/III 的 brainiac 设计,生产出了 Pentium M 及其 Core 的后续产品,并取得了巨大的成功。

能量墙和 ILP 墙

ILP (instruction-level parallelism)—— 指令级并行

奔腾 4 处理器严重的电能消耗和散热问题证明了时钟频率是有极限的。事实证明电能消耗的增长速度远比时钟增长速度快 — 无论使用任何芯片技术,20% 的时钟速度提升会导致能源消耗提高 50%,
因为不仅仅是晶体管的开关频率提高 20%,也需要提高电压才能驱动高速电路中的信号以保证很短的定时要求,当然前提是电路全部工作在提高的时钟频率下。

虽然功率的增加是随着时钟频率的线性增加,但是电压却是平方增加,导致在很高的速率下受到 “三重打击”(f*V*V)。

更糟糕的是除了正常的开关电能消耗外还有一小部分的电能泄漏,因为当晶体管关闭流过它的电流并不会完全降低到 0,就像正常的电能消耗一样,这种电能泄漏也会随电压的增高而增加。这还不是最糟糕的,电能泄漏的增多会引起温度的升高使粒子运动加剧,导致硅中的高能电子增多。

如今得出的结果是,现代 CPU 的时钟相对增加 30% 会使功率增大一倍,发热量也会增加一倍。

图 12 — 去掉了风扇的现代 PC 处理器的散热器

功率的少量提高是可以接受的,但是当提高到一个确定的点时就会导致处理器的电源和散热问题不可控,目前这个点在 150-200 瓦特之间。因为即使电路可以在高速时钟频率下工作但是也没有实际的方法来提供更多的电能并使芯片温度冷却下来。这被称为功耗墙。

Pentium 4,IBM 的 POWER6 和最近代的 AMD 的 Bulldozer/Piledriver 等处理器都过于追求时钟速度的提升,导致他们很快就遇到了能量墙,从而发现没有办法将时钟速度提高到他们想要的速度上,所以他们开始降低时钟速度转而开发更加智能的指令级并行处理器。

因此纯粹增加时钟的速度并不是一个好的决策,当然这个结论也适用于移动设备,如笔记本、平板和手机,它的会更快的到达能量墙。由于电池容量的限制并且通常没有散热器所以笔记本的能量墙在 50W 左右,平板的是 10W,手机的是 5W。

由于增加时钟速度会遇到瓶颈,那单纯的增加指令并行度是一个正确的方法吗?也不是。增加更多的并行度也会遇到瓶颈,因为正常的程序中由于加载延迟、缓存未命中、分支和指令间的依赖等因素,不可能在程序中找到更多的可以并行的指令。可用的指令级并行限制称为 ILP 墙。

早期的 POWER、SuperSPARC 和 MIPS R10000 等处理器过于追求 ILP,从而很快发现提取更多的并行指令变得非常困难,而复杂的逻辑阻碍了时钟速度的提高。导致这些处理器不再追求 ILP,转而去提高时钟速度。

一个 4 发射超标量处理器希望每个周期中有 4 个延时和依赖都符合要求的单独指令可以被执行。实际上这基本是不可能的,特别是加载延迟达到 3-4 个周期。

目前现实世界中的一个单线程应用程序的指令级并行度大约可以每个周期执行 2-3 条指令。实际上现代处理器在 SPECint 标准下的 ILP 小于 2 条指令每周期,而且 SPEC 标准的程序比现实世界中的大型应用程序更简单。某些应用程序展现出更好的并行度,例如科学计算但是这些程序不能代表主流应用程序。诸如指针追逐(pointer chasing)的多种问题会导致有些类型的代码甚至维持每个周期执行 1 条指令都非常困难。对于这些程序瓶颈在内存系统上,因此又有另一种墙 —— 内存墙(后面会讲到)。

(译注:pointer chasing 是指分支跳转较多、逻辑上高度序列化、难以并行处理的代码,这里题到 pointer chasing code 维持每周期一个指令都非常困难可能是由于分支跳转频繁访问内存耗费时间,后续的指令又依赖分支结果难以预测,导致流水线停顿和回退,总体执行效率难以提高)

x86 介绍

x86 也会遇到上面那些问题,但是 Intel 和 AMD 是怎样让一个已经存在了 35 年的架构在 CPU 不断发展的过程中仍然保持竞争力?

早期的奔腾是一款超标量 x86 指令集的处理器,它是工程界的一个奇迹因为对于复杂并且混乱的 x86 指令集来说实现超标量是极其困难的。复杂的寻址模式和最小量的寄存器数量并且指令之间潜在的依赖性意味着只有少数指令可以并行执行,x86 阵营为了和 RISC 架构竞争,他们必须找到一种方法来 “简化” x86 指令集。

Intel 和 NexGen 公司的工程师各自独立(在同一时间)提出了解决方案 — 动态将 x86 指令解码为简单指令,一种类 RISC 的微指令。这种微指令可以运行在一个快速的、RISC 风格的寄存器重命名、乱序执行、超标量的核心上,这种微指令通常叫做 μops(读作 “micro ops”)。大多数 x86 指令解码成 1,2 或者 3 个 μops,更复杂的 x86 指令需要更多的微指令。

对于这些分离的超标量 x86 处理器,很明显寄存器重命名技术是至关重要的因为 32 位模式下的 x86 架构只有 8 个寄存器(64 位模式下增加了另外 8 个寄存器)。这和 RISC 架构有很大区别,RISC 架构已经提供了很多寄存器,寄存器重命名只起到辅助作用。

然而,通过智能的寄存器重命名技术 RISC 架构上的所有技术都可以移植到 x86 上面来,但是有两个例外:静态指令调度(因为微指令隐藏在 x86 指令下面,对于编译器是不可见的)和利用大量寄存器组来减少内存访问。

x86 处理器的工作方式就像下面这样。。。

图 13 – 一种 “RISCy x86” 分离微架构

NexGen 公司的 Nx586 和 Intel 的 Pentium Pro 处理器是第一个采用 x86 指令分离的微架构设计,如今所有现代 x86 处理器使用这种技术。当然和各种各样的 RISC 处理器一样他们在流水线和功能单元等方面的设计细节是不同的,但是将 x86 指令翻译为类 RSIC 指令的基本思想是一样的。

当前的 x86 处理器甚至将翻译过后的微指令储存在一个很小的缓存当中,命名为 L0 微指令缓存。用于避免在循环当中一次次重复翻译相同的 x86 指令,这样不仅节约了时间而且减少了能量消耗。这就是为什么 Core i*2/i*3 Sandy/Ivy Bridge 和 Core i*4/i*5 Haswell/Broadwell 处理器的流水线深度写成 14/19 —— 因为有 14 级是运行在 L0 微指令缓存中(通常情况下),但是还有 19 级运行在 L1 指令缓存中需要将 x86 指令翻译为微指令。

x86 的这种类 RISC 微指令方案使得现代 x86 处理器对于带宽这个概念变得非常模糊,因为处理器内部为了便于跟踪经常将一组通用的微指令组成一个集合(例如加载和相加集合或者比较和分支集合)。例如 Core i4/i5 Haswell/Broadwell 处理器每个周期可以最多解码 5 条 x86 指令,最大产生 4 个微指令集合,然后存储到 L0 微指令缓存中。因此每个周期可以取 4 个微指令集合并执行完成,或者每个周期取 8 个单独微指令在不同的流水线中执行。所以 Haswell/Broadwell 处理器的带宽到底是多少?从处理器的硬件架构来看它的带宽是 8,因为每个周期可以取 8 条微指令并执行完成,从微指令的集合来看它的带宽是 4,因为每个周期可以完成 4 个微指令集合,而从原始的 x86 指令集来看它的带宽是 5,因为每周期执行 5 条 x86 指令。当然,带宽 这种概念只是在学术上面讨论,因为实际程序的执行不可能达到这么高水平的指令集并行度(ILP)。

类 RISC 风格的 x86 家族中最有趣的一个成员是 Transmeta 公司的 Crusoe 处理器,它将 x86 指令翻译为内部的 VLIW 形式的指令,而不是内部微指令,并且使用软件在运行时刻进行翻译,非常像 Java 的虚拟机。这种方法可以使处理器设计成简单的 VLIW 形式,而不需要硬件实现解码和寄存器重命名来解耦复杂的 x86 指令,并且不需要任何超标量调度和乱序执行的逻辑。

这种基于软件的 x86 指令翻译相比于硬件翻译降低了系统的性能,但是却可以大大简化芯片的设计并且速度可以非常快也大大减小了功率的消耗。一个 600MHz 的 Crusoe 处理器可以媲美工作在低功耗模式下的 Pentium III 处理器(低功耗模式下 300MHz,标称频率 500MHz),同时功耗和发热都只有 Pentium III 的一小半水平,这使得它成为笔记本电脑和手持电脑的理想选择。。如今 x86 处理器也有应用于低功率领域经过专门设计的处理器。例如 Pentium M 和他后面的升级版本,虽然没有必要使用 Transmeta 公司的基于软件翻译的方法,但是 NVIDIA 公司也使用了非常类似的方法设计出应用于低功率但是要求高性能领域的 Denver ARM 处理器。

线程 :SMT,超线程和多核

前面已经提到增加指令级并行的方法由于实际程序中难以找到更多的并行指令而受到严重制约。因为这个原因即使使用最好的乱序执行超标量处理器在加上智能的编译器静态指令调度优化,执行现实世界的程序由于加载延迟、缓存未命中、分支和指令间依赖这些因素的制约也很难达到平均每个周期执行 2-3 条指令。在同一个周期中发射多条指令只发生在最多几个周期的短暂时间内,大量周期是在执行低 ILP 的代码,因此很难达到峰值性能。

如果不能在当前运行的程序中找到额外的独立指令,还可以在另一个潜在的资源中找到独立指令 — 其他正在运行的程序,或者当前进程中的其他线程。同步多线程(Simultaneous multi-threading, SMT) 这种处理器设计技术正是利用了线程级的并行。

再次重复,这种方法也是利用有效的指令来填充流水线中的气泡,但是这一次不是使用当前程序中的指令(这种指令很难找到)而是使用正在运行的多线程中的其他线程的指令,他们都运行在一个处理器核心上。因此单个 SMT 处理器表现的好像是同时有多个独立处理器,就像一个真实的多处理器系统一样。

当然一个真实的多处理器系统也同时执行多个线程 — 但是每个处理器上只有一个线程在运行。多核处理器也是这样的,他是将两个或者多个处理器核心放在同一个芯片上,而其他地方和传统的多处理器系统一样。相反,一个 SMT 处理器仅仅使用一个物理处理器核心在这个系统上呈现出两个或者多个逻辑处理器。这使得 SMT 处理器在芯片面积、造价、功率和散热上比多核处理器更加高效。当然也可以实现多核处理器上的每个核心都是 SMT 设计。

站在硬件的角度看,实现一个 SMT 处理器需要复制处理器中表示一个线程运行状态的所有器件,例如程序计数器、架构可见的寄存器(不是重命名寄存器)、保存在 TLB 中的内存映射等等。幸运的是这些器件只占用整个处理器硬件的一小部分。真正庞大、复杂的部分例如解码器和调度逻辑、功能单元和缓存这些都是线程之间共用的。

当然处理器也必须在任何时刻跟踪哪条指令和哪个重命名寄存器属于哪个线程的,但是这只需要在整个处理器核心逻辑上增加少量复杂度。因此只需要花费少量成本而得到了整体性能的提升。

SMT 处理器的指令流就像下面这样。。。

图 14 – SMT 处理器的指令流

太棒了!现在我们可以通过运行多线程来填满这些气泡,我们可以增加更多的功能单元并且可以实现真正的多指令发射。在某些情况下设甚至可能提高单线程的性能(例如那些 ILP 友好的代码)。

因此我们可以实现每周期发射 20 条指令的处理器吗?不幸的是,答案是否定的。

首先实现 SMT 的前提是同时有很多个程序在运行(不仅仅是空闲进程 idle),或者一个进程中有多个线程在同时运行。从多处理器系统中获得的经验告诉我们不可能总是如此。实践中,至少对于桌面电脑、笔记本、平板、手机和小型服务器来说,同时有多个不同程序在运行的情况是很少见的,因此通常机器上只有一个任务在执行。

例如数据库系统、图像视频处理、音频处理、3D 图形渲染和科学计算等等这些应用可以很容易的利用并行处理,但是很不幸的是即使是这些应用大部分也没有被编写成并行友好的代码。另外很多应用程序可以很容易的实现并行化,但是他们本质上并不能完全并行运行,因为瓶颈在内存带宽那里而不是处理器的问题,所以增加第二个线程或者进程并没有多少用处,除非内存带宽也可以增加(我们很快将会讨论内存系统)。更糟糕的是还有很多类型的软件根本就不能实现并行化,例如 web 浏览器、多媒体工具、语言翻译、硬件模拟等等。

事实上 SMT 设计中的多线程都是共享一个处理器核心和一组缓存,他和多处理器相比在性能上还是有很大差距的。在 SMT 处理器的流水线中如果有一个线程总是占据处理器的某一个功能单元,而其他线程也需要这个功能单元,则会阻塞所有其他的线程,即使其他线程仅仅只是偶尔使用这个功能单元。因此线程之间的平衡变得非常重要,最有效的利用 SMT 的方式是使程序高度差异化,这样可以使线程之间不会经常竞争同一个硬件资源。而且多线程之间的缓存空间竞争可能会导致比运行单个线程的效率还要低,尤其是那些对缓存大小高度敏感的程序,例如硬件模拟 / 仿真,虚拟机和解码高质量的视频。

对于某些应用 SMT 的性能实际上比同时只运行单个线程然后多线程之间进行传统的线程上下文切换时的性能低。但是程序的主要限制是内存延迟(不是内存带宽),例如数据库系统、3D 图形渲染和大多数通用代码受益于 SMT,是因为它们可以更好的利用内存加载延迟和缓存未命中时的等待时间。因此 SMT 的性能和应用程序之间有着非常复杂的关系。这也使得它很难得到市场的认可,因为它的性能有时比多核处理器还要高,有时又像一个残缺的多核处理器,有时甚至比单处理器的性能还低。

奔腾 4 是第一个使用 SMT 设计的处理器,Intel 称之为 “超线程”。奔腾 4 处理器的 SMT 设计使性能提高 -10% ~ 30%,依赖于不同的应用程序。随后 Intel 逐渐避免使用 SMT 设计并开始向多核过渡。同一时期其他厂商也取消了 SMT 设计(Alpha 21464, UltraSPARC V),SMT 渐渐失宠直到采用双核四线程设计的 POWER5 的出现。后来的 Intel 酷睿 I 系列处理器都使用了 2-SMT 设计,例如我们熟悉的四核八线程。Sun 公司在并行设计上表现的更加积极,他在 UltraSPARC T1 处理器上使用了 8 个核心并且每个核心上使用了 4 线程 SMT 设计,总共 32 个线程;在 UltraSPARC T2 处理器上更是增加到了每核心 8 线程设计,然后 UltraSPARC T3 处理器上塞进 16 个核心,每个处理器上总共 128 个线程!

更多的核心还是更强大的核心

鉴于 SMT 可以使指令级并行转换为线程级并行,加上单线程的性能特别是 ILP 友好的代码。你可能会问:既然 SMT 设计要优于同样带宽的多核处理器,为什么多核处理器依然存在。

很不幸,问题并没有这么简单:事实证明高带宽的超标量设计在芯片面积和时钟速度方面表现很差。一个关键的问题是多发射的调度逻辑复杂度基本上和发射带宽的平方成正比,因为 n 个待执行的指令彼此之间必须都要进行一次比较。虽然可以利用更加智能的工程技术来重新按排指令的执行顺序以减轻指令调度的负担,但是复杂度依然成 n 的平方倍增长,因此一个 5 发射的处理器要比一个 4 发射处理器的调度逻辑复杂 50%,而一个 6 发射的处理器要复杂 2 倍,以此类推。此外一个带宽很高的超标量设计要求高度多端口访问的寄存器和缓存来满足多个指令同时访问的需求。所有的这些因素导致了不仅仅是芯片体积的增加,而且大大增加了电路设计时的布线长度,严重限制了时钟速度。所以一个 10 发射的处理器不仅比 5 发射的处理器更大而且还更慢,而我们梦想的 20 发射的 SMT 设计则更不可能出现。

由于 SMT 和多核的性能发挥非常依赖实际使用的目标程序,所以 SMT 和多核设计之间的取舍还是非常有意义的。

现今,一个典型的 SMT 设计都实现了多发射、乱序执行、多解码器和大而且复杂的超标量调度逻辑等等,因此 SMT 核心在芯片面积方面都非常大。而使用相同的芯片面积可以实现更多简单的、单发射、顺序执行的核心(无论有或者没有基本的 SMT)。实际中,一个乱序执行的超标量 SMT 设计所需要的芯片面积可以用来放置半打的小型、简单核心。

现在我们知道不管是指令级并行还是线程级并行都会遭受收益递减的命运(不同程度上),需要记住的是 SMT 实质上是将 ILP 变成了 TLP,超标量的带宽越宽芯片面积增长的越快(还有设计复杂度和功率消耗),一个明显的问题是平衡点在那里?带宽需要多大可以得到 ILP 和 TLP 之间最好的平衡?目前还在探索中。。。

图 15 – 两种设计极端:Core I*2 “Sandy Bridge” VS UltraSPARC T3 “Niagara 3″。

一种极端是 Intel 公司的 Core I*2 “Sandy Bridge”(上图左边),它是 4 核处理器,每个核心都是 6 发射,乱序执行、智能核心、2 个线程的(图中的下边是共享的 L3 缓存)所以总共有 8 个线程。

另一个极端是 Sun/Oracle 公司的 UltraSPARC T3 Niagara 3(上图右边),它包含 16 个小型,简化,2 发射的顺序执行的核心(图中上下两边是共享的 L2 缓存),每个核心运行可以 8 个线程,总共 128 个线程,但是这些线程相比 Core I*2 要慢很多。他们的芯片面积都相同,晶体管的数量都是 10 亿个左右(假设他们的晶体管分布密度相同)。请看上图一个简化、顺序执行的核心是多么的小巧。

哪种做法更好?没有简单的答案 —— 这在很大程度上取决于应用。对于拥有大量活跃但内存延迟有限的线程的应用(数据库系统、3D 图形渲染),更简单的处理器会更好,因为无论如何,大核 / 宽核都会将大部分时间用于等待内存。然而对于大多数应用来说,根本没有足够多的线程活跃,这样做是不可行的,而且仅仅一个线程的性能要重要得多,所以采用更少但更大、更宽、更偏向于 brainiac 的核心的设计是比较合适的(至少对于现在的应用来说)。

当然,在这两个极端之间,还有一系列的选择,还没有被充分挖掘出来。例如 IBM 的 POWER7 是和上述几款产品同代的产品,同样拥有约 10 亿个晶体管,采取了 8 核 4 线程的 SMT 设计,配以适度但不过分激进的乱序执行执行单元,走中间路线。AMD 的 Bulldozer 设计采用了更特别的方式,每对核心都有一个共享的 SMT 风格的前端,后端具有独立的整数运算单元,共享浮点运算单元,模糊了 SMT 和多核之间的界限。

如今(2015 年初),由于摩尔定律的存在让现代处理器能拥有十亿个晶体管,即使是激进型的 brainiac 设计也可以有相当多的核心 —— 例如英特尔的 Xeon Haswell,也就是四代酷睿 Haswell 的服务器版本,使用 5.7 亿个晶体管,提供 18 个核心(对比 Xeon Sandy Bridge 的 8 核心,提升非常显著),每个核心都是非常激进的 8 发射设计(对比 Sandy Bridge 是 6 发射),每个核心仍然是 2 线程 SMT;而 IBM 的 POWER8 则使用了 44 亿个晶体管,转为比 POWER7 更倾向于 brainiac 的核心设计,同时提供 12 个核心 (从 POWER7 的 8 个上升),每个核心都是 8 线程 SMT (从 POWER7 的 4 个上升)。当然,这么大的 brainiac 核是否是对这些晶体管的有效利用是另外一个问题。

图 16 – 18 核的 Xeon Haswell,是一种在 Brainiac 和 Speed-demon 设计上都达到巅峰的处理器

考虑到小核的多核性能单位面积效率和大核的最大单线程性能,也许未来我们可能会看到非对称设计,由一两个大、宽、复杂的核加上大量小、窄、简单的核。从很多方面来说,这样的设计是最合理的 —— 高度并行的程序会从众多小核中获益,但单线程、顺序执行程序却希望至少有一个大、宽、复杂的核的威力,即使它需要四倍的面积却只能提供两倍的单线程性能。

IBM 的 Cell 处理器(用于索尼 PlayStation 3)可以说是第一个这样的设计,但遗憾的是,它存在严重的可编程性问题,因为 Cell 中的简单小核心与大型主核心的指令集不兼容,只能有限而低效地访问主存储器,使得它们更像特殊用途的协处理器,而不是通用的 CPU 内核。现代的一些 ARM 设计还采用了非对称的方式,几个大核与一个或几个较小的、简单的 “配套的” 核搭配,而不是选择最大限度地提高多核性能,而是为了在手机或平板电脑只轻度使用时,可以将耗电的大核关闭,以增加电池寿命,ARM 将这种策略称为 “big.LITTLE”。

当然,由于晶体管数量的增加,将其他次要功能集成到主 CPU 芯片中可能也是有意义的,比如 I/O 和网络(通常是主板芯片组的一部分)、专用视频编码 / 解码硬件(通常是图形系统的一部分),甚至完整的低端 GPU。在一些减少空间、降低成本、降低能耗需求比多核性能需求更重要的场合,这种集成特别有吸引力,因此它是手机、平板电脑和小型、低性能笔记本电脑的理想选择。这样的异构设计称为片上系统,或 SoC:

图 17 – 一种典型的 SoC:NVIDIA Tegra 2

数据级并行:SIMD 和向量指令

除了指令级并行和线程级并行,在许多程序中还有另一个并行的来源 — 数据并行。与其说是寻找并行执行一组指令的方法,不如说是寻找让一条指令并行应用于一组数据值的方法:这有时被称为 SIMD 并行(单指令,多数据)。更多的时候,它被称为矢量处理(vector processing)。超级计算机过去经常使用向量处理,并且这些矢量非常长,因为在超级计算机上运行的科学程序类型很适合这样的处理方式。

然而,如今,矢量超级计算机早已被多处理器设计代替,而其中每个处理单元都是普通的商用 CPU,那么今天重提向量处理的意义何在?

在许多情况下(尤其是在成像,视频和多媒体应用程序中),程序需要为一小组相关值(通常是短向量,简单结构或小数组)执行同一条指令。例如,图像处理应用程序可能需要添加 8 位数字组,其中每个 8 位数字代表像素的红色,绿色,蓝色或 alpha(透明度)值之一

图 18 – 一种典型的 SIMD 操作

这里发生的事情与 32 位加法的操作完全相同,只是每 8 位一组的结果不会进位。另外,一旦所有 8 位为 1,继续做加法不会让结果变成 0,而是保持最大值 255(称为饱和运算 saturation arithmetic)。

所以上图所示的向量加法运算其实只是一个修改后的 32 位加法。而从硬件的角度来看,增加这些类型的向量指令并不难 —— 可以使用现有的寄存器,而且在许多情况下,计算单元可以与现有的整数或浮点单元共享。还可以增加其他有用的打包和解包指令,用于 byte shuffling 等,还有一些类似于谓词的指令用于位掩码等。只要花点心思,一个小的向量指令集就可以获得一些惊人的加速效果。

当然我们没有理由只停留在 32 位。如果恰好有一些 64 位的寄存器,而这些寄存器通常都是用于浮点的,那么它们可以用来提供 64 位的向量,从而使并行性增加一倍 ——SPARC VIS 和 x86 MMX 就是这样做的。如果可以定义全新的寄存器,那么它们可能会更宽 ——x86 SSE 增加了 8 个新的 128 位寄存器,后来在 64 位模式下增加到 16 个寄存器,然后用 AVX 拓宽到 256 位,而 POWER/PowerPC AltiVec 从一开始就提供了一整套 32 个新的 128 位寄存器(符合 POWER/PowerPC 更分离的设计风格,甚至分支指令也有自己的寄存器)。拓宽寄存器的另一种方法是使用配对,即每一对寄存器都被 SIMD 向量指令当作一个单一的操作数 ——ARM NEON 就是这样做的,它的寄存器既可以作为 32 个 64 位寄存器使用,也可以作为 16 个 128 位寄存器使用。

寄存器中的数据也可以以其他方式进行分割,而不仅仅是 8 位字节为单位,例如在高质量的图像处理中可以划分为 16 位整数,或者在科学数字计算中作为浮点值。例如,使用 AltiVec、NEONv2 和最新版本的 SSE/AVX,可以将一个 4 路并行浮点乘加指令作为一条完整的流水线指令来执行。

对于这种数据并行度高、且易于提取的应用,SIMD 向量指令可以产生惊人的速度提升。最初的目标应用主要是在图像和视频处理领域,其他合适的应用还包括音频处理、语音识别、3D 图形渲染的某些部分和许多类型的科学代码。

然而对于其他类型的软件,如编译器和数据库系统,SIMD 一般很难有提升。更糟糕的是,程序员的编码习惯是串行的,编译器在面对这样的源代码时很难自行判断何时使用 SIMD、哪些数据可以被并行化。在编码时显式地对所有需要并行部分进行声明的工作量过大,这导致了 SIMD 的应用发展缓慢。

幸运的是,操作系统只需要在图形和视频 / 音频库的关键位置重写少量代码,就能在许多应用程序中产生广泛的影响。大多数操作系统都以这种方式优化了它们的关键库,因此几乎所有的多媒体和 3D 图形应用程序都利用了这些高效的矢量指令。抽象化的又一次胜利!

现在,几乎每个架构都添加了 SIMD 向量扩展,包括 SPARC (VIS)、x86 (MMX/SSE/AVX)、POWER/PowerPC (AltiVec) 和 ARM (NEON)。然而,只有每个架构的相对较新的处理器才能执行其中的一些新指令,这就引起了向后兼容性问题,特别是在 x86 上,SIMD 向量指令的发展显得有些杂乱无章(MMX、3DNow!、SSE、SSE2、SSE3、SSE4、AVX、AVX2)。

内存和内存墙

正如之前提到的,延迟(尤其是未知的延迟)对于流水线是致命的,主存储器和处理器之间的巨大延迟成为现代体系结构中严重的问题,要知道,访存相关指令占据所有指令数量的约 1/4。

load 指令往往在代码块的开头附近,而其他大多数指令则取决于所加载的数据,这导致所有其他指令停顿且难以获得 ILP 加速;更糟糕的是,大多数超标量处理器实际每个周期只能发出一到两个 load 指令(可能受限于内存控制器端口数)

访存问题的核心在于,构建一个快速的内存系统是非常困难的,其中一部分原因是受到光速限制,信号在处理器和主存之间的延迟不可避免,更重要的是 DRAM 元件的充电和放电需要时间,这些都是无法改变的物理规律,我们需要尽可能回避这些问题展开工作。

例如,使用 CAS 延迟为 11 的现代 SDRAM,主存储器的访问延迟通常为存储器系统总线的 24 个周期:1 周期用于发送访问地址到插着内存的 DIMM 插槽上,11 周期耗费在 RAS-CAS 延迟开启行访问(row access),11 周期耗费在 CAS 延迟开启列访问(column access),最后 1 周期用于发回数据。注意这里描述的是典型值,实际中的内存延迟是复杂多样的,有可能我们很幸运的赶上了内存刚好打开了正确的行,跳过了 RAS-CAS 延迟从而省去近半的时间(1+11+1);也有可能很倒霉此时内存正好在进行另一个地址的访问,我们不得不等待多一个 RAS 延迟(1+11+11+11+1)。在对称多处理机系统上,可能还有来自内部总线的多处理器缓存一致性协议带来的延迟:地址还没有被发送到 MC(内存控制器)之前就检查各种片上缓存,返回的数据到达 MC 时也要进行同样的检查,这些过程以处理器时钟周期为单位因而相对较快,但是在大多数现代处理器中这些操作也需要 20 个 CPU 周期左右。

假设采用典型的 800MHz SDRAM 内存系统 (DDR3-1600),并假设采用 2.4GHz 处理器,这就使得 (1+11+11+1)*2400/800+20=92 个周期的 CPU 时钟来访问主内存。对于主频更高的处理器问题更糟,如 2.8 GHz 处理器需要 104 个周期,3.2 GHz 处理器需要 116 个周期,3.6 GHz 处理器需要 128 个周期,而 4.0 GHz 处理器更达到了惊人的 140 个周期。

需要注意的是,虽然 DDR SDRAM 内存系统在时钟信号的上升沿和下降沿都传输数据(即:以双倍的数据速率传输数据),但内存系统总线的真正时钟速度只有一半,而且适用于控制信号的是总线时钟速度。所以即使带宽增加了一倍,DDR 内存系统的延迟和非 DDR 系统也是一样的(关于带宽和延迟的区别,后面会详细介绍)。

一些更老的处理器的 MC 不在片上而是在芯片组上,处理器和芯片组的通信增加了额外的 2 个总线周期,这个时代的总线频率可能只有 200MHz 或者更低,因此这 2 个总线周期会给访存操作带来 20 多个 CPU 时钟周期的额外延迟。一些处理器试图通过提高处理器和芯片组之间的前端总线(FSB)的速度来缓解这个问题(Pentium 4 的 800 MHz QDR,PowerPC G5 的 1.25 GHz DDR),而更好的解决方法是和现代处理器一样,将 MC 集成到处理器内部(Intel 称为 integrated memory controller, IMC),将这两个总线周期变成更快的 CPU 周期。UltraSPARC IIi 和 Athlon 64 是最早实现这个设计的处理器,而 Intel 在酷睿 i 系列中才开始使用这个设计。

遗憾的是,无论是 DDR SDRAM 内存还是片上内存控制器再怎么尽力似乎都只能到此为止了,内存延迟至今仍然是一个大问题。这个处理器和主内存之间的巨大且缓慢增长的差距问题被称为内存墙,它一度是处理器架构师面临的最重要的一个问题。不过今天这个问题已经得到了很大的缓解,因为处理器受到功率墙限制,频率不再以以前的速度攀升。

缓存和存储结构层次

单词 “cache” 发音 “cash” 而不是 “ca-shay” or “kay-sh”.

现代处理器为了解决内存墙问题引入了缓存。缓存是位于处理器芯片上或附近的小但快的一种存储器。它的作用是保留主存的小块副本。当处理器要求一块特定的主存储器时,如果数据在高速缓存中,则高速缓存可以比主存储器更快地提供它。

通常,处理器芯片本身在每个内核内有小的但快速的一级缓存(L1),通常大小约为 8-64k,而较大的二级缓存(L2)则较远,但仍在片内(几百 KB 到几 MB),可能还有更大更慢的三级缓存等。片上缓存、任何片外外部缓存(E-cache)和主存储器(RAM)的组合一起形成一个内存层次结构,每个层次级别都比前一个级别更大也更慢。当然,在内存层次结构的最底层是虚拟内存(分页 / 交换),它通过将 RAM 页面与文件存储之间来回移动(显然这将更加的慢)。

这有点类似于在图书馆学习,你可能大部分时间都在阅读桌上的两三本书,它们随手可得(访问速度很快);但是如果你需要更多的书,可能需要在旁边放一张更大的桌子,需要翻阅时走动几步(访问速度更慢);至于更多的书,可能需要在图书馆的书架之间寻找才能找到并将它放到桌上(访问速度很慢),但是书架上的书远比你桌子的容量要大得多,不可能将所有书都放到桌子上。

Level Size Latency Physical Location
L1 cache 32 KB 4 cycles inside each core
L2 cache 256 KB 12 cycles beside each core
L3 cache 6 MB ~21 cycles shared between all cores
L4 E-cache 128 MB ~58 cycles separate eDRAM chip
RAM 4+ GB ~117 cycles SDRAM DIMMs on motherboard
Swap 100+ GB 10,000+ cycles hard disk or SSD

Table 4 – 现代电脑的代表:四代酷睿 Haswell 的存储层次

Level Size Latency Physical Location
L1 cache 64 KB 4 cycles inside each core
L2 cache 1 MB ~20 cycles beside the cores
L3 cache 4 MB ~107 cycles beside the memory controller
RAM 1 GB ~261 cycles separate SDRAM chip
Swap N/A N/A paging/swapping not used on iOS

Table 5 – 现代手机的代标:iPhone6 搭载的 Apple A8 的存储层次

缓存的神奇之处在于它们真的很好用 —— 它们有效地使内存系统看起来几乎和 L1 缓存一样快,却和主存一样大。 现代的一级缓存的延迟仅为 2-4 个处理器周期,这比访问主内存快数十倍,而对于大多数软件而言,一级缓存命中率约为 90%。因此,在 90%的时间内,访问内存仅需要几个周期。

缓存之所以能达到这些看似惊人的命中率是因为程序的工作方式。大多数程序都体现出 时间和空间局域性—— 当一个程序访问一块内存时,它很有可能在不久的将来需要重新访问同一块内存(时间局域性),也很有可能在未来也需要访问附近的其他内存(空间局域性)。时间定位性的利用方法是仅仅将最近访问的数据保存在缓存中。为了利用空间局域性,数据每次以几十个字节为一个块从内存载入缓存,称为一个 cache line。

从硬件的角度来看,缓存的工作原理就像一张两列表:一列是内存地址,另一列是数据块的值(记住,每一行缓存都是一整块数据,而不仅仅是一个值)。在现实中,因为查找所用的索引是地址的低位来进行匹配,所以缓存只需要存储必要的地址的高位。当高位地址也就是所谓的标签,与表中存储的标签相匹配时,就是一次缓存命中,就可以将相应的数据发送到处理器。

图 19 – 一次缓存查找

使用物理地址或虚拟地址来进行缓存查找是可行的,然而任何方式都有优点和缺点(就像计算中的其他东西一样):使用虚拟地址可能会引起问题,因为不同的程序使用相同的虚拟地址映射到不同的物理地址,缓存可能需要在每次上下文切换时刷新。另一方面,使用物理地址意味着虚拟地址到物理地址的映射必须作为缓存查找的一部分来执行,使得每次查找的速度变慢。一个常见的技巧是将虚拟地址用于缓存索引,但将物理地址用于标签。然后,虚拟到物理的映射(TLB)可以与缓存索引并行执行,这样就能及时为标签比较做好准备。这样的方案称为虚拟索引的物理标签缓存(virtually-indexed physically-tagged cache)。

现代处理器中各级缓存的大小和速度对性能是至关重要的,最重要的是 L1 数据缓存(D-cache)和 L1 指令缓存(I-cache)。有些处理器会选择小的 L1 缓存(Pentium 4E Prescott、Scorpion 和 Krait 的 L1 缓存为 16k(指令缓存和数据缓存各占一半),早期的 Pentium 4s 和 UltraSPARC T1/T2/T3 更小,只有 8k)。如今大多数设计已经确定了 32k 作为一个比较均衡的选择,少数会选择更大的 64k(Athlon、Athlon 64/Phenom、UltraSPARC III/IV、Apple A7/A8),偶尔甚至会高达 128k(Denver 的 I-cache,D-cache 为 64k)。

对于现代处理器的 L1 数据缓存来说,延迟通常是 3 或 4 个周期,这取决于处理器的一般时钟速度,但偶尔也会更短(在 UltraSPARC III/IV 上是 2 个周期,这要归功于无时钟的 “波浪” 流水线,在早期的处理器中是 2 个周期,这是因为它们的时钟速度较慢,流水线较短)。如果缓存延迟增加一个周期,比如从 3 个周期增加到 4 个周期,或者从 4 个周期增加到 5 个周期,看似变化不大,但实际上是对性能的严重打击,这一点很少被终端用户注意到或理解。对于日常常见的追逐指针的代码(pointer-chasing code)来说,处理器的负载延迟是影响实际性能的主要因素。

大多数现代处理器都有一个大型的二级或三级片上缓存,通常由所有核心共享。这个缓存也是非常重要的,但是它的具体大小很大程度上取决于正在运行的应用程序的类型和该应用程序的活动工作集(active working set)的大小:2 MB 的 L3 和 8 MB 的 L3 之间的差异,对于某些应用程序来说几乎不可测,而对于其他应用程序来说则是巨大的。鉴于相对较小的 L1 缓存已经占据了许多现代处理器内核的芯片面积的很大一部分,你可以想象一个大的 L2 或 L3 缓存会占据多少面积,然而这对于解决内存墙问题是必要的代价。通常情况下,大型的 L2/L3 缓存所占的面积占到了芯片总面积的一半,以至于在芯片照片上可以清晰地看到它的身影,在核心和内存控制器比较 “凌乱” 的逻辑晶体管的衬托下,它以一种相对干净、重复的结构格外显眼。

缓存的冲突和组相联

理想情况下,缓存会保存在未来最有可能被使用到的数据,但由于缓存不能预知未来,一个近似的方法是保存最常被用到的数据。

准确地保留最近使用的数据意味着来自任何内存位置的数据都可以被放入任何 cache line,因此缓存将精确地包含最近使用的 n KB 数据,这极有助于利用空间局部性。但是这种设计并不适合用于快速访问,访问缓存需要检查每条 cache line 是否匹配,对于一个具有数百行的现代缓存来说这个操作是很慢的。(译注:这类缓存被称为全相联映射)

取而代之的是,缓存通常仅允许来自内存中特定地址的数据占据高速缓存内的一个或最多几个位置。因此,在访问期间仅需要一个或几个检查,因此访问可以保持快速(快速访问是缓存设计的首要目的)。但是,这种方法确实有一个缺点,这意味着缓存存储的数据并不是绝对的最优组合,因为内存中的几个不同位置都将映射到缓存中的同一位置。当同时需要两个这样的内存位置时,这种情况称为缓存冲突。

缓存冲突的最坏情况是:当一个程序重复访问两个内存地址,而这两个地址恰好映射到同一条 cache line 时,缓存必须不断地从主内存中加载数据,因此在每次访问实际上都是访问主存的延迟(100 个周期或更多)。这种类型的情况被称为抖动 / 颠簸(thrashing),尽管有明显的时间和空间局部性,但缓存没有实现任何加速反而徒增麻烦。由于这类内存位置和 cache line 之间的简单映射的限制,缓存无法利用这种特殊访问模式提供便利。

为了解决这个问题,更复杂的缓存系统能够将数据放在缓存内的少量不同地方,而不是只放在一个地方。一个数据在缓存中可以存储的地方数量称为其关联性。”关联性” 一词得名于缓存查找是通过关联来工作的,或者说,内存中的一个特定地址与缓存中的一个特定位置相关联(对于组相联缓存来说是一组位置)。

如上所述,最简单、最快的缓存只允许内存中的每个地址在缓存中占一个位置:每个数据只需看地址的低位就可以简单地映射到缓存中的 address % size 位置(% 是取模操作,如上图 19)。这就是所谓的直接映射式缓存。在直接映射缓存中,内存中任何两个地址低位地址相同的位置都会映射到同一缓存行并造成缓存冲突。

允许数据根据其地址占据 2 个位置中的一个位置的缓存称为 2 路组相联缓存(2-way set-associative)。同理,4 路组相联允许任何给定数据占据 4 个可能的位置,8 路允许占据 8 个可能的位置。组相联缓存的工作原理和直接映射式缓存很像,只是有几个并行索引表,通过比较每个表的标签,看是否有任何一个表匹配。

 

图 20 – 4 路组相联

 

内存带宽和延迟

由于内存是按块进行数据传输的,而且缓存缺失是一个足以使得处理器产生停顿的严重问题,因此内存的传输速度至关重要。在内存系统中,传输速度被称为带宽,那么带宽和延迟在概念上有何不同?

一个不错的比喻是高速公路:假设你要开车去一个 100 公里外的城市,将公路加宽一倍能提高单位时间通过的汽车数量(带宽),但是对你个人而言并不能让你的行驶时间减半(延迟)。如果想要增加车流量,那么更多的车道是有效的(更宽的总线),但是如果想更快的抵达就需要做一些其他事情,比如提高车速(减少内存时延)或者抄近道(更短的布线)。或者直接就近建造一个超级市场,让人们根本不需要那么频繁的跑去其他城市(缓存)。

对于存储系统,通常需要在延迟和带宽之间进行微妙的权衡:较低的延迟设计对于 pointer-chasing code(如编译器和数据库系统)会更好,而侧重于带宽的系统对于具有简单、线性访问模式的程序(如图像处理和科学代码)具有优势。

增加带宽是相当容易的 —— 只需增加内存 bank 数量、加宽总线,就可以轻松地将带宽提高到两倍甚至四倍,事实上许多高端系统都是这样做的。但它也有缺点,特别是更宽的总线意味着更昂贵的主板,对系统添加内存的方式有限制(必须成对的安装),以及更高的最低内存配置。

至于延迟则要比带宽难以提升的多,常言道:“你不能贿赂上帝”,人类无法改变物理定律。不过即使这很难,在过去的几年里,有效的内存延迟还是有一些不错的改进,主要是同步时钟 DRAM(SDRAM)的出现和广泛使用,它与内存总线使用相同的时钟。SDRAM 的主要好处是:芯片运行的内部时序方面和交错结构都暴露在系统中,因此可以利用这些特点,让内存系统流水线化,允许在一次访问没有完成之前就开始一次新的访问,从而消除了旧的异步 DRAM 系统中的少量等待时间(旧的内存设计在开始下一次访问之前,必须等待当前的访问完成),平均来说,异步内存系统在开始新的请求之前必须等待上一次访问的半条缓存线的传输,这往往是几个总线周期,我们在之前已经讨论过,总线周期是非常慢的。

除了有效延迟的减少,SRRAM 也给内存带宽带来了很大的提高,因为在 SDRAM 内存系统允许在同一时刻有多个没有完成的请求处于等待中,所有的请求都以高效、完全流水线的方式进行处理。这种流水线化对内存带宽有戏剧性的影响:和同一时代的异步内存系统相比,SDRAM 系统的延迟只是略低,底层的内存单元也没有什么不同却能提供两倍或三倍的带宽。

内存技术的进一步改进,以及更多层次的缓存,是否能够继续解决内存墙问题,同时又能满足越来越多的处理器核心所要求的越来越高的带宽?还是说,未来会因为内存系统的瓶颈越来越明显导致处理器微架构和核心数量不再有明显的改善,内存系统变成最重要的东西?这将是有趣的观察,虽然预测未来从来都不是一件容易的事,但我们有充分的理由保持乐观。

致谢

本文的整体风格,特别是关于处理器 “指令流” 和微架构图的风格,来自于 Norman Jouppi 和 David Wall 在 1989 年发表的一篇著名的 ASPLOS 论文,Shlomo Weiss 和 James Smith 的《POWER & PowerPC》一书,以及 Hennessy/Patterson 两本非常著名的教科书《Computer Architecture:A Quantitative Approach》和《Computer Organization and Design: The Hardware/Software Interface》。

当然,同样的材料还有很多其他的介绍,自然也都有些相似,然而以上四本是特别好的(在我看来)。要想了解更多关于这些主题的知识,这些书是一个很好的开始。

 


本文链接:https://www.starduster.me/2020/11/05/modern-microprocessors-a-90-minute-guide/
本站基于 Creactive Commons BY-NC-SA 4.0 License 允许并欢迎您在注明来源和非商业使用前提下自由地对本文进行复制、分享或基于本文进行创作。
请注意:受限于笔者水平,本站内容可能存在主观臆断或事实错误,文中信息也可能因时间推移而不再准确,在此提醒读者结合自身判断谨慎地采纳。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据