目录
1 存储电路概述
1.1 寄存器存储电路
1.2 大容量存储器存储电路
1.2.1 大容量存储器分类
1.2.2 SRAM存储电路
1.2.3 DRAM存储电路
2 存储器地址编码
2.1 简单编码示例
2.2 矩阵式编码示例
3 CPU缓存
3.1 存储器体系结构
3.2 缓存的物理架构
3.2.1 集中式缓存
3.2.2 分布式缓存
3.2.3 混合式缓存
3.3 缓存的工作原理
3.3.1 cache line概念
3.3.2 cache line组织方式
3.3.3 缓存映射方式
3.3.4 cache line内部结构与映射实现
3.4 缓存块替换策略
3.4.1 LRU策略概述
3.4.2 LRU策略实现简介
3.5 缓存对程序性能的影响
3.5.1 概述
3.5.2 缓存缺失类型
3.5.3 冲突缺失示例
3.5.4 程序局部性与缓存性能
3.5.5 伪共享(false-sharing)
1. 一般使用D触发器来实现CPU内部的寄存器
2. D触发器可以在一个CPU时钟周期内完成读写,是最快的一类存储单元,但是他的造价十分高昂,用于存储一个比特的触发器就需要几十个晶体管,所占电路面积最大
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. SRAM使用6个晶体管存储一个比特
2. SRAM的结构比D触发器简单,访问速度也比较快
3. SRAM是单纯的时序逻辑电路,因此可以集成到芯片内部
1. DRAM的电路结构更加简单,使用一个CMOS开关和一个电容存储一个比特。因此成本更低,也更易于大规模集成,但是读取的速度比较慢
2. 之所以DRAM读取的速度比较慢,是因为DRAM的读取是一种破坏式读取。他会使得原先为1的存储单元变成0,因此DRAM在读取数据时需要为原先为1的存储单元再进行一次充电
3. 由于电容本身会缓慢漏电,因此需要每隔一段时间为电容补充电荷,这也是Dynamic名称的由来
4. 由于DRAM中有电容的存在,不再是单纯的逻辑电路,不能用CMOS工艺制造,因此不能集成在CPU内部
对存储器中各单元进行编码的目的,是为了让地址总线上的数据能转换为相应存储单元的使能信号
1. 假设有4根地址总线,通过4-16译码器可以产生16个输出,可用于寻址16个字节
2. 地址总线上的数据恰好就是存储单元地址编码的二进制数,因此地址总线的宽度会影响存储编码范围
3. 简单编码有一个问题,就是随着存储器地址范围的扩大,译码器的输出端口会变得非常多。如果有32位地址总线,则译码器输出端口为4G个
1. 为了解决简单编码输出端口多的问题,人们将地址分为高低两部分(即行地址和列地址),并且将存储单元做成矩阵式排列
2. 仍以4根地址总线为例,将其平分为两组分别送入行 / 列译码器,虽然寻址范围仍为16B,但是输出端个数只有8个
对于32位地址总线,输出端的数量可降低为(2^16 + 2^16 = 128K),远小于之前的4G
3. 在矩阵式编码中,需要行线和列线同时生效来指定一个存储单元,这里有两种实现方法,
① 在每个存储单元引入一个与门,这需要为每个字节多增加两个MOS管,会降低芯片的集成度
② 在存储芯片内部增加一行缓存,读取时分两部进行。第一步先使行线生效,将目标存储单元所在行整行读入到缓存中;第二步再使列线生效,从缓存中读取目标单元的值。这种做法需要两次读取,会有性能损耗
说明:在目前的DDR内存中,不是将所有存储单元都部署在一个矩阵中,而是会划分为多个矩阵。此时的存储器地址编码就会划分为:矩阵片选 + 行地址 + 列地址
详情可参考04. 代码重定位 & SDRAM初始化S5PV210体系结构与接口04:代码重定位 & SDRAM初始04. 代码重定位 & SDRAM初始化 chapter 4
1. 从程序员的角度,希望有无限的、快速的、便宜的存储器可供使用。但是现实中,快速的存储器价格高,便宜的存储器速度慢。所以从平衡容量、性能、成本的角度,计算机存储体系结构被设计为分层结构,一般包含寄存器、缓存、内存、磁盘等
2. 缓存是计算机存储体系结构中的灵魂,因为缓存结合了寄存器速度快和内存造价低的优点
① 缓存的访问速度很快,较内存高1 ~ 2的数量级
② 利用程序的空间局部性和时间局部性,只要程序处理得当,缓存命中率可以达到70% ~ 90%。从而使得整个存储系统的性能接近寄存器, 但是成本接近内存
说明:缓存出现的背景,是处理器速度的增长远远超过了内存速度的增长,从而使得处理器和内存之间的速度差距越来越大。缓存的出现就是作为处理器和内存之间的桥梁,弥合二者的速度差距
在多核处理器中,缓存主要有如下三种集成方式,
一个缓存和所有核直接相连,多个核共享这一个缓存
一个核仅和一个缓存相连,一个核对应一个缓存
1. 将缓存设计为多层,其中有些层使用集中式,有些层使用分布式
2. 现代多核处理器大都采用混合式的方式将缓存集成到芯片中,通常在L3采用集中式缓存,在L1和L2采用分布式缓存,如下图所示,
1. cache line是缓存进行管理的最小存储单元,也叫缓存块
2. 内存和缓存之间的数据交互都是以cache line为单位,一个缓存块和一个内存块对应
说明1:在Ubuntu中可以通过如下命令获取各级cache line的大小,可见实验环境中各级cache line的大小均为64B
getconf -a | grep CACHE
说明2:内存和缓存之间的数据交互包括,
① 从内存向缓存加载数据
② 将数据从缓存写回内存
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.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)
缓存块内部结构如下图所示,
1. V(valid)表示该缓存块是否有效,或者说是否正在被使用
2. M(modified)表示该缓存块是否被写过,也就是"脏"位
3. tag字段用于内存块与组内各路缓存块的匹配,匹配的方式就是将组内每个缓存块的tag字段与物理地址的tag字段进行比较
说明1:tag字段匹配结果与处理方式
在将物理内存地址tag字段与组内各缓存块的tag字段进行比较的过程中,
① 如果有匹配的tag,说明该内存块已经加载到缓存中
② 如果没有匹配的tag,说明缓存缺失,需要将内存块加载到该组的一个空闲缓存块中
③ 如果组内所有路的缓存块都在使用中,则需要选择一个缓存块将其换出,并将新的内存块载入
说明2:上述匹配过程涉及到缓存块状态转换,而状态转换又涉及到有效位V、脏位M、标签tag以及对缓存的读写操作类型,具体情况如下表,
1. 最完美的缓存替换策略是被换出的缓存块是将来最晚会被访问的缓存块,也就是未来最晚使用
2. 由于未来无法精确预知,所以实现中选择用过去的历史预测未来,也就是最近最少使用(Least Recently Used,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内存块对应的缓存块
1. 缓存命中
① 如果访问内存时,数据已经在缓存中,则缓存命中
② 缓存命中时获取目标数据的速度非常快
2. 缓存缺失
① 如果访问内存时,数据没有在缓存中,则缓存缺失
② 缓存缺失时需要启动内存数据传输,而内存的访问速度相比缓存慢很多,所以需要避免这种情况
3.5.2.1 强制缺失
1. 第一次将数据加载到缓存所产生的缺失,也被称为冷缺失(cold miss),因为发生缓存缺失时,缓存是空的不会有数据
2. 强制缺失无法避免
3.5.2.2 冲突缺失
1. 冲突缺失是由缓存的相联程度有限导致的缺失
2. 如果程序不断访问处于同一组的内存块,则会被加载到同一组的缓存块中。但是由于同一组中的路数是有限的,当需要加载的内存块个数超过路数时则会导致缓存块频繁替换,从而降低程序性能
3.5.2.3 容量缺失
1. 容量缺失是由于缓存大小有限导致的缺失
2. 如果在程序运行的某段时间内,访问地址范围超过缓存大小很多,这样缓存容量就会成为缓存性能的瓶颈
说明1:可以认为除了强制缺失和冲突缺失之外的缺失都是容量缺失
说明2:需要注意区分冲突缺失和容量缺失
① 冲突缺失是同一组内的缺失
② 容量缺失是整个缓存范围内的缺失
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,因此会频繁导致缓存块的替换
1. 程序局部性分为时间局部性和空间局部性,如果程序有较好的局部性,那么在程序运行期间,缓存缺失就会很少发生
2. 在C语言中,二维数组是按行存储和解释的,我们先按行访问二维数组
程序运行耗时如下,
3. 再改为案列访问二维数组
程序运行耗时如下,可见耗时明显增加
4. 出现上述情况是因为,
① 按行访问时地址是连续的,下次访问的元素和当前元素大概率在同一个cache line(实验环境中,每个cache line可以容纳8个元素)
② 按列访问时,由于地址跨度大,下次访问的元素基本不可能还在同一个cache line,因此就会增加从内存加载数据到缓存的开销
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的并发库中经常会看到为了解决伪共享而进行的数据填充