第3章 設計思想:ownership、compile-time、interop

3.1 この章で学ぶこと

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

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

学ぶこと

ポイント

Mojo の 3 つの柱

ownership、compile-time、Python interop の役割

それぞれが解く「問題」

Python で何が困るのか、Mojo がどう答えるか

アセンブリとの接続

3 つの柱が .asm のどの命令に現れているか

第2部 で何を学ぶか

各柱が具体的にどの章で展開されるか

3.2 3 つの柱:問題と解法

Mojo の設計の中核をなす 3 つの柱と、それぞれが解決する問題を整理します。

表3-2: 3 つの柱:問題と解法

Python での問題

Mojo の解法

ownership(所有権)と value semantics

値の所有者が不明瞭。GC 任せのため GPU では動かない

値の所有者をコンパイル時に明示し、参照カウントを決定的に管理

compile-time(コンパイル時特化)

型と値が実行時まで不明。最適化の余地が小さい

[] で渡すパラメータをコンパイル時に確定し、特化したコードを生成

Python interop(Python 相互運用)

Python を捨てると膨大なライブラリ資産も捨てることになる

Python を直接呼び出せる。ホットパスだけ Mojo で置き換えられる

Python の問題と Mojo の解決策 — 3 本柱

図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-3: Mojo の解法:引数修飾子

修飾子

意味

用途

imm(省略可)

読み取り専用

値を変更しない。コピー不要で安全に参照

mut

変更可

呼び出し元の値を変更する(明示的な副作用)

owned

所有権の移動

呼び出し先がその値の所有権を引き継ぐ

修飾子を見るだけで「この関数が値を変えるかどうか」がわかります。

Python と Mojo のメモリモデルの比較

図3-2: Python と Mojo のメモリモデルの比較

左は Python のメモリモデル(暗黙的所有権と GC)。 xslst が同じヒープオブジェクトを参照し、予期しない変更が起きる。 右は Mojo のメモリモデル(明示的所有権と RC)。 ownedimmmut で意図を宣言し、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 つの問題があります。

  1. 非決定的なタイミング: いつ解放されるかコードを読むだけではわからない

  2. Stop-the-world: GC が走る瞬間にプログラム全体が止まる可能性がある

  3. 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 種類に分けます。

表3-4: Mojo の解法:[]() の分離

記法

確定タイミング

def foo[width: Int]()

コンパイル時(parameters)

ベクタ幅、型、サイズなどハードウェア依存の定数

def foo(x: Int)

実行時(arguments)

ユーザー入力やループカウンタなど動的な値

[] に渡した値はコンパイル時に確定し、その値に特化したコードを生成します。

3.4.3 アセンブリの証拠:定数畳み込みとスタックサイズ

第2部第1章(第7章 コンパイル結果のアセンブリを読む)の .asm には、compile-time の効果が 2 か所に現れます。

① 定数畳み込みn = 42doubled = n * 2

;  var n: Int = 42
7:   movq   $0x2a, 0xf8(%rsp)

;  var doubled = n * 2
13:  movq   $0x54, 0x100(%rsp)

n * 2 = 840x54)が即値として埋め込まれています。 実行時の乗算命令はゼロです。

② スタックサイズの事前確定(第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 ymmvaddps 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 を呼ぶ際のオーバーヘッドは存在します。

表3-5: interop のコスト

操作

Mojo ネイティブ

Python interop

関数呼び出し

callq 1 命令

PythonObject のラッパーを介する

型チェック

コンパイル時に完了

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 つの柱がアセンブリ出力のどの命令に対応するかをまとめます。

表3-6: 3 つの柱とアセンブリの対応(まとめ)

アセンブリの命令

何を示しているか

ownership

movq $0x1, "..."(%rax)

参照カウントを 1 で初期化(所有者は自分だけ)

ownership

lock xaddq %rax, "..."(%rcx)

アトミックなデクリメント(決定的なタイミングで解放)

compile-time

subq $0x88, %rsp

スタックサイズがコンパイル時に確定

compile-time

movq $0x54, 0x100(%rsp)

n * 2 = 84 が即値として埋め込み済み

compile-time

leaq _static_string_...(%rip), %rax

文字列がバイナリに静的配置

Python interop

callq "_std::io::io::print[...]"

Python を介さないネイティブ呼び出し(interop コストなし)