2. 硬件架构认知:CPU缓存、内存带宽、NUMA架构对回测的影响

做量化回测,很多人上来就撸代码,觉得算法牛逼就完事了。我以前也这么想,直到有一次回测跑了一整天还没出结果……后来一查,CPU利用率才30%,内存带宽却打满了。说白了,你写的代码再漂亮,硬件不配合,照样白搭。

这一章,咱们聊聊硬件底层那点事。你不需要成为芯片专家,但得知道CPU缓存、内存带宽、NUMA架构这三个东西,是怎么卡你回测脖子的。

2.1 CPU缓存:你的数据到底在哪

先问个问题:CPU从内存读数据,和从缓存读数据,速度差多少?

我直接给你数据:

存储层级 典型延迟 大小
L1 缓存 ~1ns (3-4个时钟周期) 32KB - 64KB
L2 缓存 ~4ns (10-12个时钟周期) 256KB - 1MB
L3 缓存 ~12ns (30-40个时钟周期) 8MB - 32MB
主内存 (DDR4) ~100ns (300+个时钟周期) 8GB - 256GB

看到了吗?L1比内存快100倍。你想想看,如果你的回测数据频繁从内存里取,那CPU大部分时间都在等数据,而不是算数据。这就是所谓的「内存墙」。

核心观点:回测性能的瓶颈,往往不在CPU算力,而在数据搬运的速度。

2.2 缓存行与伪共享:多线程的隐形杀手

CPU缓存是按「缓存行」读取的,一般是64字节。什么意思?你读一个int(4字节),CPU会把相邻的60字节也一起拉进缓存。

这本身是好事——如果你接下来要访问相邻数据,那就赚了。但多线程场景下,这就可能出大问题。

我举个例子。假设两个线程各处理一个变量,这两个变量恰好在一个缓存行里:

// 伪代码:两个线程各更新一个变量
struct Data {
    int a;  // 线程1更新
    int b;  // 线程2更新
};

// 线程1
while (true) {
    data.a++;
}

// 线程2
while (true) {
    data.b++;
}

表面上,两个线程互不干扰。但实际上,每次线程1更新a,整个缓存行就失效了。线程2的b明明没变,却也得重新从内存加载。这就是「伪共享」。

我曾经踩过的坑:有一次回测多品种,每个品种一个线程,结果性能还不如单线程。查了半天,发现每个线程的计数器都在同一个缓存行里。加了个padding(填充字节),性能直接翻倍。

解决办法?要么让每个线程的数据按64字节对齐,要么用线程本地存储(Thread Local Storage)。

2.3 内存带宽:回测的隐形天花板

回测的本质是什么?是反复读取历史数据,然后计算策略信号。数据量一大,内存带宽就成了瓶颈。

我算笔账给你看:

  • 假设你有10年的1分钟K线数据,约500万根K线
  • 每根K线包含OHLCV+时间戳,约40字节
  • 总数据量:500万 × 40 = 200MB
  • 如果你做全市场回测(5000只股票),那就是200MB × 5000 = 1TB

1TB的数据,从内存读到CPU。DDR4-3200的理论带宽是25.6GB/s,实际能到20GB/s就不错了。算下来,光读一遍数据就要50秒。这还没算计算时间。

我的建议:回测前先估算一下你的数据量。如果超过内存带宽的承受范围,就得考虑数据压缩、增量计算或者分片处理。

2.4 NUMA架构:多核CPU的「远近亲疏」

现在的服务器CPU,动不动就几十个核。但你以为所有核访问内存的速度都一样?太天真了。

NUMA(非统一内存访问架构)把CPU和内存分成多个「节点」。每个节点有自己的内存控制器。CPU访问本地节点的内存快,访问远程节点的内存慢。

我画个图帮你理解:

NUMA Node 0 CPU Core 0-3 CPU Core 4-7 内存控制器 本地内存 延迟低 (~100ns) NUMA Node 1 CPU Core 8-11 CPU Core 12-15 内存控制器 本地内存 延迟低 (~100ns) 远程访问 延迟高 (~200ns) QPI

你看,Node 0的CPU访问Node 0的内存,延迟100ns。但如果访问Node 1的内存,延迟就翻倍到200ns。这就是NUMA的「远近亲疏」。

对回测的影响:如果你在多核服务器上跑回测,线程A处理的数据在Node 0的内存里,但线程A被调度到了Node 1的CPU上——那每次数据访问都要跨节点,性能直接腰斩。

2.5 实战:如何让回测适配NUMA

我个人的习惯是,在回测框架初始化时,做两件事:

  1. 绑定CPU亲和性:把线程固定到某个CPU核心上,避免被操作系统乱调度
  2. 内存本地分配:确保线程使用的内存分配在它所在的NUMA节点上

代码示例(Linux下用libnuma):

#include <numa.h>
#include <pthread.h>

// 绑定线程到指定CPU核心
void bind_to_core(int core_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(core_id, &cpuset);
    pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
}

// 在指定NUMA节点分配内存
void* alloc_on_node(size_t size, int node) {
    return numa_alloc_local(size);  // 分配在当前节点
    // 或者 numa_alloc_onnode(size, node);  // 分配在指定节点
}

// 回测线程函数
void* backtest_thread(void* arg) {
    int core_id = *(int*)arg;
    bind_to_core(core_id);
    
    // 分配本地内存
    double* data = (double*)alloc_on_node(1024 * 1024 * 100, 
                                          numa_node_of_cpu(core_id));
    
    // 执行回测逻辑...
    run_strategy(data);
    
    numa_free(data, 1024 * 1024 * 100);
    return NULL;
}

一个小技巧:如果你用的是Python,可以用numactl命令启动程序:numactl --cpunodebind=0 --membind=0 python backtest.py。这样整个进程都绑定在Node 0上,省心省力。

2.6 总结:一张图看懂硬件对回测的影响

我把这一章的核心逻辑画成了一张流程图,方便你理解:

硬件架构对回测性能的影响路径 回测任务 读取历史数据 CPU缓存 缓存命中率低 → 性能差 内存带宽 带宽打满 → 等待数据 NUMA架构 跨节点访问 → 延迟翻倍 回测性能瓶颈

说白了,回测性能的瓶颈就三个:缓存没用好、带宽不够用、NUMA没处理好。你把这三点搞明白了,回测速度至少能翻一倍。

最后提醒一句:别一上来就搞什么分布式回测、GPU加速。先把单机的硬件潜力榨干,往往是最划算的优化。我见过太多人,单机回测还没优化好,就跑去搭集群,结果成本翻了几倍,性能提升却微乎其微。

嗯,这一章就到这。下一章咱们聊聊具体怎么用多线程来榨干CPU的每一滴性能。


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