お疲れ `libsparseir`, こんにちは `sparse-ir-rs`
本日は
この記事は sparse-ir と呼ばれる Python, Julia で書かれたライブラリを C++ に移植した libsparseir をさらに Rust に移植された sparse-ir-rs にマイグレーションした際の技術的なお話です.
C++ に移植したまでの過程は下記の記事を参考にしてください.
C++ 実装を作ったのに Rust に移行したのは色々な事情がありますが,Hiroshi Shinaoka (品岡さん)の下記の記事が詳しいです.
個人的には Rust にはパッケージマネージャがあるので,C++ からの乗り換えには賛成でした.私は SparseIR.jl -> C++ には半年かかりましたが,現在の技術 (Cursor) では2ヶ月ほどで Rust 化することができたようです.品岡さん自身が sparse-ir 周りの物理的な背景には詳しいのでドメイン知識を持っていることが如何に大事かがわかります.
sparse-ir-rs は Rust クレートであり,intermediate representation (IR) for imaginary-time propagators のコアな実装を担っています.
Julia, Python, Fortran はこの実装を C-API を経由して呼び出しています.この記事ではそれについて話していきます.
基本的な方針 (C-API を経由して機能を呼び出す)
他言語から Rust で実装されたコアな実装を呼ぶ際は C-API を経由して機能を呼ぶことにします.
これは C++ 実装 libsparseir の時代のアイデアを踏襲しています.
LogisticKernel を作る際,spir_logistic_kernel_new という C-API を利用します.
spir_kernel::new_logistic(lambda) にある spir_kernel は下記のように定義される構造体です.
new_logistic は次で定義されるメソッドであり sparse-ir クレート内で定義されているRust 実装 LogisticKernel をラップします.
Box::into_raw(Box::new(kernel)) を返すことでポインタを呼び出し元(e.g., Python, Julia) に渡します.
Rust 実装 LogisticKernel は下記のように定義されています.
構造体が持っている lambda を他言語からアクセスする場合は C-API spir_kernel_get_lambda を使って呼び出します.
lambda_out に結果が格納されます.
Python からの連携
Python から C-API 経由で機能呼び出す時 ctypes モジュールを使います.
の例と同様に下記のように呼び出すことができます.
import ctypes
_lib = ctypes.CDLL("path/to/sharedlib")
# Kernel functions
_lib.spir_logistic_kernel_new.argtypes = [c_double, POINTER(c_int)]
_lib.spir_logistic_kernel_new.restype = spir_kernel
def logistic_kernel_new(lambda_val):
"""Create a new logistic kernel."""
status = c_int()
kernel = _lib.spir_logistic_kernel_new(lambda_val, byref(status))
if status.value != COMPUTATION_SUCCESS:
raise RuntimeError(f"Failed to create logistic kernel: {status.value}")
return kernel
C++ 実装の時は spir_logistic_kernel_new.argtypes, spir_logistic_kernel_new.restype をAIの補助のもと手動で書いていましたが,現在は Rust C-API に対応するヘッダーファイル sparseir.h を解析し argtype, restype を自動生成させています.
さらに「Rust C-API に対応するヘッダーファイル sparseir.h」自体も自動生成することができます.これは cbindgen という Rust ライブラリから C-API を作るためのヘッダーを作成するライブラリを利用しています.
cbindgen の呼び出しは build.rs で制御しています.
開発者は cargo build コマンドを知っていれば OK というわけです.開発者は Rust 実装の C-API 設計とコアなアルゴリズムの開発に集中できるわけです.
Julia からの連携
Julia には Clang.jl パッケージがあり,C-API のシグニチャを格納した sparseir.h から ccall を使う Julia の関数を自動生成してくれます.Python では我々は自動生成するスリプト・仕組みを AI に頼りながら作りましたが,Julia では Clang.jl の機能を使えば OK になってます.この意味ではやや Julia の方が楽ですね.
Python パッケージのビルドシステム
Python パッケージ管理は uv を使います.C++ 実装 libsparseir の共有ライブラリを梱包するにあたって scikit-build に任せていました.
Rust 実装 sparse-ir-rs ではビルドバックエンドとして hatching を利用しています.
pyproject.toml には下記のように設定しておき
build-backend = "hatchling.build"
hatch_build.py を書いておくと uv sync などをする際に Rust プロジェクトのビルド cargo build --release が走り共有ライブラリも生成するようにできるようになりました.
ここら辺も AI 任せでしています.AI のモデルは Cursor にある Auto モデル Composer1 Opus 4.5 を必要に応じて作らせました.
PyPI への登録などの CI/CD パイプラインは libsparseir の資源をほぼ再利用しています.
変更したことは cibuildwheel が走る際に Rust 実装を入れるぐらいの前処理を適用したぐらいです.
Julia ビルドシステム
共有ライブラリのビルド・登録は Yggdrasil にて行うのは C++ の時と変わりません.
Rust に切り替わった関係でサポートできるプラットフォームは減ってしまいましたが,Linux環境 Apple Silicon ではサポートできるので我々の想定する範囲では特に問題はなさそうです.
C++ から Rust に切り替わったことで
AI のモデルの進化もあってか,幾分か楽になりました.コンパイラがはくエラーメッセージがわかりやすい.Julia から Rust の資源を呼び出す際にエラーが起きることはたまにありますが,どこでバグが生じているのかがわかりやすくなりました.C++ ではセグメンテーションフォルトが起きたでプロセスが死んでしまいましたが,Rustでは assert! マクロなどを入れることでRust内部で起きたのか Julia 内部の方でダメだったのかを切り分けしやすくなりました.
cargo build でビルドができるようになったのが楽でした.C++ の場合は cmake でビルドディレクトリを作って・・・とか CMakeLists.txt の盆栽を作る必要がありましたが,その苦労をする必要がなくなりました.
ちなみに私は Rust のコードを手で書いてるわけではなく,全て Cursor の AI エージェントに任せてしまいました.細かいシンタックスはまだ理解できてないですが,細部は AI に補ってもらい大局的な視点でコードを読むことができました.また VS Code のRust エクステンションで特定のテストだけ走らせる機能があるのでデバッグコードをちょろちょろ書く時に重宝しました.C++ だとデバッグ用のファイルを追加・特定のテストをするために CMakeLists.txt をその度に編集するみたいなことをしなければいけなかったので作業量は減りました.
正直新しいプロジェクトで静的な言語で高速なコードを書きたいという理由で C++ を使うことはあまりやりたくないです.生成AI,LLM, AI エージェントが普及した現在では パッケージマネージャがあるモダンな Rust で書くのが良いように思えます.
Julia + Rust の組み合わせはもっと増えるといいなぁ〜って思ってます.
Discussion