现代微处理器

1. 参考文章

Modern Microprocessors. A 90-Minute Guide!

本文绝对是一篇非常好的文章,以本人的垃圾水平暂时只能读懂部分,并且有点看不下去,先挖个坑。

2. 阅读笔记

作者首先罗列了一些具有不同时钟速度的处理器(MIPS R10000 195MHz,Alpha 21164 400MHz,UltraSPARC 300MHz,Pentium II 300MHz,PowerPC G3 300MHz,POWER2 135MHz)在处理int和float数据时的耗时对比。对比结论:并不是时钟速度越快,处理数据的能力越强,而是要关注每个时钟内做了多少事情。

(1)作者开始介绍流水线和指令级别并行

通常来说,处理器都是执行完一条指令完成后再执行下一条指令。事实上,自1980年代中期之后,处理器就不是这么做的了,而是多条指令并行执行。指令分为fetch、decode、execute、writeback四个阶段,最简单的做法是一条指令的四个阶段执行完成后才会执行下一条指令,这样CPI(cycles per instruction)就是4。现代处理器会将几个阶段并行处理。例如指令a已经fetch完成正在decode,指令b就可以开始fetch了,这样CPI就是1了。

指令的不同阶段被latch(闩锁,shuan suo)分开,所有的latch根据一个公用的时钟信号进行同步。换句话说,时钟信号pump(泵送)指令在pipeline上的操作。由于指令经过execute阶段后已经可以被其他指令使用了,因此无需等到指令结果写回到目的寄存器再取用。现代处理器增加了bypasses(旁路)手段来实现该效果,不仅在execute阶段向后传递数据,也可以在writeback阶段先后传递数据。

另外需要注意,execute阶段是由多个执行单元组成的,例如处理int类型的单元、处理float类型的单元、处理跳转的单元等。

(2)作者继续深入流水线,介绍superpipelining

由于时钟速度受限于管道中最长、执行时间最慢的阶段,所以现代处理器将每个阶段进一步划分为更小的阶段。例如execute阶段划分为e0、e1、e2,当执行到指令a的e1阶段时,指令b可以开始执行e0阶段,这样每个时钟周期可以执行更多的指令。现代处理器每个阶段大约有12-25个小阶段,latch本身也有3-5个小阶段,这样构成的整体pipeline就会特别深。

(3)作者开始介绍新的问题Superscalar

前面说了execute单元有多个不同类型处理单元组成。为了支撑这些工作,decode单元通常也需要具有并行解码多条指令的能力。在execute阶段中,不同的单元通常具有不同的阶段。例如整数执行单元包括10个stage,浮点执行单元包括15个stage,内存访问单元包括13个stage。根据execute阶段内不同单元的组成情况,处理器也具有不同的指令处理能力。例如同时执行三个整数指令(CPI=0.33,instructions per cycle IPC=3,instruction-level parallelism ILP=3),或者两个浮点指令(CPI=0.5),或者一个整数指令、一个浮点指令、一个内存指令(CPI=0.33)等。不同的产品根据其不同的需求会设计出不同的功能单元组合,并非固定一致。

当然superscalar和superpipelining可以组合起来,进一步提高每个指令周期可执行的指令数量。当前几乎所有的处理都是superpipelined-superscalar的,只不过是简称为superscalar。

(4)作者开始介绍Explict Parallelism-VLIW(very long instruction word)

由于上文提到的bypasses能力,数据向后传输不是问题,因此一条指令可以由多个小指令组成。具体可以参考文章中该部分图片理解,即fetch、decode、writeback阶段均为一个,execute阶段由多个单元组成,看上去比较类似superscalar。这种指令称为超长指令集,每条指令包含多个可并行执行的操作。不过,VLIW通常存在由于cache miss导致指令卡住的情况。作为主力CPU,尚无比较成功的VILW商业实现。

(5)作者开始介绍Instruction Dependencies & Latency

为什么不设计一个suuuuuuuuuuuuuuuuuuperpipelining-suuuuuuuuuuuuuuuuperscalar的硬件呢,这里要考虑两点:指令依赖和latency。首先说指令依赖,对于以下的代码:

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

第二条指令的计算依赖第一条指令的计算结果,这样superpipelining的策略就不能使用了。另外,通常来说整数类型的指令execute通常比较快,乘法会慢一些,除法会更慢。因此当第一条指令未执行完时,处理器指令只能在第二条指令之前加入空泡,直到第一条指令执行完。从指令进入execute阶段到计算结果可以使用的这个过程叫做指令的latency,pipeline越长,latency越大。这时候,superpipelining策略就没那么有效了,因为pipeline上充满了解决上边数据依赖问题的空泡。

注意,以上说的latency是从编译器视角来看的。从硬件工程师的视角来看,通常认为latency是指令的execute阶段需要多少个cycle。

(6)作者开始介绍Branch & Branch Prediction

考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// source
if (a > 7) {
b = c;
} else {
b = d;
}

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

ble L1这条指令执行到execute阶段时,可以开始fetch下一条指令了。但是应该fetchmov c, bbr L2还是fetchmov d, b呢,这个只能根据上一条指令的结果才能得出判断。但是如果此时一直等待,就会阻塞整个流水线的进行。经过统计,每6条指令中就有一条是跳转指令,因此不能一直等待。处理器选择了guess方法,猜测下一条要执行哪条指令,并开始处理。如果猜错了,指令的所有cycle将被浪费;但是如果猜对了,流水线就会全速前进。

如何进行猜测呢?有三种方法:一是static branch prediction,即编译器标记分支应该往哪边走。对于存在标记位的指令容易做到,但是对于某些没有这种标记位的指令就不容易做到了。二是使用根据上一条指令判断下一条指令。不过这对于循环比较好实现,对于普通的if-else分支还是不好实现。三是处理器使用片上branch prediction table在runtime阶段进行预测。分支预测表上存储了过去分支的地址,并提供一个或两个bit位来指示是否采用过去的分支。尽管占用了宝贵的片上空间,但是这对于性能提升还是有收益的。不幸的是,即使最好的分支预测技术有时也会出错,对于较长的流水线来说许多指令将会被取消,这被称为mispredict penalty。

现代处理器还会使用许多更先进的方法。例如除了记录分支的走向,还会记录通往该分支的其他指令,这种称为two-level adaptive predictor。再例如保留全局分支历史,用来分析当前分支,即使其他分支跟当前分支相隔很远,这种称为gshare或gselect预测器。更高级的处理器会同时实现多种分支预测器,并看哪一种对于单一分支效果更好。

(7)作者开始介绍Eliminating Branches with Predication

与其进行复杂的分支预测,不如直接将跳转指令进行elimate。对于上边的汇编指令,可以转化为以下指令:

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

这里引入了一条cmovle指令。这样一次性删除了两个跳转指令,毫无疑问会提高软件性能。不过这同样存在一个问题,b=c和b=d都会执行,而之前的分支只需要执行一条。是否要进行这种优化,应该根据跳转指令后的指令量大小。如果指令量比较大,会增加所有要执行的指令数量,这样反而会降低了软件性能。

(8)作者开始介绍Instruction Scheduling, Register Renaming & OOO

前文中说的由于数据依赖、latency等问题导致pipeline中存在额外的空泡,这些空泡可以利用起来插入其他计算指令来减少总执行时间。有两种方法可以实现,分别介绍。

第一种是在运行时在硬件中对指令重新排序。在处理器中进行dynamic instruction scheduling(reordering)意味着必须增强调度逻辑,以查看指令组并尽可能无序地调度它们,以充分利用处理器的功能单元。这种方法称为out-of-order excution(乱序执行),或简称为OOO(有时写为OoO或OOE)。如果处理器要OOO,则需要记住这些指令之间的依赖关系。通过不处理原始架构定义的寄存器,而是使用一组重命名的寄存器,可以使这变得更容易。例如,将寄存器存储到内存中,然后将其他一些内存片段加载到同一寄存器中,代表不同的值,并且不需要进入同一物理寄存器。此外,如果这些不同的指令被映射到不同的物理寄存器,它们就可以并行执行,这就是OOO执行的全部要点。因此,处理器必须保留随时运行的指令及其使用的物理寄存器的映射。这个过程称为register renaming。作为额外的好处,可以使用可能更大的实际寄存器集来尝试从代码中提取更多并行性。

另一种方法是让编译器通过重新排列指令来优化代码。这称为static instruction scheduling或compile-time instruction scheduling。然后,重新排列的指令流可以被馈送到具有更简单的有序多发出逻辑的处理器,依靠编译器以最佳指令流spoon feed处理器。避免对复杂OOO逻辑的需求应该会使处理器更容易设计,耗电更少并且更小,这意味着可以将更多的内核或额外的缓存放置在相同数量的芯片区域。与OOO硬件相比,编译器方法还具有其他一些优势:它可以比硬件更深入地了解程序;它可以推测多条路径而不是仅一条路径,如果分支不可预测,这将是一个大问题。另一方面,不能指望编译器具有完美的能力。如果没有 OOO 硬件,当编译器无法预测缓存未命中等情况时,管道将会停止。

关于采用哪种方式更合适,作者在下一节的The Brainiac Debate一节中调研了不同处理器的发展历程,还是比较有意思的。总结一下自己的理解,面对功耗墙的问题,使用片上OOO是未来的趋势。

(9)作者开始介绍The Power Wall & The ILP Wall

事实证明,功耗增长的速度比时钟速度还要快。最终结果是,如今,将现代处理器的时钟速度提高相对适度的30%可能会消耗两倍的功率,并产生双倍的热量。在某种程度上,功率的增加是可以接受的,但在某个点上,目前约为150-200瓦,功率和热量问题变得难以控制,因为根本不可能为硅芯片提供那么多的功率和冷却。这就是所谓的power wall。过于注重时钟速度的处理器,例如Pentium 4、IBM的POWER6以及最近AMD的Bulldozer,很快就遇到了电源墙,发现自己无法将时钟速度推至最初希望的那么高,导致它们被较慢的处理器(利用了更多的指令级并行)击败。

不过,由于加载延迟、缓存未命中、分支和指令之间的依赖关系的组合,普通程序中没有很多细粒度的并行性。这种可用指令级并行性的限制称为ILP wall。过于关注ILP的处理器,例如早期的POWER处理器、SuperSPARC和MIPS R10000,很快发现它们提取额外指令级并行性的能力很有限,而额外的复杂性严重阻碍了它们达到快速时钟速度的能力,导致这些处理器被更笨但频率更高的处理器击败。

4-issue超标量处理器需要4个独立指令可用,并且在每个周期满足其所有依赖性和延迟。实际上,这几乎是不可能的,尤其是在3或4个周期的负载延迟的情况下。目前,主流单线程应用程序的实际指令级并行性最多仅限于每个周期2-3条指令。事实上,运行SPECint基准测试的现代处理器的平均ILP每个周期少于2条指令,并且SPEC基准测试比大多数大型实际应用程序更“容易”。某些类型的应用程序确实表现出更多的并行性,例如科学代码,但这些通常不能代表主流应用程序。还有一些类型的代码,例如“指针追逐”,即使每个周期维持1条指令也是极其困难的。对于这些程序来说,关键问题是内存系统,还有另一堵墙,即memory wall

(10)作者开始介绍Threads – SMT, Hyper-Threading & Multi-Core

正如已经提到的,通过超标量执行来利用指令级并行性的方法并没有想象中那么高效,因为大多数普通程序只是没有大量的细粒度并行性。正因为如此,即使是最激进的OOO超标量处理器,再加上一个智能且激进的编译器,在运行大多数主流的现实世界软件时,由于加载延迟、高速缓存未命中、分支和指令之间的依赖性的组合。每个周期仍然几乎不会超过平均约2-3条指令,因此峰值性能还远未达到。

如果当前正在执行的程序中没有其他可用的独立指令,则还有另一个潜在的独立指令源:其他正在运行的程序或同一程序中的其他线程。同步多线程 (SMT) 是一种处理器设计技术,它正是利用了这种类型的线程级并行性。SMT处理器仅使用一个物理处理器核心为系统提供两个或更多逻辑处理器。这使得SMT在芯片空间、制造成本、功耗和散热方面比多核处理器更加高效。从硬件角度来看,实现SMT需要复制处理器中存储每个线程“execute state”的所有部分,例如程序计数器、架构上可见的寄存器(但不是重命名寄存器)、TLB 中保存的内存映射等等。幸运的是,这些部件只占整个处理器硬件的一小部分。真正大而复杂的部分,例如解码器和调度逻辑、功能单元和缓存,都在线程之间共享。可以看以下文章中相关的图片配合理解。

(11)作者开始介绍Data parallelism-SIMD Vector Instructions

SIMD(single instruction,multiple data)。

(12)作者开始介绍Memory & The Memory Wall

内存加载往往发生在代码序列(基本块)的开头附近,大多数其他指令取决于正在加载的数据。这会导致所有其他指令停止,并且难以获得大量指令级并行性。事情比乍看起来还要糟糕,因为实际上大多数超标量处理器每个周期仍然只能发出一个或最多两个内存加载指令。处理器和主内存之间巨大且缓慢增长的差距问题被称为memory wall。

(13)作者开始介绍Caches & The Memory Hierarchy

现代处理器通过cache解决了内存墙的问题。cache是一种小型但快速的存储器,位于处理器芯片上或附近。它的作用是保存主存储器小块的副本。当处理器请求主内存的特定块时,如果数据在高速缓存中,高速缓存可以比主内存更快地提供它。通常,每个内核内部的处理器芯片本身都有小型但快速的L1 cache,大小通常约为 8-64k,还有一个较大的L2 cache,距离较远但仍在运行芯片(几百KB到几MB),可能还有更大、更慢的L3缓存等。片上缓存、任何片外外部缓存(E-cache)和主存储器(RAM)的组合一起形成一个内存层次结构,每个连续的级别都比之前的级别更大但速度更慢。当然,内存层次结构的底部是虚拟内存(分页/交换),它通过将RAM页面移入文件存储或从文件存储移出,提供了几乎无限量主内存的幻觉(这又慢了一些,通过大余量)。

由于程序的工作方式,缓存可以实现这些看似惊人的命中率。大多数程序在时间和空间上都表现出局部性——当程序访问一块内存时,它很有可能需要在不久的将来重新访问同一块内存(时间局部性),而且也有很大的机会将来它还需要访问附近的其他内存(空间局部性)。通过仅将最近访问的数据保留在缓存中来利用时间局部性。为了利用空间局部性,数据一次以几十个字节的块从主内存传输到高速缓存中,称为高速缓存行。可以配合图片了解缓存的工作原理。

(14)作者开始介绍Cache Conflicts & Associativity

TODO

(15)作者开始介绍Memory Bandwidth vs Latency

TODO