第13章 Python 相互運用

13.1 この章で学ぶこと

この章で学ぶ内容は以下のとおりです。

表13-1: この章で学ぶこと

学ぶこと

ポイント

Mojo → Python の呼び出し

Python.import_module で既存資産を再利用

PythonObject の役割

Python の動的な値を Mojo の静的型世界に持ち込む入れ物

Python → Mojo の呼び出し

PyInit_ エントリポイントで拡張モジュールを公開

型変換のルール

境界を越えるたびに変換コストが発生する

速くならない部分

Python を呼んだ箇所は CPython の世界に戻る(GIL も残る)

Mojo は Python とは別の言語ですが、双方向の橋を持ちます。 既存の Python 資産を捨てずに Mojo の速さを活かすための仕組みです。

13.2 相互運用の 2 方向

Mojo と Python の相互運用には 2 つの方向があります。 それぞれの特徴を整理します。

表13-2: 相互運用の 2 方向

方向

手段

典型的な用途

Mojo → Python

Python.import_module("math")

NumPy・Matplotlib など Python 資産の再利用

Python → Mojo

PyInit_ エントリポイント + PythonModuleBuilder

重い計算を Mojo で実装し Python から呼ぶ

../_images/python_interop_directions.jpg

図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 ステップに集約されます。

  1. Python.import_module("モジュール名") でモジュールを取得する

  2. モジュールのメソッドや関数を呼ぶ(戻り値は PythonObject

  3. 必要なら 型(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 関数の内部でのみ使えます。

表13-3: Mojo から Python を呼ぶ

コード

意味

Python.import_module("math")

CPython を起動・import math 相当。戻り値は PythonObject

PythonObject(2.0)

Mojo の値を Python オブジェクトに変換。CPython ヒープに確保

math.sqrt(two) の戻り値

PythonObject(Python の float オブジェクトのラッパー)

Float64(py=py_sqrt)

PythonObject を Mojo の Float64 に明示変換。失敗時は例外

GIL の取得タイミング

Python.import_module や Python 関数呼び出しの直前、Mojo のランタイムは CPython の GIL(グローバルインタープリタロック)を取得します。 Python 側の処理が終わると GIL を解放します。 したがって、「Mojo から Python を呼ぶ」コードは並列スレッドが多くても CPython 部分では直列化されます。

13.4 PythonObject:境界を越える入れ物

PythonObject は Python の動的な値を Mojo 側で保持するラッパーです。 内部的には CPython の PyObject*(参照カウント付きオブジェクトへのポインタ)を 1 つ持っています。

../_images/python_object_internal.jpg

図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) はそれぞれ boolintfloat の Python オブジェクトを CPython ヒープに確保します。

  • String(py=py_string) 等は PythonObject の中身を取り出して Mojo の静的型に変換します。変換に失敗すると例外が送出されます(raises 関数内でのみ呼べます)。

  • 属性アクセス obj.attr_namePyObject_GetAttrString を呼びます。メソッド呼び出し obj.method(args) は属性取得 → PyObject_Call の 2 ステップです。

表13-4: PythonObject の主な操作

操作

内部で呼ばれる C-API / 説明

PythonObject(42)

PyLong_FromLong(42):Mojo 値を Python オブジェクトに変換

Int(py=obj)

PyLong_AsLong(obj):Python int を Mojo Int に変換

Float64(py=obj)

PyFloat_AsDouble(obj):Python float を Mojo Float64 に変換

String(py=obj)

PyUnicode_AsUTF8(obj):Python str を Mojo String に変換

obj.attr_name

PyObject_GetAttrString(obj, "attr_name"):属性読み取り

obj.method(a, b)

属性取得 → PyObject_Call:メソッド呼び出し

Bool(py=obj)

PyObject_IsTrue(obj):真偽値変換

13.5 「速くならない」部分を理解する

Python ライブラリを呼び出す際に速くならない部分と、その理由を整理します。

表13-5: よくある誤解と現実

誤解

現実

「Python 関数を Mojo から呼べば速くなる」

Python 呼び出し部分は CPython の世界に戻る。Mojo の恩恵なし

「GIL はなくなる」

Python.import_module 以降は GIL の影響をそのまま受ける

「PythonObject の変換は無料」

PythonObject ↔ Mojo 型の変換ごとにオーバーヘッドが発生する

「NumPy を Mojo から呼べば GPU に乗る」

NumPy の実体は CPU 上の C 実装。GPU に移すには別の手段が必要

境界またぎのコスト

Python を呼ぶたびに次の処理が発生します。

  1. GIL の取得:他の Python スレッドが動いていれば待ちが発生

  2. C-API 呼び出しPyObject_Call 等のディスパッチ

  3. 引数の変換:Mojo 型 → PythonObjectPy_IncRef 等)

  4. CPython バイトコードの実行(純 Python 関数の場合)

  5. 戻り値の変換PythonObject → Mojo 型

  6. GIL の解放

1 回あたりのコストは数百〜数千ナノ秒のオーダーです。 10 万回のループで呼ぶと数十〜数百ミリ秒の損失になります。

ループの外に境界を出す

../_images/python_boundary_loop.jpg

図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 から見て「普通の拡張モジュール」として振る舞えます。

../_images/python_call_mojo_flow.jpg

図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 機構に組み込みます。

表13-6: Python から Mojo を呼ぶ際の各要素

要素

役割

@export

関数をリンカの公開シンボルにする。これがないと CPython からロード不可

PyInit_mojo_module

Python が import mojo_module するときに呼ぶエントリポイント(C 拡張と同一規約)

PythonModuleBuilder

モジュールオブジェクトを構築し、関数を登録するビルダー

def_function[factorial](...)

Mojo 関数を Python モジュールに登録。PyCFunction ラッパーを自動生成

Int(py=n)

引数として渡ってくる PythonObject を Mojo の Int に変換

PythonObject(result)

Mojo の計算結果を Python 側に返す

import mojo.importer

Mojo ビルド成果物を Python の import 機構に統合するフック

コラム: ビルド手順はバージョンで変わる

Python から Mojo を呼ぶための拡張モジュールのビルドコマンドは、Mojo のリリースごとに更新されることがあります。 実際にビルドする際は Calling Mojo from Python の最新手順を確認してください。

PYTHONPATH の設定や mojo.importer の有無もリリースによって異なります。

13.7 型変換のまとめ

Mojo と Python 間で行われる型変換のパターンをまとめます。

プリミティブ型の変換

表13-7a: プリミティブ型の変換

Mojo 型

Python 型

Mojo → Python

Python → Mojo

Int

int

PythonObject(n)

Int(py=obj)

Float64

float

PythonObject(f)

Float64(py=obj)

Bool

bool

PythonObject(b)

Bool(py=obj)

String

str

PythonObject(s)

String(py=obj)

PythonObject

任意

そのまま渡す

そのまま受け取る(動的型のまま)

コレクション型の変換

List[T]Dict は要素ごとに変換が必要です。 変換コストは要素数に比例するため、大きなコレクションを頻繁に変換するとボトルネックになります。

表13-7b: コレクション型の変換

Mojo 型

Python 型

変換の方法

List[Int]

list

PythonObject のリストを作り、要素を 1 つずつ追加

Dict 相当

dict

PythonObject の辞書に 1 エントリずつ追加

NumPy ndarray

numpy.ndarray

__array_interface__ でバッファアドレスを取得し Pointer で直接操作(コピーなし)

変換コストの目安

../_images/python_conversion_cost.jpg

図13-7: 変換コストの目安(小 → 大)

設計のガイドライン

  • 変換は境界で 1 回だけ行う。 ループ内で毎回変換するのは避ける。

  • 大きな配列は NumPy バッファ経由でゼロコピー接続する。 PythonObject__array_interface__ からポインタを取り出し、Pointer で直接読み書きする。

  • 型変換は raises 関数内で行う。 変換失敗(型不一致、値範囲外)は例外になるため、raises 関数の外では使えない。

  • 動的型のまま扱えるなら PythonObject を引き回す。 Mojo 型に変換する必要がない処理は変換をスキップできる。

13.8 まとめ

本章で扱った内容をまとめます。

表13-8: まとめ

要素

押さえるべきこと

Python.import_module

CPython を経由。戻り値は PythonObject

PythonObject

Mojo 側で Python の値を保持するラッパー。変換は明示が必要

Python を呼んだ部分の速度

Mojo の恩恵はない。GIL も残る

Python → Mojo 拡張

PyInit_ + PythonModuleBuilder で関数を登録

速くする原則

境界をまたぐ回数を減らし、ホットパスは Mojo に閉じ込める