背景与痛点:10400 周期到底卡在哪?
第一次把 perf 的cpu-clock事件开到-e cycles档,看到 core-to-core latency 高达 10400 cycles 时,我差点以为小数点打错了。换算一下,2.6 GHz 的 CPU 上这就是 4 µs——足够光信号在光纤里跑 800 米,却只是本机两个核之间打了个招呼。
多核系统里,跨核通信的“天然”成本主要来自三件事:
- 缓存一致性(MESI 协议)的握手
- 跨 NUMA 节点时的内存控制器争用
- 核间中断(IPI)与队列同步带来的序列化
10400 cycles 通常意味着数据在 LLC 里来回弹跳,还顺路去另一个 NUMA 节点“旅游”了一圈。对高频交易、实时特征计算这类场景,4 µs 足以让策略信号变成“过期行情”。
技术方案对比:为什么最后选了 RDMA?
我把常见 IPC 手段拉到同一台 2×Intel 8360Y(36C72T)机器上跑了一圈,测试模型很简单:一核发 64 Byte,另一核回 ACK,循环 1M 次取平均。
| 方案 | 平均延迟 (cycles) | 吞吐 (Mops/s) | 备注 |
|---|---|---|---|
| 共享内存 + 自旋锁 | 17 800 | 0.15 | 伪共享严重 |
| 无锁环形队列 | 12 200 | 0.42 | 仍需 CAS |
| UNIX Domain Socket | 52 000 | 0.02 | 系统调用开销 |
| RDMA 双边 SEND/RECV | 6 100 | 1.10 | 旁路 OS,CPU 不参与拷贝 |
| RDMA 单边 WRITE | 4 300 | 1.35 | 远端 CPU 不感知 |
注:RDMA 数据在本地 NUMA 节点注册内存,对端同一节点消费。
RDMA 把“写-读”动作下沉到网卡,核间不再走缓存一致性协议,10400 直接腰斩到 4300,这就是选它的理由。
核心实现:RDMA 代码骨架(C++17)
下面示例基于librdmbp(RDMA Bare-Metal Profile),单文件可编译。为阅读方便,错误检查用CHECK()宏折叠,真实工程请展开。
/** * @file core2core_rdma.cpp * @brief 把两个 core 绑在同一 NUMA 节点,用 RDMA 完成最小往返 */ #include <infiniband/verbs.h> #include <rdma/rdma_cma.h> #include <numa.h> #include <thread> constexpr int kBufSize = 64; constexpr int kPort = 20886; struct Ctrl { ibv_context* ctx; ibv_pd* pd; ibv_cq* cq; ibv_qp* qp; ibv_mr* mr; char* buf; }; /** 1. 创建 RDMA 资源,注册本地内存 */ Ctrl create_rdma_resources(uint8_t port) { Ctrl c{}; c.ctx = ibv_open_device(ibv_get_device_list(nullptr)[port]); c.pd = ibv_alloc_pd(c.ctx); c.cq = ibv_create_cq(c.ctx, 128, nullptr, nullptr, 0); c.buf = static_cast<char*>(numa_alloc_onnode(kBufSize, numa_node_of_cpu(sched_getcpu()))); c.mr = ibv_reg_mr(c.pd, c.buf, kBufSize, IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE); return c; } /** 2. 建立队列对(Queue Pair) */ void modify_qp_to_rts(Ctrl* c, uint32_t dst_qpn, uint16_t dst_lid) { ibv_qp_attr attr{}; attr.qp_state = IBV_QPS_RTS; attr.sq_psn = 0; attr.dest_qpn = dst_qpn; attr.ah_attr.dlid = dst_lid; attr.ah_attr.is_global = 0; ibv_modify_qp(c->qp, &attr, IBV_QP_STATE | IBV_QP_SQ_PSN | IBV_QP_DEST_QPN | IBV_QP_AV); } /** 3. 发送端:单边写,远端 CPU 不参与 */ void sender_loop(Ctrl* c) { for (int i = 0; < 1000000; ++i) { strcpy(c->buf, "ping"); ibv_send_wr wr{}, *bad_wr = nullptr; wr.opcode = IBV_WR_RDMA_WRITE_WITH_IMM; wr.wr.rdma.remote_addr = reinterpret_cast<uint64_t>(c->peer_addr); wr.wr.rdma.rkey = c->peer_rkey; wr.imm_data = i; wr.send_flags = IBV_SEND_SIGNALED; ibv_post_send(c->qp, &wr, &bad_wr); while (ibv_poll_cq(c->cq, 1, &wc) < 1) {} } }Python 侧只负责建连、交换rkey与remote_addr,代码略。关键是:注册内存时务必落在同一 NUMA 节点,否则 4300 cycles 会瞬间回到 8000+。
NUMA 调优技巧:让内存“少走两步”
- 绑核
numactl --cpunodebind=0 --membind=0 ./core2core_rdma - 把队列、缓冲、CQ 都放到 node 0 的内存
在numa_alloc_onnode()里显式指定节点,比numactl更细粒度。 - 关闭跨节点预取
echo 0 > /sys/devices/system/cpu/cpu*/cache/index3/rdpmc
可防止 LLC 把远端行拖进来。 - 大页 + 固定 TLB
2 MB 大页可把 64 次 TLB miss 降到 1 次,对延迟 jitter 尤其有效。
性能测试:数字说话
测试环境:Intel 8360Y ×2,DDR4-3200,Mellanox CX-6 100 GbE,CentOS 8.6,ibrs=off。
| 场景 | 平均延迟 | P99 延迟 | LLC miss/ops | 吞吐提升 |
|---|---|---|---|---|
| 优化前(10400) | 10400 cycles | 12800 cycles | 2.1 | — |
| RDMA + 本地 NUMA | 4300 cycles | 5100 cycles | 0.05 | +20% |
| 再开大页+绑核 | 3900 cycles | 4400 cycles | 0.02 | +27% |
perf stat 关键行:
2,047,881,492 cycles 10,720,031 cache-misses # 0.5 % → 原来 2.1%VTune 显示 CPI 从 1.35 降到 0.48,前端不再被 mem-barrier 阻塞。
避坑指南:生产环境血泪总结
- 缓存行伪共享
64 Byte 缓冲刚好占满一行,但控制变量(head/tail)别放在同一行;alignas(128)隔离。 - TLB 抖动
线程迁移会让 TLB 刷新,把sched_setaffinity写死在启动脚本。 - RDMA 内存注册泄漏
注册后进程 crash 会留下 MR,重启再注册会报“资源不足”。用ibv_dereg_mr+atexit兜底。 - 网卡 NUMA 不对
CX-6 在 PCIe 3 上,属于 node1,结果 QP 建在 node0,跨 QPI 带来 800 cycles 额外。lstopo先看拓扑再绑。 - 中断聚合
默认irqbalance会把网软-中断甩到远核,echo 1 > /proc/irq/${irq}/smp_affinity_list 固定到本地。
开放式思考
把延迟从 10400 压到 3900 cycles,功耗却增加了 12 W(网卡 PCIe 活动 + 大页锁定)。在延迟敏感与功耗受限之间,你更愿意牺牲哪一边?如果 CPU 进入 sub-1 V 低电压模式,RDMA 的 PIO 写还能维持同样延迟吗?期待听到你的实测故事。