第13章 Python 相互運用
13.1 この章で学ぶこと
この章で学ぶ内容は以下のとおりです。
学ぶこと |
ポイント |
|---|---|
Mojo → Python の呼び出し |
|
|
Python の動的な値を Mojo の静的型世界に持ち込む入れ物 |
Python → Mojo の呼び出し |
|
型変換のルール |
境界を越えるたびに変換コストが発生する |
速くならない部分 |
Python を呼んだ箇所は CPython の世界に戻る(GIL も残る) |
Mojo は Python とは別の言語ですが、双方向の橋を持ちます。 既存の Python 資産を捨てずに Mojo の速さを活かすための仕組みです。
13.2 相互運用の 2 方向
Mojo と Python の相互運用には 2 つの方向があります。 それぞれの特徴を整理します。
方向 |
手段 |
典型的な用途 |
|---|---|---|
Mojo → Python |
|
NumPy・Matplotlib など Python 資産の再利用 |
Python → Mojo |
|
重い計算を Mojo で実装し Python から呼ぶ |
図13-2: 相互運用の 2 方向(Mojo ↔ Python)
前提として、Mojo のランタイムには CPython インタープリタが組み込まれています。
Python.import_module を初めて呼ぶとその場で CPython が起動し、以後は同じプロセス内に共存します。
「Mojo が Python を呼ぶ」とは「同一プロセス内の CPython C-API を叩く」ことです。
境界を越えるたびに PythonObject ↔ Mojo 型の変換が発生し、どちらの方向でも変換コストはゼロではありません。
13.3 Mojo から Python を呼ぶ
最も頻繁に使うのは「Mojo から Python ライブラリを呼ぶ」方向です。 NumPy、Matplotlib、scikit-learn など膨大な Python 資産を、Mojo コードの中からそのまま利用できます。
手順は 3 ステップに集約されます。
Python.import_module("モジュール名")でモジュールを取得するモジュールのメソッドや関数を呼ぶ(戻り値は
PythonObject)必要なら
型(py=obj)で Mojo の静的型に変換する
以下は Python 標準ライブラリの math.sqrt を Mojo から呼ぶ最小例です。
# Python 標準ライブラリの `math` を Mojo から呼び出す最小例。
from std.python import Python, PythonObject
def main() raises:
var math = Python.import_module("math")
var two = PythonObject(2.0)
var py_sqrt = math.sqrt(two)
var x = Float64(py=py_sqrt)
print(x)
リスト13-1: python_from_mojo_math.mojo
コードを 1 行ずつ読みます。
① Python.import_module("math")
CPython のモジュールロード機能を呼び出します。
内部では PyImport_ImportModule("math") という C-API が実行され、戻り値はモジュールオブジェクトを包んだ PythonObject です。
CPython がまだ起動していない場合はここで初期化されます。
② PythonObject(2.0)
Mojo の Float64 リテラル 2.0 を Python の float オブジェクトに変換します。
CPython のヒープに PyFloatObject が確保され、参照カウントが 1 になります。
③ math.sqrt(two)
Python の属性アクセス(math.sqrt)とその呼び出しが順に実行されます。
内部では PyObject_GetAttrString(math, "sqrt") → PyObject_CallOneArg(fn, two) という C-API シーケンスになります。
戻り値は PythonObject(Python の float)です。
④ Float64(py=py_sqrt)
PythonObject を Mojo のプリミティブ型へ変換する明示的な手順です。
py= キーワードを使うことで「Python オブジェクトからの変換」であることを型システムに伝えます。
この変換は失敗することがあるため、raises 関数の内部でのみ使えます。
コード |
意味 |
|---|---|
|
CPython を起動・ |
|
Mojo の値を Python オブジェクトに変換。CPython ヒープに確保 |
|
|
|
|
GIL の取得タイミング
Python.import_module や Python 関数呼び出しの直前、Mojo のランタイムは CPython の GIL(グローバルインタープリタロック)を取得します。
Python 側の処理が終わると GIL を解放します。
したがって、「Mojo から Python を呼ぶ」コードは並列スレッドが多くても CPython 部分では直列化されます。
13.4 PythonObject:境界を越える入れ物
PythonObject は Python の動的な値を Mojo 側で保持するラッパーです。
内部的には CPython の PyObject*(参照カウント付きオブジェクトへのポインタ)を 1 つ持っています。
図13-4: PythonObject の内部構造と CPython ヒープ
PythonObject が生成されると Py_IncRef(参照カウント +1)、スコープを抜けると Py_DecRef(参照カウント −1)が自動で呼ばれます。
カウントが 0 になると CPython の GC がオブジェクトを解放します。
# `PythonObject` で値を包み、Mojo のプリミティブ型へ明示的に変換する例。
from std.python import PythonObject
def main() raises:
var py_string = PythonObject("Hello, Mojo!")
var py_bool = PythonObject(True)
var py_int = PythonObject(123)
var py_float = PythonObject(3.14)
var mojo_string = String(py=py_string)
var mojo_bool = Bool(py=py_bool)
var mojo_int = Int(py=py_int)
var mojo_float = Float64(py=py_float)
print(mojo_string)
print(mojo_bool)
print(mojo_int)
print(mojo_float)
リスト13-2: python_object_wrap_and_convert.mojo
コードのポイントを整理します。
PythonObject("Hello, Mojo!")は Mojo のStringLiteralを Python のstrオブジェクトに変換します。PythonObject(True)/PythonObject(123)/PythonObject(3.14)はそれぞれbool、int、floatの Python オブジェクトを CPython ヒープに確保します。String(py=py_string)等はPythonObjectの中身を取り出して Mojo の静的型に変換します。変換に失敗すると例外が送出されます(raises関数内でのみ呼べます)。属性アクセス
obj.attr_nameはPyObject_GetAttrStringを呼びます。メソッド呼び出しobj.method(args)は属性取得 →PyObject_Callの 2 ステップです。
操作 |
内部で呼ばれる C-API / 説明 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
属性取得 → |
|
|
13.5 「速くならない」部分を理解する
Python ライブラリを呼び出す際に速くならない部分と、その理由を整理します。
誤解 |
現実 |
|---|---|
「Python 関数を Mojo から呼べば速くなる」 |
Python 呼び出し部分は CPython の世界に戻る。Mojo の恩恵なし |
「GIL はなくなる」 |
|
「PythonObject の変換は無料」 |
|
「NumPy を Mojo から呼べば GPU に乗る」 |
NumPy の実体は CPU 上の C 実装。GPU に移すには別の手段が必要 |
境界またぎのコスト
Python を呼ぶたびに次の処理が発生します。
GIL の取得:他の Python スレッドが動いていれば待ちが発生
C-API 呼び出し:
PyObject_Call等のディスパッチ引数の変換:Mojo 型 →
PythonObject(Py_IncRef等)CPython バイトコードの実行(純 Python 関数の場合)
戻り値の変換:
PythonObject→ Mojo 型GIL の解放
1 回あたりのコストは数百〜数千ナノ秒のオーダーです。 10 万回のループで呼ぶと数十〜数百ミリ秒の損失になります。
ループの外に境界を出す
図13-5: ループの外に境界を出す
NumPy との協調
NumPy 配列は内部バッファとして連続メモリを持ちます。
Mojo から NumPy の __array_interface__ 経由でそのバッファのアドレスを取得し、Pointer で直接読み書きすれば、コピーなしで連携できます。
この場合も「バッファアドレスを 1 回受け取る」だけで済み、境界またぎは最小限に抑えられます。
ホットパスは Mojo 側に留め、Python を呼ぶ回数を最小化するのが基本原則です。
13.6 Python から Mojo を呼ぶ
Mojo を Python の拡張モジュールとして公開するパターンです。 重い計算をすべて Mojo で実装し、既存の Python コードから透過的に呼び出せます。
仕組みの概要
CPython は import 時に共有ライブラリ(.so / .dylib)をロードし、PyInit_<モジュール名> という名前の関数を呼び出します。
これは C 拡張モジュールと全く同じ規約です。
Mojo はこの規約に乗ることで、Python から見て「普通の拡張モジュール」として振る舞えます。
図13-6: Python から Mojo を呼ぶ仕組み
Mojo 側(拡張モジュール定義)
# Python から `import mojo_module` できるようにする拡張モジュールの例。
from std.python import PythonObject
from std.python.bindings import PythonModuleBuilder
from std.os import abort
@export
def PyInit_mojo_module() -> PythonObject:
try:
var m = PythonModuleBuilder("mojo_module")
m.def_function[factorial]("factorial", docstring="Compute n!")
return m.finalize()
except e:
abort(String("error creating Python Mojo module:", e))
def factorial(py_obj: PythonObject) raises -> PythonObject:
var n = Int(py=py_obj)
var result = 1
for i in range(2, n + 1):
result *= i
return PythonObject(result)
リスト13-3: mojo_module.mojo
コードを 1 行ずつ読みます。
① @export
この関数をシンボルテーブルに公開する Mojo のデコレータです。
@export がなければリンカが PyInit_mojo_module を外部から見えない内部シンボルとして扱い、CPython からロードできません。
② PyInit_mojo_module() -> PythonObject
CPython の拡張モジュール規約に従い、モジュール名と同名の PyInit_ 関数を定義します。
戻り値は新しいモジュールオブジェクト(PythonObject)です。
③ PythonModuleBuilder("mojo_module")
Mojo 関数を Python モジュールに登録するビルダーです。
内部では PyModule_New("mojo_module") 相当の処理を行います。
④ m.def_function[factorial]("factorial", docstring="Compute n!")
Mojo の factorial 関数を "factorial" という Python 名で登録します。
def_function はラッパー(PyCFunction)を自動生成し、引数と戻り値の変換コードを埋め込みます。
⑤ factorial(py_obj: PythonObject) raises -> PythonObject
Python から呼ばれる実体です。
引数は常に PythonObject で渡ってくるため、Int(py=py_obj) で Mojo の Int に変換してから計算します。
結果は PythonObject(result) で包んで返します。
Python 側(呼び出し)
# Mojo でビルドした `mojo_module` を Python から読み込む例。
# 実行前に、このディレクトリを PYTHONPATH に含め、Mojo 拡張をビルドしておくこと。
import mojo.importer
import mojo_module
print(mojo_module.factorial(5))
リスト13-4: call_mojo_factorial.py
import mojo.importer は Mojo のビルドシステムが提供するインポートフック(importlib メタパス finder)で、.mojo ファイルや Mojo ビルド成果物を Python の import 機構に組み込みます。
要素 |
役割 |
|---|---|
|
関数をリンカの公開シンボルにする。これがないと CPython からロード不可 |
|
Python が |
|
モジュールオブジェクトを構築し、関数を登録するビルダー |
|
Mojo 関数を Python モジュールに登録。PyCFunction ラッパーを自動生成 |
|
引数として渡ってくる |
|
Mojo の計算結果を Python 側に返す |
|
Mojo ビルド成果物を Python の |
コラム: ビルド手順はバージョンで変わる
Python から Mojo を呼ぶための拡張モジュールのビルドコマンドは、Mojo のリリースごとに更新されることがあります。 実際にビルドする際は Calling Mojo from Python の最新手順を確認してください。
PYTHONPATH の設定や mojo.importer の有無もリリースによって異なります。
13.7 型変換のまとめ
Mojo と Python 間で行われる型変換のパターンをまとめます。
プリミティブ型の変換
Mojo 型 |
Python 型 |
Mojo → Python |
Python → Mojo |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
任意 |
そのまま渡す |
そのまま受け取る(動的型のまま) |
コレクション型の変換
List[T] や Dict は要素ごとに変換が必要です。
変換コストは要素数に比例するため、大きなコレクションを頻繁に変換するとボトルネックになります。
Mojo 型 |
Python 型 |
変換の方法 |
|---|---|---|
|
|
|
|
|
|
NumPy |
|
|
変換コストの目安
図13-7: 変換コストの目安(小 → 大)
設計のガイドライン
変換は境界で 1 回だけ行う。 ループ内で毎回変換するのは避ける。
大きな配列は NumPy バッファ経由でゼロコピー接続する。
PythonObjectの__array_interface__からポインタを取り出し、Pointerで直接読み書きする。型変換は
raises関数内で行う。 変換失敗(型不一致、値範囲外)は例外になるため、raises関数の外では使えない。動的型のまま扱えるなら
PythonObjectを引き回す。 Mojo 型に変換する必要がない処理は変換をスキップできる。
13.8 まとめ
本章で扱った内容をまとめます。
要素 |
押さえるべきこと |
|---|---|
|
CPython を経由。戻り値は |
|
Mojo 側で Python の値を保持するラッパー。変換は明示が必要 |
Python を呼んだ部分の速度 |
Mojo の恩恵はない。GIL も残る |
Python → Mojo 拡張 |
|
速くする原則 |
境界をまたぐ回数を減らし、ホットパスは Mojo に閉じ込める |