第3章 設計思想:ownership、compile-time、interop
3.1 この章で学ぶこと
この章で学ぶ内容は以下のとおりです。
学ぶこと |
ポイント |
|---|---|
Mojo の 3 つの柱 |
ownership、compile-time、Python interop の役割 |
それぞれが解く「問題」 |
Python で何が困るのか、Mojo がどう答えるか |
アセンブリとの接続 |
3 つの柱が |
第2部 で何を学ぶか |
各柱が具体的にどの章で展開されるか |
3.2 3 つの柱:問題と解法
Mojo の設計の中核をなす 3 つの柱と、それぞれが解決する問題を整理します。
柱 |
Python での問題 |
Mojo の解法 |
|---|---|---|
ownership(所有権)と value semantics |
値の所有者が不明瞭。GC 任せのため GPU では動かない |
値の所有者をコンパイル時に明示し、参照カウントを決定的に管理 |
compile-time(コンパイル時特化) |
型と値が実行時まで不明。最適化の余地が小さい |
|
Python interop(Python 相互運用) |
Python を捨てると膨大なライブラリ資産も捨てることになる |
Python を直接呼び出せる。ホットパスだけ Mojo で置き換えられる |
図3-1: Python の問題と Mojo の解決策(3 本柱)
左から「所有権と値セマンティクス(read/mut/owned)」「コンパイル時特化([] vs ())」「Python 相互運用(import_module)」の 3 本柱。 各柱が Python の課題に対する Mojo の解決策を示す。
3.3 ownership と value semantics
3.3.1 Python の問題
Python でリストを関数に渡すとき、値のコピーは起きません。 参照が渡されるため、呼び出し先での変更が呼び出し元に影響します。
def add_item(lst):
lst.append(99) # 呼び出し元の list を変えてしまう
xs = [1, 2, 3]
add_item(xs)
print(xs) # [1, 2, 3, 99] ← 予期しない変更
意図した変更なのか副作用なのか、コードを読むだけでは判断できません。 Python の GC はサイクル参照の検出のため「いつ解放されるか」が非決定的です。 GPU ではそもそも GC は使えません。
3.3.2 Mojo の解法:引数修飾子
Mojo では、関数の引数にどう値を受け取るかを明示します。
修飾子 |
意味 |
用途 |
|---|---|---|
|
読み取り専用 |
値を変更しない。コピー不要で安全に参照 |
|
変更可 |
呼び出し元の値を変更する(明示的な副作用) |
|
所有権の移動 |
呼び出し先がその値の所有権を引き継ぐ |
修飾子を見るだけで「この関数が値を変えるかどうか」がわかります。
図3-2: Python と Mojo のメモリモデルの比較
左は Python のメモリモデル(暗黙的所有権と GC)。
xs と lst が同じヒープオブジェクトを参照し、予期しない変更が起きる。
右は Mojo のメモリモデル(明示的所有権と RC)。
owned、imm、mut で意図を宣言し、lock xaddq による参照カウントで決定的に解放される。
3.3.3 アセンブリの証拠:参照カウントの初期化
所有権管理の具体的な実装は、後述する第5章の .asm に刻まれています。
注釈
本章で引用するアセンブリは、Mojo 1.0.0 / x86-64 / 本書のビルド条件における観測例です(生成条件の詳細は第5章で示します)。 コンパイラのバージョンや最適化レベルが変われば、生成される機械語も変わり得ます。
; hello_mojo_minimal.asm
50: movq $0x1, "_hello_mojo_minimal::main()"(%rax)
movq $0x1 は、文字列オブジェクトの参照カウンタを 1 で初期化しています。
「この文字列を所有しているのは今 main() だけ」という状態をカウンタ 1 で表現しています。
関数の終わりでは、この参照カウントをアトミックにデクリメントします。
c8: lock
c9: xaddq %rax, "_hello_mojo_minimal::main()"(%rcx)
lock xaddq は CPU の排他命令です。
マルチスレッドで安全に参照カウントを操作します。
カウントが 0 になった時点で即座にメモリを解放します(GC を待ちません)。
コラム: なぜ GC ではなく ownership か
GC(ガベージコレクタ)は「どのオブジェクトも使われなくなったら回収する」仕組みですが、3 つの問題があります。
非決定的なタイミング: いつ解放されるかコードを読むだけではわからない
Stop-the-world: GC が走る瞬間にプログラム全体が止まる可能性がある
GPU 非対応: GPU では GC を動かす仕組みが原則存在しない
Mojo の参照カウント + ownership は、解放のタイミングをコード上で決定的に確定します。
lock xaddq がゼロになったその瞬間に解放が走ります。
これにより、CPU でも GPU でも同じメモリ管理モデルが使えます。
3.4 compile-time
3.4.1 Python の問題
Python では、変数の型は実行時まで確定しません。 そのため、演算のたびに型チェックが発生します。
n = 42
doubled = n * 2 # 実行時に「n が int か確認 → int の掛け算 → 新しい int を確保」
コンパイラがこのコードを最適化するには「n が整数であること」を事前に知る必要があります。
Python ではそれができません。
3.4.2 Mojo の解法:[] と () の分離
Mojo は引数を 2 種類に分けます。
記法 |
確定タイミング |
例 |
|---|---|---|
|
コンパイル時(parameters) |
ベクタ幅、型、サイズなどハードウェア依存の定数 |
|
実行時(arguments) |
ユーザー入力やループカウンタなど動的な値 |
[] に渡した値はコンパイル時に確定し、その値に特化したコードを生成します。
3.4.3 アセンブリの証拠:定数畳み込みとスタックサイズ
第2部第1章(第7章 コンパイル結果のアセンブリを読む)の .asm には、compile-time の効果が 2 か所に現れます。
① 定数畳み込み(n = 42 と doubled = n * 2)
; var n: Int = 42
7: movq $0x2a, 0xf8(%rsp)
; var doubled = n * 2
13: movq $0x54, 0x100(%rsp)
n * 2 = 84(0x54)が即値として埋め込まれています。
実行時の乗算命令はゼロです。
② スタックサイズの事前確定(第1章より)
_hello_mojo_minimal::main():
0: subq $0x88, %rsp ; 136 バイトを一括確保
136 という数値はコンパイル時に決定済みです。 実行時にスタックが「伸縮」することはありません。
コラム: [] で渡すとコンパイラが何をするか
def simd_sum[width: Int](v: SIMD[DType.float32, width]) のように定義すると、width = 4 で呼んだときと width = 8 で呼んだときで、コンパイラは別々の機械語を生成します。
このプロセスを specialization(特化) と呼びます。
ランタイムで width を分岐する必要がなく、それぞれのハードウェアに最適な SIMD 命令(vaddps ymm や vaddps xmm など)が直接生成されます。
第2部 の 第11章 メタプログラミング で実際のアセンブリとともに確認します。
3.5 Python interop
3.5.1 なぜ互換性が重要か
AI や科学計算の現場では NumPy、PyTorch、Matplotlib など Python の資産が膨大です。 「Mojo を使うために全部書き直す」は現実的ではありません。
Mojo は Python を直接呼び出せる設計です。
from std.python import Python
def main() raises:
var np = Python.import_module("numpy")
var arr = np.array(Python.list(1.0, 2.0, 3.0))
print(arr.sum()) # NumPy の sum() をそのまま呼ぶ
リスト3-1: numpy_sum.mojo
3.5.2 interop のコスト
Python を呼ぶ際のオーバーヘッドは存在します。
操作 |
Mojo ネイティブ |
Python interop |
|---|---|---|
関数呼び出し |
|
|
型チェック |
コンパイル時に完了 |
Python のランタイム型システムが都度判定 |
GIL |
関係なし |
Python GIL の制約が適用される |
そのため、実際の使い方は「Python で全体を書き、ボトルネックになった部分だけ Mojo の関数に置き換える」というパターンが現実的です。
3.5.3 アセンブリとの接続
第1章の callq "_std::io::io::print[...]" は、Python の print ではなく Mojo ネイティブの print の呼び出しです。
Python の print を経由していないため、PythonObject ラッパーのオーバーヘッドはありません。
これが interop を「使う/使わない」を選べる設計になっている理由です。
3.6 3 つの柱とアセンブリの対応(まとめ)
3 つの柱がアセンブリ出力のどの命令に対応するかをまとめます。
柱 |
アセンブリの命令 |
何を示しているか |
|---|---|---|
ownership |
|
参照カウントを 1 で初期化(所有者は自分だけ) |
ownership |
|
アトミックなデクリメント(決定的なタイミングで解放) |
compile-time |
|
スタックサイズがコンパイル時に確定 |
compile-time |
|
|
compile-time |
|
文字列がバイナリに静的配置 |
Python interop |
|
Python を介さないネイティブ呼び出し(interop コストなし) |