Rust製x86エミュレータのディスクを5.6倍速くする — 圧縮はゲストではなく輸送路の仕事【rustx86 ディスク編 #2】

Rust

本記事は連載「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」である。

書庫・squashfs・普通のFSの三者比較と、squashfsイメージの中身

ファイルの入れ物は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_kerneluser_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)

コメント

タイトルとURLをコピーしました