include_str!()パターン — RustバイナリにPython MLを埋め込む方法
Nightingaleが示した実用的妥協 — 単一バイナリ配布、永続プロセス通信、バージョン不一致排除
Nightingale(Rust製カラオケアプリ)のコードで発見したパターン。RustからPython MLスタックを扱う実践的アーキテクチャ。
問題:RustアプリでPython MLをどう動かすか
NightingaleはUVR/Demucs(音声分離)とWhisperX(音声認識)を使う必要がある。どちらもPyTorchベースで、Pythonエコシステムでしか正しく動かない。
一般的な選択肢とそれぞれの問題:
システムPythonに依存 — ユーザーごとにバージョンが違い、パッケージ衝突が日常。
Pythonスクリプトを別ファイルで配布 — ファイル漏れ、パス問題、ユーザーの誤削除。
PyO3でRust↔Pythonバインディング — 単純な関数は良いが、重いMLスタックのバインディングは依存性地獄。
すべてRustで書き直し — 非現実的。MLエコシステムはPython中心。
Nightingaleの解法:include_str!() + 永続プロセス
const STEMS_PY: &str = include_str!("../analyzer/stems.py");
const SERVER_PY: &str = include_str!("../analyzer/server.py");
Rustのinclude_str!()マクロはコンパイル時にファイル内容を文字列定数としてバイナリに含める。ランタイムでのファイル読み込みではない。
動作フロー
ビルド時: cargo buildで10個の.pyファイルが文字列としてバイナリに含まれる→配布ファイルは実行ファイル1つ。
初回起動(ブートストラップ): ~/.nightingale/vendor/ディレクトリ作成→埋め込み.py文字列をファイルに展開→uvでPython 3.10インストール→venv作成→PyTorch、WhisperXインストール→.readyマーカー作成。
分析実行: Rustがserver.pyを子プロセスとしてスポーン→PythonがWhisperXモデルロード→待機→RustがstdinにJSON送信→Pythonがstdoutで応答→サーバーは次の曲の分析要求を待つ。
5つの利点
1. 単一バイナリ配布 — 配るファイルは1つ。
2. バージョン不一致不可能 — RustコードとPythonコードが同じコミットからビルド。
3. ユーザーが触れない — バイナリ内にあるので誤修正・誤削除不可。
4. 永続Pythonサーバー — MLモデルが曲間でメモリに常駐。CUDA OOM?サーバーをkillして再スポーン、アプリ全体は落ちない。
5. シンプルJSON通信 — FFIの複雑さなし、デバッグ容易。
このパターンが適する場面
Python支配的な領域(ML推論)
低頻度呼び出し(曲単位、ミリ秒単位ではない)
IPC オーバーヘッドが無視できる
配布の簡素さが重要なデスクトップアプリ
適さない場面
秒間数千回の呼び出し→PyO3やONNX Runtime Rustバインディングが適切
数百のPythonファイル→バイナリ肥大化
双方向ストリーミング→gRPCやソケットが適切
核心
「MLはPythonが得意だからPythonでやる。ただし配布と制御はRustが握る。」PyO3のようなバインディングで2言語を1プロセスに詰め込む代わりに、プロセスを分離しつつ配布は1つにまとめた。結合度を下げながら配布複雑度も下げた実用的妥協。
キーコンセプト
include_str!()でPythonスクリプト10個をコンパイル時にRustバイナリに埋め込み
初回起動時に~/.nightingale/vendor/analyzer/に.pyファイルとして展開
uvでPython 3.10+PyTorch+MLパッケージを自動インストール(ブートストラップ)
server.pyを永続子プロセスとしてスポーン→WhisperXモデルがメモリに常駐
stdin/stdout JSONプロトコルで分析要求・進捗・完了を通信
CUDA OOM時:サーバーkill→GPUクリーン→再スポーン(アプリは落ちない)