9
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python + Rust バックテスターを「Zero-Copy」と「並列化」でさらに爆速・軽量にした話

Posted at

以前、以下のRustで実装した高速なバックテストエンジンをPythonから使う記事を書きました。

Rustを採用することでコアロジックは爆速になったものの、本格的なBot運用や大規模なパラメータチューニングに投入しようとすると、新たなボトルネックが見えてきました。

  1. Pythonへのデータ転送が重い: Rustで計算しても、結果をPythonのリストや辞書に変換するコストが高い(数百万件のトレード履歴など)。
  2. メモリが足りない: ティック単位で数年分のバックテストを行うと、全約定履歴を保持するためメモリが枯渇する。
  3. パラメータ模索に時間がかかる: 1つのバックテストは速くても、パラメータを1000通り試すには直列実行だと遅すぎる。

これらを解決するために、Apache ArrowによるZero-Copy転送O(1)メモリモードRayonによる並列化 を実装し、さらなる性能向上を実現しました。本記事ではその技術的な改善点を紹介します。

GitHub: https://github.com/takurot/crypto-rs-backtester


改善の3本柱

1. Zero-Copy Result Export (Apache Arrow / Polars)

Rustで作ったデータをPython (PolarsやPandas) で分析したい場合、通常は以下のようなコピーが発生します。

  1. Rustの構造体 (Vec<TradeFill>)
  2. Pythonのオブジェクト (list of dict) へ変換 (重い!)
  3. Polars/PandasがそれをパースしてDataFrameを作成

数百万件のトレードが発生する高頻度取引のバックテストでは、この「結果の受け渡し」だけで数秒〜数十秒フリーズすることがありました。

今回の改善では、Apache Arrow (pyarrow) を全面的に採用しました。
Rust側で arrow クレートを使って一度だけカラムを組み立て、そのポインタをPython側に渡すことで、Python側への受け渡し時に追加コピーを行わず(Zero-Copy) Polars DataFrameを構築できます(Rust内でのビルドコストは1回だけ発生)。

Rust側 (イメージ):

// ArrowのBuilderを使ってカラムごとにデータを詰める
let mut price_builder = Int64Builder::with_capacity(n);
for t in &self.trades {
    price_builder.append_value(t.price);
}
// ArrayDataに変換してPythonへ渡す
let price_array = price_builder.finish();
d.set_item("price", price_array.to_data().to_pyarrow(py)?)?;

Python側:

# 一瞬でDataFrame化完了
df = pl.DataFrame(result.trades_df()) 

これにより、RustとPythonの境界コストが極小化され、分析サイクルが劇的に高速化しました。
ArrowバッファはRust側でArc保持しているため、Pythonで参照中に解放される心配もありません。

2. メモリ使用量を O(1) に抑える SummaryOnly モード

長期のティックデータのバックテストを行う際、全ての約定(Fill)や注文イベントをメモリに残していると、簡単に数十GBのRAMを消費してしまいます。
しかし、パラメータチューニングの段階では「最終的なシャープレシオやドローダウンだけ知りたい」というケースが大半です。

そこで、TradeLogMode を導入し、メモリ管理を柔軟にしました。

  • All: 全履歴を保持(詳細分析用)
  • SummaryOnly: 履歴は一切保存せず、統計量(累積損益、勝数、負数、最大ドローダウン用データなど)のみをインクリメンタルに更新
  • RingBuffer(N): 最新N件のみ保持(直近のデバッグ用)

特に SummaryOnly モードでは、データ量がどれだけ増えてもメモリ使用量は O(1)(一定) に保たれます。これにより、スペックの限られたマシンでも、数年分の高解像度データを使ったバックテストが可能になりました。
制約として、個別トレードの後追いやヒストグラム/分布系の指標は取れないため、詳細分析が必要な場合は AllRingBuffer を使い分けます。

3. Rayon による並列パラメータスイープ (run_many)

Pythonの multiprocessing は手軽ですが、プロセスごとのデータコピーやIPCのオーバーヘッドがあります。
今回は Rust 側の並列化ライブラリ Rayon を活用し、run_many API を実装しました。

  • データの共有: 巨大な価格データ(Polars LazyFrameなどから変換)は、Rust側でパースされた後、読み取り専用として全スレッドで共有されます(コピー不要)。
  • GILの解放: 計算中は PythonのGIL(Global Interpreter Lock)を解放し、全CPUコアを使ってバックテスト並列実行します。
# Python側からはリストを渡すだけ
strategies = [MyStrategy(param=x) for x in range(100)]
results = bt.run_many(strategies)

これにより、Pythonスクリプトの書きやすさを維持したまま、ネイティブ並みの並列性能を引き出せるようになりました。
run_many に渡すストラテジは Send + Sync を満たす必要があり、結果の並びは入力順で返す設計です(副作用を持つ共有状態は避ける想定)。


性能等の結果

最新のベンチマーク(M1 Max MacBook Pro等で実施)では、以下のようなパフォーマンスが出ています。

  • 計測条件
    • macOS 14 / MacBook Pro 14" (M1 Max 10C CPU, 32GB RAM)
    • Rust 1.82.0 (stable) releaseビルド, cargo bench -p backtester-core --bench bench_core
    • Python 3.11 + pytest -m bench -q(FFI経由のバッチ実行)
    • 入力: Tick/Order 合計 100万イベント, シングルシンボル, デフォルト設定
  • スループット: 100万イベント(Tick/Order)の処理が 約 205msbench_engine_e2e_batch_4x250k 中央値 206ms, サンプル30)
  • Python呼び出しオーバーヘッド: 256 ticks × 1000回バッチコールで 0.413 ms → 約 1.6 ns / tickpytest -m bench -s の測定値)
  • メモリスケーラビリティ: SummaryOnly モードにより、数億件のデータに対してもメモリ枯渇なしで実行可能
  • 再現コマンド
    • Rust: cargo bench -p backtester-core --bench bench_core -- --sample-size 30
    • Python: python -m pytest -m bench -q

まとめ

「Rustで書けば速い」のは確かですが、実運用レベルで使いやすくするためには、Pythonとのインターフェース部分(データ転送やメモリ管理)の最適化が不可欠です。

  • Zero-Copy でデータ転送をなくす
  • インクリメンタル計算 でメモリを節約する
  • Rust側で並列化 してCPUを使い切る

これらの施策により、crypto-rs-backtester は「速いだけ」のおもちゃではなく、大規模なリサーチに耐えうるツールへと進化しました。

もし興味があれば、GitHubリポジトリを覗いてみてください。そして、GitHubでスター⭐️で応援をお願いします!

9
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?