面向高频交易(HFT)的 CPU Cache 效率(延迟视角)
本文讲的是:在超低延迟系统(行情接入、策略计算、下单链路)中,如何让 **CPU 的内存层级(L1/L2/L3/DRAM)**为你服务。重点放在 cache 行为、一致性(coherence)以及访问模式——当你已经去掉显而易见的开销后,真正主宰 p99 / p99.9 延迟的往往就是这些因素。
0. HFT 的语境:什么叫“高效”
- 吞吐 vs 延迟:很多提高吞吐的手段(更大的批处理、更深的队列)会抬高尾延迟。在 HFT 中,你通常更在意可预测、有上界的延迟。
- 真正的敌人是方差:cache miss 不只是“更慢”,它会制造抖动(依赖型 miss、coherence stall、TLB miss、page fault、跨 NUMA 跳转)。
- 工作集(working set)就是预算:最快的代码往往是那种热工作集能留在 L1/L2(至少留在本地 LLC),并且不会在核间来回“弹跳”cache line 的代码。
1. 实用的 cache 心智模型(够用且能推理)
1.1 cache line 是搬运的基本单位
cache 以 cache line 为单位搬运数据(常见 64B)。你只访问 1 个字节,CPU 也通常会把整个 line 拉进来。
直接推论:
- **空间局部性(spatial locality)**很重要:连续数据更容易保持“热”。
- **伪共享(false sharing)**很重要:同一条 line 上的无关变量会互相牵制。
- **指针追逐(pointer chasing)**很致命:它破坏空间局部性,也让预取(prefetch)变得困难。
1.2 把三类成本分开看
- 命中延迟(L1/L2/L3)
- 缺失延迟(DRAM / 远端 NUMA / 存储介入导致的 major fault)
- 一致性/排序成本(cache line 所有权变化、失效(invalidation)、fence)
很多“看起来很神秘”的卡顿并不是 DRAM miss,而是来自共享热点 line 的 coherence stall(原子操作、锁、共享计数器等)。
1.3 一致性:写需要独占所有权
一个 core 想写某条 line,通常需要它处于可写状态(exclusive/modified)。如果其他 core 也持有这条 line,CPU 就需要让其他副本失效或转移所有权。因此,“一个全局原子计数器”很容易变成延迟制造机。
2. HFT 常见的内存访问形态(以及 cache 的反应)
2.1 行情接入流水线
常见流程:
- 解析报文
- 规范化消息
- 更新盘口/特征状态
- 发布给策略线程
cache 角度的要点:
- 对热字段优先采用 SoA(structure-of-arrays)
- 每个标的(instrument)状态尽量紧凑且对齐
- 避免“全局共享一切”;更偏向 按 core 分片(per-core shards)
2.2 盘口(order book)更新
盘口更新常带来:
- 按标的随机-ish 访问
- 高频小更新
- 多组件读取
你的目标通常是:
- 让热路径只操作一个很小的热集合(例如 top-of-book / 最优档)
- 在关键路径避免冷指针与重量级容器
- 尽量减少跨核写(盘口更新本质是写)
3. 布局胜过小聪明:数据组织策略
3.1 热/冷拆分(hot/cold splitting,也叫“struct peeling”)
把频繁使用的字段放进紧凑的“热结构体”;把很少用的字段放进“冷结构体”,按需访问。
收益:
- 热工作集更小 → 更容易留在 L1/L2
- 每次操作触及的 cache line 更少
3.2 AoS vs SoA(以及各自何时更好)
- AoS(array of structs):如果你每次都要用到一个元素的大多数字段,AoS 往往更合适。
- SoA(struct of arrays):如果你每次只用少数字段,或你能做向量化,SoA 往往更合适。
HFT 中常见做法是:对热的数值字段(price、size、flags)偏向 SoA;当一组字段总是一起被用到且打包后更利于局部性时,再考虑 AoS。
3.3 避免指针追逐
链式结构(链表、堆上节点的树)通常会导致:
- 局部性差
- 分支不可预测
- 预取效果差
更常用的替代:
- 扁平数组 /
std::vector(预先分配、复用) - 开放寻址(open addressing)的哈希表
- ring buffer
如果不得不用指针,可以考虑:
- arena 分配(尽量让对象在内存中更“连续”)
- 基于 index 的引用(数组偏移而不是裸指针)
3.4 对齐与 padding(避免不小心共享 cache line)
两类经典问题:
- 伪共享(false sharing):两个线程写不同变量,但它们落在同一条 cache line 上。
- 真共享(true sharing):多个 core 争用同一条可写 line(原子变量/锁)。
常见修复:
- 把频繁写的 per-thread/per-core 状态按 cache line 大小对齐
- 用 padding 把不同写者隔离到不同 line
本仓库里已经有一个可运行示例:
codes/src/perf/cache_alignment_example.cpp
4. 写比读更难:控制 coherence 成本
4.1 “单个共享计数器”陷阱
即使是 relaxed 原子,在多核同时自增同一个计数器时也可能很贵:每次自增都需要拿到写所有权,并触发失效传播。
更常见的替代:
- per-core 计数器 + 周期性聚合
- 允许的话用近似计数
4.2 避免热路径上的跨核交接(handoff)
典型队列交接模式:
- core A 的 producer 写 → core B 的 consumer 读
- 队列元数据所在的 cache line 在两个 core 之间来回“弹跳”
缓解方式:
- SPSC ring buffer(读/写索引分离,且仔细 padding)
- per-core mailbox(N 个 producer 推送到每个 consumer 的队列)
- 有上界的 batching(用更少 coherence 事件换取吞吐,但要小心尾延迟)
4.3 原子与内存序(不只是正确性,也影响 cache)
更强的内存序(acquire/release/seq_cst)可能带来:
- 额外的 fence
- 推测执行受阻
- 在竞争 cache line 时更高的额外延迟
经验原则:
- 在正确的前提下使用尽可能弱的内存序
- 把同步变量放到独立的 cache line 上
5. 预取(prefetch):何时有用,何时有害
5.1 硬件预取器喜欢规律
它擅长:
- 线性扫描
- 可预测步长(stride)
它不擅长:
- 指针追逐
- 随机分支导致的不可预测访问
因此最好的“预取优化”常常不是加 prefetch 指令,而是把数据布局改到可预取。
5.2 软件预取(少用、谨慎用)
软件预取可能有效的前提:
- 你能较早预测下一次访问地址
- 预取到真正使用之间有足够计算,能隐藏延迟
风险(尤其在延迟敏感的 HFT):
- 预取错行 → cache 污染
- 带宽/压力增加 → 尾延迟变差
6. TLB 与页:cache 效率的近亲
即使 cache 本身没问题,TLB miss 也可能成为主导因素。
建议:
- 热工作集要按页数来控制,不只按字节数
- 对大而热、连续的数据结构(盘口/特征数组)可考虑 huge pages,但要验证运维与碎片化等权衡
- 避免在热路径出现频繁 page fault 的映射/访问模式
7. NUMA 与拓扑:只有“本地”才够快
在多路(multi-socket)系统上:
- 本地 DRAM 通常比远端 DRAM 更快、更可预测
- LLC 的拓扑也很关键(共享/分段)
实用规则:
- 给关键线程绑核(affinity)
- 在同一 NUMA 节点上分配并使用内存(first-touch 或显式策略)
- 避免跨 socket 的共享可写数据
8. 延迟优先的检查清单(按顺序做)
8.1 让热数据变小
- 热/冷拆分
- 缩小结构体(字段重排、更小类型)
- 减少间接层(indirection)
8.2 让访问变可预测
- 连续数组
- 稳定的遍历顺序
- 避免“随机内存 + 不可预测分支”的组合
8.3 消除共享可写 cache line
- per-core 状态
- 对齐/隔离同步变量
- 移除竞争计数器/锁
8.4 控制分配与生命周期
- 热路径避免动态分配
- 复用 buffer/object(pool)
- arena 尽量做到 per-thread/per-core
8.5 用测量验证(不要靠猜)
- 用真实流量分布测 p50/p99/p99.9
- 追踪 cache miss、LLC miss、内存 stall 周期、以及 coherence 相关事件
- 一次只改一个变量
9. 如何测 cache 效率(更贴近 HFT)
9.1 基准测试:避免自欺
常见坑:
- microbenchmark 全部落在 cache 里(看起来很快但不代表真实场景)
- 数据分布不真实(标的太少 / 盘口太小)
- 计时噪声大或没有绑核
- 用“平均延迟”掩盖尾延迟回退
9.2 值得关注的信号
即使不指定具体工具,你一般也会想观察:
- L1/L2/LLC miss 率
- stalled cycles(卡在内存上的周期)
- coherence invalidation / line transfer
- 远端 NUMA 访问
关键是:把一次延迟尖峰关联回某种内存行为(miss、coherence stall、TLB miss),再通过重设布局/所有权去解决。
10. 在 HFT 中通常会赢的设计模式
- 每个数据分片单写者(single-writer)(所有权是核心)
- 按 core 分区(per-core partitioning)(状态 + 队列)
- 扁平、连续的数据结构,并显式管理生命周期
- 有上界的 batching(只在能减少 coherence 且不拉高尾延迟时使用)
- 热/冷拆分 + 热字段 SoA
11. 总结(一段话)
HFT 里的 CPU cache 效率,本质上是让热工作集足够小且足够本地,并且消除共享可写 cache line引发的 coherence 流量。数据布局(连续性、热/冷拆分)、所有权(single-writer、per-core 分片)与拓扑(NUMA 本地性、绑核)通常比“指令级小聪明”更决定成败。正确的工作流是:先按局部性/所有权重设设计,再用延迟分布和内存/coherence 指标确认效果。