Chapter 10. The Memory Hierarchy

本章将介绍系统的内存分层架构。

之前几章,我们对于内存的理解就是一个很大的字节数组,可以用地址作为下标进行访问。但事实上,真正的计算机的存储系统背后的设备层次结构设计远比这层抽象要复杂。

正是计算机的存储系统的层次结构对有限、离散资源的管理和抽象,才能让程序的内存看起来呈现出这种线性的数组结构。本章就来讨论计算机存储系统背后的层次结构。

在正式学习存储系统的层次结构前,有必要稍微弄清楚底层硬件的情况。

10.1.1 Random-Access Memory (RAM)

当前大多数人所熟知的 “内存” 的一部分就是随机访问存储器(RAM),它具有以下的特征:

10.1.2 Nonvolatile Memories

ROMs

除了 RAM,计算机存储系统中另一类存储器是 非易失性存储器,它们在计算机断电后仍然能保存数据。请回忆数电中介绍的几种电子元件:

它们的电路结构想必各位脑海中都非常清楚对吧?

其中,大家所熟知的 闪存(Flash Memory),就是许多 EEPROM 元件组成的。

Tips. 计算机存储系统中的主存包括了 ROM 和 RAM,但闪存作为 ROM 的一种,也是现代最常用的 ROM,也可以和机械硬盘一起被用在外存中。

这些 ROM 的作用很广泛,主要有以下几个方面:

机械硬盘

除了 ROM 一类的非易失性存储器,还有 外存(外部存储器)也是非易失性存储器。我们这里介绍有代表性的机械硬盘:

⚠ 易错点警告:对于硬盘来说,它的承载量单位 GB 是特殊的:1 GB = 109 Bytes,这与之前我们见到的衡量二进制数据大小的单位 GiB(其简称也叫 GB,1 GiB = 230 Bytes)是不一样的。

这么说就明白了:承载量单位应该分开来看:1 G | B,1 吉 (Giga) = 109

衡量二进制数据大小的单位是个整体: 1 | GiB,GiB 本身是 gibibyte(giga-binary byte),是 byte 单位的 230 倍;

最常见的是计网的考题:1 MB/s 在 1 s 内传输的数据量略小于 1 MB,因为承载量速率单位 1 MB/s 是 106 Bytes / s,而数据大小 1 MiB 是 220=10242 Bytes;

固态硬盘

固态硬盘作为一种更新的、用来代替机械磁盘的外存形式,其控制器接口和机械磁盘一样,所以 CPU 一视同仁,不过它的速度介于 DRAM 和 机械硬盘之间。

其内部全部由闪存构建,并且由一个固件(firmware)称为闪存翻译层(flash translation layer)充当控制器(其作用和机械硬盘的磁盘控制器相当);

数据在 SSD 中是以 页(page) 为单位从闪存中读取和写入的。页的大小 512 KB ~ 4 KB,块(block,和之前提到的 CPU 所认为的逻辑块不同,下面解释)一般包含 32 ~ 128 页,取决于 SSD 实现的技术

这里所说的 “块” 为啥和之前的 “逻辑块” 不同?

首先明确,这是个术语重叠的现象。这里的 SSD 中的 “块” 是闪存的性质导致的。目前技术下闪存的数据擦除是成块成块擦除,这意味着一次会同时擦除多个页。因此,人们把闪存一次擦除的一组页集合称为 “块”

因此,想要修改某个块中的某个页,需要 将该块全部擦除(之前应该复制到其他位置) / 找到一个已被擦除的块 才能写入;

现代的闪存翻译层中实现了很多专有算法,能够延长 SSD 的使用寿命,例如缓存技术。

固态硬盘的读写效率大致是 随机访问 300 MB/s 左右,顺序访问 500 MB/s 左右(Intel SSD 730 的数据),可以说在计算机的存储系统的层次结构中,随机访问总是比顺序访问更耗时。其中 SSD 写入速度都略低于读取速度,这是因为闪存写入前需要擦除原先数据。总的来说,SSD 的访问速度大约比机械硬盘快 10 倍左右。

其实,SSD 和机械硬盘各有优劣,相较于机械硬盘,SSD 具有以下特征:

最后看一下各种存储介质和 CPU 时钟频率的关系:

2003 年,CPU 设计达到性能能源瓶颈,其发展从增大时钟频率转向增大 CPU 内核数。

 

10.1.3 Traditional Bus Structure Connecting CPU and Memory

说完计算机存储系统的硬件,那么它们是怎么与 CPU 建立连接,进而抽象出 “内存空间” 和 “存储空间” 的呢?

其中 I/O 桥是另外的一些芯片组,另一些芯片的集合。

CPU 访问主存

上面的图仅仅是比较简单粗略的抽象,不要较真。在比较老的计算机架构中(因为现代系统有专有的总线设计,非常复杂),采用 总线(bus)来连接 CPU 和主存的,数据通过总线在主存和 CPU 间来回传输。并且总线通常与其他多种设备共享

正常情况下,CPU 中的 ALU 根据汇编机器指令只需访问最近的寄存器就行;如果指令要求它访问内存,那么 ALU 会交由总线接口(Bus Interface)去从主存中取出相应位置的数据。

大家可以思考一下,在上面这幅图中,movq %rax, $A (A 为内存地址)和 movq $A, %rax 应该如何形象表示?

大家不难发现,寄存器离 CPU 很近,所以访问速度很快,通常在 3 个时钟周期左右就能读写到值。但是内存(图中 Memory Devices 芯片组)离 CPU 相当远,中间的步骤相当多,所以对内存读写所消耗的时间大约是对寄存器读写耗时的 100 倍左右。这就是计算机内存系统引入所发现的其中一个性能损耗。

CPU 访问外部设备(以外存: 磁盘为例)

再来看 I/O 总线(I/O Bus),它将各个设备与 I/O 桥连接,使得外部设备能和主存一样被 CPU 访问。I/O Bus 看起来是一条线,可实际不是如此。因为在老式的计算机中,它被称为 PCI 总线(广播总线),主干连接到各个设备,任何设备只要更改其上的数据,其他设备就能发现。

但现代的 I/O 总线并不是一条线了,它采用了 PCI Express 的总线结构,并不像上面画的一样,它是 点对点地连接两个设备,实现不同(我们不会深入讨论),但提供的功能就像图上画的一样。所以可以把 I/O Bus 看作一组电子线路即可。

其中 Disk、Mouse、Keyboard、Monitor 等设备使用细双箭头,表示它们不是焊在主板上,而是插入主板上的控制器(adapter、controller)来连接。

考虑如果 CPU 想要访问某个磁盘设备的某个扇区,那么会进行如下步骤:

 

10.2 Locality

无论是 8.1.2 中介绍的各自存储介质在物理层面上的性能约束,还是 8.1.3 中介绍的数据读取流程上的性能限制,都是计算机存储系统需要解决的重要问题,否则,在硬件上计算机就难以继续提升运行速度了。

其实,弥补 CPU 和内外存读写速率之间差距的机制之一就是程序的基本属性之一:局部性(locality)

局部性原则:程序倾向于使用 “内存地址接近或等于最近使用过的数据/指令地址的” 那些数据和指令。

原文:Programs tend to use data and instructions with addresses near or equal to those they have used recently.

这个原则来源于程序编写的逻辑——当程序访问到某个内存地址上的数据,那么在不久的将来,有很大的可能程序会访问该数据项或者附近的数据项

这种局部性有两种表现形式:

例子:识别代码中的局部性

这串代码中,有对数据的引用(Data references,转为汇编后会引用数据,通常只要有变量就有数据的引用)。

例如上面的数组 a,其元素在内存上连续,索引 i 每自增一个单位就访问一下(称为 stride-1 reference pattern),属于空间局部性;上面的变量 sum 在循环的每次迭代中都会被引用,属于时间局部性;

还有对指令的引用(Instruction references),例如循环中每一次都会执行循环体的内容,属于时间局部性;再比如程序代码顺序执行,本身就属于空间局部性。

不同编写方法的代码,其局部性不同。这就需要开发者训练观看的能力。举一个很简单的例子:

我们考虑对数据的引用,回忆 6.1 中对于 Nested Array 的内存排列的知识,数组数据连续排列且行优先,所以我们发现,使用 sum_array2 方式遍历数组,其数据相距距离很远,这意味着程序的局部性差于 sum_array1。而事实上,sum_array2 也真的会比 sum_array1 慢一个数量级;

例题:请修改以下代码,使得它满足 stride-1 reference pattern(即更优的程序局部性)

10.3 Conclusions for 10.1 & 10.2

没错,前面两节全部在为存储系统的层次结构做准备,之后我们才开始正式讨论 Memory Hierarchy。在此之前,我们先小总结一下前面的内容。

在本章开始,我们认识了计算机存储系统在硬件层面的两大组成:主存和外存。主存由 RAM(又分为 SRAM 和 DRAM)组成;外存则是硬盘(又分为机械硬盘、SSD)、可移动磁盘为主。

广义的内存包括:主存(RAM)、全部 ROM、Cache(高速缓存存储器);

我们紧接着比较了 SRAM 和 DRAM 的特征、异同,以及它们所使用的场合;对于 ROM,我们了解了不同的 ROM 类型,还有闪存的定义。

在外存方面,我们详细介绍了机械磁盘的物理结构(磁盘控制器、盘面、柱面、磁道、扇区、记录区、承载量计算、访问耗时分析、逻辑块),并类比出功能相同、实现不同的固态硬盘物理结构(闪存翻译层、页、块)。

于是,我们从上面存储介质的物理结构中分析并得出 其数据访问模式的性能限制和优劣

最后,我们分析了 CPU 访问主存CPU 访问外存 的情况和流程,从中我们得知了在内存读取的流程中也存在着性能的限制或者说损失。

基于这些物理结构的限制,我们讨论出代码结构层面能够在一定程度上弥补这些性能鸿沟的性质:局部性。我们根据 存储设备的物理结构数据存放的方式,可以设计写出更符合程序局部性的代码,这在一定程度上可以缓解 CPU 和内存之间访问的性能差距。

而观察代码的局部性,就需要对数据在内存中的 layout,还有储存设备的工作原理有一个很好的认识,这就是前几章、前几节的内容。

这些存储介质的物理特性,和程序的局部性相辅相成,相得益彰,为人们提供一种 “怎样设计存储系统” 的建议和信息。

接下来,我们将基于这些存储介质的物理性质,讨论在其上所建立的层次结构,和这些层次结构是如何抽象硬件,尽可能地为上层的计算机软件提供更连续完整的资源的。

 

10.4 Memory Hierarchy & Idea of Caching

下图是计算机存储系统的一个层次结构图。

其中,寄存器是访问速度最快的存储结构,它在每个 CPU 时钟周期内都可以直接访问到(大小最小、数量很少、价格最贵、速度最快);

接下来的是由 SRAM 组成的高速缓存存储器(Cache Memory),也处于 CPU 芯片内部,既不是主存,也不是寄存器;大小虽然是 MB 级,但已经比寄存器大多了。其中 3 层 Cache Memory 的具体结构将在下一章进行深入讨论;

再向下是 DRAM 组成的计算机的主存,一般你能看到的 “内存条” 就是它的组合结构,也是普通人经常说的 “内存”(狭义内存),一般从几个 G 到 几十 G 不等,程序运行内存(或者说后面要提到的虚拟内存)就是它的一部分;

最底层的像本地硬盘结构,甚至云端存储结构,空间一般都很大,但访问效率低下。

 

设计的核心:在 Memory Hierarchy 中,每一层都包含着从下一层所检索的数据(例如 CPU 寄存器保存着从 L1 高速缓存中取出的数据,依此类推);

这样设计的原因是为了充分利用各个层级资源的特征,将整体数据访问效率最大化(底层是极大的数据池,却能够以极快的速度进行访问)。

这么做之所以有效,是因为 缓存思想(Caching)的存在

这里所说的缓存,不是高速缓存存储器(Cache memories),而是一种思想作为一个更小、更快的存储设备,充当更慢设备中数据的暂存区域、能更快访问的数据子集。例如,主存 可以看成是本地硬盘的 缓存,这样一旦从磁盘获取数据,就无需在磁盘上访问它,在上一个层级内存中访问,速度得到提升。依此类推,缓存的思想在 Memory Hierarchy 中逐级传播

第 k 层更快、更小的存储设备,就是第 k+1 层更慢、更大存储设备的 缓存。

在真实场景中,每当程序访问一个不在缓存设备(第 k 层)中的数据,都会从第 k+1 层检索,并复制到第 k 层缓存起来。这是因为,根据程序的局部性,相较于第 k+1 层的设备,会更经常访问第 k 层设备中的数据,这就是 Memory Hierarchy 能最大化数据访问效率的原因。


在详细介绍高速缓存存储器(狭义的 Cache)前,我们简单介绍一下缓存的实现,因为缓存思想存在于存储层次结构的每一层。

储存数据的下层设备的空间(k+1 层)通常被分为一个个固定大小的块(blocks),缓存设备和下层设备间传输的数据以块为单位进行。

如果第 k+1 层是 Web 云端存储介质,那么 blocks 通常以文件形式传给磁盘(第 k 层,缓存设备);

如果第 k+1 层是主存,那么 blocks 可能是以某个特定大小的数据块传给高速缓存存储器(第 k 层,缓存设备);

……

在任意时间点,第 k 层的缓存设备中的数据都是第 k+1 层设备数据的一个子集。

对于 CPU 请求一个位于第 k+1 层的某个位置的数据这种情况,则 CPU 会先去层级最高的设备中寻找(假设找到第 k 层),则有两种情况:

最后,我们总结一下缓存在存储系统各层级的实现情况:

注:TLB(Translation Lookaside Buffer,后备缓冲)是一个在虚拟内存中使用的缓存,是虚拟地址翻译为物理地址的翻译过程的缓存

需要注意的是,各个层级的缓存究竟是由谁实现和管理的,这是缓存思想的一个重点,也能解释早在第 3 章的疑问——汇编代码中找不到管理高速缓存存储器的代码 的原因。

例如,当寄存器看作缓存设备时,是编译器决定用哪个寄存器、用多少个寄存器(怎么用的 conventions 是 ABI 决定的);

综上,缓存思想存在于计算机系统的几乎每个用到数据 I/O 的地方。

下一章将讨论 Memory Hierarchy 中一个具体的部分 高速缓存存储器,来深入了解缓存思想。

 

Chapter 11. Cache Memories

本章讲述 Memory Hierarchy 缓存思想中的重要一个体现:高速缓存存储器(Cache memories),它介于寄存器和内存之间,充当缓存设备的角色。

回忆上一章的内容,高速缓存器本质上是一种由 SRAM 组成的、由硬件直接管理的小型缓存存储设备:Cache memories are small, fast SRAM-based memories managed automatically in hardware.

它一般封装于 CPU 芯片中,几乎和寄存器距离 CPU 核心同样近(只是由于电路存取特性导致其慢于寄存器),存储主存中被频繁引用的数据块。

11.1 General Cache Organization

那么硬件是如何管理高速缓存存储器中的数据,在 CPU 需要的时候进行寻找呢?我们首先需要借鉴层次架构中一般的缓存模型,它们共同有一种缓存的管理方式和 layout;

首先,缓存本身就需要极速,这意味着设计缓存机制必须以非常严格且简单的方式去组织缓存模型,便于各个设备层级间进行查找。于是人们设计了下面的缓存数据组织形式:

缓存空间中一般包含 S=2s 个数据组(set),每一组又包含 E=2e 个数据行(line,图中横着排列),每一数据行由大小为 B=2b bytes(B binary digits)的数据区块和一个 tag 区、一个 valid bit 组成;现在解释一下目前可能有的疑惑:

其中高速缓存块的大小 不包含 Tag 和 valid 部分,也就是说,高速缓存的大小 C=S×E×B

11.2 Read Cache

那么缓存是如何被读取的?详细数据结构(和具体存储设备有关)又是什么样子的?

实际上,程序在运行中可能请求了下层存储介质中某个位置的数据,我们以高速缓存存储器和主存间查找数据的关系为例。步骤如下:

总体呈现出 “逐层向上缓存数据,逐层向下查找数据” 的形式。

11.2.1 Direct-Mapped Cache Simulation

下面详细解释上面的步骤。为了便于理解,我们首先从简单的情况讨论,当每一个缓存组只包含一个数据行的情况,这种情况被称为 “直接映射”(Direct-Mapped Cache Simulation)

假设某个机器的内存空间大小 M = 16 bytes(可以用 4-bit digit 代表地址),缓存空间中含有 4 个组(S = 4),每个组含有一个数据行(E = 1),每个数据行含有 2 bytes 的数据区块(B = 2)。

在数据区块中,block offset 的长度为 1 byte(b = 1,因为数据区块只有 2 bytes,一般有 B=2b),set index 的长度为 2 bytes(s = 2,因为缓冲区只有 4 组数据组,有 S=2s);

现在程序开始运行的时候,分别请求内存地址(X)为 0、1、7、8、0 的 1 byte 数据;

现在,高速缓存存储器中的所有数据行的 valid bit 都是 0(假设 0 代表无效,1 代表有效),并且开始接受 CPU 请求内存地址 X=0=00002 的数据 的请求。

首先,X 的 block offset 就是中间 2 bit00,所以在 set 0(第 0 组)中寻找;然后,将 X 的 tag 位(即最左边的 1 bit,0)与 set 0 的 tag 比较(是随机数),再看 valid bit 是 0,所以 cache miss(cold miss),如下动画(本人不会 Acrobat Animate,比较粗糙):

思考两个问题,第一个,为什这里的 tag 是 0 ?这是因为,我们在给定的 set offset 下(组相同),前面的 t bit(t=1)的 tag 位只是用来区分数据行的,借助了原内存地址 X 的前 t bits 数据而已,没有实际意义。

第二个,为什么这里要从 main memories 中同时读入 0、1 地址的数据?也就是说,为什么设定 B == 2 呢?这是因为,除去 set offset 的 2 bits、tag 的 1 bit,剩下内存地址 X 数码还有 1 bit 留给 block offset,因此 X 只能在该组的数据行中索引 2 bytes 的数据。

细细体会上面的话,你会发现这就是缓存空间如此设置、X 如此解析的原因(这里设计的和 IEEE 浮点数表示法一样巧妙,不容易用语言描述)。


好了,又有同学会好奇了,既然这些索引本身没有意义,只是借助了原地址的数码,那为什么设计缓存索引的人一定要 set index 在中间、tag 在最前面、block index 在最后面呢

这个问题非常有水平,这和二进制数码的变化方式有关。我们这里就分析对比 2 种解释 X 地址的方式:

我们接下来将缓存空间的组标为不同颜色,再把内存中将要分配到哪一组的数据块填上相同的颜色,发现结果如下:

我们发现,如果用 high bits indexing,那么内存地址相近的内存区域很容易被分配到相同的缓存组中,根据空间局部性,这样做会导致发生 conflict miss 的概率大大增加。所以,在缓存效率上,middle bits indexing 是优于 high bits indexing 的。其他情况同理。

这就是设计者们为什么要如此设计内存地址 X 在缓存中的这种解析方法。


继续看接下来的过程动画:

我们发现,5 次访问中,有高达 4 次的 cache miss。后面 2 次的 cache miss 都是 conflict miss,完全能够由 提高每组的数据行数(E 的大小)来避免。因此,缓存结构中 E 的大小越大越好(也就是每组中的数据行越多越好)。但是我们前面提到,一个组中各个数据行使用并行比较,这个操作依赖硬件的多路判断——也就是说,E 越大,硬件电路越复杂,硬件越贵。所以真实计算机硬件中会进行取舍,选择一个特定的 E 值。

当代(21 世纪初)市面上的常见的单组中数据行数目取值 E = 8,最大有 E = 16,是 Intel 的 16 路相联 L3 三级缓存。

事实上,B(数据区块的大小,block size)也是越大越好,因为越大越可以利用局部性,提升缓存命中概率。但 B 受限于两个因素:一是硬件成本(例如 Cache memories 由 昂贵的 SRAM 组成),二是块复制的时间代价,因为如果想要把很大的数据区块从内存挪至缓存中,也是一个不小的开销。

综合上面的考虑,设计者真正确定缓存空间各个参数的步骤如下:

  1. 确定合适的数据区块(就是在数据行中的那个字段)大小 B(通常被称为固定的缓存高级设计参数。Intel 一般 64 bytes);

  2. 根据实际应用场景和硬件成本情况确定大致的缓存空间总大小(也是固定的缓存高级设计参数之一);

  3. 根据硬件和实际情况确定数据组的关联性(associativity,即一个数据组中有多少数据行)E

  4. 由 1、2、3 就能计算出大致的缓存数据组数(S

最极端的情况是 全相关联高速缓存(Fully Associative Caches),缓存空间中只有一个组(S = 1,所有数据行在一个组中),这个时候如果能够并行比较,那么缓存效率是极高的。但是通常由于上述原因,我们大多数时候只能在软件级别的缓存 或者 在主存和硬盘之间的缓存模式(因为硬盘读取时间开销很大,值得我们使用复杂算法来获得更高的缓存效率,我们在“虚拟内存”一章会讨论)中找到这种组织形式。

11.2.2 E-Way Set Associative Cache Simulation

除了直接映射,还有一个稍微复杂点的例子,当不改变上面例子中的缓存空间大小,讨论每个缓存组包含 2 个数据行的情况,这也被称为 “2-Way Set Associative Cache(2 路相连高速缓存)Simulation”

这个时候 tag 变为 2-bit,set index 变为 1-bit,block index 还是 1-bit;

我们有类似上面 Directed-Mapped Cache Simulation 相近的步骤,如下图:

注意到一点,当关联性大于 1 的时候,同一个数据组中可能不同的数据行的 tag 可能是相同的,那么当我们想要覆盖数据的时候,就涉及到了选择覆盖的问题,它可以通过设计算法来完成。

根据局部性原理的逆定理(通常成立),如果一个数据长时间不被引用,那么它在未来的某个时间也不太可能被引用。所以,最常见的算法是 “最近最少使用” 策略(Least Recently Used Strategy),这种算法一般不需要额外的 bit 存储数据,只是从硬件层面跟踪在缓存中数据的使用频率(如按序保存虚拟时间戳),确保无效的数据行最先被覆盖,然后是使用频次更低的数据先被覆盖。

 

助记:

 

11.3 Write Cache

事实上,真正要更改某些数据恐怕比读数据更难,因为我们的缓存机制通常会产生多份数据的复制品。例如层级从低到高:硬盘、主存、L1 / L2 / L3 高速缓存,其中可能包含了同一份数据的副本。

于是在程序要求修改内存(仍然以 高速缓存存储器 和 内存 这对存储同样有 2 种情况 write-hitwrite-miss

如果遇到 write-hit(要写的内存数据就在缓存设备中)的情况,由于数据分布特殊性,那么有两种处理方法:

如果遇到的是 write-miss,那么也有 2 种方法(和 write-hint 是对称的操作):

一般情况下,由于对称性,人们一般选择 “write-back + write-allocate” 或 “write-through + no-write-allocate” 的策略中的其中一对(根据实际情况);

 

11.4 The Hierarchy of Cache Memories

讨论完了缓存读写的具体的逻辑实现,我们再来看看实际上硬件是如何对应这些实现的。同样以 高速缓存存储器 为例。到目前为止,我们都假设计算机系统中只有一个高速缓存存储器的缓存空间,但是实际上,早在前面第 8 章中就介绍了,一般计算机中有 L1、L2、L3 3 类 Cache memories。它们在硬件上是如何设置和协调的呢?

以 Intel Core i7 芯片为例,它的高速缓存层次结构如下:

如图,一般情况下,现代 CPU 有 4(桌面系统)/ 8 ~ 12(服务器类系统)个核,每个核可以各自并行,独立执行各自的指令流,每个处理器内核可以包含各自的通用寄存器(位于存储系统层次结构 L0).

在其中,每个核还会有 2 种 L1 Cache。其中一种是 d-cache(data cache,1 级数据高速缓存器),另一种是 i-cache(instruction cache,1 级指令高速缓存器)。它们的读取时延(4 个时钟周期)仅次于寄存器,正因速度和成本的关系,它们的大小非常小,只有约 32 KB;它们的关联性一般是 8 路(一个缓存组中有 8 个数据行);

在 L1 Cache 的下一层是 L2 Cache(L1 和),只有一种联合的高速缓存器(unified cache,同时包含某个核的数据和指令的缓存),读取速度稍慢(10 个时钟周期)于 L1 Cache,也是 8 路关联性,不过大小稍微大一点,有 256 KB;

再下层的 L3 Cache 不在 CPU 的核内,是被所有核心所共享的联合高速缓存存储器。8 bytes 大小、16 路关联性,但访问时延长达 40 ~ 75 个时钟周期;

它们间的关系和之前所说的各个层级的缓存设备一模一样,都是 “逐层向上缓存数据,逐层向下查找数据”

根据这些实际的物理结构,我们考虑一下高速缓存存储器的性能和损耗情况。我们建立如下的衡量指标(Cache Performance Metrics):

事实上,这些一次两次看似影响不大的 Cache miss 和 hit,对系统性能影响相当大!数学证明表明,99% 的 hit rate 的系统性能比 97% 的 hit rate 对应的系统性能迅速 2 倍

主要是因为 miss penalty 很大,miss rate 的权重远大于 hit rate 的权重。因此我们通常看 miss rate 而不是 hit rate;

那么我们分析之前的 cache 参数对性能的影响:

 

11.5 Performance Impact of Cache

11.5.1 Writing Cache Friendly Code Ⅰ

考虑上一节惊人的缓存性能的情况,我们确实应该写出一些 Cache Friendly 的代码,这是优化代码性能的一个重要方面。在分析了缓存的特性和程序局部性之后,我们可以这样来充分利用高速缓存带给程序的性能提升:

总结:我们分析缓存的组织原理和特性,是进一步定量地(如上面的指标)去体现、印证程序局部性的概念

11.5.2 The Memory Mountain

之前简单地对缓存组成的分析,让我们看到了关注缓存能够对程序带来较显著地性能影响。那么这节我们更深入地去探究缓存对程序性能的影响。

首先引入一个概念:

于是,我们可以绘制一个 时间局部性、空间局部性 关于 机器吞吐量的三维坐标图(存储器山),以此展示缓存对程序性能的重要影响。

其中,我们以访问数组的程序为例。我们将遍历数组的步长作为衡量程序空间局部性的指标(步长越大,空间局部性越小),将一次读取数组元素数量作为衡量程序时间局部性的指标(一次取出的数据越多,访问到相同地址数据的机会越小,时间局部性越小);

如图:

我们从读取数据量 size 的方向看图,会发现 memory mountain 有一个个像山脊一样的结构,这恰好对应了从 L1 到 Memories 的数据引用的平均性能(小的数据量更有可能在每次遍历时放在同一个缓存的 block 中,发生 cache miss 的机会就更小,只需要依靠更接近寄存器层级的缓存设备就能得到答案);

从数组访问步长 stride 的方向看图,会发现随着步长的增加,整体有一个负向的斜率。而随着步长大到一定程度,负向的斜率趋于平缓,这是因为步长大过了缓存 block size,导致几乎每次都会存在 Cache miss,就很难得到缓存的增益了

11.5.3 Writing Cache Friendly Code Ⅱ - Rearranging loops to improve spacial locality

借助上面对于 memory mountain 的进一步分析,我们还可以想到更多的具体利用缓存来优化程序性能的方法

例如,我们以矩阵乘法运算为例,我们假定以下的条件:

那么按照正确的一般矩阵相乘的方法,总单位运算次数 O(N3)

事实上,计算的一般方法有许多种(但都是 结果矩阵 C=(cij)N×N 的元素 cij=aikbkj),我们这里着重讨论缓存的效率受代码安排的影响,也即,什么样的矩阵乘法最能充分利用缓存

首先来对不同策略的矩阵乘法分析 Miss Rate:

由于运算的方式固定,我们可以通过更换内外层循环顺序(共 3!=6 种)来看究竟哪种方法最好。

在分析前,先再次回顾一下 C 中数组的 memory layout:

情况 1:先固定 i(A 的行),再固定 j(B 的列),最后遍历 k(A 第 i 行每一列、B 第 j 列每一行)

这种情况下,对每一个最内层循环

ℹ 平均每次内层循环约加载 2 次数据,存储 0 次,cache miss 次数 1.25 次

这种情况与 jik 顺序的情况相同;

情况 2:先固定 k(B 的行),再固定 i(A 固定位置 第 i 行 第 k 列),最后遍历 j(C 的第 i 行每一列)

这种情况下,对每一个最内层循环

Op Matrix A B C
Miss Rate 0.0 0.25 0.25

ℹ 平均每次内层循环约加载 2 次数据,存储 1 次,cache miss 次数 0.5 次

这种情况与 ikj 顺序的情况相同;

情况 3:先固定 j(C 的第 j 列每一行),再固定 k(A 的第 k 列),最后遍历 i(固定 B 遍历 C 的 列)

这种情况下,对每一个最内层循环

Op Matrix A B C
Miss Rate 1.0 0.0 1.0

ℹ 平均每次内层循环约加载 2 次数据,存储 1 次,cache miss 次数 2.0 次

这种情况与 kji 顺序的情况相同;


综合上面的三种情况,我们进行实际的测试,发现结果如我们所料:

事实证明,kij/ikj 遍历方法(固定 A / B 的位置,遍历剩下一个运算矩阵的行,来得到结果矩阵的行)是最能利用 cache memories 的优势的方法,而我们最常用的 ijk 方法却不是最好的方法。

 

11.5.4 Writing Cache Friendly Code Ⅲ - Using blocking to improve temporal locality

上一节的例子主要以矩阵乘法为例,从提升空间局部性的层面来充分利用缓存、提示程序缓存效率。本节将从另一个角度——提升程序时间局部性来讨论如何写出缓存友好的代码。

再举一个针对矩阵乘法的例子。

对于上面的乘法而言,我们作如下假设:

在这种情况下,最内层的循环中,每一个循环的 cache miss 平均次数为:n8+n=98n,于是总的 cache miss 的数量在 9n38 左右,显然,这样的 cache miss 数量会显著影响程序性能和对缓存的利用。于是,一种利用时间局部性的方法就出现了:

我们在每次内存循环取矩阵乘法的 C 的单元的时候,将单独取一个改为选取一个 block(小型块),如图所示,block 的宽度为 B

改为:

即计算代码改为:

这个时候我们再次分析 cache miss 的情况。

假设选取的 B 的大小能够被缓存利用:3B2<C,那么:

总而言之,这样的改进并没有根本上提升算法的时间复杂度,但是它却能确确实实地减少常数级别的 cache miss 数量(98n314Bn3),在一定程度上达到提升时间局部性的效果。这样,只要我们选择满足 3B3<C 的最大的 B 的取值,就能找到这种思路的最优计算方法。

为什么能够引起如此大的常数优化呢?主要是以下原因:

 

11.5.5 Cache Performance Summary

在 11.5 节中,我们通过几个例子了解到,虽然我们无法显式控制 cache 的存储方式,但是通过对于程序局部性的分析,我们可以更高效地利用 cache,从而提升程序允许效率。这主要可以从两个方面下手:

Chapter 12. Program Optimization

One of the themes for this chapter:

  • 去除程序不必要的工作、编写编译器友好代码、提升运行速度;

  • 利用机器代码特性,针对特定机器对程序优化;

编译器无法理解一些内容,例如 int 数据类型可能只用到相当小的范围、procedure call 究竟是什么意思,等待。编译器只是针对一些特定情况,对照 “cookbook” 进行有选择地优化。其遇到复杂或者特殊情况的 “保底” 方案是不对代码进行优化。

初始思路:查看程序汇编代码哪些地方没被优化,找到对应的源码部分进行重写,直至重构成编译器友好代码(只要不过度牺牲程序可读性就行)。

12.1 Goals of Optimization

12.2 Limits to Compiler Optimization

12.3 Generally Useful Optimizations

本部分的优化技巧不针对特定的编译器或者处理器,具有普适性。

绝大多数编译器在一定的优化等级下,都能优化本节的情况,但是我们应该学习这些方法。

注:一个能看到优化过程的网站:COMPILER EXPLORER

根据 12.1 中的目标,我们可以整理出一些常见的情况,这些情况下编译器能够优化,或者说具有普适性的优化策略,主要有以下两类:

这章仅仅叙述它们的思路,不涉及具体实现,因为具体实现就涉及到编译原理(AST 语法树等知识);

12.3.1 Constant Folding

常数折叠的方法主要有以下几个方面:

12.3.2 Dead Code Elimination

死代码删除方法的思路主要来源于 12.1 中的 “Avoid Branching” 和 “Minimize number of instructions”,分为以下几种情况:

这类优化方式看起来很蠢,但也很重要,因为有时候这些死代码不容易被肉眼识别,或者在编译器进行其他优化过程中,在语义树上出现了,那么就需要这种方法来清理。

12.3.3 Common Subexpression Elimination

CSE 的思路就是根据 AST 树的特征,约去重复计算,从而降低运算量,例子如下:

12.3.4 Code Motion

I.e., reduce frequency with which computation performed(也是降低代码重复运算频率,不过是在 global 范围进行)

12.3.5 Inlining

I.e., copy body of a function into its caller(s);

对于一些短的、计算开销小的非递归函数,即便编程人员不指定 inline 关键字,编译器也应该识别到并且内联操作。这样做的好处有两点:

这是个例子:

缺点:

12.3.6 Strength Reduction

I.e., replace costly operation with simpler one.(计算量减小)

 

12.4 Obstacles for Compiler to Optimization

现在反过来看,有哪些操作会阻碍编译器优化?

阻碍编译器优化的原因之一就是期间调用了其他函数,这样 gcc 可能就无法识别到优化方法。还有一个主要原因就是 “内存别名” 的存在。

12.4.1 Optimization Blocker #1: Procedure Calls

典型例子:

可以注意到,循环判断条件中有一个函数 strlen(s),我们都知道这是计算字符串长度的函数。但是编译器不知道,它认为这个函数有可能在每次循环中,返回值可能改变,所以不会把它优化为一个常数,而是保持每次循环判断时,都重新调用 strlen(s)

可是,strlen(s) 的复杂度是 O(n) 啊!这样好端端的 O(n) 能实现的函数被硬生生干成了 O(n2) ……

所以,正确的做法是,先把 strlen(s) 算出来。更好的主意是,干脆不使用 strlen(),因为判断字符串结束原本就是能在循环中发现的情况,并且把数组索引改成指针取值,这样还能再节省常数时间:

回过头来,为什么绝大多数的编译器没法优化这种 procedure calls 的情况?主要有几点原因:

综上,编译器一般的做法是将 procedure 看作一个黑盒,行为不确定,所以一般不会优化它的执行顺序。因此,开发者应该清楚意识到函数的作用,并且重视它执行的位置对代码性能的影响。

12.4.2 Optimization Blocker #2: Memory Aliasing

先看示例:

上面的代码看起来性能上没什么问题,大多数人都会如此实现代码。但是我们看看对应的 x86-64 汇编码:

我们发现,小小的 b[i] += a[i*n + j] 竟然有两次对内存的操作(从内存读到寄存器,计算后再写回内存),为什么会这样?为什么不直接在内存中计算?

这是因为 Memory Alias(内存别名)在 C 中是允许的。比如,如果我这么调用 sum_rows1

那么,第二参数 B 就是 A 的一部分的内存别名,也就是说,程序有两个指针指向一块内存地址,这样的话,在每次 B[i] = 0 后,就会改变 A 的内容,从而改变求和的值。因此编译器仍然不敢直接优化。

问题在于,我们知道这个函数的作用是数组列求和,我们不会传入两个内存别名。但是编译器不知道——因为要检查所有的 memory alias 开销也非常大,所以编译器默认程序中都存在内存别名。

所以解决方法是,我们暗示编译器这里不会有内存别名导致循环中数组值的更改:在求和时不直接加到 b 中,而是以临时局部变量存储,求和循环结束后同一赋值给 b,这样在一次求和循环中编译器就不会重复从内存读取信息了:

这样内层求和循环的汇编码就变得简单了:

实际上,内存读写仍然是耗时大头,所以改写后性能提升不会非常明显。

对开发者而言,应该习惯于在循环前引入一些局部变量(Accumulate in temporary),尤其是含有数组索引的循环,这样能够暗示编译器按照没有内存别名的情况处理。

总结一下就是:

上面的几种优化技巧都是比较简单和零碎的,不宜死记硬背,应该贯通在实践当中。下面从另一个角度考虑优化问题。

12.5 Machine-Dependent Optimization

这类优化取决于处理器机器和系统,根据汇编处理方式进行优化。

表面上与机器无关的优化方法都考虑得差不多了,但在机器层面还有一些优化的方法。为了考虑这些方法,我们需要先了解机器的简单组成和基本运作原理。下面是上个世纪末的处理器的大概的设计样式:

底层机制过于复杂,一般人短时间内几乎不可能理解,所以这里仅仅浅浅介绍一下。

CPU 执行代码时,借助了多种方法和技术,构建了健全的硬件设施,使得其执行指令的速度远远快于一条一条读取执行的速度。其中一种技术被称为 “超标量乱序执行” 技术(super-scalar out of order execution),它的思路可以理解为:CPU 一次性读入大量机器指令,再将顺序的指令拆开,发现逻辑上某两句间不相互依赖,于是 CPU 可以不按顺序、尽可能多地同时执行这些代码。

Superscalar(超标量)

  • 每个时钟周期执行多个操作;

  • 指令级并行(CPU 自主发现指令间依赖关系,并行执行没有依赖关系的指令);

Out-of-order execution(乱序执行)

  • Instruction Control Unit (ICU): Fetch / Decode / Write Back; Execution Unit (EU): Execute / Memory;

  • Fetch Control: Branch Prediction(比 Y86-64 的更高级) + Speculative Execution;

  • Instruction Decode

指令的这种特性被称为 指令级并行性(instruction level parallelism)

上图的 Instruction Control 展示了 CPU 如何从高速缓存中抓取指令,并放入运算单元的。注意,这里所有的操作都使用缓存和寄存器,因为其他储存介质(包括内存)都太慢了。

上面在一个时钟周期中处理、执行多条指令的处理器被称为 Super-scalar Processor,它们通常一次性获取一串指令流,并动态地进行调度和执行。它的好处是 充分利用了代码中的指令级并行性,所以大多数现代 CPU 都是超标量的,并且现代 CPU 的执行模型也几乎都是乱序执行的模型。

现代 CPU 的策略是乱序执行,固然非常复杂,比之前讨论过的按序执行的流水线(pipelining) 更复杂。

流水线的基本思想是,处理器将每个计算分解为一系列不同阶段,每个阶段都有一个专用硬件可以独立完成。于是,当一个计算阶段的硬件空闲下来时,就可以接受下一个数据的计算工作

其中,除法运算无法被分解在流水线上进行,一次操作 3 ~ 30 个时钟周期,所以是一个昂贵的操作。

这些并行性都是单个 CPU 的单核的并行性,不涉及多核并行。

总的来说,在机器层面上,程序的优化上限有两点:

  1. Latency Bound: 当 一系列指令被执行时必须按照严格的顺序进行 时,也就是说后一条指令依赖前一条的结果,此时程序的性能会受到限制。这种瓶颈(data dependency)限制了指令级别的并行性(没有 Y86-64 的 data forwarding);

    其计算方法就是,一条指令 / 操作 最原本的 latency 时延,与承载量无关

  2. Throughout Bound:处理器的运算单元所能达到的最大承载量(computing capacity,通常与对应运算单元数目、运算单元种类有关),这也是程序性能的最终上界。


接下来,借助流水线的知识,我们再分析一下现代处理器内部各个模块是如何进行协作的,以便我们针对特定机器进行优化。

对于现代处理器,主要就分为以上的两个部分:Instruction Control Unit & Execution Unit。其中 Instruction Control Unit 的主要结构如下:

而 Execution Unit 部分的主要结构如下图所示:

我们发现这里存在很多单独的运算单元,可以从 ICU 部分接收更多指令(μ code,即上面的 primitive instructions),并行进行不同种类 μ code 的运算。

Data Cache 是存放最近访问的 / 临时的数据的地方。遵循 Memory Hierarchy,支持 EU 运算单元的快速存取,稍晚些时候会根据情况向 ICU 的 register files 以及 memory 写入数据。

此外,Branch 模块不是用来运算某个分支是否应该跳转,而是用来计算分支预测是否正确。若预测错误,则 Branch 会通知丢弃该分支中执行得到的所有数据(利用 ICU 中的 Retirement Unit 不允许错误数据写入,并通知 Data Cache 标记无效数据),否则保留数据并写入;

同时,ICU 中的 Retirement Unit 跟踪当前过程所有正在执行的指令,确保数据存取符合 sequential 语义。大致逻辑如下:

当一个指令被 decode,它的信息会被放入一个 FIFO 队列中,直到两种情况之一才会从队头出队:

  1. Retirement Unit 发现该指令的所有 primitive instructions 被执行完,并且所有涉及通向该指令的 branch prediction 都是正确的,这条指令才会被 “retired”,结果写入程序寄存器中;

  2. 如果 Retirement Unit 发现该指令途中某个 prediction 是错误的,那么这个指令会被 “flushed”,计算结果会被抛弃,不会写入寄存器中。

注:上图所描述的 “Arithmetic Operation” 单元是被特化来执行整型、浮点型的不同组合的运算的(就是说,这些单元能执行多种不同操作,例如既能整型运算又能浮点型运算)。这样不致于程序对硬件资源的利用率不好。

那么,“乱序执行” 技术如何实现?很简单,在 ICU 的 Instruction Decode Logic 给出 μ code 后,由于 μ code 的临时寄存器只需要记住版本,可以随意选取,因此设计了一个类似任务队列的指派单元,将上面发出的 μ code 根据当前 EU 硬件空余情况分发下去执行。

为了加速一条指令到另一条指令的结果传送,处理器引入了一个机制,就是提到的 “寄存器重命名”。这些信息被共享在 renaming table 中(如上图下方的一条长横线)。当运算单元接到 μ code 后,会立即进行运算(非依赖地并行),将寄存器名、版本号组成的 二元组 (u,t) 写入这个列表中

注:这个表维护每个寄存器 u 与更新的重命名版本标记 t 之间的关系。

这个表只包含未 “退役” 的寄存器条目。如果指令在表中没找到某个寄存器,那么说明值在寄存器文件中,需要的指令这时才会向寄存器文件请求查找。

每当 Branch 确定了分支结果后,才会通知 retirement unit 将正确的分支上运行的结果写入寄存器。这个时候也会重整 renaming table;

等待某个版本 t 的指令可以在表中得到数据,这种方式就是一种形式的 “数据转发”(比 Y86-64 的 data forwarding 更快)。通过这种机制,能让依赖前一操作的操作尽快开始,而不需要存入寄存器文件再读出来。

这个 renaming table 就避开了 Y86-64 中的 control hazard,即便 branch prediction 还没有验证、不能更改程序寄存器,也依然能临时存放一些计算好的数据。

 

最后,为了定性分析其中的运算效率,我们定义:

 

12.5.0 Data-Flow Representation: Machine-level Profiling

在利用我们已有对于 Modern Processor 的认识来 tuning 程序前,我们还要掌握一种分析机器指令级运算效率的图:Data-Flow 图。

它展现了不同操作间数据的 dependency 是如何限制执行顺序,并形成关键路径的。这个关键路径就是该组机器指令的执行时间下界。

以一个结构体数据类型为例:

其中的一个函数是:

对这个函数进行基准测试(Benchmark Computation)

生成的机器指令如下:

其对应的 primitive instructions 及 data-flow diagram 如下:

我们能将这个 loop 中使用到的寄存器进行分类:

我们发现,loop 类寄存器的操作链决定了这个 data-flow 的关键路径(因为循环不依赖的寄存器可以并在其他步骤同时执行)。

由此可以将 loop 类寄存器涉及的路径抽取出,根据依赖关系画出新的 data-flow:

结合实验数据可知,这条链就是限制程序性能的关键。

现在我们尝试一些措施将循环 “展开”(unrolling loop),例如每个循环进行两个数组操作:

发现性能结果更加接近 Latency Bound:

分析编译器生成的汇编代码可知,原来在一个周期中的两条运算指令的 Load 被提到前面的位置上,相当于右图:

由于这些乘法运算仍然前后依赖,所以尽管我们进行 unrolling loop 来减小每个 iteration 造成的开销,仍然无法超过 latency bound。

那么,如果我们使用 多个循环累计变量(Separate Accumulator,奇偶元素分开计算,最后合并),就能手动提高并行性:

再次绘制 data-flow 图会发现,关键路径变为 2 条,意味着关键路径长度被缩短,解释如右图:

再换一种方法,如果我们更换运算结合的顺序(被称为 Re-association Transformation):

我们也能发现,两条乘法指令的依赖关系改变了:每两条乘法指令只有一条在关键路径上:

这两种方式(separate accumulator、re-association)都能大幅度缩短关键路径,让程序性能突破 latency 瓶颈,接近吞吐量:

如果 separate accumulator 展开 10 次,就能更大限度利用机器资源。

但是,无论是 unrolling loop,还是 separate accumulator,都有一个界限:register spilling(寄存器溢出)。如果中间步骤展开过多,超过了已有寄存器数量,这时处理器会用内存来代替,反而会降低运行效率

此外,之前我们提到了尽管整数加法有 4 个器件,但 throughput 不会超过 0.5,是因为 load/store 的约束。而我们之前看到的例子中,load 似乎不在关键路径上(因为之前的 load 只依赖于索引 i)。其实,load/store 操作也会和之前的 multiply 一样成为某些程序的关键路径,例如链表程序:

汇编:

这个时候加载的结果决定下一条操作的地址时,load 指令(movq 的其中一个 μ code)就是关键路径上的一环了。我们发现这种循环的 CPE 约 4.00,说明:load 的 latency 约 4.00

除了 load,store 也会成为关键路径的一环。来看两个例子:

func1 的 CPE 能达到 1.0,很好解释,是因为 store 暂时不影响寄存器值(前面提到 pending write 到 renaming table),不会产生数据依赖,因此可以完全流水线化,这也是最佳情况(throughput bound)。

但是 func2,如果 src != dst,那么 CPE 约为 1.3;但是如果 src == dst,那么 CPE 上升到 7.3(下降了 6 个 cycles)!这可以用 data-flow 分析出。

先写出汇编代码:

movq %rax, (%rsi) 会被分解为两条 primitive instruction:s_addr(计算目标内存地址,由单独的运算单元完成)、s_data(数据 store)。

如果 store 的数据依赖于前一步的 load(read-write dependency),那么根据汇编代码可以画出:

其中 movq

如果 src != dst,那么箭头 3 不存在,关键路径将是 sub(CPE 约 1.3);

如果 src == dst,那么箭头 3 的依赖关系就存在,关键路径就是 s_data -> load -> add(CPE 约 7.3)。

 

此外,还有一些在机器层面影响性能的其他因素:

12.5.1 Branch Misprediction Recovery

我们在 4.2.2 中提到过,机器层面的分支预测技术(Branch Prediction)就是为了让 CPU 流水线的效率得到充分利用而诞生的。在很多情况下,如果我们不使用条件移动的话,那么在分支预测错误的时候,很有可能整个流水线的指令和数据全部需要重新载入,这将耗费大量的运算资源和时间。

所以第一个思路就是从降低 Branch Misprediction Penalty 方面着手。首先了解一下分支预测技术的大概原理是啥。分支预测技术在早期使用的是简单的 heuristic:向后分支(backwards branches)经常是判断(if),向前分支(forwards branches)经常是循环(loop)。我们可以通过一些算法跟踪这些分支(尤其是循环)的历史行为,如果它经常通过某一分支,那么以后预测该分支的可能性会大一点

这种预测方法在有些代码中效果显著,但是另一些代码(例如强数据依赖、强随机数据)中效果极差,甚至导致更多的 Branch Misprediction Penalty;

因此接近这个方面问题的可能途径如下:

这里介绍一下 unroll loops 的思路。unroll loops 就是将循环中的代码成倍数展开,达到均摊 Branch Misprediction Penalty 的目的。同时,这种方法还能为其他的优化方法创造条件(例如 CSE、Code Motion、Scheduling 等)。方法的示例如下:

但这种方法有两个明显的缺陷,一是如果过度展开,会增长代码长度,同样会影响性能。所以需要根据数据量进行权衡;二是在某些循环中,依赖 i 进行判断的场合,这种展开就不适用了。

12.5.2 Scheduling

在上面的流程中,除了 Branch Misprediction Penalty,还有一种会影响 CPU 运算性能的情况——数据读取和写入。I/O 操作的开销一直是处理器设计者头疼的地方,尽管现在有高速缓存器来进行弥补,但数据的存取在运算过程中还是尽量能少就少。

因此,将源代码中读取、写入的部分和运算的部分分开(最好在运算前就读入所有必需的数据、在所有运算后才写入必要的数据),能够让处理器的性能发挥到最大(这个优化也可以由编译器完成)。这种方法就称为代码调度(Scheduling)。

例如 12.5.1 中的代码还可以继续优化:

同样,这样的操作对于有些情况也不适用,比如在一些业务逻辑下,读取操作必须在一群算数运算之间出现。

 

12.6 Summary

本章讲述了一些程序优化的基本思路,主要从两个方向描述。

一个是与机器无关方向的优化小技巧,而这些技巧也常常会内置在编译器中,成为编译工作的一个部分。它们大致可以分为以下几类:

在这个方面,我们考虑了相反的情况——什么样的编码会阻碍编译器帮助我们进行上面的优化。

这都在提醒我们编码时,能尽早确定的定值,尽量存在局部变量中,以防编译器认为中间存在变数,而不敢于进行优化。

另一个是与具体机器有关方向的优化技巧(但是目前世界上的机器种类就这些,所以理论上也有一定的普适性,例如 ARM 架构和 x86 架构都能使用这种优化来提升性能),编译器不一定会帮你做这些优化,这是因为这些方法有着各自的局限性。

我们分析了现代 CPU 的结构特性,找到了 3 处可以进行优化的地方,一个是分支预测的部分,一个是代码中的 I/O 调度,另一个是运算顺序和方法。

首先我们了解了分支预测器的原理和性质,我们发现想要弥补 Branch Misprediction Penalty,就需要从两个方面入手:

  1. 让它少预测点,就少错一点(减少分支结构)。这方面的方法大致有 循环转换、unrolling loops 和 conditional moves(回想之前的 General Condition Translation)

  2. 提升它预测的正确率。依从分支预测器的原理,我们可以让每次产生的分支判断的结果有迹可循。总的来说,我们可以通过为需要的判断数据排序(局限性强)、减少编码一些难以预测的结构(例如函数指针判断、虚函数)。

在 I/O 调度方面我们发现,CPU 频繁地从高速缓存器中读入和写出数据也会降低程序性能,因此在编码过程中写出能够让汇编码中读内存次数越少的源码越好。因此,这里的重要建议是将源代码中读取、写入的部分和运算的部分分开(最好在运算前就读入所有必需的数据、在所有运算后才写入必要的数据)

本章最后以一个计算例子说明,通过分析关键路径,使用 Re-association Transformation、Separate Accumulator 来让更多的乘法运算并行起来。

当然,当我们利用以上技术(例如 loop unrolling)优化到一定程度后,其他方面的问题就变成了关键路径上的问题。例如 unrolling 过多时,发生 register spilling(寄存器用完了,开始使用内存)影响性能。

最后介绍一个优化的规则:Amdahl‘s Law

S=ToldTnew=1(1α)+αKS=11α

(1α) 代表不能被优化的部分,K 代表能被优化的部分最快被优化多少倍。

当能优化的部分优化到极限后,加速比极限为 S表达式说明一个通俗的道理:选择优化时间占比更大的部分(α 更大),优化的效果、优化的极限就更好(S 更大)

也就是说,我们应该在优化时找关键路径、找 hot spot(热点位置)。这就是 profiling。

当今的 profiling 工具有哪些?Unix 上有 gprofperf,它们的思路是 random sampling,间隔一定时间进行指令中断,统计函数执行的次数、统计当前调用栈(可以发现调用关系、递归情况),用频率代表出现总时长占比。