3、数据局部性原理:时间局部性与空间局部性

各位同学,今天我们来聊一个非常实在的话题——数据局部性。说实话,我在做回测系统优化之前,对这东西的理解也就停留在「CPU缓存那点事」上。直到有一次,我负责的一个日频回测系统,跑一次全市场回测要整整47分钟。我当时就纳闷了:逻辑明明很简单,为什么这么慢?

后来一分析,发现罪魁祸首就是数据访问模式。嗯,今天我们就来彻底搞明白这件事。

3.1 什么是时间局部性与空间局部性?

先看两个核心概念。说白了,它们描述的是程序访问内存的「规律性」。

  • 时间局部性:如果一个数据被访问了,那么它在不久的将来很可能再次被访问。
  • 空间局部性:如果一个数据被访问了,那么它附近的数据也很可能被访问。

举个例子你就明白了。你写一个循环:

for (int i = 0; i < 1000000; i++) {
    sum += prices[i];
}

这里 prices[i] 被顺序访问,相邻地址的数据连续读取——这就是空间局部性。而 sum 变量每次循环都被累加——这就是时间局部性。

你想想看,CPU的L1缓存才多大?32KB左右。如果数据访问没有局部性,缓存命中率会低得可怜,CPU大部分时间都在等内存。我见过一个极端案例,某团队的回测引擎因为数据布局问题,缓存缺失率高达40%,性能直接腰斩。

3.2 回测场景下的局部性分析

好,理论说完了。我们来看看回测系统里最典型的两种遍历方式。

3.2.1 逐K线遍历

这是最自然的做法。你有一根K线,然后检查所有信号是否触发:

// 伪代码:逐K线遍历
for each bar in bars:
    for each strategy in strategies:
        if strategy.should_enter(bar):
            execute_order(strategy, bar)

这种模式有什么特点?

  • 空间局部性极好:因为 bar 的数据是连续存储的,顺序读取时CPU预取器能完美工作。
  • 时间局部性一般:每个 bar 只被访问一次,不会重复读。

我个人习惯用这种模式做日频回测。数据量不大,逻辑简单,缓存友好度很高。

3.2.2 逐信号遍历

另一种常见做法是:先遍历所有信号,再去找对应的K线数据:

// 伪代码:逐信号遍历
for each signal in signals:
    bar = get_bar_by_timestamp(signal.timestamp)
    if bar is not None:
        execute_order(signal, bar)

这里问题就来了。

  • 空间局部性极差:信号的时间戳是乱序的,你一会儿读第1000根K线,一会儿读第5根,CPU预取器完全失效。
  • 时间局部性也很差:每根K线可能只被访问一次,而且访问间隔很大。

我在项目中遇到过这样一个场景:一个高频回测系统,信号数量是K线数量的10倍。用逐信号遍历,回测时间从2分钟暴涨到18分钟。为什么?因为每次 get_bar_by_timestamp 都是一次随机内存访问,缓存命中率不到10%。

避坑指南:我曾经天真地以为「反正数据都在内存里,怎么访问不都一样?」结果被现实狠狠教育了一顿。内存随机访问和顺序访问的延迟差距,可以轻松达到10倍以上。尤其是在多策略并行回测时,逐信号遍历会让你的缓存变成摆设。

3.3 如何利用局部性提升性能?

知道了问题,解决方案就清晰了。核心思路就一句话:让数据访问模式匹配硬件的工作方式

3.3.1 数据重排:按时间序存储

把信号也按时间排序,然后和K线数据一起顺序遍历:

// 优化后:双指针顺序遍历
sort_signals_by_time(signals)
i = 0
for each bar in bars:
    while i < signals.length and signals[i].timestamp <= bar.timestamp:
        if signals[i].should_enter(bar):
            execute_order(signals[i], bar)
        i++

这样,K线数据和信号数据都是顺序访问的。缓存命中率从10%直接飙升到95%以上。我在一个实盘回测系统中应用了这个优化,回测时间从47分钟降到了6分钟。嗯,你没看错,7.8倍的提升。

3.3.2 结构体布局:SoA vs AoS

另一个容易被忽视的点是结构体布局。假设你有这样的数据结构:

// AoS(Array of Structs)—— 不推荐
struct Bar {
    double open;
    double high;
    double low;
    double close;
    long volume;
};
Bar bars[1000000];

如果你只遍历 close 价格,那么 openhighlowvolume 都会被加载到缓存行里,但根本用不上。这就是缓存污染。

改成SoA(Struct of Arrays)就好多了:

// SoA(Struct of Arrays)—— 推荐
struct Bars {
    double* open;
    double* high;
    double* low;
    double* close;
    long* volume;
};

这样遍历 close 时,只有 close 数组被加载到缓存,其他字段不会干扰。我记得有一次优化一个多因子回测系统,仅仅把AoS改成SoA,性能就提升了3倍。说白了,就是让缓存只做有用功。

个人经验:如果你在写回测引擎,建议一开始就用SoA布局。虽然代码写起来稍微麻烦一点,但后期优化空间大得多。我见过太多项目,AoS写到一半想改SoA,结果重构成本高得吓人。

3.4 知识体系总览

下面这张图总结了本章的核心逻辑。我建议你仔细看看,尤其是「数据重排」和「结构体布局」这两条路径,它们是最容易出效果的地方。

数据局部性原理与回测优化 数据局部性 时间局部性 空间局部性 逐K线遍历(好) 逐信号遍历(差) 方案一:数据重排(按时间序) 方案二:SoA结构体布局 缓存命中率提升 → 性能提升5~10倍

3.5 小结

今天的内容其实就三个要点:

  1. 时间局部性和空间局部性是硬件友好的访问模式,违背了就要付出代价。
  2. 逐K线遍历天然具有空间局部性,而逐信号遍历则相反。能用前者就别用后者。
  3. 数据重排和SoA布局是两个立竿见影的优化手段。我建议你在设计阶段就考虑进去,而不是等性能出问题了再回头改。

好了,这一章就到这里。记住:让数据访问模式去适应硬件,而不是让硬件来迁就你的代码。这是性能优化的底层逻辑。


公众号:蓝海资料掘金营,微信deep3321