CPU缓存友好性:让数据离CPU更近一点
做量化交易系统,说白了就是跟延迟赛跑。我见过太多团队,算法策略再牛,结果跑起来卡在内存访问上,白白浪费了CPU的算力。今天咱们聊聊CPU缓存——这个离CPU最近、速度最快、但容量最小的存储层级。
CPU缓存层级结构
现代CPU一般有三层缓存:L1、L2、L3。我习惯把它们比作「办公桌上的笔记本」、「办公室的文件柜」和「楼下的档案室」。
| 缓存层级 | 典型大小 | 访问延迟 | 速度对比 |
|---|---|---|---|
| L1 | 32KB - 64KB | ~1ns | 比主存快100倍 |
| L2 | 256KB - 512KB | ~4ns | 比主存快25倍 |
| L3 | 8MB - 32MB | ~10ns | 比主存快10倍 |
| 主存 | GB级别 | ~100ns | 基准 |
你想想看,一次L1缓存命中只要1纳秒,而一次主存访问要100纳秒。在交易系统里,这100倍的差距,可能就是一笔订单能不能抢到的关键。
核心原则:热路径上的数据,尽量塞进L1缓存。塞不下的,至少保证L2命中。
缓存行与伪共享问题
CPU从主存取数据,不是按字节取的,而是按「缓存行」取的。缓存行一般是64字节。什么意思呢?你读一个int,CPU会把它周围64字节的数据一起搬进缓存。
这本身是好事——利用了空间局部性。但问题来了,如果两个线程各自修改同一个缓存行里的不同变量,就会产生「伪共享」。
我在项目中遇到过这个坑。当时我们有个订单簿,用两个原子变量分别记录买一价和卖一价。两个线程各改各的,结果性能惨不忍睹。为什么?因为这两个变量恰好落在同一个缓存行里。CPU为了保证缓存一致性,每次修改都要让对方的缓存行失效,然后重新加载。
// 伪共享的典型例子
struct OrderBook {
std::atomic<double> bid_price; // 线程A频繁修改
std::atomic<double> ask_price; // 线程B频繁修改
// 这两个变量很可能在同一个缓存行里!
};
避坑指南:我曾经调试过一个延迟抖动问题,查了两天才发现是伪共享。两个高频更新的计数器放在相邻位置,导致缓存行在多个核心间来回「打架」。性能直接掉了40%。
数据对齐与填充
解决伪共享,最直接的办法就是「填充」。让每个热数据独占一个缓存行。
// 对齐到缓存行边界,避免伪共享
struct alignas(64) OrderBook {
std::atomic<double> bid_price;
char padding[56]; // 填充到64字节
std::atomic<double> ask_price;
char padding2[56];
};
嗯,这里要注意:填充会浪费内存,但换来了性能。在交易系统里,这个取舍很值。我个人习惯在热路径结构体上,都加上alignas(64),确保每个实例从缓存行边界开始。
数据对齐还有另一个好处:避免跨缓存行访问。如果一个8字节的double跨越了两个缓存行,CPU要读两次。这在热路径上绝对不能忍。
小技巧:用offsetof宏检查结构体成员的偏移量,确保没有跨缓存行的情况。我一般会在单元测试里加这个检查。
使用perf stat分析缓存命中率
光靠猜不行,得用数据说话。perf stat是我最常用的工具之一。
# 查看程序的缓存命中情况
perf stat -e cache-references,cache-misses,L1-dcache-loads,L1-dcache-load-misses ./your_trading_system
输出大概长这样:
Performance counter stats for './your_trading_system':
1,234,567,890 L1-dcache-loads
12,345,678 L1-dcache-load-misses # 1.00% of all L1-dcache accesses
567,890,123 cache-references
56,789,012 cache-misses # 10.00% of all cache refs
L1缓存未命中率超过5%,就该警惕了。超过10%,基本可以断定你的热路径代码在「等数据」。
我记得有一次帮朋友优化一个高频交易引擎,perf stat一看,L1未命中率高达18%。顺着热点函数往下查,发现是一个订单匹配循环里,频繁访问一个巨大的结构体数组,每个元素都跨了多个缓存行。改成按字段存储(结构体数组转数组结构体),L1未命中率直接降到2%以下。
优化目标:热路径代码的L1缓存未命中率控制在3%以内,L2未命中率控制在1%以内。超过这个阈值,就该动手了。
知识体系总览
下面这张图,是我自己总结的缓存优化知识体系。每次做性能分析,我都会对照着看一遍。
说白了,缓存优化就三件事:让数据更紧凑、让访问更连续、让冲突更少。每一条,都能在perf stat的数据里看到实实在在的改善。
我的习惯:每次写完热路径代码,第一件事就是跑perf stat。不看数据,你永远不知道你的代码在「等」什么。优化完再跑一次,看到L1未命中率从8%降到2%,那种成就感,比策略赚钱还爽。