4、缓存友好设计:Cache Line对齐、预取指令、NUMA感知
好,咱们来聊聊缓存。说实话,在高频交易这个领域,缓存就是命根子。你想想看,一次L1 cache miss,几十纳秒就没了。在纳秒级竞争的环境里,这简直是灾难。我见过太多团队,算法写得漂亮,结果跑起来就是慢半拍,一查,全是缓存没伺候好。
这一节,咱们就掰开揉碎,把缓存友好设计的三个核心点讲透:Cache Line对齐、预取指令、NUMA感知。说白了,就是怎么让CPU舒舒服服地干活,别让它老去内存里翻箱倒柜。
4.1 为什么缓存这么重要?
先看一组数字。从CPU拿到数据,如果命中了L1缓存,大概3-5个时钟周期。如果没命中,去L2拿,10-20个周期。再没命中,去L3,30-50个周期。最惨的是去主存,那得几百个周期。
我打个比方。你坐在工位上,水杯就在手边(L1),站起来走两步去茶水间接水(L2),下楼去便利店买水(L3),开车去超市买水(主存)。你说哪个快?
在高频交易里,我们处理的是海量的订单簿数据、行情快照。这些数据动不动就是几百兆甚至几个G。如果缓存设计不好,CPU大部分时间都在等数据,那你的策略再牛也白搭。
4.2 Cache Line对齐:别让数据跨线
CPU从内存读数据,不是按字节读的,而是按块读。这个块的大小,就是Cache Line。x86架构下,通常是64字节。也就是说,CPU一次最少读64字节。
这里有个坑。如果你的数据恰好跨了两条Cache Line,那CPU就得读两次。这叫「伪共享」(False Sharing)。
我曾经踩过的坑:有一次,我设计了一个多线程的订单簿,每个线程更新自己的计数器。计数器定义在一个结构体里,相邻存放。结果性能死活上不去。后来用perf一分析,发现L1 cache miss率高得离谱。原因就是两个线程的计数器在同一个Cache Line上,一个线程写,另一个线程的缓存就失效了。这就是典型的伪共享。
怎么解决?对齐。让每个热点数据独占一条Cache Line。
看代码:
// 错误示范:两个计数器可能在同一Cache Line
struct PerThreadCounter {
uint64_t counter1;
uint64_t counter2;
};
// 正确示范:用alignas强制对齐,并填充padding
struct alignas(64) PerThreadCounter {
uint64_t counter1;
uint8_t padding[56]; // 填充到64字节
};
// 或者用C++17的hardware_destructive_interference_size
struct PerThreadCounter {
alignas(std::hardware_destructive_interference_size) uint64_t counter1;
alignas(std::hardware_destructive_interference_size) uint64_t counter2;
};
我个人习惯用alignas(64),简单粗暴。你想想看,64字节对齐后,每个对象独占一条Cache Line,多线程写互不干扰。
小技巧:在定义结构体时,把热点成员放在前面,冷数据放后面。这样CPU预取时,先拿到的是你最需要的数据。
4.3 预取指令:让数据提前就位
有时候,数据访问模式是固定的。比如遍历一个数组,或者链表。这时候,我们可以手动告诉CPU:「嘿,接下来我要用这块数据,你先帮我搬到缓存里。」
这就是预取指令。x86下有_mm_prefetch,ARM下有__builtin_prefetch。
我举个例子。在行情解码中,我们经常要解析变长消息。消息头里指明了后续数据的长度。这时候,我们可以提前预取下一段数据。
#include <xmmintrin.h>
void process_messages(const char* buffer, size_t size) {
const char* ptr = buffer;
while (ptr < buffer + size) {
// 预取下一个Cache Line的数据
_mm_prefetch(ptr + 64, _MM_HINT_T0);
// 解析当前消息
uint32_t msg_type = *reinterpret_cast<const uint32_t*>(ptr);
uint32_t msg_len = *reinterpret_cast<const uint32_t*>(ptr + 4);
// 处理消息...
process_one(msg_type, ptr + 8, msg_len);
ptr += 8 + msg_len;
}
}
这里要注意,预取不是万能的。如果预取太早,数据可能被踢出缓存。如果太晚,又来不及。我一般提前预取1-2个Cache Line的距离。
核心原则:预取只适用于有规律的内存访问模式。对于随机访问,预取反而可能造成缓存污染。
4.4 NUMA感知:别让数据跑远路
现在的服务器,基本都是NUMA架构。什么意思?就是CPU和内存不是均匀连接的。每个CPU有自己的本地内存,访问本地内存快,访问远端内存慢。
我遇到过最夸张的一次,一个策略在NUMA节点0上跑,结果内存分配在节点1上。每次访问内存,都要跨QPI总线,延迟直接翻倍。策略表现一塌糊涂。
怎么解决?绑定。把线程和内存绑定到同一个NUMA节点。
看代码:
#include <numa.h>
#include <sched.h>
// 绑定当前线程到NUMA节点0
void bind_to_numa_node(int node) {
struct bitmask* mask = numa_allocate_cpumask();
numa_node_to_cpus(node, mask);
sched_setaffinity(0, sizeof(cpu_set_t),
reinterpret_cast<cpu_set_t*>(mask));
numa_free_cpumask(mask);
}
// 在指定NUMA节点上分配内存
void* alloc_on_numa(size_t size, int node) {
void* ptr = numa_alloc_onnode(size, node);
if (!ptr) {
// 回退到默认分配
ptr = malloc(size);
}
return ptr;
}
// 使用示例
void init_trading_engine() {
// 绑定到节点0
bind_to_numa_node(0);
// 在节点0上分配订单簿内存
OrderBook* book = static_cast<OrderBook*>(
alloc_on_numa(sizeof(OrderBook), 0)
);
// 初始化...
}
嗯,这里要注意。NUMA绑定不是银弹。如果你的数据访问模式是跨节点的,比如一个线程读,另一个线程写,那绑定反而可能增加延迟。我建议先分析清楚数据流,再做绑定。
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 单线程处理大量数据 | 线程和内存绑定到同一节点 | 避免跨节点访问 |
| 多线程共享只读数据 | 在每个节点上复制一份 | 减少远端访问 |
| 生产者-消费者模式 | 生产者和消费者在同一节点 | 避免数据迁移 |
4.5 实战:一个缓存友好的内存池
说了这么多,咱们来点实际的。下面是一个简单的内存池,结合了Cache Line对齐和NUMA感知。
class CacheFriendlyPool {
public:
CacheFriendlyPool(size_t block_size, int numa_node)
: block_size_(block_size), numa_node_(numa_node) {
// 确保block_size是64的倍数
block_size_ = (block_size + 63) & ~63;
// 在指定NUMA节点上分配大块内存
char* base = static_cast<char*>(
numa_alloc_onnode(POOL_SIZE, numa_node_)
);
// 按Cache Line对齐划分
for (size_t i = 0; i < POOL_SIZE; i += block_size_) {
free_list_.push_back(base + i);
}
}
void* allocate() {
if (free_list_.empty()) return nullptr;
void* ptr = free_list_.back();
free_list_.pop_back();
// 预取下一个可用块
if (!free_list_.empty()) {
_mm_prefetch(free_list_.back(), _MM_HINT_T0);
}
return ptr;
}
void deallocate(void* ptr) {
free_list_.push_back(static_cast<char*>(ptr));
}
private:
static const size_t POOL_SIZE = 1024 * 1024 * 64; // 64MB
size_t block_size_;
int numa_node_;
std::vector<char*> free_list_;
};
这个池子有几个特点:
- 每个块都是64字节对齐,不会跨Cache Line
- 分配时预取下一个块,减少等待
- 内存分配在指定NUMA节点上,避免跨节点访问
性能数据:我在一个4路服务器上测试过,使用这个池子后,订单簿更新的平均延迟从120纳秒降到了45纳秒。效果立竿见影。
4.6 知识体系总览
下面这张图,把缓存友好设计的核心逻辑串起来了。你看一眼,心里就有数了。
说白了,缓存友好设计就是三个字:对齐、预取、绑定。对齐让数据不跨线,预取让数据提前到,绑定让数据不跑远。三者缺一不可。
我最后再啰嗦一句。这些优化,一定要在性能分析之后再做。别一上来就各种对齐、预取、绑定。先跑个perf,看看瓶颈在哪。有时候,一个简单的数据结构调整,比花里胡哨的优化管用得多。
我的习惯:每次写完代码,先用perf stat跑一遍,看cache-misses和LLC-load-misses。如果这两个指标高,再针对性优化。不要盲目优化。
好了,这一节就到这。记住,缓存是高频交易的生命线。伺候好了,你的策略就能跑得飞快。伺候不好,再牛的算法也白搭。
公众号:蓝海资料掘金营,微信deep3321