vLLM のマルチ並列のおかげで、急に「余裕がある」という感覚になりました。複数タスクをどう直列に流すかを設計する必要がなくなり、vLLM に投げ込めばいい。さすがに超長文リクエストを 4 本以上ぶん回すのは無理ですが、リクエスト間隔を調整すれば複数タスクを均等に流し込めます。そもそもデータの収集・統合・トリミング自体に時間がかかるので、クライアント側から満杯まで供給できていないというのが実情です。
さらに vLLM のマルチプロセス構造のおかげで、以前は 1 コアに集中していた CPU 負荷が明確に下がり、CPU の発熱も以前よりだいぶ良くなりました。これには構造上の理由があります。
- host-A のローカル 2 rank はそれぞれ独立した Ray worker プロセス(スレッドではない)で、自然にコアが分かれ、Python GIL の影響を受けない
-
VLLM::EngineCoreはスケジューリング / tokenize / 出力処理のメインループで、これも独立プロセス - 各 rank プロセスはさらに複数のサブスレッド(NCCL、CUDA stream コールバックなど)を派生させ、これらも別のコアに分散する
現在、2 台ともまだ十数 GB の VRAM が余っていて、何に使うかは決めかねています。嬉しい悩みですが、それはまた別の話。
では本題へ。
現行構成
vLLM のクロスノード TP=2 × PP=2 は、すでに本番の推論サービスとして稼働しています。5 つの CoT プロジェクトはすべてこちらを向き、ollama は 2 台とも全面停止しました。
モデルは Qwen3-Next-80B-A3B-Thinking-AWQ-4bit(重み 46G、総パラメータ 80B / アクティブ 3B、ハイブリッド線形アテンション MoE)。
4 枚のカードの割り当て:
| ホスト | GPU | 割り当て | 実測使用量 |
|---|---|---|---|
| host-A | 0 | vLLM rank0 | ~15.1G |
| host-A | 1 | vLLM rank1 | ~15.1G |
| host-B | 0 | vLLM rank2 | ~15.3G |
| host-B | 2 | vLLM rank3 | ~15.1G |
一、トポロジ
今回の最適化で最も効果が大きかったのがこれなので、最初に持ってきます。
最初は --tensor-parallel-size 4 --pipeline-parallel-size 1 を使っていました。動かして最初に感じたのは、とにかく遅い、時間を食う、ということでした。
テンソル並列は層ごとに all-reduce が要ります。Qwen3-Next は 48 層で、1 層あたり最低 2 回のリダクション(アテンション / 線形アテンションの出力射影で 1 回、MoE 出力で 1 回)。つまり 1 トークンが入口から出口まで通るのに、この 5GbE リンク上を 100 回近く往復することになります。
TP の通信パターンは「1 層あたり最低 2 回のグローバル同期」であり、前提にしているのは NVLink のような数百 GB/s のカード間インターコネクトです。5GbE の実測容量は 4.71 Gb/s、換算して 0.59 GB/s。おおよそ 3 桁の差があります。しかも本当に致命的なのは帯域ではなく、リダクション 1 回ごとの往復レイテンシです。96 回の往復がデコード経路に直列に挟まるので、トークンは 1 つずつしか絞り出せません。
ちなみに vLLM の並列化ドキュメントは、このシナリオについて明確に書いています。tensor_parallel_size はノードあたりの GPU 数に、pipeline_parallel_size はノード数に設定すること。ノード内の GPU 間に NVLINK 相互接続がない場合は、通信オーバーヘッドを下げるためテンソル並列ではなくパイプライン並列を使うべきである、と。一方で Qwen3-Next 公式 blog の quickstart が示しているのは -tp 4 です。
Qwen3-Next の config.json:
"num_hidden_layers": 48,
"full_attention_interval": 4, ← 4 層に 1 層だけが完全アテンション、残りは GDN
"num_attention_heads": 16,
"num_key_value_heads": 2, ← ここが肝
"head_dim": 256,
"hidden_size": 2048,
"max_position_embeddings": 262144,
KV head は 2 つしかありません。vLLM 公式ドキュメントいわく、モデルが H 個の kv-head を持つとき、tp_size をさらに上げて H を超えると、各 GPU 上の KV cache は tp_size / H 倍に複製される。
TP=4、H=2 なら複製係数は 2。4 枚のカードの KV 容量は、実質 2 枚分にしかなりません。
ここは一言多めに書いておきます。後でトポロジを変えるとなぜ効くのか、を決めているのがこの点だからです。複製係数が 1 を超えた時点で、head 次元はすでに「1 カードあたり 1 個」で床にぶつかっており、TP をいくら足しても下がりません。この状態で 1 カード 1 トークンあたりの KV 使用量を削れる次元は「層数」だけ残っている ── そして層を切るのはまさに PP の仕事です。逆に言えば、このモデルの KV head がもっと多ければ(たとえば 8 個)、TP=4 と TP=2×PP=2 のカードあたり使用量は正確に一致し、トポロジを変えても KV 面では 1 円も得しません。得られる分は、複製で捨てていた 1 份をまるごと取り戻したもの、ということです。
逆算して検証してみます。TP=4 のとき各カードは全 48 層のうち 12 層の完全アテンションを持ち(48 ÷ 4)、KV head は 1 個。fp8 なら 1 カード 1 トークンあたり:
12 層 × 2 (K,V) × 1 head × 256 head_dim × 1 byte = 6,144 bytes
起動ログ上、一番容量の厳しいカード(host-B GPU0)の Available KV cache memory は 1.37 GiB:
1.37 GiB ÷ 6,144 B ≈ 239K tokens
ログが報告する GPU KV cache size は 235K tokens。差分の数千は Mamba の状態ブロックが取っています。
TP=2 × PP=2 に変更
--tensor-parallel-size 2 --pipeline-parallel-size 2 に変更。ノード内の 2 枚で TP を組み、クロスノードのその 1 ホップを PP に任せます。
- TP=2、H=2 でちょうど割り切れる。複製係数 1、無駄が消える
- PP は層で切るので、各カードが持つのは 24 層(うち完全アテンション 6 層)だけ。カードあたり 1 トークンの使用量はそのまま半分の 3,072 bytes へ
- クロスノードを流れるものが「層ごとに 1 回の all-reduce」から「stage 境界ごとに 1 回の隠れ状態」に変わる。hidden_size = 2048、bf16 で 4 KB / token、全行程で 1 回だけ
スループット、クロスノード転送量、KV プールの 3 箇所すべてが反転しました。ウォーム状態でのスループット比較(output tok/s 合計):
| 並列数 | tp4 | tp2pp2 | 改善 |
|---|---|---|---|
| 1 | 41.9 / 43.6 | 81.0 / 81.9 | +91% |
| 4 | 125.3 / 124.3 | 216.0 / 216.9 | +73% |
| 8 | 212.6 / 212.2 | 334.0 / 336.2 | +58% |
| 16 | 305.2 / 303.1 | 463.5 / 465.3 | +53% |
| 24 | 325.8 / 323.7 | 544.0 / 542.7 | +67% |
クロスノード転送量:
| 並列数 | tp2pp2 合計 | クロスノード転送量 |
|---|---|---|
| 1 | 81.0 / 81.9 | 0.01 Gb/s |
| 4 | 216.0 / 216.9 | 0.02 Gb/s |
| 8 | 334.0 / 336.2 | 0.03 Gb/s |
| 16 | 463.5 / 465.3 | 0.04 Gb/s |
| 24 | 544.0 / 542.7 | 0.04 Gb/s |
並列 24 のときでもクロスノードは 0.04 Gb/s しか使っておらず、5GbE 実測容量 4.71 Gb/s の 0.8% です。
この数字は手計算で検証できます。デコード段階では PP が 1 トークンあたり 4 KB を送るので、544 tok/s × 4 KB = 2.2 MB/s ≈ 0.017 Gb/s。実測 0.04 との差分は、prefill 段階の隠れ状態(2K の prompt なら 8 MB 送る)と Ray の制御トラフィックです。オーダーは完全に一致します。
過去の記録と照らすと、TP=4 の並列 16 時点でのクロスノード転送量は 5.55 Gb/s(TX+RX 合計)でした ── 2 桁の差であり、片方向だけで 5GbE の実測上限に張り付いています。つまりリンクは全行程で埋まりきり、GPU は全行程でネットワーク待ちだったということです。
コンテキスト:プールは倍増したが、1 リクエストの上限は変わらない
複製係数が 2 から 1 に下がり、KV プールはそのまま倍になりました。TP=4 時代は 235K tokens。TP2PP2 後の起動ログが報告する満長並列は 2.02x @ 262K で、プールはおよそ 530K tokens です。
別の切り口でも合います。ハイブリッドモデルでは 1 つの attention block はバイト数の上で 1 つの mamba page と恒等になります(理由は後述)。TP=4 では 544 × 6144 B = 3.34 MB、TP2PP2 では 1072 × 3072 B = 3.29 MB。ページのバイト数は変わらず、プール内のページ数も変わっていません。変わったのは 1 ページに入るトークン数が倍になったことです。
ただしプールが大きいことと、1 リクエストが長くできることは別です。1 リクエストの上限はモデル本来の max_position_embeddings = 262K でハードに天井を打たれており、突破するには YaRN を開けるしかありません。試しましたが、代償として短コンテキストの品質が落ちます。
そこで 2 段構えにして、デフォルトはネイティブを通すことにしました:
| 段 | --max-model-len |
rope | 満長並列 | 短コンテキスト品質 |
|---|---|---|---|---|
| デフォルト | 262K | ネイティブ、YaRN なし | 2.02x | 影響なし |
LONG_CTX=1 |
524K | YaRN factor 2.0 | 1.01x | 劣化 |
つまり、増えた分の半分のプールは並列容量に変換されているということです。実際には 500K 程度です。
二、パラメータを 1 つずつ
--served-model-name local --host 0.0.0.0 --port 8100
--max-model-len 262144 # LONG_CTX=1 のときは 524288 + YaRN
--gpu-memory-utilization 0.92
--max-num-seqs 32
--max-num-batched-tokens 2048 # block size はこの値以下でなければならない
--kv-cache-dtype fp8
--enable-prefix-caching
--enable-auto-tool-choice --tool-call-parser hermes
--reasoning-parser deepseek_r1
--structured-outputs-config.backend xgrammar
--structured-outputs-config.disable_any_whitespace true
コールドスタートは約 70 秒でレディ(重みが page cache に乗っている状態、Thinking 版の実測 init engine 70.33s)。
--kv-cache-dtype fp8:効くのは 1/4 の層だけ
KV cache を FP8 に量子化します。ただしこのモデルでは注意が要ります。48 層のうち完全アテンションは 12 層だけ(full_attention_interval=4)で、残り 36 層は GDN 線形アテンション。使うのは固定サイズの再帰状態で、KV cache を通りません。
つまり fp8 が効くのは 1/4 の層だけです。救いはこの 12 層の head_dim が 256 あることで、1 トークンあたり(KV head 2 個の合計):
bf16: 12 × 2 × 2 × 256 × 2 = 24 KB / token
fp8: 12 × 2 × 2 × 256 × 1 = 12 KB / token
トポロジ、fp8、プレフィックスキャッシュ ── この 3 太刀を合わせて、コンテキストを 262K まで引き上げています。
残り 36 層の状態は別系統のスイッチが管轄します。--mamba-cache-dtype(conv + ssm 状態)と --mamba-ssm-cache-dtype(ssm のみ)です。これらは --kv-cache-dtype とは完全に独立していて、しかも決めているのは mamba page のバイト数 ── つまり後述の attention block size という数字の分子にあたります。
--max-num-seqs 32:並列上限はここ
プール ~530K tok ÷ 実測平均 2K tok/リクエスト ≈ 250+ リクエスト分の容量
max-num-seqs = 32 ← 上限はここ。容量ベースで見ればプールは半分以上空いている
--enable-prefix-caching:このモデルでは自動で切られるので、明示的に戻す
vLLM V1 はグローバルにはプレフィックスキャッシュがデフォルト有効ですが、このモデルに対しては自動でオフになります。ハイブリッドアーキテクチャ(GDN 線形アテンション + Mamba)の状態はそもそも block 単位で再利用できないので、おそらくそれを理由に保守的に切っているのでしょう。
--enable-prefix-caching を明示的に渡せば有効になり、ヒット率は 38〜44%。渡すと vLLM は Mamba に対して自動的に cache align モードを選びます(このモードの Mamba 層サポートが実験的であることもログで告知されます)。5 つのプロジェクトが同じ system + 用語集を繰り返し投げるので、このプレフィックスは安定してヒットします。
起動ログにはもう一つ副産物があります。mamba page size に揃えるため、attention block size が 544 tokens まで押し上げられるのです(通常は 16、34 倍の粗さ)。この数字は適当に決まったものではなく、1 本の硬い不変条件から算出されています ── vLLM はアテンション層の page size が mamba 層の page size 以上であることを保証し、満たさなければ attention block size を引き上げる。アテンション page のほうが厳密に大きい場合は、逆に mamba page をパディングして両者を完全に一致させる、というものです。
したがって 1 つの attention block は 1 つの mamba page と恒等になります。これが「4 並列でプールの 53% を食う」の正体です ── 走っているシーケンスはまず 1 ページを丸ごと取り、その 1 ページが 544 トークン相当のアテンション KV にあたる。プレフィックスキャッシュの有無とは関係ありません。
ブロック粒度 544 にはもう一つ帰結があります。1 block より短い prompt はヒット率が恒久的に 0 になる、ということです。このモデルにおいてプレフィックスキャッシュが効くのは、長い system prompt のシナリオだけです。これは私の環境固有の話ではなく、vLLM 側に同じログ・同じ結論の issue(#40696)があり、そこには 528 のブロック粒度で 552 token ≈95%、979 token ≈54% という対照データが載っています。
TP=2×PP=2 に切り替えたあと、attention block size はすでに 1072 まで上がっています。この倍増は上記の不変条件に押されたものです。mamba page のバイト数は変わらない一方、1 トークンあたりのアテンションバイト数が PP によって半分になった(6144 → 3072)ため、attention page を mamba page 以上に保つにはトークン数を倍にするしかない、という理屈です。
プレフィックスキャッシュは block 粒度で再利用するので、こちらにも同じ問題が出ます。実測では、~600 tok のプレフィックスを 3 回連投してヒット +0、~3000 tok でヒット +4288(ちょうど完全な 2 block × 2 回ヒット分)でした。
2 つのパラメータはセットで渡さないといけない
--structured-outputs-config.backend xgrammar
--structured-outputs-config.disable_any_whitespace true
backend のデフォルトは auto ですが、disable_any_whitespace のバリデータが受け付けるのは xgrammar / guidance だけです。後者だけを渡すと起動時に pydantic に弾かれます(実測で StructuredOutputsConfig(backend='auto', disable_any_whitespace=True) はそのまま ValueError、outlines も同様に拒否)。
さらに、ドット展開のブール型サブパラメータは明示的に true を与える必要があります。値を与えないと、pydantic は次のパラメータをその値だと解釈します。
vLLM 0.25.1 では guided decoding 関連の設定が StructuredOutputsConfig という dataclass に取り込まれ、独立した flag ではなくなりました。そのため CLI ではドット展開のサブパラメータで渡します。古いドキュメントにある --guided-decoding-backend xgrammar はもう使えません。
ツール呼び出し
--enable-auto-tool-choice は tool_choice="auto" というモードを開き、モデルが自分で吐いたツール呼び出しを parser に解析させます ── ツールを呼ぶかどうかを決めるのはモデルであって、サーバ側ではありません。--tool-call-parser hermes は、モデルが吐く <tool_call>{...}</tool_call> を OpenAI 形式の tool_calls フィールドへ解析します。
parser は hermes でなければならず、qwen3_coder / qwen3_xml ではありません。後者 2 つは Qwen3-Coder の XML 形式に対応するもので、本モデルの chat_template は <tools> + <tool_call> という Hermes スタイルです(chat_template.jinja を一字一句確認済み)。Qwen の公式ドキュメントも、Coder 以外の Qwen3 には hermes を指定しています。
実測で動作確認:「東京の天気」+ get_weather ツール 1 つ → {"city": "東京"} が解析され、finish_reason: tool_calls。
ただし、外部の信頼できないコンテンツを扱うタスクではツール呼び出し能力を切るべきです。素材の中にモデルを誘導してツールを呼ばせる注入テキストが潜んでいる可能性があります。信頼できない入力 + ツール能力 = 受け入れられない組み合わせです。ここはコンテンツ中の注入タグの処理まで考えるべきですが、今のところはこのままにしています。
思考予算
"thinking_token_budget": 3000 はリクエストボディのトップレベルフィールドです(options の中ではない)。予算を超えると logits 層で </think> を強制的に注入して思考を締め、本文へ移行させます。ハード截断ではありません。
テンプレートは <think> を prompt の末尾にあらかじめ置いており、モデルの出力ストリームには閉じ側の </think> しか現れませんが、これはカウントに影響しません。v1/sample/thinking_budget_state.py の _init_state_entry が走査しているのは prompt_tok_ids で、prompt 内に <think> があり、その後ろに </think> がなければ in_think=True / continue_thinking=True をセットします。
このフィールドにはサイレントに無効化される経路が 2 つあります。踏んでもエラーにならず、ただ効かないだけなので、先に記録しておく価値があります。extra_args に入れて渡すと捨てられること、そして MTP 投機的デコーディングを有効にしていると無視されていた時期があること(0.21.0 で修正)。私は投機的デコーディングを使っていないので、押さえるべきは「トップレベルフィールドで渡すこと」の 1 点だけです。なお公式ドキュメントが Qwen3 向けに示している例は --reasoning-parser qwen3 に --reasoning-config を合わせて reasoning_start_str / reasoning_end_str を明示するものです。ここで deepseek_r1 を使っているのは、両者の思考マーカーが同じ <think> / </think> だからで、実測でカウントは正常でした。
しかし、ここには巨大な落とし穴があります。
たとえば vscode copilot 経由で「こんにちは」と入力しただけで、モデルが実際に受け取っている文字数は 100K に達します。内訳は以下の通り:
| 部分 | ロール | 文字数 |
|---|---|---|
| システムプロンプト | system | 20,967 |
| ユーザーメッセージ(エディタのコンテキスト / 選択コードなどを含む) | user ×2 | 2,814 + 726 = 3,540 |
| ツール / 関数定義(63 個、ファイル編集・ターミナル・検索などの agent ツールの schema) | tools | 78,402 |
| 合計 | ≈102,909(リクエストボディ全体の JSON は 103,757 文字) |
実測の prompt_tokens は 26,126。逆算すると平均 3.94 文字/token です。
この一連の動作で、このデータの prompt_tokens は 26K、モデルの思考はおよそ 800 token。一見すると十分に見えます。
ですが実際には足りないタスクがあります。業務によってこの数字は 8000〜12000 のあたりで揺れ、ときに 20000 を超えます。足りるだけ与えないと思考が煮詰まらないまま、タスクが終了してしまいます。したがってタスクごとに配分する必要があり、かつ content に残すトークンも確保しなければなりません。そうしないと出力が 0 になり得ます。タスク計画のトークンは、両者の合計より大きくなければならない、ということです。
ほかにも問題は山ほどあります。たとえば
第一に、モデルがときどき長時間にわたって同じところを行き来して思考し、結果を返さない。 1 周失敗したらまた 1 周やり直す。しかも自分が失敗したことに気づいていない ── すでに出した結論を再びコンテキストに入れられないので、その場で同じ実行を繰り返します。リトライは毎回まったく新しいステートレスなリクエストなので、前の周の結論が入っていないのは当然で、結論が保持されません。次のリトライに前回の結果をどう載せるか。
第二に、5 つのクライアントがそれぞれ素の状態でモデルに直結している。 タイムアウト設定を各自が持ち、文字列バリデーションを各自がやり、注入対策の prompt を各自が実装し、リトライのバックオフを各自が書く。統一したほうが良さそうです。となると中間サービスが要ります。転送、認証、統一バリデーション、統一リトライ。さらに think ストリームの中身を識別し、結果に応じて次回の思考プロセスを制御できる必要がある。ロングコネクションが要り、taskID が要り、2 回の呼び出しのあいだ状態を覚えている必要がある。
第三に、タスクごとのスケジューリングとツール呼び出しをどう組み合わせるか、入出力をどう規格化し、下流のコンシューマに統一されたインターフェースとメッセージコードを提供するか、等々。
まだ整理できていないことが多すぎます。
この先はもう、当初の目的からズレてきている気もします。私の元々の目的は、ローカルモデルを動かすことだけだったのですが……。
AI は軍馬のようなものです。馬は全力で前へ駆けていく。そしてハーネス(harness)を付けるのは、LV0 のプレイヤーです。