ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

CPU缓存测量实验:黑盒推断缓存层次与Cache Line大小的完整指南 做这个实验的时候我第一反应是有点怀疑的CPU 的数据手册上都写着 L1 多少、L2 多少、cache line 是 64 字节为什么还要让我写一个 C 程序去测但真正把代码跑起来、把曲线画出来的那一刻我才意识到手册给的只是标称值程序见到的才是真实世界里的缓存行为。这个实验本质上是一个黑盒测量它不依赖任何 CPU 型号信息只靠访问时间和缓存命中率的物理差异就能把一台机器的缓存层次、容量、块大小全部反推出来。这篇文章就把我完成计组实验5cache 大小测量与 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 字节的 charCPU 不会只把 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.3nsL2256KB ~ 2MB4 ~ 10nsL38MB ~ 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 ns32KB约 0.9 ns64KB约 1.8 ns256KB约 3.5 ns1MB约 7.5 ns8MB约 14 ns16MB约 22 ns64MB约 24 ns可以看到在 32KB 附近曲线第一次抬头对应 L1 容量在 256KB 附近第二次抬头对应 L28MB 之后增幅变缓对应 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_sizeperf 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 这个黑盒里多撬出一层真相。
返回列表