GPU精度の罠: Apple Metal上のfp32バックテストが静かにゴミを返す仕組み
「幻想なきバックテスト」シリーズの一編。
パラメータスイープのバックテストをGPUに移植したところ、2,796倍高速になった。返ってきた数値はもっともらしかった。しかし、最初に動作したバージョンにおいて、それは完全なゴミでもあった。2桁もずれていたのだ。クラッシュもせず、警告も出ず、何も間違って見えなかった。
これこそが、Apple Silicon上のGPUバックテストをCPUコードにはない仕方で危険にする罠である。Metalにはfloat64が存在しない。 Apple GPU上でバックテストが触れるすべての数値は、あなたが望んだかどうかに関わらず32ビット浮動小数点数だ。そして移動平均をベクトル化する最も魅力的な方法、つまりパフォーマンスを意識するすべてのクオンツが手を伸ばすO(n)のprefix-sumトリックは、まさに価格スケールでfp32が生き延びられない定式化そのものなのである。それはエラーを出さない。フルスピードで実行され、211倍もずれた移動平均の上に構築されたもっともらしいエクイティカーブを手渡してくる。
面白いのは解決策のほうだ。なぜならそれは「ビット数を増やせ」でもなく(それは不可能だ)、「もっと注意深くやれ」でもない(素朴なコードはすでに注意深い)からである。解決策は同じ数学的量を別の和で計算すること、すなわち直接的なウィンドウ畳み込みであり、これはすべての中間値をfp32が正確に表現できるほど小さく保つ。同じWMA、有効数字7桁まで同じ結果、そしてCPU上のシングルスレッドnumbaより55.9倍速い。この記事はその検死報告である。なぜMetalがfp32を押しつけてくるのか、明白な定式化が正確にどこでオーバーフローするのか、なぜ正しい定式化はそうならないのか、そして高速版と正直なfp64版が一致することを、カーブを目で見るのではなく取引数を数えることでどう証明したのか。
ここに挙げる数値はすべてApple M2 Max上で、150,000バー × 80パラメータ組み合わせ、ベストオブ3で測定したものであり、リポジトリの scripts/bench_param_sweep.py(M5 GPUメソッド、コミット 97eadaf)、scripts/engine_multitf_gpu.py(04d71e8)、および scripts/GPU_NOTES.md の設計ノートに基づく。
Metalにはfloat64が存在しない

CPU上では、Python/numpyのバックテストはデフォルトで倍精度で実行される。float64 は52ビットの仮数部を与える。整数は2⁵³ ≈ 9×10¹⁵まで正確に表現され、相対精度は約1.1×10⁻¹⁶だ。あなたはこれをほとんど意識しない。なぜなら価格スケール、たとえば30,000前後のBTC終値や10¹⁴に達する累積和において、fp64には余りある余裕があるからだ。
AppleのGPUはその余裕を提供しない。なぜならそれはfloat64をまったく提供しないからだ。Metal Shading Languageには double 型が存在せず、MLX、PyTorch-MPS、そしてApple Siliconを対象とする他のあらゆるフレームワークがこの制約を継承する。それを有効にするフラグもなければ、遅いが正確なフォールバックもない。計算がGPUに触れるなら、それはfp32で行われる。
- 仮数部: 23ビット(暗黙の先頭ビットを含めて24ビット)。整数は2²⁴ = 16,777,216 ≈ 1.6×10⁷までしか正確でない。
- 相対精度: ~1.2×10⁻⁷。 おおよそ有効10進7桁、それ以上はない。
この1.6×10⁷という上限が話のすべてだ。1,600万というのは寛大に聞こえる。バックテストがそれよりはるかに大きい中間量を日常的に構築し、中間値が1.6×10⁷を超えた瞬間にfp32はもはや連続する整数すら表現できなくなる、ましてや必要な小数構造など表現できない、ということに気づくまでは。精度は緩やかに劣化するのではなく、崖から落ちる。そしてその崖は、あなたのデータが何の躊躇もなく通り過ぎていく数値のところにあるのだ。
罠なのは、fp32がほとんど常に問題ないことだ。バックテストの大部分、価格、リターン、PnL、シャープレシオは、有効7桁で十分な範囲に快適に収まっている。だから素朴な移植は動作し、スモークテストを通過し、まともに見える出力を生成する。障害はちょうど1つの演算、中間値が10⁷を突き抜ける唯一の場所に局所化されており、そしてその演算こそが、誰もが真っ先にベクトル化する演算なのである。
魅力的な定式化: 1回のprefix-sumスイープでWMAを
我々の戦略はHull移動平均に依存している。HMAは3つの加重移動平均を組み合わせたものであり、HMA3のバリアントは4つだ。長さ p のウィンドウにわたる線形カーネルの加重移動平均は次の通り。
150kバーにわたって数千のパラメータ組み合わせをスイープするとき、WMAの畳み込みこそがコストである。だから本能、CPU上では正しい本能は、prefix-sumを使って各WMAをO(n·p)ではなくO(n)にすることだ。2つの累積和を一度だけ事前計算し、
すると任意のウィンドウの線形加重和は、S1 と S2 のわずかな差分とインデックスシフトへと収束する。ウィンドウごとのループも、ウィンドウごとのリダクションもない。2回の cumsum パスで、WMAの行列全体が配列演算から転がり出てくる。これは美しくベクトル化され、GPUの並列スキャンプリミティブに完璧にマッピングされ、そしてfp64では正確に正しい。
それはまた、fp32でできる最悪のことでもあり、その理由は S2 の中に隠れている。
S2 = mx.cumsum(j * price) # j is the global bar index: 0, 1, 2, ... , n-1
j · price という項が問題だ。j は150,000まで走り、price は30,000前後だから、最後の項だけで4.5×10⁹になり、S2 はそのような150,000個の項の累積和である。それは価格スケールに留まらない。fp32がすでに数えられなくなっている領域へと登っていく。
どこでオーバーフローするか: 罠の算術

桁数を並べて置いてみよう。なぜなら障害のすべてがそこに宿っているからだ。
S2 = cumsum(j · price) はおおよそ price · n²/2 ≈ 30,000 · (150,000)²/2 ≈ 3×10¹⁴ に達する。これを~10¹⁴と呼ぼう。ここでfp32の正確整数の上限を思い出してほしい。~1.6×10⁷だ。累積和は、fp32が正確に表現できる最後の整数を7桁も超えてしまう。
これは具体的に何を意味するのか。10¹⁴付近では、表現可能な2つのfp32数の間隔、つまり最下位桁の1単位であるULPは、約2²³ ≈ 8×10⁶だ。だから S2 がひとたび10¹⁴の範囲に上がると、それは±800万の範囲でしか分からない。そこに格納されるすべての値は、~8×10⁶の最も近い倍数に丸められている。
さて、WMA復元がそれで何をするか見てみよう。1つのウィンドウの加重和を抽出するには、隣接する2つの S2 値を引き算する(さらに S1 の補正を加える)。これら2つの S2 値はそれぞれ~10¹⁴で、それぞれ±8×10⁶の丸めノイズを抱えている。それらの真の差、つまりあなたが実際に欲しいウィンドウ量は、正規化後には価格そのものと同程度、~3×10⁴のWMAに対応する。つまり算術は次のようになる。
これは最も純粋な形の破滅的桁落ちである。各オペランドの丸め誤差(±8×10⁶)が、あなたが復元しようとしている答えよりも大きいのだ。信号は、それが抽出される数値のノイズフロアよりも小さい。数桁を失うのではない。すべてを失い、返ってくるのはcumsumの累積した丸めに支配されたものだ。
GPU_NOTES.md からの測定結果はこうだ。150kバー、価格30,000でこの方法で計算されたWMAについて、**fp64に対する最大相対誤差は211に達する。** 211パーセントではない。211倍だ。計算された移動平均は、真のものから2桁も離れている可能性がある。そしてこれをバグではなく罠にしているのは次の部分だ。それは完走し、有限でもっともらしい数値を返す。 無限大へのオーバーフローもなく、NaNもなく、例外もない。211倍ずれた移動平均でも、依然として移動平均のように見える。滑らかで、有限で、桁落ちがたまたま軽微なバーではおおよそ正しい範囲に収まっている。だからそれは、信頼できるリファレンスとの直接比較でない限り、あらゆるサニティチェックをすり抜ける。あなたは完全なバックテスト、完全なエクイティカーブ、完全な「最適」パラメータ一式を手に入れるが、それらはすべてフィクションである指標の上に構築されている。
解決策は精度を上げることではない — 別の和である

誤差を見た瞬間の反射は、より高い精度に手を伸ばすことだ。fp64で累積するか、補償付き(Kahan)加算を使う。Metalでは、前者は端的に利用できない。しかしどちらも必要ない。なぜなら問題はそもそもビット数ではなかったからだ。問題は定式化だった。 prefix-sumトリックは10¹⁴スケールの中間値を製造し、それを再び引き算して下げる。それが生み出す大きさはアルゴリズムの産物であって、答えの産物ではない。そのような中間値を決して生み出さない定式化を選べば、fp32で問題ない。
その定式化とは、定義そのものだ。直接的なウィンドウ畳み込みである。2つのグローバルな累積和の代わりに、長さ p の線形カーネルを系列に沿ってスライドさせ、その場で合計する。各出力は高々 p ≈ 200 個の項の和であり、各項は weight × price で、重みは合計が1になるよう正規化されている。つまり各項は price / p 程度で、すべての部分和は価格スケール(~3×10⁴)付近に留まり、いかなる中間値もfp32の上限に6桁以内まで近づくことはない。何も膨張しなかったのだから、桁落ちするものは何もない。
MLXではこれは1つのプリミティブ、mx.conv1d であり、これはまさにGPUが高速に処理するために作られたものだ。
def _mx_wma_valid(x, period):
w = mx.arange(1, period + 1, dtype=mx.float32) / (period * (period + 1) / 2.0)
return mx.conv1d(x.reshape(1, -1, 1), w.reshape(1, period, 1), padding=0).reshape(-1)
同じWMA、prefix-sum版ともCPUのfp64 vec_wma/nb_wma とも数学的に同一だ。しかし今や測定されたfp64に対する最大相対誤差は8.2×10⁻⁷であり、~1.2×10⁻⁷というfp32のノイズフロアのちょうど近く、有効数字7桁の一致だ。紙の上では遅く見える定式化(O(n)ではなくO(n·p))が、正しい唯一のものであり、しかも、それはGPUがバーとウィンドウの両方にわたって一度に並列化する密な畳み込みであるため、猛烈に速くもある。我々は211の相対誤差から8×10⁻⁷へと移行したが、それは何ビットで合計するかではなく、どう合計するかを変えることによってだった。
このやり方から自然に出てくる実用的な注意点が2つある。第一に、MLXはnumpyのように conv1d を通してNaNを伝播させないので、ウォームアップ領域(最初の p−1 バー、ウィンドウ平均が定義されない箇所)をGPU上でNaNとしてマークすることができない。それは必要ない。各系列の有効な開始点は解析的に分かっており、無効な接頭部は決して読まれないゼロで埋められ、NaNのパディングは後でCPU上で復元される。ベクトル化版やnumba版とビット単位で同一の妥当性マスクだ。第二に、スイープ全体は1つの cand_close 系列を共有し、組み合わせをまたいでウィンドウを大量に再利用するので、多数の出力チャンネルを持つ単一のバッチ化された conv1d が、スイープが必要とするすべての固有のWMAを1回のGPU呼び出しで計算し、1回の mx.eval() で具現化する。
罠に落ちていないことを証明する: 取引数によるパリティ
前節が提起すべき居心地の悪い問いはこうだ。211倍ずれたWMAでも依然としてWMAのように見えるなら、8×10⁻⁷版が実際に正しく、単にもっと微妙に間違っているのではない、とどうして分かるのか。目で見て確かめることはできない。パイプラインの下流にある離散的な部分が露出させる不変量が必要だ。そしてバックテストは完璧なものを手渡してくれる。取引だ。
ラダーの他のGPUメソッド(M0〜M4)は完全にfp64で実行されるので、我々はそれらに厳格な等価アサートを課す。取引数が同一で、PnLが atol=1e-6 で一致すること。fp32 GPUメソッド(M5)は構造上それを通過できない。そして皆のためにアサートをこっそり緩めてそれに合わせるのは、まさにこのシリーズが戦うために存在する類の不誠実さである。だからM5には独自の定量的パリティレポート report_equiv_fp32 があり、抽出された取引をfp64リファレンスと比較する。
残存する不一致のメカニズムは正確に名指しする価値がある。なぜならそれは桁落ちの破局ではなく、予想される通常の微小なfp32の丸めだからだ。戦略は2つのHull平均、h 対 h3 のクロスオーバーで発火する。価格30,000の指標に対する1×10⁻⁶の相対誤差は、~0.03の絶対的なぶれだ。大多数のバーでは2つのカーブはそれより離れており、クロスオーバーは明白である。しかしぎりぎりのバー、h − h3 自体がゼロから0.03以内にあるバーでは、そのぶれが比較の符号を反転させ、1つのクロスオーバーを1バーだけ動かし、取引を1つ追加または削除しうる。
これが「異なる組み合わせの割合」が無価値な健全性指標である理由であり、我々の最初のパリティチェックはそれを使って自らを恥じ入らせた。150kバーでは各組み合わせに数千のクロスオーバーがあるので、ほぼすべての組み合わせで少なくとも1つのぎりぎりのバーが現れる。80個中37個の組み合わせが「異なった」のだが、これは警戒すべきに聞こえて何も意味しない。重要な指標はどれだけ、である。
- 80個すべての組み合わせにわたるPnLデルタ: max |Δ| = 1.843パーセントポイント、max相対 = 1.25×10⁻²。クラッシュ閾値5 p.p.
- 組み合わせごとの取引数ドリフト: max |Δn| = 数千のうち4取引、max相対 = 2.5×10⁻³。クラッシュ閾値1%。
- 総計では: 479,016取引のうち90取引がシフト — 0.019%。
50万近くのうち90取引、そのすべてが価格ティックより小さい丸めのぶれによってつつかれたぎりぎりのクロスオーバーであり、いずれもクラッシュ閾値の近くにはない。それは正しいfp32メソッドの署名、すなわち小さく、有界で、説明可能な不一致であり、211の相対誤差とはまったくの別物だ。閾値は、「まあ、ただのfp32だから」を装った壊れた定式化を捕まえるために存在する。実際のデルタはそれらより1桁下でやってくる。取引数は、エクイティカーブがなろうとしなかった託宣なのである。
見返り、そしてGPUが助けをやめる場所
正しさが確立された今、速度は述べる価値がある。そして次に正直に限定しておく。なぜならGPUの優位はパイプライン全体で一様ではないからだ。
純粋なWMA畳み込みを単独で見ると、つまりこの方法全体が加速するために存在する演算では、fp32の conv1d バッチはシングルスレッドnumbaより55.9倍速く、その相対誤差は8.2×10⁻⁷だ。これがクリーンで公平なGPU対CPUの数字だ。同じ数学、1スレッドのコンパイル済みCPUコード対Metal GPU。
しかしスイープは畳み込みだけではない。HMA/HMA3の行列がGPU上で計算されたら、取引はなお抽出されねばならず、これは各組み合わせのクロスオーバーにわたるO(n)のウォークで、我々はそれをCPU上でfp64で行い、GPU上で再実装するのではなく他のメソッドとまったく同じ取引セマンティクスを再利用する。エンドツーエンドの timed() の数字はすべてを含む。カーネルのウォームアップは除外(numbaのコンパイル除外と対称的)だが、GPU→CPU転送とCPUの取引抽出は含まれる。150kバー × 80組み合わせ、ベストオブ3、M2 Maxでは以下の通り。
| メソッド | Wall | ベースライン比の高速化 | combos/s |
|---|---|---|---|
| M0 pandas + Python loop* | 287.08s | 1.0× | 0.3 |
| M1 vectorized numpy | 3.14s | 91.5× | 25.5 |
| M2 numba (serial) | 2.02s | 142.3× | 39.7 |
| M3 multiprocess + vectorized | 0.50s | 570.2× | 158.9 |
| M4 multiprocess + numba (12 cores) | 0.33s | 882.5× | 245.9 |
| M5 MLX GPU (fp32) | 0.10s | 2796.0× | 779.2 |
*M0は一様な5組み合わせのサンプルから外挿。
フルエンジンのM5は779 combos/sを叩き出す。pandasベースラインの2,796倍、シリアルnumbaの19.6倍、そしてnumbaを実行する12コアCPUプール全体(M4)の3.2倍だ。1つのGPUが、そのマシンが持つすべてのCPUコアを3倍で打ち負かす。
さて正直な限定条件だ。エンドツーエンドのGPU優位(M2の19.6倍)が畳み込みのみの優位(numbaの55.9倍)より小さいことに注目してほしい。そのギャップは、予定通りにやってくるアムダールの法則である。GPUが畳み込みをあまりに完全に殲滅するので、それらはもはやボトルネックではなくなる。残ったもの、つまりGPUがまったく高速化しなかったO(n)のCPU取引抽出が、今やM5のwall timeを支配する。これはこのシリーズが繰り返し着地する教訓と同じだ。速度ラダーとIPC税の記事が。ある点を過ぎると、勝利は「速い部分をもっと速くする」ことではなく、オーケストレーションになる。データがどこに存在するか、どの段階が今やシリアルなのか、デバイスとホストの間で何を動かすために対価を払っているのか。取引抽出をカスタムのMetalカーネルに押し込む仮想のM6を追いかけても、縮小していくCPUの一片を取り戻すだけであり、だから我々はそれを作らなかった。
一般的な教訓: 静かな数値的ゴミがデフォルトである
Hull平均とMLXから一歩下がろう。なぜならこの罠はこの1つの指標をはるかに超えて一般化するからだ。
GPUバックテストの魅惑的な売り文句は「1つの大きな行列」だ。あらゆるパラメータ組み合わせをテンソルに積み上げ、スイープ全体を一握りの密な配列演算として実行し、ハードウェアに食わせる。その売り文句は本物だ。上の高速化は本物だ。しかしそれはあなたの下にある数値的レジームを静かに変え、その変化はコードには見えない。CPU上ではデフォルトがあなたを守っていた。fp64、NaN伝播、気づかれずに10¹⁴まで走れる cumsum。同じ配列式をMetalに移せば、あなたはハードな1.6×10⁷の整数上限を持つfp32の中にいて、同一のコード行 cumsum(j * price) が正確からゴミへと変わる。構文の何もあなたに警告しない。コンパイラは満足だ。出力は有限でもっともらしい。fp32は騒がしく失敗するのではない。数値をもって、丁寧に失敗する。
実際にあなたを守る3つの習慣は安価だ。
- 入力と出力だけでなく、中間値がどこに存在するかを知れ。 入力(価格
10⁴)と出力(WMA10⁴)はどちらもfp32の正確な範囲に快適に収まっていた。災厄はもっぱら隠れた中間値(S210¹⁴)の中にあり、APIも型もそれを可視化しなかった。いかなるfp32のリダクションを信頼する前にも、最大の部分和がどこまで達するかを問え。そしてそれが10⁷を超えるなら、定式化を変えよ。 - 大きさを有界に保つ定式化を好め。 prefix-sumより直接畳み込み、グローバルスキャンより局所ウィンドウ、合計の後ではなく前の中心化/差分。大きくしてから桁落ち、はアンチパターンだ。正しいアルゴリズムはしばしば、紙の上では漸近的により悪く見えるが、桁落ちさせねばならない量を決して製造しないものである。
- 離散的な不変量を通じてfp64の託宣に対して検証せよ。 カーブを比較するな。量子化された下流のもの、取引数、クロスオーバーの数、ポジション変更イベントを比較せよ。離散的な不変量は、静かな211倍の誤差を絶叫するアサーション失敗に変え、許容できる8×10⁻⁷の誤差を小さく、有界で、説明可能なデルタに変える。これは先読みバイアスの1バーシフトテストと同じ規律だ。見えない障害を見える障害に変換する安価な診断である。
これのどれも風変わりな数値解析ではない。速い、信頼できないバックテストを、遅く信頼できるものが承認するまでは信頼しない、という通常の衛生管理であり、それを、言語が精度が静かに12桁も落ちたことを警告するのをやめる唯一の場所へと拡張したものである。
要点

- AppleのGPUにはfloat64が存在しない — バックテスト内のすべてのGPU数値はfp32だ。 整数は
1.6×10⁷までしか正確でなく、精度は1.2×10⁻⁷だ。フラグもフォールバックもない。バックテストの大部分はこれを生き延びる。ちょうど1つの演算が、通常はそうならない。 - prefix-sumのWMAが罠だ。
cumsum(j · price)は~10¹⁴まで登り、fp32の正確な上限を7桁越え、ウィンドウを復元するにはそのような2つの数を引き算せざるをえず、その丸め誤差(±8×10⁶)はすでに答えを圧倒している。測定された最大相対誤差: 211倍。 決してクラッシュせず、もっともらしいゴミを返す。 - 解決策はビットを増やすことではなく、異なる和だ。 直接的なウィンドウ畳み込み(
mx.conv1d)はすべての部分和を価格スケール付近に保つので、fp32は正直な7桁を保持する。8.2×10⁻⁷の相対誤差、そしてシングルスレッドnumbaより55.9倍速い。 Metalではfp64を買えないし、その必要もない。 - 離散的な不変量で検証せよ、カーブでは決してない。 取引数のパリティがそれを捕まえた。fp32 conv1dはfp64と**479,016取引のうち90取引(0.019%)**で不一致だったが、すべてぎりぎりのクロスオーバーであり、すべてクラッシュ閾値をはるかに下回っていた。これは正しいメソッドの署名であり、211倍の誤差とはまぎれもなく異なる。「異なる組み合わせの割合」はおとりの指標だ。どれだけを測れ。
- フルスイープは779 combos/sを叩き出す — pandasベースラインの2,796倍、12コアCPUプール全体の3.2倍 — が、エンドツーエンドの勝利(シリアルnumbaの19.6倍)は畳み込みのみの勝利(55.9倍)より小さい。なぜなら今やCPUの取引抽出がボトルネックだからだ。ある点を過ぎると、速度は算術ではなくオーケストレーションである。
GPU移植は2,796倍速く、そして最初に動作したバージョンでは完全に間違っていた。そしてこの2つの事実は互いに何の関係もなかった。速度は本物だった。ゴミは、fp32が保持できず、いかなるエラーメッセージも言及しなかった隠れた10¹⁴の中間値だった。バックテストが劇的に速くなり、それでも数値がまともに見えるなら、それは確証ではない。Metalでは、「まともに見える」こそがまさに、211の相対誤差が見える姿なのである。
これはこのシリーズが登ってきたラダーのGPUの段だ。バックテストエンジンの速度ラダー、マルチプロセスのIPC税、リークの先読み分類学、そして「良い」とは何かを決める目的関数の設計。間違った数値を計算するのが速いなら、速度は無価値である。
Authors
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.