本記事は連載「rustx86 ディスク編」の第2回である。ブラウザで動くRust + WebAssembly製のx86エミュレータ rustx86 にディスクを生やしていく。前回: virtio-blkを実装して、ブラウザのLinuxでgccを128MBで動かす
前回、ブラウザの中のLinuxにvirtio-blkのディスクを生やし、gcc一式をsquashfsに焼いてrootfsを引っ越した。RAMは256MBから128MBになり、めでたしめでたし — のはずだった。
計測が全てをひっくり返す。今回はディスク編の初回から仕込まれていた時限爆弾を、time コマンドの3つの数字から掘り出して解体する話である。
発端 — sysだけが20秒違う
同じ gcc hello.c を、ディスク版とメモリ(initramfs)版で測った。
ディスク版 メモリ版
~ # time gcc hello.c ~ # time gcc hello.c
real 0m 24.95s real 0m 2.25s
user 0m 1.68s user 0m 1.68s
sys 0m 20.90s sys 0m 0.38s
11倍遅い。 だがこの数字は、犯人の居場所も同時に教えてくれている。
user(ユーザー空間のCPU時間)は1.68秒でぴたり一致。コンパイルの仕事そのものは1バイトも変わっていない- 差は全部
sys(カーネルの中のCPU時間)。20.9秒、カーネルが何かをしている
ディスクの読みが遅いなら待ち時間(realとsysの差)に出るはずで、sysに出るということは、カーネルが計算をしている。ディスクを読むとき、カーネルがそんなに計算する仕事があるだろうか。
ある。解凍だ。
squashfsとは — mountできるzip
容疑者の説明をしよう。前回、rootfsの乗り物に選んだsquashfsは、一言でいえば「mountできるzip」である。

ファイルの入れ物は3種類に分けられる。tar/cpioのような書庫は頭から順に読む前提で、使うには全部展開が要る — initramfsがこれで、展開後の中身が全部RAMに載る(前回の256MB問題の正体)。ext4のような普通のFSは読み書き両方できるが、そのぶんジャーナルや空きブロック管理の重装備が付く。
squashfsはその中間に居る。書庫のように1ファイルで木まるごとを持ち、FSのようにmountして拾い読みできる。 読み専用と割り切ることで重装備を全部捨てた。2002年ごろLive CDのために生まれ、OpenWrtのルータ、Ubuntuのsnapパッケージなど「変わらないものを配る」場面の定番である。書けない欠点はoverlayfsでtmpfsを上に重ねて補う — これも前回やった通り。
拾い読みできるから、触らなかった80MB分はディスクに寝たままRAMに来ない。これがRAM節約の正体だった。ここまでは前回の話。
問題は、squashfsがデータを128KBブロック単位で圧縮できることだ。そして前回の私は、何も考えずgzip圧縮で焼いていた。
mksquashfs "$work/root" images/disk-gcc.img -comp gzip ... # ← 時限爆弾
犯人 — 読むたび、76MHz級のCPUがgzipを解く
gccの本体 cc1 は45MBある。gccを実行すると、カーネルはこれをディスクから読む — そしてsquashfsが圧縮されていると、読んだブロックをカーネルが解凍する。
どこのCPUで? ゲストの、エミュレートされたCPUで。 このエミュレータのゲストは実測76MHz級(TSC基準)の速度で回っている。45MBのgzip解凍を76MHzのCPUにやらせたら、それは20秒かかる。sysに出ていたのはこれだ。

メモリ(initramfs)版が速かったのは圧縮していないからではない — あちらも起動時に一度gunzipしている。違いは回数だ。initramfsは起動時に1回展開してtmpfsに置き、以後はタダ。squashfsのゲスト内解凍は、ページキャッシュから追い出されるたび何度でも払う。
A/B — zstdは救ってくれない
「圧縮方式を速いものに替えればいいのでは」— 誰でもまずそう思う。測った。cc1(45MB)を冷えたキャッシュからcatしたときのsys時間と、その後の gcc hello.c である。
| squashfs | イメージ | cold read (sys) | gcc real |
|---|---|---|---|
| gzip | 35MB | 15.59s | 8.08s |
| zstd | 32MB | 16.34s | 8.35s |
| lz4 -Xhc | 41MB | 3.30s | 3.55s |
| 無圧縮 | 88MB | 0.91s | 2.82s |
zstdは「モダンで速い」圧縮の代名詞だが、gzipと変わらない。zstdの解凍が速いのはネイティブのCPUで回したときの話で、エミュレートされたCPUの上では、どの方式も桁で高い。lz4は3倍緩和するが、それでも無圧縮の3.6倍のsysを払う。
答えは「速い方式に替える」ではない。払わなくていい場所に仕事を移すことだ。
直し — 圧縮は輸送路の仕事
squashfsは無圧縮で焼く。ダウンロードのサイズが心配? それは輸送路の圧縮で解決する — 配布ファイルだけgzipし、ブラウザがfetchした直後にホスト側で1回だけ解く。
# make-gcc-disk.sh — squashfsは無圧縮で焼く
mksquashfs "$work/root" images/disk-gcc.img \
-noI -noD -noF -noX -all-root -no-xattrs -noappend -quiet
# 配布は .gz で運ぶ (輸送路の圧縮。ホスト側で解いてからvdaに挿す)
gzip -9 -c images/disk-gcc.img > web/disk-gcc.img.gz
ブラウザ側はfetchの後に3行足すだけである。DecompressionStream は今のブラウザに標準で載っているgzip解凍器で、当然ネイティブ速度で回る。
disk = await fetchWithProgress(`./${usedDisk}`, usedDisk);
if (usedDisk.endsWith('.gz')) {
const stream = new Blob([disk]).stream().pipeThrough(new DecompressionStream('gzip'));
disk = new Uint8Array(await new Response(stream).arrayBuffer());
}
実測: 88MBの解凍が335ms。同じ仕事をゲストにやらせると15.6秒 — 47倍差である。同じバイト列を解くのでも、どのCPUが払うかでこれだけ違う。
結果
| 転送サイズ | 冷えた gcc hello.c |
|
|---|---|---|
| 旧 (gzip squashfs) | 34MB | 24.95s |
| 新 (無圧縮 + 輸送gzip) | 34MB | 4.44s (ネイティブ) / 4.45s (ブラウザ) |
転送サイズはそのまま、実行は5.6倍。ネイティブとブラウザの数字がぴたり並ぶのは、ゲストの時計が命令数基準(仮想時計)だからで、これはこれで別の回に書きたい話である。
残るsysの1.6秒は初回のページキャッシュ充填(virtioのmemcpyとページ管理)で、2回目からは温まってメモリ版と同じ速さになる。
~ # time gcc hello.c
real 0m 4.44s
user 0m 1.61s
sys 0m 1.65s
おまけ — 計測ハーネスが2連敗して番犬が生まれた
この計測、実は本題より測る側で2回転んでいる。開発記なので恥も書いておく。
1敗目: 抽出フィルタがデータを食った。 time の行だけ拾うつもりの grep "real|user|sys" | head -16 が、カーネルログの user_kernel や user_space にもマッチして、head の枠が雑音で埋まった。肝心のtime行はその後ろに居たのに届かない。生ログを残さずフィルタ済みだけ保存していたので、復元もできなかった。
2敗目: 後始末のpkillが自分を撃った。 残骸プロセスの掃除に pkill -f "examples/guestcmd" を仕込んだら、-f は全プロセスのコマンドライン全文と照合するので、そのpkillを含む自分のシェルにもマッチした。親ごと死んで、計測は1本も走らずに静かに消えた。
反省から番犬を書いた。コマンドを走らせ、出力が一定時間伸びなければプロセスグループごと殺す — パターン照合はもう使わない。
# tools/watchdog.py の芯
child = subprocess.Popen(cmd, stdout=log, stderr=log, start_new_session=True)
...
if now - last_grow > a.idle:
os.killpg(child.pid, signal.SIGTERM) # グループごと。流れ弾なし
あわせて計測ランナー(guestcmd)にも、シリアル出力の流し見せと5G命令ごとの心拍を足した。ゲストが黙る区間(コンパイル中)でも、機械が回っていれば心拍が打たれる — 「生きて進んでいるか」を外から読めるようにした。
この番犬、配備した直後に本物を捕まえた。A/B用のイメージを焼くとき、前回書いた「二周目のディスク探しを断つ印」(/.rustx86-disk)を入れ忘れ、ゲストのinitが輪に入って Kernel panic: Attempted to kill init! — 旧ハーネスでは無言で17分空回りしていた事故が、流し見せのログで3分で特定できた。自分がドキュメントに書いた罠を自分で踏むのは締まらないが、道具が育っていれば被害は縮む。
まとめと次回
timeの3つの数字は犯人の居場所を教える: userが一致してsysが違えば、カーネルの中- squashfsの圧縮はゲスト内解凍 — エミュレートされたCPUには桁で高く、zstdでも救えない
- 圧縮は輸送路の仕事: squashfsは無圧縮、配布だけ.gz、ホストで1回解く(88MB/335ms)
- 冷えたgcc 24.95s → 4.44s。転送サイズは34MBのまま
ディスクはひとまず「メモリとほぼ同速・RAMは半分」まで来た。台帳にはまだ「ゲストが書いた中身の持ち帰り」「ReactOS・純正UNIXのためのATA」が寝ている。次に触るのがディスクの続きか、それともこのPCに画面(フレームバッファ)を生やす話になるかは、いつも通り測ってから決める。
リポジトリ: github.com/yoshiharu-ishii/rustx86(数字の台帳は docs/explanation/disk.md、判断の記録は docs/adr/0019)


コメント