存储器的层次结构
存储器系统是一个具有不同容量、成本和访问时间的存储设备的层次结构:
- CPU寄存器
- 高速缓存
- 主存
- 磁盘
具有良好局部性的程序倾向于一次又一次地访问相同的数据项集合,或是倾向于访问邻近的数据项集合。具有良好局部性的程序比局部性差的程序更多地倾向于从存储器层次结构中较高层次处访问数据项,因此运行得更快。
存储技术
随机访问存储器
随机访问存储器(Random-Access Memory, RAM)分为两类:静态RAM(SRAM)比动态RAM(DRAM)更快。SRAM用来作高速缓存存储器(可以在CPU芯片上、也可以在片下),DRAM用来作主存和图形系统的帧缓冲区。
SRAM
SRAM将每个位存储在一个双稳态的存储器单元里,每个单元是用一个六晶体管电路来实现的。它可以无限期地保持在两个不同的电压配置或状态之一。
由于SRAM存储器单元的双稳态特性,只要有电,它就会永远保持它的值,即使有干扰(例如电子噪音)来扰乱电压,当干扰消除时,电路就会恢复到稳定值。
DRAM
DRAM将每个位存储为对一个电容的充电。这个电容非常小,通常只有大约 30 × 1 0 − 15 30 \times 10^{-15} 30×10−15 法拉。DRAM 的每个单元由一个电容和一个访问晶体管组成,当电容的电压被扰乱后, 他就不会恢复了。
内存系统必须周期性地通过读出,然后重写来刷新内存每一位。只要有供电 SRAM 就会保持不变,而 DRAM 必须需要刷新。
传统的DRAM
DRAM被分为 d 个超单元阵列,每个超单元由 w 个单元组成,超单元阵列一般为 r 行 c 列的长方形。其中 r * c = d,d x w 的DRAM共存储 dw 位信息。下图位 16 x 8 的 DRAM 芯片组织。共有 16 个超单元,每个超单元由 8 位。他们通过引脚(高低电压表示二进制位)与外部交换信息,分别为 8 个 data 引脚,以字节为单位传输数据;2个 addr 引脚,携带超单元行列地址。也有其他的一些引脚,下图未画出。
DRAM连接到内存控制器的电路,DRAM需要通过addr引脚与其分两次通信,第一次通信行(RAS,Row Access Strobe,行访问选通脉冲),DRAM将数据缓存在行缓冲区,第二次通信列(Column Access Strobe,列访问选通脉冲)。采用二维阵列可以减少引脚数量,但是二维阵列需要两步发送地址。
增强的DRAM
- 快页模式(FPM DRAM),可以访问同一行的其他元素
- 扩展数据输出(EDO DRAM)
- 同步DRAM(SDRAM)
- 双倍数据速率同步(DDR SDRAM)
通过使用两个时钟沿作为控制信号,从而使DRAM速度翻倍。不同类型的 DDR SDRAM 是用提高有效带宽的很小的预取缓冲区的大小来划分的。 - 视频RAM(VRAM)
非易失性存储器
断电不会丢失信息的存储设备:
- 只读存储器(Read-Only Memory, ROM)可读可写
- PROM 只能被编程一次
- 可擦写可编程ROM(EPROM)
- 电子可擦除PROM(EEPROM)
- 闪存 (固态硬盘等)
存储在 ROM 设备中的程序称为固件,例如 BIOS 等。
访问主存
总线是一组并行的导线,能携带地址、数据、控制信号。数据流通过总线在处理器和DRAM主存之间传输数据,分为读事务和写事务。如下图所示,系统总线通过 I/O 桥与内存总线相连,内存总线链接主存。常说的南桥、北桥就是总线的一种。
连接 I/O 设备
主机一般采用 PCI 总线(I/O总线)连接到外围设备,虽然 I/O 总线
相较于系统总线和内存总线慢,但可以容纳种类繁多的第三方 I/O 设备。例如:
- 通用串行总线控制器,连接到 USB 总线的设备的中转机构
- 图形卡(适配器)
- 主机总线适配器,将一个或多个磁盘连接到 I/O 总线,采用的是一个特别的主机总线接口定义的通信协议,例如 SATA,NVmi等。
访问磁盘
CPU使用内存映射I/O的技术向 I/O 设备发射命令,地址空间中有一块地址是为与 I/O 设备通信保留的,该地址被称为 I/O 端口,一个 I/O 设备可以被映射到一个或多个端口,内存映射I/O分为三步:
- CPU发送一个读的命令字,同时发送其他参数,例如读完成时,是否中断CPU
- 指令读的逻辑块号
- 指明存储读取内容的主存地址
设备自己执行读或者写总线事务而不需要CPU干涉的过程,被称为直接内存访问(Direct Memory Access, DMA)。当 DMA 传送完成,磁盘内容被安全存储至主存后,磁盘控制器发一个中断信号至CPU。
关于机械硬盘的知识,这里不做介绍。
固态硬盘的结构如上图所示,其读写以页为单位,但是写的时候需要将一个块擦除,所以其写的速度较慢:
- 擦除块需要较长时间
- 如果修改一个已经有数据的页,需要将该块中的所有带数据的页复制到一个新块
局部性
局部性:倾向于引用邻近于其他最近引用过的数据项(空间局部性),或者最近引用过的数据项(时间局部性)。有良好局部性的程序的程序运行的更快。
对程序数据引用的局部性
对于数组而言,访问步长为 1 具有良好的局部性,但是二维数组最好使用行优先顺序访问,因为 C 数组在内存中是按照行顺序存放的。
取指令局部性
因为程序指令是存放在内存中的,CPU必须取出(读)这些指令,for 循环内部按连续内存顺序执行就具有良好的空间局部性,同时也具有很好的时间局部性。
量化评价程序中局部性的一些原则:
- 重复引用相同变量的程序具有良好的时间局部性
- 对于具有步长为 k 的引用模式的程序,步长越小,空间局部性越好
- 对于取指令来说,循环有良好的时间和空间局部性。循环体越小,循环迭代次数越多,局部性越好
存储器的层次结构
下图展示了一个经典的存储器层次结构,从高层向底层,存储设备变得更慢。第 k 的数据是第 k+1 层数据的缓存,数据以块大小为传送单元,较高层的存储器传送数据使用的块的大小较小,底层存储器传送数据使用的块的大小较大。
缓存命中
当程序需要第 k+1 层的某个数据对象时,它首先在第 k 层的一个块中查询,如果刚好该数据对象缓存在第 k 层,那么就是缓存命中。
缓存不命中
与缓存命中相反,当发生缓存不命中的时候,第 k 层的缓存从第 k+1 的缓存中读取数据,如果第 k 层缓存已满,可能会覆盖现存的一个块(牺牲块)。替换哪个块取决于替换策略,如 LRU 策略。
缓存不命中种类:
- 强制性不命中(冷不命中):第 k 层缓存为空(被称为冷缓存)。
- 冲突不命中:限制性的放置策略会引发该现象。例如才所以取余法放置,0,2,4,8块会被放置到缓存块 0 中,重复读0 2 块,那么缓存始终不命中
通用的高速缓存存储器组织结构
下图为高速缓存的通用组织,每个存储器地址有 m 位。形成 M = 2 m M = 2^{m} M=2m 个不同的地址,一般使用元组 ( S , E , B , m ) (S,E,B,m) (S,E,B,m)来描述高速缓存:
- 共有 S = 2 s S = 2^{s} S=2s个高速缓存组
- 每个组包含 E 个高速缓存行
- 每个行由一个 B = 2 b B = 2^{b} B=2b字节的数据块组成,
- 其中的有效位指明这个行是否包含有意义的信息,
- 还有 t = m − ( b + s ) t=m-(b+s) t=m−(b+s)个标记位,唯一标识存储在这个高速缓存行中的块。
直接映射高速缓存
高速缓存的每个组只有一行高速缓存被称为直接映射高速缓存。下图为直接映射高速缓存的取值过程,其中块偏移的值 100 是二进制,十进制值为 4:
问题引入:
冲突不命中的时候如何解决该问题,考虑以下函数:
float dotprod(float x[8], float y[8]){
float sum = 0.0;
itn i;
for(i = 0; i < 8; ++i>)
sum += x[i] * y[i];
return sum;
}
假设浮点数为 4 字节,直接映射高速缓存共两个组,每个组有一个块(16字节)。乍一看上述代码是满足空间局部性的,但是x[0]~x[3] 和 y[0]~y[3]都被映射到高速缓存的组0(块需要4为表示,组索引一位,其余为标记),如下图所示:
为了避免该问题,我们可以把 x 数组的大小修改为 12,这样可以得到下图, 这样 y 数组和 x 数组映射到高速缓存的组就不同,可以很好的使用空间局部性:
组相联高速缓存
组相联高速缓存:高速缓存的每个组都有多余一个的高速缓存行,一般被称为E路组相联高速缓存。下图展示了该高速缓存的取值过程:
全相联高速缓存
全相联高速缓存是由一个包含所有高速缓存行的组( E = C / S , E = C/S, E=C/S, 即 S = 1 S = 1 S=1),如下图所示:
全相联高速缓存因为只有一个组,所有地址中没有组索引位,地址只被划分为一个标记和一个块偏移
下图为全相联高速缓存取值过程,因为高速缓存电路必须并行地搜索许多相匹配的标记,构造一个又大又快的相连高速缓存很困难。因此,全相联高速缓存只适合做小的高速缓存,如虚拟内存的翻译后备缓冲器(TLB)。
有关写的问题
高速缓存的写是一个复杂的问题,写策略分为以下几种:
- 直写(write-through),立即将修改的高速缓存块写回到低一层。缺点:会引起总线流量。
- 写回(write-back),尽可能推迟更新,显著减少了总线流量,但是增加了复杂性。高速缓存必须为每一个高速缓存行维护一个额外的修改位,表示该高速缓存块是否被修改过。
另一个问题是如何处理写不命中:
- 写分配,加载相应的低一层中的块到高速缓存,然后更新该块
- 非写分配,直接把字写到低一层中。
通常直写高速缓存是非写分配的,写回高速缓存是写分配的。
高速缓存层次结构剖析
- 只保存指令的高速缓存称为 i-cache
- 只保存程序数据的高速缓存称为 d-cache
- 既保存指令又包括数据的高速缓存称为统一的高速缓存
现代处理器包含独立的 i-cache 和 d-cache,因为:
- 处理器能同时处理一个指令字和一个数据字
- i-cache是只读的,比较简单
- 确保数据访问和指令访问不会形成冲突不命中
编写高速缓存友好的代码
基本方法:
- 让最常见的情况运行得快。把注意力集中在核心函数的循环上
- 尽量减小每个循环内部的缓存不命中数量。按照数据对象存储在内存中的顺序、以步长为 1 来读数据。
- 一旦存储器加载了一个数据对象,就尽可能多的使用它(时间局部性)。
参考 问题引入 部分。
高速缓存对程序性能的影响
存储器山
一个数组,以不同步长读取不同大小的数据的吞吐量示意图,从图中可以看出,存储器对小步长的算法效果最好(从硬件层次说,存储器系统有硬件预取机制),因为小步长可以造就较好的局部性。
重新排列循环以提高空间局部性
n × n n\times n n×n的矩阵乘法,一般需要三层嵌套的循环来实现,如果改变循环的次序,会得到下图 6 个功能等价的函数:
假设矩阵 A 和 B 足够大,且高速缓存只有32字节。
ab中内循环的主体是AB,A按行加载到缓存中,B按列使用缓存,A不命中次数为0.25,B一定不能命中,所以共 1.25 次不命中
cd的内循环按列读取内存,虽然内循环主体是AC,但每次都不能命中,所以共 2 次不命中
ef的内循环主体是BC,且按行读取,使用了两个加载和一个存储操作,共0.5次不命中。
下图展示了各个版本矩阵乘法的性能
给我们的教训就是要尽可能的按照数据对象存储在内存中的顺序、以步长为 1 来读数据。同时,可以看到,在 n 值很大时,最优版本的性能不变,这得益于之间讲的硬件预取机制。