Apple Silicon の Mac で動く Smalltalk 処理系「Ao」を、コーディングエージェントに作らせた記録の中編前半です。
前の記事は以下の通り。
協調スケジューラ:ファイバの切り替えを AArch64 のアセンブリで書いた
ここでのプロセスは、Smalltalk の中で動く軽量スレッドです。[n := n + 1] fork. Processor yield. n が 0 になり(前編、B10)、Semaphore new wait のあとは Processor activeProcess が nil でした。
fork したブロックが一度も走っていなかったのは、プロセスの切り替えが、そもそも実装されていなかったからです。
Ao のネイティブ関数は Smalltalk のメソッドを C++ の再帰で呼ぶので、プロセスを途中で止めるには、C++ のスタックごと退避しなければなりません。ucontext、C++20 のコルーチン、pthread、Boost.Context は、次の理由で使うことができませんでした。
- ucontext:macOS では非推奨で、切り替えのたびに
sigprocmaskも呼ぶ。 - C++20 のコルーチン:スタックを持たないので、再帰したネイティブ関数の奥では止められない。
- pthread:プロセスを別スレッドで走らせると、メインスレッドで呼ぶべき GUI のフックを、そこから呼べない。
- Boost.Context:依存ライブラリを増やさない方針に反する。
結局、切り替えは自前のアセンブリ(FiberSwitch_arm64.S)で書きました。
アセンブリはZ80以来のこと。ここは素直にAIエージェントにお任せしました。
退避するのは、関数呼び出しをまたいで保存すべきレジスタだけです。
x19〜x28、fp、lr、sp、d8〜d15 の 168 バイトで、プラットフォーム用の x18 には触りません。スタックまわりは次のとおりです。
- 1 本 8 MiB。触れたページにだけ物理メモリが割り当てられる。
- 下端に、書き込みを禁じたガードページを置く。
- 使い終わったスタックは 4 本までプールに残し、プールに入れるときに
madvise(MADV_FREE)でページを OS に返す。 - 同時に生きるファイバは 256 本まで。
AddressSanitizer(ASan)との相性にも手間がかかりました。ASan にファイバの切り替えを知らせる __sanitizer_start_switch_fiber と __sanitizer_finish_switch_fiber を呼んでいます。ASan は use-after-return を検出するために、関数のフレームを偽のスタック(fake stack)に置くことがあります。
ファイバから最後に抜ける切り替えでは、__sanitizer_start_switch_fiber の呼び出しがこの偽のスタックを捨てます。切り替える関数のフレームがそこにあると、捨てたあとも使ってしまいます。そこで、この切り替えだけは別の関数にし、no_sanitize("address") を付けました。
計画の穴には、着手してから気づきました。
まず、ネイティブ呼び出しのたびに積み、戻るときに降ろす GC ルート用のフレームスタック(LIFO)です。
ファイバをまたぐと、積み順が入り乱れます。このフレームスタックはプロセスごとに持たせ、切り替えのたびに差し替えることにしました。
スタックの深さのガードも、スレッドのスタックの範囲しか知りませんでした。ファイバの上では、即座に stack overflow と判定されていました。ファイバごとに、そのスタックの範囲を持たせました。
終了時やイメージの読み替えで残ったプロセスの片付けにも穴がありました。
スタックを解放するだけでは、GC のルートに、解放したスタックを指すエントリが残ります。そこで、一度そのファイバに切り替えて C++ のフレームを正常に巻き戻す、専用の terminate を用意しました。
これでルートも元に戻ります。ヒープを捨てる直前に使うので、Smalltalk 側の後始末のブロックは走らせません。
切り替えが本当に動くようになると、今度はデッドロックが出ました。
以前の SharedQueue>>nextPut: は書き込み用のセマフォを wait していたので、wait で本当に待たされるようになると、2 回続けて nextPut: した時点で止まります。
実装が正しくなったことで、別の誤りが表に出たわけです。Blue Book にならい、nextPut: は待たないことにしました。
思い込んでいた前提が、何度も崩れた
ここに挙げるのは、どれも計画の段階で確かめずに置いた前提です。
macOS の open は環境変数をアプリに渡していた
配布用の .app を open で起動して確かめるテストでは、「open はシェルの環境変数を渡さない」ことを前提にしていました。Codex に指摘されて man open を読み直すと、起動したアプリは環境変数を継承すると書いてありました。
完全な思い込みでした。
開発環境で AO_VENDOR_DIR(取り込んだ Cuis のコードの置き場所を指す環境変数)を設定していると、アプリは同梱の Cuis ではなくその場所を読みにいきます。そのため、正しいバンドルでもテストがタイムアウトしていました。今は open --env AO_VENDOR_DIR= で変数を空にして起動しています。
ベンチマークは Debug ビルドの数字だった
フェーズ P6 で、1 to: 10000000 do: [:i | i + 1] を本体に持つメソッドを測ると 61,449 ms でした(Apple M1 Max)。あとで、これは Debug ビルドの値だったと分かりました。
CMake の CMAKE_BUILD_TYPE が空で、最適化なしの構成になっていたのです。Release で測り直すと、同じメソッドは 3,099 ms で、約 20 倍の差がありました。前編で書いた 1000 万回ループの起点も、この 3,099 ms です。
Cuis のコアはパッケージに無く、変更ログにあった
Cuis Smalltalk の Bag、Exception、Date などは、パッケージのファイルから取り込むつもりでした。
ところが、どれもパッケージに無かったのです。正本は変更を時系列で積んだ .changes のログだったので、クラスごと、メソッドごとに最後の定義を採る切り出し器を C++ で書きました。
double 版の std::from_chars は 2〜36 進の小数を読めなかった
Float のリテラルを丸めるのに使うつもりだった std::from_chars は、double 版が macOS 26 より前の SDK に無く、読める基数も 10 と 16 だけでした。
Smalltalk では 2r1.1 のように 2〜36 進のリテラルを書けるので、多倍長整数の比で丸める実装に切り替えました。
仕様と手順は先に固めたが、道具の強制は守り切れなかった
コードを 1 行も書く前に、製品仕様の SPEC.md と、作業手順の CLAUDE.md を用意しました。フェーズごとの作業メモ 54 本も、最初のコードより先に作っています。
現在のフェーズはリポジトリ直下の PHASE ファイルに書き、そのフェーズのテストが緑になるまで次へ進ませません。
CLAUDE.md では、Graphify と Serena を必須にしました。
Graphify はリポジトリ全体の知識グラフを作ってモジュール間の経路を引けるツールで、Serena は言語サーバを通してシンボル単位で検索と置換をするツールです。
必須にしたのは、処理系ではメタクラスの循環、ネイティブのディスパッチ、コンパイラ、AppKit が互いを参照するからです。grep と行番号の編集に頼ると、ブートストラップやメソッド辞書を壊しやすいと考えました。
ただ、ルールと実態はずれました。
Serena はメインのチェックアウトに結び付いているので、git worktree で並行して動くエージェントからは、worktree のファイルが見えません。worktree のエージェントが Serena で編集すると、書き換わるのはメインのツリーのほうです。
worktree のエージェントには、Serena を読み取りだけに使わせるか、まったく使わせずに、ふつうの編集で直させました。この事情を記したコミットが 11 本ほどあります。
Graphify の深い解析には LLM の API キーが要るので、構文木だけの更新で代用しました。
こうした逸脱は、各コミットの Graphify: と Serena: のトレーラ行に正直に書かせています。この記録のおかげで、あとから経緯を追えました。
なお、この 2 つのツールで、エージェントが構造を壊す回数がどれだけ減ったかは、測っていません。
効いたやり方:再現してから直し、直したものを別のエージェントに疑わせる
12 のバッチは、毎回同じ手順で進めました。
- 仕様(SPEC.md)の変更を、実装とは別のコミットで先に入れる。
- レビューが示した失敗シナリオを、そのまま回帰テストにする。赤になることを確かめる。
- 実装してテストを緑にする。
- 独立したサブエージェントと、Codex(OpenAI のコーディングエージェント)にレビューさせる。
- 指摘を再現してから直す。
テストの厚みも増えました。B0 の時点で 302 だった ctest の項目は、B11 で 850 になっています。ctest の項目には、GoogleTest の各テストのほか、CLI のテストなども含みます。XCTest は 23 件から 56 件になりました。
Codex は、修正が持ち込んだ退行と、見落としていた不具合を捕まえた
1 つは、前編で書いた近道の穴です。Kernel のクラスかどうかを名前で判定していたので、SmallInteger に別名を付けると <= を定義し直せました。送信を省く近道は、その定義を無視していました。
std::from_chars の代わりに書いた Float リテラルの丸めも、退行していました。
処理時間が仮数の桁数の 2 乗に比例して伸び、10 万桁のリテラルで 4.23 秒かかりました。
先頭 1100 桁で切った値と、その最後の桁に 1 を足した値の両方を丸め、同じ double になればそれを答えにする方法に変えて、0.01 秒になっています。
一致しないのは 2 つの double のちょうど中間に近い場合だけで、そのときは全桁を使って丸めます。3300 ケースを正確な丸めと突き合わせ、不一致はありませんでした。
B9 で作り直した Dictionary と Set のハッシュ表では、再入の穴が見つかりました。
表を引く途中で呼ばれた要素の = が、その表から要素を削除して再挿入すると、表は見かけ上元どおりになります。そのため、書き換えを検出できませんでした。
表の配列の末尾に世代番号を置いて直しました。
GC の修正(B1)では、Codex は出荷不可と判定しました。
たとえば、old の上限近くでは、同じ生存物を何度も退避し直していました。
これは、full GC を起こす条件をもう 1 つ足して直しています。Object instVarAt: 1 put: Object. Object new isKindOf: UndefinedObject の評価が止まらないことも指摘されました。Object のスーパークラスを Object 自身にすると、連鎖が循環するからです。連鎖をたどる処理を 1024 段で打ち切る共通の関数を置きました。
修正と関係のない、以前からの不具合も見つけています。ログを表示する Transcript の文字が、ダークモードでは暗い背景に黒で描かれていました。文字色の属性を付けていなかったためです。
頼み方しだいで、Codex のレビューは止まる
壊れたイメージファイルを読み込む処理のレビューを「細工した入力で検査をすり抜ける」のような言葉で頼んだら、サイバーセキュリティのフィルタにかかって止まりました。「ディスクの不具合で切り詰められたファイル」のように、正しさのレビューとして頼み直すと通りました。
数値は Python と、イメージは壊れたファイルと突き合わせた
数値演算は、Python を正解にした差分テストで約 16 万件を突き合わせています。イメージの読み込みは、壊れたファイルを 8 通りの乱数の種で 300 件ずつ作って読ませました。記録に残る結果は、どちらも不一致 0 件、クラッシュもハングも 0 件です。不具合を見つけるためというより、直したあとに答えが崩れていないことを確かめる土台として回し続けました。
例外の仕組みや remembered set は、まだ後回しにしている
v1 では、次のものに手を付けていません。
- 例外の仕組み(
on:do:)と、実行時の文字列連結, - 取り込めていない Cuis のメソッド 8 件(
{}の配列、名前付きプリミティブなど) - old 領域の remembered set(old 領域から、新しいオブジェクトを置く nursery への参照を覚えておく表)。今は nursery の GC のたびに、到達できる old をすべてたどっている。
- 評価の中断。
yieldしない無限ループはアプリを止める。 - GUI の手動確認の一部
問いを外から持ち込む仕組みは、最初のフェーズから入れておく
不具合を多く掘り出したのは、GC のストレスモードと、実装とは別のエージェントのレビューでした。ストレスモードでは GoogleTest 262 件中 36 件が落ちました。v1 直後の全体レビューは Critical 11 件を挙げ、バッチごとの Codex のレビューは修正の退行を捕まえています。
Ao では、ストレスモードが v1 の後の B0 にずれ込みました。処理系のように内部の不変条件が多いものをエージェントに作らせるなら、この 2 つは 最初から入れておくべきです。
Python や壊れたファイルとの突き合わせも、修正の前に用意しておけば、どのバッチでもすぐ答えを確かめられます。
(まだ続きます)