目录
1 存储电路概述
1.1 寄存器存储电路
1. 一般使用D触发器来实现CPU内部的寄存器
2. D触发器可以在一个CPU时钟周期内完成读写,是最快的一类存储单元,但是他的造价十分高昂,用于存储一个比特的触发器就需要几十个晶体管,所占电路面积最大
1.2 大容量存储器存储电路
1.2.1 大容量存储器分类
1.2.1.1 只读存储器
1. 只读存储器(Read Only Memory,ROM)只能读,不能写
2. ROM中的数据断电后不会消失,因此属于非易失性存储器
3. 在生产时,厂家将内容写入只读存储器之后,用户就只能从中读取数据,不能再修改其中的内容
说明:只读存储器发展简介
① 早期的ROM基于熔丝制作,写入操作通过烧断熔丝实现,因此只能在初始化时写入一次,之后就不能再更改(目前这种类型的ROM已经不存在)
② 后续人们在MOS管上增加浮置栅,在一定条件下浮置栅可以充电也可以放电,从而实现了可编程ROM(Programmable ROM,PROM)
③ 早期的PROM可以使用紫外线进行擦除,这就是紫外线可擦除PROM(Ultral Violet Erasable PROM,UV-EPROM)
④ 使用紫外线进行擦除的缺点是擦除速度慢,而且可擦除的次数有限。因此人们又发明了电可擦除PROM(Electronic Erasable PROM,EEPROM)
EEPROM是目前使用最广泛的一种存储器件,通常被称作闪存
1.2.1.2 随机访问存储器
1. 随机访问存储器(Random Access Memory,RAM)可读可写
2. RAM中的数据断电后会消失,因此属于易失性存储器
3. RAM总体上又分为两类,
① 静态随机访问存储器(Static RAM,SRAM)
SRAM的特点是速度快、造价高,一般用作高速缓存,集成在CPU中,容量一般不会超过几MB
② 动态随机访问存储器(Dynamic RAM,DRAM)
DRAM的特点是速度慢、造价低,一般用作计算机主存,容量可达十几甚至几十GB
说明:随机访问与顺序访问
① 随机访问是与顺序访问相对应的
② 早期的存储设备(e.g. 纸带、磁带)只能顺序访问,如果要访问某个特定的位置,需要将纸带 / 磁带快进到所需的位置,然后再顺序地访问该位置的数据
③ 支持随机访问的存储器可以用相同的速度访问存储器内任何位置上的数据
1.2.2 SRAM存储电路
1. SRAM使用6个晶体管存储一个比特
2. SRAM的结构比D触发器简单,访问速度也比较快
3. SRAM是单纯的时序逻辑电路,因此可以集成到芯片内部
1.2.3 DRAM存储电路
1. DRAM的电路结构更加简单,使用一个CMOS开关和一个电容存储一个比特。因此成本更低,也更易于大规模集成,但是读取的速度比较慢
2. 之所以DRAM读取的速度比较慢,是因为DRAM的读取是一种破坏式读取。他会使得原先为1的存储单元变成0,因此DRAM在读取数据时需要为原先为1的存储单元再进行一次充电
3. 由于电容本身会缓慢漏电,因此需要每隔一段时间为电容补充电荷,这也是Dynamic名称的由来
4. 由于DRAM中有电容的存在,不再是单纯的逻辑电路,不能用CMOS工艺制造,因此不能集成在CPU内部
2 存储器地址编码
对存储器中各单元进行编码的目的,是为了让地址总线上的数据能转换为相应存储单元的使能信号
2.1 简单编码示例
1. 假设有4根地址总线,通过4-16译码器可以产生16个输出,可用于寻址16个字节
2. 地址总线上的数据恰好就是存储单元地址编码的二进制数,因此地址总线的宽度会影响存储编码范围
3. 简单编码有一个问题,就是随着存储器地址范围的扩大,译码器的输出端口会变得非常多。如果有32位地址总线,则译码器输出端口为4G个
2.2 矩阵式编码示例
1. 为了解决简单编码输出端口多的问题,人们将地址分为高低两部分(即行地址和列地址),并且将存储单元做成矩阵式排列
2. 仍以4根地址总线为例,将其平分为两组分别送入行 / 列译码器,虽然寻址范围仍为16B,但是输出端个数只有8个
对于32位地址总线,输出端的数量可降低为(2^16 + 2^16 = 128K),远小于之前的4G
3. 在矩阵式编码中,需要行线和列线同时生效来指定一个存储单元,这里有两种实现方法,
① 在每个存储单元引入一个与门,这需要为每个字节多增加两个MOS管,会降低芯片的集成度
② 在存储芯片内部增加一行缓存,读取时分两部进行。第一步先使行线生效,将目标存储单元所在行整行读入到缓存中;第二步再使列线生效,从缓存中读取目标单元的值。这种做法需要两次读取,会有性能损耗
说明:在目前的DDR内存中,不是将所有存储单元都部署在一个矩阵中,而是会划分为多个矩阵。此时的存储器地址编码就会划分为:矩阵片选 + 行地址 + 列地址
详情可参考04. 代码重定位 & SDRAM初始化S5PV210体系结构与接口04:代码重定位 & SDRAM初始04. 代码重定位 & SDRAM初始化 chapter 4
3 CPU缓存
3.1 存储器体系结构
1. 从程序员的角度,希望有无限的、快速的、便宜的存储器可供使用。但是现实中,快速的存储器价格高,便宜的存储器速度慢。所以从平衡容量、性能、成本的角度,计算机存储体系结构被设计为分层结构,一般包含寄存器、缓存、内存、磁盘等
2. 缓存是计算机存储体系结构中的灵魂,因为缓存结合了寄存器速度快和内存造价低的优点
① 缓存的访问速度很快,较内存高1 ~ 2的数量级
② 利用程序的空间局部性和时间局部性,只要程序处理得当,缓存命中率可以达到70% ~ 90%。从而使得整个存储系统的性能接近寄存器, 但是成本接近内存
说明:缓存出现的背景,是处理器速度的增长远远超过了内存速度的增长,从而使得处理器和内存之间的速度差距越来越大。缓存的出现就是作为处理器和内存之间的桥梁,弥合二者的速度差距
3.2 缓存的物理架构
在多核处理器中,缓存主要有如下三种集成方式,
3.2.1 集中式缓存
一个缓存和所有核直接相连,多个核共享这一个缓存
3.2.2 分布式缓存
一个核仅和一个缓存相连,一个核对应一个缓存
3.2.3 混合式缓存
1. 将缓存设计为多层,其中有些层使用集中式,有些层使用分布式
2. 现代多核处理器大都采用混合式的方式将缓存集成到芯片中,通常在L3采用集中式缓存,在L1和L2采用分布式缓存,如下图所示,
3.3 缓存的工作原理
3.3.1 cache line概念
1. cache line是缓存进行管理的最小存储单元,也叫缓存块
2. 内存和缓存之间的数据交互都是以cache line为单位,一个缓存块和一个内存块对应
说明1:在Ubuntu中可以通过如下命令获取各级cache line的大小,可见实验环境中各级cache line的大小均为64B
getconf -a | grep CACHE
说明2:内存和缓存之间的数据交互包括,
① 从内存向缓存加载数据
② 将数据从缓存写回内存
3.3.2 cache line组织方式
1. 上图中的每个小方框就代表一个缓存块
2. 整个缓存由组(set)组成,每个组由路(way)组成,所以整个缓存的容量为,
缓存容量 = 组数 * 路数 * 缓存块大小
3. 为了简化寻址方式,内存地址确定的内存块总是会被放在固定的组,但可以放在组内的任意路上
也就是说,对于一个特定地址数据的访问,如果将其所在的内存块加载到缓存,那么他放在上图中的行数是固定的,但是具体放到哪一列是不固定的
4. 物理内存地址中的组索引字段(set index)用于确定内存块所映射的组
① 可以发现,如果将组数设计为2^n,则有利于组的寻址。因为此时组索引字段的值,就是对应的组号。假设组数为8(2^3),那么组索引字段需要3位,而这3位二进制构成的值正好可以索引8个组
② 假设组数不是2^n,例如组数为7,那么组索引字段也需要3位,但是映射组号却不能与这3位二进制构成的值一一对应,需要设计映射规则
5. 物理内存地址中的标签字段(tag)用于标识内存块被映射到一个组内的哪一路
6. 物理内存地址中的偏移字段(block offset)用于在cache line中索引对应的字节
3.3.3 缓存映射方式
根据缓存中组数和路数的不同,将缓存映射方式分为三类,
3.3.3.1 直接相联映射(direct-mapped)
1. 缓存只有一个路,一个内存块只能放置在特定的组上
2. 此时物理内存地址的组索引字段要能索引所有缓存块
3.3.3.2 全相联映射(fully-associative)
1. 缓存只有一个组,所有的内存块都放在这一个组的不同路上
2. 此时物理内存地址相当于没有组索引字段
3.3.3.3 组相联映射(set-associative)
1. 缓存同时由多个组和多个路
2. 此时先通过物理内存地址的组索引字段确定内存块映射的组,之后通过标签字段确定内存块映射的路
说明1:缓存映射方式评价
① 对于直接相联映射,当多个内存块映射到同一个组时(物理内存地址的组索引字段相同),会产生冲突,因为只有一列,此时就需要将旧的缓存块换出,同时将新的缓存块换入,所以直接相联映射会导致缓存块被频繁替换
② 全相联映射可以在很大程度上避免冲突,但是当要查询某个缓存块时,需要逐个遍历每个路,而且电路实现比较困难
③ 组相联映射是一种折中的方式,
- 这种方式与直接相连映射相比,产生冲突的可能性更小,因为组索引相同的内存块还可以映射到不同的路上
- 与全相联映射相比,查询效率更高,实现也更加简单
说明2:组数与路数计算方式
以实验环境中的L1 data cache为例,
① cache总大小为32KB
② 路数(DCACHE_ASSOC)为8
③ cache line大小为64B
④ 由此可以计算出组数为(32KB / 8 / 64 = 64)
3.3.4 cache line内部结构与映射实现
缓存块内部结构如下图所示,
1. V(valid)表示该缓存块是否有效,或者说是否正在被使用
2. M(modified)表示该缓存块是否被写过,也就是"脏"位
3. tag字段用于内存块与组内各路缓存块的匹配,匹配的方式就是将组内每个缓存块的tag字段与物理地址的tag字段进行比较
说明1:tag字段匹配结果与处理方式
在将物理内存地址tag字段与组内各缓存块的tag字段进行比较的过程中,
① 如果有匹配的tag,说明该内存块已经加载到缓存中
② 如果没有匹配的tag,说明缓存缺失,需要将内存块加载到该组的一个空闲缓存块中
③ 如果组内所有路的缓存块都在使用中,则需要选择一个缓存块将其换出,并将新的内存块载入
说明2:上述匹配过程涉及到缓存块状态转换,而状态转换又涉及到有效位V、脏位M、标签tag以及对缓存的读写操作类型,具体情况如下表,
3.4 缓存块替换策略
3.4.1 LRU策略概述
1. 最完美的缓存替换策略是被换出的缓存块是将来最晚会被访问的缓存块,也就是未来最晚使用
2. 由于未来无法精确预知,所以实现中选择用过去的历史预测未来,也就是最近最少使用(Least Recently Used,LRU)
3.4.2 LRU策略实现简介
1. 可以使用位矩阵来实现LRU算法,假设缓存采用4路组相联映射,并且目前已加载了B1 ~ B4共4个内存块,如果现在要加载B5内存块,则需要从B1 ~ B4中选择一个进行替换
2. 可以定义一个行列均与缓存路数相同的矩阵,此处就是定义一个4 * 4矩阵。当访问某路对应的缓存块时,先将该路对应的所有行置为1,再将该路所对应的所有列置为0
最终结果体现为,缓存块访问时间的先后顺序,由矩阵行中1的个数决定,最近最常访问缓存块对应的行中1的个数最多,那么只要替换1的个数最少的行对应的缓存块即可
3. 假设先后访问B2、B3、B1、B4,则位矩阵变化状态如下图所示。如果现在要加载B5内存块,则会替换B2内存块对应的缓存块
3.5 缓存对程序性能的影响
3.5.1 概述
1. 缓存命中
① 如果访问内存时,数据已经在缓存中,则缓存命中
② 缓存命中时获取目标数据的速度非常快
2. 缓存缺失
① 如果访问内存时,数据没有在缓存中,则缓存缺失
② 缓存缺失时需要启动内存数据传输,而内存的访问速度相比缓存慢很多,所以需要避免这种情况
3.5.2 缓存缺失类型
3.5.2.1 强制缺失
1. 第一次将数据加载到缓存所产生的缺失,也被称为冷缺失(cold miss),因为发生缓存缺失时,缓存是空的不会有数据
2. 强制缺失无法避免
3.5.2.2 冲突缺失
1. 冲突缺失是由缓存的相联程度有限导致的缺失
2. 如果程序不断访问处于同一组的内存块,则会被加载到同一组的缓存块中。但是由于同一组中的路数是有限的,当需要加载的内存块个数超过路数时则会导致缓存块频繁替换,从而降低程序性能
3.5.2.3 容量缺失
1. 容量缺失是由于缓存大小有限导致的缺失
2. 如果在程序运行的某段时间内,访问地址范围超过缓存大小很多,这样缓存容量就会成为缓存性能的瓶颈
说明1:可以认为除了强制缺失和冲突缺失之外的缺失都是容量缺失
说明2:需要注意区分冲突缺失和容量缺失
① 冲突缺失是同一组内的缺失
② 容量缺失是整个缓存范围内的缺失
3.5.3 冲突缺失示例
1. 如上文所述,实验环境的L1 data cache为:64组 * 8路 * 64B = 32KB,示例程序如下,
程序运行耗时如下,
说明1:示例程序间隔512个元素进行访问,访问的数据会被映射到cache的同一个组
① long long类型数组元素的长度为8B
② cache line长度为64B,可以容纳8个元素
如果间隔8个元素进行访问,访问的数据会被映射到下一个组。因为相当于offset字段满64进1,会导致组索引字段值加1
③ cache共有64个组,因此间隔(8 * 64 = 512)个元素进行访问,访问数据就会被映射到同一个组
说明2:从物理内存地址的角度理解访问数据映射到cache的同一个组
① 在实验环境中,对于被映射到cache同一个组的内存块,物理地址的组索引字段是相同的
② 假设两个物理内存地址tag字段差1,组索引字段相同,offset字段相同,二者相减的地址差值为(2 ^ 12 = 4KB)。而间隔512个long long类型的数组元素,就是(512 * 8 = 4KB)
因此以4KB的倍数间隔访问,所访问的数据就会被映射到cache的同一个组
③ 当然,这里的offset字段可以不相同,但是在计算时仍可以忽略,因为offset字段只是在cache line之内的偏移量
2. 对示例程序进行修改,只是将内存循环的执行次数从8增加到16
程序运行耗时如下,可见虽然计算量只是原先的2倍,但是耗时却是原先的约6倍,相当于性能劣化3倍
3. 这是因为内层循环会访问16个映射到cache同一组的数据,而cache的路数为8,因此会频繁导致缓存块的替换
3.5.4 程序局部性与缓存性能
1. 程序局部性分为时间局部性和空间局部性,如果程序有较好的局部性,那么在程序运行期间,缓存缺失就会很少发生
2. 在C语言中,二维数组是按行存储和解释的,我们先按行访问二维数组
程序运行耗时如下,
3. 再改为案列访问二维数组
程序运行耗时如下,可见耗时明显增加
4. 出现上述情况是因为,
① 按行访问时地址是连续的,下次访问的元素和当前元素大概率在同一个cache line(实验环境中,每个cache line可以容纳8个元素)
② 按列访问时,由于地址跨度大,下次访问的元素基本不可能还在同一个cache line,因此就会增加从内存加载数据到缓存的开销
3.5.5 伪共享(false-sharing)
3.5.5.1 伪共享含义
1. 当两个线程同时各自修改两个相邻的变量时,由于缓存时按cache line组织的,当一个线程对一个cache line进行写操作时,必须使其他线程含有对应数据的cache line无效
2. 这两个线程都会同时使对方的cache line无效,从而导致性能下降
3.5.5.2 伪共享示例
1. 在示例程序中,两个线程只各自修改结构体中的一个变量,但是这两个变量极大概率在同一个cache line中
程序运行耗时如下,
2. 解决伪共享的方法,就是不要将变量a和变量b放在同一个cache line中,这样两个线程分别操作不同的cache line,自然就不会相互影响
为达到这一目的,我们在结构体中填充8个元素,这样变量a和变量b中间间隔了64B,就一定会被映射到不同的缓存块
修改后程序运行耗时如下,可见性能有明显提升
说明1:伪共享问题只会发生在多核CPU且不使用全集中式缓存的场景,这里主要的问题是两个线程工作在不同的CPU,然后相互使得对方L1 data cache中的cache line无效。理解这点还涉及多核间的缓存一致性问题,可参考后文相关章节
说明2:为了验证上述分析,我们将两个线程绑定在同一个CPU上,这样就可以达到类似单核的效果,两个线程操作同一个CPU的L1 data cache
① 实验环境中有4个CPU核
② 对示例程序进行如下修改,将两个线程都绑定到CPU0运行
③ 修改后程序运行耗时如下,可见性能有明显提升,也就验证了之前的分析
说明3:在Java的并发库中经常会看到为了解决伪共享而进行的数据填充
4 缓存一致性与MESI协议
4.1 缓存一致性问题
4.1.1 概述
缓存在带来性能提升的同时,也引入了缓存一致性问题,
1. 缓存一致性问题产生的主要原因是在多核体系结构中,如果有一个CPU修改了内存中的某个值,则必须有一种机制能保证其他CPU能观察到这个修改
2. 而所谓确保缓存一致性,就是保证同一个数据在每个CPU的私有缓存(一般为L1 Cache)中的副本是相同的
4.1.2 问题示例
假设线程Thread1和Thread2都会操作全局变量sum,其中Thread1由CPU1执行,Thread2由CPU2执行
int sum = 0;
// Thread1(CPU1)
sum += 3;
// Thread2(CPU2)
sum += 5;
由于Thread1和Thread2先后对全局变量sum做累加,因此我们期望内存中的sum值为8,但是2个线程的一种执行情况可能如下图所示。其中的"脏",表示该CPU缓存中的内容被修改,但是还没有同步到内存
4.1.3 解决思路
通过上述示例可以看出,为了确保缓存一致性,必须解决2个问题,
1. 写传播
① 对应示例中的第3步
② 写传播是指一个CPU对缓存中的值进行了修改,需要通知其他CPU,以便其他CPU维护缓存中所持有该数据的副本。根据不同的策略,又分为写更新与写失效
2. 事务串行化
① 对应示例中的第5和第6步
② 事务串行化是指多个CPU对同一个值进行修改时,在同一时刻只能有一个处理器写成功,必须保证写操作的原子性,多个写操作必须串行执行
说明:各种缓存一致性协议主要用来解决写传播带来的缓存一致性问题
4.2 缓存写策略
因为缓存一致性问题是由CPU对私有缓存进行写操作而未能及时通知其他CPU所引起的,所以缓存的写策略会深刻地影响缓存一致性问题的解决,下面就对缓存写策略的不同方面进行说明
4.2.1 写入后更新策略
根据CPU对缓存的修改何时能更新到内存,分为写回和写直达两种策略
4.2.1.1 写回策略(Write Back)
如果CPU采用写回策略,对缓存的修改不会立刻更新到内存。只有当缓存块被替换时,这些被修改的缓存块才会写回并覆盖内存中的过时数据
4.2.1.2 写直达策略(Write Through)
如果CPU采用写直达策略,对缓存的任何修改会立刻更新到内存
说明:关于写回策略与写直达策略的详细写入流程,可参考04. 存储和IO系统 chapter 3.3,此处给出两种策略的流程图
4.2.2 写入后通知策略
根据一个CPU修改缓存后的写传播方式,分为写无效和写更新两种策略
4.2.2.1 写无效策略(Write Invalidate)
1. 如果CPU采用写无效策略,每当他的缓存写入新的值,该CPU会发起总线请求,通知其他CPU将他们缓存中的副本置为无效
2. 当其他CPU再次访问自己缓存中的副本时,会发现缓存已经失效,此时CPU就会从内存中重新加载最新的数据
说明1:需要特别注意的是,写无效的总线请求只需要在其他CPU缓存中有该数据的有效副本时才需要发送,如果其他CPU缓存中没有该数据的有效副本则无需发送(此时已经没有要"无效"的对象)
因此对于写无效策略,多次写操作只需要发起一次总线事件即可(此处的隐含条件为在此CPU多次写入期间,没有其他CPU读取该数据),这样可以有效节省总线带宽
说明2:这里的总线是指CPU核间总线
4.2.2.2 写更新策略(Write Update)
1. 如果CPU采用写更新策略,每次他的缓存写入新的值,该CPU都会发起一次总线请求,通知其他CPU将他们缓存中的副本更新为刚写入的值
2. 由于每次写入操作都会发起总线请求,而且不仅要通知其他CPU有缓存写入事件发生,还要通知其他CPU新写入的值,所以写更新策略很占用总线带宽
3. 只有在一个CPU对缓存进行写操作后,其他CPU会多次读取被写过的数据的情况下,写更新策略才是比较高效的
说明:在具体的实现中,绝大多数CPU都会采用写无效策略以节省总线带宽,因此后文也只讨论写无效策略
4.2.3 缓存加载策略
根据要写入的数据不在缓存中是否先将数据加载到缓存中,分为写分配和写不分配两种策略
4.2.3.1 写分配策略(Write Allocate)
1. 如果CPU采用写分配策略,当要写入的数据不在缓存中时,在写入数据前会将数据读入缓存
2. 当缓存中的数据在未来读写概率较高,也就是程序空间局部性较好时,写分配策略的性能较好
4.2.3.2 写不分配策略(Not Write Allocate)
1. 如果CPU采用写不分配策略,当要写入的数据不在缓存中时,在写入数据时直接将数据更新到内存,而不将数据读入缓存
2. 当缓存中的数据在未来读写概率较低时,写不分配策略的性能较好
说明1:如果缓存块的大小较大,则该缓存块未来被多次访问的概率也会增加,此时写分配策略的性能要优于写不分配策略
说明2:不同的写策略对应不同的缓存一致性协议,下文缓存一致性协议的说明基于如下2种策略组合,
① 写直达 + 写不分配(以VI协议为例)
② 写回 + 写分配(以MESI协议为例)
上述2种组合中,写传播方式均采用写无效策略
4.3 基于"写直达"的缓存一致性协议
4.3.1 请求事件
假设CPU拥有一个私有单级缓存,该缓存既可以接收来自CPU的请求(来自本地CPU的请求),也可以处理来自总线侦听器的总线侦听请求(来自其他CPU的请求)
4.3.1.1 CPU请求事件
1. PrRd:CPU请求从内存读取数据
2. PrWr:CPU请求向内存写入数据
4.3.1.2 总线请求事件
1. BusRd:总线侦听到一个来自其他CPU从内存读取数据的请求
2. BusWr:总线侦听到一个来自其他CPU向内存写入数据的请求
说明:上述总线请求事件都是在需要实际访问内存时才会发生
4.3.2 缓存状态
1. Valid(V):缓存是有效且干净的,即缓存中的内容与内存一致
2. Invalid(I):缓存无效,访问该缓存会出现缓存缺失
说明1:由于采用写直达策略,因此缓存不会有Modified状态
说明2:Invalid状态代表了两种情况,即尚未使用的缓存和无效的缓存。由于尚未使用的缓存中也没有有效的数据,所以可以和无效缓存同等对待
4.3.3 协议状态机
4.3.3.1 CPU请求事件状态机
当前状态 |
处理事件 |
行为 |
下一状态 |
引起总线事件 |
V |
PrRd |
|
V |
无 |
PrWr |
|
V |
BusWr |
|
I |
PrRd |
|
V |
BusRd |
PrWr |
|
I |
BusWr |
说明1:本节及后文的协议状态机表格都是从"总线事件"和"缓存状态"两个方面说明状态机行为,其中,
① 总线事件描述状态机在处理事件时是否会产生总线事件,以及为何需要产生该总线事件
② 缓存状态描述状态机在处理事件后缓存状态的变迁及其原因
说明2:从处理CPU请求事件的视角,
① Valid状态表示本地缓存中有本地CPU要操作数据的有效副本
② Invalid状态表示本地缓存中没有本地CPU要操作数据的有效副本
说明3:从VI协议状态机可见,VI协议每次写入操作都会产生BusWr总线事件,因此会消耗较多的总线带宽
4.3.3.2 总线请求事件状态机
当前状态 |
处理事件 |
行为 |
下一状态 |
引起总线事件 |
V |
BusRd |
本地缓存中有其他CPU要读取数据的有效副本,但是其他CPU的读取不影响本地缓存状态,不产生总线事件 |
V |
无 |
BusWr |
本地缓存中有其他CPU要写入数据的有效副本,由于采用写无效策略,其他CPU的写操作会导致本地缓存无效,但是不产生总线事件 |
I |
无 |
|
I |
BusRd |
本地缓存中没有其他CPU要读取数据的有效副本,不影响本地缓存状态,不产生总线事件 |
I |
无 |
BusWr |
本地缓存中没有其他CPU要写入数据的有效副本,不影响本地缓存状态,不产生总线事件 |
I |
无 |
说明1:从处理总线请求事件的视角,
① Valid状态表示本地缓存中有其他CPU要操作数据的有效副本
② Invalid状态表示本地缓存中没有其他CPU要操作数据的有效副本
说明2:在分析协议对总线请求事件的处理时,主要是关注其他CPU的内存读写请求对本地有效缓存的影响
4.4 MESI协议
4.4.1 请求事件
4.4.1.1 CPU请求事件
1. PrRd:CPU请求从内存读取数据
2. PrWr:CPU请求向内存写入数据
4.4.1.2 总线请求事件
1. BusRd:总线侦听到一个来自其他CPU从内存读取数据的请求
2. BusRdX:总线侦听到一个来自其他CPU向内存写入数据的请求,并且发起该写操作的CPU缓存中没有要写入数据的有效副本
3. BusUpgr:总线侦听到一个来自其他CPU向内存写入数据的请求,并且发起该写操作的CPU缓存中有要写入数据的有效副本
4. Flush:总线侦听到一个缓存被其他CPU写回到内存的请求
5. FlushOpt:总线侦听到一个缓存被放置在总线以提供给其他CPU的请求
说明:Flush事件是从缓存到内存的传输请求,FlushOpt事件是从缓存到缓存的传输请求
4.4.2 缓存状态
缓存的4种状态就是MESI协议名字的由来,
1. Modified(M):缓存有效,但是是"脏"的,即缓存中的数据与内存不一致
该状态同时还表示CPU对于缓存的唯一所有权,即只有该CPU的缓存中有要操作数据的有效副本
2. Exclusive(E):缓存有效、干净且唯一
3. Shared(S):缓存有效且干净,但是多个CPU的缓存中有要操作数据数据的有效副本
4. Invalid(I):缓存无效
4.4.3 协议状态机
4.4.3.1 CPU请求事件状态机
当前状态 |
处理事件 |
行为 |
下一状态 |
引起总线事件 |
M |
PrRd |
|
M |
无 |
PrWr |
|
M |
无 |
|
E |
PrRd |
|
E |
无 |
PrWr |
|
M |
无 |
|
S |
PrRd |
|
S |
无 |
PrWr |
|
M |
BusUpgr |
|
I |
PrRd |
|
E或S |
BusRd |
PrWr |
|
M |
BusRdX |
4.4.3.2 总线请求事件状态机
注意:下表中加*的事件是在当前状态下不可能侦听到的总线请求事件,表格中会在行为栏加以解释
当前状态 |
处理事件 |
行为 |
下一状态 |
引起总线事件 |
M |
BusRd |
|
S |
Flush |
BusRdX |
|
I |
Flush |
|
*BusUpgr |
BusUpgr总线事件由处于S状态的缓存处理PrWr事件时发出,但是本地缓存处于M状态时,其他CPU不可能处于S状态 |
/ |
/ |
|
*Flush |
Flush总线事件由处于M状态的缓存处理BusRd/BusRdX事件时发出,但是本地缓存处于M状态时,其他CPU不可能处于M状态 |
/ |
/ |
|
*FlushOpt |
FlushOpt事件由处于E/S状态的缓存处理BusRd/BusRdX事件时发出,但是本地缓存处于M状态时,其他CPU不可能处于E/S状态 |
/ |
/ |
|
E |
BusRd |
|
S |
FlushOpt |
BusRdX |
|
I |
FlushOpt |
|
*BusUpgr |
BusUpgr总线事件由处于S状态的缓存处理PrWr事件时发出,但是本地缓存处于E状态时,其他CPU不可能处于S状态 |
/ |
/ |
|
*Flush |
Flush总线事件由处于M状态的缓存处理BusRd/BusRdX事件时发出,但是本地缓存处于E状态时,其他CPU不可能处于M状态 |
/ |
/ |
|
*FlushOpt |
FlushOpt事件由处于E/S状态的缓存处理BusRd/BusRdX事件时发出,但是本地缓存处于E状态时,其他CPU不可能处于E/S状态 |
/ |
/ |
|
S |
BusRd |
|
S |
FlushOpt |
BusRdX |
|
I |
FlushOpt |
|
BusUpgr |
|
I |
无 |
|
*Flush |
Flush总线事件由处于M状态的缓存处理BusRd/BusRdX事件时发出,但是本地缓存处于S状态时,其他CPU不可能处于M状态 |
/ |
/ |
|
*FlushOpt |
当本地CPU处于S状态时,只会在处理BusRd/BusRdX事件时发出FlushOpt总线事件而不会接收到该事件 |
/ |
/ |
|
I |
BusRd |
本地缓存中没有其他CPU要操作数据的有效副本,因此其他CPU对该数据的操作不影响本地缓存状态,不产生总线事件 |
I |
无 |
BusRdX |
||||
BusUpgr |
||||
Flush |
||||
FlushOpt |
说明1:缓存一致性协议优化的目标是减少内存访问与减少总线事件,
① MESI协议采用写回策略,相较于写直达策略减少了内存访问
② MESI协议通过引入Modified和Exclusive两种状态,并且引入缓存之间可以相互同步的机制,有效降低了CPU核间带宽消耗
因此MESI协议是当前主流的缓存一致性协议
说明2:能发出FlushOpt事件进行缓存到缓存传输的,肯定是干净的缓存,所以只会在Exclusive和Shared状态下发出
说明3:缓存一致性协议是一个约定,具体实现上是由硬件电路保证的
说明4:在设计中要尽量避免全局数据,这才能从根本上避免缓存一致性问题,提升系统吞吐量
说明5:MESI协议缓存状态转换图示示例
下面给出一个MESI协议缓存状态转换的图示示例,一方面更直观地展现MESI协议的工作过程,另一方面用于下节说明严格遵守MESI协议会导致的性能问题
① 假设目前CPU0 ~ CPU3的缓存中均没有变量a的有效副本,因此缓存状态均为Invalid
② CPU0读取变量a,此时CPU0会发送BusRd总线事件将内存中的数据加载到CPU0的缓存中,并且将缓存状态设置为Exclusive
③ 之后CPU1也要读取变量a,此时CPU1会发送BusRd总线事件,该事件会在总线上被广播
④ CPU0侦听到BusRd总线事件后,发现自己的缓存中有该数据的有效副本而且是干净的,就将自己的缓存状态变更为Shared,然后将自己缓存中的数据通过FlushOpt总线事件发送给CPU1
⑤ CPU1接收到CPU0回应的缓存数据后,将数据复制到自己的缓存,并将缓存状态置为Shared
⑥ 此时CPU1想修改变量a的值,所以发出PrWr请求。因为CPU1的缓存中有变量a的有效副本,所以不需要从内存中加载数据。但是由于变量a在多个CPU的缓存中均有有效副本,所以需要发送BusUpgr事件通知其他处理器失效相应的缓存
⑦ CPU0侦听到BusUpgr总线事件后,知道其他处理器要修改变量a,就将本地缓存状态置为Invalid的并回复Invalid Acknowledge。CPU1在接收到Invalid Acknowledge之后,会将缓存状态变更为Modified
5 内存屏障
5.1 严格遵守MESI协议的问题
1. 如上节的图示示例,当要操作数据在多个CPU的缓存中处于Shared状态时,如果一个CPU想修改该数据的值,就需要先给其他处于Shared状态的CPU发送BusUpgr总线事件,之后要等待这些CPU确认并回复他Invalid Acknowledgememt之后,他才能将本地的缓存状态变更为Modified,这是保持缓存一致性的必然要求
2. 在这种情况下,严格遵守MESI协议虽然可以确保缓存一致性,但是相关的核间同步会给CPU带来性能问题。那么自然的解决方案,就是通过放宽MESI协议的限制来获得性能提升
说明:可以看出上述问题有2个方面,
① 在Shared状态下的写入端,在发送BusUpgr总线事件通知其他Shared数据的CPU后,需要等待他们回复Invalid Acknowledgement
② 在Shared状态下的失效端,需要在处理完BusUpgr总线事件之后才能发送Invalid Acknowledgement
那么在解决该问题时就可以在写入端和失效端分别进行优化
5.2 写缓冲与写屏障
5.2.1 写缓冲及其作用
1. 由于Shared状态下写入端的性能瓶颈是需要等待其他CPU都回复Invalid Acknowledgement,所以解决问题的思路就是让CPU可以在写入后不再等待Invalid Acknowledgement
2. 为实现该目标,CPU的设计者为每个CPU都添加了一个名为store buffer(写缓冲)的结构,用于汇集CPU的写操作。store buffer是硬件实现的缓冲区,因此读写速度比缓存的速度更快,所有面向缓存的写操作都会先经过store buffer
3. store buffer会收集多次写操作,然后在适当的时机提交给缓存,并处理缓存一致性协议的各种交互。也就是说,CPU在将数据写入store buffer时不会发出总线事件;直到store buffer提交数据并且与缓存交互时,才会发出总线事件
4. 每个CPU的store buffer是私有的,对其他CPU不可见
5. store buffer如何提升性能?
① 当CPU要对变量进行赋值时,可以直接将新的值写入store buffer,不必等待其他CPU都回复Invalid Acknowledgement,之后再由store buffer去做核间同步
② 如果刚才写入数据的CPU需要再进行读取操作,则可以直接从自己的store buffer中读取到正确的新值
③ 如果刚才写入数据的CPU需要再次修改变量的值,则可以将更新直接写入自己的store buffer
可见上述场景提升写操作性能的核心,就是将缓存一致性的处理推迟到由store buffer进行,CPU只需要和速度更快的store buffer进行交互
说明:cache与buffer辨析
中文材料中经常将cache和buffer都翻译为缓冲(或者缓存),因此很容易混淆二者的概念,此处予以辨析
① cache往往意味着他所存储的信息是副本,cache中的数据即使丢失,也可以从内存中找到原始数据(不考虑脏数据的情况),因此cache存在的意义是加速查找
② buffer更像是蓄水池,他会汇集CPU的数据写入请求然后再在适当的时机提交给cache。buffer中的数据没有副本,一旦丢失就将彻底丢失
③ 为了更准确地表达含义,我们将cache称为缓存,将buffer称为缓冲
5.2.2 写缓冲引入的问题
引入store buffer放宽MESI协议的限制之后,一方面提升了写操作性能,另一方面也导致变量写入缓存和内存的顺序无法得到保证
int a = 0;
int b = 0;
// CPU0执行
void foo(void)
{
a = 1;
b = 1;
}
// CPU1执行
void bar(void)
{
while (b == 0)
continue;
assert(a == 1);
}
以上述代码为例,foo函数对变量a和b的赋值顺序在如下两种情况下会被打乱,
1. CPU乱序执行
由于变量a与变量b无关,因此CPU0在执行foo函数时可能先给变量b赋值后给变量a赋值,从而导致bar函数assert失败(此时变量b的值已经被修改为1,而变量a的值仍为0)
2. store buffer导致变量b的值先写入缓存,具体场景如下,
① 假设在CPU0的缓存中,变量a处于Shared状态,变量b处于Exclusive状态。CPU0在执行foo函数时没有乱序,先给变量a赋值,后给变量b赋值
② CPU0在将变量a和变量b的新值都写入store buffer之后,由于变量b处于Exclusive状态,缓存一致性处理较为简单,可能先于变量a被写入缓存
③ 由于变量b的值先于变量a的值被写入缓存,所以bar函数assert失败
说明:即使在如下更极端的情况下,也就是变量a和变量b之间存在数据依赖关系不可能乱序执行,store buffer仍可能导致变量b的值先于变量a的值被写入缓存
// CPU0执行
void foo(void)
{
a = 1;
b = a;
}
5.2.3 写屏障的作用
在写操作中引入内存屏障的作用,就是让CPU暂停执行等待store buffer生效,从而确保变量a的值一定先于变量b的值被写入缓存。在foo函数中增加内存屏障操作后,可以确保在内存屏障之前的写操作没有完成的情况下,之后的写操作不会发生
int a = 0;
int b = 0;
// CPU0执行
void foo(void)
{
a = 1;
// 内存屏障
// 此时可以确保在变量b的值为1时,变量a的值一定为1
smp_mb();
b = 1;
}
// CPU1执行
void bar(void)
{
while (b == 0)
continue;
assert(a == 1);
}
说明1:由于不同体系结构的内存屏障指令不同,此处使用smp_mb函数进行封装
说明2:store buffer功能小结
① store buffer为了提升写操作性能放弃了缓存的顺序一致性,我们将这种现象称为弱缓存一致性
② 在正常的程序中,多个CPU操作同一个变量的情况比较少,所以store buffer可以大大提升程序的运行性能
③ 在需要核间同步的情况下,因为必须要保证缓存的顺序一致性,所以需要软件工程师在适当的位置添加内存屏障
说明3:关于写操作的缓存顺序一致性
① 添加内存屏障后,可以确保变量的实际赋值是以程序预期的顺序进行的
② 其他CPU也能观察到CPU0按照程序预期的顺序更新变量
5.3 失效队列与读屏障
5.3.1 失效队列及其作用
1. 由于Shared状态下失效端的性能瓶颈是需要在处理完BusUpgr总线事件之后才能发送Invalid Acknowledgement,所以解决问题的思路就是让CPU可以在尚未处理BusUpgr总线事件的情况下就发送Invalid Acknowledgment
2. 为实现该目标,CPU的设计者又为每个CPU添加了一个名为invalid queue(失效队列)的结构,用于汇集其他CPU的失效请求
3. 每个CPU的invalid queue也是私有的,对其他CPU不可见
4. invalid queue如何提升性能?
假设CPU1收到了CPU0发出的失效请求,将会按如下步骤处理,
① CPU1立即向CPU0发送Invalid Acknowledgement
② CPU1此时并没有将自己缓存的状态变更为Invalid,而是将收到的失效请求放入invalid queue
③ invalid queue在适当的时机与缓存交互,将缓存状态从Shared变更为Invalid
可见在引入invalid queue之后,CPU响应失效请求的速度大大提升
说明:注意上图中store buffer和invalid queue与缓冲的相对位置,
① store buffer用于处理写入端,所以在CPU和缓存之间。写请求是先到store buffer,后到缓存
② invalid queue用于处理失效端,所以在缓存和内存之间。失效请求是先到invalid queue,后到缓存
5.3.2 失效队列引入的问题
invalid queue提升性能的本质也是放宽了MESI协议的限制,自然也会导致缓存一致性问题
int a = 0;
int b = 0;
// CPU0执行
void foo(void)
{
a = 1;
// 内存屏障
// 此时可以确保在变量b的值为1时,变量a的值一定为1
smp_mb();
b = 1;
}
// CPU1执行
void bar(void)
{
while (b == 0)
continue;
assert(a == 1);
}
继续上文添加写屏障之后的示例,此时foo函数中可以确保变量a和变量b的赋值顺序,但是bar函数的执行仍然可能失败,具体场景如下,
① 假设变量a在CPU0和CPU1的缓存中处于Shared状态且值为0;变量b在CPU0的缓存中处于Exclusive状态,在CPU1的缓存中处于Invalid状态
② CPU0写入变量操作
- CPU0写入变量a时会发出失效请求,CPU1立即发送Invalid Acknowledgement并将失效请求加入invalid queue,CPU0在接收到Invalid Acknowledgement之后将变量a的缓存变更为Modified状态
- CPU0写入变量b时不会发出总线事件,会将缓存状态变更为Modified
③ CPU1读取变量操作
- CPU1读取变量b时会发出BusRd总线事件,总线会将CPU0缓存中的新值先更新到内存中,然后从内存中获取到变量b的新值,从而可以跳出while循环
- CPU1读取变量b时,之前接收到的失效请求可能还在invalid queue中没有被及时处理,此时CPU1还是会使用自己缓存中的旧值0,从而导致bar函数assert失败
5.3.3 读屏障的作用
在读操作中引入内存屏障的作用,就是让CPU暂停执行等待invalid queue生效,从而确保在读取变量a之前,invalid queue中的失效消息全部被处理完毕,后续的读操作可以读取到变量a的新值
int a = 0;
int b = 0;
// CPU0执行
void foo(void)
{
a = 1;
// 内存屏障
// 此时可以确保在变量b的值为1时,变量a的值一定为1
smp_mb();
b = 1;
}
// CPU1执行
void bar(void)
{
while (b == 0)
continue;
// 内存屏障
// 此时可以确保invalid queue中的失效请求均被处理完毕
smp_mb();
assert(a == 1);
}
5.4 读写屏障分离
1. 上文示例中的smp_mb函数可以同时对store buffer和invalid queue施加影响,但是在程序中可能不需要smp_mb有那么大的作用,毕竟内存屏障会让CPU暂停从而影响性能
2. 在上文示例中,foo函数中只需要保证store buffer的写入顺序,bar函数中只需要保证invalid queue的失效顺序。为了更加精细地控制store buffer和invalid queue的顺序,内存屏障被分离为写屏障和读屏障
① 写屏障的作用是让屏障前后的写操作不能越过屏障,即写屏障之前的写操作一定会比之后的写操作先写入到缓存中,此时不会影响读操作的性能
② 读屏障的作用是让屏障前后的读操作不能越过屏障,即读屏障之前的失效请求一定会被处理完成,此时不会影响写操作的性能
使用分离读写屏障的示例如下,
int a = 0;
int b = 0;
// CPU0执行
void foo(void)
{
a = 1;
// 内存写屏障
// 此时可以确保在变量b的值为1时,变量a的值一定为1
smp_wmb();
b = 1;
}
// CPU1执行
void bar(void)
{
while (b == 0)
continue;
// 内存读屏障
// 此时可以确保invalid queue中的失效请求均被处理完毕
smp_rmb();
assert(a == 1);
}
说明1:写屏障会禁止写操作的乱序执行
这个要求是隐含的,但是是显然的。毕竟如果写屏障不能禁止写操作的乱序执行,那么也就不存在写屏障要确保的写入先后顺序了
说明2:读写屏障分离需要体系结构的支持,对于X86和ARM体系结构,并没有读写屏障
① X86采用TSO(Total Store Order)模型不存在缓存一致性问题
② ARM采用了另一种称为单向屏障的分类方式
5.5 单向屏障
1. 单向屏障(half-way barrier)也是一种内存屏障,但是他不是以读写来区分,而是像单行道一样,只允许单向通行
2. 单向屏障的实例就是ARM体系结构中的STLR指令和LDAR指令,
① STLR(Store-Release Register):以release语义将寄存器的值写入内存
② LDAR(Load-Acquire Register):以acquire语义从内存中将值加载到寄存器
3. 如果采用带有release语义的写内存指令,那么屏障之前的所有读写都不能发生在这次写操作之后,相当于在这次写操作之前施加了一个内存屏障。但是他并不能保证屏障之后的读写操作不会前移,也就是挡前不挡后
4. 如果采用带有acquire语义的读内存指令,那么屏障之后的所有读写都不能发生在这次读操作之前,但是并不能保证屏障之前的读写操作不会后移,也就是挡后不挡前
5.6 引入内存屏障思路小结
1. 为了弥合CPU和内存之间越来越大的速度差距,CPU中引入了缓存
2. CPU从单核发展为多核,带来了多核之间的缓存一致性问题
3. 为了解决缓存一致性问题,CPU设计者提出了MESI等缓存一致性协议
4. 完全遵守MESI协议会导致性能问题,所以CPU设计者增加了store buffer和invalid queue,用于提升核间同步的速度,从而提升了程序整体性能
5. store buffer和invalid queue放宽了MESI协议的要求,导致缓存的顺序一致性变为弱缓存一致性
6. 当需要缓存的顺序一致性时,需要软件工程师在适当的位置添加内存屏障
7. 使用功能强大的内存屏障会导致性能下降,所以CPU设计者提供了分离的读写屏障或者单向屏障