深入理解计算机内存——CPU 缓存实验
深入理解计算机内存——CPU 缓存本科期间学习过计算机体系结构对现有计算机的内存与缓存机制有一定理解但一直没有针对性地在真实物理机器上做过速率实验。最近偶然发现了一本非常好的书——《What Every Programmer Should Know About Memory》书中详细介绍了内存、缓存等硬件结构及其发展以及这些硬件结构对性能的影响。为了进一步加深对缓存的理解我决定参照书中的实验思路在自己的机器上复现内存速率等测量并根据实验结果结合书中的内容加以归纳总结。环境WSL2.0-UbuntuCPUAMD Ryzen 5 5600X1 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 it0;ititerations;it){p*reinterpret_castuintptr_t*(p);}AMD 5600X 的 CPU 有三级缓存分别为 32KB、512KB、32MB。从下面的测量结果可以看到在工作集大小接近各级缓存容量边界时读取延迟出现明显的阶梯式上升直观地展示了缓存未命中带来的性能惩罚。2.2 随机访问延迟上面的指针追逐测试因存在严重的数据依赖导致 CPU 无法最大化利用流水线并行。随机访问时指令间的并行度要高很多部分延迟可以被吞吐隐藏。因此测出的数据明显优于指针追逐同时保持了阶梯状的访问耗时变化。for(size_t it0;ititerations;it){for(size_t j0;jn;j)sinkdata[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(intti0;tit;ti){pool.emplace_back([,ti](){membench::SetAffinity(cpus[ti%hw]);constsize_t chunkn/static_castsize_t(t);constsize_t beginstatic_castsize_t(ti)*chunk;constsize_t end(tit-1)?n:beginchunk;while(!go.load(std::memory_order_acquire)){}uint64_tlocal0;if(moderead){for(size_t ibegin;iend;i)localstatic_castuint64_t(a[i]);}elseif(modewrite){for(size_t ibegin;iend;i)a[i]3.0;}elseif(modecopy){for(size_t ibegin;iend;i)b[i]a[i];}else{// triadfor(size_t ibegin;iend;i)c[i]a[i]b[i];}sink.fetch_add(local,std::memory_order_relaxed);});}2.4 缓存行Cache Line缓存行基准测的是单次访问步长stride变化时每次访问的延迟和整体吞吐如何改变用来量化缓存行对访问效率的影响。它在 32MB 数组上按 stride8/16/32/64/128/256 字节步进顺序读取全部元素即 stride8 时每个元素都访问同一行 8 次满载stride256 时每行只取 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 it0;ititers;it){for(size_t i0;iaccesses;i)sinkdata[i*stride];}2.5 伪共享False Sharing伪共享基准测的是两个线程共享同一个缓存行时性能如何崩塌用来展示缓存一致性协议在写共享数据时的代价。它让两个线程各绑定到一个超线程兄弟核共享 L1上对a、b两个计数器各做 1e7 次自增nonea、b相邻共享同一 cacheline→ 触发伪共享。64Ba、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(intti0;tit;ti){pool.emplace_back([,ti](){membench::SetAffinity(cpus[ti%hw]);constsize_t chunkn/static_castsize_t(t);constsize_t beginstatic_castsize_t(ti)*chunk;constsize_t end(tit-1)?n:beginchunk;while(!go.load(std::memory_order_acquire)){}doublelocal0.0;for(size_t ibegin;iend;i)localdata[i];result.fetch_add(local,std::memory_order_relaxed);});}2.7 TLBTLB 基准测的是工作集大小增长时地址翻译开销如何攀升用来揭示 TLB快表的容量边界以及大页 vs 普通页的价值。它使用固定 4KB 小页工作集从 32KB 到 64MB 做随机指针追逐延迟的每次跳升都对应一层 TLB 被撑爆。数据显示了四级台阶32KB3.4cyL1 TLB 命中。32KB÷4KB8 个页面轻松装进 L1 TLB本机 64 项翻译近乎免费。64KB~1MB12→19cyL1 TLB 溢出L2 TLB 兜底。工作集翻过 L1 TLB 容量后翻译要到 L2 TLB二级快表延迟跳到 12cy并在 1MB 内缓慢爬升到 19cy。2MB~8MB29→44cyL2 TLB 也溢出开始页表遍历。超过 L2 TLB 可覆盖的页面数后每次翻译要多次访问内存查页表延迟随工作集线性恶化。16MB~64MB76→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.3read、write、copy、triad 的搬运量依次翻倍吞吐也依次减半读可以靠多核打满加载队列近线性扩展而写回 DRAM 是共享瓶颈几乎无法扩展。单核远达不到总线峰值本质是受延迟×MLP限制。缓存行2.4步长到 64B缓存行大小时每行利用率达到 100%吞吐封顶而 256B 的吞吐跳升是硬件预取器造成的假象并不代表真实访存带宽。伪共享2.5两个线程写同一缓存行的不同变量也会因一致性协议付出代价用alignas(64)对齐即可消除。线程扩展2.6内存密集型负载在 2 线程内近线性扩展4 线程时已受带宽争抢拖累8 线程超线程虚核反而更慢——多线程未必能换来吞吐。TLB2.7小页下 TLB 覆盖不了大工作集1MB 后翻译开销就超过缓存访问本身16MB 后延迟直接翻倍这也是大页存在的意义。把这些结论落到实际编程上可以概括为四条原则一是保持访问局部性让热点数据紧凑存放尽量顺序访问以利用预取器二是量化感知工作集大小让数据尽量落入更快的缓存层级必要时用大页缓解 TLB 压力三是避免伪共享多线程共享的可变数据按缓存行对齐或分区四是别迷信多线程内存密集型负载的瓶颈往往是带宽而非算力先确认负载性质再决定线程数。总的来说CPU 缓存并不是一个黑盒它的容量边界、延迟阶梯与带宽上限都可通过实验精确测量。理解了这些硬件规律等待数据就不再是不可控的瓶颈而成了可以预测、可以优化的成本。