硬件架构与NUMA亲和性:CPU缓存层级、NUMA节点绑定、避免跨核通信

做量化交易系统,说白了就是在跟时间赛跑。我见过太多团队,代码写得花里胡哨,结果一上生产环境,延迟直接崩了。为什么?因为他们忽略了硬件。

今天咱们聊聊CPU缓存层级、NUMA节点绑定,还有怎么避免跨核通信。这些知识点,我当年在搭建自营交易系统时踩过不少坑,今天一并分享给你。

CPU缓存层级:别让数据在内存里闲逛

先问个问题:你的交易策略数据存在哪?很多人脱口而出「内存」。嗯,没错,但不够精确。

CPU访问内存的速度,跟访问L1缓存比,差了将近两个数量级。你想想看,一次L1缓存命中只要1纳秒左右,而一次内存访问要100纳秒。如果你的高频策略每笔交易要访问几百次数据,这差距就大了去了。

CPU缓存分三级:

  • L1缓存:每个核心独享,32KB左右,速度最快
  • L2缓存:每个核心独享,256KB左右,速度次之
  • L3缓存:多个核心共享,几MB到几十MB,速度相对慢一些

我在项目中遇到过一个问题:策略引擎跑得好好的,突然延迟飙升。查了半天,发现是某个共享数据结构被多个线程频繁修改,导致缓存行在L3和L1之间来回「乒乓」。这就是典型的缓存行颠簸

核心原则:让数据尽量待在L1缓存里,别让它掉到内存去。

怎么做?两个技巧:

  1. 数据对齐:把热数据放在同一个缓存行(64字节)内,避免跨行访问。
  2. 伪共享避免:如果多个线程写不同变量,确保它们不在同一个缓存行。用 alignas(64) 或者填充字节来隔离。
// 伪共享示例:两个线程写不同变量,但可能在同一个缓存行
struct Bad {
    int a;  // 线程1写
    int b;  // 线程2写
};

// 正确做法:用填充隔离
struct Good {
    alignas(64) int a;  // 线程1写
    char pad[60];       // 填充到64字节
    alignas(64) int b;  // 线程2写
};

嗯,这里要注意:填充会浪费内存,但换来的性能提升是值得的。我见过一个团队,就靠这个优化,把订单处理延迟从5微秒降到了1.2微秒。

NUMA节点绑定:别让你的数据「串门」

现代服务器都是多路CPU架构,每个CPU有自己的内存控制器。这就是NUMA(非统一内存访问)。

说白了,CPU访问本地内存很快,访问远端内存就慢。这个「慢」不是一点点,而是30%-50%的延迟差异。

我曾经接手过一个交易系统,延迟一直不稳定。排查后发现,进程被调度到了CPU 0,但分配的内存却在CPU 1的NUMA节点上。每次内存访问都要跨QPI总线,延迟自然忽高忽低。

避坑指南:我曾经犯过一个错误——在NUMA架构下用默认内存分配策略。结果订单处理线程和它的数据不在同一个节点,延迟直接翻倍。后来我强制绑定了NUMA节点,问题才解决。

NUMA节点绑定的具体做法:

  • 使用 numactl 命令绑定进程到指定节点
  • 在代码中用 sched_setaffinity 设置CPU亲和性
  • mbindlibnuma 库分配本地内存
// C++代码示例:绑定线程到指定CPU核心
#include <pthread.h>
#include <sched.h>

void bind_to_core(int core_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(core_id, &cpuset);
    
    pthread_t current_thread = pthread_self();
    int result = pthread_setaffinity_np(current_thread, sizeof(cpu_set_t), &cpuset);
    if (result != 0) {
        // 处理错误
    }
}

// 使用示例:绑定订单处理线程到核心2
// bind_to_core(2);

个人习惯:我一般会预留几个核心给操作系统和中断处理,剩下的核心全部绑定给交易线程。比如32核的机器,我会用核心0-3跑系统,核心4-31跑交易逻辑。

避免跨核通信:能在一个核上干完就别折腾

跨核通信的成本有多高?我直接给你数据:

通信方式 延迟(纳秒) 说明
同一核心L1缓存 ~1 最快,无锁
同一核心L2缓存 ~4 还是很快
同一NUMA节点跨核 ~40 需要缓存一致性协议
跨NUMA节点 ~100 最慢,要过QPI

看到没?跨NUMA节点的通信,比同一核心慢了100倍。你想想看,如果你的交易策略里频繁用锁或者原子操作,那延迟能低才怪。

怎么避免?我总结了三条经验:

  1. 单线程优先:能用一个线程干完的活,绝不用两个。很多交易策略其实可以设计成单线程流水线。
  2. 数据分区:如果必须多线程,就把数据按交易品种或订单ID分区,每个线程只处理自己的分区。
  3. 无锁设计:用 std::atomicmemory_order_relaxed 代替互斥锁,减少缓存一致性开销。
// 无锁队列示例:单生产者单消费者
template<typename T>
class LockFreeQueue {
    static constexpr size_t CAPACITY = 1024;
    T buffer[CAPACITY];
    std::atomic<size_t> head{0};
    std::atomic<size_t> tail{0};
    
public:
    bool push(const T& item) {
        size_t t = tail.load(std::memory_order_relaxed);
        size_t h = head.load(std::memory_order_acquire);
        if ((t + 1) % CAPACITY == h) return false; // 队列满
        
        buffer[t] = item;
        tail.store((t + 1) % CAPACITY, std::memory_order_release);
        return true;
    }
    
    bool pop(T& item) {
        size_t h = head.load(std::memory_order_relaxed);
        size_t t = tail.load(std::memory_order_acquire);
        if (h == t) return false; // 队列空
        
        item = buffer[h];
        head.store((h + 1) % CAPACITY, std::memory_order_release);
        return true;
    }
};

关键点:这个无锁队列只适用于单生产者单消费者场景。如果你有多生产者,那就得用更复杂的机制了。我个人建议,交易系统里尽量保持单生产者单消费者的设计模式。

知识体系总览

下面这张图,是我根据多年经验总结的硬件优化知识体系。你可以把它当作一个检查清单:

硬件架构与NUMA亲和性优化知识体系 CPU缓存层级 • L1缓存:1ns,32KB/核 • L2缓存:4ns,256KB/核 • L3缓存:10ns,共享 优化技巧: • 数据对齐到64字节 • 避免伪共享 • 热数据紧凑排列 常见问题: • 缓存行颠簸 • 伪共享 • 缓存未命中 NUMA节点绑定 • 本地内存访问:快 • 远端内存访问:慢30-50% 绑定方法: • numactl命令行 • sched_setaffinity • mbind/libnuma 最佳实践: • 线程绑定到固定核心 • 内存分配在本地节点 • 预留核心给系统 避免跨核通信 • 同核L1:1ns • 同节点跨核:40ns • 跨节点:100ns 设计原则: • 单线程优先 • 数据分区 • 无锁设计 实现方案: • 单生产者单消费者队列 • 内存屏障优化 • 原子操作轻量化 核心目标:让数据靠近计算,让计算留在原地

这张图把三个核心模块串起来了。你对照着看,就知道自己的系统在哪块还有优化空间。

我的建议:别一上来就搞什么高级优化。先把CPU亲和性和NUMA绑定做好,这通常能带来50%以上的延迟改善。然后再去抠缓存行对齐、无锁队列这些细节。

好了,这一章的内容就这些。记住:硬件是基础,软件是上层建筑。基础不牢,地动山摇。下次遇到延迟问题,先看看你的数据是不是在正确的核心上、正确的缓存里。


公众号:蓝海资料掘金营,微信deep3321