テキストエディタを書いたら、言語のほうが直った
自作言語 Mere で、ファイルを読み込まないテキストエディタを書いた。208 MB が 0.5 秒・24.7 MB で開く。だが収穫はエディタではなかった。11 個の発見のうち 10 個が言語本体に入り、そのうち 5 つは既存のテストが構造的に見られない場所にあった。2 か月前に公開したエディタが、実は実端末では保存も終了もできなかったことも含めて。
Mere は私が自分のために書いている小さな言語だ。インタプリタと 4 つのコンパイル バックエンドを持つ。この記事は、その言語でテキストエディタを書いた 2 日間の話だ。
エディタは未完成のまま終わった。検索が無い。選択もコピーも無い。キーは 6 つと 矢印だけだ。それでもこの 2 日は言語を 6 箇所強くした。そしてそのうち 5 つは、 既存のテストスイートが構造的に見られない場所にあった。
実測の記事である。以下の数字はすべてこのマシンか CI から出たもので、都合の悪い 数字も都合の良い数字と同じ表に載せてある。私が途中で 3 回間違えたことも書く。
なぜエディタだったか
7 月に一度、同じ言語で kilo 風のエディタを書いている。223 行で、あのときは **意図的に「言語に何も足さない」**ことを狙った probe だった。既にある機能だけで 書けるか、書いていて何が痛いかを測るためだ。
今回は逆を狙った。「エディタが要るもので、この言語に無いものは何か」。 軸は 2 つに絞った。巨大ファイルと言語サーバだ。
手を動かす前に、全部測った
先に調査をした。そして当初の計画のうち 3 項目が消えた。要らなかったからだ。
| 想定 | 実測 |
|---|---|
| 言語サーバに長寿命パイプの新機能が要る | 要らない。 socketpair を使うと子プロセスが「既に受け入れた TCP 接続」と同じ形になる |
| イベントループに新機能が要る | 要らない。 poll(2) のラッパが既にあり、任意の fd を取る |
| 改行スキャンに SIMD が要る | 要らない。 clang が scalar ループを自動ベクトル化していて差が出ない |
socketpair の話が一番きれいだった。Mere の tcp_read / tcp_write は実は
read(2) / write(2) そのもので、ソケット専用ではない。だから子プロセスを
socketpair の片端に繋ぐと、既存のソケット呼び出しがそのまま使える。
コンパイラの変更はゼロ、C は 25 行で済んだ。
そして「要らない」と分かった代わりに、計画に無かったバグが 3 件出てきた。
1. 位置指定読み込みが 1 バイトずつ読んでいた
エディタの設計は piece table だ。ファイルは読み込まず、「元ファイルのここからここ」 という断片のリストとして持つ。3 GB のファイルを開いても断片は 1 個だ。
そのためにはファイルの一部をオフセット指定で読む必要がある。Mere には
file_pread があった。ただし返すのが Vec[int] ——「1 バイトが boxed int 1 個」で、
しかも C バックエンドは fgetc で 1 バイトずつ読んでいた。
208 MB を 256 KiB のページで通す:
| 実時間 | ピーク RSS | |
|---|---|---|
file_pread |
3.64 s | 10.1 MB |
ファイル全体を読む read_bytes |
0.33 s | 210 MB |
省メモリか速いかの二択だった。
書き込み側には file_pwrite_bytes が既にあった。8 か月前に別の dogfood が
「1 バイトずつ int に詰めるのはおかしい」と言って足したものだ。
読み側の双子が居なかっただけだった。
足した結果:
| 実時間 | ピーク RSS | |
|---|---|---|
file_pread_bytes |
0.36 s | 1.8 MB |
10 倍速く、5.6 倍省メモリ。そして二択が消えた。
なぜ 8 か月気づかれなかったか。file_pread を最初に要求したのは B-tree の
dogfood で、あちらは「ページを数として索引する」ので Vec[int] が正しかった。
ファイルを流し読む利用者が現れて初めて、形が違うと分かる。
WebAssembly バックエンドでは逆のことが起きていた。ホスト側の import は
元からバイト列のポインタを返していて、file_pread はその後ろに変換を
1 つ足していただけだった。安い方が元から下にいた。
2. 領域が解放したメモリを、再利用していなかった
これがこのアークで一番「読んでも分からない」ものだった。
Mere には region R { ... } という構文がある。ブロックの中で確保したものは
ブロックを出るときにまとめて解放される。エディタは打鍵ごとに画面を組み立てるので、
これが無いと 20,000 回の再描画で 5.9 GB に達する(実測)。あると 2.0 MB だ。
ところが、ブロックの中で大きな値を作るループで様子がおかしかった。 5 MiB の値を 40 回作ると 216 MB に達する。
最初の読みは「ブロックが回収していない」だった。これが間違いだった。
生成された C にカウンタを差し込んで走らせたら、こう出た。
block_release=40 freed_chain=40 big_allocs=40
40 回解放していた。帳簿は合っていた。 それでもプロセスは太り続けた。
起きていなかったのは再利用だった。解放処理が領域を丸ごと捨てて 1 MiB で 作り直すので、次の反復はまた malloc に数 MB を要求する。そして malloc は 同じページを返さない。
一番大きいブロックだけ残すようにしたら 13.7 MB で平坦になった。
| 値の大きさ | 変更前 | 変更後 |
|---|---|---|
| 4 MiB | 8.6 MB | 11.3 MB |
| 5 MiB | 107 MB | 13.4 MB |
| 8 MiB | 167 MB | 19.5 MB |
| 16 MiB | 327 MB | 35.9 MB |
RSS が反復数ではなく 1 反復ぶんでスケールするようになった。4 MiB の行が代償だ (成長した領域が縮まなくなった)。
ここで学んだのは実装の話ではない。ピーク RSS は「回収されたか」に答えない。 それは「同時に何バイト常駐したか」に答えるもので、「保持して再利用」と 「解放して再取得」はピークが同じになる。答えを出したのはカウンタだった。
そして仮説の検証は、コンパイラを触る前に、生成済みの C を直接書き換えて A/B した。16 倍の差はそこで出た。
3. raw モードが、プログラムが要求したキーを届けていなかった
これが一番恥ずかしい。
端末を「raw モード」にする関数がある。エコーを止め、行単位のバッファリングを 止める。エディタもゲームも最初に呼ぶ。
だがこの実装は ICANON と ECHO しか落としていなかった。IXON が残る。
IXON が残っていると、Ctrl-S は XOFF、Ctrl-Q は XON だ。端末の行編集規律が
両方を食べてしまい、プログラムはどちらのバイトも見ない。
そして 7 月に公開したエディタは、Ctrl-S を保存、Ctrl-Q を終了と謳っていた。
擬似端末で駆動して測った:
| Ctrl-S の後に描画されたバイト | 0(端末が止まっている) |
| Ctrl-Q の後に描画されたバイト | 0、プロセス生存、ファイル未変更 |
2 か月間、実端末では保存も終了もできないエディタが公開されていた。
なぜ誰も気づかなかったか。テストがパイプ経由だったからだ。パイプには行編集 規律が無い。0x13 も 0x11 もそのまま届き、すべて正常に見える。
コンパイラを直して再ビルドしただけで——エディタのソースは 1 行も変えずに—— 保存も終了もできるようになった。
もう 1 つ、同じ族のものが居た。ISIG だ。これが残っていると Ctrl-Z は SUSP に
なり、undo に割り当てても黙って効かない。
ただしこちらは同じ扱いにしなかった。ISIG を落とすと Ctrl-C を失う。
エディタは払える代償だが、q で終わるゲームには不要で、raw に畳み込むと
要求していない既存の全 TUI から脱出口を取り上げることになる。
別の関数として足した。
4. ライブラリ関数はコンテナを返せない
日本語を正しく描くには「書記素クラスタ」——読者が 1 文字と呼ぶ単位——で数える
必要がある。👩👩👦 はコードポイント 7 個だが 1 文字で、幅は 2 桁だ。
クラスタ分割のライブラリは既にあった。使ってみたら、フレームごとに約 60 KB
漏れた。2,000 フレームで 127.8 MB、完全に線形。しかも region ブロックの中で。
理由は言語の設計にある。コンテナ(可変バッファなど)は、確保が呼び出し側の
region ブロックに字句的に入っていないとき、プログラム寿命の領域に行く。
そしてライブラリ関数は決して字句的に入っていない。
だからクラスタ 1 個につきバッファ 1 個は、永久に返らないバッファ 1 個だった。
直し方は 2 つあった。型システムに手を入れるか、ライブラリがコンテナを使うのを やめるか。
測ったら後者で十分だった。クラスタは 1〜数コードポイントしかないので、 素朴な文字列連結が払う二乗コストは「1 クラスタの長さ」で頭打ちになる。 バッファは何も買っていなかった。
| 2,000 フレーム × 40 行の日本語 | ピーク RSS | 実時間 |
|---|---|---|
| 可変バッファ | 127.8 MB(線形) | 0.84 s |
| ただの文字列 | 1.6 MB(平坦) | 0.45 s |
78 倍のメモリ、しかも 1.9 倍速い。 ICU との一致は 8,509 入力すべて維持した。
型システムの方は触らなかった。調べたら、今の保守的な挙動のほうが正しかった からだ。関数の型に現れない確保は、内部だけのものか、外に共有されたものか、 型だけからは区別できない。区別しているのは別の仕組みで、それが「分からない ものは長生きする側に倒す」と決めている。
ついでに、コンパイラのコメントが逆のことを主張していたのを見つけて直した。 「見えない確保は自分では escape できないので束縛してもコストは無い」—— 証人を作ったら escape できた。
私が 3 回間違えたこと
振り返りで一番価値があるのはここだと思うので、書いておく。
「領域が回収していない」と書いた。 実際は回収していた(上記 2)。 ピーク RSS を「回収されたか」の答えとして読んだ。自分のメモに 「ピーク RSS は回収に答えない」という項目がそのまま存在していて、それを踏んだ。
「表示幅の関数がどこにも無い」と書いた。 あった。1 年以上前から標準ライブラリに 入っていて、ドキュメントにも載っていた。私は contrib ディレクトリと組み込み関数の 一覧だけを見て、prelude を見ていなかった。
ただし新しく作った判断自体は測定で残った。17,661 コードポイントで比べると
2,083 件(11.8%)食い違う。既存のものは手書きの 14 範囲で、結合文字の
取りこぼしが 1,488 件ある(U+200B ZERO WIDTH SPACE を含む)。
表の桁揃えには十分だが、カーソルを文字の終端に置く用途には足りない。
そこでは誤差が 1 セルに留まらないからだ。
「無いから作った」ではなく「あるものが足りないから作った」が正しい記述で、 両方のドキュメントに測定値付きの相互参照を書いた。
実行中の被検体を書き換えて、偽の回帰を 2 回作った。 テストスイートを走らせて いる最中にコンパイラを再ビルドして、存在しない失敗を報告させた。単体で再現しない ことを確かめて偽物と分かった。これも自分のメモに書いてある罠だった。
そして CI が、私のゲートを直した
修正を push したら CI が赤くなった。ローカルでは全部緑だったのに。
落ちたのは私が書いたゲートだった。「上限を超える大きさの値は、領域が保持せずに 返す」ことを検査するために、ピーク RSS が反復数に比例することを見ていた。
macOS では通った。glibc では落ちた。
理由は上記 2 と同じだ。「保持して再利用」と「解放して再取得」はピークが同じ になる。macOS は大きな解放を OS に返すので、たまたま差が出ていた。glibc は 再利用するので平坦になる。私のゲートはアロケータを測っていて、コンパイラを 測っていなかった。
期待値ではなく計器を差し替えた。ランタイムに「キャッシュされた領域が何バイト 抱えているか」を報告させるようにした。プログラムの関数であって、機械の関数ではない。
| 保持バイト数 | |
|---|---|
| 上限未満(5 MiB の値) | 8,388,608(成長したブロックを保持) |
| 上限超(32 MiB の値) | 1,048,576(返して作り直す) |
数字
| 期間 | 2 日 |
| コンパイラ本体の変更 | 511 行 |
| 計測とゲート | 970 行 |
| ドキュメント | 307 行 |
| エディタ | 自作 1,560 行(Mere 979 / C 115 / テスト 466) |
直した量の 2 倍近くを「次に同じことが起きたら赤くなる仕掛け」に使った。
最初これは効率が悪く見えた。だが 6 件のうち 5 件が「テストの置き場所が構造的に 見えない」場所にあったのだから、当然の比だ。欠陥は被検体ではなく計器の側に あった。
作った道具は、擬似端末でプログラムを駆動して「送ったバイトが届いたか」を訊く
ゲート、生成した幅テーブルを別実装と 18,226 点で突き合わせるゲート、
そして端末そのものに ESC[6n で訊くプローブだ。最後のものが必要なのは、
「曖昧幅」の文字の幅が Unicode の性質ではなく端末の性質だからで、
それに答えられるのは端末しかいない。
全部、壊してみて赤くなることを確かめてある。1 つは最初赤くならず、 判別する入力を作り直した——言語サーバの JSON パーサが、文字列中の生の改行を 通してしまうことに気づいていなかった。引用符に変えて初めて効いた。
エディタは、できたのか
できていない。
キーは 6 つと矢印だけ。言語サーバは 9 個のメソッドを提供しているのに、 使っているのは診断表示の 1 つだけ。そして検索が無い。 208 MB のログを 0.5 秒で開けても、その中を探せない。
できたのはこれだけだ:
| 208 MB を 0.5 秒 / 24.7 MB / 断片 1 個で開く | 多くのエディタより良い |
| undo がファイルサイズに無関係に O(1) | 断片リストが編集回数ぶんしか無いため |
| 日本語の桁計算とクラスタ単位のカーソル | これも多くのエディタより正確 |
最初に「『最高』の軸を決めてほしい」と自分で書いた。速度・日本語・言語サーバ・ 拡張性のどれを取るかで作るものが変わる、と。2 つ選んだということは、 エディタとしての完成度を捨てたということだった。
それでも書いてよかったこと
7 月の probe は「言語に何も足さない」ことを狙って、還元は 1 件だった。 今回は 11 件中 10 件が言語本体に入った。同じ対象でも、狙いを変えると収量が変わる。
そして 7 月のエディタ自身が、このアークの被害者であり受益者だった。 2 か月間動いていなかったものが、自分のソースを 1 行も変えずに動くようになった。
dogfood は「言語を測る道具」だと思っていたが、それだけではなかった。 言語の変更を受け取る側でもある。3 ダースある dogfood のうち 13 個は、 言語のリポジトリの CI から「まだプログラムか」を訊かれている。 今回それを 14 個にした。エディタが通る面——コンパイラが実装していない 外部関数宣言——を通る dogfood が、それまで 1 つも無かったからだ。
次に続けるなら検索からだと思う。巨大ファイルを開ける唯一のエディタが、 その中を探せないのは一番おかしいので。ただしそれを作ることで言語が何を得るかは、 また別の問いだ。