存储器结构

337 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天,点击查看活动详情

虽然说 Java 程序员不像 C 程序员需要特别关心内存分配等操作,但是学习下计算机的存储结构,明确各个层次的存储器特性,对写高性能的代码还是很有帮助的。所以基于“深入理解计算机系统”,一起学习下存储器相关的一些知识。

存储器分类

存储器系统是一个不同容量、成本和访问时间的存储设备的层次结构。越靠近 CPU 的存储器越快也越贵、越小,从磁盘上读取信息比从 DRAM 中读取慢了 10 万倍,比 SRAM 慢了 100 万倍。计算机巧妙的运用了各种存储器的特性,构建了一个既运行高效又支持大容量存储的系统。

SRAM

SRAM 全称“Static Random Access Memory”,之所以被称为“静态”存储器,是因为只要有电,它就会永远保持它的值。即使有干扰来扰乱电压,当干扰消除时,依然会恢复到稳定值。而一旦断电,里面的数据就会丢失了。SRAM 对诸如光和电噪声这样干扰不敏感,是因为 SRAM 比 DRAM 使用了更多的晶体管,因而密集度较低,也就更贵,功耗更大。

由于 SRAM 速度快、容量小、价格贵,所以多用来制造高速缓存,如现在计算机中基本都有的 L1,L2,L3 缓存。

DRAM

DRAM 全称“Dynamic Random Access Memory”,相比 SRAM,DRAM 需要定时刷新,所以也被称为“动态”存储器。DRAM 每个单元由一个电容和一个晶体管组成,由于组成简单,所以 DRAM 可以制造的非常密集,因而容量可以做的较大。但DRAM 对干扰非常敏感。因为数据存储在电容里面,而电容会不断漏电,所以要定时刷新,才能保证数据不会丢失。

DRAM 由于容量大、价格便宜,多用来制作计算机主存,或者各种显卡的显存等。

每位晶体管数相对访问时间持续的?敏感的?相对花费应用
SRAM61X1000X高速缓存存储器
DRAM110X1X主存,帧缓冲区

ROM

ROM 全程“Read Only Memory”,由于历史原因,虽然 ROM 中有的类型即可以读也可以写,但是他们整体上被称为只读存储器。相比于 SRAM,DRAM 最大的不同就是断电后不会丢失信息,因为是非易失性存储器。

存储在 ROM 中的程序通常被称为“固件”,当计算机通电后就会运行存储在 ROM 中的固件,例如我们所熟知的 BIOS 程序,就存储在 ROM 中,每次计算机开机会引导操作系统启动。复杂的设备,如显卡和磁盘驱动控制器,也依赖固件翻译来自 CPU 的 I/O 请求。

磁盘

磁盘是由“盘片”构成,每个盘片有两面或者一面称为“表面”,表面覆盖着磁性记录材料。盘片中央有一个旋转主轴,使得盘片以固定的旋转速率旋转,通常 5400 ~ 15000 转/分钟。磁盘通常包含一个或多个盘片,并封闭在一个容器中。

磁盘的表面由一组组称为“磁道”的同心圆组成,每个磁道被划分为一组“扇区”,每个扇区包含相同数量的数据位(通常 512 byte)。扇区之间由一些间隙分隔开,间隙不存储数据位,只用来标识扇区的格式化位。

盘片磁盘

磁盘容量

一个磁盘可以记录的最大位数称为他的容量。磁盘容量由以下技术因素决定:

  • 记录密度:磁道一英寸的段中可以放入的位数。
  • 磁道密度:从盘片中心向外半径一英寸可以有的磁道数。

磁盘的容量 = 字节数/扇区 * 平均扇区数/磁道 * 磁道数/表面 * 表面数/盘片 * 盘片数/磁盘

磁盘操作

磁盘用“读/写头”来读写存储在磁性表面的位,读写头连接到一个传动臂。通过沿着半径轴前后移动传动臂,可以将读/写头定位到期望磁道,这个过程称为“寻道”。有多个盘面的磁盘针对每个盘面都有一个独立的读/写头,读/写头垂直排列,一致行动,在任何时刻,所有读/写头都位于同一柱面上。

读写统一移动

磁盘以扇区大小的块来读写数据,对扇区的访问时间主要由下面几个部分决定。

  • 寻道时间:移动传动臂到指定磁道上的时间。现在驱动器中平均寻到时间通常是 3~9 ms,并且受限于物理性能局限,很难再缩小。
  • 旋转时间:读/写头到指定磁道后,需要等待磁盘旋转到目标扇区,目标扇区第一个位旋转到读/写头下的时间为旋转时间。以 7200 转磁盘为例,平均旋转时间约为 4ms。
  • 传送时间:从读取扇区第一个位到扇区读取完毕的时间为传送时间,主要取决于磁盘的旋转速度及磁道的扇区数目。相比于寻道时间及旋转时间,传送时间很小基本可以忽略不计。

由于寻道时间和旋转时间大致相等,所以经常用寻道时间*2 来估算磁盘访问时间。

逻辑磁盘块

为了对操作系统隐藏磁盘构造的复杂性,现代磁盘将其构造呈现为一个简单的视图:一个 B 个扇区大小的逻辑块序列,编号为0,1,...,B-1。磁盘中有一个小的硬件称为磁盘控制器,维护着逻辑块号与实际磁盘扇区的映射关系。

当操作系统想要读写一个磁盘的扇区数据时候,将会发送一个命令到磁盘控制器,让他读取某个逻辑块号。磁盘控制器将逻辑块号翻译成一个(盘面,磁道,扇区)的三元组,唯一的标识了对应的物理扇区。然后将读/写头移动到相应的物理扇区,进行读写操作。

磁盘读取

固态硬盘

固态硬盘(SSD)是一种基于闪存的存储技术,有一个或多个闪存芯片和闪存翻译层组成。闪存芯片替代传统磁盘中的机械驱动器,闪存翻译层则是类似于磁盘控制器的存在。

固态硬盘

相比传统磁盘,SSD 有很多优点。由于由半导体存储器构成,因而随机访问时间比旋转磁盘要快很多,功耗更低,也更结实。同样也有一些缺点,闪存块会磨损,因而 SSD 寿命较普通磁盘短。SSD 较传统磁盘价格更贵,因而常用的存储容量也比传统磁盘较小。

硬件技术趋势

硬件技术趋势

上图是各种硬件按年统计的运行速度,可以看出,虽然 SRMA 的性能滞后于 CPU 的性能,但是在持续保持增长,追赶 CPU 的增长曲线。而 DRAM 和磁盘的性能增长缓慢,严重滞后于 CPU 的性能,与 CPU 性能之间的差距在不断扩大。

CPU 在 2003 年开始出现变化,是由于当时无法再通过增加 CPU 的频率来提升性能,因为这样芯片的功耗会太大。解决办法就是用多个小处理器取代单个大处理器,从而提升性能。CPU 频率在 2003 年达到了最低点,上升后又开始以比以前慢一些的速率下降。不过,CPU 的有效周期时间还是以接近以前的速率持续下降。

局部性

从上面我们可以看到不同硬件的运行效率差距简直天壤之别,那么计算机是如何将这些运行效率差异如此之大的硬件组合在一起,并能保证高效运行呢?这里就要了解一个很重要的概念--局部性原理。

一个编写良好的计算机程序常常有良好的“局部性”。也就是,它们倾向于引用的数据项多临近与其他最近引用过的数据项,或者是最近引用过的数据项本身。这种倾向性,被称为“局部性原理”。这是一个很重要的概念,对硬件和软件系统的设计和性能都有着极大的影响。

局部性有两种不同的形式:

  • 时间局部性:被引用过的内存位置很可能在不远的将来再被多次引用。
  • 空间局部性:如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用其附近的一个位置。
/**
 * 局部性验证小程序
 * @author 鱼蛮 on 2021/11/1
 **/
public class Locality extends AbstractBenchMark {

    /**
     * 缓存组是8路的,所以需要大于8才会比较明显的缓存冲突情况
     */
    final static int ROW = 16;
    
    /**
     * Core i7,L1 d-cache是32KB,L2 cache 是256K,这个值可以根据 cache 情况调整
     * 一个Integer值16byte,32KB/16byte=2048,数组的一行正好可以填充满L1缓存
     */
    final static int COLUMN = 2048;

    static Integer[][] arr;

    @Benchmark
    public void locality() {
        int tmp = 0;
        for (int i = 0; i < ROW; i++) {
            for (int j = 0; j < COLUMN; j++) {
                tmp += arr[i][j];
            }
        }
    }

    @Benchmark
    public void noneLocality() {
        int tmp = 0;
        for (int j = 0; j < COLUMN; j++) {
            for (int i = 0; i < ROW; i++) {
                tmp += arr[i][j];
            }
        }
    }
    
    static {
        int counter = 0;
        arr = new Integer[ROW][COLUMN];
        for (int i = 0; i < ROW; i++) {
            for (int j = 0; j < COLUMN; j++) {
                // 主要Integer的常量池影响
                arr[i][j] = new Integer(counter++);
            }
        }
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(Locality.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();

        /// 用来打印数据内存地址,来实际验证
        printAddress(arr[0][0]);
        printAddress(arr[0][1]);
        printAddress(arr[1][0]);
    }

    public static void printAddress(Object o) {
        System.out.println("number:" + o + ",address:"+ Long.toHexString(VM.current().addressOf(o)) + "##" + Long.toBinaryString(VM.current().addressOf(o)));
    }
}

可以用上面的小程序来验证局部性的重要性,两种方式的运行效率差距还是比较大的。

Benchmark              Mode  Cnt      Score      Error  Units
Locality.locality      avgt    5  18587.618 ± 1567.916  ns/op
Locality.noneLocality  avgt    5  37490.564 ± 5833.672  ns/op

存储器层次结构

基于各种存储技术特性及局部性原理,硬件和软件可以很好的互相补充。它们这种互相补充的性质使人们想到一种组织存储系统的方法,称为“存储器层次结构”,所有的现代计算机系统都使用了这种方法。下图展示了一个典型的存储器层次结构。一般,从高处往底层走,存储设备变的更慢、更大、更便宜。

存储器层次结构

缓存

一般而言,缓存是一个相对小而快速的设备,他作为储存在更大、更慢的设备中的数据对象的缓冲区域。在存储器层次结构中,任何一层都可以看做是其下一层的缓存。在计算机系统中,缓存主要是为了加速 CPU 的访问,毕竟 CPU 跟底层的存储系统运行效率差距巨大。

在 CSAPP 里面老师描述缓存举得例子特别形象:对一个学生来说,家就好比主存,里面有各种各样的东西。而背包就好比高速缓存,只装了你最需要的东西。当你从书包拿出课本的时候可以很快,否则你就需要跑回家拿。

现代操作系统可以说是各种缓存的集合,如下表所见,从 CPU 都浏览器,到时都运用了缓存技术。

类型缓存什么被缓存在何处延迟(周期数)由谁管理
CPU寄存器4字节或8字节字芯片上的CPU寄存器0编译器
TLB地址翻译芯片上的TLB0硬件MMU
L1高速缓存64字节块芯片上的L1高速缓存4硬件
L2高速缓存64字节块芯片上的L2高速缓存10硬件
L3高速缓存64字节块芯片上的L3高速缓存50硬件
虚拟内存4KB页主存200硬件+OS
缓冲区缓存部分文件主存200OS
磁盘缓存磁盘扇区磁盘控制器100 000控制器硬件
网络缓存部分文件本地磁盘10 000 000NFS客户
浏览器缓存Web页本地磁盘10 000 000Web浏览器
Web缓存Web页远程服务器磁盘1 000 000 000Web代理服务器

缓存数据总是以块大小为传送单元,在第 k 层跟 k+1 层之间来回拷贝。相邻层次之间块大小是固定的,但其他层次对之间可以有不同的块大小。L1 和 L0 之间传送通常使用 1 个字的块,L2 和 L1、L3 和 L2、L4 和 L3 之间传递通常使用几十个字节的块。而 L5 跟 L4 之间传递通常用几百或者几千字节的块。一般而言离CPU越远的设备访问时间越长,为了补偿这些时间,倾向于使用较大的块。

缓存不命中

缓存不命中主要分为三类:

  • 冷不命中:一个空的缓存被称为冷缓存,此类不命中称为冷不命中。应对这种情况就是在使用前需要暖身(warm up),也就是常说的预热。
  • 冲突不命中:由于缓存的容量限制,读取不同的内容命中同一缓存块称为冲突不命中。比如一个容量为 4 的缓存(采用取余的放置策略),程序来回请求块 0 和块 8,就会导致每次请求都无法命中缓存。
  • 容量不命中:当缓存太小无法缓存工作集的时候称为容量不命中。 只要发生了缓存不命中,就需要执行严格的缓存“放置策略”,来决定将 k+1 层的块放置何处,而不是放置在任意地方,否则定位起来代价很高。

高速缓存

早起计算机系统的存储器只有三层:CPU 寄存器、DRAM 主存和磁盘。不过,由于 CPU 和主存(DRAM)之间的差距逐渐增大,于是在 CPU 寄存器和主存之间插入了小的 SRAM 高速缓存,称为 L1 高速缓存,访问速度大约 4 个时钟周期。如下图所示。

高速缓存

随着 CPU 和主存的性能差距不断变大,于是又在 L1 和主存之间增加了 L2 高速缓存,L2 高速缓存访问速度大约 10 个时钟周期。现代计算机在 L2 和主存之间又插入了一个 L3 缓存,访问速度大约 50 个时钟周期。

高速缓存组织结构

高速缓存通常按组形式来组织,如下图所示,高速缓存被组织成 S 个高速缓存组。每个缓存组有 E 个高速缓存行,每行包含 B 字节的数据块及 1 个有效位和 t 位的标记位(用来标识缓存组中惟一的缓存行)。所以高速缓存的容量计算也非常容易计算:S*E*B。

高速缓存组织

内存地址根据高速缓存的组织形式,被划分为了三部分,用于快速定位到缓存:

  • 块偏移:简称(CO),用于在缓存块中快速定位数据,位数等于log2Blog_2B
  • 组索引:简称(CI),用于查找缓存所在的缓存组,位数等于log2Slog_2S
  • 标记:简称(CT),查找到的缓存组如果有多行时,需要依次比较缓存标记是否相同,来确定缓存是否存在,位数等于:内存地址位数-CO位数-CI位数。

直接映射高速缓存

每个缓存组只有一行(E=1)的高速缓存被称为直接映射高速缓存。

让我们一起看一个高速缓存的查找实例,假设内存地址为 4 位。高速缓存有 4 组,每组 1 行,每个缓冲块 2 字节。那么根据之前的知识我们可以知道,4 位地址被划分成了如下图所示的 3 部分。

直接映射高速缓存

假设 CPU 每次读取 1 字节的数据,我们按照下表来依次读取,则会有相应的缓存命中情况。

内存地址标记有效位块偏移是否命中
0(0 00 0)0(00)000miss
1(0 00 1)0(00)011hit
7(0 11 1)3(11)001miss
8(1 00 0)0(00)100miss
0(0 00 0)0(00)000miss

当程序访问地址大小为 2 的幂的数组时,直接映射高速缓存中通常会发生冲突不命中。这是因为这些块被映射到了同一个高速缓存组,例如上表中的内存地址 0 跟内存地址 8 就映射到了一个缓存组,从而产生冲突。即使程序有良好的空间局部性,而且我们的高速缓存有足够的空间来存放数据(组 1 跟 组 2 还未被使用),也无法有效利用缓存来提升性能。

组相连高速缓存

直接映射高速缓存中冲突不命中造成的原因源于每组只有一行,而组相连高速缓存就是组数大于 1 组,并且小于缓存大小/块大小的情况,所以每组都存在多个缓存行。我们让上面中的缓存每组有 2 行,则缓存组变成 2,再看下缓存命中情况。

内存地址标记有效位块偏移是否命中
0(00 0 0)0(0)0000miss
1(00 0 1)0(0)0011hit
7(01 1 1)1(1)0101miss
8(10 0 0)0(0)1000miss
0(00 0 0)0(0)0010hit

我们会发现,相比直接映射高速缓存产生了更少的缓存未命中。缓存组内较多的缓存行降低了缓存冲突的可能性,但是也需要更多的标记位,也会增加命中时间,因为最坏情况下需要遍历组内所有的标记位,才能找到对应的缓存行。

当 CPU 请求的数据不在缓存中,而查找到的缓存组已满时候,高速缓存必须替换缓存组中的某一个行,这其中就需要用到高速缓存替换策略,常见的有 LFU(最不常使用),LRU(最近最少使用)等。

全相连高速缓存

全相连高速缓存即只有一个组,包含所有缓存行。全相连高速缓存因为只有一个组,所以组选择非常简单。全相连高速缓存拥有最大的缓存空间利用率,但是必须搜索许多标记,构造一个又大又快的全相连高速缓存很困难,也很昂贵,因此全相连高速缓存只适合做小的高速缓存。有些虚拟系统中使用的小容量的 TLB 采用这种形式。

实际高速缓存示例

实际高速缓存

在高缓存中其实不止保存程序数据,也可以保存指令。只保存指令的高速缓存称为 i-cache,只保存数据的高速缓存称为 d-cache。

上图给出的是 Intel Core i7(Haswell)处理器的高速缓存层次结构。每个 CPU 芯片有四个核。每个核有自己私有的 L1 i-cache,L1 d-cache 及 L2 统一高速缓存。所有的核共享 L3 统一高速缓存。所有的 SRAM 高速缓存都在 CPU 芯片上。下表是 Core i7 处理器高速缓存的基本特性。

高速缓存类型访问时间(周期)高速缓存大小(C)相连度(E)块大小(B)组数(S)
L1 i-cache432KB864B64
L1 d-cache432KB864B64
L2统一高速缓存10256KB864B512
L3统一高速缓存40~758M1664B8192

存储器山

一个程序从存储系统中读取数据速率称为吞吐量,或者称为读带宽。通常以 MB/s 为单位。

存储器山

这张图是开头的那张图,描绘的是 Intel Core i7 系统的存储器山。展示了一个丰富的地势结构,垂直于大小轴的是四条山脊,对应 L1 高速缓存、L2 高速缓存、L3 高速缓存和主存的时间局部性区域。L1 山脊的最高点(读速率为 14GB/s)与主存山脊的最低点(读取速率为 900MB/s)之间的差别有一个数量级。

在 L2、L3、主存山脊上,随着步长的增加,有一个空间局部性的斜坡,空间局部性下降。即使工作集太大,不能全部装入任何一个高速缓存是,主存山脊的最高点仍然比它的最低点高 8 倍。因此,即使当程序的时间局部性很差时,空间局部性仍能补救,并且是非常重要的。

有一条特别的平坦的山脊线,对于步长 1 垂直于步长轴,此时吞吐量相对保持不变,为 12GB/s,即使工作集超过 L2、L3 的大小。这是由于 Core i7 存储器系统中的硬件预期机制,它会自动的识别顺序的、步长为 1 的引用模式,试图在一些块被访问之前,将他们取到高速缓存中。从存储器山可以明显看出算法对小步长效果最好--这也是代码中要使用步长为 1 的顺序访问的另一个理由。

存储器系统的性能不是一个数字就能描述的,而是一座时间和空间局部性的山。我们应该构造运行在山峰而不是山谷的程序。主要就是利用时间局部性,使得频繁使用的字从 L1 中取出,还要利用空间局部性,使得尽可能多的字从一个 L1 高速缓存行中访问到。

深入理解计算机系统

2015 CMU 15-213 CSAPP 深入理解计算机系统 课程视频