(microgpt.py を Mojo で書き直す)=
# microgpt.py を Mojo で書き直す
## この章で学ぶこと
- `microgpt.py` の**B1〜B7 ブロック**が Mojo でどう書かれているか、ソースコードを読みながら理解する
- Mojo の**所有権・テープ(計算グラフ)・構造体**を、動くコードの上で確認する
- Python の `Value` クラスを Mojo の `Tape`(インデックスベース)に置き換えた**設計上の理由**を知る
- `main.mojo` で B1〜B7 を組み合わせた**学習・推論の全体像**を把握する
---
## 実行例
`src/part3/microgpt_mojo/` で次のように実行します。
```bash
cd src/part3/microgpt_mojo
mojo run main.mojo
```
出力(学習ステップは途中を省略):
```text
num docs: 32033
vocab size: 27
num params: 4192
step 1 / 1000 | loss 3.2589016467713185
step 2 / 1000 | loss 3.4757539661271393
step 3 / 1000 | loss 3.2850563256139123
step 4 / 1000 | loss 3.2385487413715050
step 5 / 1000 | loss 3.4347922579358574
...
step 996 / 1000 | loss 1.9243386681471330
step 997 / 1000 | loss 2.2576022798634730
step 998 / 1000 | loss 1.6500781354343423
step 999 / 1000 | loss 1.9544453781129398
step 1000 / 1000 | loss 2.5348443763481185
--- inference ---
sample 1 : ala
sample 2 : nayna
sample 3 : anare
sample 4 : alania
sample 5 : odelen
sample 6 : sona
sample 7 : adila
sample 8 : kais
sample 9 : tayna
sample 10 : adelel
sample 11 : atena
sample 12 : koncy
sample 13 : maneza
sample 14 : aleran
sample 15 : amelin
sample 16 : tirha
sample 17 : saria
sample 18 : tanin
sample 19 : jaian
sample 20 : astyan
```
loss が 3.2 前後から 1.6〜2.5 程度まで下がり、推論では名前らしい文字列("ala", "sona", "adelel" など)が生成されていることが確認できます。
---
## 全体構造
### B1〜B7 の依存関係
各ブロックは以下のように依存しています。矢印は「使う→使われる」の方向です。
```{mermaid}
flowchart LR
B1[b01_dataset] --> B2[b02_tokenizer]
B2 --> B4[b04_state_dict]
B3[b03_value] --> B4
B3 --> B5a[b05_ops]
B3 --> B5b[b05_gpt]
B4 --> B5b
B5a --> B5b
B5b --> B6[b06_train]
B5b --> B7[b07_infer]
B2 --> B6
B3 --> B6
B3 --> B7
```
**B3(Tape)がすべての土台**です。演算のたびにノードをテープに追記し、逆伝播で勾配を計算します。B4〜B7 はテープ上のノード ID(`Int`)を受け渡しながら動きます。
### ファイル構成
| ファイル | 対応する Python の概念 | 主な内容 |
|---------|----------------------|---------|
| `b01_dataset.mojo` | `docs` リスト構築 | テキストから文書リストを作る |
| `b02_tokenizer.mojo` | `uchars` / `BOS` / トークン列 | 文字→IDの変換 |
| `b03_value.mojo` | `Value` クラス / `backward` | 自動微分テープ |
| `b04_state_dict.mojo` | `state_dict` / `params` | モデル重みの初期化・管理 |
| `b05_ops.mojo` | `linear` / `rmsnorm` / `softmax` | テープ上のテンソル演算 |
| `b05_gpt.mojo` | `gpt()` 関数 | GPT 1ステップの順伝播 |
| `b06_train.mojo` | 学習ループの核 | 損失計算・Adam 更新 |
| `b07_infer.mojo` | 推論ループの核 | 温度付きサンプリング |
| `main.mojo` | スクリプト本体 | 全体を組み合わせた実行ファイル |
### データとコードの流れ
```{mermaid}
flowchart TD
A["input.txt
(32033件の名前)"] -->|load_docs_from_text| B["docs
List[String]"]
B -->|build_tokenizer| C["Tokenizer
(uchars, bos)"]
B -->|encode_doc| D["tokens
List[Int]"]
C --> D
E["Tape"] -->|init_state_dict| F["StateDict
(重み行列 = ノードID行列)"]
F -->|flatten_params| G["params
List[NodeId]"]
D -->|loss_on_document| H["loss_node
(テープ上のノード)"]
E --> H
F --> H
H -->|backward| I["勾配 grad[i]"]
I -->|adam_update| J["更新後の重み"]
J -->|gpt_forward| K["logits
List[NodeId]"]
K -->|"logits_with_temperature
sample_from_probs"| L["次のトークン ID"]
```
---
## B1 — データセット(b01_dataset.mojo)
### ソースコード
```{literalinclude} ../../../src/part3/microgpt_mojo/b01_dataset.mojo
:language: mojo
```
### 解説
`load_docs_from_text` は `input.txt` の中身(文字列)を受け取り、**空行を除いた行のリスト**を返します。
```mojo
var lines = text.splitlines() # 改行で分割
var line = String(String(lines[i]).strip()) # 前後の空白を除去
if len(line) > 0: # 空行をスキップ
docs.append(line)
```
`String(String(lines[i]).strip())` と 2 重に `String()` が現れているのは、型変換が 2 段階必要なためです。
| 式 | 型 | 説明 |
|----|-----|------|
| `lines[i]` | `StringSlice` | `splitlines()` の要素(元テキストへの参照) |
| `String(lines[i])` | `String` | 所有権のあるコピーに変換 |
| `String(lines[i]).strip()` | `StringSlice` | 前後の空白を除いた部分への参照(所有権なし) |
| `String(String(lines[i]).strip())` | `String` | 所有権のあるコピーに変換 ← これを `append` |
`strip()` の戻り値が `StringSlice`(参照)であることが核心です。`List[String]` に `append` するには所有権のある `String` が必要なので、外側の `String(...)` でもう一度コピーを作っています。
関数の末尾で `return docs^` と書いているのは、**`docs` をコピーせず転送(move)して返す**ためです。`^` は「所有権をここで手放す」という演算子で、不要なコピーを避けます。
`load_sample_names` はテスト用の小さなデータセット(3件の名前)を固定文字列から作る便利関数です。
---
## B2 — トークナイザー(b02_tokenizer.mojo)
### ソースコード
```{literalinclude} ../../../src/part3/microgpt_mojo/b02_tokenizer.mojo
:language: mojo
```
### 解説
#### Tokenizer 構造体
```mojo
struct Tokenizer:
var uchars: List[String] # 語彙: ソート済みユニーク文字の一覧
var bos: Int # BOS トークンの ID(= len(uchars))
```
`uchars` は `['a', 'b', ..., 'z']` のような文字リストで、インデックスがそのままトークン ID になります。**BOS(Beginning of Sequence)** は文の先頭・末尾を示す特殊トークンで、文字数より 1 大きい ID を持ちます。
#### トークン化の例
文書 `"ada"` をトークン化すると次のようになります。
```{mermaid}
flowchart LR
A["BOS
ID=26"] --> B["'a'
ID=0"]
B --> C["'d'
ID=3"]
C --> D["'a'
ID=0"]
D --> E["BOS
ID=26"]
style A fill:#f9c,stroke:#333
style E fill:#f9c,stroke:#333
```
`encode_doc` は `[BOS, id('a'), id('d'), id('a'), BOS]` というリストを作ります。学習時は位置 `t` のトークンから位置 `t+1` のトークンを予測するタスクになります。
#### 文字の取り出し
```mojo
def _char_at(doc: String, j: Int) -> String:
return chr(Int(doc.as_bytes()[j]))
```
Python では `doc[j:j+1]` でスライスできましたが、Mojo 0.26 では `String` のスライスが直接 `String` を返さないため、`as_bytes()` でバイト列を取り出し `chr()` で文字に変換しています(ASCII 文字専用の教材向けの実装です)。
#### バブルソート
Python の `sorted(set(...))` に相当する処理は `from std.builtin.sort import sort` でインポートした `sort()` で行います。`sort(chars)` と呼ぶだけで `List[String]` をインプレースにソートできます。
---
## B3 — 自動微分テープ(b03_value.mojo)
### ソースコード
```{literalinclude} ../../../src/part3/microgpt_mojo/b03_value.mojo
:language: mojo
```
### Tape の仕組み
これが**最も重要なモジュール**です。GPT の学習には「どのパラメータをどの方向に動かすか」を知るための**勾配計算**が必要です。`Tape` はその計算グラフを管理します。
```{note}
**「テープ」という名前の由来**
「テープ」は自動微分の歴史的な用語です。1964 年に R. E. Wengert が提案した「演算を順番に書き留めたリスト(Wengert list)」が起源で、テープレコーダーが音声を順に記録するように、**順伝播中のすべての演算を記録していく**ことからこう呼ばれます。この方式を「テープベース自動微分(tape-based AD)」といいます。
TensorFlow には同じ概念の `tf.GradientTape` という API があり、PyTorch 内部でも "gradient tape" という用語が使われています。
Python 版の `Value` が「微分可能な値 1 個」を表す名前なのに対し、Mojo 版の `Tape` は「演算の記録装置全体」を表す名前です。Mojo の所有権制約で自己参照構造体が作れないため、全ノードを 1 つの構造体に集約する設計になり、その結果としてテープという概念と自然に一致しました。
```
演算(加算・乗算など)を呼ぶたびに、`Tape` は新しいノードを 4 つの配列に**追記**します。
| 配列 | 内容 |
|------|------|
| `data[i]` | ノード i の計算値 |
| `grad[i]` | ノード i の勾配(逆伝播後) |
| `children[i]` | 子ノードの ID 列 |
| `local_grads[i]` | 各子ノードへの局所勾配 |
#### 具体例: `c = a + b`
```mojo
var a = t.leaf(2.0) # ノード 0: data=2.0
var b = t.leaf(3.0) # ノード 1: data=3.0
var c = t.add(a, b) # ノード 2: data=5.0, children=[0,1], local_grads=[1.0,1.0]
```
テープの内部状態はこうなります。
| index | data | grad | children | local_grads |
|-------|------|------|----------|-------------|
| 0 | 2.0 | 0.0 | `[]` | `[]` |
| 1 | 3.0 | 0.0 | `[]` | `[]` |
| 2 | 5.0 | 0.0 | `[0, 1]` | `[1.0, 1.0]` |
加算の局所勾配はどちらも 1.0 です(`∂(a+b)/∂a = 1`、`∂(a+b)/∂b = 1`)。
乗算 `c = a * b` の場合は `local_grads = [b.data, a.data]`(`∂(a*b)/∂a = b`、`∂(a*b)/∂b = a`)になります。
#### 逆伝播アルゴリズム
`backward(tape, root)` はルートノードから勾配を伝播します。
```{mermaid}
flowchart TB
subgraph "forward(順伝播)"
n0["node0
leaf a=2.0"] --> n2["node2
add c=5.0"]
n1["node1
leaf b=3.0"] --> n2
end
subgraph "backward(逆伝播)"
n2b["node2
grad=1.0
(ルートなので1)"] -->|"grad×1.0=1.0"| n0b["node0
grad+=1.0"]
n2b -->|"grad×1.0=1.0"| n1b["node1
grad+=1.0"]
end
```
1. `clear_grads()` で全勾配をゼロにリセット
2. **トポロジカルソート**で「子より親が後」の順に並べる
3. ルートの勾配を 1.0 にセット(`∂loss/∂loss = 1`)
4. 逆順に辿りながら `child.grad += local_grad × node.grad` を累積
連鎖律(chain rule)の実装そのものです。
#### sum_nodes で sum() を使わない理由
`sum_nodes` は `xs: List[Int]` を受け取りますが、この `Int` は**数値ではなくノード ID**です。
```{note}
`xs` の中身はたとえば `[42, 57, 63]` のような整数ですが、これは「テープの 42 番目・57 番目・63 番目のノード」を指す**インデックス**です。
`sum(xs)` を使うと `42 + 57 + 63 = 162` という**インデックスの和**が計算されてしまい、まったく意味が違います。
`self.add(a, b)` を繰り返す理由は、単に数値を足すのではなく、**テープに加算ノードを追記する**ためです。
xs = [42, 57, 63] (ノード ID)
step1: s = self.add(42, 57) → テープにノード 100 を追記(children=[42,57])
step2: s = self.add(100, 63) → テープにノード 101 を追記(children=[100,63])
return 101 (総和を表すノードの ID)
このノードの連鎖があることで、`backward()` 時に勾配が各子ノードへ正しく伝播できます。`sum()` で単純に足すと計算グラフが作られず、逆伝播できません。
```
---
## B4 — ハイパーパラメータと重み(b04_state_dict.mojo)
### ソースコード
```{literalinclude} ../../../src/part3/microgpt_mojo/b04_state_dict.mojo
:language: mojo
```
### 解説
#### HyperParams
```mojo
var hp = HyperParams(1, 16, 16, 4)
# n_layer=1, n_embd=16, block_size=16, n_head=4
```
GPT の形状を決めるパラメータです。`head_dim()` メソッドは `n_embd ÷ n_head`(各アテンションヘッドの次元数)を返します。
#### StateDict — 重みの束
重み行列の各要素は**テープ上のノード ID**(`Int`)として保存されています。つまり `wte[0][3]` は「単語 0 の埋め込みベクトルの 3 番目の要素に対応するノードの ID」です。
```{mermaid}
graph TD
subgraph "StateDict(ノードID の行列)"
wte["wte
vocab_size × n_embd"]
wpe["wpe
block_size × n_embd"]
lm["lm_head
vocab_size × n_embd"]
wq["attn_wq[layer]
n_embd × n_embd"]
wk["attn_wk[layer]"]
wv["attn_wv[layer]"]
wo["attn_wo[layer]"]
fc1["mlp_fc1[layer]
4*n_embd × n_embd"]
fc2["mlp_fc2[layer]
n_embd × 4*n_embd"]
end
subgraph "Tape"
nodes["data[], grad[]
children[], local_grads[]"]
end
wte --> nodes
wpe --> nodes
wq --> nodes
```
#### matrix_rand — 重みの初期化
```mojo
row.append(t.leaf(randn_float64(0.0, std)))
```
`from std.random import randn_float64` でインポートした `randn_float64(mean, std)` を使い、Python 版の `random.gauss(0, std)` と同等の正規分布乱数で重みを初期化しています。
#### flatten_params
Adam 更新ループで全パラメータを一括処理するため、行列の入れ子をフラットな `List[NodeId]` に展開します。
---
## B5a — 演算ユーティリティ(b05_ops.mojo)
### ソースコード
```{literalinclude} ../../../src/part3/microgpt_mojo/b05_ops.mojo
:language: mojo
```
### 解説
すべての関数は `Tape` 上に演算ノードを追加しながら、**ノード ID のリスト**を入出力します。
#### linear(線形変換)
$$\text{out}[r] = \sum_{c=0}^{C-1} W[r][c] \cdot x[c]$$
```
out[r] = x[0]*w[r][0] + x[1]*w[r][1] + ... + x[C-1]*w[r][C-1]
```
行列 `w`(形状 $n_\text{out} \times n_\text{in}$)の r 行目と入力ベクトル `x`(長さ $C = n_\text{in}$)の内積です。行列とベクトルの積 $\mathbf{out} = W\mathbf{x}$ を1行ずつ計算しています。Python の `sum(wi*xi ...)` に相当します。
#### rmsnorm(RMS 正規化)
LayerNorm の簡略版です。各要素を「二乗平均の平方根」で割って正規化します。
$$\text{rms} = \sqrt{\frac{1}{n}\sum_{i=0}^{n-1} x[i]^2 + \varepsilon}, \qquad \text{out}[i] = \frac{x[i]}{\text{rms}}$$
```
rms = sqrt( (x[0]² + x[1]² + ... + x[n-1]²) / n + ε )
out[i] = x[i] / rms
```
`pow_const(node, -0.5)` で $(\text{rms}^2)^{-0.5} = 1/\text{rms}$ を計算し、これを全要素に掛けています。
#### softmax
logits を確率分布に変換します。数値安定化のため最大値 $m$ を引いてから exp を取ります(値が大きすぎて exp がオーバーフローするのを防ぐ)。
$$p[i] = \frac{\exp(\text{logits}[i] - m)}{\sum_j \exp(\text{logits}[j] - m)}, \qquad m = \max_j \text{logits}[j]$$
```
m = max(logits[0], logits[1], ..., logits[V-1])
e[i] = exp(logits[i] - m)
p[i] = e[i] / (e[0] + e[1] + ... + e[V-1])
```
```mojo
var m = t.node_data(logits[0]) # 現在の値(.data)を取り出し
for i in range(1, len(logits)):
var v = t.node_data(logits[i])
m = m if m > v else v # 最大値を求める
```
ここで `t.node_data(...)` を呼んでいるのは、**最大値は定数扱い**で微分不要なためです(ソフトマックスの安定化はグラフに乗せない)。
#### scaled_attention_logits
Query と Key の内積にスケール $1/\sqrt{d_k}$ を掛けます。
$$\text{logits}[t] = \frac{\mathbf{q} \cdot \mathbf{k}_t}{\sqrt{d_k}}$$
```
logits[t] = (q[0]*k_t[0] + q[1]*k_t[1] + ... + q[d-1]*k_t[d-1]) / sqrt(d_k)
```
スケールしないと内積が $d_k$ に比例して大きくなり、softmax 後の分布が一点に集中してしまいます。
---
## B5b — GPT 順伝播(b05_gpt.mojo)
### ソースコード
```{literalinclude} ../../../src/part3/microgpt_mojo/b05_gpt.mojo
:language: mojo
```
### 解説
`gpt_forward` が1ステップの GPT 計算です。**1トークン分の入力**を受け取り、**次トークンの確率を示す logits ベクトル**を返します。
#### 埋め込み層
```mojo
var tok = row_embedding(sd.wte, token_id) # トークン埋め込み
var pos = row_embedding(sd.wpe, pos_id) # 位置埋め込み
var x = ... # tok + pos の要素和
x = rmsnorm(t, x^) # 初期正規化
```
`row_embedding` は行列の 1 行(= ノード ID の列)をコピーして返します。`mat[idx]` を直接返すのではなく `mat[idx][i]` と**要素ごとに読む**のは、行リストのムーブによる行列破壊を防ぐためです(後述)。
#### Transformer 層
```{mermaid}
flowchart TD
X["入力 x
(n_embd 次元)"] --> RN1["rmsnorm"]
RN1 --> Q["Q = linear(attn_wq)"]
RN1 --> K["K = linear(attn_wk)"]
RN1 --> V["V = linear(attn_wv)"]
K --> CACHE["KV キャッシュ
(過去のK/V)"]
V --> CACHE
Q --> ATT["scaled_attention_logits
softmax → 重み付き和"]
CACHE --> ATT
ATT --> WO["linear(attn_wo)"]
WO --> ADD1["残差接続 (+x)"]
X --> ADD1
ADD1 --> RN2["rmsnorm"]
RN2 --> FC1["linear(mlp_fc1)"]
FC1 --> RELU["relu"]
RELU --> FC2["linear(mlp_fc2)"]
FC2 --> ADD2["残差接続 (+x)"]
ADD1 --> ADD2
ADD2 --> OUT["出力 x
(次層へ)"]
```
#### KV キャッシュ
推論時も学習時も、過去のトークンで計算した Key/Value をリストに蓄積します。位置 `t` での Attention は位置 0〜t の K/V すべてに注目できます(因果的 Attention)。
```mojo
append_kv_row(keys[li], k^) # この位置の Key をキャッシュに追加
append_kv_row(vals[li], v^) # この位置の Value をキャッシュに追加
for tix in range(len(keys[li])):
k_h.append(head_slice_from_cache(keys[li], tix, hs, hd))
...
```
`head_slice_from_cache` が `cache[tix]` を丸ごと渡さず `cache[tix][j + off]` と要素アクセスしているのは、行リストのムーブを避けるためです。
#### マルチヘッドアテンション
```mojo
for h in range(hp.n_head):
var hs = h * hd # このヘッドの開始インデックス
var q_h = slice_vec(q, hs, hd) # Q をヘッドごとに切り出す
# K/V も同様に head_slice_from_cache で切り出す
var hpiece = attn_head(t, q_h, k_h, v_h, hd)
heads.append(hpiece^)
var merged = concat_heads(heads) # ヘッドを連結して n_embd 次元に戻す
```
`n_head` 個のヘッドを独立に計算して最後に連結します。
---
## B6 — 学習ステップ(b06_train.mojo)
### ソースコード
```{literalinclude} ../../../src/part3/microgpt_mojo/b06_train.mojo
:language: mojo
```
### 解説
#### loss_on_document — 1文書の損失
1文書分のトークン列に対して損失を計算します。
```mojo
for pos_id in range(n):
var tid = tokens[pos_id] # 現在のトークン
var target = tokens[pos_id + 1] # 次のトークン(正解ラベル)
var logits = gpt_forward(...)
var probs = softmax(t, logits)
var nll = t.neg(t.log_(probs[target])) # 負の対数尤度
losses.append(nll)
return t.scale(t.sum_nodes(losses), 1.0 / Float64(n)) # 平均損失
```
正解トークン `target` の確率 `probs[target]` を取り出し、その対数の符号反転(NLL)を損失とします。予測が正確なほど確率は 1 に近づき、NLL は 0 に近づきます。
#### adam_update — Adam 最適化
```mojo
var lr_t = lr * (1.0 - Float64(step_idx) / Float64(total_steps)) # 線形学習率減衰
m[i] = beta1 * m[i] + (1 - beta1) * g # 第1モーメント(勾配の移動平均)
v[i] = beta2 * v[i] + (1 - beta2) * g * g # 第2モーメント(勾配二乗の移動平均)
var m_hat = m[i] / (1 - b1_corr) # バイアス補正
var v_hat = v[i] / (1 - b2_corr)
var newv = tape.node_data(pid) - lr_t * m_hat / (sqrt(v_hat) + eps)
tape.set_data(pid, newv) # 重みの値を更新
tape.set_grad(pid, 0.0) # 勾配をリセット
```
Adam はモメンタム(第1モーメント)と RMSProp(第2モーメント)を組み合わせた最適化手法です。初期ステップのバイアスを $1 - beta^t$ で補正しています。
---
## B7 — 推論ヘルパ(b07_infer.mojo)
### ソースコード
```{literalinclude} ../../../src/part3/microgpt_mojo/b07_infer.mojo
:language: mojo
```
### 解説
#### logits_with_temperature — 温度スケーリング
```mojo
var inv_t = 1.0 / temperature
for i in range(len(logits)):
scaled.append(t.scale(logits[i], inv_t))
return softmax(t, scaled)
```
`temperature` が低い(例 0.5)ほど logits の差が拡大し、**確率分布が尖って**確定的な出力になります。`temperature=1.0` では logits をそのまま使います。
:::: {container} mermaid-flow-half
```{mermaid}
graph LR
subgraph "t=0.5(確定的寄り)"
L1["logits: [2, 1, 0]"] --> S1["probs: [0.84, 0.11, 0.04]"]
end
subgraph "t=1.0(フラット)"
L2["logits: [2, 1, 0]"] --> S2["probs: [0.67, 0.24, 0.09]"]
end
```
::::
#### sample_from_probs — 累積分布サンプリング
```mojo
var r = random_float64() # [0, 1) の一様乱数
var cumsum = 0.0
for i in range(len(probs)):
cumsum += tape.node_data(probs[i])
if r < cumsum: # 累積確率が乱数を超えた時点で選択
return i
```
確率に比例した頻度でトークンを選びます。Python の `random.choices(range(n), weights=probs)` と同等です。
```{mermaid}
graph LR
subgraph "probs = [0.6, 0.3, 0.1]"
A["0.0"] -->|"0.6"| B["0.6"]
B -->|"0.3"| C["0.9"]
C -->|"0.1"| D["1.0"]
end
R["乱数 r = 0.75"] -->|"0.6 ≤ 0.75 < 0.9
→ index 1 を選択"| B
```
---
## main.mojo — 全体を組み合わせる
### ソースコード
```{literalinclude} ../../../src/part3/microgpt_mojo/main.mojo
:language: mojo
```
### 解説
#### データ読み込みとシャッフル
```mojo
var f = open("input.txt", "r")
var text = f.read()
f.close()
var docs = load_docs_from_text(text)
# Fisher-Yates シャッフル
for i in range(len(docs) - 1, 0, -1):
var j = Int(random_float64() * Float64(i + 1))
var tmp = docs[i]
docs[i] = docs[j]
docs[j] = tmp
```
Fisher-Yates シャッフルは末尾から先頭に向かって走査し、各要素をランダムな位置と入れ替えます。学習順序をランダム化することで、特定の文書の偏りを防ぎます。
#### 学習ループ
```mojo
for step in range(num_steps):
var doc = docs[step % len(docs)] # docs を順番に使い回す
var keys = List[List[List[Int]]]()
var vals = List[List[List[Int]]]()
init_kv_cache(hp.n_layer, keys, vals) # KV キャッシュをリセット
var tokens = encode_doc(tok, doc)
var loss_node = loss_on_document(t, sd, hp, tokens, keys, vals)
backward(t, loss_node) # 勾配を計算
adam_update(t, params, m_buf, v_buf, step, num_steps) # 重みを更新
```
**各ステップで KV キャッシュを初期化する**のが重要です。KV キャッシュは1文書内の過去トークンを蓄積するもので、ステップをまたいで持ち越してはいけません。
#### 推論ループ
```mojo
var token_id = tok.bos # BOS から生成スタート
for pos_id in range(hp.block_size):
var logits = gpt_forward(...)
var probs = logits_with_temperature(t, logits^, 0.5)
token_id = sample_from_probs(probs, t)
if should_stop(token_id, tok.bos): # BOS が出たら終了
break
result += tok.uchars[token_id]
```
BOS トークンを起点に1文字ずつ生成し、再び BOS が出たら(= 文の末尾を予測したら)停止します。`block_size` は生成の上限長になります。
---
## Python 版との違いと実装上の工夫
### なぜ Value 型ではなく Tape 型なのか
Python 版の `Value` はクラスのインスタンスで、参照によって計算グラフを形成します。
```python
# Python: オブジェクト参照でグラフを作る
class Value:
def __init__(self, data, children=(), local_grads=()):
self._children = children # 別の Value オブジェクトへの参照
```
Mojo でこれを直接再現しようとすると問題が起きます。
```
struct Value:
var _children: List[Value] # ❌ Value が Value を含む → 無限サイズ
```
Mojo の struct は**固定サイズ**でなければならないため、自己参照型は定義できません。また、所有権ルールにより `List[Value]` 内の要素を `mut` 参照で変更することも困難です。
解決策が **テープ(Tape)パターン**です。
```mojo
struct Tape:
var data: List[Float64] # 全ノードの値
var grad: List[Float64] # 全ノードの勾配
var children: List[List[Int]] # 各ノードの子インデックス
var local_grads: List[List[Float64]]
```
ノード同士は**インデックス(Int = NodeId)で参照**します。`Tape` は複数の配列の束(SoA: Structure of Arrays)として表現されているので、サイズが固定でなくとも問題ありません。Python で言えば「グラフをオブジェクト参照で持つ」代わりに「グラフをインデックステーブルで持つ」イメージです。
### `inout`/`owned` の廃止への対応
Mojo 0.26 では `inout`・`owned` キーワードが完全に削除されました。
| 旧構文 | 新構文 | 用途 |
|--------|--------|------|
| `fn __init__(inout self, ...)` | `def __init__(out self, ...)` | コンストラクタ |
| `fn method(inout self, ...)` | `def method(mut self, ...)` | 変更ありメソッド |
| `fn func(inout x: T, ...)` | `def func(mut x: T, ...)` | 変更あり引数 |
| `fn func(owned x: T)` | `def func(x: T)` + 呼び側で `x^` | 所有権の転送 |
`owned` の代替として、呼び出し側で `x^` を使いムーブ渡しします。
```mojo
return Tokenizer(chars^, bos) # chars の所有権をコンストラクタに渡す
```
### List[T] の所有権
`List[T]` は**コピー不可能**(`ImplicitlyCopyable` でない)です。別の変数に代入すると所有権が移動します。
```mojo
var row = mat[0] # ❌ mat から行をコピーしようとしてエラー
var row = mat[0][j] # ✅ 要素(Int)はコピー可能
```
ネストしたリスト操作では**要素インデックスで個別アクセス**するのが基本方針です。`children` のような `List[List[Int]]` を取り出すときは `.copy()` が必要です。
```mojo
var ch = tape.children[v].copy() # .copy() で明示的にコピー
```
### 転送演算子 `^` と変数の再代入
```mojo
x = rmsnorm(t, x^) # x を rmsnorm にムーブし、結果を x に代入
```
`x^` は「x の所有権をここで放棄する」という宣言です。これ以降 x は未初期化になりますが、右辺の戻り値が即座に x に代入されるため問題ありません。
ただし**同じスコープで x を複数の関数に渡す場合**はムーブできないため、`rmsnorm` の結果を新しい変数名で受けています。
```mojo
var x_ln = rmsnorm(t, x^) # x はムーブ後に無効化
# x_res(別途保存した残差)を使う
```
### 初期化方法の違い
| 項目 | Python 版 | Mojo 版 |
|------|----------|---------|
| 重みの初期化 | `random.gauss(0, std)` | `randn_float64(0.0, std)` |
| シード | `random.seed(42)` で固定 | `seed(42)` で固定 |
| docs シャッフル | `random.shuffle(docs)` | Fisher-Yates + `random_float64` |
| input.txt の取得 | 自動ダウンロード | 手動で配置(要事前準備) |
`seed(42)` を `main.mojo` の冒頭で呼ぶことで、重みの初期化・シャッフル・推論サンプリングすべてに同じシードが適用され、Python 版と同様に再現性が確保されます。
---
## まとめ
- B1〜B7 は `src/part3/microgpt_mojo/b0*.mojo` に対応し、`b*_test.mojo` でブロック単位に検証できます
- **Tape パターン**は Mojo の所有権制約のもとで計算グラフを安全に扱うための鍵です
- Python の参照ベースの設計を、Mojo ではインデックスベースの SoA に置き換えることで、型安全性を保ちながら同等の自動微分を実現しています
- `inout`/`owned` の廃止、`List[T]` の所有権制約、`^` 転送演算子など、Mojo 固有のルールに沿った書き方が全体を通して現れています
## 次に読む章
{numref}`MAX を導入する意味`({ref}`MAX を導入する意味`)へ進みます。