news 2026/10/4 7:53:21

CPU缓存测量实验:黑盒推断缓存层次与Cache Line大小的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CPU缓存测量实验:黑盒推断缓存层次与Cache Line大小的完整指南

做这个实验的时候,我第一反应是有点怀疑的:CPU 的数据手册上都写着 L1 多少、L2 多少、cache line 是 64 字节,为什么还要让我写一个 C 程序去"测"?但真正把代码跑起来、把曲线画出来的那一刻,我才意识到手册给的只是"标称值",程序见到的才是真实世界里的缓存行为。这个实验本质上是一个黑盒测量,它不依赖任何 CPU 型号信息,只靠访问时间和缓存命中率的物理差异,就能把一台机器的缓存层次、容量、块大小全部反推出来。

这篇文章就把我完成"计组实验5:cache 大小测量与 cache line 大小测量"的完整过程整理出来,包括原理、代码、读图方法、踩坑记录。如果你是计算机组成原理、体系结构相关课程的学生,或者自己买了个新 CPU 想验证缓存参数,这篇文章都可以直接照着做。

1. 实验想回答的核心问题

1.1 为什么"测"比"查手册"更有意义

缓存(cache)是 CPU 和主存之间的一层高速缓冲。现代 CPU 至少有三层缓存,L1 最快但最小,L3 最慢但最大。我们平时常说的"缓存"也可能指 Redis 注解、HTTP 缓存、本地磁盘缓存,各种术语容易被绕晕。但在计组实验里,我们关心的只有 CPU 硬件缓存,参数就两个:缓存总容量,以及缓存里面的"最小分配单位"——也就是 cache line 的大小。

查手册当然能查到这些参数,但实际行为会受很多因素影响:预取器开没开、虚拟内存的页着色、同一物理核心的超线程、多核共享 L3 时的竞争,都会让性能发生变化。手册的标称值是一个理想边界,而实验测量出来的是一个"程序可见的拐点"。对于操作系统、编译器、性能优化的人来说,这个实测拐点才是真正有意义的。

1.2 缓存容量和 cache line 分别是什么

先简单对齐概念。缓存容量是指 L1、L2、L3 分别能装多少数据。cache line 是缓存和内存之间传输数据的最小单位,常见值是 64 字节,也有平台是 128 字节,部分老处理器是 32 字节。

举个例子:程序想读一个 1 字节的 char,CPU 不会只把 1 字节拿回来,而是把它所在的整条 cache line(比如 64 字节)一起载入缓存。所以程序中两个相距 0 到 63 字节的访问,在实际硬件上可能只触发一次内存读取;如果两个访问相距 64 字节,就一定是两条不同的 cache line。

这个特性就是本次实验的突破口。缓存大小影响访问延迟的"容量拐点",cache line 大小影响跨步访问的"步长拐点"。只要把时间和数据规模的关系测出来,两个参数就都浮出水面了。

2. 两个指标的测量原理拆解

2.1 用"容量阶梯"测量缓存大小

假设我们有一个很大很大的数组,比如 64MB。我们以 64 字节为步长遍历它,也就是一次只碰一条 cache line,把数组从头扫到尾。然后不断缩小数组规模,重复同样的遍历,记录每次访问的平均耗时。

原理特别直观:当数组规模远超某一级缓存时,大部分访问会落到下一级乃至内存里,速度明显变慢;当数组规模刚好能被某级缓存装下时,访问速度就会处于一个相对平缓的台阶。如果我们从 1KB 一直测到 64MB,理论上会看到三个明显的"台阶",对应 L1、L2、L3 的容量。台阶交界处,就是对应缓存的容量。

但顺序遍历会遇到预取器干扰。现在 CPU 的硬件预取器很聪明,它发现你在顺序读,就会提前把后面的数据拉进缓存,导致访问速度看起来没那么慢。所以更严谨的做法是随机访问。用"指针追逐"模式,让下一次要访问的地址由上一次访问的结果决定,预取器就基本猜不中。我在后面代码里先给出最直观的顺序遍历版本,同时补充指针追逐的改进策略。

2.2 用"跨步拐点"测量 cache line 大小

这次我们把数组固定在一个很大的规模上,比如 32MB,保证它无论如何都装不进最大缓存。然后改变访问步长,从 1 字节、2 字节、4 字节一直增加到 1024 字节,统计每次访问的平均耗时。

关键逻辑在于:访问同一个缓存行内的不同字节,只有第一次是真的从内存加载,后续都是缓存命中。当步长小于 cache line 大小时,每加载一条 cache line,会有多个被访问到的字节落在同一条 line 内,相当于一次内存访问摊到多次程序访问上,平均时间就低。当步长等于 cache line 大小时,每条 cache line 只被访问一次,每次访问都对应一次实际的内存加载,平均时间达到峰值。步长继续增大,超过 cache line 大小后,每条访问都从新的一行拿数据,但实际访问次数变少了,所以均摊下来每条的时间保持在一个高平台上。

用数字算一下:假设 cache line 是 64 字节,数组 32MB。步长为 1 字节时,一次完整的遍历会发生 32MB 次程序访问,但内存只真正加载了 32MB / 64 = 512K 次,每个程序访问平均摊到的"硬成本"大概是内存延迟的六十四分之一。步长为 64 字节时,程序访问次数是 512K,每次访问都要跨一条新 cache line,每个程序访问都承担一次完整的内存加载延迟。所以曲线上必然在步长 64 附近出现一个突然爬升的拐点,这个拐点对应的横坐标就是 cache line 大小。

2.3 预取器、频率和多核对测量的干扰

原理听起来很清爽,实际上手才会发现脏活全在后头。最典型的三个干扰源:

第一是预取器。顺序访问时,预取器会把后续几行提前拉进来,让"内存延迟"看起来变短。对策是改用随机化访问,或者在同一个测试内重复多次,把预取效果压低。

第二是 CPU 频率。现代处理器有睿频,温度一变频率就变,时间读数就会乱飘。实验之前最好把测试线程绑定到固定核心,并且尽量让机器空闲,不要开一堆后台任务。

第三是多核共享。L3 是整个芯片共享的,别的核也在跑程序的话,L3 同时被占用,测量结果会被拉偏。写代码时用sched_setaffinity把进程绑到一个核上,虽然不能完全隔离 L3 竞争,但至少能减少一部分。

3. 实验环境与 C 语言实现

3.1 工具选择和准备

我的环境是 Linux,编译器用 gcc。计时用clock_gettime(CLOCK_MONOTONIC),它返回单调时钟,不会因为手动改系统时间而跳变,精度也足够到纳秒量级。

需要特别注意的是编译器问题。如果数组内容简单累加,编译器可能把整个循环优化成无意义的常量计算,所以测试数组必须声明为volatile,并且用一个 volatile 变量承接读取结果,确保每次内存访问都真实发生。

还要做两件事:关掉会拉偏测试的 CPU 迁移,用sched_setaffinity绑定到 0 号核心;如果机器支持,也可以考虑关掉超线程后再测,减少同核争抢。

3.2 实验一代码:测量缓存大小

核心思路是让数组容量从 1KB 增长到 64MB,固定步长 64 字节,记录每次访问的平均纳秒数。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <time.h> #include <sched.h> #define STRIDE 64 #define MAX_SIZE (64 * 1024 * 1024) static volatile unsigned char pool[MAX_SIZE]; static double now_ns(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (double)ts.tv_sec * 1e9 + (double)ts.tv_nsec; } static double run(int bytes, int loops) { volatile unsigned char sink = 0; double t0, t1; int i, j; // 预热:把访问过的页面提前碰一遍,避免把缺页时间算进去 for (i = 0; i < bytes; i += STRIDE) sink += pool[i]; t0 = now_ns(); for (j = 0; j < loops; j++) { for (i = 0; i < bytes; i += STRIDE) sink += pool[i]; } t1 = now_ns(); return (t1 - t0) / (double)((long)loops * (bytes / STRIDE)); } int main(void) { int kb[] = {1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 6144, 8192, 12288, 16384, 32768, 65536}; int i; memset((void *)pool, 0, sizeof(pool)); // 绑定到 0 号核心 cpu_set_t set; CPU_ZERO(&set); CPU_SET(0, &set); sched_setaffinity(0, sizeof(set), &set); for (i = 0; i < sizeof(kb) / sizeof(kb[0]); i++) { int bytes = kb[i] * 1024; int lines = bytes / STRIDE; int loops = 16 * 1024 * 1024 / lines; if (loops < 1) loops = 1; double ns = run(bytes, loops); printf("%8d KB %10.3f ns/access\n", kb[i], ns); } return 0; }

这段代码的关键在loops的调整。数组变小的时候,每轮访问到的 cache line 数量少,所以要多跑几轮,保证每个规模点的总访问量差不多,避免小数组因为跑得太快、计时器精度不够导致误差。volatile unsigned char sink是为了告诉编译器"每次读取的结果都可能改变",从而保住每一次内存访问。

编译时用:

gcc -O2 -o cache_size cache_size.c -lrt

-lrt在老版本 glibc 上需要,新系统一般可以省略。

如果你想更严格,可以在这段顺序遍历的基础上改成"指针追逐"版:先生成一个大小为bytes/4的随机索引链,然后从链头开始,一步步pos = next[pos]。这样每条指令的地址依赖上一次结果,预取器基本没法发挥作用,L1、L2、L3 之间的台阶会更清晰。

3.3 实验二代码:测量 cache line 大小

这次数组固定为 32MB,保证大于大多数 CPU 的 L3 容量。步长从 1 字节逐步增大到 1024 字节,计算每次访问的平均耗时。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <time.h> #include <sched.h> #define MAX_SIZE (32 * 1024 * 1024) static volatile unsigned char pool[MAX_SIZE]; static double now_ns(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (double)ts.tv_sec * 1e9 + (double)ts.tv_nsec; } int main(void) { int stride[] = {1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024}; int i; memset((void *)pool, 0, sizeof(pool)); cpu_set_t set; CPU_ZERO(&set); CPU_SET(0, &set); sched_setaffinity(0, sizeof(set), &set); for (i = 0; i < sizeof(stride) / sizeof(stride[0]); i++) { int s = stride[i]; long access_count = MAX_SIZE / s; int loops = 64 * 1024 * 1024 / access_count; if (loops < 4) loops = 4; volatile unsigned char sink = 0; double t0, t1; int j, k; // 预热 for (k = 0; k < MAX_SIZE; k += s) sink += pool[k]; t0 = now_ns(); for (j = 0; j < loops; j++) { for (k = 0; k < MAX_SIZE; k += s) sink += pool[k]; } t1 = now_ns(); double ns = (t1 - t0) / (double)((long)loops * access_count); printf("stride %4d B, %10.3f ns/access\n", s, ns); } return 0; }

这段代码的运行逻辑是:步长为 1 时,access_count很大,loops就会被压小;步长为 1024 时,每轮访问次数很少,loops会变大。这是为了让总访问次数在同一个量级,从而让每毫秒的计时误差不至于在数据点上造成完全不可信的波动。

步长范围建议根据实际 cache line 大小调整。常见的 x86 平台 cache line 是 64 字节,所以我在 32 和 64 之间多插了一个点。有些 ARM 平台是 128 字节,那你在 64、128、256 之间可以再加几个点,比如 96、160,把拐点找得更准。注意步长最好保持 2 的幂,这样与缓存索引和组映射的关系更清晰。

3.4 编译、运行和结果采集

两个程序编译后直接运行即可,普通用户权限就够,不需要 root。输出是文本,方便你重定向到文件后用 Python、Excel 或 gnuplot 画图。

gcc -O2 -o cache_size cache_size.c gcc -O2 -o cache_line cache_line.c ./cache_size > size_result.txt ./cache_line > line_result.txt

画图时横轴建议用对数坐标。缓存大小测试横轴是数组容量,从 KB 到 MB,差异可能上千倍,线性坐标根本看不出早段的细节;cache line 测试步长从 1 到 1024,也要用对数坐标才直观。

4. 实测数据与读图方法

4.1 预期曲线形态

现代 x86-64 桌面处理器的典型缓存参数大致是:

层级典型容量典型访问延迟
L132KB ~ 64KB1 ~ 1.3ns
L2256KB ~ 2MB4 ~ 10ns
L38MB ~ 32MB20 ~ 50ns
主存无上限60 ~ 120ns

因此缓存大小测试的输出曲线应该是:在 32KB 附近出现第一次跳升,L2 边界出现第二次跳升,L3 边界出现第三次跳升。三次跳升把曲线分成四段平缓区,这个"阶梯"非常明显。

cache line 测试的输出曲线应该是:步长在 1 到 32 字节之间时,每次访问的平均时间相对平稳;在步长 64 附近突然变高;之后保持在偏高平台。拐点横坐标直接等于 cache line 大小。

4.2 纵轴和横轴读法

缓存大小测试的纵轴是每次访问 cache line 的平均耗时,单位 ns/access。这里说的"一次访问"是指一次pool[i]读取,由于步长固定为 64 字节,它近似等于"访问一条 cache line"的成本。

缓存行测试的纵轴含义不同。纵轴是每访问一个元素的时间,元素大小是 1 字节。当步长小的时候,一次内存加载能覆盖多个元素,均摊成本低;当步长等于 cache line 大小时,一次访问就要承担一次完整的内存加载,成本高。所以这个实验的纵轴不是纯访问延迟,而是"均摊到每个逻辑元素上的平均延迟"。这个区别如果不说明白,画图的时候很容易误解成 cache line 越大越慢。

4.3 我的一台实际测试机结果

在一台 L1 32KB、L2 256KB、L3 8MB 的机器上,缓存大小测试得到的数据大概是:

测试规模每访问平均耗时
8KB约 0.4 ns
32KB约 0.9 ns
64KB约 1.8 ns
256KB约 3.5 ns
1MB约 7.5 ns
8MB约 14 ns
16MB约 22 ns
64MB约 24 ns

可以看到在 32KB 附近曲线第一次抬头,对应 L1 容量;在 256KB 附近第二次抬头,对应 L2;8MB 之后增幅变缓,对应 L3 容量。为什么 L3 平台不如前两级那么"陡"?因为现代 CPU 预取器和跨步访问策略对 L3 访问的掩盖作用比较强,但拐点依然可辨。

cache line 测试部分的数据也符合预期:步长 1 到 32 字节的平均访问时间大约在 1.2 到 2.0ns 之间,步长 64 突然跳到 8ns 以上。这说明 cache line 边界就是 64 字节,和手册上的参数完全吻合。

5. 常见问题与排查实录

5.1 编译器把测试代码优化成"什么都没做"

这是最容易踩的坑。如果你没把测试数组声明成volatile,编译器会认为整个循环的累加结果从来没被使用,直接把它删除或合并。你测到的不是内存访问时间,而是循环空转的耗时,甚至可能快到一个不合常理的数值,比如每访问 0.001ns。

解决方法是三件套:测试数组全局声明为volatile;承接结果的变量也声明为volatile;编译时用-O2而不是-O0。只要三点做到,编译器就没法偷懒。

5.2 曲线台阶不明显,全是锯齿

原因主要是预取器和系统噪声。我遇到过的情况是机器后台有索引服务在跑,L3 时快时慢,曲线在 16MB 到 64MB 之间上下抖动 30%。

处理办法,先把后台程序尽量停掉;然后把测试进程绑定到一个固定核心。如果还不行,可以尝试关闭 CPU 硬件预取,但普通环境下没有 root 权限通常改不了 MSR,所以我一般是用随机访问模式做替代,不再依赖顺序遍历。随机访问会让每个数据点都被真实未命中主导,锯齿会小很多。

5.3 计时器分辨率不够

有些虚拟机或老内核里,clock_gettime的分辨率可能只有几微秒,而我们测量一次访问只有零点几纳秒到几十纳秒,直接测单次访问肯定不行。代码里已经做了多次循环取平均,但如果你的机器特别老,可以把loops那一行的基准值从16 * 1024 * 1024提高到64 * 1024 * 1024,让总耗时放大到毫秒级。

如果是在虚拟化环境里测,我的建议是放弃这个实验,去实体 Linux 机器上跑。虚拟机的时间切片和中断注入会让曲线变成疯子,测出来的数据只能作为"相对趋势"参考,不能当真实硬件参数。

5.4 打开perf验证硬件计数器

时间测量本质上是间接推断,如果想确认缓存大小台阶确实对应缓存失效,可以在 Linux 下用perf stat看硬件计数器。比如缓存大小测试跑到 32KB 和 64KB 两个规模时,分别看 L1 缓存失效次数:

perf stat -e cache-references,cache-misses,l1d-loads,l1d-load-misses ./cache_size

perf stat输出是整个程序的总计,不方便按规模拆分。更精细的做法是在 C 代码里调用perf_event_open,在每个测量点前后读一次硬件计数器。不过对大多数实验课来说,用时间曲线已经足够了。硬件计数器主要用于验证"为什么这里会拐弯",属于锦上添花。

5.5 多核缓存共享带来的误判

如果你在一颗 8 核开满任务的情况下测 L3,测出来的 L3 容量可能不足,因为有一部分被其他核抢占了。更隐蔽的是,同一颗物理核心的超线程也会共享 L1 和 L2,如果另一个逻辑核在跑任务,你的 L1、L2 就会被挤压。

我的经验是:先用taskset -c 0或者代码里的sched_setaffinity绑定核心,再用top或者htop确认 0 号核心基本空闲,然后才开始测。如果机器是大小核架构,比如 Intel 12 代以后的 P 核和 E 核,最好绑定到一个固定的 P 核上,大小核混跑会让数据出现两套完全不同的曲线。

6. 最后说点个人体会

这个实验做完之后,我对"缓存是什么"的理解完全不一样了。以前背的"L1 32KB、L2 256KB、cache line 64 字节"只是纸面上的数字,现在我看一条曲线就能直接说出这台机器缓存分几层、每层大概多大。这种能力在调优内存访问密集型程序的时候特别有用,比如矩阵分块、池化分配、链表转数组,这些优化本质上都是在顺应缓存的行为。

实际跑实验时,我个人建议不要只跑一遍。多跑两三遍,取每次读数的中位数而不是平均数,因为平均数容易被偶发的中断拉高。如果你用 Python 处理结果,可以直接画一张双对数图,把缓存大小测试和 cache line 测试两条曲线放在一起,很多规律一眼就看出来了。这个实验后续还能继续扩展,比如测量缓存相联度、测量不同写分配策略的效果,甚至改成用rdtsc指令做高精度计时。每次换一种测法,都能从 CPU 这个黑盒里多撬出一层真相。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 7:52:13

DRAM刷新机制与参数详解:从tREFI到自刷新,一文读懂

记得早年间第一次被领导按着头去查DDR初始化代码里的refresh相关寄存器时&#xff0c;我满脑子都是“这不就是定时给电容充电吗&#xff0c;有什么好查的”。结果板子在高温房里跑了一个小时&#xff0c;随机出现位翻转&#xff0c;排查了整整两天才把问题定位到tREFI配置和温度…

作者头像 李华
网站建设 2026/10/4 7:50:52

从零构建AI工程体系:Python实验、TS契约、Rust运行时的分层实践

1. 什么是“从零构建AI工程体系”——不是写个Hello World&#xff0c;而是搭一座能跑模型、扛流量、可迭代的桥“ai-engineering-from-scratch”这个标题乍看像极了那些泛泛而谈的“手把手教你用Python写个神经网络”的入门教程&#xff0c;但其实它指向的是一个被严重低估、却…

作者头像 李华
网站建设 2026/10/4 7:48:06

QT+C++打地鼠游戏毕业设计:从零实现与答辩避坑指南

简介&#xff1a;这是一份基于QT与C实现的打地鼠游戏完整源码&#xff0c;面向计算机相关专业的毕业设计、课程设计以及入门级项目开发学习者。项目在QT环境下编译运行&#xff0c;核心依托QT信号与槽机制完成界面与业务逻辑的关联&#xff0c;并支持动态调整地鼠出现速度等参数…

作者头像 李华
网站建设 2026/10/4 7:45:21

服务端推送技术全景:从轮询到 WebSocket

服务端推送技术全景&#xff1a;从轮询到 WebSocket&#xff0c;一文讲透四种方案的原理与选型 &#x1f4cc; 本文是我在视频推流项目&#xff08;含秒杀优惠券系统&#xff09;中做"库存实时刷新、秒杀结果通知"时整理的完整技术调研 项目实战。 配套源码仓库&…

作者头像 李华
网站建设 2026/10/4 7:42:02

JAVA游戏支付源码拆解:免签支付平台的核心机制与实战避坑

简介&#xff1a;一份可直接部署运行的JAVA游戏通用支付平台源码&#xff0c;面向游戏开发者、站长及支付集成需求方&#xff0c;已对接正在运营的免签支付平台。使用个人支付宝或微信收款二维码即可完成自动发货&#xff0c;支持mysql/sqlserver数据库&#xff0c;内置免签支付…

作者头像 李华