本文翻译自:Modern Microprocessors A 90-Minute Guide!,,我认为原文是相当好的计算机体系结构方面的概述,与时代相结合是国内计算机课本普遍缺失的一环,本文可作为一个有效的补充,向原作者和其他译者表示感谢。
(阅读 https://cxd2014.github.io/2016/09/15/cpu-architecture/ 时发现只有前半部分,另外 CSDN 有一篇翻译了后半但是他删掉了不少段落,于是我还是抱着练习英语的心态花了点时间自己翻了一遍,虽然试图避免翻译腔,但是笔者水平有限,要是觉得翻得狗屁不通非常抱歉)
Contents
现代微处理器架构
警告:本文章非权威,仅仅只是兴趣
好吧,如果你是 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 年代中期开始,处理器就可以同时执行多个指令。
想一想是 CPU 是怎样执行的?首先是取指、解码,接着在合适的功能单元中执行,最后将结果写入寄存器。根据这种方式,一个简单的处理器执行一条指令需要 4 个周期(CPI=4)。
图 1 – 顺序处理器的指令流
现代处理器将这些阶段相互叠加(overlap)到一条流水线中,就像工厂的装配流水线。一条指令正在执行的同时下一条指令开始解码,下下一条指令则正在取指……
图 2 – 流水线处理器的指令流
现在处理器的每个时钟周期可以执行一条指令(CPI=1)。在完全没有改变时钟频率的情况下将处理器速度提高了 4 倍。听起来很不错?
从硬件的角度来看,流水线的每个阶段由一些组合逻辑、(可能有的)寄存器组、和高速缓存组成。流水线的每个阶段被锁存器分隔开。一个共同的时钟信号来同步每个流水线阶段之间的锁存器,以便于所有锁存器在同一时间捕获流水线的每个阶段产生的结果。也就是说使用时钟来驱动指令在流水线上流动。
在每个时钟周期的开始,流水线中的锁存器保存着当前正在执行指令的数据和控制信息,这些信息构成了该指令输入到流水线下一阶段的逻辑电路。在一个时钟周期中,信号通过这一阶段的组合逻辑传输到下一阶段,在时钟周期的最后每个阶段产生的输出正好被下一阶段的锁存器捕获……
图 3 – 流水线微架构
在执行阶段结束后,指令的结果就应该被确定了,下一条指令此时理应能够立刻用到这个结果(而不是等待结果在写回阶段保存到寄存器后才能使用),为了实现一点,增加了被称为 “旁路(bypass)” 的转发线路,将结果沿着流水线返回……
图 4 – 流水线微架构中的旁路
虽然每个流水线阶段看起来很简单,但是在 执行 这个关键阶段需要制造几组不同的逻辑(多条路径),为处理器必须有的每一种操作制作不同的功能单元……
图 5 – 流水线微架构中的更多细节
早期的 RISC 处理器,例如 IBM 的 801 研究原型,MIPS R2000(基于斯坦福大学的 MIPS 架构)和原版的 SPARC(伯克利 RISC 的衍生项目) ,都实现了如上所示的简单 5 级流水线。在同一时期主流的 80386, 68030 和 VAX 这些 CISC 处理器的大部分指令还是工作在顺序执行模式 —— 因为 RISC 处理器和 X86, 68K, VAX 为代表的 CISC 处理器不同,RISC 处理器的指令大多是简单的寄存器之间操作,因而 RISC 可以更加容易地实现流水线。结果,使用了流水线工作模式的 SPARC@20MHz 比顺序处理的 386@33MHz 运行速度快得多。从那时开始每个处理器都实现了流水线模式,至少一定程度上的流水线化。David Patterson 的这篇文章 1985 CACM article 对早期 RISC 研究项目做了一个很好的总结。(译注:就是本文末尾提到的 计算机组成原理硬件软件接口 和 计算机体系结构量化方法 的作者)
深度流水线
不考虑其他因素的前提下,由于时钟频率受制于流水线中最长、最慢的那个阶段。如果把流水线阶段(尤其是耗时的阶段)拆分成更加简单的子阶段,那么整个处理器的主频就可以运行得更高!当然,此时每条指令需要花费更多的时钟周期来完成(每个时钟周期有额外的时延,一条指令拆成多个周期执行,总的时延可能增加),但是处理器仍然是每个周期完成一条指令(吞吐量),由于时钟频率更快所以处理器每秒可以执行更多的指令(实际性能)……
(译注:一个时钟周期要大于流水线中最慢的那个阶段,而随着外界环境变化不同逻辑门的反应时间可能发生改变,因此段首注明不考虑其他因素。拆分流水线阶段的这个思想被称为 multi-cycle processing)
图 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 i2/i3 Sandy/Ivy Bridge, Core i4/i5 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 处理器的流水线级数比同时代的 RISC 处理器多,因为他们需要额外的工作来解码复杂的 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 个。
PowerPC 的前身,IBM POWER1 是第一个主流超标量处理器。之后大多数 RISC 处理器(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 i1 Nehalem, Core i2/i*3 Sandy/Ivy Bridge, Apple A7/A8 |
7 | Denver |
8 | Core i4/i5 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 的指令相互之间没有关联。这意味着指令之间不需要依赖性检查,并且当缓存未命中时没有办法停止单独指令的执行只能停止整个 流水线。因此编译器需要将有依赖关系的指令进行适当的协调,使其之间相隔合适的时钟周期,如果没有合适指令来填充这个间隔时间,甚至会使用 nops
(空指令)指令来填充,这使得编译器变得复杂,而超标量处理器上通常是在运行时刻做这些调度的,为了节约处理器宝贵的片上资源,编译器中会尽可能减少空指令的插入。
非 VLIW 设计的 CPU 仍然是目前市场上的主流,但是 Intel 的 IA-64 架构(应用于安腾处理器系列产品中)曾经试图取代 x86 架构。Intel 将 IA-64 称为 EPIC
设计,意思是 “显式并行指令计算”(explicitly parallel instruction computing),但是实际上就是 VLIW 的基础上加上智能分组(保证长期兼容性)和分支预测功能。图形处理器(GPU)中的可编程着色器有时候也采用 VLIW 设计,同时还有很多数字信号处理器(DSP),也有 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 个时钟周期。即使 Pentium Pro/II/III 处理器的动态分支预测器非常智能(正确率高达90%),但高昂的分支预测惩罚依然会使其浪费掉 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 个)。更重要的是这种做法完全避免了分支预测错误时的巨大惩罚。
当然如果 if
和 else
语句中的代码块非常大,则使用谓词会比使用分支执行更多的指令,因为处理器会将两条路径上的代码都执行一遍。这个棘手的问题是优化时不得不考虑的问:多执行几条指令以消除分支是否值得?在很大或者很小的代码块中这个问题很容易决定,但是对于那些中等大小的代码块则需要经过复杂的权衡。
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,这词实在是太奇怪了下面保留不翻,有的地方还会使用”brawny”和”wimpy”,意思类似)。这种简单也有趣的设计风格分类最早出现在 Linley Gwennap 的 1993 Microprocessor Report editorial 中,Dileep Bhandarkar 的 Alpha Implementations & Architecture 一书使其广为人知。
Brainiac 设计处于更智能的一端,历史上有大量支持乱序执行的硬件付出了数百万逻辑晶体管和多年的设计努力,试图从代码中压榨出每一滴指令级并行性。相比之下,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 墙
奔腾 4 处理器严重的电能消耗和散热问题证明了时钟频率是有极限的。事实证明电能消耗的增长速度远比时钟增长速度快——无论使用任何芯片技术,20% 的时钟速度提升会导致能源消耗提高 50%,因为不仅仅是晶体管的开关频率提高 20%,也需要提高电压才能驱动高速电路中的信号满足更短的时序要求。
虽然时钟频率能随着电压线性增加,但是功耗却是正比于电压的三次方的,也就是功耗正比于频率的三次方。更糟糕的是除了正常的晶体管开关造成的电能消耗外,还有一小部分的能耗来自静态泄漏,当晶体管关闭流过它的电流并不会完全降低到 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 是指分支跳转较多、逻辑上高度序列化、难以并行处理的代码)
x86 介绍
x86 也会遇到上面那些问题,但是 Intel 和 AMD 是怎样让一个已经存在了 35 年的架构在 CPU 不断发展的过程中仍然保持竞争力?
被称为惊人的工程的早期奔腾是一款超标量 x86 指令集的处理器,在复杂并且混乱的 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 (也被称为 P6)处理器是第一个采用 x86 指令分离的微架构设计,如今所有现代 x86 处理器使用这种技术。当然和各种各样的 RISC 处理器一样他们在流水线和功能单元等方面的设计细节是不同的,但是将 x86 指令翻译为类 RSIC 指令的基本思想是一样的。
当前的 x86 处理器甚至将翻译过后的微指令储存在一个很小的缓存当中,命名为 L0
微指令缓存。用于避免在循环当中一次次重复翻译相同的 x86 指令,这样不仅节约了时间而且减少了能量消耗。这就是为什么 Core i2/i3 Sandy/Ivy Bridge 和 Core i4/i5 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 处理器需要复制处理器中表示一个线程运行状态的所有器件,例如程序计数器(PC 寄存器)、架构可见的寄存器(不是重命名寄存器)、保存在 TLB 中的内存映射等等。幸运的是这些器件只占用整个处理器硬件的一小部分。真正庞大、复杂的部分例如解码器和调度逻辑、功能单元和缓存这些都是线程之间共用的。
当然处理器也必须在任何时刻跟踪哪条指令和哪个重命名寄存器属于哪个线程的,但是这只需要在整个处理器核心逻辑上增加少量复杂度。因此只需要花费少量成本而得到了整体性能的提升。
SMT 处理器的指令流就像下面这样:
图 14 – SMT 处理器的指令流
太棒了!现在我们可以通过运行多线程来填充流水线气泡,我们可以增加更多的功能单元并且可以实现真正的多指令发射 ,在某些情况下设甚至可能提高单线程的性能(例如那些 ILP 友好的代码)。
因此我们可以实现每周期发射 20 条指令的处理器吗?很遗憾,并不是。
首先实现 SMT 的前提是同时有很多个程序在运行,或者一个进程中有多个线程在同时运行。从多处理器系统中获得的经验告诉我们不可能总是如此。实践中,至少对于桌面电脑、笔记本、平板、手机和小型服务器来说,同时有多个不同程序在运行的情况是很少见的,因此通常机器上只有一个任务在执行。
例如数据库系统、图像视频处理、音频处理、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^2 指数增长,例如一个5发射处理器的分发逻辑比4发射大一半以上,到了6发射就会大两倍,以此类推,一个8发射处理器只比4发射宽度增加一倍,却要增加4倍以上的面积。此外,一个宽发射的超标量设计要求多端口访问的寄存器和缓存来满足多个指令同时访问的需求。所有的这些因素不仅仅导致芯片体积的增加,还大大增加了电路设计时的布线长度而严重限制了时钟速度。所以一个 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 图形渲染),更简单的处理器会更好,因为无论如何,大核 / 宽核都会将大部分时间用于等待内存。然而大多数应用场景下单线程的性能要重要得多,更何况很有可能根本没有足够多的活动线程可供 SMT 调度 。所以采用更少但更大、更宽、更偏向于 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 的核心设计,对比 POWER7,核心数从8核提升到了12核,每核 SMT 线程数从4提升到了8。当然,这么大的 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 向量指令可以产生惊人的速度提升。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,而 Denver 的 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 系统中的少量等待时间(旧的内存设计在开始下一次访问之前,必须等待当前的访问完成),平均来说,异步内存系统在开始新的请求之前必须等待上一次访问的半条缓存线的传输,这往往是几个总线周期,我们在之前已经讨论过,总线时钟是非常慢的。
除了降低延迟,SDRAM 也大幅提高了内存带宽,因为 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 允许并欢迎您在注明来源和非商业使用前提下自由地对本文进行复制、分享或基于本文进行创作。
请注意:受限于笔者水平,本站内容可能存在主观臆断或事实错误,文中信息也可能因时间推移而不再准确,在此提醒读者结合自身判断谨慎地采纳。
Permalink
Permalink
指令依赖和延迟延迟一节:
但是对于多发射的情况,应该改为‘对于乘法的情况’
Permalink
多谢指正
Permalink
显示并行:“显式”?
Permalink
感谢指出