Slide 1

Slide 1 text

Sansan技術本部 Profilingから始めるPython⾼速化とRust bindings実践 Sansan株式会社 技術本部 研究開発部 Architectグループ ML Architect 蔀⻯太

Slide 2

Slide 2 text

蔀 ⻯太 Sansan株式会社 技術本部 研究開発部 Architectグループ - Sansan株式会社に2025/8に中途⼊社 - 普段は関⻄で勤務しています

Slide 3

Slide 3 text

経理AXサービス: 「Bill One」

Slide 4

Slide 4 text

AI⾃動照合機能

Slide 5

Slide 5 text

照合処理での問題点 - 請求書や発注書のレコード数が増えると、処理時間が⼤幅に増加する - JSON読み込みや結果書き込み等の他の処理も混在しており、ボトルネックの特定が困難

Slide 6

Slide 6 text

ボトルネックを特定し、Rust Bindingsで解決 プロファイラであるpyinstrumentでボトルネック処理を特定し、その処理のみをRust で書き直し、Rust Bindings(PyO3)でPythonから呼ぶことで、性能要件を満たした Python 既存データロード‧出⼒処理 呼び出し Python Bindings PyO3 / FFI境界 Rust ボトルネック処理

Slide 7

Slide 7 text

今回話すこと - pyinstrumentによるプロファイリングとボトルネックの特定 - FFI(Foreign Function Interface), Rust Bindingsの選定基準 - Rust Bindings(PyO3)を使った実装と検証

Slide 8

Slide 8 text

使⽤するサンプルコード def match_records(source_records: list[Record], target_records: list[Record]) _> list[dict]: matched = [] for source in source_records: for target in target_records: match_kind = None if source.key _= target.key: # source, targetの完全一致判定 match_kind = "perfect" elif source.key in target.key: # source, targetの部分一致判定 match_kind = "source_in_target" elif target.key in source.key: # source, targetの部分一致判定 match_kind = "target_in_source" if match_kind is not None: if abs(source.amount - target.amount) <= 1: matched.append(__.) return matched # 金額の一致判定

Slide 9

Slide 9 text

使⽤するサンプルコード def match_records(source_records: list[Record], target_records: list[Record]) _> list[dict]: matched = [] for source in source_records: for target in target_records: match_kind = None if source.key _= target.key: # source, targetの完全一致判定 本番コードはより複雑なため、 # source, targetの部分一致判定 match_kind = "source_in_target" ボトルネックの特定が難しいことが多い elif target.key in source.key: # source, targetの部分一致判定 match_kind = "perfect" elif source.key in target.key: match_kind = "target_in_source" if match_kind is not None: if abs(source.amount - target.amount) <= 1: matched.append(__.) return matched # 金額の一致判定

Slide 10

Slide 10 text

プロファイリング

Slide 11

Slide 11 text

プロファイリングとは - コンピュータプログラムの実⾏状況を監視‧記録し、各処理の実⾏時間を集計すること - どの関数や⾏がボトルネックになっているかを可視化できる

Slide 12

Slide 12 text

Pythonのプロファイラ - 決定的プロファイラ - cProfile: 標準ライブラリ - line_profiler: ⾏単位で測定できるプロファイラ - 統計的プロファイラ - pyinstrument: Cythonで書かれたプロファイラ ⾮同期(async/await)コードに対応 - py-spy: Rustで書かれた低オーバーヘッドプロファイラ ネイティブコードも追跡可能 - Tachyon: Python 3.15で追加予定の標準統計的プロファイラ 低オーバーヘッドで⾮同期コードも追えるpyinstrumentを使⽤

Slide 13

Slide 13 text

プロファイリング実施 実⾏ - サンプルコードに対して、source:3万件、target:3万件のランダムデータで検証 uv add _-dev pyinstrument # 開発時のみ必要なため開発依存として追加 uv run pyinstrument <計測したい対象のスクリプト> 結果 - 関数ごとにかかった秒数がツリー構造で表⽰される。 43.9726 main.py:1 └─ 43.9263 run_match_records_with_random_data └─ 43.8482 match_records ├─ 43.2737 [self] └─ 0.4609 abs main.py:7 main.py main.py:78 match_records関数に時間がか かっていることがわかる。

Slide 14

Slide 14 text

HTMLでの可視化 実⾏ uv run pyinstrument -r html <計測したい対象のスクリプト> 結果

Slide 15

Slide 15 text

プロファイリング結果の⾒⽅ - if⽂や変数への代⼊は、[self]として表⽰される - absなどの組み込み関数はとして表⽰ - match_recordsで処理時間がかかっていることがわかる match_kind = None 43.9726 if source.key _= target.key: main.py:1 └─ 43.9263 run_match_records_… └─ 43.8482 match_records ├─ 43.2737 [self] └─ 0.4609 abs main.py:78 main.py:7 main.py match_kind = "perfect" elif source.key in target.key: match_kind = "source_in_target" elif target.key in source.key: match_kind = "target_in_source" if match_kind is not None: if abs(source.amount - target.amount) <= 1: results.append(__.) サンプルコード(再掲)

Slide 16

Slide 16 text

より詳細に⾒る - ⽂字列⽐較をする関数match_keyを作り、さらに分析をする - match_keyの⽂字列⽐較ではなく、match_records⾃⾝のループ処理で時間がか かっている事がわかる (時間が伸びているのは関数呼び出しが増えたため) def match_key(source_key, target_key): if source_key _= target_key: return 'perfect' elif source_key in target_key: return 'source_in_target' elif target_key in source_key: return 'target_in_source' return None 99.422 main.py:1 └─ 99.378 run_match_… main.py:106 └─ 99.301 match_records ├─ 61.007 [self] main.py:16 main.py └─ 37.895 match_key main.py:7

Slide 17

Slide 17 text

(余談) FastAPIのエンドポイントに対するプロファイリング middlewareとして追加して、リクエストを送信する @app.middleware("http") async def add_profiling_middleware(request, call_next): pyinstrument_profile_filename = f"pyinstrument_profile_{now}.html" profiler = Profiler() profiler.start() response = await call_next(request) profiler.stop() Path(pyinstrument_profile_filename).write_text(profiler.output_html()) return response

Slide 18

Slide 18 text

ボトルネックを解消するための技術選定

Slide 19

Slide 19 text

⼀番考えるべきはアルゴリズムでの⼯夫 - まず、データ構造やアルゴリズムによる改善を検討 > 事前に同じ⽂字列、⾦額をグルーピングするなど - ⽂字列⼀致判定を⾼速に⾏うことができるオープンソースライブラリ > daachorse (アルゴリズム⾃体は、Rustで実装されている) > pyahocorasick - ⽐較回数/ループ処理そのものを⼗分に減らせないケースでは、依然として処理時間が課題 > プロダクションコードでは、照合結果の位置情報の取得や正規化があった > FFI(Foreign Function Interface)で、PythonからRustコードを呼び出す⽅式に

Slide 20

Slide 20 text

なぜFFIを選択したか - 変更時の要件 > 既存の資産を維持したい - APIの⼊出⼒ - ストレージからのダウンロードやアップロード - ボトルネック以外の既存処理の流⽤ > Pythonの拡張性を維持する - 将来的に機械学習モデルによる推論を追加 - Pythonの機械学習エコシステムとの連携 既存資産とPythonの拡張性を残しつつボトルネックとなっている処理 のみを変更の対象とするためにFFIを選択

Slide 21

Slide 21 text

FFIの実装⼿段としてRust Bindings(PyO3)を選択 - 安全性 > Rustの型システムと所有権を活⽤ - 処理との相性 > ⽂字列⽐較‧正規化に使えるクレート(パッケージ)が豊富 > CPUバウンドな処理を⾼速化しやすい - 実装‧運⽤ > 社内にRustの採⽤実績があり、有識者がいる > 実装‧運⽤時の相談ができる - OSSでの利⽤実績 > Polars / pydantic-core / tiktoken / orjson > Pythonライブラリで広く活⽤されている 性能だけでなく、安全性‧処理特性‧運⽤可能性‧エコシステムを含めてRust Bindings(PyO3)を選択

Slide 22

Slide 22 text

PyO3によるRust Bindingsの実装

Slide 23

Slide 23 text

PyO3とは - Rustの関数をPythonから呼び出せるように橋渡しするライブラリ _[pyfunction] from my_extension import sum_as_string result = sum_as_string(21, 21) # 42 PyO3 Python int → Rust usize Rust String → Python str fn sum_as_string(a: usize, b: usize) _> PyResult { Ok((a + b).to_string()) } _[pymodule] fn my_extension(m: &Bound<'_, PyModule>) _> PyResult<()> { m.add_function(wrap_pyfunction!(sum_as_string, m)?)?; Ok(()) } PyO3 User Guide

Slide 24

Slide 24 text

Rust Bindingsのパッケージ管理ツール: maturin - maturinを使うことにより、RustコードのコンパイルからPython Wheelファイルの⽣ 成までを⾏う > Wheelファイル: ビルド済みのパッケージファイル(Zip形式アーカイブ) maturinが担当 1. Rustで実装 2. コンパイル 3. wheel化 4. Pythonから呼び出し PEP 427 – The Wheel Binary Package Format 1.0 Maturin User Guide

Slide 25

Slide 25 text

実装の準備 maturin でRust Bindingsプロジェクト作成 uv tool install maturin maturin new my_extension ディレクトリ構成 . ├── main.py # Rust Bindingsの呼び出し側のコード ├── my_extension # Rust Bindingsパッケージ(Pythonから呼ぶ際のパッケージ名) │ ├── Cargo.lock │ ├── Cargo.toml │ ├── pyproject.toml │ └── src │ └── lib.rs └── pyproject.toml # Rust コード

Slide 26

Slide 26 text

Rust実装 lib.rs :[pyfunction] fn match_records(py: Python<'_>, source_json: &str, target_json: &str) :> PyResult { let source_records: Vec = serde_json::from_str(source_json) - #[pyfunction]で、Python側か .map_err(|e| PyValueError::new_err(format!("failed to parse source json: {e}")))?; let target_records: Vec = serde_json::from_str(target_json) ら呼ばれるように .map_err(|e| PyValueError::new_err(format!("failed to parse target json: {e}")))?; - 戻り値をPyResultにすること py.detach(:| { let mut matched = Vec::new(); で、成功値またはPython例外を for source in &source_records { for target in &target_records { 返す <キーの比較>. if match_kind.is_some() { matched.push(MatchResult { source_id: &source.id, target_id: &target.id, diff: source.amount - target.amount, kind: match_kind.unwrap() }); }}} serde_json::to_string(&matched) .map_err(|e| PyValueError::new_err(format!("failed to serialize result: {e}"))) }) }

Slide 27

Slide 27 text

パッケージ化してPythonから呼び出す Pythonパッケージとしてインストール uv add _-editable ./my_extension Python側で呼び出す def match_records_wrapper(source_records: list[dict], target_records: list[dict]) _> list[dict]: matched_json = my_extension.match_records( json.dumps(source_records), json.dumps(target_records), ) return json.loads(matched_json)

Slide 28

Slide 28 text

⼯夫した点: Rust/Python間のデータ受け渡し - PythonとRustの境界では⽂字列で受け渡しを⾏った > 引数: Python側はjson⽂字列を渡し、Rust側はそれをパースする > 戻り値: Rust側はjson⽂字列を渡し、Python側はそれをパースする - PythonオブジェクトをRust側で直接扱うことも可能。 > ただし、その間はGIL(Global Interpreter Lock)の影響を受ける def match_records_wrapper(source_records: list[dict], target_records: list[dict]) _> list[dict]: matched_json = my_extension.match_records( json.dumps(source_records), json.dumps(target_records), ) return json.loads(matched_json)

Slide 29

Slide 29 text

⼯夫した点: 実装粒度を元のPython実装と揃える - 関数‧クラスの責務と粒度を、元のPython実装に揃えた - テストコードを再利⽤できるため、Rust書き換え時の意図しない挙動の変化を防⽌ Python版match_records match_recordと 同じテストケース そのまま流⽤ match_records_wrapper Rust版 match_records

Slide 30

Slide 30 text

実務で苦労した点 - ライフタイムと所有権の設計 > レコードのコピーを避けるため、関数間の受け渡しとマッチ結果を参照で保持 > 関数終了後も参照が有効になるようライフタイムの設計が必要 > コンパイルエラー発⽣時は、⽣成AIも活⽤して複数の設計案を⽐較し、修正⽅針を整理 - Rustでの⽂字列の扱い > それぞれの関数ごとに⽂字単位で出⼒されるか、バイト単位で出⼒されるのかを⾒極め る必要があった fn print_length() { let text = "請求書"; println!("bytes: {}", text.len()); _/ bytes: 9 println!("chars: {}", text.chars().count()); _/ chars: 3 }

Slide 31

Slide 31 text

Rust Bindings書き換え後のプロファイリング - 43.9秒 → 6.6秒: 約6.7倍⾼速化 - 実際のプロダクションコードでは、アルゴリズムの改善も組み合わせて約30~44倍⾼速化を実現 6.7287 43.9726 ├─ 6.6501 run_match_… main.py:1 main_rs.py:70 │ ├─ 6.5698 match_records_wrapper │ │ ├─ 5.8504 match_records │ │ └─ 0.7085 loads main.py │ │ │ └─ 0.0792 generate_random_records └─ 43.9263 run_match_… main.py:78 └─ 43.8482 match_records ├─ 43.2737 [self] └─ 0.4609 abs main_rs.py:1 main.py:7 json/__init__.py:304 [2 frames hidden] └─ 0.0774 [self] main_rs.py:11 json main_rs.py:24 main_rs.py Python実装(再掲) Rust Bindingsでの実装

Slide 32

Slide 32 text

まとめ

Slide 33

Slide 33 text

まとめ - パフォーマンス改善では、まずプロファイリングで現状を分析する - アルゴリズムの改善だけでは解決しないときの⼿段として、⾔語レベルの⾼速化も検討 > 今回はボトルネック部分だけをRust化し、約6.7倍⾼速化 - Rust Bindingsはすべてのケースで最適とは限らないが、既存のPython資産を活かしながら ⼀部を⾼速化する有効な選択肢 > 既存資産、チームの技術スタックなどを含めて選定 - 現状の運⽤では、Rust未経験者も機能追加を担当し、運⽤⼈数を増やす取り組みを実施 > ⽣成AIの⽀援もあり、Python実装時と⽐べて開発時間は⼤きく増えていない

Slide 34

Slide 34 text

当社テックブログの記事もぜひご覧ください https://buildersbox.corp-sansan.com/entry/2026/08/19/130000

Slide 35

Slide 35 text

No content