(長文です)
突然ですが、現在、休職中です。
何もしないでいるとかえって辛くなってしまうので、この機会に以前から作ってみたかった自分だけのSmalltalk処理系を作ってみることとしました。
ちゃんと日々の計画や週ごとの目標を立ててのことです。
最初に全体の感想を!
日頃、AIエージェントでツールなどを作っていたこともありました。またSmalltalk処理系にはブルーブックという仕様書もあるため、「比較的簡単かも」と思っていました。
甘かったです。
難しい……。アーキテクチャもさることながら、各所の細かい設計にいたるまでその度、かなり考えないといけませんでした。
「SmalltalkはミニOSだ」ということを身をもって思い知ることとなりました。
非エンジニアである私にとっては、何度も書籍みたり、ネット検索してみたりと勉強になることばかりだったのは大きな収穫だったと思います。
Apple Silicon の Mac で動く Smalltalk 処理系「Ao」は、ランタイムが C++20、GUI は Swift 6 の AppKit です。コードはほぼコーディングエージェントに書かせてます。
使ったのは Grok Build、Claude Code、Codex(OpenAI のコーディングエージェント)で、とっかかりには Grok4.7が出たばかりだったこともあり、Grok Build を使っています。
最初のコミットから v1 の受け入れ(フェーズ P0〜P9 の完了)までが約 50 時間で、その時点のテストはすべて緑でした。ところが、直後に全体をレビューさせると Critical が 11 件、High が 29 件出てきました。
テストが緑のまま Critical を見逃したのは、テストが「作ったものが作ったとおりに動くか」しか確かめていなかったからだと考えています。「GC がいつ走っても壊れないか」「Smalltalk としてどう動くべきか」という問いは、テストに含まれていませんでした。それを外から持ち込んだのは、GC を強制的に走らせるストレスモードと、実装とは別に走らせたレビューです。Python を正解にした突き合わせ(後編)は、直したあとに答えが崩れていないかを確かめる土台です。ストレスモードとレビューは、最初のフェーズから入れておくべきでした。
3 日かけて直すなかで手こずったのは、次のところです。
- オブジェクトを動かす GC と、古い番地を持ち続ける C++ のローカル変数
- ブロックの中の代入と
^を、コンパイラとネイティブ関数が正しく扱えていなかったこと - Smalltalk のプロセス(軽量スレッド)を、C++ のスタックごと切り替えるファイバ
- OS やツールについての思い込み
前編では、Ao の設計とレビューの結果に続けて、最初の 2 つ(GC とブロック)を書きます。残りの 2 つと、効いた進め方は後編で扱います。
Ao は Kernel を C++ で書き、画面を AppKit に任せる処理系
Squeak や Pharo は、バイトコード VM と巨大なイメージ(ヒープを丸ごと保存したファイル)の組み合わせです。Smalltalk-80 の仕様書(通称 Blue Book)の VM を忠実に再現し、ビットマップを自前で描く BitBlt の画面までエミュレートする処理系もあります。
Ao もイメージとバイトコードは持ちますが、どちらとも違う形をねらいました。ホストは macOS の AppKit にし、実行の速さが効く Kernel は、C++ で書いたネイティブ関数で走らせます。
標準的なやりかたではない方法をとったのは、もっと早い処理系が欲しいな、と思ったからです。
- オブジェクトの参照は 64 ビットのタグ付きポインタで、オブジェクトテーブルを持たない。
- Kernel(Object や SmallInteger など、言語の核になるクラス群)のメソッドは、すべて C++ のネイティブ関数にする。SmallInteger の算術、
==、配列アクセス、ブロックの評価などが該当する。Smalltalk で書いた Kernel のソースは、分類とコメントのために置くだけで、実行には使わない。「Kernel のメソッド辞書の値はすべてネイティブ」をテストで強制している。 - ユーザーが書いたメソッドは、独自のバイトコードにコンパイルしてインタプリタで動かす。
- クラスとメタクラスの関係は Blue Book の規則に従う。
- GUI は BitBlt の画面を使わず、式を試す Workspace やクラスを編集する Browser を、独立した NSWindow にする。モデルの状態だけを Smalltalk が持ち、ビューは AppKit が持つ。
- イメージファイルには関数ポインタを書かない。ネイティブ関数は
ao_SmallInteger_addのようなシンボル名で記録し、ロード時に結び直す。
規模は次のとおりです(行数は空行とコメントを含みます)。
| 項目 | 値 |
|---|---|
| 期間 | 2026-09-21〜09-26 の 6 日間、385 コミット |
| C++ のランタイム | 約 2 万 1 千行 |
| コンパイラ | 約 5,700 行 |
| C++ のテスト | 約 2 万 1 千行、ctest で 850 項目 |
| Swift のアプリ | 約 2,100 行、XCTest で 56 件 |
50 時間で v1 に届き、直後のレビューで Critical が 11 件見つかった
P0 から P9 までは、09-21 の午前から 09-23 の昼までかかりました。
コミットは約 100 本です。
v1 の受け入れを記録したあと、実装とは別に Claude に全体をレビューさせました。指摘は重複を除いて Critical 11 件、High 29 件、Medium 28 件、Low 39 件です。
レビューはほぼすべての指摘について、ビルド済みのライブラリにリンクした小さなプログラムで実際に再現しています。
再現された失敗の一部を挙げます。コメントにある Accept は、メソッドを保存してコンパイルする操作です。
foo
^self foo
"Accept して呼ぶと、アプリごと SIGSEGV で落ちる"
| a |
a := Array new: 1.
a at: 1 put: a.
a = a "segfault"
| y |
y := 0.
3 > 1 ifTrue: [y := 1].
y "0 が返る(正しくは 1)"
最後の例と同じ形は、クラスライブラリとして取り込んでいた Cuis Smalltalk のコードに 57 か所ありました。
修正は 12 のバッチ(B0〜B11)に分け、1 バッチ 1 PR で進めました。上の 3 つとほかの主な失敗は、それぞれ次のバッチで直しています。
| 失敗 | バッチ | 直し方 |
|---|---|---|
foo ^self foo で落ちる |
B2 | スタックの残りが少なくなったら、stack overflow として評価を中断する |
a = a で落ちる |
B3 | Array の = は、まず == で同じオブジェクトかを確かめる |
y が 0 のまま |
B2 | 捕捉されて代入される一時変数を、ヒープ上の配列に置く(後述の「ブロックの意味論」の節) |
[n := n + 1] fork. Processor yield. n が 0 になる。fork したブロックが走らない |
B10 | プロセスの切り替えを、AArch64 のファイバで実装した(後編) |
OrderedCollection に 7 万回 add: すると、要素数が 65536 で止まり、4464 件が黙って消える |
B1 | 大きなオブジェクトを old 領域に直接置き、old 領域が伸びるようにした |
| Dictionary に 4000 件入れるのに 9.3 秒かかる | B9 | 要素の hash を保存して引くハッシュ表に作り直した |
| 既存クラスの定義を Browser で Accept し直すと、メソッドがすべて消え、しかも成功と報告される | B5 | 形が同じならクラスをそのまま使い、形が変わるならメソッドを再コンパイルして移す |
Workspace で 'hello' と打つと、スマート引用符に置き換わってコンパイルエラーになる |
B5 | ソース欄の自動置換を切った |
レビューが横断的に挙げた「失敗が黙って成功になる」問題も、B3 で直しています。以前は、ネイティブ関数が失敗すると空の値がスタックに積まれたまま評価が続き、(1 + nil) class が Message を返すほどでした。今は失敗した時点で評価を中断し、doesNotUnderstand: #sel などの理由を返します。
GC 安全性:ネイティブ関数がローカル変数に持ったレシーバが、GC のあとで古い番地を指す
一番手こずったのが GC です。
Ao の GC は世代別で、新しいオブジェクトを置く nursery と、長く生き残ったオブジェクトを置く old 領域に分かれています。
どちらの GC もオブジェクトを移動させるので、GC が走ると番地が変わります。GC がたどれる場所(ルート)に載っていない参照は、古い番地を指したまま残ります。
Kernel をすべて C++ で書いたので、この危険はネイティブ関数の数だけあります。
当初のネイティブ関数は、レシーバ(メッセージを受け取るオブジェクト。C++ の this に当たる)を値で受け取っていました。
この値はどのルートにも載っていません。それなのに、GC を起こしうる送信のあとでもこの値を使い続ける書き方が、C++ のあちこちにありました。
// 壊れる書き方(模式)
Oop rcv = receiver; // ルートに載っていない値を持つ
Oop r = send(rcv, selector, args); // この中で GC が走ると、rcv は古い番地のまま
writeSlot(rcv, 0, r); // 解放済みの領域に書き込む
レビューの Critical のうち 3 件は、ルートに載らない値を GC をまたいで使う書き方から来ています。
たとえば subclass: は、ルートに載せていないスーパークラスを、新しいクラスに書き込んでいました。その結果、A のサブクラス B を作る途中で GC が走ると、B のメタクラスの親を返す B class superclass が、A のメタクラス A class ではなく A そのものを返しました。
まず問題を見えるようにしました(B0)。
環境変数 AO_GC_STRESS=n を付けると、割り当てなどの n 回に 1 回 nursery の GC を走らせ、そのうち 4 回に 1 回は old 領域の GC も走らせます。解放した領域は 0xA5 で埋めるので、古い番地を読めばすぐに分かります。
効果はてきめんでした。
既存の GoogleTest 262 件を 1 件ずつこのモードで回すと、通ったのは 226 件です。28 件は埋めた値を読んで abort し、8 件はオブジェクトの同一性が崩れていました。
B1 では、NativeMethod::invoke がレシーバと引数をルート済みのスロットに置き、ネイティブ関数はそれを const Oop& で受け取るようにしました。
エージェントが同じ書き方をしないよう、エージェント向けの作業手順書 CLAUDE.md には「receiver をローカル変数にコピーして GC をまたがない。GC しうる呼び出しのあとは、ルート済みのスロットから読み直す」という規則も足しています。
あわせて、16 ファイルに散らばっていたルート管理の RAII クラスのコピーを、ao/HandleScope の 1 つにまとめました。既存のテストでも、約 30 件で GC をまたぐローカル変数をルートに載せ直しています。
想定外だったのは性能です。
計画どおりルート用の配列で実装すると、ベンチマークが 86% 遅くなりました。性能の悪化は 5% までと決めていたので、この案は捨てました。
ネイティブ呼び出しは最も頻繁に通る道なので、ここではメモリの割り当てを一切しない形にしました。あらかじめ確保したブロックの中でポインタを進めて積み、戻るときに降ろすフレームスタック(LIFO)としてみました。
GC がこのスタックをルートとしてたどるようにして、悪化を 3.1% に抑えています。86% も 3.1% も Debug ビルドでの測定で、5% の基準もこの数字で判断しました。
GC のテストが本当に効いているかも確かめています。B8 では、「メッセージ送信の前にレシーバをローカル変数にコピーする」誤りを、わざと 4 か所に埋め込みました(変異テスト)。AO_GC_STRESS=1 のテストは、4 つとも検出しました。
old 領域が 4 MB で頭打ちになり、full GC を 88 回回していた
old 領域は当初 4 MB 固定で、生きているデータが約 2.5 MB を超えると、何も回収できない full GC を回し続けていました。レビューの例では、1 回の評価で full GC が 88 回走り、98 秒かかっています。今は mmap(PROT_NONE) で 4 GiB − 1 MiB を予約し、必要な分だけ mprotect でコミットします。64 KiB 以上のオブジェクトは、最初から old に置きます。
ただ、old に直接置くオブジェクトは nursery の GC を通りません。そのため、大きなゴミだけを作るループでは full GC が一度も起きず、old が数百 MB まで伸び続けました。そこで、大きなオブジェクトを old に置く前にも、GC を起こす条件を足しました。そのループの最大メモリ使用量は、485 MB から 9.9 MB に下がりました。
ブロックの意味論:ifTrue: の中の代入と ^ が正しく動かなかった
y が 0 のままだったのは、ブロックの中の代入が外側の一時変数(temp)のコピーにしか書かれず、しかも ifTrue: がインライン展開されていなかったからです。
B2 では、ブロックに捕捉され、どこかで代入される一時変数を、ヒープ上の配列(temp ベクタ)に置くようにしました。ifTrue: を含め、どの制御構造を展開するかも、コンパイラの 1 か所で決めます。
ここでも性能で足をすくわれました。この 1 か所で to:do: も展開するようにすると、展開前のネイティブ実装より遅くなったのです。
展開したループは、1 反復ごとに <= と + をメッセージとして送ります(呼ぶメソッドを実行時に探します)。さらに <= 自体も、Magnitude クラスの実装の中で < を送っていました。そこで、SmallInteger 同士の算術と比較には、送信を省く近道を足しました。
1000 万回のループ(Release ビルド)は、3,099 ms から展開直後に 5,975 ms へ悪化し、近道を入れて 2,606 ms になりました。
その近道には穴がありました。
Kernel のネイティブ関数を上書きするメソッドは、Accept で拒む決まりです。
ところが Kernel のクラスかどうかを名前で判定していたので、Smalltalk at: #IntegerAlias put: SmallInteger で別名を付けると、その別名を通して <= を定義し直せました。近道はこの定義を無視します。
そのため、常に false を返す <= を定義すると、3 <= 4 は true、3 perform: #<= with: 4 は false になりました。
この穴を見つけたのは、Codex のレビューです。今は、名前が指すクラスそのものが Kernel のクラスかどうかで判定しています。Codex のレビューについては後編で書きます。
c do: [:e | ^e] の答えも、コレクションの種類で変わっていました。ブロックの中の ^ は、ブロックを書いたメソッドごと抜けます(非局所リターン)。c が Array なら最初の要素を返すのに、OrderedCollection、Set、Dictionary、Interval では最後の要素を返していました。反復するネイティブ関数が、ブロックから ^ で抜けたことを確かめていなかったからです。ネイティブ関数からブロックを呼ぶ処理を 1 つの関数にまとめて直しました。
ストレスモードを入れたのは、v1 の後のことです。
最初のフェーズから入れていれば、GoogleTest の 36 件の失敗は v1 より前に見つかってはずです。後編では、ファイバを AArch64 のアセンブリで書いた話と、思い込んでいた前提が崩れた話、そして効いた進め方を書きます。