連載「rustx86 ネットワーク編」: Rust + WebAssembly の x86 エミュレータをインターネットに繋ぐまでの開発記。#1 は SLiRP backend「wsslirp」(Go + gVisor netstack)、#2 は ISA バスの NE2000 で 1993 年の DOS から ping、#3 は PCI + RTL8029 で Linux をネットに乗せて真犯人の x87 FPU を追い、#4 で TLS の壁 6 枚を越えて wget https:// が通った。
今回は本測定である。始まりは「デバイス関連の最適化をやろう」という一言だった。装置のエミュレーションを速くする話のつもりで、実際にやったのは装置を速くしても意味がないと確かめることと、その先で見つけた本当の犯人の始末だった。
装置は起動時間の 0.64% しか使っていなかった
最適化の作法として、まず測る。ネイティブのフル起動 (bzImage、1160M 命令 / 15.2 秒) を macOS の sample で解剖した。
| 経路 | 割合 |
|---|---|
tick_devices (PIT / CMOS / UART / キーボード / NIC の挙手確認) |
0.64% |
io_read8 / io_write8 + UART 本体 |
0.03% |
| CPU 本体 (デコード済みキャッシュ・実行・TLB) | 残り全部 |
装置の側は 1% にも届かない。ここを 2 倍速くしても起動は 0.3% しか縮まない。
ゲスト側も見た。bootprof (ip を 4096 命令ごとにサンプリングして System.map で関数名に解決する自作ハーネス) で 771M 命令の内訳を出すと、こうなる。
156M 20.2% inflate_fast ← initramfsの展開
106M 13.8% blake2s_compress_generic ← 乱数の初期化
74M 9.7% trace_event_update_all
44M 5.8% mpihelp_submul_1 ← 署名検証の多倍長演算
36M 4.7% get_symbol_offset ← kallsyms
4M 0.6% pv_native_safe_halt ← アイドル(HLT)
装置ドライバの遅延ループ (udelay や PIT の較正) は影も形もない。エミュレータでよくある「ゲストが装置待ちで空回りして時間を溶かす」病理は、HLT の早送りを入れた時点で片付いていた。
装置を速くする話はここで終わりである。 ただしこれは無駄な調査ではない。「効きそうな場所」を潰したのだから、次にどこを見るかが決まる。装置の性能でユーザーに見えるものは1つしかない — ネットワークの実効速度である。
定規を作る: 空回りの割合が犯人を指す
速度の話をするには定規が要る。tools/webtest/netbench.mjs を書いた。ゲストの Linux に wget を 1 本走らせて、こう出す。
実時間 : 2.53 s
受信 : 1.18 MB (WS上)
実効速度 : 0.47 MB/s = 3.7 Mbps
空回り : 86% (ゲストの時間 240M のうち 192M がHLT)
実行命令 : 47 M = 39.3 命令/バイト
フレーム : 注入 798 / 落とした 60 (7.0%) / 送信 236
この中で一番効いた軸が空回り (HLT) の割合である。ゲストの時間のうち何割を「待って」いたかで、犯人が2つに割れる。
- 空回りが高い → 遅延の問題 (待たされている)
- 空回りが低い → 単価の問題 (CPU を使い切っている)
初回の測定は 86% が空回りだった。ゲストは仕事をしていない。待っている。
宛先の罠 — 測っていたのはインターネットだった
最初に使った配信元は speedtest.tele2.net だった。1MB に 6.9 秒。遅い。……と思ったが、念のためホスト自身で測った。
$ curl -o /dev/null -w '%{time_total}' http://speedtest.tele2.net/1MB.zip
6.292365
ホストの curl 自身が 6.3 秒かかっていた。 ゲストの 6.9 秒はほぼ素通しで、私が測っていたのはスウェーデンまでの経路であってエミュレータではなかった。
近い配信元 (cachefly、ホストで 0.56 秒) に替えたところ、ゲストは 2.4〜3.8 秒。ホストの 4〜7 倍。ここでようやく、測っているものが自分の実装になった。
定規を作るときは、まず定規自身を測る。外の遅さを自分の遅さと読み違えると、直しても数字が動かないものを延々いじることになる。
フレームの 6〜10% が消えていた
出力をもう一度見る。
フレーム : 注入 798 / 落とした 60 (7.0%) / 送信 236
受信フレームの 7% が消えていた。 走行によっては 10% を超えた。TCP にとって損失は「経路が混んでいる」の合図なので、再送と輻輳ウィンドウの縮小で応じる。速度が半分以下になるのは当然だった。
原因は、外の世界とカードの間の寸法の不一致である。
WebSocket の向こうからは、TCP の 1 ウィンドウが束で落ちてくる。数十枚が一度に届く。ところが NE2000 の受信リングは SRAM 16KB — フル長フレームなら9 枚で満杯になる。

そして当時のコードは、届いた束をその場で全部リングへ押し込もうとしていた。
// 旧: 入り切らなければ、そこで捨てる
if pages_needed + 1 > free {
self.isr |= ISR_OVW; // 溢れた印
return false;
}
Ethernet は「届かないことがある」層だから、落ちても TCP が拾い直す — その理屈自体は正しい。実際コメントにもそう書いてあった。間違っていたのは、落ちる理由が実機に存在しないものだったことである。
実機では束は消えない。10Mbps の線を1枚ずつ流れてくるので、カードは自分のペースで受け取れる。消えていたのは「線が無限に速い」という、エミュレータ側が勝手に持ち込んだ嘘の副作用だった。
なので、カードの前に線を置く。
/// **まだリングに入れていない受信フレーム = 線の上に居るぶん。**
rx_queue: VecDeque<Vec<u8>>,
inject_frame は線に積むだけ。リングへ詰めるのは drain_rx で、呼ぶ契機は2つ。ドライバが BNRY を進めた瞬間 (リングに空きができる瞬間そのもの) と、装置の tick である。
// BNRYを進める = ドライバが1枚読み終えてページを返した瞬間。
// **リングに空きができるのはここ**なので、線で待っている次の
// フレームをすぐ入れる (待たせると次の割り込みまで遅れる)
(0, 0x03) => {
self.bnry = val;
self.drain_rx();
}
そして機械が止まった
「捨てるのをやめる」だけを入れた版は、転送の途中で永久に止まった。
シェルは無反応。Ctrl-C も効かない。まず ip の頻度を取ると、4000 サンプル中 3925 がカーネルのアイドルループ (0xc5a4a26b) だった。暴走ではなく永眠である。誰も起こしていない。
次に NIC の中を覗く窓 (net_debug) を足した。1行で答えが出た。
q=35 isr=00 imr=3f curr=51 bnry=56 pstart=4c pstop=80 run=true
読み方はこうである。
pstart=4c pstop=80→ リングは 52 ページcurr=51 bnry=56→ 使用中 47 ページ、空きは 5 ページ。フル長フレームには 7 ページ要るので満杯q=35→ 線には 35 枚の行列isr=00→ 割り込みは1つも上がっていない

三すくみである。リングは満杯なので新しいフレームが入れない。フレームが入らないので受信割り込み (PRX) が立たない。割り込みが立たないのでドライバは動かない。ドライバが動かないのでリングが空かない。
なぜドライバが未読を残したまま寝たのか。 8390 のドライバは1回の割り込みで読む枚数に上限を持っていて、上限に達すると未読を残したまま ISR を下ろして降りる。実機ならその間にも次のフレームが届いて PRX がまた立つので、次の割り込みで続きを読む。うちは「満杯だから何も入らない」ので、その次が永久に来なかった。
そして重要なのは、旧実装ではこの出口を OVW が兼ねていたことである。溢れるたびに立てていた「オーバーラン」の印を見て、ドライバは重い回復処理 (受信機を止めてリングを浚って再開する) を走らせていた。無駄な処理だと思っていたそれが、実はデッドロックの非常口だった。捨てるのをやめた瞬間に、出口も一緒に消えた。
直しは、事実をそのまま合図にすることだった。
if !self.ring_put(&frame) {
self.rx_queue.push_front(frame);
// **リングが満杯 = 読まれていないフレームがリングに居る。**
// その合図 (PRX) を立て直す。下ろすのはドライバの仕事で、
// 下ろした後もまだ残っていれば次のtickでまた立つ。
if self.running {
self.isr |= ISR_PRX;
}
break;
}
「満杯」は「未読が居る」と同じことなので、受信済みの合図を立て直すのは嘘ではない。レベルトリガとして読み替えただけである。
結果: 3〜4倍速く、命令は半分以下
同じ 1MB を、新旧のバイナリを交互に 3 周走らせて測った。
| 実時間 | 実効速度 | 落とした | 実行命令 | |
|---|---|---|---|---|
| 捨てる (旧) | 2.42 / 2.53 / 3.82 s | 2.6〜4.0 Mbps | 6〜10% | 39〜42 命令/バイト |
| 線で待たせる | 0.64 / 0.68 / 0.71 s | 10〜14 Mbps | 0% | 19 命令/バイト |
| (参考) ホストの curl | 0.56 s | — | — | — |
命令が半分以下になったのが答え合わせになっている。 消えていたのは帯域だけではなかった。再送と溢れ回復に費やしていたゲストの CPU も、まとめて消えていた。1バイト運ぶのに 40 命令使っていたのが 19 命令になり、ホストの素の速さにほぼ並んだ。
空回りの割合も 86% → 27〜62% に下がった。待つ機械から、働く機械になった。 ここから先は遅延ではなく単価 (19 命令/バイトの内訳) の話になる。
教訓: 捨てるのをやめるだけでは詰まる
これは他所でも踏める型なので、罠の台帳 (pitfalls) に 13 番目として書いた。
有限のバッファ (リング・キュー・窓口) が溢れたとき、捨てる代わりに待たせるのは正しい改善であることが多い。ただし捨てる経路には、たいてい「詰まりを相手に知らせる合図」が抱き合わせで乗っている。合図ごと消すと、待たせた側が永久に待つ。
そして検知の側にも型がある。今回、原因に届いたのは推測を1回も挟まない2つの道具だった。
- 止まった場所の分布 — アイドルループが 99% なら「暴走」ではなく「永眠」。探す先が半分になる
- 中を覗く窓 —
q=35 isr=00 curr=51 bnry=56の 4 つの数字が並んだ瞬間に、三すくみは自明になった
エミュレータは「外から見えない状態」の塊なので、覗き窓を作る手間はだいたい即座に回収できる。
測ったら、構造の歪みが見えた
余談だが、この作業の副産物として装置レイヤを組み替えることになった。
受信リングのコードは dev/isa/ne2000.rs にあった。ところが同じ 8390 チップは PCI 版の RTL8029 としても使っていて、そちらは dev/pci/rtl8029.rs に「設定空間の顔」だけがある。触っているうちに、この木は軸が2つ混ざっていると気づいた。
dev/isa/に「ISA の機構」は無い。実体は I/O ポートの固定番地のmatchで、isa/はただの装置置き場だった- そこに居る 8259・8254・8042 などはマザーボードに半田付けされたチップで、ISA カードではない (どれも ISA の標準化より前の部品である)
dev/pci/は装置の種類ではなく「数える仕組み」
決め手は先の見立てだった。将来の virtio は ISA でも PCI でもないので、バス別に装置を置く木は3つ目のディレクトリで破綻する。バスは「どう見つかるか」であって「なにか」ではない — 装置の分類軸に使えない。
そこで軸を2本に割った。
dev/chip/ 素子。番地もバスも知らない (dp8390, pic, pit, kbd, cmos, crtc, uart)
dev/card/ 基板 = 素子 + 基板の都合 (ne2000: 倍幅PROM / rtl8029: 平坦PROM + 名乗り)
bus/ どう見つかるか
memmap.rs メモリ空間の地図
isa/ 固定番地の表 + IRQの配線
pci/ 設定空間・BAR・スロット
ディレクトリはバス、ファイルは地図。例外を作らない。 これで DP8390 の実装は1つになり、NE2000 と RTL8029 の違いは PROM の並べ方 (ISA の 8bit 経路では各バイトが2度ずつ並ぶ) と名乗りだけになった。おまけに、機械の本体 (lib.rs) に住んでいた IRQ の配線もバス側へ引っ越した — どの装置がどの線に繋がっているかは装置の性質ではなく、実機ならジャンパで決める機械の配線である。
構造だけの組み替えなので、関門は「外から見える振る舞いが1ビットも変わらないこと」に置いた。3OS の起動命令数 (ELKS 25M / FreeDOS 2M+2M / Linux 770M) がビット同一で、NE2000 の外形テスト 12 本が1行も書き換えずに通ることを条件にしている。判断は ADR-0018 に残した。
次回
実効速度は「待ち」から「単価」の側に移った。次に見るのは 19 命令/バイトの内訳と、ブラウザ側に残っている遅延 (アイドル時のスライスの刻みと、最大 50ms の寝) である。TCP over TCP の病理と MTU の押し出しも、定規ができたので今なら分解できる。
連載: #1 wsslirp (Go + gVisor netstack) / #2 ISAバスのNE2000で1993年のDOSからping / #3 PCI + RTL8029 と、真犯人のx87 FPU / #4 wget https:// が通るまでの壁は6枚あった / #5 (この記事)


コメント