缓存基础解释与缓存友好型编程基础

讨论了如何使用快速核心内存(约32,000个字)作为更大、更慢的核心内存(约1,000,000个字)的从属内存(slave)。

通过这种方式,可以在实际使用案例中设计出接近于更快内存的有效访问时间(effective access time),而不是较慢的内存。

-《从属内存与动态存储分配》(1965年,莫里斯·威尔克斯)

我们总是被迫进行缓存友好的编程。

现代处理器不仅在CPU性能方面有所提升,内存带宽和缓存效率也成为了性能的关键因素。

即使执行相同的运算,根据内存访问方式的不同,性能差异可能会非常大。理解和优化这一点就是缓存优化编程。

那么,让我们来理解一下什么是缓存。

什么是缓存?

缓存是用于解决CPU与内存(RAM)之间速度差距的高速存储器。

每当CPU处理数据时,如果每次都直接访问RAM,性能会大大降低,因此缓存会存储经常使用的数据以缩短访问时间。

缓存具有分层结构,现代缓存通常分为L1、L2、L3三层(少数情况下甚至有L4)。

L1最小且最快,而L3则逐渐变大但速度变慢。

那么,这一切是如何开始的呢?

缓存的历史

缓存的需求本身在冯·诺依曼的经典论文中已被认识到,这篇论文奠定了冯·诺依曼架构的基础。

在这篇论文中,已经意识到高速运算单元和内存速度之间的不平衡,并间接暗示了类似缓存解决方案的必要性。

缓存的确切起源可以追溯到莫里斯·威尔克斯教授的《从属内存与动态存储分配》(1965年,莫里斯·威尔克斯)。在这篇文章中,缓存被称为"从属内存"(Slave Memory)。

从属内存意味着它是一个依赖于主内存工作的层级结构,表示缓存会复制主内存(Core Memory)的部分数据并使用它。

当时(1960年代中期),计算机技术正在迅速发展,但...

内存系统存在严重的冯·诺依曼瓶颈(Von Neumann bottleneck)现象。

原因是当时的主内存(Core Memory)主要使用磁芯内存(Magnetic Core Memory),虽然稳定,但读写速度为微秒级别,无法跟上CPU的快速运算速度,从而导致整个系统的性能下降。

因此,由于CPU与内存之间的数据传输速度差异,冯·诺依曼瓶颈问题变得非常严重。

(莫里斯·威尔克斯Maurice Wilkes

在这个过程中,威尔克斯教授在1965年的《从属内存与动态存储分配》中提出了从属内存的概念。

从属内存(Slave Memories)是指在CPU和主内存之间设置一个高速的小规模内存层,以提高对常用数据的访问速度,从而提升整体系统性能。这是现代缓存的雏形。

随后,IBM System/360 Model 85基于莫里斯·威尔克斯教授的从属内存概念克服了技术障碍,并首次在商用系统中引入了这一概念,同时确立了"缓存"这一术语。

"缓存"一词源自法语"cache"(藏匿处),意指隐藏常用数据以便快速访问的存储器。

尽管按现在的标准来看,其容量仅为KB级别,非常小,但它确实帮助提升了CPU性能。

这被认为是L1(一级缓存)的雏形。不过,当时只使用了"缓存"这一名称,并没有使用L1这个称呼。

随着后来多层结构的整理,才逐步划分为L1和L2。

无论如何,仅靠L1缓存无法完全弥合CPU和主内存之间的速度差距,因此需要更大容量的缓存来辅助L1。

随着新一代处理器的速度越来越快,CPU周期时间和主内存访问时间之间的差距将进一步扩大。

这种差距的增加使得设计单级缓存变得更加困难。

也就是说,很难设计出既能足够快以匹配快速的CPU周期时间,又能足够大以有效隐藏较慢主内存访问时间的单级缓存。

解决这个问题的一种方法是使用多级缓存(multi-level cache hierarchy)

本文研究了多级缓存的组成方式及其与程序执行时间的关系。

-《性能最优多级缓存层次结构的特性》(Steven Przybylski, Mark Horowitz, John Hennessy, 1989)

最初并没有明确称为L2(二级缓存)的名称,但随着1980年代后期多级缓存结构需求的出现,这一概念逐渐确立。

1989年引入了多级缓存结构(Multi-Level cache hierarchy),相关论文《性能最优多级缓存层次结构》分析了其必要性。

无论如何,L1之后出现了L2,并以外部芯片的形式搭载L2缓存。直到英特尔奔腾Pro(1995年)才在CPU封装内集成了L2缓存(On-Chip Cache),使L2缓存得以广泛普及。

随着CPU核心数量的增加,每个核心高效共享缓存并保持数据一致性成为了一个重要的课题。

而为了解决这个问题,L3缓存应运而生。

(首款搭载L3缓存的消费级CPU------英特尔奔腾4 Extreme Edition 2003)

当然,在当前环境中,L3缓存作为多核环境中的共享缓存提升了性能。但在现代系统中,由于CPU与内存之间的带宽问题以及图形和AI计算的增长,对超高速内存访问的需求使得L4缓存的重要性日益凸显。

L4缓存不仅可以与CPU共享,还可以与集成GPU(iGPU)共享。

实际上,英特尔在2013年推出了名为Crystal Well的L4缓存,但由于成本过高,目前尚未像L3那样广泛商业化,主要用于高性能服务器。

现在,让我们总结一下缓存的层次结构。

缓存的层次结构(Memory Hierarchy)

层次 容量 访问时间 位置 特点
L1 32~64KB 1~4 CPU周期 CPU核心内置 - 最快且最小的缓存 - 指令缓存和数据缓存分离(L1I, L1D)
L2 256KB~1MB 10~20周期 CPU核心附近 - 每个核心单独拥有 - 比L1缓存大但速度较慢
L3 16~64MB 50~100周期 多个核心共享 - 多个核心共享的缓存 - 负责缓存一致性管理
L4 64MB~256MB+ 150~300周期 CPU封装内或外部芯片 - 用于部分高性能服务器、AI计算、集成GPU(iGPU) - 可与CPU、GPU、内存控制器共享
DRAM 8GB~1TB+ 300~500周期 主板DIMM插槽 - 系统整体内存 - 比L4缓存大但速度较慢 - 易失性存储器(断电后数据丢失)

从L1到L4,容量逐渐增大但速度逐渐变慢。当发生缓存未命中时,必须参考DRAM,这会显著降低CPU性能。

当从RAM中获取数据时,通常需要100~300个周期。

随着L4缓存容量的增加,它变得越来越像DRAM,使用快速DRAM比搭载L4缓存更具成本效益,因此目前消费者PC中尚未积极采用L4缓存。

特别是随着HBM(高带宽内存)的出现,L4缓存的作用被取代,因此L4目前仅用于服务器、AI加速器等特殊环境。

不过,未来会发生什么还不得而知。

缓存的工作原理

接下来,让我们解释一下缓存的工作原理。

(缓存直接映射图)

缓存行(Cache Line)

缓存行是缓存存储和管理数据的最小单位。当CPU从内存中获取数据时,不是以单个字节或字为单位获取,而是以固定大小的数据块(缓存行)为单位一次性获取。通过这种方式,提高了内存访问效率,并利用数据局部性(Locality)优化性能。

在现代系统中,缓存行通常为64字节(即512位)。在过去,也有使用16字节或32字节的情况,但近年来64字节已成为标准。

缓存行不仅包含数据,还包括元数据(标签、有效位、脏位等)。

缓存行的结构

缓存行在缓存中作为一个"槽"或"条目"存储,包含以下信息:

  • **数据(Data):**从主内存中获取的实际数据块(例如:64字节)。不仅包括CPU请求的数据,还包括周围的数据(利用空间局部性)。

  • **标签(Tag):**表示该缓存行引用的主内存地址信息,用于识别数据来自哪个内存位置。

  • **有效位(Valid Bit):**表示该缓存行是否包含有效的数据(0:空,1:有效)。

  • **脏位(Dirty Bit):**在写回(write-back)缓存中使用,表示缓存行中的数据已被修改但尚未与主内存同步。

  • **其他元数据:**包括用于缓存替换策略(如LRU,最近最少使用)的时间信息、缓存一致性状态(MESI协议中的Modified、Exclusive、Shared、Invalid等)。

缓存行的操作原理

缓存行决定了CPU访问内存时如何获取和管理数据。

以下是操作过程的逐步说明:

1. 缓存命中(Cache Hit)与未命中(Cache Miss)

当CPU请求内存地址X的数据时,缓存会检查该地址所属的缓存行。

  • **命中:**如果请求的地址已经在缓存行中,则直接从缓存返回数据(快速访问)。

  • **未命中:**如果缓存行中没有,则从主内存中获取包含该地址及其周围数据的整个缓存行。


2. 数据预取(Fetching)

当发生缓存未命中时,CPU会以缓存行大小(例如64字节)为单位获取请求的数据。

例如:请求地址0x1000的数据时,会一次性获取0x1000~0x103F(64字节范围)的数据。

这是利用了空间局部性(spatial locality),假设相邻数据很快会被使用。


3. 缓存行存储

获取的缓存行存储在缓存的槽中。

  • **直接映射(Direct-Mapped):**每个内存地址映射到固定的缓存行位置。

  • **组相联(Set-Associative):**存储在多个缓存行(集合)中的一个,由替换策略(如LRU)决定。

  • **全相联(Fully Associative):**可以存储在缓存中的任何位置(现实中很少见)。

当缓存已满时,会替换现有缓存行(replacement)。


4. 写操作

  • 写命中(Write Hit):

    • **写直达(Write-Through):**同时更新缓存和主内存。
    • **写回(Write-Back):**仅更新缓存并设置脏位,稍后再同步到主内存。
  • 写未命中(Write Miss):

    • **写分配(Write-Allocate):**获取缓存行并在缓存中写入后更新。
    • **非写分配(No-Write-Allocate):**直接写入主内存而不使用缓存。

5. 缓存一致性

在多核CPU中,每个核心的缓存行可能引用相同的内存地址。

通过MESI协议等机制保持缓存行之间的数据一致性。


缓存的主要概念解释

局部性(Locality)

  • **时间局部性(Temporal Locality):**当重复使用相同数据时,缓存命中率会提高。

  • **空间局部性(Spatial Locality):**当顺序访问相邻内存地址时效率更高。

缓存未命中的三种类型

(图片来源:https://rocketcdn.me/what-is-a-cache-hit-ratio/)

1. 强制未命中(Compulsory Miss)

定义:首次访问数据时发生的缓存未命中,无法避免的必然未命中。也称为"冷未命中"或"首次引用未命中"。

原因:缓存为空时,或程序首次使用特定数据时,缓存中不存在该数据。

特点:主要发生在程序执行初期,数据一旦被加载到缓存中,这种类型的未命中就会减少。

示例 :程序启动时第一次读取数组int array[100]array[0]时,缓存中没有数据,因此发生强制未命中。之后访问array[0]时则为缓存命中。

减少方法:通过硬件预取器或软件预取数据,可以减少强制未命中。虽然无法完全消除,但可以改善初始加载速度。

2. 冲突未命中(Conflict Miss)

定义:即使缓存中有空间,但由于其他数据占据了相同的缓存行而导致的缓存未命中。也称为"映射未命中"。

原因:当缓存为直接映射(direct-mapped)或组相联(set-associative)时,多个内存地址映射到同一缓存行,导致冲突。

特点:与缓存大小无关,取决于缓存映射方式和数据访问模式。

示例:假设有4个缓存行(每行64字节)且为直接映射,地址0x1000和0x2000映射到同一缓存行(例如第0行)。先加载0x1000,再访问0x2000时,0x1000被驱逐(Eviction),再次访问0x1000时发生冲突未命中。

减少方法:使用2路、4路组相联缓存以减少冲突可能性;通过数据填充(Padding)调整内存布局以避免数据冲突;对齐数据结构以最小化冲突。

3. 容量未命中(Capacity Miss)

定义:当缓存大小不足以容纳所有活动数据(working set)时发生的缓存未命中。由于缓存容量不足,频繁使用的数据被驱逐(Eviction)并需要重新获取。

原因:程序的数据集大小超过缓存容量时,发生频率增加。

特点:直接依赖于缓存大小,与关联性或映射方式关系不大。

示例:在一个具有256KB L2缓存的系统中,反复遍历1MB数组时,前256KB加载到缓存中,但随着数组末尾的到达,超出的数据覆盖了先前的缓存行。当再次回到开头时,因缓存被驱逐的数据导致容量未命中。

减少方法:增加缓存大小(如L2、L3等)以缓解容量问题;通过改进数据局部性(例如块处理)减少工作集;选择高效算法以减少不必要的数据访问。

缓存未命中的三种类型通常被称为"3C模型"。

总缓存未命中率 = 强制未命中 + 冲突未命中 + 容量未命中。

通过这种方式,尽量减少缓存未命中是程序员能力的体现。

减少3C缓存未命中的方法示例

  • **强制未命中:**第一次访问不可避免的未命中 → 使用预取缓解。

  • **冲突未命中:**相同缓存行冲突 → 使用8路组相联 + 填充解决。

  • **容量未命中:**超过缓存容量 → 使用分块(Blocking)分割数据集。

非缓存友好代码

int array[1024][1024]; // 4MB(每个元素4字节)
for (int j = 0; j < 1024; j++) 
{ // 按列优先访问
    for (int i = 0; i < 1024; i++) 
    {
        array[i][j] = i + j; // 按列访问(非连续)
    }
}

缓存友好代码

int array[1024][1024];
for (int i = 0; i < 1024; i++) 
    { // 按行优先访问
    for (int j = 0; j < 1024; j++) 
    {
        array[i][j] = i + j; // 按行访问(连续)
    }
}

(C语言系列中的缓存友好代码编写)

1. 利用空间局部性的顺序访问

在二维数组中,C语言系列中按列(Column)访问会导致内存地址不连续,缓存行频繁更换。

例如,array[0][0]之后访问array[1][0]时,两者相差4096字节(1024*4),缓存行(64字节)无法充分利用相邻数据,导致容量未命中增加。

因此,按行访问时,array[i][j] -> array[i][j+1]仅相差4字节,可以利用空间局部性。

(不过,这是因为C语言按行存储数据。如果是Matlab等按列存储的语言,则应按列访问。)

非缓存高效代码

int array[4096][4096]; // 64MB
for (int i = 0; i < 4096; i++) 
{
    for (int j = 0; j < 4096; j++) 
    {
        array[i][j] *= 2;
    }
}

分块处理以提高缓存效率

int array[4096][4096];
const int BLOCK_SIZE = 64; // 与缓存行(64字节)和数组大小对齐
for (int ii = 0; ii < 4096; ii += BLOCK_SIZE) 
{
    for (int jj = 0; jj < 4096; jj += BLOCK_SIZE) 
    {
        for (int i = ii; i < ii + BLOCK_SIZE && i < 4096; i++) 
        {
            for (int j = jj; j < jj + BLOCK_SIZE && j < 4096; j++) 
            {
                array[i][j] *= 2; // 按64x64块处理
            }
        }
    }
}

2. 分块处理以减少容量未命中

将大数据集分成适合缓存大小的块以最小化容量未命中。

例如,64MB数组会超过L2(1MB)或L3(16MB)缓存,随着数组末尾的到达,最初加载的数据被驱逐并重新获取,导致容量未命中增加。

将数据分成64x64块(16KB)进行处理,每个块更有可能保留在缓存中。

非缓存高效结构

struct Data 
{
    int a; // 4字节
    int b; // 4字节
} data[1024];
    
for (int i = 0; i < 1024; i++) 
{
    data[i].a = i; // 可能映射到同一缓存行
    data[(i + 256) % 1024].b = i; // 高概率发生冲突
}

缓存高效结构

struct Data 
{
    int a; // 4字节
    int b; // 4字节
    char padding[56]; // 添加填充以匹配64字节缓存行
} data[1024];
for (int i = 0; i < 1024; i++) 
{
    data[i].a = i;
    data[i].b = i; // 连续访问,最小化冲突
}

3. 使用数据填充减少冲突未命中

在原始代码中,data[i]data[i+256]可能映射到相同的缓存行。通过将结构体大小填充到64字节,确保每个元素映射到唯一的缓存行。

这样可以改变访问模式为顺序访问,增强时间和空间局部性。

*数据填充(Data Padding):为了内存对齐和缓存性能优化,在数据间插入额外的空白空间(Padding),减少伪共享(False Sharing)。

此外,还有缓存阻塞、分块等示例,但由于篇幅限制,这里不再赘述。

无论如何,这些都是典型的示例,以下是一些编写缓存友好代码的提示。

缓存友好代码编写技巧

  • 顺序访问:按行处理数组以充分利用缓存行。
  • 调整数据大小:将工作数据集调整为适合缓存大小(L1:32KB,L2:256KB等)。
  • 填充:将结构体对齐到缓存行(64字节)边界以避免冲突。
  • 预取:使用显式或隐式预取来减少强制未命中。
  • 循环优化:设计嵌套循环,使内部循环访问连续内存。

此外,在选择算法时也需要注意。

例如,快速排序和归并排序的平均时间复杂度相同,

但快速排序具有缓存友好的访问模式,而归并排序由于需要额外的内存空间,可能导致缓存效率较低。

让常见情况更快(Make the common case fast)------David Patterson

正如我们之前所见,缓存并不仅仅是"快速内存"。

它是一个控制数据流动的预测系统,同时也是连接硬件与软件的桥梁和翻译者。

因此,作为程序员,我们肩负着将硬件与软件紧密连接的重要使命。

我们的任务不仅仅是编写代码,更是成为在硬件与软件之间架起桥梁的人,就像一个让两者流畅沟通的翻译者。

我们所做的,不仅仅是写代码,而是向硬件传递我们的想象,赋予它生命,让它理解我们的意图并与软件协同工作。

相关推荐
一个热爱生活的普通人12 分钟前
golang的切片(Slice)底层实现解析
后端·go
红目香薰22 分钟前
Trae——慧码速造——完整项目开发体验
后端
Vcats1 小时前
深入浅出:基于SpringBoot和JWT的后端鉴权系统设计与实现
java·spring boot·后端
~kiss~1 小时前
Rust~二刷异步逻辑
开发语言·后端·rust
SomeB1oody2 小时前
【Rust中级教程】2.7. API设计原则之灵活性(flexible) Pt.3:借用 vs. 拥有、`Cow`类型、可失败和阻塞的析构函数及解决办法
开发语言·后端·性能优化·rust
larance2 小时前
Flask 发送邮件
后端·python·flask
Aska_Lv2 小时前
从零到一写组件库-日志组件库
后端
ybq195133454312 小时前
javaEE-SpringBoot日志
java·spring boot·后端
PyAIGCMaster2 小时前
第二周补充:Go语言中&取地址符与fmt函数详解
开发语言·后端·golang