12GB の VRAM に 27B の dense モデルを載せて複数人で使っていると、「誰かが長い文書を投げている間、他の人の短い質問がいつまでも返ってこない」という状態になります。この記事は、その原因を実測で切り分けて sglang のスケジューラに手を入れた記録です。
結論から書くと、vLLM から移植すべきものは何も無く、詰まっていたのは「待ち行列から誰を入れるか」だけでした。そして最後に正直な話として、体感は数字ほどには良くなっていません。
前提
- GPU 1枚、VRAM 12GB
- dense 27B(Escha-W2 系、Qwen3.8 ベース)を INT2 KV キャッシュで載せている(このKV量子化が無いと文脈長が取れない)
- 推論エンジンは sglang 0.5.19 ベースのフォーク
- 利用者は数人。長文を投げるエージェントと、短い質問を投げるチャット UI が同居する
この構成を選んでいる理由は単純で、VRAM が足りないからです。INT2 KV と mamba ハイブリッドのキャッシュ戦略が使える環境が限られており、現状は sglang に依存しています。
症状
長文(13万トークン)の prefill が走っている最中に短い質問を投げると、最初のトークンが返るまで 5.80 秒。4セッション同時だと 109.7 秒かかっていました。
最初に捨てた仮説
「vLLM の PagedAttention と Continuous Batching を持ってくれば直る」と考えていました。これは間違いでした。ログを見ると、どちらも既に動いています。
Prefill batch, #new-seq: 2, #new-token: 4126
4126 = 4096 + 30 です。長文のチャンクと短文のプロンプトが、同じ forward に既に同居しています。ページ管理も page_size: 1 のトークン単位で、vLLM のブロック単位より細かい。断片化は構造上起きません。
つまり「混ぜる能力」は最初から在りました。足りなかったのは「誰をどれだけ入れるか」です。
原因 1: 容量で弾かれた要求の後ろが検査されない
これが 109 秒の主犯でした。デバッグログを仕込んで受理の棄却理由を出したところ、こうなっていました。
res=NO_TOKEN need=70945 rem_total=68534 rem_chunk=6144 chunked=2/8 can_run=2
読み方はこうです。
-
need=70945… これから取る1チャンクではなく、プロンプト全長で判定している -
rem_total=68534… 残り容量。7万トークンは入らないので棄却 -
chunked=2/8… 同時 prefill の枠は6本も空いている - そして棄却すると
breakする
待ち行列は先着順に走査されるので、7万トークンの要求が入らないと分かった時点で走査が終わり、その後ろに並んだ30トークンの要求は検査すらされません。プールには 68,540 トークン空いているのに、です。
対処は、容量棄却で打ち切らずに後続を一定数まで見るようにしただけです(上限16本)。厳密な先着順は崩れますが、109 秒が 5.49 秒になりました。
原因 2: 刻みが粗すぎる
もう一方の原因は単純な算術でした。実測すると prefill は約 1,200 tok/s しか出ません(内訳の 55% がテンソル並列の AllReduce)。1パス 4096 トークンなら 3.4 秒かかります。
走行中の forward は中断できないので、後から来た要求の待ち時間は必ずこうなります。
待ち時間 = 進行中パスの残り + 自分が乗るパスの時間
これは vLLM でも同じ下限です。効くノブはチャンクの粒度しかありません。
粒度を振って実測した結果です。
| 1本あたりの上限 F | 短文の初トークンまで | 長文の prefill |
|---|---|---|
| 4096 | 6.04s | 1,276 tok/s |
| 2048 | 2.57s | 1,276 tok/s(劣化ゼロ) |
| 1024 | 1.80s | 1,216 tok/s(-5%) |
| 512 | 0.87s | 1,098 tok/s(-14%) |
| 409 | 0.56s | 1,006 tok/s(-21%) |
2048 までは無料でした。そこから下は、パス数が増える分だけ長文が遅くなります。
入れた仕組み
予算の均等配分
固定値で刻むのではなく、その時点で prefill 中・待機中の本数で予算を割ります。
上限 = min(F, チャンク予算 / (prefill中 + 待機中の本数))
1本しかいなければ予算を丸ごと使うので遊びが出ません。新しいセッションが来た瞬間に全員の取り分が自動で縮みます。実際、単独なら 8192、2本同居なら 409×2 = 818 という形でパスが構成されます。
並列数から逆算する
F を直に書くと、並列数の設定と二重管理になって必ずズレます。そこで並列上限から逆算するようにしました。
N = 同時実行上限 × 2
F = チャンク予算 / N
同時実行上限を変えれば取り分も自動で追従します。起動時にこう出ます。
Prefill concurrency N=20: long_prefill_token_threshold=409
結果
冷えた状態の13万トークン prefill 中に短い質問を投げたときの、最初のトークンまでの時間です。
| 変更前 | 変更後 | |
|---|---|---|
| 単独セッション | 5.80s | 0.56s |
| 4セッション同時 | 109.7s | 1.12s |
| 長文の prefill(4並列) | 1,289 tok/s | 1,200 tok/s(-6%) |
| 長文の prefill(単独) | 1,276 tok/s | 1,006 tok/s(-21%) |
同居するセッションが増えるほど1パスが太るので、並列時のスループット劣化は -6% に収まります。
正直な話: 体感はこの数字ほどではない
ここが一番書いておきたい部分です。
上の数字は合成した短いプロンプトを推論サーバへ直接投げて測ったものです。実際の利用では、プロキシを経由し、RAG の文脈が付き、他のセッションと並走します。実際に使ってもらった感想はこうでした。
- 3セッション並列のときは確かに速くなった
- ただし待つときは待つ。デコードが詰まることもある
- 「前よりはましなのかも」という程度
さらに、単独で長い文書を投げた場合は逆に遅くなります。誰も待っていないのに細かく刻むので、その要求自身の prefill が -21% 遅くなり、それがそのまま初トークンまでの時間に出ます。実際、約3万トークンの要求で 20 秒台という報告が出ました。私が最適化したのは「他人の長文の裏で来た短文」であって、長文本人の待ち時間は悪化させています。
計測を短文の初トークンだけで見ていたのが設計上の誤りでした。デコード中のトークン間隔(他セッションの生成が何倍遅くなるか)を一度も測っていません。「デコードが詰まる」という体感はおそらくそこで、prefill 1パスごとに decode を何ステップ回すかという別のノブが残っています。ここは次の宿題です。
数字が良くなったことと、使って速いと感じることは別物です。合成ベンチだけで完了と判断してはいけない、という当たり前の話を改めて踏みました。
切り戻せるようにしておく
全部、環境変数で元に戻せるようにしてあります。
- 非競合時の上限を外す(単独の長文を全速に戻す)
- 追い越しを無効化して厳密な先着順に戻す
- 並列数からの逆算を止めて値を明示する
- 受理の棄却理由と予算内訳を出す診断ログ(既定オフ)
特に追い越しは先着順を壊すので、いつでも戻せることが条件でした。
そして vLLM の話
ここまで sglang のスケジューラを触ってきましたが、この構成を選んでいるのは VRAM が足りないからです。12GB に 27B を載せるために INT2 の KV 量子化に依存していて、それが動く環境が限られている、というだけの理由です。
今回分かったとおり、アーキテクチャ上の優位は sglang 側にも vLLM 側にも無いと思っています。ページ管理も連続バッチングも1チャンク内の複数セッション混載も、両方に在る。違ったのは受理順という実装上の一点で、それは直せるものでした。
なので、**Escha-W2 が vLLM で同じ品質・同じメモリ効率で動くようになったら、素直に乗り換えるつもりです。**エコシステムの厚みと、上流に載り続けることの価値は大きい。今のフォーク運用は、上流に無い機能を必要としている間の一時的な状態だと考えています。
まとめ
- vLLM から移植すべきものは無かった。ページ管理も混載も既に在る
- 詰まっていたのは受理順。容量で弾かれた要求の後ろが検査されず、30トークンの質問が109秒待たされていた
- チャンク粒度は 2048 までなら無料で細かくできる。それ以下はスループットと交換
- 数字は大きく改善したが、体感はその数字ほどではない。合成ベンチだけで判断してはいけない
- デコード側の詰まりは未計測。次の宿題