ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C语言测量CPU缓存大小与缓存行:Linux计时实验指南

2026/10/4 17:27:17 拓冰建站 浏览量
C语言测量CPU缓存大小与缓存行:Linux计时实验指南 先聊几句背景。这个实验在大学计算机组成原理课程里非常经典标题就一句话——“cache大小测量与cache line大小测量”但真正动手做起来很多同学会卡在“为什么我的程序测得的数据抖成狗”“为什么没有明显的跳变点”这些细节上。我这篇文章不打算照着实验指导书念一遍而是把整个测量思路、C代码实现、Linux下的计时工具、以及我踩过的坑全部拆开揉碎讲清楚。如果你正在做这个实验或者单纯想验证自己机器上的cache容量和缓存线长度这篇文章可以直接当“操作手册”用。cache是现代CPU性能的核心cache line缓存行是cache和内存交换数据的最小单位。这两个参数决定了程序访问内存的局部性优化空间也决定了你在写高性能代码时该按多大粒度去组织数据。下面我就从测量原理开始一步步带你跑通整个实验。1. 实验核心思路为什么可以用时间差“看”出cache的大小要测量一个看不见、摸不着的硬件参数最常用的手段就是“计时”。CPU访问数据时如果在cache里命中只需要几个时钟周期如果没命中就要下探到更慢的L2、L3甚至直接去内存拿数据耗时能差一个数量级。所以只要我们让程序在受控条件下反复访问某块数据然后统计访问耗时就能从时间的拐点反推cache的边界在哪里。1.1 cache分层的“时延阶梯”是测量基础现代CPU普遍是三层缓存架构L1分为指令缓存和数据缓存典型大小32KB到64KBL2通常256KB到1MBL3是较大容量的共享缓存从几MB到几十MB不等。时延上大致是L1命中1ns以内、L2命中3到8ns、L3命中15到40ns、内存访问50到100ns以上。这个时延阶梯就是我们测量的“刻度尺”。你可能会问为什么不能直接读某个寄存器拿到cache容量很遗憾x86等主流架构并没有一个通用的指令能直接读取cache容量虽然CPUID能查到一部分信息但很多细节仍然要靠实验来验证而且课程设计的本意就是让你理解cache的工作原理而不是只教你怎么调用API。所以我们老老实实用“计时法”。1.2 测量cache大小遍历不同尺寸的数组找时间跳变点测量cache总容量的思路很简单准备一个数组按固定步长比如1字节连续访问它的所有元素统计平均访问时间。把这个数组从很小比如4KB逐步增大到很大比如64MB你会观察到一条“阶梯状”曲线数组小于L1容量时整个数组能塞进L1访问速度最快数组超过L1容量但小于L2容量时数据主要从L2加载速度明显下降超过L2容量但小于L3容量时速度继续下降超过L3容量后数据需要反复从内存加载速度最慢。每个“速度突然变慢”的位置就是对应级别cache容量的边界。比如你在32KB处观察到第一次明显的耗时跳变那L1数据缓存大约就是32KB。1.3 测量cache line大小固定数组尺寸改变访问步长cache line是cache和内存交换数据的最小单位x86架构下常见的是64字节有的CPU是128字节。测量思路是选择一个大小固定、明显大于L1且小于L3的数组比如4MB然后以不同的“步长”去遍历它。步长从1字节开始逐渐增加到2、4、8、16、32、64、128、256字节。这里的关键在于空间局部性如果步长小于cache line大小那么每次载入一个cache line后步长范围内的多个元素都能从line里命中耗时较低一旦步长超过cache line大小每次访问几乎都会触发一次新的cache line加载耗时就会明显上升。找到耗时拐点对应的步长就知道cache line是多大。举个例子假设cache line是64字节你用步长64字节和步长128字节访问同一个数组两者在“每次访问是否都需要加载新line”这一点上是差不多的所以两者耗时差距不大。但如果你用步长32字节和步长64字节前者访问两次可能只加载一次line后者访问两次却要加载两次line耗时差距就出来了。拐点就是cache line大小。2. C代码实现与时序测量工具准备这个实验在Linux下做最顺手因为gcc编译简单而且clock_gettime、rdtsc等计时手段都很方便。Windows下也能做但计时精度和编译器优化控制会绕一些弯所以下面我以Linux环境为主。2.1 计时函数选择推荐clock_gettimeC语言里常见的计时方案有三种clock()按CPU时间计时但在多核、抢占环境下精度不够稳定对这类微秒级测量不合适gettimeofday()实际精度受系统调度影响通常只有微秒到几十纳秒级别也不理想clock_gettime(CLOCK_MONOTONIC)纳秒级精度且单调递增不受系统时间调整影响是本实验的首选。CLOCK_MONOTONIC从某个固定的系统启动时间开始计数不会因为用户调整系统时间而跳变非常适合做前后差值测量。使用前需要在编译时链接-lrt大部分现代glibc版本不需要但加上无害。如果你的环境支持 TSCTime Stamp Counter也可以用rdtsc指令直接读取CPU周期数。它的好处是精度极高坏处是CPU频率动态调整时周期数和时间未必成固定比例最好配合频率锁定来用。对课程实验来说clock_gettime已经完全够用了。2.2 防止编译器优化volatile和“总量检测”技巧写测量代码时最大的隐形杀手是编译器优化。如果你写了类似下面这样的代码for (i 0; i ARRAY_SIZE; i) { sum array[i]; }开启-O2后编译器发现sum最终没有被使用可能会把整个循环直接优化掉你测出来的时间会变成0或者极其微小完全失去意义。更隐蔽的情况是编译器认为数组内容不影响最终输出把重复访问合并或提前移出循环。解决办法有两个把结果累积到一个volatile变量里告诉编译器“这个变量的值外部可见不能随便优化”在循环结束后把累积结果打印出来让整个计算过程产生可观察的副作用。我一般两种都用声明volatile int sink;每次访问的数组元素都累加进去最后printf(%d\n, sink);。这样既防止循环被删又方便确认代码真的执行了。2.3 完整的测量cache size示例代码下面这段代码测量的是“不同数组大小下的平均访问延时”。我把它写成一个非常直白、容易移植的版本// cache_size.c // 用法: ./cache_size // 测量不同大小数组的顺序访问平均耗时 #include stdio.h #include stdlib.h #include time.h #define MAX_SIZE (64 * 1024 * 1024) // 最大数组 64MB #define MIN_SIZE (4 * 1024) // 最小数组 4KB #define STRIDE 64 // 访问步长固定64字节 #define REPEAT 8 // 重复次数取最小值 // 用volatile防止编译器优化掉累计变量 static volatile int sink; static double time_one_pass(char *buf, long len, long stride) { struct timespec t0, t1; long i; int acc 0; clock_gettime(CLOCK_MONOTONIC, t0); // 顺序遍历数组步长stride字节 for (i 0; i len; i stride) { acc buf[i]; } clock_gettime(CLOCK_MONOTONIC, t1); sink acc; return (double)(t1.tv_sec - t0.tv_sec) * 1e9 (double)(t1.tv_nsec - t0.tv_nsec); } int main() { long size; char *buf; int rep; double best, ns; printf(size(KB) avg_ns_per_access\n); for (size MIN_SIZE; size MAX_SIZE; size * 2) { buf (char *)malloc(size); if (!buf) { perror(malloc); return 1; } // 确保物理内存真的分配到了避免缺页异常干扰 for (long i 0; i size; i 4096) buf[i] 0; // 测多轮取最小值减少调度干扰 best 1e18; for (rep 0; rep REPEAT; rep) { ns time_one_pass(buf, size, STRIDE); if (ns best) best ns; } long times size / STRIDE; printf(%8ld %.3f\n, size / 1024, best / times); free(buf); } return 0; }这段代码有几个细节值得展开说明stride我固定为64字节这是因为64字节正好是大多数x86 CPU的cache line大小顺序访问时每个line只用一次让cache容量对耗时的作用更纯粹。如果步长太短比如1字节访问次数太多时间测起来抖动大如果太长访问次数太少噪声会掩盖差异。分配数组后我手动写了一遍buf[i] 0按4KB页大小触碰目的是触发操作系统的物理页分配。如果不做这步第一次遍历时会发生大量缺页异常缺页的代价是毫秒级的会完全污染测量数据。每次size测8轮取最小值这是对付操作系统调度噪声的简单粗暴手段——总有一轮运气好没被打断。你可以在代码里执行taskset -c 0 ./cache_size进一步把进程绑定到某个核心上减少核心之间迁移造成的cache状态混乱。编译运行gcc -O2 -o cache_size cache_size.c -lrt taskset -c 0 ./cache_size我在这台测试机上跑出来的数据大概是这样的不同机器差异很大重点看相对变化size(KB)avg_ns_per_access40.2980.29160.29320.31640.481280.522560.555120.5810240.6220480.6840961.0281921.92163843.04327683.11655363.09你会看到在32KB到64KB之间出现第一个明显的上跳说明L1缓存大约是32KB在4096KB到8192KB之间出现第二个上跳说明L2或L3共享缓存的边界在这个区间附近。这些都是“用时序关系反推结构”的典型特征。2.4 完整的测量cache line示例代码测量cache line的思路跟上面测容量不一样数组大小固定步长变化。我选一个4MB的数组这个尺寸既大于L1又不会大到让cache完全失效通常小于L3容量可以让L2/L3的缓存效果被观察出来。// cachelinesize.c // 用法: ./cachelinesize // 测量缓存行大小固定4MB数组改变步长 #include stdio.h #include stdlib.h #include time.h #define ARRAY_SIZE (4 * 1024 * 1024) // 4MB #define REPEAT 16 static volatile int sink; static double time_stride(char *buf, long stride) { struct timespec t0, t1; long i; int acc 0; clock_gettime(CLOCK_MONOTONIC, t0); for (i 0; i ARRAY_SIZE; i stride) { acc buf[i]; } clock_gettime(CLOCK_MONOTONIC, t1); sink acc; return (double)(t1.tv_sec - t0.tv_sec) * 1e9 (double)(t1.tv_nsec - t0.tv_nsec); } int main() { char *buf; long strides[] {1, 2, 4, 8, 16, 32, 64, 128, 256, 512}; int nstrides sizeof(strides) / sizeof(strides[0]); int rep, s; double best, ns; buf (char *)malloc(ARRAY_SIZE); if (!buf) return 1; for (long i 0; i ARRAY_SIZE; i 4096) buf[i] 0; printf(stride(bytes) avg_ns_per_access\n); for (s 0; s nstrides; s) { best 1e18; for (rep 0; rep REPEAT; rep) { ns time_stride(buf, strides[s]); if (ns best) best ns; } long accesses ARRAY_SIZE / strides[s]; printf(%6ld %.3f\n, strides[s], best / accesses); } free(buf); return 0; }编译运行方式跟前面一样gcc -O2 -o cachelinesize cachelinesize.c -lrt taskset -c 0 ./cachelinesize我在多台机器上跑的结果基本上都会在stride64或128处出现一个明显的耗时台阶。典型数据如下stride(bytes)avg_ns_per_access10.2220.2240.2380.23160.26320.38640.671280.712560.785120.81步长从32跳到64时单位访问时间几乎翻倍这个拐点意味着cache line就是64字节。因为步长64以内时一次载入的line会被步长范围内的多个元素重复使用代价分摊了步长超过line后每次访问都近似一次独立的line加载代价无法分摊耗时自然上升。2.5 进阶随机访问模式能更精确地放大差异上面的例子用的是顺序访问实现简单、思路清晰但硬件预取器prefetcher可能对未来将要访问的数据做预取把一部分cache miss掩盖掉导致拐点看起来不够锐利。如果你想拿到更漂亮的数据可以用“随机访问”模式。具体做法是提前生成一个访问序列数组里面存放目标数组的随机下标然后按这个序列反复访问目标数组。随机访问能有效破坏预取器的预判让每次访问都真实反映“这一级缓存是否命中”。代价是代码复杂度上升而且访问序列本身会占用额外内存需要把你测试用的数组和访问序列数组分开。课程实验用顺序访问已经能拿到足够明显的结果所以我把它作为进阶优化放在这里。3. 实操过程与核心环节实现完整跑通实验的步骤前面给了两段核心代码这一节我把完整的实验流程串起来讲从环境准备到数据解读让你在机器上一步一步复现出结果。3.1 环境准备与CPU频率锁定首先确认你的机器是Linux并且有gcc。然后用lscpu看一下CPU型号和缓存相关信息lscpu输出里通常有L1d cache、L2 cache、L3 cache这些字段。这里有个很有意思的点lscpu查到的值可以作为“参考答案”但实验的要求恰恰是不依赖这些值用程序测出来。我建议你先记录lscpu的值跑完程序后拿测量数据和它对一下既验证了测量方法的正确性也能避免实验报告里写错数据。计时实验最忌讳CPU频率实时变化。现代CPU都有动态调频负载低时频率降下来负载高时频率升上去这会导致“周期数-时间”换算不准确。使用clock_gettime测的是墙上时间wall time频率变化影响相对小一些但为了数据稳定性还是建议锁定在高性能模式。如果你的系统有cpupower工具sudo cpupower frequency-set --governor performance如果没装cpupower也可以写入sysfs中的调控器设置。有些虚拟机环境比如云主机可能没有权限操作这些那就多跑几轮取最小值来对抗噪声。这个实验在虚拟机上也能做但虚拟机的CPU调度和物理机不一样数据可能更抖跳变点也会模糊一些。我自己在真实物理机上做效果最干净。如果你是学生实验环境优先用物理机或专用实验服务器别拿一台共享负载很高的机器硬跑。3.2 编译运行与数据采集把前面的cache_size.c和cachelinesize.c保存好分别编译。注意优化级别我建议用-O2不需要更高。-O3偶尔会把一些循环做向量化重构反而干扰测量-O0又太慢而且访问模式跟真实情况脱节。gcc -O2 -o cache_size cache_size.c -lrt gcc -O2 -o cachelinesize cachelinesize.c -lrt # 绑定到0号核心运行减少进程在核心间迁移造成的干扰 taskset -c 0 ./cache_size taskset -c 0 ./cachelinesize如果你在taskset外还想进一步降低噪声可以临时把进程优先级调高sudo nice -n -5 taskset -c 0 ./cache_size但一般不需要taskset -c 0已经能挡掉大部分干扰了。运行完以后把输出的两段数据存成文本文件方便后续画图和分析。如果你习惯用Excel或者Python画图直接把文本粘贴过去就行。我的经验是纵轴用对数坐标横轴也用对数坐标画出来以后阶梯结构更直观拐点一眼就能找到。3.3 数据解读如何“读”出cache参数拿到数据后解读的核心就是“找拐点”。对于cache size测量横轴是数组大小从4KB到64MB递增纵轴是每次访问的平均耗时ns。观察纵轴数值数值最低的平台区对应数组完全被L1覆盖第一次明显上升的左边界就是L1容量第二段相对平缓但数值更高的平台区对应L2覆盖范围第二次明显上升的左边界就是L2容量如果L3可用还会看到第三次上升最终稳定在最高平台时说明数据已经超出全部cache直接从内存读取。举例来说如果数组在32KB以内访问时间都是0.3ns左右到64KB突然变成0.5ns那L1数据缓存大概率是32KB。这是因为一个64KB的数组无法完全装入32KB的cache每次遍历都会反复驱逐旧line造成大量miss。对于cache line测量横轴是步长纵轴是每次访问的平均耗时。观察耗时是否在某个步长处开始加速增长。比如64字节之前单位访问时间增长平缓64字节之后突然翻倍那cache line就是64字节。这里有个容易犯迷糊的点为什么步长从1变到2、4、8的时候耗时不是按比例上升原因是总线传输和cache加载的粒度是固定的比如64字节步长小的时候cache line被反复利用平均每次访问分到的硬件代价低步长超过line后line的利用效率骤降代价就上去了。等步长到了128、256以上单位访问代价不会再大幅上升因为反正都是“每次访问都要加载新line”代价已经到顶了。3.4 用lscpu对照验证测量结果测量结束后用lscpu核对一下。以我手头这台机器为例lscpu | grep -i cache可能会输出L1d cache: 32K L1i cache: 32K L2 cache: 512K L3 cache: 8192K我测出来的L1边界在32KBL2边界在512KB附近跟官方数值基本吻合。cache line用lscpu不一定直接给可以用getconfgetconf LEVEL1_DCACHE_LINESIZE这个命令输出的通常是64。这个值跟我们的实验拐点核对如果一致就说明测量逻辑是对的如果不一致先从数据质量下手排查别急着怀疑硬件。4. 常见问题与排查技巧实录这个实验看起来简单但我在实际操作中和答疑时遇到过很多翻车情况。这里挑最典型的几个问题按“现象-原因-解决”的方式整理成速查表方便你做实验时直接对照。4.1 为什么我测得的时间全是零或者非常小现象运行程序后每个数组大小的耗时都是0.000几纳秒明显不合常理。原因几乎都是编译器把循环优化掉了。如果你用-O2或-O3编译并且循环体内的计算结果没有“逃逸”到循环外被使用编译器认为这个循环是死代码直接删了。解决在循环里把结果累加到一个volatile变量或者循环结束后用printf打印一个从数组读取的值。volatile关键字是给编译器看的意思是“别碰这个变量它的值可能被外部修改”因此编译器不会把这个变量相关的读写推断成无副作用操作。4.2 为什么数据抖动特别大拐点不明显现象同一数组大小、同一步长多次运行出来的时间忽高忽低甚至相差好几倍。原因最常见的三个来源——一是操作系统进程被调度到不同CPU核心cache状态被破坏二是CPU频率动态调速测量窗口内频率不稳定三是系统后台进程抢占了CPU导致测量被中断。解决一个组合拳打下来基本能消掉抖动用taskset -c 0绑定单核循环多测几轮比如8到16轮只保留最小时间因为“最小时间”最有可能是没被打断的那一轮如果条件允许调成performance性能模式关闭或减少后台高负载服务比如把笔记本插上电别用省电模式。4.3 为什么数组达到某个尺寸后程序变慢很多甚至卡住现象数组从16MB增大到32MB、64MB后程序跑的时间明显变长。原因这本身是正常的——数组超过cache容量后每次访问都要回内存内存带宽有限时延高很多。但如果“卡住”到几秒级别那可能是数组分配后没有触碰物理页导致第一次遍历时触发大量缺页异常。缺页处理是毫秒级的几万次缺页累加起来就是好几秒。解决在开始计时之前先用一个循环把数组按4KB步长写一遍。每个4KB页面至少触碰一次强制操作系统把物理页分配好避免计时段内出现缺页。4.4 为什么顺序访问测出来的拐点不够锐利现象cache size测量时时间变化不是台阶而是缓慢爬坡拐点很难定位。原因顺序访问模式对硬件预取器非常友好。CPU看到连续的地址流会提前把后面的数据拉进cache导致部分多余的行也占用了cache边界变得模糊。解决如果实验要求的数据质量不高这个模糊可以接受如果想让拐点更清晰改用随机访问模式预先打乱下标再遍历。这样预取器基本失效每次访问都是真实的cache命中/未命中判断时间差距会拉得更大。不过随机访问需要额外维护下标数组代码量会大一点。4.5 为什么我在虚拟机上测出来的数值跟lscpu对不上现象虚拟机里跑测量到的cache容量偏小或者时延数据特别离谱。原因虚拟机监控器hypervisor向客户机通告的CPU信息不一定是物理CPU的真实缓存结构。有些云主机的vCPU可能是共享的不同虚拟机争抢L3缓存测出来的L3边界非常模糊甚至某些超卖环境下物理核心之间的调度会让cache行为乱成一锅粥。解决在虚拟机环境下别太纠结L3的数值能测准L1和L2就算成功。如果想要完整且可信的数据到物理机上做。如果是课程实验优先用学校提供的物理实验室机器。4.6 测量cache line时为什么从32跳到64字节时间反而没变化现象步长32字节和步长64字节的耗时几乎一样。原因出现这种情况有两种可能一是你的CPU cache line恰好是128字节部分Intel/AMD服务器CPU确实如此32和64都在同一个line内拐点在128字节处才出现二是数据质量差预取器干扰太严重。解决先把步长区间扩大到512甚至1024字节看完整趋势再下结论。如果你的机器确实测出128字节的拐点别惊讶现代处理器确实存在128字节缓存行的设计这在服务器CPU上不稀奇。5. 深入优化与方法扩展让实验数据更漂亮课程实验的验收标准通常是“数据可信、拐点清楚、结论正确”。如果你想让报告更出彩下面这三个方向可以继续做。5.1 用random模式做出对比图很多实验报告只放顺序访问的数据。如果你能把顺序访问和随机访问的曲线画在一张图里对比效果会非常直观。随机访问模式下cache miss对耗时的惩罚更明显拐点更陡峭能给阅卷老师留下“这个学生真的理解了cache”的印象。随机访问的代码难点在于生成一个足够长的下标数组并保证下标范围落在被测数组内。注意下标数组本身也会占cache所以它的组织方式要尽量不影响测量。可以用uint32_t数组存放随机索引按顺序遍历索引数组去访问目标数组。5.2 同时对“读”和“写”访问做对比cache有“写回write-back”和“写直达write-through”等策略读miss和写miss的开销不完全一样。你可以把实验扩展成两组一组测读延时上面的代码就是读另一组测写延时把acc buf[i]改成buf[i] (char)acc之类。对比之后你会发现写访问的最低耗时通常比读访问略高并且某些CPU架构上写分配的细节会让cache容量测量结果有细微差异。5.3 测量结果画图与实验报告要点我强烈建议你用Python或者Excel画图。画图时注意用对数坐标。cache容量跨越4KB到64MB步长跨越1到512不用对数坐标的话前面数据挤成一团后面数据看得太稀疏标出拐点在图上用一个箭头或圆圈标出你判断的“容量边界”或“line大小边界”并注明数值图例、轴标签命名清楚纵轴写“平均访问耗时(ns)”横轴写“数组大小(KB)”或“步长(bytes)”。写报告时“结论”部分应当包含L1容量、L2容量、L3容量如果测到、cache line大小、以及每个参数与lspcu/getconf的对照。再附上关键数据表和对比图就是一份完整的实验报告。6. 项目笔记与经验总结最后分享几个我从这个实验里总结出来的经验都是代码和文档之外的东西。第一做硬件测量实验“控制变量”四个字要刻在脑子里。影响程序耗时的因素太多了CPU频率、缓存状态、调度亲和性、编译器行为、内存分配时机。每一步都要刻意地排除干扰。我见过很多同学辛苦跑一晚上数据最后发现编译器开了-O0导致时间全被循环开销污染了连cache的影子都看不到。第二多测几轮取最小值是低成本且高效的抗干扰手段真的很好用。它的逻辑是多次测量时系统噪声通常只会让时间变长不会让时间变短所以最小值最接近真实硬件耗时。这个方法在后续做任何性能基准测试时都可以复用。第三实验数据要和权威渠道交叉验证。lscpu、getconf、甚至CPU厂商的官方文档都能提供参考值。如果测量结果和这些值差太远绝大多数情况是你的测量流程有问题而不是CPU设计有问题。第四别害怕把实验往深处做。cache size和cache line只是计算机组成原理实验里最基础的测量题。你可以继续往下测“关联性cache associativity”——通过构造特定地址冲突测出cache是几路组相联你还可以测“替换策略”——LRU还是随机替换。这些进阶实验用的都是计时法只是访问模式更精巧。等你把这一套玩熟了再看Linux内核里那些针对cache优化的数据结构设计比如cacheline对齐、per-cpu变量就会有一种“原来如此”的通透感。我在实际做这个实验的时候最有成就感的一刻不是测出L1是32KB而是第一次看到那条平滑的阶梯状曲线出现在屏幕上——你能直观地“看见”数据在L1、L2、L3和内存之间逐级下落比看教科书上的流程图要震撼得多。希望你也能把工具和环境调好把代码跑起来亲手画一画这张属于你自己的cache时延曲线。