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

IPC税:バックテストエンジンをソケットの向こうに置くと13%失う——そのほとんどはソケットのせいではない

IPC税:バックテストエンジンをソケットの向こうに置くと13%失う——そのほとんどはソケットのせいではない
#algotrading
#backtest
#performance
#ipc
#rust
#architecture
Part 4 of 4 · Collection
High-Performance Backtest Engines

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

📄 この記事は研究論文に発展しました。 1つの経路依存バックテストカーネルをnumbaからRustへ一行一行移植し、4通りの方法でプロセス/言語境界を越えて呼び出しています——コンボ別PnLが完全一致することを確認する等価性ゲートに加え、純粋なIPCレイテンシ曲線、シリアライゼーション税、スポーンコストを個別に測定しています。論文はオンライン版(インタラクティブ版+PDF)をipc-tax.marketmaker.ccで、コードとデータはgithub.com/suenot/ipc-taxで公開しています。

速くなったバックテストエンジンは、いずれ必ず同じ議論を呼び起こします。私たちのケースも予定通りにやってきました。速度の梯子は80コンボのパラメータスイープを、pandasの69.9秒からシングルスレッドnumbaの約2秒まで引き下げたばかりでした。次に自然と湧いてくる疑問はこうです——なぜPython JITで止まるのか?カーネルをRustに書き直せばいい。ちゃんとしたエンジンサービスにすればいい——コンパイル済みバイナリ1つをソケットの向こうに置き、あらゆるリサーチスクリプトから、あらゆる言語から、そしてライブトレーダーからも呼び出せるようにする。1つのカーネル、1つの真実、ロジックの重複なし。

そして反論もまた、予定通りにやってきます——プロセスを出た瞬間、IPCがすべてを食い尽くす。データはシリアライズされ、境界を越えて運ばれ、デシリアライズされなければならない。すべての呼び出しがsyscallとコンテキストスイッチのコストを払う。あなたの美しいRustカーネルは、その生涯をパイプの前で待つことに費やすだろう。プロセス内に留まれ。誰もがそう知っている。

本記事は、誰もが知っているそのことを実測します。そして測定結果は、議論のどちらの側よりも興味深いものでした。「IPCが殺すから、より速いクロス言語エンジンもプロセス内numbaに負ける」という民間伝承は、一般には誤りで、特定の条件下でのみ正しいことが判明しました。境界を1回、生バイトで越えるコストは、2秒のジョブに対して約2ミリ秒——誤差の範囲です。税金は境界そのものにあるのではなく、どう越えるかにあります。そして実際にエンジンサービスがデプロイされる典型的な3つのパターン(JSON API、作業単位ごとの呼び出し、呼び出しごとのプロセス起動)はそれぞれ、測定可能な形で、民間伝承が予言する惨事の一部を体現しています。

実験全体をまず先に示します。以下はすべて、この各行の解剖です。

アーキテクチャ スイープごとに境界を越えるもの 実時間 プロセス内比
プロセス内numba なし——直接呼び出し 2.010 s 1.00x
Rustサーバー、バッチ(Unixソケット) 1往復:系列全体+80パラメータセットすべて 2.276 s 1.13x
Rustサーバー、バッチ、get_uncheckedカーネル 同じ1往復——境界チェックなしのカーネル亜種(結論を参照) 2.337 s 1.16x
Rustサーバー、チャッティ(Unixソケット) 80往復:コンボごとに系列を再送 2.383 s 1.19x
Rustスポーン(stdin/stdout) プロセス起動+パイプ経由のリクエスト1回 2.300 s 1.14x

Apple M2 Max、Python 3.14.6、numpy 2.4.3、numba 0.64.0、rustc 1.94.0(リリースビルド、外部クレートゼロ)。150,000本のバー×80コンボ、往復0.09%の手数料、seed 42;クローズ系列はワイヤー上で1,200,000バイト(1.2 MB)。各アーキテクチャにつき10回実行の中央値;最小-最大の幅は約2%以内。5つすべてが同一のHMA/HMA3ストップ・アンド・リバーススイープを実行し、等価性ゲートは両方のRustカーネル亜種のコンボ別(PnL、トレード数)結果がnumbaと完全一致することを確認しています——フィンガープリントPnL −5165.58、57,029トレード、同一seedにおける速度の梯子研究のnumbaカーネルとバイト単位で同一です。私たちが比較しているのは実装ではなく境界です。

バッチ行を注意深く読んでください。この記事の主張全体がそこに詰まっています。Rust-over-a-socketアーキテクチャはプロセス内numbaより1.13倍遅い——スイープ全体で266ms遅れています(導出:2.276 − 2.010)。民間伝承はこのミリ秒をIPCのせいにします。しかし違います。そのギャップのうち約2 msが境界です——1.2 MBのクローズ系列全体を送り込み、結果を送り返す、直接測定された値です。残りの約264msは、私たちの素朴なRustカーネルがnumbaカーネルよりも単純に約13%遅くスイープを計算しているという事実です(導出:2.276 sから境界の約2msを引くとRustの計算時間は約2.274 s、対するnumbaは2.010 s)。Rustという言語がPythonという言語に負けたのではありません——1つのスカラーLLVMコンパイル済みループが、もう1つに対してコード生成競争で負けただけです。しかも、その敗因を明白な容疑者に押し付けることさえできませんでした:同じカーネルの境界チェックなしget_uncheckedビルドは速くならなかったのです(2.337 s;結論のセクションでこれを解剖します)。ソケットはこの結果にほとんど関与していませんでした。

この一文の両方の半分を、同時に心に留めておいてください。境界は正しく越えればほぼ無料です——そして「Rustで書き直す」ことが買ってくれるのはデプロイメント境界であって、自動的な計算上の勝利ではありません。どちらの事実も一般的な直感に反しますが、どちらも表の中にあります。

1つのカーネル、2つの言語、4つの境界

ワークロードは意図的に、速度の梯子が固定したものと同一にしてあります。これにより2つの研究が互いに固定点となります。カーネルはHMA/HMA3クロス——2本のHullスタイル移動平均によるストップ・アンド・リバースシステムで、パラメータ組合せごとに7回の加重移動平均パス、加えてポジションを保持し、クロスのたびに往復0.09%の手数料を差し引いてPnLを記帳し、反転するステートフルなバー単位のイベントループを持ちます。データはシードつき合成幾何ブラウン運動の150,000本のバー(seed=42);グリッドは[6,200][6, 200]に広がる80通りのHMA長です。プロセス内リファレンスは梯子研究のシングルスレッドnumba段を本研究向けに再測定したもの:あちらでは1.98 s、こちらでは2.010 s——同じカーネル、同じマシン、安心するほど退屈な結果です。

クロス言語エンジンは、そのnumbaカーネルをRustに一行一行移植したものです——同じループ、同じNaN処理、同じ手数料の算術——外部クレートなしでリリースモードでコンパイルされているため、実験全体が依存関係フリーで再現可能なままです。プロトコルは意図的に最小限のバイナリプロトコルで、両方向とも長さプレフィックス付きフレーム1つ、すべてリトルエンディアンです。

request:  [u32 body_len][body]
body:     [u8 opcode][u32 n_bars][u32 n_combos]
          [n_bars × f64 close][n_combos × 6 × i64 params]

opcode 0 = sweep : reply = [n_combos × f64 pnl][n_combos × i64 trades]
opcode 1 = echo  : reply = the close array, verbatim

echoオペコードは本研究のメスです——何も計算しない、サイズを制御可能な往復であり、これによって純粋な境界コストだけを単離して測定できます——シリアライゼーション、syscall、ソケット転送、デシリアライゼーション、それだけです。

測定した5つのアーキテクチャ——4つの境界パターンと1つのカーネル亜種:

  • in_process — numbaカーネルを直接呼び出す。境界なし。リファレンス。
  • rust_batch_unix — Unixドメインソケット上の永続的なRustサーバー。1回の往復でクローズ系列全体と80パラメータセットすべてを送る;Rustが全コンボを計算する;応答が1回返ってくる。チャンキーな呼び出し。
  • rust_batch_unchecked — 同じバッチ境界だが、カーネルはget_uncheckedでインデックスする(ホットパスに境界チェックなし)。計算ギャップに関する特定の仮説を検証するために存在する;結論のセクションでこれを使い切ります。
  • rust_chatty_unix — 同じサーバーだが、コンボごとに1往復、1.2 MBの系列を毎回再送する。素朴な「作業単位ごとのRPC」アーキテクチャ。
  • rust_spawn_stdin — スイープごとにバイナリを起動し、リクエストをstdin経由でパイプする。「CLIエンジンをシェルアウトする」パターン;プロセス生成のコストを払う。

そして等価性ゲート——これがなければ、ここまでの話は何の意味もなくなります。タイミング測定の後、各Rust亜種のコンボ別(PnL、トレード数)ベクトルをnumbaのものと比較します——トレード数は完全一致、PnLは絶対値10610^{-6}まで一致。コミットされた実行結果は、安全インデックスビルドとget_uncheckedビルドの両方についてall_ok: trueを報告しています。最初のコンボのフィンガープリント——57,029トレードにわたるPnL −5165.58パーセンテージポイント——は、速度の梯子研究のnumbaカーネルと桁単位で一致し、これにより両方の論文が同じseedの同じカーネルに固定されます。クロス言語移植こそ、サイレントな乖離が潜みやすい場所です(パーセント変換の前ではなく後に手数料を適用してしまう、分岐の仕方が異なるNaN比較、ウィンドウのオフバイワン——私たちのルックアヘッド分類がノイズから15のシャープレシオを作り出せることを示したのと同じ種類のバグです)。異なるものを計算する2つのエンジンのベンチマークは、ベンチマークではありません——それは無関係な2つのプログラムの競走です。

等価性が確立された以上、上の表にあるすべての差は境界と計算によるものです——それ以外の何ものでもありません。

境界を越える実際のコスト:echoカーブ

境界越えの実測コスト:小さなペイロードでは14マイクロ秒でフラットなレイテンシカーブが、1万浮動小数点数を超えたところで初めて上向きに曲がり、1.2メガバイトの系列全体で2ミリ秒に達する様子

メスから始めましょう。echoオペレーションはnn個の浮動小数点数のペイロードをRustサーバー経由で往復させます——Pythonがフレームを構築し、サーバーがnn個すべての浮動小数点数をパースし、再エンコードして送り返します。両方向とも、シリアライゼーション、syscall、ソケット転送のコストを払います。実測されたカーブは以下の通りです(10回実行の中央値):

ペイロード(浮動小数点数) 片道バイト数 往復
1 8 14.1 µs
100 800 16.4 µs
1,000 8,000 18.1 µs
10,000 80,000 192.5 µs
100,000 800,000 1,367.3 µs
150,000 1,200,000 2,043.4 µs

この表には、構造的な事実が2つ存在します。

第一に、床(フロア)。 実質的に何も運ばない往復——8バイト——のコストは14 µsです。これがこのトランスポート上でそもそも呼び出しを行うことの、それ以上削れない価格です:2回のwritesyscall、2回のreadsyscall、カーネルのソケット機構、スケジューラのウェイクアップ。左端でカーブがどれほどフラットかに注目してください——1個の浮動小数点数から1,000個まで、コストはほとんど動きません(14.1 → 18.1 µs)。約8 KB以下では、あなたが払っているのはバイトではなく呼び出しのコストです。この数字——レイテンシの床——は本研究全体で最も重要な定数であり、以下でこれを土台に損益分岐点の算術を組み立てます。

第二に、傾き。 約10,000個の浮動小数点数を超えると、カーブは帯域幅律速になり、ほぼ線形になります。1.2 MBの系列全体——往復で合計2.4 MBが動き、Rust側で150,000個の浮動小数点数の完全なパースと再エンコードを含む——のコストは2,043.4 µsです。これは素朴なスタック全体を通した実効約1.2 GB/sに相当します(導出:2.4 MB / 2.04 ms)——長さプレフィックス付きフレームとバイト単位の浮動小数点数パーサーを使うUnixドメインソケットで、ゼロコピーの小技もシェアードメモリも、賢いことは何もしていません。

両方の定数を測定した上での、1回の境界越えの妥当なモデルは以下の通りです:

Tcall(b)    14 μsfloor  +  2b1.2 GB/spayload, both waysT_{\text{call}}(b) \;\approx\; \underbrace{14\ \mu\text{s}}_{\text{floor}} \;+\; \underbrace{\frac{2b}{1.2\ \text{GB/s}}}_{\text{payload, both ways}}

では見出しの数字を文脈の中に置いてみましょう。スイープ全体はプロセス内で2.010 sかかります。そのデータセット全体を境界越しに送り、送り返すコストは約2.0 ms——**ジョブ全体の約0.1%**です(導出:2.0434 ms / 2.010 s)。1回、生バイトで越えるなら、境界は誤差の範囲です。これが民間伝承のうち最初に死ぬ半分です——恐れられていたのは、そもそもこれほど安いものではなかったのです。

その境界越えのRust側は、システムコードとしてはこれ以上ないほど地味なものです——engine/src/main.rsから抜粋・改変:

fn read_frame<R: Read>(r: &mut R) -> Option<Vec<u8>> {
    let mut len_buf = [0u8; 4];
    r.read_exact(&mut len_buf).ok()?;
    let len = u32::from_le_bytes(len_buf) as usize;
    let mut body = vec![0u8; len];
    r.read_exact(&mut body).ok()?;
    Some(body)
}

fn write_frame<W: Write>(w: &mut W, body: &[u8]) {
    w.write_all(&(body.len() as u32).to_le_bytes()).unwrap();
    w.write_all(body).unwrap();
    w.flush().unwrap();
}

// the server is a loop: read frame -> compute -> write frame
for stream in listener.incoming() {
    serve_stream(stream.unwrap());
}

先に進む前に、範囲について正直な注記を1つ。本研究のすべての境界の数字は1台のホスト上のUnixドメインソケットによるものです。エンジンはTCP(TCP_NODELAYつき)も話しますが、それは測定していません;ループバックTCPはこれらの床よりいくらか高いところにあり、実際のネットワークホップはまったく別のレジームです——マイクロ秒ではなくミリ秒の床になります。したがってここでのすべては、この方式で境界を越えるほぼ最良のケースです。だからこそ、次に測定する税金はいっそう手厳しいものになります——それらは、この最良のケースの上に、選択によって上乗せされるコストなのです。

シリアライゼーション税:JSONを選ぶと1348倍

同じ150,000個の浮動小数点数配列に対する2つのエンコーディングの比較:マイクロ秒単位で測定された生バイトのmemcpyと、それより3桁高くそびえ立つJSONテキストエンコーディング

"IPCオーバーヘッド"についての民間伝承が、実はラベルの貼り間違いだったことがここで判明します。同じ150,000個の浮動小数点数のクローズ系列——上記のすべてのアーキテクチャが送るのとまったく同じペイロード——を3通りの方法でエンコードするコストを測定しました:

エンコーディング 1.2 MBの浮動小数点数をエンコードする時間 生バイト比
raw bytes(.tobytes() 49.1 µs 1.0x
pickle 29.8 µs 0.6x
JSON(json.dumps(close.tolist()) 66,243 µs 1348x

生バイトのパスは、関数呼び出しの皮をかぶったmemcpyです:

def build_request(opcode, close, params):
    body = bytes([opcode]) + struct.pack("<II", len(close), len(params))
    body += close.astype("<f8").tobytes()      # 150,000 floats -> 1.2 MB in 49 µs
    body += np.asarray(params, dtype="<i8").reshape(-1).tobytes()
    return struct.pack("<I", len(body)) + body  # length-prefixed frame

(pickleは私たちの生バイトパスよりもわずかに安く済んでいます。なぜならastypeはdtypeがすでに一致していてもdtype変換のコピーを払うからです;どちらもmemcpyクラスであり、どちらも誤差の範囲です。バイナリ系全体としては、テキスト系より3桁下に位置しています。)

そしてテキストパスこそ、「エンジンをマイクロサービスにしよう」というほぼすべてのデプロイが実際に送っているものです:

body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})

66ミリ秒エンコードするだけで。json.dumps(close.tolist())はすべての浮動小数点数をPythonオブジェクトにボックス化し、それぞれを10進数テキストとしてレンダリングします——生バイトパスが1回のブロックコピーで済ませたところを、150,000回のヒープ割り当てと150,000回の浮動小数点数→文字列変換で行うのです。しかもワイヤーペイロード自体も膨張します(float64はバイナリでは8バイトですが、10進数テキストではおよそその2〜3倍になります——この余分な転送コストすら計上していません)。

では実際のデプロイのようにスケールさせてみましょう。その66 msは1回のエンコード、片側だけ、1回の呼び出し分です。JSONサービスはエンコードデコードの両方を、境界の両側で、すべての呼び出しについて払います。JSON経由でバッチ呼び出しを1回行うだけでも、クライアント側のエンコードだけでスイープ全体の計算予算の約3.3%を燃やします(導出:66 ms / 2.010 s)。JSONをチャッティアーキテクチャ——コンボごとに1呼び出し、以下で扱うパターン——に載せると、クライアント側のエンコードだけで80 × 66 ms = 5.3 sのコストになります:1バイトも動く前、サーバーが何もパースする前の時点で、有用なジョブ全体の2.5倍以上です(導出)。

これこそが、多くのチームが気づかないまま本番環境で測定してきた実際の「IPC税」です。それはプロセス間通信ではまったくありませんでした。数値配列のテキストシリアライゼーションだったのです——境界の中で最も安いはずの構成要素に対する、自ら課した1348倍の税金です。カラム指向の世界はこの教訓を何年も前に学んでおり、それは私たちのPolars対pandas研究がデータパイプラインの側から何度も突き当たってきたのと同じ教訓です:Arrowのようなフォーマットが存在するのは、まさに配列データがテキストとしてではなく生のカラム型バイト列としてプロセスと言語の境界を越えられるようにするためです。あなたのエンジンサービスが価格配列にJSONを話すなら、ソケットのチューニングでは何も救えません——プロトコルそのものがボトルネックです。

チャッティ vs チャンキー:Fowlerの法則を測る

境界を1回だけ越えて大きなフレーム化ペイロードを1つ送るチャンキーなアーキテクチャと、その隣で、それぞれがデータセット全体を引きずる80回の小さな往復を行うチャッティなアーキテクチャ

Martin Fowlerの分散オブジェクト設計の第一法則——「オブジェクトを分散させるな」——には、同じ息で語られた系がついてきます:境界を越えなければならないなら、インターフェースは粗粒度でなければならない、なぜならリモート呼び出しはローカル呼び出しより桁違いにコストが高いからです。分散システムのベテランなら誰もがうなずくでしょう。しかし、自分のワークロードに対する具体的な数字を持っている人はほとんどいません。これが私たちの数字です。

チャンキーとチャッティのアーキテクチャは同じサーバー、同じプロトコル、同じデータで動きます——違うのは呼び出しの粒度だけです:

srv.call(0, close, params)

[srv.call(0, close, [params[k]]) for k in range(n)]

チャンキー:2.276 s(1.13倍)。チャッティ:2.383 s(1.19倍)——107 ms遅い(導出:2.383 − 2.276)。このデルタが何であり何でないかを正確に言うと:echoカーブは素朴な予測値を与えてくれます——系列全体の追加送信79回分、それぞれ2,043 µsのフルペイロード往復のおよそ半分として、約81 ms——これは実測された107msより約25%低くなります;残りはPython側でのリクエスト構築とフレーミングであり、echoの予測には含まれていません。いずれにせよ、追加の境界越え1回あたり約1.4 msになります(導出:107 / 79);応答は無視できるほど小さい——コンボあたり16バイトです。

その107msには2通りの読み方があり、どちらも重要です。

寛容な読み方:これは実時間の約4.5%に過ぎず、破滅的ではない。これは真実です——そしてなぜ民間伝承が予言する惨事がここで実現しなかったのかを理解する価値があります。各チャッティ呼び出しは依然として25,130 µsの実計算(コンボ1つ分——プロセス内で測定されたコンボあたりのコスト)を運んでいるため、呼び出しあたり約1.4 msの境界オーバーヘッドは、呼び出しあたりの作業量より1桁下に留まります。チャッティなアーキテクチャは、各呼び出しが本当に重い場合には致命的ではありません。それが致命的になるのは粒度が小さくなるにつれてです——それが損益分岐点セクションの主題そのものです。

手厳しい読み方:この税金は完全に任意であり、呼び出し回数×ペイロードでスケールします。チャッティなパターンがすべての呼び出しでデータセットを再送するのは、理由はただ1つ——サービスがステートレスだから、すべてのリクエストがすべてのコンテキストを運ばなければならないのです。これは素朴な「sweepエンドポイント」——そしてホワイトボードに描かれてきたほぼすべてのRESTマイクロサービス——のデフォルトの形です。ステートフルなサーバー——系列を1度だけロードし、その後48バイトのパラメータフレームを送る——であれば、コンボごとの各呼び出しはechoカーブの小ペイロード側の端近くに位置することになります:呼び出しあたり約16 µs、80回分すべてでおよそ1.3 ms(echoの床から導出;別途測定したものではなく分析的な値)。チャッティのペナルティは縮小するのではなく、消滅するでしょう。教訓は明確です:問題は多くの呼び出しを行うことではなく、プロトコルがすべての呼び出しを最初の呼び出しであるかのように装うために、状態を再送していることなのです。

データは事前にロードしておく。送るのはパラメータだけ。境界は意図を持って越える——毎回スーツケースに世界全体を詰め込むのではなく。

スポーンコスト:呼び出しごとにエンジンをレンタルする

1回のリクエストのためにゼロから起動されるエンジンバイナリ:プロセス生成、ローダー、パイプのセットアップが、短い有用な作業区間の手前に固定料金所として積み重なっている様子

3つ目のデプロイパターンは最も古いものです:サーバーを一切置かない。エンジンバイナリを起動し、1つのリクエストをstdin経由でパイプし、応答をstdoutから読み、そのまま死なせる。シェルスクリプターの本能そのもの、「PythonからCLIを呼ぶだけ」という統合そのもの、試行ごとにバイナリを起動するよう設定されたハイパーパラメータフレームワークそのものです。

実測値:2.300 s(1.14倍)——永続サーバーのバッチより約24 ms上(導出:2.300 − 2.276)。この24ミリ秒が買っているのはfork/exec、動的ローダー、パイプのセットアップ、プロセスの終了処理です。そしてこの測定値は、このパターンにとってのに近いことに注意してください:小さな依存関係のないネイティブバイナリで、ページキャッシュに温まっている状態です。ランタイムを伴う何か——JVM、importつきのPythonインタプリタ——を起動すればはるかに高くつきます;ここでは測定していませんが、方向性に疑いの余地はありません。

重要なのはこの税金の構造です:呼び出しごとに固定であり、その呼び出しがどれだけの作業を運ぶかとは無関係です。80コンボのスイープ全体で償却すると、24 msは約1%——ノイズです。コンボごとに再起動すると、同じ定数は80 × ~24 ms ≈ 1.9 sになります——有用なジョブのほぼ全体がプロセス生成に燃やされることになります(導出;分析値)。バーごとに再起動すると、その算術はもはや書き出すまでもありません。

固定コストか、細かい粒度か——どちらか一方を選んでください。スポーンのコストを払うパターンが理にかなうのは、スポーンがまれで、その背後のペイロードが巨大な場合だけです——まさに私たちの「スイープごとに1回のスポーン」という測定のように。そしてまさに、シンボル数が増えるにつれてシンボルごとのサブプロセスアーキテクチャが最終的に使われる羽目になる形とは正反対です。

損益分岐点の算術:床はハードルレートである

天秤の上の損益分岐点の算術:片側には14マイクロ秒の境界の床、もう片側には各呼び出しが運ぶ計算量。コンボごとの呼び出しは水面よりはるか上に、バーごとの呼び出しは水没している様子

ここまで測定してきたすべてが、1つの設計ルールに圧縮されます。そしてそのルールは意見ではなく算術です。

すべての境界越えは、最低でもレイテンシの床のコストがかかります——ここでは14 µs、小ペイロードのechoの往復であり、このトランスポートが提供できる最良に近い値です。この床はハードルレートです:境界を越える呼び出しは、それが運ぶ計算量が余裕を持った倍率でこのハードルを超える場合にのみ、行う価値があります。粒度比を定義します

G  =  Tcompute per callTfloorG \;=\; \frac{T_{\text{compute per call}}}{T_{\text{floor}}}

境界が実時間に占める割合はおおよそ1/(1+G)1/(1+G)です——呼び出しがデータも運ぶ場合は、それに加えてペイロード転送のコストが上乗せされます。

では、スイープの数字をこれに通してみましょう。プロセス内で測定された1コンボあたりのコストは25,130 µsです。コンボ単位の粒度では:

G  =  25,130 μs14 μs    1795G \;=\; \frac{25{,}130\ \mu\text{s}}{14\ \mu\text{s}} \;\approx\; 1795

コンボごとの呼び出しは床の約1,795倍の位置にあります——境界が要求するのは、呼び出しあたり0.1%を大きく下回る割合です。だからこそ、チャッティなアーキテクチャでさえ107msしか失わなかったのです:このワークロードの粒度では、データを再送せず、テキストを話さない限り、どの境界越えパターンも安全に償却されます。コンボ単位、フォールド単位、スイープ単位の呼び出しはすべて、安全圏の奥深くにあります。

では反対の極端に振ってみましょう。これはワークロードをまたいだ例示的な外挿です——私たちのスイープの変種ではなく、実際に世の中に存在するワークロードの形です:エンジンがバーごとに参照されるケース。ライブ風のティックごとのエンジンサービス;gRPCによるバーごとのシグナルストリーム;150,000本のバーの1本ごとに1回ポーリングされる「戦略サーバー」。このカーネルにおけるバーあたりの有用な計算量は25,130 µs / 150,000 ≈ 0.17 µs(導出)——各呼び出しは、自らの境界コストのうちわずか約1/84しか有用な作業として運ばないことになります(導出:0.168 µsの計算に対して14.05 µsの床)。合計は、この比率が示唆するよりもさらに悪くなります:

150,000 calls×14 μs    2.1 s of pure IPC150{,}000 \ \text{calls} \times 14\ \mu\text{s} \;\approx\; \mathbf{2.1\ s\ of\ pure\ IPC}

——プロセス内ジョブ全体の2.010 sを上回る量で、しかもリモートエンジンが1つの数字を計算する前に消費されてしまいます。そしてこの2.1 sは、向こう側のエンジンが無限に速かったとしても変わりません(導出:150,000 × 14 µs)。これほど細かい粒度では、いかなる計算上の優位も生き残れません。そして、この床が1台のホスト上のUnixソケットによるものであることを思い出してください;このバーごとの呼び出しをネットワーク越しのサービスに対して行えば、150,000回の呼び出しに対して床は2〜3桁大きくなります。

実装の選択としての同一マシン境界の床:14マイクロ秒のPython-over-Unixソケット往復が、39ナノ秒のシェアードメモリリング越えの上にそびえ立ち、3桁の差がある様子

もう1つ、正直なキャリブレーションを。14 µsもまた物理法則ではないからです——それは私たちのトランスポートの価格です:Pythonクライアント、カーネルソケット、両方向のsyscall。専用に設計された同一マシントランスポートなら、はるかに低くなります。ZigBolt——HFTワークロード向けの、私たちのオープンソースZigメッセージングバスで、この同じマシン上でネイティブにベンチマークされたもの——は、シェアードメモリリングの往復を平均約39 nsで行います(64/256/1024バイトのメッセージで片道p50は10/20/30 ns)。これは私たちのソケットの床よりおよそ360倍低い値です(導出:14.05 µs / 39 ns)。この比較は意図的にリンゴとオレンジの比較であり、私たちもそう明示します:私たちの14 µsはPythonクライアントによるソケット往復であり、ZigBoltの39 nsはシェアードメモリ上のネイティブZigです。したがってこのギャップはトランスポートランタイムの両方を混ぜ合わせたものです。これを両者のレースとしてではなく、同一マシンの床が占めうる範囲:実装の選択によっておよそ3桁として読んでください。これはLightweight RPCの古い教訓(Bershad et al., 1990)を現代風に着せ直したものです——同一マシン越えはプロトコルの機構に支配され、トランスポートが同一マシンのケース向けに作られていれば、それは崩壊します。上の損益分岐点の算術は形を変えません;ハードルが動くだけです。床が39 nsなら、バーごとの粒度でさえこれをクリアします(150,000 × 39 ns ≈ 5.9 ms、導出)——これこそが、HFTシステムがRESTサービスには手が届かない境界を持つ余裕を持てる正確な理由です。

損益分岐点の物語全体を一文にまとめると:境界はあなたのエンジンがどれほど速いかを気にしない;境界は越えるたびに課金する。だからあなたがコントロールできる変数は、各越境がどれだけの作業を運ぶか——そして、その越境が何でできているか、だけである。 スイープごとにバッチすればGGは10万を超える。コンボごとにバッチすればG1795G \approx 1795——まだ問題ない。ソケット越しにバーごとに呼び出せばG<1G < 1——アーキテクチャは最初の最適化より前にすでに死んでおり、エンジンをRustであろうと何であろうと書き直したところで蘇らせることはできない。

1.13倍は実際どこにあるのか——そして結論

266ミリ秒のギャップの解剖:境界とラベル付けされたわずか2ミリ秒の薄片が、2つのスカラーコンパイル済みカーネル間の実測されたコード生成差という大きな塊の隣にあり、民間伝承には取り消し線が引かれている様子

見出しのギャップを正直に解剖する時が来ました。それが本研究で最も直感に反する発見を担っているからです。

バッチRustアーキテクチャはプロセス内numbaに266ms遅れています(導出:2.276 − 2.010)。測定された境界の構成要素:約2.0 msのフルペイロード往復1回、49 µsの生シリアライゼーション、数バイトのフレームヘッダー——境界の請求額全体を約2 msと呼びましょう。したがってギャップの99%以上はまったく境界のせいではありません。それは計算です:IPCを取り除くと、Rustサーバーはnumbaが2.010 sで行うスイープを約2.274 sかけて行っています——素朴なRustカーネルは、生の計算において約13%遅いのです(導出)。

これは目をそらさずに書くべき段落です。「Rustで書き直せば速くなる」は、「IPCが殺す」と同じくらい民間伝承だからです。両方のカーネルはLLVMに帰着します——numbaはPythonバイトコードをLLVM経由で降ろし、rustcはMIRをLLVM経由で降ろします——そしてどちらもおそらくスカラーループとして実行されています:WMAの内側の総和は浮動小数点の還元演算であり、numbaの@njitのデフォルトが許可せず、私たちの移植も要求していないfast-math再結合ライセンスなしには、LLVMはこれを自動ベクトル化しません。つまり約13%は、2つのスカラーLLVMコンパイル済みループの間で実測されたコード生成のギャップです——原因を断定するのではなく、私たちは明白な候補を検証しました。自然な容疑者はRustの安全インデックスです:ホットなWMAループはすべての配列アクセスで境界チェックを行いますが、numbaの@njitは境界チェックをオフにしてコンパイルします。そこで私たちは、同じカーネルのget_unchecked版——ホットパスのどこにも境界チェックがない——を等価性検証つきで構築し、5番目のアーキテクチャとして計測しました。ギャップは閉じませんでした2.337 s(1.16倍)、境界チェックつきビルドの2.276 sよりわずかに遅いくらいです。仮説を検証し、仮説を棄却しました。誠実な知識の現状はこうです:約13%は実在し再現可能ですが(10回実行の中央値、幅は約2%以内)、現時点では帰属先不明です——アセンブリレベルのプロファイリングでしか決着しないような、割り当て挙動、ループ構造、命令スケジューリングのどこかの違いです。教訓は無傷で生き残ります:素朴なRustは、良質なnumbaより自動的に速いわけではない。そして、無料の計算上の勝利を前提に買った言語境界は、計算上の損失を伴って届くことがあります。チューニングされたRustカーネル——事前確保されたバッファ、明示的なSIMD、コンボ間のスレッド——であれば、符号を反転させられるかもしれません。しかしそれは計算の問題であり、プロファイリングとカーネル作業によって決着すべきものです。本研究の問いは境界です。境界の答えはこうです:1回、バイトで越えれば、コストは約0.1%。

では完全な結論を組み立てましょう。そのすべての条項が上で測定済みです。

クロス言語エンジンサービスが勝つのは、以下がすべて成り立つ場合です:

  • 計算上の優位が本物である——言語の評判からの推測ではなく、あなた自身のカーネルで測定されたもの。(私たちのケースは、証明されるまでは−13%でした——そしてその不足に対する最初の「明白な」説明は、検証の中で死にました。)
  • 粗く越える——スイープごと、あるいはフォールドごとに1回の呼び出しで、14 µsの床の数千倍上、バッチアーキテクチャの合計1.13倍(境界は約0.1%)が示す通り。
  • バイナリを話す——長さプレフィックス付きの生配列、Arrow、1.2 MBあたり49 µsのmemcpyクラスなら何でもよい;66,243 µsのテキストは決して使わない。
  • データが事前ロードされている——ステートフルなサーバーは、メガバイトを再送する代わりに、echoカーブの約16 µs側の端でパラメータのみの呼び出しを受け取る。

エンジンサービスが通常デプロイされる方法では、負けます:

  • JSON/RESTマイクロサービス——すべての呼び出しで、両方向に1348倍のシリアライゼーション税を払う;チャッティな粒度では2秒のジョブに対して5.3 sのエンコードになる。
  • 作業単位ごとのRPC——コンボごとではここでは107msのコストで済んでおり、それは各呼び出しが25,130 µsの計算を運んでいるから生き延びているに過ぎない;バーごとでは、2.0 sのジョブに対して何の作業も起こる前に約2.1 sの純粋なIPCになる。
  • 呼び出しごとのスポーン——毎回約24 msの固定コスト、スイープごとに1回なら無害だが、コンボごとに払うとほぼ2秒になる。

つまりこういうことです:失敗するアーキテクチャはエキゾチックなものではありません。JSON RESTエンジン、シンボルごとのサブプロセス、ティックごとのgRPC——これは「バックテストエンジンを切り出そう」が実際にどう構築されるかについての、公平な統計です。民間伝承は一般的な実践の描写としては経験的に十分根拠があり、自然法則としては経験的に誤りです。境界は決して問題ではありませんでした。問題は、それを越えるデフォルトの方法にありました。

境界に賛成する1つの論拠には、それ専用の一文を与える価値があります。なぜなら、それこそが私たちがそもそもこの研究を行った理由だからです。よく設計された境界の向こうにある1つのコンパイル済みカーネルは、リサーチのスイープとライブトレーディングのループの両方に、まったく同じバイナリ、まったく同じ算術で、ビット単位で仕えることができます。私たちのバックテスト-ライブパリティ研究は、リサーチとプロダクションのエンジンが2つの別々のコードベースであるときにどれほど乖離していくかを整理しました;エンジンサービスは、その乖離に対する最も強力な構造的治療法であり、本研究はその治療法の値段を正直に示しています:正しく行えば、実時間の約0.1%と、翻訳の過程で何も変わっていないことを証明する等価性ゲート。この取引——専用のプロセス境界と引き換えに1カーネルのパリティを得る——は、この数字を見る限りお買い得です。誤って行えば、同じアイデアが1348倍のシリアライゼーション税を本番環境に送り込み、あなたのPnLがその上に乗ることになります。

まとめ

  1. 境界はほぼ無料であり、民間伝承は実測に耐えない。 1.2 MBのクローズ系列全体をUnixソケット経由で往復させる——完全なパースと再エンコードを含む——コストは2,043.4 µs、2.010 sのジョブの約0.1%です(導出)。バッチRust-over-socketアーキテクチャは合計1.13倍に着地し、そのギャップの約99%さえもIPCではありません。
  2. 「Rustで書き直す」は計算上の主張である——境界を買う前に検証せよ。 私たちの一行一行のRust移植はnumbaカーネルより約13%遅く計算します(導出:2.274 s対2.010 s)——2つのスカラーLLVMコンパイル済みループ間の再現可能なコード生成ギャップであり、帰属先は不明のままです:明白な容疑者を検証して棄却しました。境界チェックなしの等価性検証つきget_uncheckedビルドが速くならなかったからです(2.337 s対2.276 s)。素朴なRustは自動的に速いわけではない;チューニングされたカーネルなら速いかもしれない——測定してから決めよ。
  3. 本当の税金はテキストである。 150,000個の浮動小数点数をJSONでエンコードすると66,243 µs、生バイトなら49.1 µs——1348倍、方向ごと、呼び出しごと、両側で支払う。チャッティなJSONデプロイは、2秒のジョブに対して5.3 sのエンコードを燃やす(導出)。境界越しにはバイナリを話せ:生フレーム、Arrow——価格配列にjson.dumpsを使うことは決してするな。
  4. チャッティ対チャンキーは測定可能であり、犯人はステートレス性である。 データを再送するコンボごとの呼び出し:バッチの1.13倍に対して1.19倍(+107 ms、導出;echoカーブの片道予測約81 msはそれより約25%低く、残りは呼び出しごとのフレーミングによるもの)。事前ロードされたステートフルなサーバーなら、同じ80回の呼び出しをそれぞれ約16 µsで済ませる——合計で約1.3 ms(echoの床から導出)。送るのはデータセットではなくパラメータであるべきだ。
  5. 床を尊重せよ——そして床が選択の産物であることを知れ。 私たちのPython-over-Unixソケット越えは14 µsで底を打つ;コンボごとの粒度はこれを約1,795倍クリアする(呼び出しあたり25,130 µsの計算)——安全。バーごとのパターン(ワークロードをまたいだ例示的な極端なケース:このスイープではなく、ライブのティックごとのエンジン)は、2.0 sのジョブに対して150,000 × 14 µs ≈ 2.1 sの純粋なIPCを支払うことになる(導出)——エンジンが無限に速くても到着前に死んでいる。呼び出しごとのスポーンは固定で約24 msを追加する(導出)。そしてZigBoltのような専用に設計されたシェアードメモリトランスポートは、このマシン上でネイティブに約39 nsで往復する——私たちのソケットの床より約360倍低い(導出;ネイティブZig対Pythonクライアントなので、レースとしてではなく床が占めうる範囲として読むこと)。
  6. 1回、バイトで、データがすでにそこにある状態で越えよ——そうすれば境界は約0.1%でパリティを買ってくれる。 リサーチとライブの両方に仕える1つのカーネルを、等価性チェック(PnL −5165.58、57,029トレード、言語間および両方のRustビルド間で同一)でゲートすること——これがエンジンサービスの正直なケースです。不誠実なケース——JSON、チャッティ、呼び出しごとのスポーン——こそが、IPCにあの悪評を与えたものです。

実験全体——Rustエンジン、ワイヤープロトコル、echoとシリアライゼーションのハーネス、等価性ゲート、そして本記事のすべての数字を1つの決定論的スクリプトから再生成できること——は、ipc-tax.marketmaker.ccの姉妹論文にあります。コードとデータはgithub.com/suenot/ipc-taxにあります。

ソケットは決して問題ではありませんでした。データセット全体で往復2ミリ秒——民間伝承は3桁も外れていました。しかも一度に両方向で:バイトについては悲観的すぎ、テキストについては甘すぎたのです。境界を、何かコストがかかるものであるかのように越えなさい。そうすれば、実際にはコストはかからなくなります。

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

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