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 把 open、high、low、volume 也一起拉进来了。这些数据你根本用不上,白白浪费了宝贵的缓存空间。
核心矛盾: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 在内存中的实际样子。你可以直观看到缓存行里装了什么。
4.6 小结
说了这么多,其实核心就一句话:数据怎么放,决定了你的回测能跑多快。AoS 写起来舒服,但 SoA 跑起来快。在回测系统这种对性能敏感的场景里,我建议你优先考虑 SoA。
至于内存对齐,记住一个原则:大字段放前面,小字段放后面。别让编译器偷偷塞一堆填充字节,既浪费内存又拖慢性能。
最后一个小建议:如果你用的是 C++17 以上版本,可以试试 std::mdspan 或者自己封装一个 SoA 容器。把布局细节藏起来,对外暴露友好的接口。这样既享受了 SoA 的性能,又保持了代码的可读性。