第四章:内存访问优化——缓存行对齐、预取、大页内存与TLB优化
做量化交易系统,说白了就是跟延迟赛跑。我见过太多团队,算法写得花里胡哨,结果一上生产环境,延迟直接崩了。为什么?十有八九是内存访问出了问题。
CPU 其实很快,真的很快。但内存呢?慢得像蜗牛。你想想看,CPU 执行一条指令可能只要 0.3 纳秒,但从主存读一次数据,却要 100 纳秒。这中间差了 300 倍!所以,怎么让数据更快地送到 CPU 手里,就是我们今天要聊的核心。
核心观点:内存访问优化,本质上是让 CPU 的缓存(L1/L2/L3)更高效地工作,减少它去主存“长途跋涉”的次数。
4.1 缓存行对齐:别让数据“打架”
CPU 缓存不是按字节读的,而是按“缓存行”读的。x86 架构下,一个缓存行通常是 64 字节。这意味着,你读一个 4 字节的 int,CPU 会连它旁边的 60 个字节一起拉进缓存。
这本来是个好事——空间局部性嘛。但问题来了:如果两个线程各自修改同一个缓存行里的不同变量,就会发生“伪共享”。
什么叫伪共享?我举个例子。假设你有一个结构体:
struct Order {
int64_t order_id; // 线程A 只改这个
int64_t price; // 线程B 只改这个
};
两个字段紧挨着,很可能在同一个缓存行里。线程A 改了 order_id,这个缓存行就“脏”了。线程B 想读 price,发现缓存行失效,只能重新从主存加载。反过来也一样。两个线程明明没共享数据,却因为共享了缓存行而互相拖累。
我在项目中遇到过这种情况。一个订单撮合引擎,性能死活上不去。用 perf 一查,L1 缓存 miss 率高得吓人。最后定位到就是伪共享。解决方式很简单——缓存行对齐。
// 使用 alignas 指定对齐到 64 字节
struct alignas(64) Order {
int64_t order_id; // 线程A 专用
char padding[56]; // 填充到 64 字节
};
struct alignas(64) Price {
int64_t price; // 线程B 专用
char padding[56];
};
这样每个变量独占一个缓存行,谁也碍不着谁。
我的习惯:在多线程高频访问的热点数据结构中,我通常会显式加上 alignas(64)。虽然会浪费一点内存,但换来的是稳定的低延迟,值。
4.2 预取:让数据“提前就位”
CPU 有硬件预取器,它会自动识别访问模式并提前加载数据。但硬件预取不是万能的。对于随机访问、链表遍历、或者跳转很大的场景,硬件预取基本就废了。
这时候,就需要我们手动干预——用 __builtin_prefetch(GCC/Clang)或者 _mm_prefetch(MSVC)。
我举个例子。假设你要遍历一个很大的订单列表,每个订单里有个指针指向下一个订单:
struct OrderNode {
int64_t id;
double price;
OrderNode* next;
};
void process_list(OrderNode* head) {
while (head) {
// 预取下一个节点到 L1 缓存
__builtin_prefetch(head->next, 0, 3);
// 处理当前节点
process(head);
head = head->next;
}
}
__builtin_prefetch 的三个参数:第一个是地址,第二个是读写提示(0 表示读,1 表示写),第三个是时间局部性(0-3,3 表示尽量留在缓存里)。
我曾经在一个行情解析模块里用过这个技巧。行情数据是链表结构,每个 tick 都要遍历。加上预取后,延迟降低了大约 15%。效果很明显。
注意:预取不是万能的。预取本身也有开销。如果预取的数据还没用到就被踢出缓存了,反而浪费。我建议只在确定有“延迟瓶颈”时再用,不要无脑加。
4.3 大页内存:TLB 的救星
TLB(Translation Lookaside Buffer)是 CPU 内部的一个小缓存,用来加速虚拟地址到物理地址的转换。默认情况下,内存页大小是 4KB。TLB 条目数有限(通常几十到几百个),覆盖不了太大的内存区域。
你想想看,如果你的交易系统用了 2GB 的共享内存,4KB 一页,就需要 524288 个页表项。TLB 根本装不下。每次访问新页,都要去查页表,延迟一下子就上去了。
解决方案?用大页。Linux 下支持 2MB 和 1GB 的大页。2MB 的大页,覆盖 2GB 内存只需要 1024 个条目,TLB 完全能装下。
启用大页的步骤:
- 配置系统大页数量:
echo 1024 > /proc/sys/vm/nr_hugepages - 挂载 hugetlbfs:
mount -t hugetlbfs none /mnt/huge - 在代码中通过
mmap使用大页:
#include <sys/mman.h>
#include <fcntl.h>
void* alloc_hugepage(size_t size) {
int fd = open("/mnt/huge", O_CREAT | O_RDWR, 0755);
if (fd < 0) return nullptr;
void* addr = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_HUGETLB, fd, 0);
close(fd);
return addr;
}
或者更简单,用 mmap 的 MAP_HUGETLB 标志:
void* addr = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
实际效果:我在一个订单簿重建模块中测试过,启用 2MB 大页后,TLB miss 减少了 90% 以上,整体延迟降低了约 30%。
4.4 TLB 优化:不只是大页
大页是 TLB 优化的主要手段,但不是唯一手段。还有一些细节值得注意:
- 减少内存碎片:频繁的
malloc/free会导致页表碎片化。我习惯用内存池,提前分配好一大块,然后自己管理。 - 绑定 NUMA 节点:在多路服务器上,跨 NUMA 访问内存会触发额外的 TLB 操作。用
numactl或者mbind把线程和内存绑定到同一个 NUMA 节点。 - 避免频繁的 mmap/unmap:每次
mmap都会刷新 TLB。如果必须动态映射,考虑用mremap或者提前映射好。
我曾经踩过一个坑。一个行情网关程序,每收到一条新行情就 mmap 一块内存来存。结果 TLB 频繁刷新,延迟抖动非常严重。后来改成预分配环形缓冲区,问题就解决了。
4.5 知识体系总览
下面这张图,是我自己总结的内存访问优化知识体系。你可以把它当成一个检查清单:
我的建议:不要试图一次性优化所有点。先用 perf 或者火焰图找到真正的瓶颈,然后针对性地优化。很多时候,一个缓存行对齐就能解决 80% 的问题。
好了,这一章的内容就到这里。内存访问优化是个细活,需要耐心和工具。但一旦做好了,回报是实实在在的——微秒级的延迟下降,在交易系统里就是真金白银。