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。

AoS 与 SoA 在逐笔更新中的缓存行访问对比 AoS:单标的全字段连续 股票A: last_px | cum_vol | bid1_qty | ask1_qty | ma20 股票B: last_px | cum_vol | bid1_qty | ask1_qty | ma20 股票C: last_px | cum_vol | bid1_qty | ask1_qty | ma20 横向扫描买一量:每读 1 个缓存行仅命中 1 个字段,浪费约 7/8 SoA:同字段跨标的连续 last_px: [A][B][C][D][E][F][G][H] cum_vol: [A][B][C][D][E][F][G][H] bid1_qty:[A][B][C][D][E][F][G][H] ask1_qty:[A][B][C][D][E][F][G][H] 横向扫描买一量:连续命中 8 个字段,可向量化 折中:热字段 SoA + 冷字段 AoS 热字段数组(每笔必写) last_px / cum_vol / bid1_qty 冷字段结构体(低频更新) ma20 / 波动率 / 分位数 单笔更新触碰缓存行数 热字段 1~2 行,冷字段按需加载 判断依据:统计单笔更新实际写入字段集合;写入 ≤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) 隔离。

选型检查顺序建议:先确定线程拓扑(SPSC 还是多源),据此选队列;再统计单笔写入字段集合,据此定 AoS/SoA;最后用 perf 观察 L1 缺失率与伪共享事件,验证布局是否达标。三步都围绕“热路径触碰的缓存行数”这一核心指标,而不是盲目追求无锁或向量化。

一个可参考的示例参数组合(属合理假设,非实测):股票数 5000,热字段 4 个 float 共 16 字节,按 SoA 存放约 320KB,超出 L2 但可被 L3 容纳;单笔更新只写 1 个数组元素,触碰 1 个缓存行;SPSC 队列容量 4096,元素 32 字节,约 128KB;微批大小 8。该组合下热路径的缓存行访问集中在少数几个数组段,便于用 perf 的 cache-misses 与 L1-dcache-load-misses 观察优化效果。

需要强调的是,四类基础设施并非独立选择:SoA 布局让向量化成为可能,无锁队列的分片设计又依赖热字段的紧凑布局来降低单笔拷贝成本。选型的落脚点始终是“单笔更新在热路径上触碰多少缓存行、经过多少次原子操作”,围绕这两个可测量指标做取舍,比套用任何单一模式都更可靠。