125B MoE を 3090 3 枚で運用する vLLM編
前回の記事で Qwen Sparse Attention の性質を解説した。簡単に言うと 1 回の decode で読む KV は indexer_budget で一定になり、コンテキスト長には依存しないので KV キャッシュはホスト RAM にオフロードできて、コンテキスト長はボトルネックではなくなる。前回の記事では、ExLlamaV3 で実装して 3.14M まで到達したので、今回はそれを vLLM で再現する。
私が普段サービスとして立てているのは vLLM で、OpenAI 互換 API にクライアントがリクエストする構造である。
| コスト | BF16 | 最終的な置き場所 |
|---|---|---|
| ウェイト (body) | 約 250 GiB | 量子化して GPU 3 枚に 65.05 GiB |
| KV キャッシュ @ 262,144 tok | 6.94 GiB | ホスト RAM |
| PLE n-gram テーブル | 95.37 GiB | NVMe 上 |
表の上では正常に動作しそうだ。
実際、結果的に:
| 実測 | |
|---|---|
| GPU 常駐ウェイト | 65.05 GiB = 21.7 GiB x 3、body 4.205 bpw |
| KV キャッシュ | 1,067,300 トークン = 262,144 トークンのリクエスト 4.07 本分 |
| ホスト RAM | 約 63 GiB (うち 47.5 GiB は QSA の KV 用にページ固定した領域) |
| PLE n-gram テーブル | 95.37 GiB、ディスクから読む |
| decode (単発) | 80.0 tok/s。3.6k から 248k までフラット |
| decode (4 並列) | 合計 約 155 tok/s |
| prefill @248k | 3,701 tok/s |
| BF16 に対する mean KLD | 0.0149 nats、perplexity +0.46% |
を達成した。
1. このモデルの形
text_config より:
hidden_size 2560 / ブロック間の residual は 4 倍の 10240 (hc_count=4)
num_hidden_layers 48 layer_types = [linear x3, full] x 12
num_experts 512 num_experts_per_tok 10 moe_intermediate_size 640
num_attention_heads 24 num_key_value_heads 2 head_dim 256
indexer_budget 2048 indexer_compress_ratio 4 indexer_head_dim 128
ngram_size 3 split_ngram_parts 128 ple_layer_ids [2]
vocab_size 248320 max_position_embeddings 262144
モデルの特徴としては、
48 layerのうち 36 layerは KV キャッシュを持たない。 gated delta-net という linear attention で、コンテキストが伸びてもメモリは増えない。K/V を持つのは full attention (QSA) の 12 layerだけである。
** residual の幅は 2560 ではなく 10240。** hyper-connection がブロック間で residual を 4 本持つ。ブロックあたりのメモリ予測は全部 10240 で計算する必要がある。
layer 2 に 95.37 GiB の埋め込みテーブルがぶら下がっている。 vocab 埋め込みではなく、それとは別の n-gram テーブルで、[2500012, 160] の shard が 128 個。1 トークンにつき 1 回引く構造。
2. ビット配分を決める
VRAM 69.2 GiB から、KV とアクティベーションと CUDA graph のぶんを簡単に計算して残すと、ウェイトに使えるのは約 62 GiB程度。
配分は憶測で書きたくなかったので、既存の量子化モデルを参考にした。EXL3 の 4.05bpw_h6_ng6 は quantization_config.json に 74,395 module 分の bits_per_weight を持っている。Unsloth の GGUF UD-IQ4_XS はヘッダに 1,224 tensor の型がある。2 つを比較して分かったこととして:
- dense layer を 4bit にしている実装は無い。 EXL3 は 6、Unsloth は 8.5。私の元案は
linear_attn/self_attn/shared_expertを INT5 としていて、両者より下のビット数だった。差は 0.31 GiBで、このために、既存の 2 つと違うことをする理由は無い。 - router は両者とも量子化していない。 expert の選択を間違えるのは怖い。実際ここを量子化している人はみたことがない。
- hyper-connection では GGUF と EXL3 で量子化の度合いが違う。 EXL3 は FP16、GGUF は Q8_0。
- n-gram テーブルを BF16 で持っている実装も 1 つも無い。 EXL3 は 6bit trellis で 36.36 GiB、GGUF は IQ4_NL で 26.82 GiB。
採用した配分:
| 部位 | 方式 | bpw | GiB |
|---|---|---|---|
| routed experts | INT4 g128 | 4.125 | 58.01 |
linear_attn GEMM | INT6 g64 | 6.250 | 1.51 |
QSA q/k/v/o_proj | INT6 g64 | 6.250 | 0.44 |
| shared expert | INT6 g64 | 6.250 | 0.17 |
| hyper-connection | INT8 g64 | 8.250 | 0.62 |
embed_tokens / lm_head | INT8 g128 | 8.125 | 各 0.60 |
PLE key/value_proj、indexer | INT8 g128 | 8.125 | 0.05 |
router (mlp.gate) | BF16 | 16 | 0.12 |
| GPU 常駐計 | 4.21 | 62.14 |
experts が INT4 で、他はほぼ誤差
routed experts は GPU 常駐 125.75B のうち 120.8B、つまり 96%で、ここがモデルサイズの大体を決める。INT8 の experts は 114 GiB で論外なので、INT4で確定で、残りを考える必要がある。
INT4 g64 にすれば +0.125 bpw 上げられるが、+1.76 GiB 増える。group_size は本番の量子化を始める前に決める必要があって、48 ブロック回した後で足りないと分かったらやり直しは数日コースになるので、小さくすることで余裕を確保した。
実際に効いたのは hyper-connection だった
input_mix_weight_down [320, 10240] と up [10240, 320] は、 residual をいったん rank 320 にしてから混合する低ランクの行列だ。保存されているウェイトの 1.9% しかないうえに、低ランク側の量子化誤差は residual 全体に乗るし、27B にはこの構造が無くて勘が働かなかったから元案では BF16 のまま触らないことにしていた。
INT8 に落とすと VRAM が 1.193 → 0.622 GiB になるうえに、1 トークンあたりの読み出しが 0.63 GB 減る。BF16 のままだと、保存されているウェイトの 1.9% しかないのに、毎トークンの読み出しの 23% も支配する。dense を INT5 から INT6 に上げると +0.36 GB/token 増えるが、まあ元案よりは速くなる。
input_mix_weight_up は in_features=320 で、g128 だと 2.5 グループになって割り切れない。
なぜ TP ではなく PP なのか
hidden_size 2560、linear_num_key_heads 16、num_experts 512、shared_expert_intermediate_size 640、moe_intermediate_size 640は、どれも 3 で割り切れない。よって PP=3 / TP=1にする。
品質はどうだったか
見込みは mean KLD 0.010〜0.014 だった。AutoRound は EXL3 の trellis ではなく、スケール付きの整数量子化の仲間なので、参考にした曲線上の GGUF と NVFP4 を結んだ線から出した数字だ。
同じトレース・同じ測定スクリプトでの実測は 0.014888 nats、body 4.178 bpw
| body bpw | mean KLD | |
|---|---|---|
| EXL3 4.05bpw H6 NG6 | 4.05 | 0.0067 |
| 今回の構成 | 4.18 | 0.0149 |
| GGUF UD-IQ4_XS | 4.09 | 0.0165 |
| NVFP4 W4A16 | ~4.75 | 0.0100 |
同じビット幅の GGUF に 11% 勝ち、EXL3 に 2.2 倍負けた。この差はモデルの保存形式そのもの差であって、ビット配分の差ではない。ビットを動かして埋められる差ではないし、それは承知で進めた。こちらの取り柄は KLD ではなく、レイテンシとスループットのほうにある。
3. 実際に量子化する
計画では DDP=3、1 rank 1 GPU、iters=1000 だったが、どれも実用不能だった。
DDP が成立しない。 1 ブロックは 2.58B で、BF16 のウェイト 5.2 GB、fp32 の丸めパラメータ 10.3 GB、その勾配 10.3 GB。アクティベーションを載せる前に 25.8 GB で、24 GB のカードには入らない。よって device_map=auto で 1 プロセスを 3 枚でロードする必要がある。
iters は 1000 ではなく 200。 1 iter の実測が 12.9 秒。200 なら 48 ブロックで 34.6 時間、1000 なら 172 時間。流石に量子化だけで1週間超えはまずい。実際に本番では 56 時間 37 分かかった。
nsamples は 768 ではなく 384。 アクティベーションのキャッシュは nsamples x seqlen x hidden x 2 byte x 入出力の 2 本ぶん で、この hidden は residual 幅の 10240 であって 2560 ではない。768 だと計算上は 64 GB、実測から外挿したピークでは約 161 GBで、128GB のサーバーでは不可能である。
他には:
Qwen4ExpTextRMSNorm.group_size が消される。 AutoRound の apply_plan_to_model() は全 module を探索して、QuantizationScheme のフィールド名と一致する属性を delattr する。そのフィールドに group_size がある。一方 Qwen4Exp の RMSNorm は group_size をアーキテクチャのパラメータとして所持しており、10240 幅の residual を 2560 ずつ 4 分割して正規化するための値になっている。消えたままならブロック 0 で AttributeError が出る。None になると、10240 幅 1 本の正規化に変わって、出てくるモデルが全く別のモデルになる。 クラスに property を置いて実体を別名に逃がし、チェック用のスクリプトで数えるようにする必要がある。
メモリ配分スクリプトが experts を認識しない。 AutoRound は expert をこう判定する。
is_moe_expert = "expert" in name.lower() and isinstance(parent, nn.ModuleList)
ところが AutoRound 自身が expert を 1 つずつに展開するときに作るコンテナは _ExpertContainer で、nn.ModuleList ではない。結果として expert が 1 つも認識されず、512 experts の出力アクティベーションが全部同時に起こっている前提で足され、「layer出力 30.67 GB」、「card 0 に 62.34 GB 必要」と出て、card 0 が配分から丸ごと外れ、ブロックが card 1 と 2 に載って OOM する。その上に、余った分を載せるための additional_memory factor 7 が card 0 にしか加算されないので、配分が 10 : 45 : 45と不均等になるという問題も重なる。
| factor | peak VRAM {0, 1, 2} | 結果 |
|---|---|---|
| 7 (AutoRound 既定) | 12.0 / 22.6 / 22.4 GB | ブロック 1 で card 1 が OOM |
| 0 | 19.3 / 17.1 / 16.8 GB | ブロック 0-3 完走 |
参照 forward が全サンプルの出力を GPU に溜める。 バッチ出力をリストに積んで、ループを抜けてから CPU に移す実装になっている。 residual 10240 幅 x 384 サンプルだと 384 x 2048 x 10240 x 2 = 16.1 GiB が card 0 に載る。しかも low_gpu_mem_usage=True のときの配分はこの項を 0 にするので、予約されていない。
ブロック 1は 95 GiB のテーブルを抱えている。 ple_layer_ids は [2] だが、チェックポイント上の実体は layers.1.ple.* にある。このブロックの forward には n-gram の参照が要るので、本番の前に 128 shard を NVMe 上のフラットな memmap に展開し (5.7 分)、ディスクから直接読む埋め込みと、サンプル単位の gather キャッシュに差し替えた。キャッシュが無いと 1 回の実行で 1 億回を超えるランダム読みが発生する。
書き出し後の後処理
config_groupsが 4 グループともtargets: ["Linear"]で出てきた。 vLLM はtarget_scheme_map[target] = {...}と dict に出力するので、同じキーが 4 回上書きされて最後のグループしか残らない。イメージ内のCompressedTensorsConfigをそのまま call して実測したら、73,728 個の INT4 expert を含めて代表 26 layerのうち 16 layerが INT8 g128 と上書きされていた。ウェイト自体は正しく量子化されていて、metadataのみが間違えているだけなので書き換えるだけで十分だった。
4. ストックの vLLM ではそもそも起動しない
プロンプトが 1 回通るまでに 5 個、実用になるまでにさらに 4 個必要だった。
A — 量子化した embed_tokens / lm_head / hyper-connection が読めない
INT5〜7 の記事で書いたのと同じで、qwen4_exp は VocabParallelEmbedding を作るときに quant_config も prefix も渡さない。だから量子化した vocab tensor はロードできない。
hyper-connection にも同種の問題がある。vLLM は低ランク混合のウェイトと block-inject のウェイトを、input_mix_weight_down_block_inject という 1 つの Linear にまとめてしまう。このチェックポイントでは前者が INT8、後者が BF16 で、1 つの Linear には 1 つの量子化方式しか紐づけられない以上、両方は扱えないので、パッチで 2 つに分ける必要がある。
C — PP>1 の拒否が 2 箇所にある
62.14 GiB は 3090 1 枚に載らないので PP=3 が必須で、つまりこの拒否を外すのも必須。
vllm/model_executor/models/config.py <- engine 起動時
vllm/models/qwen4_exp/nvidia/model_state.py <- worker の model runner 構築時
片方を当てたら、もう片方に弾かれた。二重化は意図的で、1 つ目のコメントに “Checked again in Qwen4ExpModelState” と書いてある。
ガードの理由自体は正当で、gpu/model_runner.py が non-first の PP rank で model_inputs["input_ids"] = None にするのに対し、PLE の lookup は生のトークン ID を必要とする。PLE は decoder layer 1 にしか無く、get_pp_indices(48, 0, 3) == (0, 16) なのでlayer 1 は必ず first rank に載る。first rank には input_ids が来て、他の rank は ple is None で素通りするので、正しい修正は input_ids を rank 間で配ることではなく、PLE layerが実際にどの rank に載ったかを見て判断するようにガードを絞ることで、layer 1 を後ろに送るような構成は従来どおり拒否すればいい。
B — PLE テーブルはそもそも確保できない
vLLM の PLE offload は mmap ではない。テーブル全体を torch.empty(..., device="cpu", pin_memory=True) に載せて、GPU から直接読み込む実装になっている。RAM 125 GB で 95.43 GiB のページ固定を要求するとこうなる。
torch.AcceleratorError: CUDA error: out of memory
ngram_embedding.py:483 in allocate_embedding_weight
-> torch.empty(320001536, 160, dtype=bfloat16, device="cpu", pin_memory=True)
パッチは mmap バックエンドを足す。ファイルが既にあれば copy-on-write で開き、weight_loader を no-op にするので、起動時にチェックポイントから 95 GiB を読むこともしない。その副産物として、2 回目以降の起動が大幅に速くなる。
madvise の 1 行で NVMe の読み出しが 23 分の 1 になる
gather が本当に要るのは 1 トークンあたり 16 行 x 320 B = 5 KiBで、各行を 4 KiB ページに丸めても 64 KiBだから、最初の実測は 922 KiB/token とした。
差は readahead で、カーネルはページフォールトのたびに前後 128 KiB を読むが、完全にランダムな gather なので、読んだ分はほぼ全部捨てられる。
| NVMe read | /token | |
|---|---|---|
| madvise 無し (272 token) | 245.8 MiB | 922 KiB |
MADV_RANDOM あり (1,621 token) | 62.5 MiB | 39.5 KiB |
D — PLE に対して compressed-tensors が未対応
PLE テーブルは素の BF16 で、Qwen4ExpPLEEmbeddingMethod.from_quant_config() は None / ModelOpt / Fp8 しか受け付けない。なので、compressed-tensors のチェックポイントだと NotImplementedError でモデル構築が止まる。compressed-tensors はそこに Linear しか列挙しないので、チェックポイントの ignore にも載っていない。scheme map を直接見て、どの config group も PLE を名指ししていなければ Unquantized と判定させる必要がある。
E — upstream のバグ
PP>1 を通すと、今度は KV 確保の直前で rank 1 と 2 が落ちる。
File ".../vllm/v1/worker/utils.py", line 426, in allocate_kv_cache
group_id, group = next(
StopIteration
_project_kv_cache_groups_to_worker() はグローバルな KV グループを各 rank の担当layerに絞り込むが、内側の spec dict を作り直すのは「その rank が 1 layer以上持っているとき」だけで、空になったグループは layer_names=[] のまま、spec dict は全モデル分を保持する。 tensor 生成は layer_names ではなく spec dict のほうを回すので、その rank が持っていないlayerの KVCacheTensor を作ってしまい、allocate_kv_cache がその tensor の属するグループを見つけられない。
このモデルで空になるのは PLE の short-conv 状態で、構造上 rank 0 以外には存在しない。パッチは tensor 生成側を group.layer_names で絞るだけにしてある。絞り込み側を直すほうが危険で、空の UniformTypeKVCacheSpecs を作ると、別の場所の next(iter(...)) や max() が落ちる。
そして動いた
Loading weights took 31.86 seconds
Model loading took 21.71 / 21.67 / 21.67 GiB -> 合計 65.05 GiB
GPU KV cache size: 25,122 tokens
Application startup complete. (150 秒)
コンテキスト 8,192、8.5 tok/sで動いたが、実用性は皆無だ。
ウェイトに注目してほしい。計算上は 62.14 GiB のはずが、実測は 65.05 GiBだ。差の約 1 GiB/枚は Marlin / Humming のカーネルワークスペースと PLE の prefetch バッファである。vLLM はこういうところが怖い。余裕をあえてとらないと、ここで終わることになる。
5. 実用速度まで持っていく
5.1 QSA の K/V をホスト RAM に出す
QSA 1 layer・1 トークンあたりの K/V 行は 2 kv head x 256 次元 x 2 (K と V) x 2 B = 2,048 B。262,144 トークンなら 1 layer 512 MiB、12 layerで 6 GiB。コンテキストを全部読む attention なら、これを毎ステップ読むことになる。PCIe 4.0 x16 は約 25 GiB/s なので、そういうモデルのキャッシュをホストに置くと 4 tok/s になる。
幸い QSA は全コンテキストを読まない。indexer が pooled/圧縮されたキー (indexer_head_dim=128 / indexer_compress_ratio=4) でスコアを付け、indexer_budget=2048 個までを選ぶ。読むのはその行だけである。
選択 2048 x kv head 2 x 次元 256 x 2 (K と V) x 2 B = 1 layer 4 MiB
x 12 layer = 1 トークン 48 MiB
80 tok/s なら 3.9 GB/s で、PCIe の帯域の 6 分の 1 程度で、なんなら計算と重ねられる。GPU に残るのは 2 B の slot number と 64 B の pooled key だけで、1 layer 1 トークンあたり 66 Bであり、K/V 本体の 2,048 B に対して VRAM に置く KV は 31 分の 1 になる。どこを読むかを選ぶ処理自体は PCIe を通らない。
| prompt | prefill | decode |
|---|---|---|
| 3,671 | 1,064 t/s | 44.23 t/s |
| 121,147 | 945 t/s | 46.06 t/s |
| 248,667 | 940 t/s | 45.62 t/s |
3.6k から 248k まで decode が一切落ちない。 (これは 5.6 の前の数字。5.6 で速度は上がる。)
5.2 オフロードして初めて出てくるブロックサイズの無駄
オフロードした後も、取れる容量は 14 万トークン程度しか無い。KV プールのサイズを決めているのは別の場所だ。
vLLM はブロックプールを、全グループ中で最大の 1 ページに揃えて作る。
KVPROBE groups=39 bytes_per_block=3211264 available=720000000 num_blocks=224
37 groups: mamba, block=262144, 1 layer, page 3,211,264 <- これが決めている
1 group : QSA, block=1568, 8 名, page [100352, 3136]
1 group : PLE conv, block=4, 4 名, page 1024
QSA グループが実際に要るのは 4 layer x 1568 トークン x 66 B = 413,952 B なのに、3,211,264 B を確保する。これとは別に、_align_hybrid_block_size は attention の 1 トークンあたりのページサイズを model_config から計算する (2 head x 256 x 2 x BF16 = 2048 B) ので、そのlayerが今 66 B しか使っていないことを認識しない。オフロードしないときは QSA のページが 13.2 MB で mamba を上回るので、この無駄は出ない。
修正は、オフロード時に限って attention のブロックサイズをQSA のページが mamba のページと一致する値 (ここでは 12,144 トークン) まで上げて early return する。
この時点で、400 MB で 1,215,172 トークン。同じバイト数あたり 7.7 倍で、予測の 7.8 倍と一致した。
5.3 prefill はループの順番を入れ替える
毎ステップの読み出しが一定なのは 1 トークンについての話。prefill では数千トークンがそれぞれ勝手に 2048 個を選ぶので、素直に書くと「トークン数 x 選択数」の行を 1 行ずつランダムに PCIe 越しに読むことになる。
修正としては、ループの順番を入れ替える。コンテキストを一定サイズずつに区切って、その区間を VRAM 上の作業用バッファへ 1 回だけコピーし、全トークンをその区間のぶんだけまとめて計算して、softmax の途中結果をマージしながら次の区間へ進む。転送量がトークン数 x 選択数ではなく、文脈の長さで決まるようになる。
| 121k | 248k | GPU1 ピーク | |
|---|---|---|---|
| そのまま読む、chunk 256 | 945 t/s | 941 t/s | 23,696 MiB |
| 区間コピー、chunk 512 | 4,451 t/s | 3,701 t/s | 23,894 MiB |
| 区間コピー、chunk 1024 | 5,533 t/s | 4,723 t/s | 24,122 MiB (余裕 2 MiB) |
softmax の途中結果を区間ごとに持ってはいけない。 最初の版は 区間数 x トークン数 x heads x dim x 4 B を持っていて、作業用バッファを小さくするほど VRAM を食うという逆の挙動になり、カードの余裕が 2 MiB まで削れた。(acc, max, norm) を 1 組だけ持って、区間をまたいで順にマージしていくのが正しい形で、直したらピークが動かなくなった。
5.4 設定を行う
以下はどれか 1 つ外すと長文の prefill が落ちる。
--kv-cache-memory 550000000
--max-num-batched-tokens 512
--no-enable-prefix-caching
--gpu-memory-utilization 0.97
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0
--kv-cache-memory を指定すると、--gpu-memory-utilization は KV キャッシュのサイズに効かなくなる。 メモリの profiling ごと飛ばされるからである。
vLLM 自身が出す --kv-cache-memory の推奨値は長文で OOM する。 理由が 2 つ独立にある。1 つは cudagraph の二重計上で、peak_activation_memory が既に予測を含んでいるのに推奨値の計算で実測値をもう 1 回足している。だから VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0 にするだけで KV が +15% 増える。もう 1 つは、アクティベーションのピークの予測が、実際の長文 prefill の約半分しかないこと。予測 0.27 GiB に対して実測 約 0.53 GiB で、推奨値ちょうどで起動して 47,927 トークンを投げると、rank 0 が 20 MiB の確保に失敗して落ちる。
expandable_segments:True は最適化が無しだと約 100 MiB が「予約済み未使用」の断片化で消え、ピークがプロンプト長とともに徐々に上がっていく。入れると 4k から 130k まで完全に安定し、ウェイトの実測も 0.07 GiBだけだが減る。
prefix caching を切ると、prefill を何トークンずつ処理するかの決まり方が変わる。 有効なときは KV のブロックサイズが単位になるが、切ると --max-num-batched-tokens そのものが単位になる。だから prefix caching を切って既定の 2048 のままにすると、4k のプロンプトですら OOM する。512 なら 1568 に対してピークが 470 MiB 低く、速度はほぼ変わらない。256 は prefill が 32% 遅くなる。
PyTorch の caching host allocator は、ページ固定したメモリの確保を 2 の冪に切り上げる。--kv-cache-memory 560000000 だとホストプールが 1 layer 4.03 GiB になり、8 GiB に切り上がって、QSA 12 layerで 96 GiB をホストに要求する。550000000 だと 1 layer 3.96 GiB で 4 GiB に切り上がり、同じ構成が 48 GiB で済む。ホスト全体の RAM 使用は 111 GiB から 62〜63 GiB になった。
一番きついのは GPU0 ではなく GPU1、つまり中間の PP rankである。ウェイトは一番軽いのにピークは常に 40〜60 MiB 高い。これは送受信バッファを両方持つからであり、--kv-cache-memory は全 rank 一律なので、GPU1 のピークだけを見て決める。
5.5 4 並列が単発より遅い
| 同時実行数 | 1 本あたり | 合計 |
|---|---|---|
| 1 | 44.8 | 42.2 |
| 2 | 45.6 | 86.7 |
| 3 | 41.5 | 117.5 |
| 4 | 9.1 | 35.8 |
4 リクエストになった瞬間に 1 ステップが 25 ms から 110 ms になり、サーバ全体が単一リクエストより遅くなる。この原因は CUDA graph の capture サイズの設定ミスである。PP では、同時リクエスト数と forward のバッチサイズが別の数字になる。3 本まではステージごとに 1 本ずつ並べられることがあるが、4 本目を足すと、必ずどこかの forward に 2 本入る。サイズ 1 しかキャプチャしていないと、そういう forward は CUDA graph を使えず eager 実行になる。cudagraph_capture_sizes: [1,2,4] で解決して、合計 139〜146 tok/s になった。
--compilation-config '{...}' と --compilation-config.cudagraph_capture_sizes を両方渡すと、この nightly は JSON 全体を黙って捨てて mode を既定に戻す。1 個の JSON にまとめる必要がある。
5.6 45 tok/s よりも早くする
単発の decode が 45 tok/s で止まっていた。safetensors のヘッダを全部読んで、decode 1 ステップが実際に触るバイト数を数えると 5.83 GB/token (dense 4.62 + experts 10/512 の 1.22) で、3090 の 936 GB/s なら 160 tok/s が上限になる。45 tok/s は帯域の利用率にして 29%で、明らかに遅い。GPU がカーネルを実行していた時間は 21.9 ms のステップのうち 12.62 ms で、42% は何もしていない。
原因は PLE の lookup にあるこの 1 行で、
ids = flat_ids.to(device="cpu", dtype=torch.int64).numpy() # 同期 D2H
これが毎ステップ、CPU を GPU に同期させる。vLLM V2 + async scheduling は、CPU が GPU より先に走ってタスクを予約させておくことを前提にしている。それがなくなると、layerごとの eager 実行の区間 (1 rank あたり 17 個) で CPU 側が遅れた分が、そのまま GPU の空き時間になる。
環境変数で切り替えられる 15 行のパッチを当てて、起動実験を 5 通り行なった:
| run | PLE の扱い | cudagraph | tok/s |
|---|---|---|---|
| A (未変更) | 実際に gather する | PIECEWISE + breakable | 45.3 / 45.4 / 45.6 |
| D | 同期は残して gather だけ省略 | PIECEWISE + breakable | 45.5 / 45.4 / 45.7 |
| B | 同期ごと省略 | PIECEWISE + breakable | 73.9 / 74.9 / 75.4 |
| C | 同期ごと省略 | FULL_DECODE_ONLY | 80.8 / 80.7 / 80.8 |
| E | 実際に gather する | PIECEWISE、async scheduling off | 38.3 / 40.4 / 38.6 |
A と D が同じということは、mmap の読み・numpy の gather・H2D を全部合わせても 0.16 ms しかないということだ。gather を速くする価値はゼロである。D → B は同期を 1 つ消しただけで +29 tok/s。E は --no-async-scheduling が 15% 損なので使うなということだ。
修正は gather を forward の外に出すこと。Qwen4ExpModelState.prepare_inputs が forward の前に、アドレスの変わらない device 側のバッファを埋めておき、PLE layerはそれを読むだけにする。forward から CPU の仕事が消えるので cudagraph_mode: FULL_DECODE_ONLY が使えるようになる。
実測 45.4 → 80.0 tok/s (単発)、4 並列で 152〜155 tok/sとなった。 prefill は悪化せず (4 x 247,823 トークンが 378 秒 → 368 秒)、GPU ピークの動きは 2 MiB、decode 側の CUDA graph をキャプチャするコストは 1 rank あたり 0.06 GiB。
覚えておく価値があるのは、gather を forward の外に出したこと自体は、1 tok/s も速くしていないことである。PIECEWISE のままだと 45.1 tok/s で、layerごとの eager 実行の区間が CPU 側のクリティカルパスに残るからだ。速くしているのは全部 full graph のほうで、gather を動かしたのはそれを使えるようにするためだけである。
5.7 ここまでの全部と何の関係も無かったバグ
全部動いた状態で、日本語の出力が崩壊した。回答の途中から他の言語やコード片の塊と化していく。英語では起きない。これは generation_config.json が原因のようだ。
| BF16 原本 | 量子化リリース | |
|---|---|---|
temperature / top_k / top_p | 1.0 / 20 / 0.95 | 無し |
do_sample | true | 無し |
eos_token_id | [248046, 248044] | 248044 のみ |
AutoRound の書き出しはこのファイルを再生成して、サンプリングの既定値を落とす。vLLM の --generation-config は既定が auto で、get_diff_sampling_param() はそこから 6 つのキーだけを拾う。1 つも無ければ {} を返し、vLLM 側の既定値に落ちる。つまり 248,320 vocabを、丸ごとサンプリングすることになる。
6. 現在の構成と、できないこと
--pipeline-parallel-size 3 --tensor-parallel-size 1
--max-model-len 262144 --max-num-seqs 4
--max-num-batched-tokens 512 --no-enable-prefix-caching
--gpu-memory-utilization 0.97 --kv-cache-memory 550000000
--compilation-config '{"mode":0,"cudagraph_mode":"FULL_DECODE_ONLY","cudagraph_capture_sizes":[1,2,4]}'
VLLM_PLE_MMAP_PATH=<テーブルファイル> VLLM_QSA_KV_OFFLOAD=1
VLLM_QSA_KV_OFFLOAD_MAX_GIB=56 VLLM_QSA_KVO_ARENA=100663296
VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
おわりに
構造的な要素は 1 つだけで、そもそもこれ全部を可能にしているのは、QSA が 1 ステップの K/V 読み出しを indexer_budget で制限してくれるからだ。だから 1 トークンあたり 24 KiB のキャッシュを PCIe の向こう側に置いても、帯域の計算にコンテキスト長が出てこない。この記事の他の部分は、全部その性質を実際に動かすための配線作業でしかない。
vLLM のパッチはここに置いてある。対象は vllm/vllm-openai の 0.29.1rc1.dev47+gdc36fcce9。
https://huggingface.co/Minachist/Qwen3.8-Flash-Next-INT4-Mixed-AutoRound
Read this post in English: Running 125B MoE on Three 3090s - vLLM Edition