4. 结构体 vs 数组布局:AoS 与 SoA 的对比

说到内存布局,我得先坦白一件事。我早年写回测引擎时,K线数据直接用了个结构体数组,心想「这不挺自然的吗?」结果回测跑起来,CPU缓存命中率惨不忍睹。后来我才意识到——数据怎么放,决定了你的回测能跑多快

今天咱们就聊聊两种经典的内存布局:AoS(Array of Structs)SoA(Struct of Arrays)。说白了,就是「把结构体串成数组」还是「把每个字段单独拎出来放数组」。

4.1 AoS:最直观,但未必最快

AoS 就是咱们平时最习惯的写法:

struct KLine {
    double open;
    double high;
    double low;
    double close;
    double volume;
    uint64_t timestamp;
};

KLine klines[100000];  // 100000根K线

每个 KLine 对象里,所有字段挨个排好。你访问 klines[i].close 时,CPU 会把整个结构体加载到缓存行里。

问题在哪?

回测时,你往往只用到其中一两个字段。比如计算移动平均线,你只需要 close。但 AoS 布局下,CPU 把 openhighlowvolume 也一起拉进来了。这些数据你根本用不上,白白浪费了宝贵的缓存空间。

核心矛盾:AoS 的缓存利用率 = 你实际需要的字段大小 / 整个结构体大小。如果结构体有 8 个字段,你只用 1 个,那缓存利用率只有 12.5%。

我曾在项目中遇到过,一个回测任务跑 10 年数据,AoS 版本用了 47 秒,改成 SoA 后降到 12 秒。差别就在缓存命中率上。

4.2 SoA:为缓存而生

SoA 的思路很简单:把每个字段单独拿出来,存成数组。

struct KLineSoA {
    double* open;
    double* high;
    double* low;
    double* close;
    double* volume;
    uint64_t* timestamp;
};

// 初始化
KLineSoA klines;
klines.close = new double[100000];
klines.volume = new double[100000];
// ... 其他字段同理

现在你算均线时,只遍历 close 数组。CPU 缓存里全是连续的 close 数据,没有垃圾字段干扰。

为什么会快?

  • 空间局部性更好:连续访问 close[i]close[i+1],缓存预取机制能完美工作。
  • 缓存利用率高:每个缓存行里全是有效数据,没有浪费。
  • SIMD 向量化友好:编译器可以轻松对连续 double 数组做向量化。

一个小技巧:如果你用 C++,可以考虑用 std::vector<double> 代替裸指针。既安全,性能也不差。我个人习惯在 SoA 里用 std::vector,除非对内存布局有极端要求。

4.3 回测系统中 K 线数据的最佳实践

嗯,这里要注意。并不是所有场景都适合 SoA。我总结了几条经验:

场景 推荐布局 原因
全字段遍历(如导出 CSV) AoS 代码简洁,一次遍历搞定所有字段
单字段计算(如均线、成交量) SoA 缓存利用率高,性能提升明显
多字段混合计算(如 KDJ) SoA 按需加载字段,避免缓存污染
数据量小(< 1000 根 K 线) AoS 性能差异可忽略,代码可读性更重要

我个人建议:回测引擎的核心循环,一律用 SoA。你想想看,回测 90% 的时间都在算指标、跑策略,这些操作往往只用到少数几个字段。SoA 带来的缓存优势,是实打实的。

4.4 内存对齐与填充

聊完布局,咱们再说说对齐。这玩意儿看着不起眼,踩坑了才知道疼。

先看个例子:

struct Misaligned {
    char type;      // 1字节
    double price;   // 8字节
    int volume;     // 4字节
};

这个结构体有多大?直觉上 1+8+4=13 字节。但实际 sizeof 会告诉你:24 字节

为什么?因为编译器会在 type 后面填充 7 个字节,让 price 对齐到 8 字节边界。然后在 volume 后面再填充 4 个字节,让整个结构体大小是 8 的倍数。

我曾经踩过的坑:有一次我把 K 线结构体里的字段顺序排错了,导致每个结构体多了 16 字节填充。100 万根 K 线,多占了 16MB 内存。更坑的是,缓存行里一半都是填充数据,性能直接掉了 30%。

最佳实践

  • 按字段大小降序排列:把大的字段(double、uint64_t)放前面,小的(int、char)放后面。
  • 使用 #pragma pack 要谨慎:强制紧凑排列虽然省内存,但可能导致未对齐访问,在 ARM 等平台上会触发异常。
  • static_assert 检查大小:确保你的结构体大小符合预期。
// 优化后的布局
struct AlignedKLine {
    double open;     // 8字节
    double high;     // 8字节
    double low;      // 8字节
    double close;    // 8字节
    double volume;   // 8字节
    uint64_t ts;     // 8字节
    // 没有填充,总共 48 字节
};

static_assert(sizeof(AlignedKLine) == 48, "Unexpected size!");

4.5 一张图看懂内存布局

下面这张 SVG 图,展示了 AoS 和 SoA 在内存中的实际样子。你可以直观看到缓存行里装了什么。

AoS vs SoA 内存布局对比 AoS 布局 每个缓存行包含多个字段,但只用到一个 缓存行 0 open[0] high[0] low[0] close[0] vol[0] ts[0] open[1] high[1] low[1] ← 实际只用 close[0],其他 5/6 的缓存空间浪费了 → SoA 布局 每个数组连续存放,缓存行里全是有效数据 close[] close[0] close[1] close[2] close[3] close[4] close[5] close[6] close[7] close[8] volume[] vol[0] vol[1] vol[2] vol[3] vol[4] vol[5] vol[6] vol[7] vol[8] 缓存行 0(全是 close) 缓存行 1(全是 close) 缓存行 2(全是 close) SoA 缓存利用率接近 100%,AoS 可能只有 16.7%

4.6 小结

说了这么多,其实核心就一句话:数据怎么放,决定了你的回测能跑多快。AoS 写起来舒服,但 SoA 跑起来快。在回测系统这种对性能敏感的场景里,我建议你优先考虑 SoA。

至于内存对齐,记住一个原则:大字段放前面,小字段放后面。别让编译器偷偷塞一堆填充字节,既浪费内存又拖慢性能。

最后一个小建议:如果你用的是 C++17 以上版本,可以试试 std::mdspan 或者自己封装一个 SoA 容器。把布局细节藏起来,对外暴露友好的接口。这样既享受了 SoA 的性能,又保持了代码的可读性。

专注资料整理