はじめに
半日かけて測って、途中で一度間違った結論に到達しました。しかもそれなりに筋の通った説明までついていて、記事の下書きまで書きました。
最終的にひっくり返った本当の結果はこうです。
| 同時実行6・短コンテキスト | BF16 | FP8 | FP8 の優位 |
|---|---|---|---|
| 投機なし | 219 tok/s(27.4 ms/iter) | 263 tok/s(22.8 ms/iter) | +20% |
| EAGLE K=3 | 369 tok/s(43.1 ms/iter) | 406 tok/s(37.5 ms/iter) | +13% |
FP8 は速いです。VRAM も半分になります。当たり前の結論に見えますが、私は途中で「FP8 は VRAM しか得しない」と確信していました。
間違えた理由は単純で、比較の条件が揃っていなかったからです。この記事は、その過程をそのまま書きます。同じ 4090 ×4 の箱で同じことをやる人が、私と同じ回り道をしないように。
なお本記事は同じマシン・同じモデルの続編にあたります。
検証環境
| 項目 | 内容 |
|---|---|
| GPU | NVIDIA GeForce RTX 4090 24GB × 4(Ada / SM89) |
| 接続 | PCIe のみ。NVLink なし、P2P なし |
| OS | Windows + WSL2 Ubuntu |
| 推論エンジン | SGLang(dev-qwen38-27b-dflash2 イメージ) |
| モデル | Qwen3.8-27B(BF16)/自前ビルドの FP8 版 |
| FP8 方式 | Block-FP8 E4M3、weight_block_size [128,128]、動的アクティベーション |
| 負荷 | 実運用のエージェント(/v1/responses)。ベンチ用の合成 prompt ではない |
FP8 版は公式の Qwen/Qwen3.8-27B-FP8 と同じ量子化スキームです。vision tower / norm / router / embedding / lm_head は BF16 のまま残しています。
4090 はそもそも P2P が使えないため NCCL_P2P_DISABLE=1、WSL2 では torch の symmetric memory の rendezvous が rank0 で SIGFPE するため sitecustomize.py で潰しています(詳細は前回の記事)。つまりすべての all-reduce がホストメモリ経由という、通信が不利な構成です。
第一印象:VRAM は半分、速度は変わらない
まず FP8 がちゃんとロードされているかを確認します。エンジンが量子化を検出し損ねて BF16 で読んでいるだけ、というのはよくある落とし穴です。
SGLang のデコードログから逆算できます。
Decode batch, #running-req: 1, #full token: 55084, full token usage: 0.07, ...
55084 / 0.07 ≈ 787,000 トークン。これが KV プールの総容量です。BF16 で同じ設定を起動したときは 350,333 でした。
| 重み | KV プール | |
|---|---|---|
| BF16 TP=4 | 13.05 GB/枚 | 350,333 tokens |
| FP8 TP=4 | 6.57 GiB/枚 | 約 787,000 tokens |
きれいに 2 倍。量子化は間違いなく効いています。
そしてデコード速度は 40 tok/s。BF16 のときの記録も 40 tok/s。
ここで「FP8 は VRAM だけ」という第一印象が刷り込まれました。この 2 つの 40 が曲者だったのですが、それに気づくのはずっと後です。
寄り道1:lm_head の all-to-all は使えない
通信が怪しいと思い、一番安いスイッチから試しました。SGLang の --enable-tp-lm-head-all-to-all です。
lm_head は TP 下では語彙次元で分割されるので、各カードは 15 万語彙のうち 3.8 万個分の logits しか持ちません。サンプリングには全語彙が要るので、デフォルトでは all-gather で集めます。all-to-all に切り替えると通信量が 1/TP になります。
起動すらしませんでした。
AssertionError: Please enable dp attention when setting enable_tp_lm_head_all_to_all.
これは独立したスイッチではなく、DP attention のデータ配置とセットの機能でした。DP attention を有効にする案は、以下の理由で見送りました。
- もともと MLA(DeepSeek 系の単一 latent KV head で TP 分割できない構造)向けの機能で、GQA + GDN ハイブリッドの Qwen3.8 が抱えている問題ではない
- DP rank ごとに KV プールと GDN 状態プールを別々に持つため、12 GiB/枚のプールが 4 分割される
- 単一ストリームでは 1 rank しか働かず、残り 3 枚は attention 中に遊ぶ
そもそも BS=1 での lm_head の通信量は logits 303 KB/token 程度で、1 トークン 25 ms のうち 0.1〜0.3 ms しかありません。1% 未満の項目のために構成を大きく変えるのは割に合わない、という判断です。
寄り道2:TP=2 は 32% 遅くなった
FP8 で重みが 25.85 GiB になったので、TP=2(12.9 GiB/枚)でも 24 GB カードに載ります。BF16 では 13.05 × 2 = 26 GB で載らなかったので、FP8 が初めて開けた選択肢でした。
狙いは all-reduce の削減です。48 層 × 各層 2 回、1 トークンあたり約 100 回の小さな all-reduce がホストメモリ経由で走っている。参加者を 4 → 2 に減らせばレイテンシが減るはずだ、と。
TP を変えると 1 枚あたりの状態サイズも変わる点に注意が要ります。GDN 状態は 36.7 → 73.4 MiB/slot/枚、KV は 16 → 32 KiB/token/枚、重みは 6.46 → 13.04 GiB/枚。
結果は 27.4 tok/s。TP=4 の 38.2 から 32% 悪化しました。
予想と真逆ですが、この失敗が一番の収穫でした。重みのバイト数が 2 倍になったら遅くなった=重み読み出しが支配的である、という証明になったからです。もし通信レイテンシが支配的なら、参加者が減った TP=2 は速くなっていたはずです。
ここで私はこう考えました。「重み読み出しが効く軸なのに、FP8 でバイト数を半分にしても時間が変わらない。ならば block-FP8 の GEMM オーバーヘッドが帯域の得をちょうど食い潰しているに違いない」。
Ada / SM89 では DeepGEMM が使えず(SM90 以降専用)、SGLang は Triton 実装の w8a8_block_fp8_matmul を選びます。これに per-token の動的量子化カーネルが加わる。説明としては筋が通っています。
ただし「ちょうど相殺」というのは、本来もっと疑うべき偶然でした。 後述します。
投機デコード:SGLang では長コンテキストでも効いた
以前 vLLM で MTP K=7 を試したとき、2k の短い prompt では 2 倍出たのに、10.7 万トークンでは受容長が同じまま 1 iteration が 81ms → 137〜154ms に膨張してベースラインまで落ちました。「長コンテキストでは投機の利得が消える」という結論を出していました。
ところが SGLang では、コンテキスト 10k → 88k での劣化が 42.0 → 38.2 tok/s、たった 10% しかありません。挙動が明らかに違う。もう一度試す価値があります。
FP8 checkpoint は MTP head を保持しているので、EAGLE がそのまま使えます。
--speculative-algorithm EAGLE
--speculative-num-steps 2
--speculative-eagle-topk 1
--speculative-num-draft-tokens 3
--enable-linear-replayssm-spec
コンテキスト約 9.3 万で 57.4 tok/s(投機なし 38.2 の 1.50 倍)。vLLM で見た悲観的な結論は、SGLang には当てはまりませんでした。
--enable-linear-replayssm-spec と tree draft は排他
受容率を上げようと --speculative-eagle-topk 2 にしたら弾かれました。
ValueError: --enable-linear-replayssm-spec requires a linear draft chain
(--speculative-eagle-topk in {None, 1}); the chunked verify kernel uses a
strictly-lower causal mask and is invalid for EAGLE tree verify.
GDN 混合アーキテクチャでは、投機の拒否のたびに SSM 状態をロールバックする必要があります。線形注意の状態は順次累積なので、KV cache のように slot を捨てれば済む話ではない。linear-replayssm-spec はそのための最適化された再生パスで、tree verify のマスク形状とは両立しません。
tree draft を取るか、安い状態再生を取るか。今回は後者を選びました。
K スイープ:受容率は 2 本目で崖から落ちる
num-draft-tokens を振ってみました(単一ストリーム、コンテキスト約 10 万)。
| K | accept len | tok/s |
|---|---|---|
| 2 | 1.75 | 57.4 |
| 3 | 1.93 | 57.4 |
| 4 | 2.01 | 57.4 |
| 8 | 2.06 | 47.1 |
2/3/4 は完全に横並び、8 は明確に損。
K=2 は draft が 1 本だけなので accept len = 1 + q₁ が直読できます。q₁ = 0.75。ここから K=4 の accept len 2.01 を説明しようとすると、2 本目以降は 0.25 前後でなければ合いません。
つまり受容確率は均等に減衰するのではなく、1 本目 0.75 → 2 本目以降 0.25 という崖型です。checkpoint 内蔵の MTP head は「次の 1 トークン」を予測するよう学習されていて、2 歩目からは自分の出力を食って自己回帰するので品質が即座に落ちる。K を増やすのは、ほぼ確実に棄却されるトークンの検証コストを払っているだけです。
1 iteration の時間も素直にモデル化できました。
iter_ms ≈ 26.2 + 2.15 × num_draft_tokens
K=4 で 34.8(実測 35.0)、K=8 で 43.4(実測 43.6)。切片の 26.2 ms は投機なしのベースラインそのものです。
BF16 対照実験で前提が崩れた
ここまでは「FP8 は VRAM だけ、投機で 1.5 倍」という記事になるはずでした。最後に一つだけ、誰も測っていないことを確認しようとしました。
block-FP8 の量子化は、checkpoint 内蔵 MTP head の受容率を劣化させないか?
draft head は BF16 target と一緒に学習されています。target を FP8 にすると出力分布がわずかにずれるので、draft の当たりが悪くなるのではないか。私のスクリプトにも UNTESTED with this FP8 target と書いてありました。
BF16 に同じ EAGLE 設定を入れて、同じコンテキスト長で測ります。
| コンテキスト約 10.5 万・単一ストリーム | accept len | tok/s | ms/iter |
|---|---|---|---|
| BF16 EAGLE K=3 | 1.96 | 52 | 37.5 |
| FP8 EAGLE K=3 | 1.93 | 57.4 | 33.6 |
受容長はほぼ同じ。 block-FP8 は MTP head の品質を壊しません。これは公開資料に見当たらなかったので、それ自体が収穫です。
しかし同時に、見たくないものが見えました。1 iteration が FP8 のほうが 12% 速い。
投機なしでは 40 tok/s 同士で並んでいたはずなのに。ここで初めて、最初の比較を疑いました。BF16 の「40 tok/s」は、コンテキスト長を記録せずに測ったものでした。
条件を揃えたら 20% 差がついた
同時実行 6・短コンテキスト・同じエージェント負荷という、完全に揃った条件で 2×2 を埋めます。
| BF16 | FP8 | FP8 の優位 | |
|---|---|---|---|
| 投機なし | 219 tok/s(27.4 ms/iter) | 263(22.8 ms) | +20% |
| EAGLE K=3 | 369(43.1 ms) | 406(37.5 ms) | +13% |
| 投機の利得 | 1.68x | 1.55x | — |
FP8 は両方のセルで勝ち、しかも投機なしのほうが差が大きい。
物理的にも整合します。1 枚あたりの重みは 13.05 → 6.57 GiB で 6.48 GiB の削減。4090 の帯域 1008 GB/s(≒939 GiB/s)で割ると理論値 6.9 ms。実測の削減は 4.6 ms。
つまり 理論値の約 2/3 が実現し、残り 1/3 が block-FP8 の GEMM オーバーヘッドで消えている。
これは「ちょうど相殺」よりずっと自然な説明です。そして振り返れば、ぴったり相殺という偶然を疑わなかったのが最大の失敗でした。物理量が都合よく完全に打ち消し合うときは、たいてい測り方が間違っています。
正直に書いておくと、単一ストリーム同士の比較(BF16 約 41 / FP8 42.0、いずれも短コンテキスト)では差が出ていません。同時実行 6 のペアは 19 サンプルで分散も小さく信頼できますが、なぜ単流で差が見えないのかは説明できていません。固定オーバーヘッドの比率の問題かもしれないし、8/25 に記録した BF16 の数値自体が粗いのかもしれません。
最終構成
eagle)
SPEC_ARGS=(
--speculative-algorithm EAGLE
--speculative-num-steps 2
--speculative-eagle-topk 1
--speculative-num-draft-tokens 3
--enable-linear-replayssm-spec
)
;;
| 項目 | 値 | 理由 |
|---|---|---|
| TP | 4 | TP=2 は実測 32% 遅い |
| 量子化 | block-FP8(自動検出) | 帯域理論値の 2/3 が実現、VRAM は半分 |
| KV dtype | bfloat16 | Ada の fp8-KV × 投機は IMA の前科あり |
| SPEC | EAGLE | 長コンテキストで 1.5 倍 |
| num-steps / draft-tokens | 2 / 3 | K=2/3/4 は横並び、8 は損 |
| eagle-topk | 1 | topk>1 は replayssm と排他 |
| max-running-requests | 6 | 4 では常時 5 件が待たされていた |
| context-length | 262144 | KV プール 64.4 万 tokens、使用率 16% |
実測は単一ストリーム 57.4 tok/s(10 万コンテキスト)、短コンテキストなら 79 tok/s、同時実行 6 で合計 406 tok/s。
まとめ
- Ada / SM89 でも block-FP8 は速い。 帯域理論値の約 2/3(実測 4.6 ms / 理論 6.9 ms)が実現する。DeepGEMM が SM90+ 専用で Triton パスに落ちるぶんが残り 1/3
- block-FP8 は MTP head の受容率を劣化させない(accept len 1.93 vs 1.89)
- SGLang では長コンテキストでも投機デコードが効く(10 万トークンで 1.5 倍)。vLLM で得た悲観的な結論は移植できない
- checkpoint 内蔵 MTP head の受容確率は 1 本目 0.75 → 2 本目以降 0.25 の崖型。K を増やしても伸びない
- TP を下げると遅くなる。
--enable-tp-lm-head-all-to-allは DP attention とセットで単独では使えない。--enable-linear-replayssm-specと tree draft は排他
そして一番の教訓は技術的なことではありません。コンテキスト長を記録していない測定値をベースラインに使ってはいけない。 私はそれで半日、正反対の結論を信じていました。
「ちょうど相殺」「きれいにゼロ」が出たら、それは発見ではなく、測り方を見直せという合図です。