Hanzoのベータに参加すると、有料プランが50%オフになります

GB10で毎秒50万リクエスト:ボトルネックは筐体ではなくネットワークだった

NVIDIA GB10上でクラウドのデータプレーンがどこまで速く走れるかを確かめた。答えが教えたのは自分たちのコードよりNICの受信キューのことで、同じ線のままスループットは2倍を超えた。

このページの内容

私たちのクラウドは単一の Go バイナリの上に建つ。AI ゲートウェイ、商取引、IAM、KMS を載せて api.hanzo.ai を出す、一つの成果物だ。その HTTP 面は zip、ZAP ネイティブなウェブフレームワークで、fasthttp の上に被せてある。そこで単純な問いを立てた。これは実際どこまで速く走れるのか、そして何が止めるのか。

正直な答えは、三度こちらを驚かせた。数字つきの捜査記録の全文がこれだ。すべては hanzoai/benchmarks で公開している。

第一に:フレームワークは税金か

違う。zip が包む生の fiber/fasthttp エンジンに対して載せるのは、一定の約 24 ナノ秒と 48 バイトの割り当て一つだけだ。実ハンドラのどれでも 1% を下回る。端から端まで、ループバック上で zip は生の fasthttp の 0.95× を出す。フレームワークは雑音だ。よし。次へ進む。

フレームワークリクエスト/秒
生の fasthttp365–400k
zip (HTTP)338–348k

第二に:分散した結果は遅くなった

ここからが面白い。直接の 2.5 GbE 有線リンクの上に二台ある。サーバが spark(NVIDIA GB10、20 コア)、負荷生成が evo(32 コアの x86)だ。負荷を箱の外へ出せば spark のコアはサーブに専念できるはずで、数字は上がると見ていた。

下がった。194k リクエスト/秒。接続を足してさらに押すと悪化した。4,000 接続で 167k、8,000 で 154k、p99 遅延は 5 ms から 52 ms へ登った。教科書どおりの輻輳崩壊だ。

しかしリンクは約 ~70 MB/s、2.5 GbE の 22% にすぎない。帯域ではない。spark の 20 コアも大半が遊んでいた。CPU でもない。では何か。

犯人:受信キューが一つ

ethtool -l がばらした。NIC はハードウェア RX キューが一つで動いていた。入ってくるパケットはすべて一つのコアの softirq で処理され、そのコアが天井だった。さらに悪いことに、ドライバはキューの追加をきっぱり拒んだ(ethtool -L → "Operation not supported")。

所有する最速の開発機が、パケットを掬うカーネルスレッド一つに絞られ、十九のコアは見物していた。

直し方:ソフトウェアでパケットを振り分ける

ハードウェアがマルチキューを拒むなら、カーネルがやる。受信パケットステアリングはフローをソフトウェアでコアへハッシュする。20 コアすべてで RPS(と RFS/XPS)をオンにし、受信バックログを 1,000 から 300,000 へ上げ、ソケットバッファを 256 MiB にし、パフォーマンスガバナをピンした。冪等な tune.sh を一つ、両方の箱で走らせる。

同じ線。同じハンドラ。518,000 リクエスト/秒。2.67× の跳躍で、ピークは 696k。

同時接続調整前調整後
1,000194k518k
4,000154k474k(ピーク 696k)

肝心なのは、調整後のネットワークの数字(518k)が今やループバック(365–400k)を上回ることだ。線の上では spark のコアが負荷生成と分け合わず、フルタイムでサーブする。518k で箱の空きは約 ~12% だけだ。単一ローダから見た本物の天井に近い。

第三に:ZAP は速いはずだった — だから速くした

zip は ZAP ネイティブだ。素の待受アドレスは私たちのバイナリ転送を話し、http:// は HTTP を話す。バイナリ転送が勝つと見て、ZAP の負荷クライアントを作り(HTTP の道具はそれを話せない)、測った。

ZAP は 91k。HTTP は 481k。ネイティブ転送は負けた。5× も。

追うと、線のせいではなかった。コーデックだ。遅延の裾が物語った(p50 は健全な 303 µs、しかし p99 は 13 ms。ガベージコレクションの署名だ)。GOGC を 100 から 800 へ緩めると ZAP は 2 倍を超えて 203k になった。6 秒の走行で ZAP サーバはガベージコレクションを 2,203 回起こし、HTTP サーバは 57 回。割り当て箇所は三つが担っていた。マーシャラが呼び出しごとに新しいフレームバッファを鋳造する。ヘッダ復号がすべての文字列をマップへコピーする。そして大きいもの、zaphttp のサーバはリクエストコンテキストをリクエストごとに新規割り当てし、fasthttp 自身のサーバはそれを接続ごとにプールする。

だから直した。変更(zap-proto/http、ブランチ perf/zero-alloc-codec)は機構であり、設計ではない。リクエストコンテキストを接続ごとに一度プールし、プール済みバッファへ追記するフレームエンコーダを手で書き、4 バイトの長さ接頭辞を bufio.Peek/Discard で抜いてヘッダ配列がヒープへ逃げないようにする。サーブループはその再利用バッファへ AppendResponse で直列化し、ヘッダをその場で復号する。温まった接続の上ではリクエストごとに何も割り当てない。ゴールデンテストは、前後で線がバイト単位で同一だと確認する。純粋なコーデックの書き直しで、プロトコルは変わらない。

結果、ZAP は 91k → 570k リクエスト/秒、6.3× の上昇で HTTP の 481k を追い越し、ループバックでは ZAP が 1.18× で勝つ。p99 は 13 ms から 1.19 ms へ崩れ、GC は 2,203 から 62 へ落ちた。

自分で訂正しなければならなかったことが二つある。どちらも修正のあとにも残るので、はっきり書いておく。第一に、勝ちは CPU であって線ではない。 ZAP のフレームは実際には HTTP より大きい(/health リクエストで 124 対 75 バイト)。ZAP が勝つのはバイトが少ないからではなく、バイトあたりのサイクルが少ないからだ。第二に、zaphttp は JSON を飛ばさない。 両方の転送で HTTP ボディを不透明なバイトとして運ぶので、ハンドラはどちらでも同じマーシャリングコストを払う。チャット型の JSON ペイロードでは ZAP は実際 HTTP の 0.95× で、ボディをフレーム末尾へコピーするぶんのわずかな負けだ。JSON の省略はネイティブな ZAP の型付き RPC の性質であり、zaphttp が踏まない別の軸だ。ここでのループバックの勝ちは正直で、純粋に CPU と割り当ての勝ちだ。では実物の 2.5 GbE では。両方の転送は ~530k で並ぶ。リンクは飽和し、コーデックはもうボトルネックではない。

第四に:では ZAP は本当に JSON を飛ばすのか

あの最後の一行、「JSON の省略はネイティブ ZAP の性質で zaphttp はそれを行使しない」は約束手形だ。読者は私にそれを現金化させるべきだ。zaphttp はボディを不透明に運ぶので、バイナリプロトコルが勝つはずの点、つまりテキストを解析せずに構造化データを読むことを、一度も試さない。だからそれを行うテストを作った。

共有レコード一つ。eth_getBalance 型の呼び出しで、行きも帰りも四フィールド(account、block、id → balance、nonce、blockHash)。往復は四通り。標準ライブラリの encoding/json、goccy/go-json と sonic(Go で最速の JSON ライブラリ二つ)、そして zap-proto/go の型付きバイナリ。入るフィールドは同じ、計算は同じ、出るフィールドは同じ。往復等価テストがバイト厳密な再符号化を強制するので、一方が他方の仕事を飛ばせない。JSON は標準ライブラリではなく最速の実装に固定した。結果を手で払えないようにするためだ。

復号は虐殺だ。 四フィールドのリクエストを読む。

コーデック復号割り当て
JSON 標準ライブラリ2334 ns13
JSON sonic845 ns8
JSON goccy473 ns4
型付き ZAP13 ns0

13 ナノ秒、割り当てゼロ。最速の JSON より 35×、標準ライブラリより 174× 速い。 解析はない。フィールドは既知のオフセットにあり、それを読む。型付きバイナリ線の売りはそれだけで、それは持つ。

ただ、ZAP を持ち上げない部分も報告すると約束した。だから書く。

  • 符号化は引き分けで、割り当ては多い。 ZAP は 629 ns、割り当て 12 で直列化する。goccy は 426 ns / 4 だ。公開済みの zap-proto/go v1.3.0 のビルダは可変長の末尾をコピー経由で後回しにし、オブジェクトビルダをヒープへ逃がす。それを直すプール済み固定幅 API はリリース済みタグにない。だからこれが正直な数字だ。差し引きすれば、サーバハンドラ全体(復号 + 計算 + 符号化)は ZAP を確かに好む。274 ns 対 goccy の 431。復号の勝ちが符号化の引き分けを飲み込むからだ。担っているのは復号だ。
  • 端から端まででは、174× は ~1.2× まで縮む。 実物の /rpc エンドポイントで、ZAP は 535k リクエスト/秒、sonic は 483k(1.11×)、goccy は 452k(1.18×)、標準ライブラリは 407k(1.31×)。本物だが、マイクロベンチの差には遠く及ばない。毎秒 50 万リクエストではソケットとスケジューラがそのコストで、直列化はその薄い一切れにすぎない。速くなったと気づくずっと前に、コーデックはボトルネックでなくなる。

ZAP の側に切る驚きが一つある。実物の構造化レコードでは線は大きくなく小さい。162 対 233 バイト。JSON は 0x の十六進文字列、十進整数、引用符、フィールド名にバイトを使うからだ。フレーミング試験の「ZAP の方が大きい」という留保は、取るに足らない /health サイズのペイロードにだけ当たる。本物の型付きフィールドを与えれば、サイズでもバイナリが勝つ。

だから正直に言う。ZAP は JSON を飛ばすか。飛ばす。そして読み取り経路では、差は近くない。 それを感じるかどうかは、直列化がボトルネックかどうかによる。小さなレコードの 500k rps では、そうではない。太い入れ子のペイロードや復号の重いファンアウトに替えれば、その 174× は自分がいる場所に現れ始める。

学んだこと

  • 経路全体を測り、道具を疑え。 hey は約 ~100k で頭打ちだった。それはクライアントであってサーバではない。bombardier は 4× 多くを見せた。
  • 箱が最初のボトルネックであることはまれだ。 NIC の RX キュー一つが、アプリケーションのコードを一行も触る前に 2.67× を奪った。
  • スループットだけでなく割り当てを測れ。 ZAP が「遅い」のは、コーデックの問題だと明らかになるまで GOGC フラグ一つだった。コンテキストをプールし割り当てをゼロにすると、ネイティブ転送は HTTP を追い越した。
  • マイクロベンチと負荷試験は別の問いに答える。 型付き復号は単体では JSON より 174× 速く、端から端まででは 1.2× 速い。どちらの数字も真で、片方だけを引くのは省略による嘘だ。
  • 公平に、実物として報告せよ。 同じハンドラ、温まった接続、都合の良い GOGC は使わない。JSON は最遅ではなく最速のライブラリに固定し、スクリプトを公開する。正直な数字が負けだったとき、符号化の引き分けも、圧縮された端から端までの差も、負けのまま出した。

ゼロ割り当ての ZAP コーデックは終わり、勝っている。残る停止は物理だ。デュアル NIC のローダ(七桁へ向かう並列の受信経路)、より速い相互接続(10 GbE / RDMA)、大きなペイロードで ZAP のフレーム末尾がボディのコピーを一つ飛ばすための writev 分割、そして Go 1.26 がネイティブに渡すポスト量子転送、PQ-TLS 1.3、ZAP-over-PQ-TLS、PQ-QUIC。そこでは ML-KEM のハンドシェイクコストを定常スループットから切り分ける。

必要なところまで、これを持っていける。

コメント

コメントするには Hanzo ID でサインインしてください。

コメントを読み込み中…

続きを読む