← 記事一覧に戻る
July 1, 2026
読了時間: 5分

バックテストエンジンの速度の梯子:ラップトップCPUで298倍、最後の1トレードまで同一のPnL

バックテストエンジンの速度の梯子:ラップトップCPUで298倍、最後の1トレードまで同一のPnL
#algotrading
#backtest
#performance
#numba
#vectorization
#optimization

「幻想なきバックテスト」シリーズの一部。

📄 この記事はリサーチペーパーへと発展しました。 1つの経路依存バックテストカーネルを5通りに実装 — 素朴なpandasから並列numbaカーネルまで — 各段はすべて同一のコンボ別PnLを生成することを相互検証済みで、異なるのは速度だけです。論文をオンラインで読む(インタラクティブ版+PDF): speed-ladder.marketmaker.cc、コードとデータは github.com/suenot/backtest-speed-ladder

70秒。これは、150,000本のバーにわたって1つの移動平均戦略の80パラメータ組合せをスイープするのに、素朴なリファレンス実装がかかる時間です:インジケーターにはpandasrolling().apply()、トレードには素のPythonループ。これは現実の研究コードの大部分が乗っている形であり、なぜなら戦略を素直に書けば自然とこの形に落ち着くからです。

同じスイープを、同じラップトップで、すべての組合せについて最後の1トレードまで同じPnLを生成しながら実行すると:0.23秒

この2つの数字の差 — 測定された298倍 — が本記事の主題です。この差の1パーセントすら新しいハードウェアから来ていません。GPUは一切使われていません(そもそもこのマシンにはCUDAの意味でのGPUすら存在しません)。梯子のすべての段は、同じ戦略、同じデータ、同じ手数料、同じトレード数であり、いずれかの実装のコンボ別結果が食い違えばベンチマーク全体を失敗させる等価性ゲートによって検証されています。変わったのは作業がどう表現されているかだけです:インタプリタで実行されるか、コンパイルされて実行されるか、並列で実行されるか。そして、意図的に遅いベースラインはどんな見出しの数字も引き立ててしまうため、もう一つ数字を先出ししておきます:有能なベクトル化numpy実装 — 腕の立つnumpyプログラマーが出荷するであろうコード — と比べても、完成したエンジンはなお約13倍速いのです。

パラメータ探索が遅いとき、条件反射的に手を伸ばすのはより大きなハードウェア — GPU、クラスター、クラウド予算です。この実験の測定された現実は、もっと地味な場所を指しています:ボトルネックはエンジン(ウィンドウごとにPython呼び出しを行うインタプリタの内側ループ)とオーケストレーション(独立したコンボを1コアで逐次実行すること)でした。どちらも、すでに持っているマシン上で、結果を一切変えずに、午後のうちに直せます。

まず梯子全体を先に示します。以下はすべて各段の解剖です。

実装 実時間 高速化率 Combos/s
M0 pandas: rolling.apply + Pythonバーループ 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)を1スレッドに固定し、シングルスレッド段が本当にシングルコアであることを保証。150,000本 × 80コンボ、3回中ベストの実時間、JITウォームアップは除外。すべての段 — pandasベースラインを含む — を全体で計測し、80コンボすべてで同一のコンボ別PnLとトレード数を生成することを検証済み。

1つのカーネル、5通りの実装

1つの階段の5つの段:同じバックテストカーネルが70秒のpandasベースラインから0.23秒の並列numba実行まで登っていく様子。各段はPnLが同一であることを検証済み

速度比較を意味あるものにするには、計算対象そのものを正確に固定し、すべての実装がそれを計算していることを証明しなければなりません。そのためこの実験では1つの戦略カーネルを固定し、5つの段すべてで一定に保ちます。

カーネルはHMA/HMA3クロス — 2本のHullスタイル移動平均に基づくストップ・アンド・リバースシステムです。基本要素は加重移動平均:

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 Moving Averageはこれを3回組み合わせてラグを削減します:

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から構築され、もう一度平滑化されます。パラメータ組合せごとに6種類の異なるウィンドウ長にわたる7回のWMAパスが必要です — おもちゃではなく、本物のインジケータースタックです。

トレードルールは意図的にステートフルで実用的です:HMAがHMA3を下回っていればロング方向、それ以外はショート。最初に方向が定まったバーでポジションを開く。クロスするたびにポジションをクローズし、0.09%の往復手数料を差し引いたPnLを記録し、反転する。ポジションはバーをまたいで持ち越されます — バーiiで何をするかは、最後のクロス以降に蓄積された状態に依存します。この経路依存性こそがこの実験の要点です:これはバックテストを汎用のデータフレームパイプラインと区別する性質であり、(後で測定するように)GPUの議論を複雑にします — ただし通説が言うような形ではないことが判明します。

判断材料として、残りのセットアップを示します:

  • データ: 150,000本の合成幾何ブラウン運動、シード固定(seed=42)。ここでのパフォーマンスは配列サイズとウィンドウ長に律速されており、どの価格パスを与えるかには依存しません — そして合成系列にすることで実験全体が誰にでも決定論的に再現可能になります。
  • グリッド: [6,200][6, 200]に広がる80通りの異なるHMA長 — つまりスイープには安価な短ウィンドウのコンボと高価な長ウィンドウのコンボが、実際のグリッドと同様に混在します。
  • タイミング: ウォールクロック、段ごとに3回中ベスト、JITコンパイルはタイマーのでウォームアップ、プールワーカーもクロック開始前にウォームアップ済み。すべての段 — pandasベースラインを含む — が80コンボ全体にわたり全体で計測されます。BLAS(AppleのAccelerate)は1スレッドに固定されているため、シングルスレッド段は本当にシングルコアです:numpy段が比較の裏でこっそりマルチスレッドの行列ベクトル積をしていることはありません。
  • 等価性ゲート: 計測後、各段のコンボ別(PnL、トレード数)ベクトルをリファレンスと比較 — トレード数は厳密に一致、PnLは絶対値10610^{-6}パーセンテージポイント以内。コミット済みの実行では、pandasベースラインを含むすべての段について、80コンボすべてでall_ok: trueが報告されています。このゲートが失敗すれば、そこにベンチマークは存在しません — 5つの異なる速度で5つの異なるものを計算する5つのプログラムがあるだけです。これは「私たちのエンジンは100倍速い」という主張の多くが密かにやっていることでもあります。

等価性ブロックの1つの数字は、正直に触れておく価値があります:最初のコンボのフィンガープリントは、57,029トレードにわたって**−5165.58**パーセンテージポイントのPnLです。これは恥じるべき戦略の結果ではありません — これは最短のHMA長(6)がランダムウォークのほぼすべての揺らぎで反転し、そのたびに0.09%を支払っているだけで、まさに想定通りです。これは正確性のフィンガープリントであって、取引可能なバックテストではありません。ここにアルファを読み取ってはいけません — 読み取るべきは決定論性です:5つの実装が同じ57,029トレード、同じ小数点6桁までのPnLに着地することが、ここでの「同一」の意味です。

これを確立した上で、以下のすべての高速化は純粋な速度です。何も近似で切り捨てられていません。

段M0:素朴なpandasプロファイル — 69.9秒

素朴なpandasベースラインの解剖:rolling.applyウィンドウが150,000本のバーそれぞれについてPythonラムダ呼び出しを生成し、その下でインタプリタループが這っている様子

このベースラインは藁人形ではありません。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本のバーそれぞれについて、pandasはウィンドウをメモリ上に実体化し、C/Python境界を越え、Python呼び出し可能オブジェクトを呼び出し、結果をボックス化します。raw=True(少なくともラムダにSeriesではなく素のndarrayを渡す)を使っても、1回の呼び出しあたりのインタプリタオーバーヘッドは、そのウィンドウが実際に必要とする数十〜数百FLOPをはるかに上回ります。これをコンボあたり7回のWMAパスで掛け合わせると、インジケータースタックだけで数百万回のインタプリタ往復になります。その後バーループがコンボあたりさらに150,000回の解釈実行イテレーションを回し、それぞれがnumpyスカラーへの境界チェック付きインデックス参照、浮動小数点のボックス化、そしてインタプリタが毎回律儀に再発見する動的型ディスパッチを行っています。

結果:スイープに69.92秒、コンボあたり約0.87秒、スループットは毎秒1.1コンボ。80コンボのグリッドなら肩をすくめて1分待てば済みます。問題は、80コンボのグリッドを長く運用する人などいないということです — そしてこのコストは永遠に線形にスケールします。この点には後で戻ります。

段M1:numpy — ループの中でPythonを呼ぶのをやめる — 3.07秒、22.7x

上の段はインタプリタループを2つとも一気に排除します。この2つのトリックは一般性がまるで違うので、分けて見る価値があります。

インジケーター側は簡単で完全に一般的な方です。全ウィンドウにわたる加重移動平均は、入力のストライドビューに対する行列–ベクトル積にすぎません — コピーなし、BLAS呼び出し1回:

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はすべてのウィンドウのドット積をコンパイル済みコードで計算します。100万回のラムダ呼び出しが1回のライブラリ呼び出しになります。

トレード側は興味深い方です。なぜならイベントループはステートフルなのに — それでも、このカーネルに限ってはベクトル化できるからです。カギとなる洞察は、任意のバーでのポジションは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を1スレッドに固定した状態で。この段にはラベルが必要です:これが有能なベースラインであり、腕の立つnumpyプログラマーが出荷するであろう実装であり、それより上のすべてに対する公正な物差しです。ただしこの段には2つの正直な但し書きが付きまといます。

第一に、このベクトル化は戦略固有の解析的書き換えであって、機械的な変換ではありません。これが成立するのは、このカーネルがストップ・アンド・リバースで、ストップロスも、トレーリングエグジットも、進行中のPnLに依存するポジションサイジングも一切ないからです。もっともありふれた機能であるストップロスを1つ足すだけで、バーiiでのエグジットがバーj>ij > iのどのエントリが存在するかを変え、状態がパスにフィードバックされ、この閉形式は蒸発します。実運用のカーネルの大半は、この境界線の向こう側に住んでいます。

第二に、この段こそ正確性が死にやすい場所です。フリップインデックスの帳簿処理(ここに+1、あそこに[:-1]、最初の方向のシーディング)は、まさにオフバイワン実行バグを生む類のコードです — 私たちのルックアヘッド分類がノイズから15のシャープレシオを捏造しうると示したのと同じ種類のバグです。等価性ゲートはこの段では形式的なものではなく、これを信頼できる唯一の根拠です。愚直なリファレンス実装に対する等価性チェックなしの巧妙なベクトル化書き換えこそが、エンジンが自身のテストしていると称する戦略から乖離していく典型的な経路です。

段M2:numba — 実際に書きたいループをコンパイルする — 1.98秒、35.3x

PythonイベントループがnumbaのJITコンパイラを通過し、引き締まった機械語コードとして現れる様子:同じ分岐だらけのバー単位ロジックが、解釈ではなくコンパイルされている

段M2は正反対の哲学を取ります:アルゴリズムをベクトル化プリミティブに合わせて捻じ曲げる代わりに、素朴なループをそのまま書き — それをコンパイルします。Numba(Lam, Pitrou & Seibert, 2015)は、Pythonの数値サブセットをLLVM経由で機械語に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イテレーションあたりのコストはインタプリタディスパッチのマイクロ秒単位からナノ秒単位に落ちます。

1.98秒 — pandasに対して35.3倍ですが、numpyに対してはわずか約1.6倍程度です(導出:3.07/1.98)。この控えめな一歩自体が示唆的です:numpyの内側ループはすでにコンパイル済みだったため、特徴量計算に対するnumbaの勝ち分は、ウィンドウの実体化と中間配列をスキップすることに限られます。変革的な部分は別の場所にあります:

  1. イベントループがもう無料になった — そして「無料」は修辞ではなく測定値です。 M1は自身の巧妙さをすべてトレードロジックをベクトル化可能にすることに費やしました。M2はその巧妙さを不要にします — 素朴で監査しやすく修正しやすいループが機械速度で動きます。このコンパイル済みカーネル内で特徴量ステージとトレードループを別々に計測すると、時間の**99.3%がWMA特徴量計算に、わずか0.7%**がステートフルなイベントループに帰属します。明日にでも研究プロジェクトなしにストップロスを追加できます — そしてこの分割を覚えておいてください。これが下のGPUの議論を決定づけます。
  2. 次の2段を解放します。 コンパイル済みでGILを解放し、割り当てが軽いカーネルこそが、並列オーケストレーションが必要とする作業単位です。M0を生産的に並列化することはできません — 遅いものを12個コピーしても、暖かくなるだけで遅いままです。

方法論上の注意点を1つ:numbaは初回呼び出し時にコンパイルし、そのコンパイル(数百ミリ秒)はタイマーの中に含めてはいけません — このハーネスは計測前に500本のスライスでJITをウォームアップし、cache=Trueによってコンパイル済みカーネルがプロセス起動をまたいで永続化されます。この点を「忘れる」ベンチマークは、不当に悪い(コールドコンパイルを含んだ)numbaの数字か、再現不能な数字のどちらかを生み出します。

段M3:prange — すでに持っていた並列性 — 0.32秒、217.6x

12個のCPUコアに扇状に広がる80個の独立したパラメータコンボ:パフォーマンスコアとエフィシェンシーコアが不均等なウィンドウ長を並列に引いていく様子

ここで、大量パラメータ探索を特別なものにする観察があります:80個のコンボは完全に独立しています。共有状態なし、順序なし、通信なし。これは、段M0〜M2が単なる習慣で12コア中1コアだけで実行していた、当惑するほど並列な作業です。

Numbaはこの修正をほぼ構文的な変更にしてしまいます — コンボループのrangeprangeに入れ替えるだけです:

@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倍-on-Nコアを謳う人は、何か合成的なものを測っています。

段M4:残り3分の1のためのプロセスプール — 0.23秒、297.9x

最後の段はスレッドをプロセスに置き換えます — 同じコンパイル済みカーネルを、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コンボ。このスループットをもう一度読んでください:このラップトップは今、それぞれが7本の加重移動平均を計算し、数万件のステートフルなトレードをシミュレートする、150,000本バーのフルバックテストを毎秒およそ340回実行しています。

prangeに対する優位は実在しますが控えめです — 約1.4倍(導出:0.32/0.23)— そしてもっともらしい仕組みはスケジューリングとメモリの分離です:chunksize=1ではプールがコンボを1個ずつ配るため、安いウィンドウと高いウィンドウの入り混じった不均等な組合せが非対称なコア間で動的に負荷分散され、各ワーカープロセスは自前のアロケータを持つため、コンボ単位の一時オブジェクトの競合を回避できます。これらは測定結果と整合する仕組みとして報告しているのであって、個別に証明された事実としてではありません。

プロセスは無料ではなく、ハーネスはそのコストをタイマーので正直に支払っています。そこでは一度きりのコストです(ワーカーの起動、initializerを介した各ワーカーへのcloseの受け渡し、ワーカーごとのJITウォームアップ)— なぜなら実際の探索では、これらのコストは80回ではなく数千回のコンボにわたって償却されるからです。正直な一般論としてのガイダンス: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側に留められている — 80コンボのスイープと0.23秒という時間は、往復の費用を払うには狭すぎ短すぎるから

「とにかくGPUに移植すればいい」は、遅いパラメータスイープに対する最も一般的な反応です。そのためこの実験は、その議論が出発点にすべき2つの数字を測定します — そしてどちらも、どちらの安易な答えも支持しません。

ルーフラインモデル(Williams, Waterman & Patterson, 2009)はカーネルを演算強度(移動バイトあたりのFLOP)で分類します。このスイープにおけるWMA特徴量スタックについて、長さppのウィンドウ・バーあたり2p2p FLOPを1バーあたり1回の8バイト読み取りに対してカウントすると、80コンボスイープ全体で約6.2 GFLOPが576 MBのストリーミングに対して計算されます:

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}}

(これはコンボあたり6種類の異なるWMAウィンドウにわたる理想化されたカウントです。実際に実行される7パスをカウントすると11.07 FLOP/byteになります。結論は同じです。)

この数字が重要なのは、それが排除するものゆえです:「バックテストの計算はメモリ律速だからGPUは役に立たない」というよくある主張は、ここでは誤りです。約10.8 FLOP/byteでは、特徴量計算は明確に計算寄りです — 典型的なハードウェアが帯域幅律速でなくなるリッジポイントをはるかに超えています。GPUは確実に80コンボ×7WMAパスを少数の大きなカーネルにバッチ化し、その演算をやすやすと処理できます。もし特徴量スタックが問題の全体であれば、GPU案は十分に正当なものだったはずです。

2つ目の測定値は、もう一つの安易な答え — 私たち自身が最初に手を伸ばしていたであろう答え — を殺します。このコンパイル済みカーネル内で特徴量ステージとトレードループを別々に計測すると、**特徴量99.3%、イベントループ0.7%**という分割になります。「バックテストにはステートフルで分岐だらけのイベントループがあり、それがGPUを妨げている」という魅力的な議論は、ここでは定量的に誤りです — CPUは事実上すべての時間を、GPUがバッチ化できるまさにその部分に費やしています。80コンボ×7WMAパスを大きなバッチ化された畳み込みとして再構成すれば、まったく妥当なテンソルワークロードになります。だから正直な問いは、この作業がGPUに移せるかどうかではありません — その大部分は移せます。問いは、その往復に見合うかどうかであり、このスイープについては、2つの特定の理由から見合いません:

1. 活用できる幅は80コンボであり、GPUは幅の機械です。 パラメータスイープにおける唯一の正直な並列性の軸はグリッドそのものです:1つのコンボの中では、150,000本のバーパスは逐次的です。GPUはレーンを埋めてレイテンシを隠すために何万もの独立した作業項目を欲しがりますが、このスイープが提供するのは80個です。12個のCPUコアはすでにその幅を飽和させています — それがまさに段M3〜M4で測定されたことです。GPUの幅がようやく効いてくるようなコンボ数の領域では、CPUの梯子はすでに毎秒数百回のフルバックテストを配達しています。

2. ジョブ全体が0.23秒です。 M4速度では1コンボあたり約2.9ミリ秒かかります(導出:0.23秒 / 80)。この予算に対して、カーネル起動のレイテンシとデバイス同期ポイントは償却可能な誤差ではありません — ジョブの重要な割合を占めます。(この統合メモリのAppleマシンでは、ホスト–デバイス間の転送は小さな懸念に過ぎませんが、ディスクリートGPUのCUDAマシンでは、これも請求書に加わります。)古典的なGPUの勝利は、固定オーバーヘッドを巨大な作業バッチにわたって償却することで成り立ちます。1秒未満のスイープは決してそれを生み出しません。

そしてイベントループはどうか?これはまさにバッチ化されない部分です — 逐次的、分岐だらけ、経路依存で、1つのコンボの中ではどんなハードウェアでも並列化できない150,000本のバーにわたるループ内キャリー依存であり、SIMTレーンが嫌う類の分岐だらけです。GPU移植ならこの部分はCPUに残すか、コンボごとに1レーンで実行することになるでしょう。しかしカーネルの0.7%では、何も決定できないほど小さなAmdahl項です。これは行かない部分であって、行かない理由ではありません。(段M1を思い出してください:フィードバックのないカーネルであればループを解析的にベクトル化することさえできます — ただしこの書き換えは、戦略がストップを1つでも持った瞬間に失われます。)

完全性のために1つプラットフォーム上の脚注を:このマシン(Apple Silicon)ではGPU経路はCUDAではなくMLXかPyTorch-MPSになります — cupyとCUDAエコシステムはそもそも該当しません — どちらを選んでも、実験を試みるためだけにホットパスをテンソル方言で書き直す必要があります。これは実際のコストであり、上の分析に照らせば、このスイープの形状には特定された見返りがありません。ここでのGPUの議論は分析的なもので、測定された演算強度と測定された特徴量/ループ分割に基づいており、そのようにラベル付けします:開示されたハードウェアでは不可能だったため、CUDA実行は一切行っていません。

査読で擁護できる要約文はこうです:この作業のほぼすべてはGPUに移せる。ただしこのスイープは、往復に見合うには狭すぎ短すぎる。 そしてこれを両方向に読んでください — これは切り捨てではありません。バッチ化された「大行列」再定式化 — スイープを数千コンボ一括の大規模テンソル演算として再構成すること、あるいは端から端までバッチ化される正真正銘フィードバックフリーなカーネル — は、専用の研究に値する、実在する有望な方向であり、却下ではありません。80コンボ、0.23秒では、単にまだそのチケットを獲得していないだけです。あなたのワークロードにその幅があるなら、計算は変わります。そのときは私たちの言葉を鵜呑みにせず、自分でやり直してください。

本当のボトルネックはどこにあるか:エンジンとオーケストレーション

明らかになった本当のボトルネック:砂時計の中で、エンジンと数千のパラメータコンボのオーケストレーションが流れを詰まらせている様子。その下のハードウェアではない

80コンボはデモ用のグリッドです。実際のパラメータ探索は、これらの要因がもはや机上の空論ではなくなる場所です。なぜならグリッドは乗法的に増えるからです:4パラメータ×各10値なら10410^4コンボ。そこに十数個のフォールドを持つウォークフォワード検証を加えれば、何も探索しないうちから1.2×1051.2 \times 10^5回のフルバックテストに達します。これが次元の呪いであり、なぜ探索戦略 — Optuna、座標降下法、Sobol — があれほど注目されるのかという理由です:賢い探索はより少ない点を訪れます。

しかしこの梯子は、方程式のもう半分 — あまり語られない半分 — を露わにします:訪れた1点あたりのコストです。測定されたスループットを線形に外挿すると(コンボは独立しているので、これはモデリングではなく算術です):

グリッドサイズ M0で(1.1 combos/s) M4で(340.9 combos/s)
10,000コンボ 約2.4時間 約30秒
100,000コンボ 約24時間 約5分

素朴なエンジンでは一晩がかりのバッチジョブになる同じ実験が、チューニング済みのエンジンではインタラクティブなクエリになります。この違いは、実時間の表が示唆する以上に複利で効いてきます:1回のスイープに5分なら、あなたは反復します — リークを直して再実行し、フォールドを1つ追加し、グリッドを広げ、昼食中に思いついたアイデアを試します。1回のスイープに24時間なら、そうしません。エンジンの速度が研究ループのテンポを決め、研究ループのテンポこそが本当の成果物です。

梯子全体には、Amdahlの法則的な読み方もあります:

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

任意の1つのステージppを係数ssで高速化しても、その効果は残りすべてがどれだけ遅いままかに縛られます。この梯子はその順序を尊重していました:35.3倍のエンジン改善は支配的だった項(特徴量スタックとループの双方における解釈実行反復)を攻撃し、8.4倍のオーケストレーション改善はその後に支配的になった項(11個のアイドルコア)を攻撃しました。特徴量/ループの分割は同じ教訓の縮図です — 時間が実際にどこへ流れているかを測定しなければ、GPUの議論の本当の形を言い当てることはできなかったでしょう。プロファイルしてから最適化する — この順序で。同じロジックはエンジンの上流にあるデータ層も支配します:私たちのPolars対pandasベンチマークは、スタックのロード・変換半分について同一のパターン(グループ化ローリングパイプラインで10〜3500倍)を発見しており、同じハイブリッドな結論に至りました — パイプラインには列指向エンジンを、経路依存シミュレーションにはコンパイル済みカーネルを。

一般性についての最後の正直な注意を2つ。第一に、この実験は意図的に自己完結型かつ合成的です — シード固定データ、単一カーネル、開示された単一マシン — 誰でも決定論的にこの現象を再現できます。実時間の数字はあなたのハードウェアでは異なるでしょうが、等価性と梯子の方向性は変わりません。第二に、この現象は合成セットアップの産物ではありません:私たちの実運用HMAエンジンのベンチマーク(bench_param_sweep.py、実際の取引所データと実運用の手数料・約定モデルの完全版で実行)は同じ形の梯子を示し、numbaパスは素朴なpandasプロファイルの約100〜200倍のところに着地します。この自己完結型の実験が存在するのは、私たちの実運用の数字を鵜呑みにする必要がないようにするためです。

まとめ

  1. 梯子は298倍で、これは分解できます:35.3倍のエンジン × 8.4倍のオーケストレーション。 反復をインタプリタの外に出すこと(pandas → numba)と、独立したコンボをコア間に広げること(1個 → 12個)が掛け合わさって、変わらぬラップトップの上で3桁近い高速化を生みました。69.92秒 → 0.23秒、1.1 → 340.9コンボ/秒。そしてこれは遅いベースラインを選んだことの産物ではありません:有能なベクトル化numpy実装と比べても、完成したエンジンはなお約13倍です。
  2. 速度に感心する前に、等価性を要求せよ。 ここでのすべての段は、80コンボすべてで自動的にゲートされた(PnLは絶対値10610^{-6}の許容差、トレード数は厳密一致)、同一のコンボ別PnLとトレード数を生成します。何か微妙に異なるものを計算する高速なエンジンは、速くなどありません — それは高スループットで間違っているのであり、その誤りは大抵ベクトル化された書き換えのどこかに紛れ込みます。
  3. ステートフルなロジックには、巧妙なベクトル化より@njitが勝る。 numpy段は戦略固有の閉形式を必要とし、それはストップロスを1つ加えた瞬間に死にます。numba段は素朴で監査しやすいループをコンパイルします — 同じ速度クラスで、脆さは一切なく、しかもこれが並列化の単位になります。
  4. GPUの答えは「このスイープには向かない」— そしてその理由は名指しできるべきです。 特徴量計算は計算寄り(10.78 FLOP/byte)でかつコンパイル済みカーネルの99.3%を占めるため、「バックテストはメモリ律速」も「ステートフルなループが支配的」も測定の前では成立しません。正直な理由は幅と予算です:12個のCPUコアがすでに飽和させている80コンボ分の活用可能な並列性、そしてローンチと同期のオーバーヘッドが食い尽くしてしまう0.23秒という総ジョブ時間。実際の幅を持つバッチ化された大行列再定式化は、反証された方向ではなく、依然として有望な方向として残っています。
  5. エンジンの速度は研究のテンポである。 素朴なエンジンのスループットでは、100,000バックテストの探索は1日仕事です。梯子の頂点のスループットでは5分です。ハードウェアを買ったりクラスターを借りたりする前に、ボトルネックがそもそもシリコンなのかを確認してください — 私たちの場合はrolling.applyの中のlambdaと、11個のアイドルコアでした。

全実験 — 5つの実装すべて、等価性ハーネス、ルーフライン計算、そしてこの記事のすべての数字を1本の決定論的スクリプトから再生成可能 — は、姉妹論文としてspeed-ladder.marketmaker.ccにあり、コードとデータはgithub.com/suenot/backtest-speed-ladderにあります。

70秒かかっていたスイープが、その4分の1秒で済むようになります。同じトレード、同じPnL、同じラップトップ。あなたが調達しようとしていたGPUは待たせておけます。あなたが出荷しようとしていたインタプリタループは待ってくれません。

blog.disclaimer

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取引の洞察、市場分析、プラットフォームの更新情報を受け取りましょう。

プライバシーを尊重します。いつでも配信停止可能です。