前回の記事(RTX 5070×2(12GB×2)で Qwen3.8-27B を動かす)の続きです。
同じ構成で「3人目のユーザから急にプロンプト処理が毎回57秒かかる」という現象を追いかけ、フラグ3つで 0.3秒 に直した話です。診断を途中で盛大に間違えたので、その過程も含めて書きます。
環境
| モデル | Qwen3.8-27B-Escha-W2(2bit重み・hybrid Gated-DeltaNet) |
| GPU | 12GB × 2(TP2) |
| ランタイム | sglang 0.5.19 + OSCAR INT2 KV |
| 文脈長 | 114,688 |
症状
66Kトークンの文脈を持つチェーンを3本、順に1回ずつ聞いて、それを3周します。2周目は1周目でキャッシュ済みなので速いはずです。
lap1(冷): 0.0% 62.6s
lap2: 0.0% 62.6s ← キャッシュが効いていない
lap3: 0.0% 62.6s
毎回フルの冷プレフィルが走ります。 エラーもログも出ません。本来なら約2秒です。
最初の診断(間違い)
hybrid SSM モデルは chunked prefill の境界ごとに mamba のチェックポイントを木へ寄贈します。上流に #36935 という issue があり、「mamba の LRU がパスの状態を浅い側から剥がすので、分岐マッチに必要な深い側から先に消える」と報告されています。
メトリクスを見ると mamba_evictable = 24 / 32。プールが埋まっているように見えました。これを「圧迫の証拠」と解釈したのが間違いの起点です。
mamba 側のレバーを3つ試して、全部効かなかった
| 施策 | lap2/3 ヒット率 |
|---|---|
基準(--chunked-prefill-size 4096) |
0.0% |
| CHUNK 4096 → 8192 | 12.4% |
| + PR #38000(被覆で間引く eviction) | 12.4%(変化なし) |
| + int8 チェックポイントプール(容量2倍相当) | 12.4%(変化なし) |
| チェーン数を 3 → 2 に減らす | 99.9% |
最後の行が答えでした。
3チェーン × 66,000 = 198,000 トークン
max_total_num_tokens = 148,058
KV プールに3本入らないだけでした。 mamba_evictable = 24 は単に3本ぶんの状態が入っていただけで、圧迫ではありませんでした。
mamba 側を調べるために深さ写像プローブ(プレフィクスを12.5%刻みで切って cached_tokens を見る)まで作りましたが、結果は「チェックポイントは各深さに作られている」「マッチも最深の有効ノードを拾う」で、mamba は全部正常でした。
教訓としては単純で、凝った機構を疑う前に、引き算ひとつで確かめられる構造的な上限を見ろ、ということです。sum(チェーン長) vs max_total_num_tokens の比較で済みました。
直し方:フラグ3つ。ただし3つ揃わないと成立しない
--mem-fraction-static 0.78 → 0.85 静的予算 +856MiB → KVプール 148,058 → 210,000
--max-total-tokens 150000 → 210000 上限の解除。単体では何も起きない
--chunked-prefill-size 4096 → 8192 mamba チェックポイント需要を半減
--max-total-tokens はサイズではなくキャップ
210000 を指定しても、--mem-fraction-static が 0.78 のままだと max_total_num_tokens は 148,058 のままでした。プールのサイズは静的メモリ予算から逆算され、このフラグは「それ以上大きくしない」上限にすぎません。実際に動かすレバーは --mem-fraction-static です。
CHUNK を上げないとプール拡大は逆効果になる
hybrid SSM では1チェーンあたり プールサイズ ÷ chunked_prefill_size 個のチェックポイント枠が要ります。
| CHUNK 4096 | CHUNK 8192 | |
|---|---|---|
| プール210K での必要スロット |
51 > 32 ✗ |
26 ≤ 32 ✓ |
プールだけ広げると枠が足りなくなり、hybrid モデルの prefix 再利用が崩れます。2つはセットです。
結果
| 指標 | 変更前 | 変更後 |
|---|---|---|
| 66K×3本の再訪 | 12.4% / 57秒 | 99.9% / 0.3秒 |
| 冷プレフィル(75,150tok) | 970 tok/s | 1,010 tok/s |
| decode(bs=1) | 51.9 tok/s | 51.9 tok/s |
| 長文プレフィル中の短文TTFT | 0.135秒 | 0.123秒 |
| 並列10 | エラー0 / 169W / 70℃ | エラー0 / 175W / 78℃ |
| 判別テスト(96K) | 17/20 | 18/20 |
--context-length |
114,688 | 131,072(後述) |
悪化した指標はひとつもありません。 追加した VRAM は起動時 +428MiB で、もともと遊んでいた分です。
「起動した」は「使える」ではない
途中で2回、起動は成功するのに最初の長文プレフィルで落ちる構成を踏みました。
Triton kernel 'chunk_gated_delta_rule_fwd_kernel_h_blockdim64' device-loaded
after serving started (free device mem: 0.00 GiB).
Pre-load it during engine init to avoid CUDA OOM.
RuntimeError: CUDA driver error: device not ready
Triton のカーネルは初回使用時に JIT で device へ載ります。起動時の VRAM 表示が出たあとの話なので、「起動時点では余裕がある」ように見えます。--int8-mamba-ckpt-size 64 は起動時 11,329/12,227 MiB で収まり、そのあと死にました。
起動検証には長文プレフィルを1本通すところまで含める必要があります。
実測したスロット単価も置いておきます。
- fp16 mamba スロット ≈ 38MB
- int8 チェックポイントスロット ≈ 23MB(fp16 の6割。半分ではない)
VRAM 崖の見分け方
この環境の崖はメモリ確保エラーとして出ません。GPU 使用率が 100% のまま、電力と温度が落ちます(仕事が流れていない)。
- 健全な全負荷: 97〜99% / 169〜175W / 70〜78℃
- 崖: 100% / 45W / 48℃
新構成の並列10は 175W / 78℃ でエラー0。変更前の 169W より電力が上がっています。より多くの実仕事をしている側の動きです。
計測で踏んだ罠2つ
1. --allow-auto-truncate は黙って末尾を捨てる
文脈長 114,688 のサーバに 180K のプロンプトを投げると、切り詰められた文書から答えようとします。最初の測定で「99.9%ヒット」という数字が出たのですが、全リクエストが同じ位置で切られて同一プレフィクスになっていただけでした。スクリプトに「プロンプトが上限未満であること」の assert を入れて再発防止しています。
2. 温まったサーバと冷えたサーバを比べる
最初の A/B で1件だけ差が出て「移植の影響か」と疑いましたが、こちらのベースラインを長時間稼働した本番で取っていたのが原因でした。両方を冷起動から測ったら完全一致しました。
追記:そのあと --context-length も上げた(114,688 → 131,072)
プールが 210,000 あるのに1セッションの上限が 114,688 では、セッション側が余らせています。131,072 に上げました。
131,066トークン(上限ぴったり)のプレフィル実測:
810.6 tok/s ・ 157W ・ 73℃ ・ 使用率100%
崖ではありません。 この環境の崖は「使用率100%のまま電力が45W級に落ちる」なので、157W は健全側です。ただし 114K のときの 1,010 tok/s より約20%遅いです。
遅くなる理由はプールとは別のバッファ
int2 の prefill は、キャッシュ済み prefix を attention に渡す前にdense へ展開します。
prefix_k, prefix_v = dequantize_prefix_kv(kv_pool, layer.layer_id,
prefix_indices, model_dtype)
int2(0.25バイト)→ fp16(2バイト)で 8倍。しかもシーケンス長に比例し、静的予算の外から確保されます。112K で約962MB です。
| 置き場所 | サイズ | |
|---|---|---|
| KV プール 210,000トークン | 静的予算の中 | 813 MiB |
| prefill の dequant バッファ | 静的予算の外・リクエストごと | 128K で約1GB |
ここが厄介で、--mem-fraction-static を 0.85 に上げてプールを広げると、このバッファが使う非静的の余白は逆に減ります。どちらも良い変更なのですが、同じ12GBを両端から引っ張り合います。
上限付近の空応答に注意
131,066トークンの入力を 131,072 の上限に対して投げると、生成する枠が残らず '' が返ります(finish_reason=stop)。切り詰めが max_tokens を差し引かない #5409 の症状です。
114K のときは上限が遠くて踏みにくかったのですが、128K にしたことで通常利用の範囲に入りました。
ホストメモリへ退避すればいいのでは?
--enable-hierarchical-cache は、溢れた prefix を破棄せずホストRAMへ退避します。「3本目が入らない」への素直な答えに見えます。
しかし hybrid GDN では今は使えません。
#39830(open)が 20/20 で再現しています。ホスト層から復元した prefix が「別のパスの声で」間違った出力を返す、という報告です。クラッシュも警告も無く、cached_tokens は満額のヒットを報告し、finish_reason=stop で終わります。
さらに --enable-int8-mamba-checkpoint とは併用不可で、起動時に明示的に弾かれます。
コードと計測スクリプト
フォークに置いてあります。起動スクリプト全文(パスはプレースホルダ)と、計測スクリプト一式が入っています。
| スクリプト | 用途 |
|---|---|
load_multi.py |
別々の長チェーン K 本の保持率。K を振るのが一番効く切り分け |
depth_probe.py |
プレフィクスを刻んで、どの深さまでキャッシュが生きているか写像 |
speed.py |
冷プレフィル tok/s・短文TTFT・decode tok/s・長文中の短文TTFT |
conc.py |
並列N。3秒ごとに VRAM/使用率/電力/温度をサンプリング |
undef.py |
使っているのに束縛が無い名前の検出(pyflakes 相当) |
おまけ:上流に差分を当て続ける話
この移植は「週単位で動く上流」に差分を当て続ける作業です。0.5.20 がこの計測の前日に出ました。リベースのコストを決めるのは4点でした。
1. そのクラスは本当に構築されるのか。 上流 PR #32129 は RadixCache にパッチを当てます。しかし 0.5.19 の registry.py は hybrid SWA も hybrid SSM も素のモデルも全部 UnifiedRadixCache に流します。RadixCache(params) は create_simulated() からしか呼ばれません。上流も #40313 でこの2クラスを削除する方針です。パッチを当てても何も動きません。 grep -rn "ClassName(" と factory を1回読めば済みました。
2. 3-way マージの「競合なし」は合格ではない。 きれいに当たったハンクが、新版で消えた API を参照したままのことがあります(req.req_pool_idx や req.kv_committed_len が req.kv 配下へ移動)。2件ありました。
3. import が通ることと、名前が解決することは別。 関数の中だけで使われる未 import の名前は、その関数が走るまで NameError になりません。19モジュールの import テストを全部通過したうえで、エンジン起動時に落ちました。
4. 変えた関数の churn を測る。 v0.5.19 → v0.5.20 で triton_backend.py は +403/−101、memory_pool.py は +1017/−324 と大きいのですが、再ターゲットした API(free_kv_row / free_segment / check_decode_capacity / _page_size_default など)は全部 0.5.20 に生存していました。つまり判断は持ち越せて、マージし直すだけです。高くつくのは判断の方なので、そこが再利用できるのは大きいです。
ひとつ注意点として、#39627 が Rust の tree core を既定にします。0.5.19 の SGLANG_UNIFIED_RADIX_TREE_CORE_BACKEND は python なので Python 側のパッチが効いていますが、これが入ったら env var を明示的にピンしないと黙ってバイパスされます。