← 返回模块
3.5.3.1beta 可读 · 未来免费校验通过内容版本 2026-05-27

Rust 低延迟之缓存布局、SIMD 与测量

3.5.3 · Rust 低延迟交易 · 编程

周二上午,你坐在 CFFEX 张江 COLO 机房旁边的运维台前。你是一家头部私募 Rust 团队的开发,负责沪深300 ETF (510300.SH) 的做市策略,代码已经过编译、单元测试通过、回测看起来正常,但 profiler 显示热点循环把 70% 的周期花在了两个 AtomicU64::fetch_add 调用上 —— 这两个调用按理每次只应消耗一纳秒。两个核被打满,吞吐量停在每秒一千万次更新,而硬件本可以做到一亿次。9 点 25 分集合竞价快到了,策略组的量化研究员要一个答复。你盯着代码,看见两个原子计数器在同一个结构体里背靠背声明,然后画面对上焦了:这两个计数器躺在同一条 64 字节的缓存行里,两个线程每秒把这条缓存行在两个 L1 之间来回拖拽四千万次,缓存一致性协议把所有 CPU 周期都吃掉了。本课就是让你在这种 bug 上线之前就识破它的训练,顺带把后面 Module 3.5.3 默认你已经掌握的布局词汇与测量工具一并交付。

缓存层级:把数字记牢

现代 x86 服务器在一条 load 指令背后藏了四个数量级的延迟。你必须烂熟于心的数字形态:

L1d         32-48 KiB per core, ~1 ns
L2          256 KiB-1 MiB per core, ~4 ns
L3          8-64 MiB shared, ~12-30 ns
DRAM        ~80-120 ns
cache line  64 bytes; CPU fetches and evicts in 64-byte units

硬件锚点是 CFFEX 张江 / SSE 浦东 / SZSE 福田 COLO 机房常用的 Intel Xeon Gold 6342 (Ice Lake-SP, 24 核),每核 L1d 48 KiB、L2 1.25 MiB、L3 36 MiB,3.4.3 / 3.4.4 / 3.4.5 用的同款。每次布局决策心里默念延迟差:工作集进 L1d 约一纳秒;溢出到 L3 约十五纳秒;到 DRAM 约一百纳秒。布局糟糕的版本可能慢一百倍。x86-64 缓存行永远 64 字节,一次写让整条缓存行脏掉。

Vec<T>Box<[T]> 是工作主力。两者都把元素连续存放在堆上,从缓存硬件视角看与 C++ 的 std::vector<T> 完全一致,硬件预取器特别喜欢它们:一次正向扫描,只需两三条缓存行的训练时间,后面整条数组就在 load 指令到达之前先抵达 L1。生产法则:owned 堆上连续存储默认 Vec<T>;只在你想要构造后大小固定、且想省下八字节容量字段时换成 Box<[T]>;std::collections::LinkedList<T> 在性能代码里几乎永远不是正确选择,因为每个节点单独分配,预取器无法沿着 next 指针追下去。

Rust 默认的 #[repr(Rust)] 会重排结构体字段以紧凑封装。优化器在大约 95% 的情况下做得对,但你不能跨编译器版本依赖字段顺序。需要稳定字段顺序时 —— 比如 FFI、二进制 wire 协议、跨进程的 C 风格结构 —— 使用 #[repr(C)];需要显式对齐时用 #[repr(align(N))]。内省工具是 std::mem::size_of::<T>()std::mem::align_of::<T>(),两者都是 const fn,可以在编译期做 static_assert! 式检查。

伪共享与 CachePadded

伪共享是除数据竞争之外最昂贵的并发 bug。两个线程各自写不同的原子,但这两个原子恰好落在同一条 64 字节缓存行上;一核上的每一次写都让另一核 L1 里的这条行失效,MESI 缓存一致性协议每写一次就把这条行在两核之间 ping 一次,吞吐量塌到大概是分开放在不同缓存行版本的五到十分之一。编译器看到的是两个独立变量,硬件看到的是一条争抢的缓存行,profiler 告诉你周期被烧在了看似无辜的代码里。

Fenced 在下面的代码块是你要刻进肌肉记忆的基线加修复;循环次数 10_000_000Ordering::Relaxed 的选择是有意的,#[repr(C)] 是为了在对比中固定布局:

use crossbeam_utils::CachePadded;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;

#[repr(C)]
struct Counters {
    a: AtomicU64,
    b: AtomicU64,
}

#[repr(C)]
struct CountersPadded {
    a: CachePadded<AtomicU64>,
    b: CachePadded<AtomicU64>,
}

fn bench<C: Send + Sync + 'static>(ctr: Arc<C>, get_a: fn(&C) -> &AtomicU64, get_b: fn(&C) -> &AtomicU64) {
    let c1 = Arc::clone(&ctr);
    let h1 = thread::spawn(move || {
        for _ in 0..10_000_000 { get_a(&*c1).fetch_add(1, Ordering::Relaxed); }
    });
    let c2 = Arc::clone(&ctr);
    let h2 = thread::spawn(move || {
        for _ in 0..10_000_000 { get_b(&*c2).fetch_add(1, Ordering::Relaxed); }
    });
    h1.join().unwrap();
    h2.join().unwrap();
}

crossbeam_utils::CachePadded<T> 是修复伪共享的生产工具:在 x86 上每个被包裹的值独占一条 64 字节缓存行,aarch64 上同理;实现上是 #[repr(align(64))] 加上把总大小补齐到缓存行长度倍数的尾部填充。在 CFFEX 张江的 Xeon Gold 6342 上把上面那段基准跑起来,padded 版本比 unpadded 快 2 到 10 倍,线程数越多差距越大。底层机制是结构体上的 #[repr(align(64))],当被对齐的类型不是原子时直接用这一招 —— 典型场景是线程池工作线程的 per-thread 状态,每个 worker 都应该独占一条缓存行来放它自己的 scratch buffer。

结构体数组 vs 数组结构体

第二个布局决策是表格数据的「行 vs 列」选择。以 510300.SH 的 tick 数据为例,每条 tick 有 timestamp / price / qty 三个字段。AoS 是面向对象的自然建模 —— 一条 tick 一个结构,一个 Vec 装所有结构,一条缓存行大约能装下三条 tick,适合「把第 N 条 tick 的所有字段都给我」这类查询。SoA 把字段拆成三条并行 Vec;一条缓存行能装八个 f64 价格、或八个 u64 时间戳、或十六个 u32 数量,列式扫描只触碰它真正读取的字节。两种写法:

#[repr(C)]
#[derive(Clone, Copy)]
pub struct Tick {
    pub ts: u64,
    pub price: f64,
    pub qty: u32,
}

// Array-of-structures: one Vec of whole ticks.
pub struct TicksAoS {
    pub data: Vec<Tick>,
}

// Structure-of-arrays: three parallel Vecs.
pub struct TicksSoA {
    pub ts:    Vec<u64>,
    pub price: Vec<f64>,
    pub qty:   Vec<u32>,
}

impl TicksAoS {
    pub fn mean_price(&self) -> f64 {
        self.data.iter().map(|t| t.price).sum::<f64>() / self.data.len() as f64
    }
}

impl TicksSoA {
    pub fn mean_price(&self) -> f64 {
        self.price.iter().copied().sum::<f64>() / self.price.len() as f64
    }
}

生产法则:

  1. 「每行只读一个字段」的列扫描 SoA 胜出,f64 tick 数据上典型 3 到 5 倍提速,因为 AoS 版本读每个 price 时还把 tsqty 一起拖进缓存。
  2. 「需要整条 tick 所有字段」的行查找 AoS 胜出,三字段共享同一缓存行的工作集优势占主导。
  3. 访问模式真的混合时,两种都写,用 criterion 测,选你工作负载上赢的那个上线。

国内一线量化 (幻方 / 鸣熙 / 九坤 / 明汯 / 灵均) 与头部券商自营 Rust 团队在热路径上几乎清一色走 SoA + AVX2 + CachePadded 的组合,本课教法与生产现场一致。

SIMD:stable Rust 上的 AVX2 点积

第三个性能杠杆是内层循环的数据并行。AVX2 把一个 f64 操作从单 lane 拓宽到四 lane;AVX-512 在 Sapphire Rapids 与开启相应特性的 EPYC 上拓宽到八 lane。stable Rust 的写法是 std::arch::x86_64::* 内禀函数,用 #[target_feature] 包出允许 AVX2 codegen 的函数,在调用点用 is_x86_feature_detected! 做运行期分发:

use std::arch::x86_64::*;

#[inline(always)]
fn dot_scalar(a: &[f64], b: &[f64]) -> f64 {
    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}

#[target_feature(enable = "avx2")]
unsafe fn dot_avx2(a: &[f64], b: &[f64]) -> f64 {
    debug_assert_eq!(a.len(), b.len());
    let n = a.len();
    let mut acc = _mm256_setzero_pd();
    let mut i = 0;
    while i + 4 <= n {
        let va = _mm256_loadu_pd(a.as_ptr().add(i));
        let vb = _mm256_loadu_pd(b.as_ptr().add(i));
        acc = _mm256_add_pd(acc, _mm256_mul_pd(va, vb));
        i += 4;
    }
    // Horizontal sum of the four lanes.
    let mut tmp = [0.0_f64; 4];
    _mm256_storeu_pd(tmp.as_mut_ptr(), acc);
    let mut s = tmp[0] + tmp[1] + tmp[2] + tmp[3];
    while i < n {
        s += a[i] * b[i];
        i += 1;
    }
    s
}

pub fn dot(a: &[f64], b: &[f64]) -> f64 {
    assert_eq!(a.len(), b.len());
    if is_x86_feature_detected!("avx2") {
        unsafe { dot_avx2(a, b) }
    } else {
        dot_scalar(a, b)
    }
}

三条法则把这个模式串起来:

  1. unsafe 标在 SIMD 函数上,因为内禀函数接受裸指针、假设 load 地址可访问;运行期检测让调用变得健全,因为 is_x86_feature_detected! 在调用点把它门禁住。
  2. 安全包装 dot 是公开接口,调用方不见 unsafe
  3. 使用 is_x86_feature_detected!("avx2") 而不是 #[cfg(target_feature = "avx2")],因为部署目标可能异构 —— 国内 colo 机房里 Ice Lake-SP 与 Sapphire Rapids 共存,同一份二进制必须在两者上都跑得起。

nightly 上的 std::simd (portable_simd) 是未来跨架构答案,带 lane 类型、掩码、gather/scatter;此处只作前向指针,因为尚未稳定。wide crate 是 stable 上把架构内禀函数包成跨架构接口的第三方替代。在 Ice Lake-SP 上跑基准,dot_avx2 比 scalar 参考实现快 3 到 5 倍 f64;表面的 4 倍 (每向量 4 lane) 因水平折叠尾部与 load 开销折扣。

测量工具链

Cargo.toml[dev-dependencies]criterion = "0.5";基准放在 benches/perf.rs 下。每个新低延迟项目都复制一份这个骨架:

use criterion::{black_box, criterion_group, criterion_main, Criterion};
use my_crate::{dot, TicksAoS, TicksSoA};

fn bench_dot(c: &mut Criterion) {
    let a: Vec<f64> = (0..4096).map(|i| i as f64).collect();
    let b: Vec<f64> = (0..4096).map(|i| (i as f64).sin()).collect();
    c.bench_function("dot_4096_avx2", |bench| {
        bench.iter(|| dot(black_box(&a), black_box(&b)))
    });
}

criterion_group!(benches, bench_dot);
criterion_main!(benches);

black_box 是编译器提示,防止优化器把调用 hoist 出循环或把结果折叠成常量;你把每个想让编译器视作不透明的输入都包一层。cargo bench 输出 CLI 报告外加 target/criterion/ 下的 HTML 报告,带逐次迭代纳秒数、置信区间、与上一次基准的对比。#[bench] 那个不稳定属性是更老的替代品,生产 Rust 里不用;criterion 是 stable 上的事实标准。

Fenced 下面是 perf stat 调用形态;四个事件名与列结构在你部署的每台 Linux 上都一样:

$ cargo build --release
$ perf stat -e cache-misses,cache-references,cycles,instructions ./target/release/dot_bench

 Performance counter stats for './target/release/dot_bench':

         12,345,678      cache-misses              #    2.34 % of all cache refs
        527,891,234      cache-references
      8,901,234,567      cycles
     23,456,789,012      instructions              #    2.63  insn per cycle

       3.456789012 seconds time elapsed

cache-miss / cache-reference 比例是你读的第一个数。热循环上 5% 以下表示布局没问题;10% 以上要查,通常用 cargo flamegraph 做函数级归因,底层机制是 perf record -g ./target/release/<bin>; perf script | inferno-flamegraph > flame.svg。课后阅读:Rust Atomics and Locks (marabos.nl/atomics) 第 7 章硬件并发原语 (英文免费;中文社区翻译进行中);What Every Programmer Should Know About Memory (LWN.net 七部分);agner.org/optimize 优化手册;course.rs 的「Rust 圣经」性能优化章;《Rust 编程之道》性能优化章;Intel Intrinsics Guide (intel.com) 是单条内禀函数的查询手册,你一旦开始手调 SIMD 内层循环就离不开它。

Exercise

Exercise

(a) 取伪共享基线 (Counters { a: AtomicU64, b: AtomicU64 }) 与 padded 版本 (CountersPadded { a: CachePadded<AtomicU64>, b: CachePadded<AtomicU64> }),用 criterion 在两线程各做 10_000_000 次 fetch_add(1, Ordering::Relaxed) 的工作负载上分别基准,报告两者墙钟时间与提速比 (预期:64 字节缓存行的现代 x86 上 padded 比 unpadded 快 2-10 倍)。(b) 在同一份 1_000_000 条 tick 的合成数据上实现 TicksAoS::mean_priceTicksSoA::mean_price,基准两者并报告提速比 (预期:SoA 快 3-5 倍,因为 AoS 每次迭代仅读 price 却把 ts / qty 的缓存行字节一并拉进来)。(c) 在两个 4096 元素的 Vec<f64> 输入上实现 dot_scalardot_avx2,通过运行期分发包装 dot 基准,报告提速比 (预期:开启 AVX2 特性的 x86 上 3-5 倍)。(d) 对 (b) 的 AoS 变体跑 perf stat -e cache-misses,cache-references,cycles,instructions ./target/release/<your_bin>,报告 cache-miss 率 (它应当显著高于 SoA 变体的 cache-miss 率)。(e) 用三句话解释:对于可能部署到异构硬件的二进制,为什么 is_x86_feature_detected!("avx2") 是对的门禁,而 #[cfg(target_feature = "avx2")] 不是。

提示
把每个被共享的原子用 CachePadded<AtomicU64> 包住再跑;未填充的基线在两核之间的每一次 store 都付了 MESI 缓存一致性代价。
提示
SoA 把八个 f64 价格塞进一条缓存行,预取器在 load 卡顿前就送达了下一条;AoS 扫描每条缓存行只读其中一字段。

行业背景

国内做 Rust 低延迟的团队主要包括幻方、鸣熙、九坤、明汯、灵均、宽德、思勰、衍盛,以及头部券商自营的中信、中信建投、华泰、海通、招商、国泰君安、中金的 Rust 自营组。这些团队的内部新人培训覆盖的正是本课四块 —— 缓存布局、伪共享、AVX2 SIMD、criterion 测量 —— 之后才让新人接触 510300.SH、510500.SH、510050.SH、IF / IC / IH 等热路径。证监会、SSE、SZSE、CFFEX、SHFE、CZCE、DCE 与中国证券登记结算公司是下游监管面;Wind、东方财富 Choice、聚源数据、同花顺是常见下游数据源。

通向 L2 的桥

到这里你已经掌握了本模块后面默认你已具备的纪律:缓存行尺寸是一个常量、CachePadded<T> 是修复伪共享的生产工具、AoS 与 SoA 是有意识的布局选择、AVX2 加运行期分发是 stable Rust 的 SIMD 模式、criterionperf statcargo flamegraph 是测量工具链。下一课把同样的纪律向上提一层:从单线程要把缓存用好,提到多生产者多消费者队列要在线程之间传递事件而不付伪共享的代价。我们把 3.5.2 L3 手写的 SPSC 环换成 crossbeam_queue::ArrayQueue<T>,在其上叠加 drop-oldest 反压策略,并基准一个四 feed-handler 进单 strategy 的管道与 3.4.4 的 C++ 基线对照。国内一线量化 (幻方 / 鸣熙 / 九坤 / 明汯 / 灵均) 在热路径上 99% 的场合直接拿 ArrayQueue;手写 SPSC 环是那 1% 你要用 profile 来证明的特例,而这个特例的纪律就建立在你刚学到的 CachePadded 之上。