4. 低延迟计算基础设施选型
逐笔特征增量更新的核心矛盾在于:每来一笔成交或委托,就要在微秒级完成若干特征的增量修正,而特征状态往往分散在多个字段中。此时基础设施选型直接决定延迟下限。本节围绕四个关键决策展开:内存布局用 AoS 还是 SoA、线程间通信用无锁队列还是加锁、批处理与向量化如何取舍、以及如何让数据布局贴合 CPU 缓存层级。
一、内存布局:AoS 与 SoA 的适用边界
假设我们要维护每只股票的特征状态,字段包括最新价 last_px、累计成交量 cum_vol、买一量 bid1_qty、卖一量 ask1_qty、最近 20 笔成交均价 ma20。AoS(Array of Structs)把一只股票的所有字段连续存放,SoA(Struct of Arrays)把同一字段的所有股票连续存放。
逐笔更新时,若每笔行情只影响单只股票的少数字段,AoS 的优势是“一次缓存行加载就能拿到该股票的全部状态”,适合单标的深度计算;但若特征计算需要跨标的横向扫描(例如全市场买一量求和、行业均值),AoS 会导致每个缓存行只用到 1/8 甚至更少,缓存浪费严重。SoA 则相反:横向扫描时同一字段连续,向量化友好;但单标的更新会触碰多个数组,产生多次缓存行访问。
实践中的折中方案是“热字段 SoA + 冷字段 AoS”:把每笔更新必改的 last_px、cum_vol、bid1_qty、ask1_qty 拆成独立数组,保证单笔更新只触碰 1~2 个缓存行;把 ma20 等计算成本高、更新频率低的字段放在结构体中按标的存放。判断依据是:统计单笔更新实际写入的字段集合,若写入字段数 ≤ 2 且跨标的扫描频繁,优先 SoA。
二、无锁队列:单生产者单消费者是首选
逐笔行情从接收线程流向特征计算线程,典型拓扑是“1 个行情接收线程 → 1 个特征计算线程”。这种单生产者单消费者(SPSC)场景下,环形无锁队列是最优解:生产者只写 tail,消费者只写 head,通过 std::atomic 的 acquire/release 语义保证可见性,无需互斥锁,也没有锁竞争导致的线程挂起。
关键实现细节有三点。第一,容量取 2 的幂(如 4096),用位掩码 idx & (cap-1) 代替取模,避免除法开销。第二,生产者和消费者的 head/tail 分别放在不同缓存行,用 alignas(64) 填充,防止伪共享(false sharing)——这是无锁队列最常见的性能陷阱。第三,队列元素应尽量小且为平凡可复制类型,避免在队列中传递堆指针导致额外的缓存缺失。
struct alignas(64) SPSCQueue {
std::atomic<size_t> head{0}; // 消费者写
char pad1[64 - sizeof(std::atomic<size_t>)];
std::atomic<size_t> tail{0}; // 生产者写
char pad2[64 - sizeof(std::atomic<size_t>)];
Tick buf[4096]; // 容量 2^12
bool push(const Tick& t) {
size_t t_ = tail.load(std::memory_order_relaxed);
if (t_ - head.load(std::memory_order_acquire) >= 4096) return false;
buf[t_ & 4095] = t;
tail.store(t_ + 1, std::memory_order_release);
return true;
}
};
若拓扑变成多生产者(多个行情源汇入同一特征线程),SPSC 不再适用,此时应优先做“分片”:每个行情源一个 SPSC 队列,特征线程轮询多个队列,而不是直接上 MPSC 无锁队列——后者需要 CAS 循环,竞争激烈时延迟抖动明显大于分片方案。
三、批处理与向量化:延迟与吞吐的权衡
逐笔特征对延迟敏感,但完全不批处理会让每笔都触发一次函数调用和缓存加载。合理的做法是“微批”:消费者一次从队列取出最多 N 笔(例如 N=8 或 16),在寄存器中完成同标的合并后再写回状态。这样既摊薄了循环开销,又不会引入毫秒级等待。批大小需实测:N 过大导致单批处理时间上升、尾延迟变差;N 过小则摊薄效果不足。
向量化适用于“同一字段跨多标的”的计算,例如全市场买一量求和、行业均值。使用 SoA 布局后,编译器可自动向量化连续数组的求和;若需手工控制,可用 AVX2 的 _mm256_load_ps 一次加载 8 个 float。注意:逐笔增量更新本身是“单标的、稀疏写”,向量化收益有限,不要为了向量化强行把单笔更新改成批量扫描,否则反而增加无效计算。
四、CPU 缓存友好设计:让热路径只碰 L1
逐笔特征的热路径应满足:单笔更新触碰的缓存行不超过 2 个,且这些行常驻 L1(32KB 左右)。具体手段包括:把每只股票的热状态压缩到 64 字节以内(一个缓存行),字段用 float 而非 double(若精度允许);把只读的静态配置(如股票代码映射)与可写状态分离,避免写操作使只读行失效;对多线程共享的计数器使用 alignas(64) 隔离。
一个可参考的示例参数组合(属合理假设,非实测):股票数 5000,热字段 4 个 float 共 16 字节,按 SoA 存放约 320KB,超出 L2 但可被 L3 容纳;单笔更新只写 1 个数组元素,触碰 1 个缓存行;SPSC 队列容量 4096,元素 32 字节,约 128KB;微批大小 8。该组合下热路径的缓存行访问集中在少数几个数组段,便于用 perf 的 cache-misses 与 L1-dcache-load-misses 观察优化效果。
需要强调的是,四类基础设施并非独立选择:SoA 布局让向量化成为可能,无锁队列的分片设计又依赖热字段的紧凑布局来降低单笔拷贝成本。选型的落脚点始终是“单笔更新在热路径上触碰多少缓存行、经过多少次原子操作”,围绕这两个可测量指标做取舍,比套用任何单一模式都更可靠。