深入理解计算机内存——CPU 缓存实验
深入理解计算机内存——CPU 缓存
本科期间学习过计算机体系结构,对现有计算机的内存与缓存机制有一定理解,但一直没有针对性地在真实物理机器上做过速率实验。最近偶然发现了一本非常好的书——《What Every Programmer Should Know About Memory》,书中详细介绍了内存、缓存等硬件结构及其发展,以及这些硬件结构对性能的影响。为了进一步加深对缓存的理解,我决定参照书中的实验思路,在自己的机器上复现内存速率等测量,并根据实验结果结合书中的内容加以归纳总结。
环境:WSL2.0-Ubuntu
CPU:AMD Ryzen 5 5600X
1 CPU 缓存简介
与早期计算机相比,现代 CPU 核心频率的提升速度远快于内存与系统总线。原因并不在于制造更快 RAM 在技术上不可行,而在于经济性:同等速度的内存价格会高出数倍。由此产生了一个关键矛盾——CPU 运算极快,但访问主存(DRAM)很慢,导致大量时间浪费在等待数据上。
当程序的"工作集"(当前需要反复访问的数据与代码集合)较小、能长期驻留在更快的存储器中时,性能会很好;但一旦工作集超出小容量高速内存的容量,系统就不得不把部分数据换出到更慢的二级存储(例如硬盘)。硬盘的访问速度通常比 DRAM 慢几个数量级,性能会因此明显崩塌。这说明我们需要一种折中方案:既利用大容量 DRAM,又尽量减少对 DRAM 的直接访问。
一种思路是使用少量高速 SRAM,让其充当"寄存器集的扩展"。但这种做法很难完全可行:高速内存的物理资源映射、进程级管理分配,以及模块间的同步与分割开销,都会抵消其收益。更合理的做法是:让高速存储不再由操作系统或应用显式管理,而是由处理器透明地用作缓存——当 CPU 即将使用某些数据时,把它临时保存在缓存里。缓存之所以有效,根本在于程序具有局部性:
- 时间局部性(temporal locality):同一数据在短时间内很可能会被再次访问。
- 空间局部性(spatial locality):相邻或附近的数据在短时间内也很可能被一起访问。
这种局部性使得"把主存的一小部分复制到更快的位置"能带来巨大收益。举例来说:若访问主存需要 200 个周期,访问缓存只需 15 个周期,那么在某个程序访问 100 个数据元素、且每个元素被反复访问 100 次的理想情况下,不使用缓存会造成约 2,000,000 个周期的内存等待;若所有数据都能命中缓存,则只需约 168,500 个周期,效率提升约 91.5%。
现实中缓存容量远小于主存,工作集往往无法完全落入缓存。这迫使系统依赖一系列策略来决定缓存内容如何被替换、何时预取、以及如何隐藏延迟。通过把不常用的数据暂时替换出去、甚至在真正需要之前提前加载,缓存看起来会比实际容量更"有效"。这些技术将在后文继续讨论,最终也需要程序员在一定程度上协助 CPU 更好地利用缓存。
从架构角度看缓存:多级缓存与缓存一致性
在最小的缓存配置中,CPU 核心并不直接与主存通信,所有读写都必须经由缓存完成。现代处理器通常采用多级缓存:例如 L1(一级缓存)中既有数据缓存(L1d)也有指令缓存(L1i),再到 L2、L3 等更大但更慢的层级。这样做是为了在性能与成本之间取得平衡:单纯扩大某一级缓存,会因经济成本和物理布线延迟而不可行。
此外,现代 CPU 常见多核与多线程设计:不同核心拥有几乎独立的硬件资源副本,但共享同一内存视图。为保证"各处理器看到的内存结果一致",缓存必须实现**缓存一致性(cache coherence)**机制。当某个核心修改了某个缓存行后,其他核心上对应数据的缓存副本需要被标记失效,或触发数据获取。基于这种思想,系统发展出多种一致性协议,其中最重要的之一是 MESI 协议(后文会进一步解释其规则与成本)。
缓存是如何工作的:行(cache line)、标签与命中
对 CPU 而言,默认情况下对内存的读写都会经过缓存。每条缓存记录保存的并不是"单个字",而是一整个缓存行(cache line),因为利用空间局部性更符合硬件与内存传输的效率。典型缓存行大小从早期的 32 字节演进到如今的 64 字节;如果内存总线为 64 位,那么一条缓存行需要多次传输才能装满。
当 CPU 访问某个地址时,缓存会根据地址中的部分信息(如缓存行偏移、缓存组索引、以及标签)在缓存中寻找匹配的条目;匹配成功称为缓存命中,否则为缓存未命中。未命中时,需要从更高层级的缓存甚至主存加载缓存行,并可能引发驱逐(替换),把旧缓存行推入下一层,直到最终写回主存(若该行是"脏"的,即已被修改但尚未写回)。
性能代价:命中很便宜,未命中很昂贵
缓存命中与未命中的成本差异非常大。即使部分代价可被 CPU 流水线并行与隐藏(例如提前发起内存加载指令、与其他指令重叠执行),性能仍高度取决于工作集大小能否落在合适的缓存层级中。
如果工作集能完整容纳在 L1d 中,平均开销较低;一旦超出 L1d,就需要更多地从 L2 加载,平均周期数会显著上升;当继续超出 L2 容量后,成本会进一步跳升到更高数量级,并且脏数据还会产生额外的写回开销。与此同时,加载延迟也可能难以完全隐藏,例如因为资源不足或加载地址尚未确定。
总体而言,CPU 缓存通过利用代码与数据的时间/空间局部性,将昂贵的主存访问次数尽量压缩到最低,从而把"等待数据"从瓶颈变为可控的成本。但由于缓存容量有限,仍需要依赖替换、预取、以及缓存一致性等机制来维持性能;而当这些机制被充分利用之后,程序员的代码组织方式(局部性友好、减少不必要的数据访问与冲突)仍然是决定最终性能上限的重要因素。
2 CPU 缓存实验
2.1 缓存延迟
通过指针追逐(pointer chasing)来测试 CPU 缓存延迟:下一次访问的地址存储在当前访问的内存数据中。CPU 必须完全解码并拿到当前指针指向的内容,才能计算出下一个加载地址,形成严格的数据依赖。工作集内每个 cacheline 放一个节点,节点内存储下一个节点的随机下标,从 nodes[0] 开始沿指针链遍历——每次访问都依赖上一次的结果,无法被硬件预取器预测,测得的正是真实的访存延迟。测量使用 2MB 大页分配,并在每次 trial 前 touch 整个工作集,取多次 trial 的 min(而非 mean)以滤除 WSL2 的调度噪声。代码很简单,用上一次的链表指针构造下一次的链表指针即可:
for(size_t it=0;it<iterations;++it){p=*reinterpret_cast<uintptr_t*>(p);}AMD 5600X 的 CPU 有三级缓存,分别为 32KB、512KB、32MB。从下面的测量结果可以看到,在工作集大小接近各级缓存容量边界时,读取延迟出现明显的阶梯式上升,直观地展示了缓存未命中带来的性能惩罚。
2.2 随机访问延迟
上面的指针追逐测试因存在严重的数据依赖,导致 CPU 无法最大化利用流水线并行。随机访问时指令间的并行度要高很多,部分延迟可以被吞吐隐藏。因此测出的数据明显优于指针追逐,同时保持了阶梯状的访问耗时变化。
for(size_t it=0;it<iterations;++it){for(size_t j=0;j<n;++j)sink+=data[idx[j]];}需要注意两点:
- WSL2 下 16MB 工作集延迟在 42–56cy 间波动,mean 会被噪声污染到 135cy 级。
- 5600X 的硬件预取器会吸收所有顺序/固定 stride 访问(全程 ~5.6–9cy 平线),因此顺序访问测不出缓存层级。
2.3 带宽
带宽基准测的是内存子系统能搬多少数据——使用 STREAM 风格的四个操作(read 纯读、write 纯写、copy 读源写目标、triad 两读一写),在 128MB 工作集(远超 32MB L3,确保流量落到 DRAM)下,测量 1~8 线程的吞吐量(GB/s),线程绑定在物理 CPU 核上。结果揭示了三个层面的信息:
一是各操作的固有成本。read 单线程 17.0 GB/s、write 16.4、copy 10.9、triad 8.5——因为 read 只搬 1 倍数据、write 1 倍(但写 DRAM 要先读旧行,受写总线限制)、copy 2 倍、triad 3 倍,搬运量翻倍带宽就减半。按同一基数比较,顺序符合直觉:读最快、写次之、copy 半速、triad 最低。
二是读与写的扩展性差异。read 从 1→8 线程从 17 涨到 42.6 GB/s(占双通道 DDR4 峰值 51.2 的 83%),接近线性;而 write 只从 16.4 涨到 20.1(仅 +23%),copy/triad 更是几乎不随线程增长。原因在于:读可以被多核并行把加载队列打满去喂总线,而写通道(写回 DRAM 的带宽)是共享瓶颈,多核一起写反而互相挤占,收益封顶。
三是瓶颈归属。单线程 17 GB/s 远低于总线峰值,因为单核受"延迟×MLP"限制(最多 ~6–10 个未完成加载,DRAM 延迟 ~80ns);只有多核协作才能逼近总线真实带宽。所以这张图同时量化了三个层级:单核延迟受限、多核带宽受限、以及写比读更难扩展。
for(intti=0;ti<t;++ti){pool.emplace_back([&,ti](){membench::SetAffinity(cpus[ti%hw]);constsize_t chunk=n/static_cast<size_t>(t);constsize_t begin=static_cast<size_t>(ti)*chunk;constsize_t end=(ti==t-1)?n:begin+chunk;while(!go.load(std::memory_order_acquire)){}uint64_tlocal=0;if(mode=="read"){for(size_t i=begin;i<end;++i)local+=static_cast<uint64_t>(a[i]);}elseif(mode=="write"){for(size_t i=begin;i<end;++i)a[i]=3.0;}elseif(mode=="copy"){for(size_t i=begin;i<end;++i)b[i]=a[i];}else{// triadfor(size_t i=begin;i<end;++i)c[i]=a[i]+b[i];}sink.fetch_add(local,std::memory_order_relaxed);});}2.4 缓存行(Cache Line)
缓存行基准测的是单次访问步长(stride)变化时,每次访问的延迟和整体吞吐如何改变,用来量化缓存行对访问效率的影响。它在 32MB 数组上按 stride=8/16/32/64/128/256 字节步进顺序读取全部元素,即 stride=8 时每个元素都访问(同一行 8 次,满载),stride=256 时每行只取 1 次(跳 4 行取 1 字节,稀疏)。数据揭示了三个效应:
一是延迟随步长单调上升。每次访问的延迟从 8B 的 1.97cy 涨到 128B 的 13.4cy。步长越大,一次访问触及的新 cacheline 越多,越依赖缓存/内存往返;8B 时因为连续字节共享同一条缓存行(L1 命中、预取加持),延迟最低。
二是吞吐在 64B 处出现明显台阶。GB/s 从 8B 的 15 单调爬到 32B 的 23、64B 的 32,再平缓到 128B 的 35。64B 正是本机缓存行大小——步长达到 64B 后每行恰好取满一次,能最大化每行 64 字节的搬运效率;再加大步长只增加延迟却不提升吞吐,因为每行利用率已达 100%。
三是 256B 处吞吐异常跳升。吞吐跳到 74 GB/s(是 64B 的两倍多),但这并非缓存效应,而是 5600X 硬件预取器的功劳——256B 步长形成了跨行的顺序模式,预取器提前把整条流式数据拉入缓存,让每次访问都命中,吞吐被预取到接近缓存带宽。因此,大 stride(≥256B)的吞吐被预取器抬高,并不代表真实的访存带宽。
for(size_t it=0;it<iters;++it){for(size_t i=0;i<accesses;++i)sink+=data[i*stride];}2.5 伪共享(False Sharing)
伪共享基准测的是两个线程共享同一个缓存行时性能如何崩塌,用来展示缓存一致性协议在写共享数据时的代价。它让两个线程各绑定到一个超线程兄弟核(共享 L1)上,对a、b两个计数器各做 1e7 次自增:
- none:
a、b相邻(共享同一 cacheline)→ 触发伪共享。 - 64B:
a、b各自alignas(64)对齐 → 无冲突。
2.6 线程扩展(Thread Scaling)
线程扩展基准测的是一个内存密集型任务(128MB 求和)随线程数增加性能能提升多少,用"加速比"和"效率"两个指标回答"多线程到底值不值"。它让 N 个线程并行处理同一份工作负载,测完成时间,再换算成相对单线程的加速比(加速比=单线程耗时/该线程数耗时)和效率(效率=加速比/线程数)。数据显示了三段式规律:
1→2 线程:接近完美的线性扩展。加速比 1.94,效率 97%——两个线程基本各自干活互不干扰,是理想的 Amdahl 情况。
2→4 线程:仍良好但有损耗。加速比 3.39,效率降到 85%——开始出现共享资源争抢(内存带宽、L3),但整体收益依然显著。
4→8 线程:加速比不升反降,效率崩到 40%。8 线程(3.21)反而比 4 线程(3.39)更慢!原因有三:本机是 6 核 12 线程,第 7 个线程起是超线程虚核(共享物理核的执行资源,不增加真实算力);加上 WSL2 的调度开销;以及内存密集型负载早已饱和内存带宽,再多线程只是让更多核抢同一条总线。这也说明:当负载已经触顶内存带宽时,多线程反而达不到理论带宽。
for(intti=0;ti<t;++ti){pool.emplace_back([&,ti](){membench::SetAffinity(cpus[ti%hw]);constsize_t chunk=n/static_cast<size_t>(t);constsize_t begin=static_cast<size_t>(ti)*chunk;constsize_t end=(ti==t-1)?n:begin+chunk;while(!go.load(std::memory_order_acquire)){}doublelocal=0.0;for(size_t i=begin;i<end;++i)local+=data[i];result.fetch_add(local,std::memory_order_relaxed);});}2.7 TLB
TLB 基准测的是工作集大小增长时地址翻译开销如何攀升,用来揭示 TLB(快表)的容量边界,以及"大页 vs 普通页"的价值。它使用固定 4KB 小页,工作集从 32KB 到 64MB 做随机指针追逐,延迟的每次跳升都对应一层 TLB 被撑爆。数据显示了四级台阶:
- 32KB:3.4cy,L1 TLB 命中。32KB÷4KB=8 个页面,轻松装进 L1 TLB(本机 64 项),翻译近乎免费。
- 64KB~1MB:12→19cy,L1 TLB 溢出,L2 TLB 兜底。工作集翻过 L1 TLB 容量后,翻译要到 L2 TLB(二级快表),延迟跳到 12cy,并在 1MB 内缓慢爬升到 19cy。
- 2MB~8MB:29→44cy,L2 TLB 也溢出,开始页表遍历。超过 L2 TLB 可覆盖的页面数后,每次翻译要多次访问内存查页表,延迟随工作集线性恶化。
- 16MB~64MB:76→140cy,深度页表遍历,DRAM 主导。翻译本身就要多次访问内存,访问延迟从缓存的 40cy 级暴涨到 140cy。
可见小页下 TLB 只能覆盖很小的工作集:1MB 之后翻译开销就超过缓存访问本身,16MB 后翻译成本直接翻倍。
3 缓存未命中与内存访问性能
从上面的实验能够看出缓存未命中(Cache Miss)对现代处理器性能的影响。当程序访问的数据不在当前缓存层级中时,需要从更低级缓存甚至主存中获取数据,而不同存储层级之间存在数量级的延迟差异。因此,理解缓存未命中的影响因素,对于编写高性能程序非常重要。
3.1 缓存层级与内存带宽
处理器访问数据的性能高度依赖于数据所在的缓存层级。通常情况下:
- L1 Cache 命中:访问速度最快,可以达到每周期处理多个字节甚至多个缓存行。
- L2 Cache 命中:延迟增加,但仍远快于主存。
- L3 Cache 命中:由于共享缓存以及更大的容量,速度进一步下降。
- 主存访问:需要经过内存控制器和总线,延迟最高。
实际测试表明,当工作集(Working Set)完全位于 L1 数据缓存(L1d)中时,处理器可以达到理论峰值带宽。例如部分处理器可以每周期加载一个完整缓存宽度的数据。但是,当工作集超过 L1 Cache 容量后,性能会明显下降。原因包括:
- 缓存容量不足:数据无法全部驻留在高速缓存中,需要不断进行缓存行替换。
- TLB 缺失:当访问的数据跨越大量内存页时,地址转换缓存(TLB)可能耗尽,需要额外访问页表。
- 内存带宽限制:当数据无法由缓存提供,而必须从内存读取时,性能受到内存总线和内存控制器限制。
不同处理器架构的表现存在明显差异。例如:
- 较老的 Intel NetBurst 架构中,写入采用 Write Through 模式,导致写性能明显低于读取性能。
- Intel Core 架构采用 Write Back 缓存策略,写入数据首先进入缓存,因此写性能更接近理论峰值。
- AMD Family 10h 架构拥有独立 L2 和共享 L3,不同缓存层级之间存在明显性能差异。
因此,程序优化不能只关注计算量,还必须考虑数据访问模式以及数据是否能够保持在高速缓存中。
3.2 缓存行与关键字优先加载
处理器访问内存时,并不是以单个变量为单位,而是以**缓存行(Cache Line)**为单位加载数据。目前主流处理器缓存行大小通常为 64 字节。当发生缓存未命中时,整个缓存行需要从内存加载。然而,程序真正需要的数据可能并不是缓存行中的第一个字节。例如:
Cache Line (64 Bytes) +----+----+----+----+----+----+----+----+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +----+----+----+----+----+----+----+----+ 程序需要访问第 7 个元素如果处理器按照顺序加载缓存行,则必须等待前面的数据传输完成,增加额外延迟。为降低这种影响,现代处理器采用:
Critical Word First(关键字优先):即优先请求当前指令马上需要的数据。
Early Restart(提前重启):当关键数据到达后,处理器立即恢复执行,而缓存行剩余部分继续加载。
这种机制能够隐藏部分缓存未命中延迟。但是,缓存行内部数据位置仍可能影响性能。例如:
- 顺序访问通常更容易被硬件预取器优化。
- 随机访问更容易暴露缓存行加载延迟。
- 如果关键数据位于缓存行末尾,可能产生额外等待。
因此,在设计数据结构时,应尽量提高访问局部性,使热点数据靠近缓存行前部。
3.3 多核处理器中的缓存共享影响
现代多核 CPU 中,不同核心之间存在复杂的缓存拓扑。不同处理器设计存在差异:
- 早期多核处理器:核心之间没有共享缓存。
- Intel Core 早期架构:多个核心共享 L2 Cache。
- AMD Family 10h:每个核心拥有独立 L2,共享 L3 Cache。
共享缓存具有优势:
- 多个线程访问相同数据时,可以减少数据复制。
- 增大有效缓存容量。
- 提高线程之间的数据共享效率。
但共享缓存也带来竞争:
缓存容量竞争:多个核心同时使用共享缓存,会导致缓存行频繁驱逐。
缓存一致性开销:多核心修改同一缓存行时,需要通过缓存一致性协议同步。
False Sharing(伪共享):不同线程修改同一个缓存行中的不同变量,也会造成缓存同步开销。
因此,多线程程序设计需要考虑线程绑定、数据划分以及缓存拓扑。
3.4 内存总线对性能的影响
当工作集超过缓存容量后,性能最终受到内存系统限制。数据传输路径:
CPU Core | Cache | Memory Controller | Memory Bus | DRAM其中内存总线带宽决定了处理器从主存获取数据的速度。提高内存频率能够明显提升大规模数据访问性能,但是这一操作并总是可行的,对于移动设备为了控制功耗不会随意修改DDR频率。
- 当数据完全位于缓存中时,内存速度影响较小。
- 当工作集超过缓存容量时,更快的内存和更高的总线频率能够显著提升性能。
例如,提高 DDR 内存频率可以带来接近理论比例的性能提升。
因此,对于:
- 大规模矩阵计算
- 图像处理
- 科学计算
- 数据分析
等内存带宽敏感型程序,仅提高 CPU 频率并不能解决性能瓶颈,需要同时优化:
- 内存带宽
- 数据布局
- 缓存利用率
- 访问模式
小结
缓存能大幅提升性能,本质上靠的是程序的局部性:时间局部性让同一数据被反复命中,空间局部性让一条缓存行被充分利用。但缓存的容量终究有限,工作集一旦超出某一层级,性能就会迎来一次明显的"台阶式"下跌。本文的实验正是用数据量化了这些台阶。
纵观七个实验,可以得到几条核心结论:
- 延迟(2.1、2.2):指针追逐测出了真实的访存延迟阶梯——32KB 内命中 L1d、512KB 内命中 L2、32MB 内命中 L3,每一级跳升都对应一次缓存未命中。随机访问因指令级并行掩盖了部分延迟,数值更好看,但阶梯依旧清晰。
- 带宽(2.3):read、write、copy、triad 的搬运量依次翻倍,吞吐也依次减半;读可以靠多核打满加载队列近线性扩展,而写回 DRAM 是共享瓶颈,几乎无法扩展。单核远达不到总线峰值,本质是受"延迟×MLP"限制。
- 缓存行(2.4):步长到 64B(缓存行大小)时每行利用率达到 100%,吞吐封顶;而 256B 的吞吐跳升是硬件预取器造成的假象,并不代表真实访存带宽。
- 伪共享(2.5):两个线程写同一缓存行的不同变量,也会因一致性协议付出代价;用
alignas(64)对齐即可消除。 - 线程扩展(2.6):内存密集型负载在 2 线程内近线性扩展,4 线程时已受带宽争抢拖累,8 线程(超线程虚核)反而更慢——多线程未必能换来吞吐。
- TLB(2.7):小页下 TLB 覆盖不了大工作集,1MB 后翻译开销就超过缓存访问本身,16MB 后延迟直接翻倍;这也是大页存在的意义。
把这些结论落到实际编程上,可以概括为四条原则:一是保持访问局部性,让热点数据紧凑存放,尽量顺序访问以利用预取器;二是量化感知工作集大小,让数据尽量落入更快的缓存层级,必要时用大页缓解 TLB 压力;三是避免伪共享,多线程共享的可变数据按缓存行对齐或分区;四是别迷信多线程,内存密集型负载的瓶颈往往是带宽而非算力,先确认负载性质再决定线程数。
总的来说,CPU 缓存并不是一个黑盒:它的容量边界、延迟阶梯与带宽上限都可通过实验精确测量。理解了这些硬件规律,"等待数据"就不再是不可控的瓶颈,而成了可以预测、可以优化的成本。
