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访问本地节点的内存快,访问远程节点的内存慢。
我画个图帮你理解:
你看,Node 0的CPU访问Node 0的内存,延迟100ns。但如果访问Node 1的内存,延迟就翻倍到200ns。这就是NUMA的「远近亲疏」。
对回测的影响:如果你在多核服务器上跑回测,线程A处理的数据在Node 0的内存里,但线程A被调度到了Node 1的CPU上——那每次数据访问都要跨节点,性能直接腰斩。
2.5 实战:如何让回测适配NUMA
我个人的习惯是,在回测框架初始化时,做两件事:
- 绑定CPU亲和性:把线程固定到某个CPU核心上,避免被操作系统乱调度
- 内存本地分配:确保线程使用的内存分配在它所在的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 总结:一张图看懂硬件对回测的影响
我把这一章的核心逻辑画成了一张流程图,方便你理解:
说白了,回测性能的瓶颈就三个:缓存没用好、带宽不够用、NUMA没处理好。你把这三点搞明白了,回测速度至少能翻一倍。
最后提醒一句:别一上来就搞什么分布式回测、GPU加速。先把单机的硬件潜力榨干,往往是最划算的优化。我见过太多人,单机回测还没优化好,就跑去搭集群,结果成本翻了几倍,性能提升却微乎其微。
嗯,这一章就到这。下一章咱们聊聊具体怎么用多线程来榨干CPU的每一滴性能。
公众号:蓝海资料掘金营,微信deep3321