初めに
RTX 4090 24GB × 4 の 1 台(WSL2)で Qwen3.8-27B を vLLM で動かすと、単一ストリームの decode は 29 tok/s 前後。
投機デコード(speculative decoding)を入れてみたところ、58〜59 tok/s(約 2 倍) になりました。
ただ、この 2 倍は「スイッチを入れるだけ」では出ません。draft token 数(num_speculative_tokens、以下 K)の選定、WSL2 の UVA、Ada(SM 89)の attention backend、KV cache の予算……と落とし穴が続きます。
さらに、当初「このモデルでは使えない」と思っていた MTP(vLLM 標準の投機デコード)が、実は DFlash2 と同程度の性能を出せる ことも、後から分かりました。今回は DFlash2 と MTP の 2 方式を、同じベンチマークで比較した結果をまとめます。
1. 投機デコードのおさらい
投機デコードは「小さいモデル(draft)が先に K token 書いて、大きいモデル(target)がまとめて答え合わせ(verify)する」仕組みです。
- verify に通った token だけが進み、1 回の forward で複数 token 進める
- 出力自体は(理想的な実装なら)通常 decode と変わらない
- 効き目は「K をどれだけ採用してもらえるか(受容率)」と「draft + verify の追加コスト」のトレードオフ
草稿の作り方には流派があります。
| MTP | DFlash / DFlash2 | |
|---|---|---|
| draft を書く主体 | モデル本体の MTP ヘッド | 独立した小型 draft model(1.92B / 5 層) |
| 書き方 | 1 token ずつ再帰的に予測 | 双方向 attention + conv でブロック丸ごと一括予測 |
| K を増やすと | draft コストが線形に増える | draft コストはほぼ不変 |
| 1 token あたりの受容率 | 高い | やや落ちる |
| 追加依存 | なし(モデル内蔵) | draft モデル + 実装パッチ |
DFlash2 は DFlash に candidate selector(codebook から top-k 候補を歩く)を足した後継で、draft の質を大きく上げています。
2. 検証環境
| 項目 | 内容 |
|---|---|
| GPU | RTX 4090 24GB × 4(TP=4、PCIe のみ) |
| OS | Windows 11 + WSL2(kernel 6.6.114、mirrored mode) |
| target | Qwen/Qwen3.8-27B(64 層 = GDN 48 + full attention 16、BF16) |
| draft(DFlash2 用) | Qwen3.8-27B-DFlash2(1.92B、5 層、sliding window 2048、embedding/lm_head 共有) |
| 実行基盤 | vLLM nightly 0.27.2rc1.dev77+gac7509e2b(検証済み image)+ PR #52816 の Python オーバーレイ |
| max-model-len | 131072 |
| attention backend | flash_attn(FA2。FA3 は Hopper 専用) |
| ベンチ | 2k token の OCR 風 prompt、temp 0、単一ストリーム、warm(初回 JIT 後の安定値) |
vLLM は GitHub から完全に遮断された環境だったため、PR #52816 の差分を別マシンで取得して手元に適用しました(純 Python / Triton のみで、C++ コンパイルは不要)。
3. まず DFlash2 で K を 1〜10 まで振ってみた
一番知りたかったのはここです。結果:
| K | decode 速度 (warm) | 1 iteration あたりの採用 token 数 | 受容率 p0.. |
|---|---|---|---|
| なし (baseline) | 29.4 tok/s | 1.00 | - |
| 1 | 約 34 tok/s | 1.87 | .87 |
| 3 | 約 49 tok/s | 2.98 | .83/.65/.50 |
| 4 | 約 50 tok/s | 3.24 | .76/.60/.48/.40 |
| 7 | 約 58.5 tok/s | 3.92 | .77/.60/.50/.42/.29/.19/.15 |
| 10 | 約 55.5 tok/s | 3.84 | .69/.55/.43/.37/... |
読み取れること:
- K=7 がピークで、baseline 比 約 2.0 倍
- K=10 にすると下がる。「とりあえず大きめに」が一番やってはいけない設定
- K=3 → K=4 はほぼ横ばい(49 → 50)。採用数は伸びているのに速度が伸びない → このあたりから draft/verify の追加コストが効き始めている
ちなみに最初の測定は 34.5 tok/s でしたが、これはコールドスタート値。初回リクエストに Triton JIT コンパイルと CUDA graph キャプチャが含まれるためです。warm 状態で測り直すと 49.6 → 58.5 と上がりました。測定は必ず warm で。
4. なぜ K=7 なのか——答えは checkpoint 側に書いてあった
K=10 で落ちる理由は、後から draft model の config を見直して腑に落ちました。
DFlash2 の conv 層は block_size = 1 + num_speculative_tokens で動きます。K=7 のとき block_size は 8 で、これは draft model を学習したときの block_size と同じ値 です。
dflash_config: { block_size: 8, conv_group_size: 16, conv_kernel_size: 2,
selector_rank: 256, selector_top_k: 16, ... }
つまり:
- K=7 は「たまたま速かった値」ではなく、checkpoint の学習条件に一致する値
- K=10 は block_size 11 となり、学習時に見ていない長さのブロックを書かせることになる。採用数が 3.92 → 3.84 に落ちているのはそのまま草稿品質の劣化
K を決める前に、draft model の config で block_size を確認する。K = block_size - 1 が最速の出発点。 これは他のモデルに移っても使える考え方です。
事前に立てた仮説は普通に外れました。私は最初 K=4 で始めるつもりでした(llama.cpp の別バックエンドで「32k context では width 4 が width 7 より 29% 速い」というデータを見ていたため)。結果は K=7 が K=4 を約 17% 上回りました。アーキテクチャも draft の仕組みも違うベンチから、K の最適値だけを持ってくることはできません。 傾向の参考にはなりますが、最終的な値は自分の環境で振るしかありません。振ってみれば数十分で済みます。
5. MTP も同じベンチで測ってみた
DFlash2 の導入が落ち着いた後、ふと「Qwen3.8-27B は MTP ヘッド内蔵なのでは?」と config を確認してみました。
config.json (text_config): { mtp_num_hidden_layers: 1, mtp_use_dedicated_embeddings: False }
内蔵していました。 つまり vLLM 標準の method=mtp がそのまま使えます。追加の draft model もパッチも不要です。
同じベンチで測った結果:
| 方式 | K | decode 速度 (warm) | 1 iteration あたりの採用 token 数 | 受容率 p0.. |
|---|---|---|---|---|
| MTP | 7 | 59.2 tok/s(58.5〜60.3) | 4.82 | .87/.79/.69/.54/.44/.28/.21 |
| MTP | 12 | 約 52.8 tok/s | 5.03 | .82/.72/.62/.56/... |
| DFlash2 | 7 | 約 58.5 tok/s | 3.92 | .77/.60/.50/.42/... |
| baseline | - | 29.4 tok/s | 1.00 | - |
注目すべきは 2 点:
- MTP K=7 は DFlash2 K=7 とほぼ同等(むしろ僅かに上)。約 2.0 倍。
- 受容率は MTP のほうが大幅に高い(4.82 vs 3.92 token/iter)のに、tok/s はほぼ同じ。これは MTP の draft フェーズがモデル本体を再帰的に回すため、1 iteration あたりのコストが重い(約 81ms vs DFlash2 の約 67ms)からです。DFlash2 は軽量 draft model(1.92B)を使うぶん草稿は安いが、受容率で負ける——ちょうど釣り合って、実測トップスピードはほぼ同点という結果になりました。
両者とも K=7 がピークですが、理由は違います。
- DFlash2:
block_size = 1 + Kが学習条件(8)に一致するかどうか - MTP: draft ヘッドの再帰コストが K に比例して増える(K=12 で 52.8 に低下)
「K=7 が正解」という点だけ同じで、背後にある制約は別物、ということです。
なお、この環境の vLLM の古いコードコメントには「MTP は Qwen3.8-27B では 3.6 倍の純損失」という記録があり、当初はそれを鵜呑みにして DFlash2 一択だと思い込んでいました。実際に同じベンチで測ると 現在の nightly では再現しない ことが分かりました。他所の計測・コメントも、自分の環境で測り直すべき、という良い例です。
6. 正しさの検証
投機デコードを導入したら「出力が壊れていないか」も確認すべきです。やったことは 2 つ:
- 決定性: 同一 prompt・temp 0 で 2 回実行 → 出力は完全一致(DFlash2 / MTP とも)
- baseline との一致: 素の decode の出力と比較 → DFlash2 は 99.62%、MTP は 99.55% 一致
わずかな差は、verify 経路で GDN(Gated DeltaNet の線形 attention)が Triton カーネルを、素の decode では fused CUDA カーネルを使うための数値精度の差です(低信頼度 token の argmax が稀に反転する)。意味論的には同一で、品質劣化はありませんでした。
7. 導入で踏んだ落とし穴(WSL2 / Ada)
7.1 WSL2 で UVA is not available になり起動すらしない
DFlash2 を有効にすると、vLLM は V2 model runner を強制します(PR の仕様)。V2 model runner は UvaBuffer(host と device の共有メモリ)を必要とします。
一方、WSL2 では vLLM は pinned memory / UVA をデフォルトで無効にします。結果、is_uva_available() が False を返し、worker が全滅して起動失敗します。
対処は環境変数 1 つ:
-e VLLM_WSL2_ENABLE_PIN_MEMORY=1
WSL2 の kernel は 6.6.114 で、vLLM が要求する下限(4.19.121)は十分超えています。「WSL2 だから一律で無効」という保守的な既定値に引っかかっていただけ、というオチでした。WSL2 で投機デコード系の新機能を試すときは、まずここを疑うと早いです。
7.2 Ada では attention backend を明示する
DFlash2 は投機スロット用に non-causal を扱える backend が必要です。Ada(SM 89)では FA2(flash_attn) が通常経路。FA3 は Hopper 専用なので選べません。target 側と speculative-config 側の両方で指定しました。
7.3 KV cache を削らないと drafter が乗らない
24GB × 4 だと、draft model の分だけ確実に VRAM が足りなくなります。sharded された draft の重みが 1 枚あたり約 1GiB、これに draft 側の KV とアクティベーションが乗ります。
その分、KV cache を 7.0 GiB → 4.5 GiB まで落としました(トークンプール約 25 万)。
--kv-cache-memory 4831838208
投機デコードは**「余っている VRAM で速度を買う」機能**です。余っていなければ context か同時実行数のどちらかを売ることになります。
7.4 速くなるのは decode だけ
投機デコードが効くのは生成部分だけ。TTFT(最初の 1 token まで)は改善しません。むしろ同時実行数が増えると草稿・検証の分だけ TTFT は悪化する方向です。
- 入力が短く出力が長く、同時実行が少ない用途 → よく効く
- 入力が長い(prefill 支配)用途 → 体感はそこまで変わらない
私の用途(日本語ドキュメントの OCR)では、対話では明確に速くなり、長文入力では「そこそこ」。1 台で両方やるなら、投機あり/なしのコンテナを分けて用途で使い分けるのが素直です。
8. 2 方式のまとめ(この環境での実測)
| 項目 | MTP K=7 ✅ | DFlash2 K=7 |
|---|---|---|
| warm decode | 59.2 tok/s(約 2.0 倍) | 約 58.5 tok/s(約 2.0 倍) |
| 採用 token/iter | 4.82 | 3.92 |
| 1 iteration コスト | 約 81ms | 約 67ms |
| 受容率 | 高い(.87 スタート) | やや低い(.77 スタート) |
| draft の主体 | モデル内蔵 MTP ヘッド | 外部 draft model(1.92B) |
| 追加依存 | なし | draft モデル + PR #52816 パッチ |
| WSL2 の追加対応 | 不要 | VLLM_WSL2_ENABLE_PIN_MEMORY=1 |
| K の制約 | ヘッド再帰コスト(K 増で劣化) | 学習時 block_size(K = block_size - 1) |
| KV cache 予算 | 7.0 GiB のまま | 4.5 GiB に削減が必要 |
| 出力の正しさ | baseline と 99.55% 一致 | baseline と 99.62% 一致 |
結論: この環境では、MTP K=7 が「最も簡単で最高速(ほぼ同点)」です。 DFlash2 は外部 draft model を積むぶん受容率は高いものの、そのぶん iteration が重く、実測トップスピードは MTP とほぼ同点でした。追加依存ゼロで同じ性能が出るなら、運用コストで MTP が勝ちます。
DFlash2 を選ぶべきなのは「draft model の受容率がもっと高いモデルがある」「K を大きくしても draft が安い」など、MTP との差が出る条件が揃ったときです。
9. 起動スクリプト
2 つのコンテナを使い分けています(port 8000 / served name claude-opus-5 / API key sk-123456 は共通)。
9.1 MTP(推奨・デフォルト)
qwen38-vllm.sh の要点:
IMAGE="${IMAGE:-docker.1ms.run/vllm-openai:qwen38-verified}"
ENABLE_MTP="${ENABLE_MTP:-true}" # 2026-08-20 からデフォルト true
NUM_SPECULATIVE_TOKENS="${NUM_SPECULATIVE_TOKENS:-7}"
KV_CACHE_MEMORY="${KV_CACHE_MEMORY:-7516192768}" # 7.0 GiB(削減不要)
if [ "${ENABLE_MTP}" = "true" ]; then
VLLM_ARGS+=(--speculative-config \
"{\"method\":\"mtp\",\"num_speculative_tokens\":${NUM_SPECULATIVE_TOKENS}}")
fi
docker run $TTY_FLAG --rm \
--gpus "\"device=${GPU_DEVICES}\"" \
-v "${MODEL_DIR}:/models/Qwen3.8-27B:ro" \
... "${IMAGE}" "${VLLM_ARGS[@]}"
9.2 DFlash2(外部 draft model 版)
qwen38-dflash-vllm.sh の要点:
IMAGE="${IMAGE:-docker.1ms.run/vllm-openai:qwen38-dflash2}" # 検証済み image + PR #52816 overlay
DRAFT_DIR="/root/HuggingFaceCache/Qwen3.8-27B-DFlash2"
NUM_SPECULATIVE_TOKENS="${NUM_SPECULATIVE_TOKENS:-7}"
KV_CACHE_MEMORY="${KV_CACHE_MEMORY:-4831838208}" # 4.5 GiB(drafter 分を確保)
VLLM_ARGS=(
/models/Qwen3.8-27B
--served-model-name claude-opus-5
--tensor-parallel-size 4
--kv-cache-memory "${KV_CACHE_MEMORY}"
--attention-backend flash_attn
--speculative-config \
"{\"method\":\"dflash\",\"model\":\"/models/Qwen3.8-27B-DFlash2\",\
\"num_speculative_tokens\":${NUM_SPECULATIVE_TOKENS},\"attention_backend\":\"flash_attn\"}"
)
docker run $TTY_FLAG --rm \
--gpus "\"device=${GPU_DEVICES}\"" \
-v "${MODEL_DIR}:/models/Qwen3.8-27B:ro" \
-v "${DRAFT_DIR}:/models/Qwen3.8-27B-DFlash2:ro" \
-e VLLM_WSL2_ENABLE_PIN_MEMORY=1 \
... "${IMAGE}" "${VLLM_ARGS[@]}"
9.3 使い分け
# MTP(推奨・追加依存なし)
bash /root/myvllm/qwen38-vllm.sh
# DFlash2(外部 draft model を使う)
bash /root/myvllm/qwen38-dflash-vllm.sh
# 素の baseline(投機なし)に戻す
ENABLE_MTP=false bash /root/myvllm/qwen38-vllm.sh
まとめ
RTX 4090 × 4 という「データセンター向けではない」構成でも、投機デコードは普通に動いて 2 倍 出ました。そして今回は、当初の想定を覆す形で「外部 draft model を使う DFlash2 と、モデル内蔵 MTP がほぼ同点」という結果になりました。
- K は振って決める。 公式推奨値も、他所のベンチの値も、そのままでは当たらない
- DFlash2 の K は draft model の学習時 block_size から逆算できる(
K = block_size - 1) - どちらの方式も K=7 がピークだったが、背後にある制約は別物(学習ブロック長 vs ヘッドの再帰コスト)
- WSL2 は pinned memory / UVA が既定で無効。V2 model runner を使う機能はここで落ちる(
VLLM_WSL2_ENABLE_PIN_MEMORY=1) - Ada では attention backend を明示する(FA3 は選べない)
- draft の分だけ KV cache を削る。context か同時実行数を売ることになる
- 速くなるのは decode だけ。TTFT は改善しない
- 測定は必ず warm で(コールドスタートは JIT 込みで低く出る)
- 出力の正しさも確認する(今回 99.6% 一致・決定性あり)
個人的な感想として、投機デコードは「入れれば速くなる機能」というより、自分の環境で 1 回ちゃんと測る手間とセットの機能 です。設定を 1 つ間違えるだけで、2 倍のはずが 1.7 倍になったり、そもそも起動しなかったりします。逆に言うと、測りさえすれば数十分で決まる話でもありました。
参考リンク
- DFlash: Block Diffusion for Flash Speculative Decoding(arXiv:2602.06036)
- vLLM Speculative Decoding ドキュメント: https://docs.vllm.ai/en/latest/features/speculative_decoding/
- vLLM PR #52816(DFlash2 対応、Python/Triton のみ)