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%以内。超过这个阈值,就该动手了。

知识体系总览

下面这张图,是我自己总结的缓存优化知识体系。每次做性能分析,我都会对照着看一遍。

CPU缓存友好性优化知识体系 CPU缓存友好性 缓存层级结构 L1: 1ns, 32-64KB L2: 4ns, 256-512KB L3: 10ns, 8-32MB 缓存行与伪共享 缓存行大小: 64字节 伪共享: 多线程修改同一缓存行 性能损失: 可达40%+ 数据对齐与填充 alignas(64) 对齐 填充避免跨行访问 空间换时间 perf stat 分析缓存命中率 L1未命中率 < 3% L2未命中率 < 1% cache-misses 优化

说白了,缓存优化就三件事:让数据更紧凑、让访问更连续、让冲突更少。每一条,都能在perf stat的数据里看到实实在在的改善。

我的习惯:每次写完热路径代码,第一件事就是跑perf stat。不看数据,你永远不知道你的代码在「等」什么。优化完再跑一次,看到L1未命中率从8%降到2%,那种成就感,比策略赚钱还爽。


专注资料整理