← 返回文章列表
July 1, 2026
5 分钟阅读

回测引擎速度阶梯:笔记本 CPU 上 298 倍提速,PnL 精确到最后一笔交易

回测引擎速度阶梯:笔记本 CPU 上 298 倍提速,PnL 精确到最后一笔交易
#算法交易
#回测
#性能
#numba
#向量化
#优化

"回测无幻觉"系列文章。

📄 本文成长为了一篇研究论文。 一个路径依赖的回测内核被实现了五种不同版本——从朴素的 pandas 一路到并行的 numba 内核——每一级都经过交叉核验,产出的逐组合 PnL 完全相同,因此唯一的差异就是速度。可在线阅读论文(交互版 + PDF):speed-ladder.marketmaker.cc,代码与数据见 github.com/suenot/backtest-speed-ladder

七十秒。这是朴素的参照实现扫描一个移动平均策略在 150,000 根 K 线上的 80 组参数组合所需的时间:指标用 pandasrolling().apply(),交易用一个普通的 Python 循环。这正是现实世界中大量研究代码运行时的画像,因为这正是按照最直观的方式写策略时自然而然得到的画像。

同一次扫描,在同一台笔记本电脑上,每个组合的 PnL 精确到最后一笔交易都完全相同:0.23 秒

这两个数字之间的差距——实测 298 倍——正是本文要讨论的主题。这里面没有一个百分点来自新硬件。全程没有用到 GPU(这台机器在 CUDA 意义上甚至都没有 GPU 可用)。阶梯上的每一级都是同一个策略、同一份数据、同一套手续费、同一个交易笔数,并由一道等价性关卡校验:只要任何一个实现的逐组合结果出现分歧,整个基准测试就判定失败。真正改变的只是工作被表达的方式:哪些代码跑在解释器里,哪些被编译执行,哪些被并行执行。而且,因为刻意选一个很慢的基准可以让任何头条数字显得更漂亮,这里先给出另一个数字:即便相对一个称职的向量化 numpy 实现——一个优秀的 numpy 程序员会交付的那种代码——最终引擎依然快约 13 倍

当参数搜索很慢时,本能反应是去找更强的硬件——GPU、集群、云预算。而这个实验的实测事实指向了一个远没那么光鲜的地方:瓶颈在于引擎(一个逐窗口调用 Python 的解释执行内层循环)和编排(把相互独立的组合串行地跑在一个核上)。这两者都能在一个下午之内、用你已经拥有的机器修好,而且结果分毫不变。

先把整个阶梯摆出来。下面的内容是对每一级台阶的解剖。

阶梯级 实现方式 墙钟时间 加速比 组合/秒
M0 pandas:rolling.apply + Python K 线循环 69.92 s 1.0x 1.1
M1 numpy:滑动窗口 WMA + 向量化交易 3.07 s 22.7x 26.0
M2 numba:@njit WMA + @njit 事件循环 1.98 s 35.3x 40.4
M3 numba prange:跨组合多线程 0.32 s 217.6x 248.9
M4 进程池 + numba:跨组合多进程 0.23 s 297.9x 340.9

Apple M2 Max(12 核),Python 3.14.6,numpy 2.4.3,numba 0.64.0,BLAS(Accelerate)被钉死在单线程上,因此单线程各级阶梯是真正意义上的单核运行。150,000 根 K 线 × 80 组合,取 3 次运行的最优墙钟时间,JIT 预热不计入计时。所有阶梯级——包括 pandas 基准——都完整计时,并在全部 80 个组合上验证产出完全相同的逐组合 PnL 和交易笔数。

一个内核,五种实现

同一段楼梯的五级台阶:同一个回测内核从 70 秒的 pandas 基准一路攀升到 0.23 秒的并行 numba 运行,每一步都验证产出完全相同的 PnL

要让速度对比真正有意义,被计算的东西必须被精确钉死,而且每一种实现都必须被证明确实在计算它。所以这个实验固定了一个策略内核,并让它在全部五级阶梯上保持不变。

这个内核是一个 HMA/HMA3 交叉——建立在两条 Hull 风格移动平均线之上的停止并反向(stop-and-reverse)系统。它的构建单元是加权移动平均线:

WMAp(x)i=j=1pjxip+jj=1pj\mathrm{WMA}_p(x)_i = \frac{\sum_{j=1}^{p} j \cdot x_{i-p+j}}{\sum_{j=1}^{p} j}

Hull 移动平均线组合了三条这样的均线以削减滞后:

HMAn(x)=WMAn(2WMAn/2(x)WMAn(x))\mathrm{HMA}_n(x) = \mathrm{WMA}_{\lfloor\sqrt{n}\rceil}\Big(2\,\mathrm{WMA}_{\lfloor n/2\rceil}(x) - \mathrm{WMA}_{n}(x)\Big)

而 HMA3 是它更平滑的一个变体,由大致在 n/6n/6n/4n/4n/2n/2 处的 WMA 构建而成,再额外平滑一次。每个参数组合就是跨六种不同窗口长度的七次 WMA 计算——这是一套真正的指标栈,不是玩具。

交易规则被刻意设计为有状态的,而且这种有状态是有用的:当 HMA 低于 HMA3 时方向为多,反之为空;在方向首次被确定时开仓;每次发生交叉,就平仓、记录 PnL 减去 0.09% 的往返手续费,然后反向。仓位会跨 K 线延续——你在第 ii 根 K 线上做什么,取决于自上一次交叉以来积累的状态。这种路径依赖正是这个实验的核心所在:正是这个特性让回测有别于通用的数据帧流水线,而且(正如我们将要测量的那样)它让 GPU 的问题变得复杂——只不过复杂的方式并不是坊间传说的那种。

其余的实验设置如下,方便你自行判断这些数字:

  • 数据: 150,000 根合成几何布朗运动 K 线,带种子(seed=42)。这里的性能受限于数组大小和窗口长度,而不是喂给它的具体价格路径——用合成序列可以让整个实验对任何人都是确定性且可复现的。
  • 网格: 80 个分布在 [6,200][6, 200] 区间上的不同 HMA 长度——所以这次扫描既包含开销小的短窗口组合,也包含开销大的长窗口组合,就像一个真实网格那样。
  • 计时: 墙钟时间,每一级取 3 次运行中的最优值,JIT 编译在计时器之外预热,进程池的 worker 在计时开始前就已预热。每一级阶梯——包括 pandas 基准——都在全部 80 个组合上完整计时。BLAS(苹果的 Accelerate)被钉死在单线程上,所以单线程各级阶梯是真正意义上的单核运行:numpy 那一级并没有在对比背后悄悄用多线程去做它的矩阵-向量乘法。
  • 等价性关卡: 计时结束后,每一级阶梯的逐组合 (PnL、交易笔数) 向量都会与参照实现做比对——交易笔数必须完全一致,PnL 的绝对误差须在 10610^{-6} 个百分点以内。已提交的运行结果对每一级阶梯——包括 pandas 基准——在全部 80 个组合上都报告 all_ok: true。如果这道关卡没通过,那就根本没有基准测试可言——那只是五个程序在以五种不同的速度计算五种不同的东西,而这恰恰是很多"我们的引擎快 100 倍"之类说法悄悄运作的方式。

等价性区块里有一个数字值得坦诚说明一下:第一个组合的指纹是 57,029 笔交易上 −5165.58 个百分点的 PnL。这不是什么需要感到难堪的策略结果——它是最短的 HMA 长度(6)在一段随机游走的几乎每一次摆动上都发生翻转,并且每次都按理应该地支付 0.09% 的手续费。这是一个正确性指纹,而不是一个可交易的回测结果。不要从中读出 alpha;要从中读出确定性——五种实现落在同样的 57,029 笔交易、同样精确到小数点后六位的 PnL 上,这正是本文中"完全相同"的含义。

确立了这一点之后,下面提到的每一个加速比都是纯粹的速度提升,没有任何近似被悄悄省略掉。

M0 阶梯:朴素的 pandas 画像——69.9 秒

朴素 pandas 基准的解剖:一个 rolling.apply 窗口在 150,000 根 K 线的每一根上都触发一次 Python lambda 调用,解释器循环在底层缓慢爬行

这个基准并不是一个稻草人。它就是当你按照 pandas 文档建议的方式写 WMA、按照策略描述本身的方式写事件循环时,自然会得到的代码:

def pd_wma(s: pd.Series, period: int) -> np.ndarray:
    w = np.arange(1, period + 1, dtype=np.float64)
    w /= w.sum()
    return s.rolling(period).apply(lambda x: np.dot(x, w), raw=True).to_numpy()

def run_pandas_one(close, length):
    h, h3 = pd_hma(close, length), pd_hma3(close, length)  # 7 rolling.apply WMAs
    total, ntr, prev_dir, entry, pos = 0.0, 0, 0, 0.0, 0
    for i in range(len(close)):                            # Python bar loop
        if np.isnan(h[i]) or np.isnan(h3[i]):
            continue
        d = 1 if h[i] < h3[i] else -1
        if prev_dir == 0:
            prev_dir, pos, entry = d, d, close[i]
            continue
        if d != prev_dir:                                  # cross: close + reverse
            pnl = ((close[i] - entry) if pos == 1
                   else (entry - close[i])) / entry * 100 - FEE
            total += pnl
            ntr += 1
            pos, entry, prev_dir = d, close[i], d
    return total, ntr

为什么这会很慢?不是因为 pandas "不好"——而是因为迭代发生在哪里rolling(period).apply(lambda ...) 是一个披着向量化外衣的 Python 层循环。对 150,000 根 K 线中的每一根,pandas 都要实体化一个窗口、跨越一次 C/Python 边界、调用一个 Python 可调用对象,再把结果装箱。即便用了 raw=True(它至少能让 lambda 拿到一个裸的 ndarray 而不是 Series),单次调用的解释器开销也远远超过该窗口实际需要的那区区几十到几百次浮点运算(FLOP)。乘以每个组合七次 WMA 计算,光是指标栈就是数百万次解释器往返。然后这个 K 线循环每个组合还要再跑 150,000 次解释执行的迭代,每一次都要对 numpy 标量做带边界检查的索引、给浮点数装箱,并对类型做动态分派——而这个类型是解释器每一次都要重新发现的。

结果:整次扫描耗时 69.92 秒,平均每个组合约 0.87 秒,吞吐量为每秒 1.1 个组合。在一个 80 组合的网格上,你耸耸肩,等一分钟就过去了。问题在于,没有人会长期只跑 80 组合的网格——而这个成本会永远线性扩展下去。我们后面还会回到这一点。

M1 阶梯:numpy——别再在循环里调用 Python——3.07 秒,22.7 倍

往上第一级阶梯一次性消除了两个解释器循环,而这两个技巧值得分开来看,因为它们的通用性截然不同。

指标那一侧是简单、完全通用的那一个。对全部窗口计算加权移动平均,不过是对输入的一个步幅视图做一次矩阵-向量乘法——不需要拷贝,一次 BLAS 调用搞定:

def vec_wma(x: np.ndarray, period: int) -> np.ndarray:
    w = np.arange(1, period + 1, dtype=np.float64)
    win = np.lib.stride_tricks.sliding_window_view(x, period)  # zero-copy view
    out = np.full(len(x), np.nan)
    out[period - 1:] = win @ w / w.sum()                       # one matvec
    return out

sliding_window_view 在同一块内存上构建出一个 (n − p + 1, p) 的视图,而 win @ w 用编译好的代码计算出每个窗口的点积。上百万次 lambda 调用变成了一次库函数调用。

交易那一侧才是有意思的部分,因为事件循环是有状态的——然而对这个内核来说,它居然能被向量化。关键洞察在于:任意一根 K 线上的仓位只取决于 HMA − HMA3 的符号,而不取决于任何交易结果。状态从不会反过来影响决策。于是整个循环就坍缩成了"找出符号翻转的位置,在这些位置上取出价格":

d = np.where(h[idx] < h3[idx], 1, -1)             # direction per valid bar
flips = np.flatnonzero(np.diff(d) != 0) + 1       # bars where it crosses
cross = idx[np.concatenate(([0], flips))]         # entry/exit indices
side  = d[np.concatenate(([0], flips))]
entries, exits, s = close[cross[:-1]], close[cross[1:]], side[:-1]
pnl = np.where(s == 1, (exits - entries) / entries,
               (entries - exits) / entries) * 100 - FEE
return float(pnl.sum()), int(pnl.size)

3.07 秒,22.7 倍加速,每秒 26.0 个组合——单核运行,BLAS 被钉死在单线程上。这一级阶梯值得单独贴个标签:它是称职基准,是一个优秀的 numpy 程序员会交付的实现,也是衡量之上所有阶梯级的公平标尺。但这一级阶梯也伴随着两点必须坦诚说明的注意事项。

第一,这种向量化是一次针对该策略的解析式改写,而不是一种机械式变换。它之所以成立,是因为这个内核是停止并反向的,没有止损、没有追踪离场、没有依赖累计 PnL 的仓位大小调整。只要加上一个止损——这可以说是最普通不过的功能——第 ii 根 K 线上的离场就会改变第 j>ij > i 根 K 线上存在哪个入场,状态就会反过来影响路径,闭式解也就随之蒸发。大多数生产环境的内核,都站在这条线错误的那一侧。

第二,这一级阶梯正是正确性最容易牺牲的地方。翻转索引的记账工作(这里一个 +1,那里一个 [:-1],还有首个方向的种子设定)恰恰就是那种会产生差一(off-by-one)执行错误的代码——正是我们在前视偏差分类法中展示过的那一类错误,它能从纯噪声中凭空捏造出 15 的夏普比率。在这一级阶梯上,等价性关卡不是走过场——它是你能信任这段代码的唯一理由。没有针对一个笨拙参照实现做等价性核验的聪明向量化改写,正是引擎悄悄偏离它自称在测试的那个策略的方式。

M2 阶梯:numba——编译你真正想写的那种循环——1.98 秒,35.3 倍

一个 Python 事件循环穿过 numba JIT 编译器,蜕变为紧凑的机器码:同样带分支的逐 K 线逻辑,从解释执行变成了编译执行

M2 阶梯采用了相反的思路:与其把算法硬拗成适配向量化原语的形状,不如直接写朴素的循环——然后把它编译掉。Numba(Lam、Pitrou 与 Seibert,2015)通过 LLVM,把 Python 的一个数值子集即时编译(JIT)成机器码:

@njit(cache=True)
def nb_wma(x, period):
    n = x.shape[0]
    out = np.full(n, np.nan)
    wsum = period * (period + 1) / 2.0
    for i in range(period - 1, n):        # the "slow" loop, now machine code
        s = 0.0
        for j in range(period):
            s += x[i - period + 1 + j] * (j + 1)
        out[i] = s / wsum
    return out

@njit(cache=True)
def nb_sweep(close, half, full, sq, p3, p2, pi, fee):
    h  = nb_wma(2.0 * nb_wma(close, half) - nb_wma(close, full), sq)
    a  = 3.0 * nb_wma(close, p3) - nb_wma(close, p2) - nb_wma(close, pi)
    h3 = nb_wma(a, pi)

nb_sweep 内部的事件循环,在文本上就是 M0 的那个循环。分支、continue、保存在局部变量里的状态——全都一模一样。而在 @njit 之下,这些局部变量存活在寄存器里,分支变成了真正的跳转指令,每次迭代的开销从解释器分派的微秒级降到了纳秒级。

1.98 秒——相对 pandas 快 35.3 倍,但相对 numpy 只快约 1.6 倍(推算:3.07/1.98)。这个不算大的跨步本身就很能说明问题:numpy 的内层循环本来就已经是编译过的,所以 numba 在特征计算上的优势,仅限于省掉窗口实体化和中间数组。真正带来质变的部分在别处:

  1. 事件循环现在是"免费"的——而这个"免费"是实测出来的,不是修辞。 M1 把它的聪明才智都花在了让交易逻辑可向量化上。M2 让这份聪明变得没有必要——那个朴素的、可审计的、易于修改的循环现在就以机器速度运行。在这个编译后的内核内部,把特征计算阶段与交易循环分开计时,会发现 99.3% 的时间花在 WMA 特征计算上,而有状态的事件循环只占 0.7%。你明天就能加上一个止损,而不需要立一个研究项目——请记住这个时间占比,下文关于 GPU 的论证会重新用到它。
  2. 它解锁了接下来的两级阶梯。 一个编译好的、释放 GIL 的、分配开销很轻的内核,正是并行编排所需要的那种工作单元。你没法有效地把 M0 并行化——十二份慢,终究还是慢,只是让机器更热一点。

有一点方法论上的说明:numba 在首次调用时才会编译,而这次编译(耗时几百毫秒)绝不能被计入计时器——测试框架会先在一段 500 根 K 线的切片上把 JIT 预热好,再开始测量,而 cache=True 会让编译好的内核跨进程启动持久化。"漏掉"这个细节的基准测试,得到的 numba 数字要么不公平地偏差(把冷编译也算进去了),要么无法复现。

M3 阶梯:prange——你早就拥有的那份并行能力——0.32 秒,217.6 倍

八十个相互独立的参数组合被摊开分给十二个 CPU 核心:性能核与能效核并行地拉动着长短不一的窗口长度

这里有一个观察,让海量参数搜索变得特殊:这 80 个组合是完全独立的。没有共享状态,没有顺序要求,没有通信需求。这是一种"尴尬并行"(embarrassingly parallel)的工作,而 M0–M2 阶梯却纯粹出于习惯,把它跑在十二个核里的一个核上。

Numba 让这个修复几乎只是语法层面的事——把组合循环里的 range 换成 prange

@njit(parallel=True, cache=True)
def nb_sweep_all(close, params, fee):
    N = params.shape[0]
    totals = np.empty(N, dtype=np.float64)
    ntrs = np.empty(N, dtype=np.int64)
    for k in prange(N):                    # threads across combos
        t, ntr = nb_sweep(close, params[k, 0], params[k, 1], params[k, 2],
                          params[k, 3], params[k, 4], params[k, 5], fee)
        totals[k] = t
        ntrs[k] = ntr
    return totals, ntrs

因为 nb_sweep 是以 nopython 模式编译的,它不持有 GIL,numba 的线程层会把这些迭代摊开分给全部 12 个核心。只读的 close 数组被所有线程以零成本共享。

0.32 秒——相对 pandas 快 217.6 倍,每秒 248.9 个组合。相对单线程的 M2,在 12 个核上的跨步大约是 6.2 倍(推算:1.98/0.32),而与"理想的 12 倍"之间的落差值得坦诚说明,而不是藏起来:M2 Max 的 12 个核心是 8 个性能核加 4 个能效核,所以名义上限从来就不是 12 倍;这 80 个组合的开销差异极大(长度为 6 的 HMA 远比长度为 200 的便宜),所以各线程完成的时间参差不齐;再加上每次内核调用都要从一个共享的分配器里为中间数组申请内存。真实机器上的并行加速比就是这个样子的。任何人在异构任务上宣称干净的"N 核 N 倍",测的都是某种人造的东西。

M4 阶梯:用进程池啃下最后一段——0.23 秒,297.9 倍

最后一级阶梯把线程换成了进程——同一个编译好的内核,由 ProcessPoolExecutor 编排:

with ProcessPoolExecutor(max_workers=12, initializer=_init_worker,
                         initargs=(close,)) as ex:          # ship data ONCE
    list(ex.map(_warmup_worker, range(12 * 3)))             # JIT-warm every worker
    results = list(ex.map(_run_one_combo, grid, chunksize=1))

0.23 秒——相对 pandas 快 297.9 倍,每秒 340.9 个组合。再读一遍这个吞吐量:这台笔记本电脑现在每秒能跑大约 340 次完整的、150,000 根 K 线的回测,每一次都要计算七条加权移动平均线,并模拟数万笔有状态的交易。

相对 prange 的优势是真实存在的,但幅度不大——约 1.4 倍(推算:0.32/0.23)——合理的机制可能是调度和内存隔离:chunksize=1 让进程池一次只分发一个组合,于是开销参差不齐的窗口混合能在这些不对称的核心之间动态负载均衡,而且每个 worker 进程都有自己独立的分配器,避开了逐组合临时对象上的争用。我们把这些说成是与实测结果相符的机制,而不是已被单独证明的事实。

进程不是免费的,测试框架诚实地把它们的成本放在计时器之外支付,作为一次性成本(worker 启动、通过初始化器把 close 送到每个 worker、每个 worker 的 JIT 预热)——因为在真实的搜索中,这些成本会被摊薄到成千上万个组合上,而不是八十个。诚实的通用建议是:prange 更简单,通常也够用;当任务粒度较大、网格较大,或者你的逐组合工作在 numba 触及不到的地方持有 GIL 时,进程池才会占优。

至此,整个阶梯可以清晰地拆解开来。从 M0 到 M2——引擎:把迭代移出解释器,在单核上带来 35.3 倍。从 M2 到 M4——编排:使用本来就已经在那里的那些核心,再带来 8.4 倍(推算:1.98/0.23)。相乘:298 倍。没有新硬件,结果分毫不变。而如果从称职的 M1 基准而不是朴素基准来测量,最终引擎依然高出约 13 倍(推算:3.07/0.23)——这个阶梯不是靠选一个慢起点制造出来的假象。

为什么不用 GPU——诚实的版本

一块闲置的 GPU 待在一颗已经跑满的 CPU 旁边:本可批量化的移动平均运算被留在了 CPU 上,因为八十个组合、四分之一秒的扫描,规模太窄、耗时太短,不值得这趟旅程

"直接把它搬到 GPU 上"是面对一次缓慢的参数扫描时最常见的反应,所以这个实验测量了这场讨论理应从之出发的两个数字——而这两个数字都不支持那种偷懒版本的任何一种答案。

屋顶线模型(roofline model,Williams、Waterman 与 Patterson,2009)按照算术强度——每搬运一字节数据对应多少次浮点运算(FLOP)——对一个内核做分类。对于这次扫描中的 WMA 特征栈,按每根 K 线、每个长度为 pp 的窗口 2p2p 次 FLOP,对比每根 K 线一次 8 字节读取来计算,整个 80 组合的扫描大约相当于在流经的 576 MB 数据上完成 6.2 GFLOP:

I=6.21×109 FLOP5.76×108 bytes10.78 FLOPbyteI = \frac{6.21 \times 10^9\ \text{FLOP}}{5.76 \times 10^8\ \text{bytes}} \approx 10.78\ \frac{\text{FLOP}}{\text{byte}}

(这是按每个组合六种不同 WMA 窗口计算出的理想化数值;如果按实际执行的七次计算来算,得到的是 11.07 FLOP/字节。无论哪种算法,结论都一样。)

这个数字之所以重要,是因为它排除了什么:那种流传甚广的说法——"回测运算是受内存带宽限制的,所以 GPU 帮不上忙"——在这里是错的。在约 10.8 FLOP/字节的水平上,特征计算明显偏向计算密集型——远远越过了典型硬件不再受带宽限制的那个拐点。GPU 完全能够把 80 个组合 × 7 次 WMA 计算批量打包成少数几个大内核,把这些运算量啃下来。如果特征栈就是问题的全部,那么上 GPU 的理由会相当站得住脚。

第二个实测数字,扼杀了另一个偷懒的答案——那个我们自己也很可能会脱口而出的答案。在编译后的内核内部,把特征计算阶段与交易循环分开计时,得到的占比是99.3% 特征计算,0.7% 事件循环。那个诱人的论点——"回测有一个有状态、带分支的事件循环,正是它挡住了 GPU"——在这里从数字上讲是错的:CPU 几乎把全部时间都花在了那个 GPU 本来可以批量处理的部分上。把 80 个组合 × 7 次 WMA 计算重新表述为大规模的批量卷积,你就得到了一个完全合理的张量工作负载。所以,诚实的问题不是这些工作能不能搬到 GPU 上去——它的大部分都能。真正的问题是这趟旅程是否划算,而对这次扫描来说,答案是不划算,原因有两个具体的方面:

1. 可利用的宽度只有 80 个组合——而 GPU 是一台"宽度机器"。 一次参数扫描中唯一诚实的并行轴就是网格本身:在单个组合内部,那 150,000 根 K 线组成的路径是严格顺序的。GPU 需要数万个独立的工作项来填满它的通道、隐藏延迟;而这次扫描只提供了八十个。十二个 CPU 核心就已经把这份宽度吃满了——这正是 M3–M4 阶梯实测出来的东西。要等到组合数量大到足以让 GPU 的宽度真正派上用场时,CPU 阶梯本身就已经能做到每秒交付数百次完整回测了。

2. 整个作业只需要 0.23 秒。 以 M4 的速度算,一个组合的开销约为 2.9 毫秒(推算:0.23 秒 / 80)。相对这样的预算,内核启动延迟和设备同步点根本不是可以摊薄掉的舍入误差——它们是这次作业中实实在在的一部分。(在这台统一内存的苹果机器上,主机到设备的传输只是个次要问题;而在一台独立 GPU 的 CUDA 机器上,这部分开销同样也要算进账单。)经典的 GPU 优势,靠的是把固定开销摊到海量的工作批次上;一次不到一秒的扫描永远制造不出这样的批次。

那事件循环呢?它是唯一无法被批量化的部分——串行、带分支、路径依赖,一条长达 150,000 根 K 线的循环携带依赖(loop-carried dependency),在单个组合内部没有任何硬件能把它并行化,而且它恰好带着 SIMT 通道最讨厌的那种分支发散。GPU 移植方案要么会把它留在 CPU 上,要么让每个组合各占一条通道来跑它。但它只占内核 0.7% 的时间,是一个小到不足以决定任何事情的 Amdahl 项。它是那个搬不走的部分;但它不是不该搬的理由。(回想一下 M1 阶梯:对于没有反馈的内核,这个循环甚至能被解析式地向量化——而这份改写,在策略一旦长出一个止损时就会失去。)

为求完整,补充一条平台层面的脚注:在这台机器上(Apple Silicon),GPU 路径会是 MLX 或 PyTorch-MPS,而不是 CUDA——cupy 和 CUDA 生态在这里根本不适用——而无论选哪一条路径,光是为了尝试这个实验,都需要把热路径重写成张量方言。按照上面的分析,这是一项真实的成本,而对这次扫描的形状来说,并没有找到能匹配的收益。这里关于 GPU 的讨论是分析性的,建立在实测的算术强度和实测的特征/循环占比之上,我们也如实标注:没有跑过任何 CUDA,因为在已披露的这台硬件上,这本来就不可能。

我们会在同行评审中为之辩护的总结句是:这项工作几乎全部都能搬到 GPU 上;只是这次扫描规模太窄、耗时太短,不值得这趟旅程。 而这句话要往两个方向去读——它不是一句一笔勾销的否定。批量化的"大矩阵"重构方案——把扫描重新表述为一次性跨越数千个组合的大规模张量运算,或者一个真正无反馈、能端到端批量化的内核——是一个真实且有前景的方向,值得专门做一项研究,而不是被一句话打发掉。在 80 个组合、0.23 秒的规模下,它只是还没有挣到这张门票而已。如果你的工作负载有那样的宽度,这笔账就会改变,那时你应该自己重新算一遍,而不是引用我们的结论。

真正的瓶颈在哪里:引擎与编排

真正的瓶颈现出原形:一个沙漏,卡住流速的是引擎和对成千上万个参数组合的编排,而不是底层的硬件

八十个组合只是一个演示性质的网格。真实的参数搜索才是这些因素不再是纸上谈兵的地方,因为网格是乘法式增长的:四个参数、每个十个取值,就是 10410^4 个组合;再加上十几折的 walk-forward 验证,你在还没探索出任何东西之前,就已经要跑 1.2×1051.2 \times 10^5 次完整回测了。这就是维度灾难,也是为什么搜索策略——Optuna、坐标下降、Sobol——会受到如此多关注的原因:更聪明的搜索会访问更少的点。

但这个阶梯揭示了这个等式中另外那一半、较少被讨论的部分:每访问一个点的成本。把实测的吞吐量线性外推(组合之间相互独立,所以这只是算术,不是建模):

网格规模 在 M0 下(1.1 组合/秒) 在 M4 下(340.9 组合/秒)
10,000 combos ~2.4 hours ~30 seconds
100,000 combos ~24 hours ~5 minutes

同一个实验,在朴素引擎上是一次通宵跑的批处理作业,在调优过的引擎上则是一次交互式查询。这种差异复合累积的方式,是墙钟时间表格所低估的:在每次扫描只需 5 分钟的情况下,你会去迭代——修好一个泄漏后重新跑一遍,加一折验证,把网格拓宽,试一试午饭时想到的那个点子。在每次扫描需要 24 小时的情况下,你不会。引擎的速度设定了研究循环的节奏,而研究循环的节奏,才是真正的产出物。

对整个阶梯,也存在一种阿姆达尔定律(Amdahl's law)式的解读:

S=1(1p)+p/sS = \frac{1}{(1 - p) + p / s}

把任意一个阶段 pp 提速 ss 倍,其收益都会被你留下来没优化的其他一切所限制。这个阶梯尊重了这个顺序:35.3 倍的引擎增益,打的是那个占主导地位的项(无论在特征栈还是循环里,都是解释执行的迭代);而 8.4 倍的编排增益,打的是那之后占主导地位的项(十一个闲置的核心)。特征/循环占比拆分,也是同一个道理的缩影——如果不去实测时间究竟花在了哪里,我们就没法说清 GPU 论证的真实形状。先做性能剖析,再做优化——顺序不能颠倒。同样的逻辑也支配着引擎上游的数据层:我们的 Polars 与 pandas 基准测试 在这套技术栈的加载与转换那一半上,发现了完全相同的模式(分组滚动流水线上 10–3500 倍),也得出了同样的混合结论——流水线用列式引擎,路径依赖的仿真用编译好的内核。

最后补充两点诚实的说明,把关于普适性的这个环收好。第一,这个实验刻意保持自包含和合成性——有种子的数据、一个内核、一台已披露的机器——这样任何人都能确定性地复现这个现象;具体的墙钟数字在你的硬件上会不一样,但等价性和阶梯的方向不会变。第二,这个现象并不是合成实验设置制造出来的假象:我们生产环境 HMA 引擎的基准测试(bench_param_sweep.py,跑在真实交易所数据上,配着完整的生产级手续费和成交模型)呈现出同样的阶梯形状,numba 路径落在朴素 pandas 画像之上大约 100–200 倍的位置。之所以还要做这个自包含的实验,就是为了让你不必单凭信任来接受我们生产环境里的数字。

要点总结

  1. 这个阶梯是 298 倍,可以拆解为:35.3 倍引擎 × 8.4 倍编排。 把迭代移出解释器(pandas → numba),再把相互独立的组合摊开分给多个核心(一个 → 十二个),在一台硬件毫无变化的笔记本电脑上相乘出了一个逼近三个数量级的加速比。69.92 秒 → 0.23 秒;1.1 → 340.9 组合/秒。而且这不是慢基准制造出来的假象:相对称职的向量化 numpy 实现,最终引擎依然快约 13 倍。
  2. 在欣赏速度之前,先要求等价性。 这里的每一级阶梯都产出完全相同的逐组合 PnL 和交易笔数,并在全部 80 个组合上自动把关(PnL 绝对误差容限 10610^{-6},交易笔数完全精确)。一个计算出细微不同结果的快引擎并不是快——它只是在高吞吐量下犯错,而向量化改写正是错误通常悄悄溜进来的地方。
  3. 对有状态的逻辑,@njit 胜过聪明的向量化。 numpy 那一级阶梯需要一个针对该策略的闭式解,一旦加上止损就会失效。numba 那一级阶梯编译的是朴素、可审计的循环——同一个速度量级,却没有那份脆弱性,而且它正是能被并行化的那个单元。
  4. 关于 GPU 的答案是"这次扫描用不上"——而且你应该能说清楚为什么。 特征计算偏计算密集型(10.78 FLOP/字节),而且它占了编译内核 99.3% 的时间,所以"回测受内存带宽限制"和"有状态循环占主导"这两种说法,都经不起实测的检验。真正诚实的原因是宽度和预算:80 个组合的可利用并行度,12 个 CPU 核心早就吃满了;而 0.23 秒的总作业时长,会被内核启动和同步开销吃掉。在真正的宽度下做批量化的大矩阵重构,仍然是一个有前景的方向,而不是一个被推翻的方向。
  5. 引擎的速度就是研究的节奏。 以朴素引擎的吞吐量,一次 100,000 次回测的搜索要花一天;以阶梯顶端的吞吐量,只要五分钟。在买硬件或租集群之前,先检查一下你的瓶颈到底是不是硅片——我们的瓶颈是藏在 rolling.apply 里的一个 lambda,和 十一个闲置的核心。

完整的实验——全部五种实现、等价性测试框架、屋顶线计算,以及本文中每一个可以从单一确定性脚本重新生成出来的数字——都收录在配套论文中,见 speed-ladder.marketmaker.cc,代码和数据见 github.com/suenot/backtest-speed-ladder

曾经耗时七十秒的那次扫描,现在只需要四分之一秒。同样的交易,同样的 PnL,同样的笔记本电脑。你正打算申请的那块 GPU,可以再等等;你正打算交付的那个解释器循环,等不了了。

免责声明:本文提供的信息仅用于教育和参考目的,不构成财务、投资或交易建议。加密货币交易涉及重大损失风险。

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

紧跟市场步伐

订阅我们的时事通讯,获取独家 AI 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。