第四章:内存访问优化——缓存行对齐、预取、大页内存与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 完全能装下。

启用大页的步骤:

  1. 配置系统大页数量:echo 1024 > /proc/sys/vm/nr_hugepages
  2. 挂载 hugetlbfs:mount -t hugetlbfs none /mnt/huge
  3. 在代码中通过 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;
}

或者更简单,用 mmapMAP_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 知识体系总览

下面这张图,是我自己总结的内存访问优化知识体系。你可以把它当成一个检查清单:

内存访问优化知识体系 内存访问优化 缓存行对齐 alignas(64) / padding 避免伪共享 预取 __builtin_prefetch 链表/随机访问优化 大页内存 2MB / 1GB 大页 MAP_HUGETLB TLB 优化 内存池 / NUMA绑定

我的建议:不要试图一次性优化所有点。先用 perf 或者火焰图找到真正的瓶颈,然后针对性地优化。很多时候,一个缓存行对齐就能解决 80% 的问题。

好了,这一章的内容就到这里。内存访问优化是个细活,需要耐心和工具。但一旦做好了,回报是实实在在的——微秒级的延迟下降,在交易系统里就是真金白银。


专注资料整理