3、内存布局优化:缓存行对齐、内存池、预分配策略,减少TLB miss
好,咱们今天聊点硬核的。订单簿重建,说白了就是跟内存打交道。你算法写得再花哨,数据在内存里摆得乱七八糟,CPU 就得干等着。我见过太多团队,花几个月优化算法逻辑,结果一上生产,延迟还是下不去。查到最后,问题全出在内存布局上。
我个人习惯,做性能调优第一步,不是看代码逻辑,而是看数据怎么放的。你想想看,CPU 缓存就那么点大,TLB 条目更是稀缺资源。订单簿里成千上万的订单,如果每个订单对象都散落在内存各处,那 CPU 光忙着搬运数据了,哪还有空算价格?
核心观点: 内存布局优化的本质,就是让 CPU 更高效地访问数据。缓存行对齐、内存池、预分配,这三板斧砍下去,TLB miss 能降一个数量级。
3.1 缓存行对齐:别让一个订单污染了邻居
先说说缓存行。现代 CPU 从内存读数据,不是按字节读的,而是按「缓存行」读的。一般是 64 字节。什么意思呢?你读一个 4 字节的整数,CPU 会连它周围 60 个字节一起拉进缓存。
这就有个问题。假设你有个订单结构体:
struct Order {
uint64_t order_id; // 8 字节
double price; // 8 字节
uint32_t quantity; // 4 字节
char side; // 1 字节
// 这里还有 43 字节的 padding!
};
你看,这个结构体实际只用了 21 字节,但编译器为了对齐,硬是塞了 43 字节的填充。一个缓存行 64 字节,刚好放一个 Order 对象。但如果你把成员顺序调整一下:
struct alignas(64) Order {
uint64_t order_id; // 8 字节
double price; // 8 字节
uint32_t quantity; // 4 字节
char side; // 1 字节
char _pad[43]; // 显式填充到 64 字节
};
嗯,这里要注意。加上 alignas(64) 之后,每个 Order 对象都从 64 字节对齐的地址开始。这样遍历订单数组时,每个缓存行刚好装一个订单,不会出现一个订单跨两个缓存行的情况。
我的经验: 我在一个高频交易项目中,发现订单匹配引擎的延迟抖动特别大。排查了很久,最后发现是订单结构体没有对齐。一个订单的 price 字段跨了两个缓存行,每次读取都要等两次内存访问。对齐之后,P99 延迟直接降了 40%。
3.2 内存池:别再让 new/delete 拖后腿
订单簿重建过程中,订单的创建和销毁非常频繁。如果你用 new 和 delete,每次都要走一次系统调用,还要经过内存管理器的复杂逻辑。这太慢了。
我建议的做法是:预分配一块大内存,自己管理。这就是内存池。
class OrderPool {
static constexpr size_t POOL_SIZE = 1 << 20; // 100 万个订单
char buffer[POOL_SIZE * sizeof(Order)];
std::atomic<uint32_t> next_free{0};
public:
Order* allocate() {
uint32_t idx = next_free.fetch_add(1);
if (idx >= POOL_SIZE) return nullptr; // 池子满了
return new (buffer + idx * sizeof(Order)) Order();
}
void deallocate(Order*) {
// 啥也不做,批量重置即可
}
};
你看,分配一个订单,就是一个原子加操作,加一个 placement new。释放?直接忽略。等整个订单簿重建完了,一次性重置 next_free 就行。
避坑指南: 我曾经犯过一个错误,内存池里的对象没有正确析构。如果 Order 里有持有资源的成员(比如 std::string),直接重置指针会导致内存泄漏。所以,要么确保 Order 是 POD 类型,要么在重置前手动调用析构函数。
3.3 预分配策略:让 TLB 少翻几页
TLB 是 CPU 用来加速虚拟地址到物理地址转换的缓存。它的条目非常有限,一般只有几十到几百个。如果你的数据分散在大量不同的内存页上,TLB 就会频繁 miss,每次都要去查页表,那延迟就上去了。
解决方案很简单:预分配一大块连续内存。这样你的数据只占用少数几个大页(比如 2MB 的 huge page),TLB 条目就能覆盖全部数据。
在 Linux 上,你可以这样启用 huge page:
// 分配 2MB 对齐的内存
void* ptr = mmap(nullptr, 2 * 1024 * 1024,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
-1, 0);
或者更简单,用 libhugetlbfs 库。我个人习惯在系统启动时预留一些 huge page,然后在程序初始化时直接映射。
| 策略 | TLB miss 减少 | 适用场景 |
|---|---|---|
| 普通 4KB 页 | 基准 | 小数据量 |
| 2MB 大页 | 约 90% | 订单簿、价格深度 |
| 1GB 大页 | 约 99% | 全量市场数据回放 |
你看这个表,从 4KB 页换成 2MB 大页,TLB miss 能减少 90%。代价是什么?内存浪费。如果你只用了 1MB 数据,却分配了 2MB 的大页,那 1MB 就浪费了。所以要根据数据量选择合适的页大小。
3.4 实战:订单簿重建的内存布局
好,理论说完了,咱们看看实际怎么用。下面是一个订单簿重建的简化实现,融合了上面三个策略:
class OrderBook {
// 使用大页分配的内存池
OrderPool pool;
// 价格档位,用数组而不是链表
struct PriceLevel {
double price;
uint32_t volume;
Order* head;
};
// 预分配所有价格档位
PriceLevel levels[MAX_PRICE_LEVELS];
public:
void rebuild(const std::vector<OrderUpdate>& updates) {
// 重置内存池
pool.reset();
for (const auto& update : updates) {
Order* order = pool.allocate();
// 填充订单数据
// 插入到对应的价格档位
}
}
};
这里有几个关键点:
- 内存池:所有 Order 对象从连续内存中分配,避免碎片
- 预分配数组:价格档位用数组,不用链表。数组在内存中是连续的,遍历时缓存友好
- 大页支持:整个 OrderBook 对象可以放在大页上
一个小技巧: 如果你用 std::vector 来存订单,记得在构造时 reserve 足够的容量。否则 vector 扩容时会重新分配内存,把数据拷来拷去,之前的缓存优化全白费了。
3.5 知识体系总览
下面这张图,把本章的核心逻辑串起来了。你可以看到,从 CPU 缓存到内存布局,每一步都有对应的优化手段。
说白了,内存布局优化不是什么玄学。它就是让数据在物理上更紧凑,让 CPU 能一口气多读点东西。你想想看,订单簿重建时,你要遍历成千上万个订单。如果每个订单都在不同的缓存行、不同的内存页上,那 CPU 大部分时间都在等内存。优化之后,一个缓存行能装好几个订单,一个 TLB 条目能覆盖整个订单簿,速度自然就上去了。
总结一下: 缓存行对齐解决的是「伪共享」和跨行访问问题;内存池解决的是频繁分配释放的开销;预分配和大页解决的是 TLB miss。这三者配合使用,效果远大于单独使用任何一个。
嗯,今天就聊到这儿。这些技巧我在多个低延迟系统里验证过,效果都很稳定。你回去可以看看自己的订单簿实现,是不是还有优化的空间。