装置を速くしても0.6%だった — 犯人はNE2000の受信リングで、wgetが3倍になった【rustx86 ネットワーク編 #5】

装置を速くしても0.6%だった — NE2000の受信リング Rust

連載「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 枚で満杯になる。

外は束で寄越すが、NE2000の受信リングは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 (この記事)

リポジトリ: rustx86 / wsslirp

コメント

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