はじめに
前回、同じマシン(RTX 4090 24GB × 4枚 / WSL2 + Docker)で Qwen3.8-27B を vLLM で動かす記事 を書きました。今回は 同じチェックポイント・同じ4枚・同じポートのまま、エンジンだけ SGLang に差し替える 話です。
先に結論を4つ書いておきます。
- 素の decode は SGLang のほうが速い。 同一マシン・同一チェックポイント・投機デコードなしで、vLLM が 29.4 tok/s、SGLang が 約 40 tok/s でした。約 1.35 倍です
-
代わりに KV プールは小さくなる。 実測 281,143 トークン(vLLM は
--kv-cache-memory固定で 440,673)。131,072 での同時実行は 3.36x → 2.14x に落ちます。つまり 速度を取るか同時実行を取るかの選択 になります -
SGLang のメモリモデルは vLLM のそれではありません。
--mem-fraction-staticは CUDA graph を含みません。そして 24GB カードで最初に効いてくるのは KV ではなく GDN(Gated DeltaNet)の状態プール です。ここは「リクエスト数 × スロット数」で決まり、コンテキスト長には一切依存しません -
そして今回いちばんの地雷。 この WSL2 + PCIe-only の箱では、SGLang が TP>1 で無条件に武装する multimem all-gather のせいで rank 0 が SIGFPE(exit code -8)で即死します。 Python の
try/exceptでは絶対に捕まりません。回避策はsitecustomize.pyを 1 枚差し込むことでした(第4節)
なお SGLang 公式 cookbook の Qwen3.8-27B ページには、Ada(SM89)の verified cell がありません。 検証済みとして載っているのは H200 / RTX PRO 6000 / RTX 5090 / DGX Spark / GB300 です。以下の設定は cookbook の「幾何学」(層構成とメモリ式)から自分で導いたもので、検証済みレシピの引き写しではありません。 そのつもりで読んでください。
同じマシンでの検証記事はこれで4本目になります。
- 1本目:Muse Glimmer 30B
- 2本目:Fable-Fusion-711(Qwen3.6-27B ベース)
- 3本目:Qwen3.8-27B / vLLM
- 番外:Qwen3.8-27B の投機デコード(DFlash2 vs MTP)
1. なぜ SGLang を試したか
vLLM 側の実測で、この構成の性格ははっきりしていました。
- decode は帯域律速で、バッチングがほぼタダで効く(同時 1→8 で TPOT +12%)
- 一方で 1 ステップあたり約 17 ms が allreduce とカーネル起動のオーバーヘッド。理論上限 72 tok/s に対して実測 31 tok/s
つまり 単発の速度を上げたいなら、削るべきはステップあたりの固定オーバーヘッド です。投機デコード(MTP / DFlash2)はまさにそこを狙う手段でしたが、別記事 で書いたとおり、長コンテキストでは利得がほぼ消える という追試結果になりました。受容長は伸びるのに 1 iteration が膨らんで、結局ベースライン相当まで落ちます。
そこで「エンジンそのものを替えたらどうなるか」を測りに行った、というのが今回の動機です。SGLang は overlap scheduler と radix cache まわりの作りが vLLM と違うので、同じハードで違う答えが出る可能性がありました。
結果は冒頭に書いたとおりで、素の decode は速くなり、KV は狭くなりました。 どちらが正解かは用途次第です。
2. 検証環境
| 項目 | 内容 |
|---|---|
| GPU | NVIDIA GeForce RTX 4090 24GB × 4 |
| GPU 接続 | PCIe のみ(NVLink なし、P2P 非対応) |
| OS | Windows + WSL2 (Ubuntu) |
| コンテナ | Docker(nvidia runtime 有効) |
| イメージ | lmsysorg/sglang:dev-qwen38-27b-dflash2 |
| torch / triton | 2.13.0 / 3.7.1 |
| FlashInfer | 0.6.17 |
| モデル |
Qwen/Qwen3.8-27B(BF16、チェックポイント 51.75 GiB) |
| chat template | froggeric/Qwen-Fixed-Chat-Templates v22.2 |
前回・前々回とまったく同じ物理マシンです。
2.1 イメージは consumer カード向けタグを使う
cookbook の rtx5090 / rtx6000 / dgx-spark セルが参照している dev タグを使いました。
docker pull docker.1ms.run/lmsysorg/sglang:dev-qwen38-27b-dflash2
lmsysorg/sglang:latest でも起動はしますが、Qwen3.8 の GDN カーネルと DFlash2 の投機デコードパスが揃っているのはこちら です。GDN や spec decode が怪しい挙動をしたら latest に戻して切り分けます。
なお国内から Docker Hub が細い場合は、いつもどおり docker.1ms.run 経由で引いています。
2.2 chat template は必ず差し替える
vLLM のときと同じ話ですが、SGLang は chat template を tokenizer_config.json から読みます(froggeric のインストーラはこの文字列も同期してくれます)。
素の Qwen3.8 テンプレートには 3 つ問題があります。
-
enable_thinking=falseでクラッシュする -
reasoning_effortがxhigh固定でハードコードされている - 空の
<think>ブロックが履歴に混入して、以降のターンを汚染する
起動スクリプトに検出を仕込んであります(第6節)。
3. SGLang のメモリ設計 — vLLM の感覚は捨てる
ここが今回いちばん時間を使ったところです。--mem-fraction-static を --gpu-memory-utilization と同じものだと思うと、必ず事故ります。
3.1 プールは 2 つある
Qwen3.8-27B は 64 層のうち 48 層が Gated DeltaNet、16 層が Gated Attention です(前回記事の 1.4 節と同じ)。したがってメモリは 2 系統に分かれます。
(a) KV キャッシュ — 16 層ぶんだけ、トークン長に比例
16 層 × 4 KV heads × 256 head_dim × 2 (K/V) × 2 byte = 64 KiB / token
TP=4 なので 1 枚あたり 16 KiB / token
(b) GDN 状態プール — スロット単位、トークン長に非依存
48 層 × (48 × 128 × 128 × 4 byte + 10240 × 3 × 2) = 146.8 MiB / slot
TP=4 なので 1 枚あたり 36.7 MiB / slot
float32 の場合の値です。bfloat16 にすると半分(74.8 MiB → 18.4 MiB/枚)になります。
3.2 「1 リクエスト = 1 スロット」ではない
ここが罠でした。 SGLang は radix cache の戦略によって、1 リクエストあたり複数のスロットを掴みます。
--mamba-radix-cache-strategy |
スロット数 S | 備考 |
|---|---|---|
extra_buffer(既定) |
5 | 低レイテンシ |
extra_buffer_lazy |
4 | 少しレイテンシを払って状態プールを 1/5 返してもらう |
no_buffer |
3 | overlap scheduler が黙って無効化されます(警告は出ます) |
| radix cache 自体をオフ | 1 | — |
つまり 固定すべき状態プールは --max-running-requests × S です。既定の extra_buffer で --max-running-requests 8 なら 40 スロット、1 枚あたり約 1.43 GiB になります。
--mamba-full-memory-ratio に平均リクエスト長から推測させることもできますが、OOM するのはこの数字なので、--max-mamba-cache-size で明示的にピン留めするほうが決定的です。 スクリプトでは自動計算して渡しています。
3.3 CUDA graph は mem-fraction-static の外にいる
vLLM との最大の違いです。 --gpu-memory-utilization は CUDA graph 分を内側で見てくれますが、--mem-fraction-static は見てくれません。 graph capture は静的予算の「上に」積まれます。
実測で確認しました。
| 起動時間の増分 | free 領域の消費 | KV プール | |
|---|---|---|---|
| decode graph(bs ≤ 8) | 約 7 秒 | 約 0.21 GiB | 281,143 tokens |
| prefill graph(2048 まで 42 形状) | 約 68 秒 | 約 1.55 GiB | 281,143 tokens |
KV プールはどちらの場合も 281,143 トークンで完全に同じでした。 graph は KV 予算には一切触れません。触るのは free 領域、つまり マルチモーダルエンコーダの活性値や prefill の活性値が住んでいる場所 です。
そこで prefill graph は既定でオフ にしました。68 秒の起動時間と 1.55 GiB を払う一方で、Ada の GDN prefill はどのみち TritonGDNKernel を通る ので、graph capture の旨味が薄いからです。decode graph は 0.21 GiB / 7 秒なので、こちらは当然オンのままです。
--mem-fraction-static 0.85 は、この graph 分と活性値のために 1 枚あたり約 3.6 GiB を空けておく ための数字です。上げるなら --cuda-graph-max-bs-decode も一緒に下げてください。
3.4 起動前に予算を計算する
52 GiB をロードして 2 分待ってから OOM するのは時間の無駄なので、スクリプトの冒頭で awk に見積もりを吐かせています。既定値での出力はこうなります。
TP=4 spec=none ctx=131072 max_running=8 prefill_graphs=off
per card: budget 20.39 GiB (mem-fraction-static 0.85 of 23.99 GiB)
baseline 1.72 GiB (CUDA ctx + NCCL + dist init)
weights 13.05 GiB
GDN state 1.43 GiB (40 slots x 36.7 MiB)
mm reserve 0.10 GiB
KV pool 4.09 GiB -> ~268k tokens (16 KiB/token)
free (est.) 3.39 GiB after graphs; mm-encoder activations live here
実測は 281,143 トークン だったので、見積もりは約 5% 保守側 に出ます。ちょうどいい安全マージンだと思っています。
baseline 1.72 GiB は測って入れた値です。重みをロードする前の時点で、CUDA context + NCCL + 分散初期化だけで 1.7 GiB 消えており(Load weight begin 時点の空きが 22.27 GiB)、この分も mem-fraction-static の予算から引かれます。 ここを 0 と仮定すると計算が 1.7 GiB ぶん楽観的になります。
3.5 vLLM との比較
同じマシン、同じ 131,072 コンテキストでの着地です。
| vLLM(BF16, TP=4) | SGLang(BF16, TP=4) | |
|---|---|---|
| 重み / 枚 | 13.37 GiB | 13.05 GiB |
| KV プール / 枚 | 7.0 GiB(明示固定) | 約 4.3 GiB |
| KV トークン数 | 440,673 | 281,143 |
| 131,072 での同時実行 | 3.36x | 2.14x |
| decode(投機なし・単発) | 29.4 tok/s | 約 40 tok/s |
きれいなトレードオフになりました。 対話用途で 1 人が速く返してほしいなら SGLang、長文を数本並列で流したいなら vLLM、という切り分けです。
KV を取り戻したい場合の手はあります。--mamba-ssm-dtype bfloat16 で状態プールが半分(約 0.72 GiB)に、--kv-cache-dtype fp8_e4m3 で KV が 32 KiB/token になります。ただし後者は先に単独で試してください。理由は 5.2 節に書きます。
4. 最大の地雷:rank 0 が SIGFPE で即死する
この箱で SGLang を初めて起動したとき、こう言われて死にました。
Rank 0 scheduler died during initialization (exit code: -8)
その直前、rank 1〜3 はこう言い残しています。
multimem all-gather disabled (CUDA driver error: invalid device ordinal)
exit code -8 は SIGFPE、ゼロ除算です。 Python の例外ではないので、どこにも try/except を足しても捕まりません。
4.1 何が起きているか
SGLang の logits processor は、lm_head の all-gather を MultimemAllGatherer(triton_symm_mem_ag.py)でラップしており、これは TP > 1 なら無条件に武装します。 その _build() が torch の symmetric memory rendezvous を遅延呼び出しします。
この箱ではドライバが multicast サポートを誤って「あり」と報告するため、マルチキャストオブジェクトを cuMulticastCreate する rank 0 が、granularity 0 で除算して SIGFPE します。rank 1〜3 はもっと後段のハンドル import で失敗するので、そちらは catchable です。だから rank 1〜3 だけが「disabled」とログを残して NCCL にフォールバックし、rank 0 だけがシグナルで死ぬ、という非対称な絵になります。
4.2 効かなかった対策(記録として)
同じところに嵌まる人がいると思うので、試して駄目だったものも残しておきます。
-
TORCH_SYMM_MEM_DISABLE_MULTICAST=1— torch 2.13.0 に確かに存在し、multicast の分岐もちゃんとゲートします。しかし SIGFPE はその分岐に入る前、rendezvous の中で起きます。 rendezvous が無条件に行う CUDA-IPC ハンドルの export / fabric クエリが WSL2 で通らないためです。torch v2.13.0 のソースを追いましたが、rendezvous 全体をゲートする環境変数はありません -
TORCH_SYMMMEMでバックエンドを変える — 2.13.0 の既定バックエンドは CUDA です(NVSHMEM 既定は upstream でコメントアウトされています)。何も変わりません -
SGLANG_DISABLE_MULTIMEM_AG=1— sgl-project/sglang#36110 がまさに正しいレイヤでこれをゲートしてくれるのですが、執筆時点で未マージ です。マージされたとき効くように、スクリプトでは先に渡してあります
4.3 効いた対策:sitecustomize.py を 1 枚差す
発想を変えました。rank 1〜3 は既に「例外を投げられた場合のフォールバック」を通って生き延びています。 MultimemAllGatherer._build() は create_state を except Exception で包んでおり、設計として NCCL に落ちるようになっている からです。
ならば、rank 0 にも同じ catchable な失敗をさせればいい。 ネイティブコードが走る前に、Python 側で例外を投げてしまいます。
"""Injected by qwen38-sglang.sh via PYTHONPATH=/patches."""
import os
if os.environ.get("SGLANG_PATCH_DISABLE_SYMM_MEM", "1") != "0":
try:
import torch.distributed._symmetric_memory as _sm
def _blocked(*_args, **_kwargs):
raise RuntimeError(
"torch symmetric memory disabled by sitecustomize "
"(PCIe-only / WSL2 box: rendezvous SIGFPEs on rank 0)"
)
_sm.empty = _blocked
_sm.rendezvous = _blocked
except Exception as exc: # never brick the interpreter
import sys
print(f"[sitecustomize] symm-mem patch skipped: {exc}", file=sys.stderr)
sitecustomize.py を選んだのが肝です。 Python はインタプリタ起動時にこれを自動 import します。SGLang は multiprocessing の spawn で worker を起こすので、spawn のたびに startup が再実行され、rank 0 を含む全ランクに確実に効きます。 モンキーパッチをどこかの関数の先頭に書く方式だと、rank 0 の初期化順序によっては間に合いません。
PYTHONPATH=/patches でコンテナに読み込ませます。NVLink のある箱に移ったら SGLANG_PATCH_DISABLE_SYMM_MEM=0 で無効化できるようにしてあります。
4.4 ついでに --disable-custom-all-reduce
これは SGLang 自身が「PCIe-only の GPU が 3 枚以上なら明示的に付けろ」と言ってくるので、素直に従います。4090 は P2P 非対応なので、ここは構造的にどうにもなりません。
もし symm-mem AG がそれでも噛んでくる場合の逃げ道として、--enable-tp-lm-head-all-to-all も用意しました。lm_head の gather を all-gather ではなく all-to-all という別の集団通信に付け替えます。 今回は使わずに済みました。
4.5 誤解していたこと(自己訂正)
初期のスクリプトでは、この crash の原因を コンテナ内の CUDA_VISIBLE_DEVICES の二重マッピング だと書いていました。間違いです。 --gpus "device=3,2,1,0" の時点でコンテナからは 4 枚が 0〜3 に再採番されて見えているので、その上に CUDA_VISIBLE_DEVICES を重ねるのは確かに冗長ですが、それらを外しても crash は再現しました。 原因は 4.1 のとおりです。
5. 投機デコードの罠
5.1 --max-running-requests が勝手に 48 になる
これはドキュメントを読んでいても踏みます。
SGLang は、投機アルゴリズムが指定されていて、かつ --max-running-requests が渡されていない場合、この値を 48 にリセットします。
この構成でそれが起きると、
48 requests × 5 slots = 240 slots × 36.7 MiB = 約 8.6 GiB / 枚
起動時に OOM します。 状態プールがコンテキスト長と無関係に膨らむ、という 3.1 節の話がここで効いてきます。
対策は単純で、常に --max-running-requests を明示的に渡すこと です。スクリプトでは投機の有無にかかわらず必ず渡しているので罠は無効化されていますが、この行を消さないでください。
5.2 既定は SPEC=none にしました
意図的に投機オフを既定にしています。
HF Discussions #160 には EAGLE で受容率 0.94〜1.00・約 2.2 倍という報告がありますが、あれは RTX PRO 6000 1 枚(SM120 / 96GB / fp8 KV / TP=1) の話です。メモリ regime もカーネル regime も違います。
そして自分の箱では、前回の投機デコード記事 の追試で、約 10.7 万トークンのコンテキストでは MTP K=7 が受容長を稼いでも wall-clock の tok/s は伸びない という結果が出ています。他所の速度向上は、この箱では継承できるものではなく測り直す対象 です。
スクリプトには 3 つの投機モードを用意してあります。
SPEC= |
中身 | 備考 |
|---|---|---|
eagle |
チェックポイント同梱の MTP ヘッド | 追加ダウンロード不要。cookbook のレシピは 3/1/4 |
dflash2 |
incoai/Qwen3.8-27B-DFlash2 |
vLLM 側で約 2.0 倍を実測した同じ draft |
dspark |
RadixArk/Qwen3.8-27B-DSpark |
別途学習された draft |
eagle については --enable-linear-replayssm-spec が事実上必須 です。これを付けると verify の中間状態がリクエストごとの状態スロットではなく固定リングに乗る(D=0)ので、24GB カードでも払える コストになります。付け忘れると状態プールが跳ねます。
5.3 fp8 KV は単独で先に試す
--kv-cache-dtype fp8_e4m3 は KV を 32 KiB/token に半減させる魅力的な手で、Blackwell では cookbook の既定でもあります。
ただし SM89 の fp8-KV パスは、FlashInfer 側で投機デコードとの組み合わせ時に illegal memory access を出した前科があります。 順序としては、
- まず
fp8_e4m3だけを有効にして起動・ベンチ - 安定を確認してから投機を足す
にしてください。両方いっぺんに入れると、落ちたときどちらが原因か分かりません。
5.4 attention backend は選択肢がない
SM89 では flashinfer 一択です。fa3 は SM90 限定、trtllm_mha は SM100 限定。FlashInfer の JIT が失敗する場合や、このビルドに MTP の verify カーネルが入っていない場合の逃げ道として triton があるだけです。
なお FlashInfer は初回起動でカーネルを JIT します(3〜5 分)。 現行の SGLang は Triton / FlashInfer / Inductor / DeepGEMM / CUDA driver のキャッシュを SGLANG_CACHE_DIR(既定 /root/.cache/sglang)に統合したので、昔の ~/.cache/flashinfer や ~/.triton のマウントはもう不要 です(起動ログがそう言ってくれます)。ここだけホストにマウントしておけば、再起動で JIT を払い直さずに済みます。
6. 起動スクリプト
~/myvllm/qwen38-sglang.sh として保存して chmod +x します。vLLM 版と同じ 8000 番ポート・同じ --served-model-name にしてあるので、クライアント側は差し替えなしで切り替えられます。
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# Qwen3.8-27B (BF16) on 4x RTX 4090 (Ada / SM89) with SGLang, TP=4.
#
# Counterpart to qwen38-vllm.sh. Same box, same checkpoint, same served name,
# different engine. Read section 0 before the first run: SGLang's memory model
# is NOT vLLM's, and the two knobs that bite on this card are the GDN state
# pool and the speculative-decoding override of --max-running-requests.
#
# Reference: SGLang cookbook "Qwen3.8-27B"
# https://docs.sglang.io/cookbook/autoregressive/Qwen/Qwen3.8-27B
# The cookbook has verified cells for H200 / RTX PRO 6000 / RTX 5090 /
# DGX Spark / GB300 only. There is NO Ada (SM89) cell -- everything below is
# derived from the cookbook geometry, not copied from a verified recipe.
# ============================================================================
# ---------------------------------------------------------------------------
# 0. Memory model (why the defaults are what they are)
#
# Architecture (cookbook): 64 layers total.
# - 48 layers Gated DeltaNet: 48 value heads / 16 QK heads, head_dim 128.
# - 16 layers Gated Attention: GQA 24/4 heads, head_dim 256.
# So the KV cache is charged for 16 layers only; the other 48 layers cost a
# fixed-size recurrent state per SLOT that does not grow with context.
#
# Two independent pools, both sharded by TP:
# state_bytes/slot = 48 * (48*128*128*ssm_bytes + 10240*3*2)
# = 146.8 MiB (fp32) / 74.8 MiB (bf16) -> /TP per card
# kv_bytes/token = 16 * 4 * 256 * 2 * kv_bytes
# = 64 KiB (bf16) / 32 KiB (fp8) -> /TP per card
#
# Slots per running request (S), set by --mamba-radix-cache-strategy:
# extra_buffer 5 (default) | extra_buffer_lazy 4 | no_buffer 3 | radix off 1
# so the state pool to pin is MAX_RUNNING_REQUESTS * S.
#
# NOTE: unlike vLLM's --gpu-memory-utilization, mem-fraction-static does NOT
# cover CUDA graphs. Graph capture allocates on top of it. 0.85 leaves ~3.6
# GiB/card of headroom; do not raise it without also lowering
# --cuda-graph-max-bs.
# ---------------------------------------------------------------------------
MODEL_DIR="${MODEL_DIR:-/root/HuggingFaceCache/Qwen3.8-27B}"
# Consumer-card image from the cookbook (rtx5090 / rtx6000 / dgx-spark cells).
# It carries the Qwen3.8 GDN kernels plus the DFlash2 spec-decode path, which
# the plain :latest tag may not.
IMAGE="${IMAGE:-docker.1ms.run/lmsysorg/sglang:dev-qwen38-27b-dflash2}"
SERVED_MODEL_NAME="${SERVED_MODEL_NAME:-qwen3.8-27b}"
PORT="${PORT:-8000}" # same port as the vLLM box, so clients are drop-in
API_KEY="${API_KEY:-sk-123456}"
GPU_DEVICES="${GPU_DEVICES:-3,2,1,0}"
TP_SIZE="${TP_SIZE:-4}"
# Native 262144. 131072 mirrors the vLLM box and keeps ~2x concurrency.
# PROFILE=longctx switches to the single-session envelope.
PROFILE="${PROFILE:-default}"
if [ "${PROFILE}" = "longctx" ]; then
CONTEXT_LENGTH="${CONTEXT_LENGTH:-262144}"
MAX_RUNNING_REQUESTS="${MAX_RUNNING_REQUESTS:-2}"
CHUNKED_PREFILL_SIZE="${CHUNKED_PREFILL_SIZE:-2048}"
else
CONTEXT_LENGTH="${CONTEXT_LENGTH:-131072}"
MAX_RUNNING_REQUESTS="${MAX_RUNNING_REQUESTS:-8}"
CHUNKED_PREFILL_SIZE="${CHUNKED_PREFILL_SIZE:-2048}"
fi
# GPU0 also drives the display (~600MiB, fluctuates). Start at 0.85; the
# vLLM box tolerated 0.88 but vLLM budgets graphs inside that number.
MEM_FRACTION_STATIC="${MEM_FRACTION_STATIC:-0.85}"
# bf16 is the safe default on Ada. fp8_e4m3 halves the KV pool (32 KiB/token)
# and is the cookbook default on Blackwell, but SM89 fp8-KV paths in
# FlashInfer have a history of illegal-memory-access under speculative
# decoding -- turn it on alone first, benchmark, and only then add spec.
KV_CACHE_DTYPE="${KV_CACHE_DTYPE:-bfloat16}"
# fp32 GDN state is the cookbook default and costs 36.7 MiB/slot/card at TP=4,
# i.e. ~1.4 GiB for 40 slots. Affordable here; bfloat16 halves it if you need
# the KV pool back.
MAMBA_SSM_DTYPE="${MAMBA_SSM_DTYPE:-float32}"
# extra_buffer (S=5) is low-latency; extra_buffer_lazy (S=4) trades a little
# latency for a fifth of the state pool back. no_buffer (S=3) forces the
# overlap scheduler off -- SGLang warns and disables it silently.
MAMBA_RADIX_STRATEGY="${MAMBA_RADIX_STRATEGY:-extra_buffer}"
case "${MAMBA_RADIX_STRATEGY}" in
extra_buffer) SLOTS_PER_REQ=5 ;;
extra_buffer_lazy) SLOTS_PER_REQ=4 ;;
no_buffer) SLOTS_PER_REQ=3 ;;
*) echo "ERROR: unknown MAMBA_RADIX_STRATEGY=${MAMBA_RADIX_STRATEGY}"; exit 1 ;;
esac
# Pin the state pool explicitly instead of letting --mamba-full-memory-ratio
# guess from an assumed average request length. Deterministic, and it is the
# number that actually OOMs you.
MAX_MAMBA_CACHE_SIZE="${MAX_MAMBA_CACHE_SIZE:-$((MAX_RUNNING_REQUESTS * SLOTS_PER_REQ))}"
# SM89: flashinfer is the default and the only real choice. fa3 is SM90-only,
# trtllm_mha is SM100-only. triton is the fallback if FlashInfer JIT fails.
ATTENTION_BACKEND="${ATTENTION_BACKEND:-flashinfer}"
# Cap decode graph capture at the concurrency we actually pinned. Graphs live
# OUTSIDE mem-fraction-static, so this is real VRAM. If left unset SGLang
# auto-picks from GPU memory and TP size, which lands far above 8 here.
CUDA_GRAPH_MAX_BS_DECODE="${CUDA_GRAPH_MAX_BS_DECODE:-${CUDA_GRAPH_MAX_BS:-${MAX_RUNNING_REQUESTS}}}"
# ---------------------------------------------------------------------------
# PCIe-only collectives (4x 4090, no NVLink, no P2P, WSL2)
#
# SGLang's logits processor wraps the lm_head all-gather in a
# MultimemAllGatherer (triton_symm_mem_ag.py) that is armed unconditionally
# whenever TP > 1. Its _build() lazily calls torch symmetric-memory
# rendezvous; on this box the driver misreports multicast support, so rank 0
# divides by a multicast granularity of 0 -> SIGFPE, a signal the surrounding
# try/except can never catch -> "Rank 0 scheduler died during initialization
# (exit code: -8)".
#
# The robust fix is the sitecustomize.py written further down. Opt out with
# SGLANG_PATCH_DISABLE_SYMM_MEM=0.
DISABLE_CUSTOM_ALL_REDUCE="${DISABLE_CUSTOM_ALL_REDUCE:-true}"
# Fallback if the symm-mem AG still engages: route the lm_head gather through
# all-to-all instead of all-gather, i.e. a different collective entirely.
LMHEAD_ALL_TO_ALL="${LMHEAD_ALL_TO_ALL:-false}"
# Last-resort escape hatch, bisect step only.
DISABLE_DECODE_CUDA_GRAPH="${DISABLE_DECODE_CUDA_GRAPH:-false}"
# Prefill graphs are off by default here. Measured: capturing 42 shapes up to
# 2048 tokens costs 68 s of startup and ~1.55 GiB/card of the FREE region
# (the KV pool is unaffected -- 281,143 tokens with graphs on or off).
DISABLE_PREFILL_CUDA_GRAPH="${DISABLE_PREFILL_CUDA_GRAPH:-true}"
# ---------------------------------------------------------------------------
# Speculative decoding: none | eagle | dflash2 | dspark
#
# TRAP: SGLang resets --max-running-requests to 48 whenever a speculative
# algorithm is set and the flag is absent. 48*5 = 240 state slots = ~8.6
# GiB/card here -> OOM at startup. This script always passes the flag, so the
# trap is defused, but do not strip it.
# ---------------------------------------------------------------------------
SPEC="${SPEC:-none}"
SPEC_ARGS=()
case "${SPEC}" in
none) ;;
eagle)
SPEC_ARGS=(
--speculative-algorithm EAGLE
--speculative-num-steps 3
--speculative-eagle-topk 1
--speculative-num-draft-tokens 4
--enable-linear-replayssm-spec
)
;;
dflash2)
SPEC_ARGS=(
--speculative-algorithm DFLASH
--speculative-draft-model-path /models/Qwen3.8-27B-DFlash2
--speculative-num-draft-tokens 8
)
;;
dspark)
SPEC_ARGS=(
--speculative-algorithm DSPARK
--speculative-draft-model-path /models/Qwen3.8-27B-DSpark
--speculative-draft-attention-backend flashinfer
)
;;
*) echo "ERROR: unknown SPEC=${SPEC} (none|eagle|dflash2|dspark)"; exit 1 ;;
esac
DRAFT_DIR="${DRAFT_DIR:-}"
if [ "${SPEC}" = "dflash2" ] && [ -z "${DRAFT_DIR}" ]; then
DRAFT_DIR="/root/HuggingFaceCache/Qwen3.8-27B-DFlash2"
DRAFT_MOUNT="/models/Qwen3.8-27B-DFlash2"
elif [ "${SPEC}" = "dspark" ] && [ -z "${DRAFT_DIR}" ]; then
DRAFT_DIR="/root/HuggingFaceCache/Qwen3.8-27B-DSpark"
DRAFT_MOUNT="/models/Qwen3.8-27B-DSpark"
else
DRAFT_MOUNT=""
fi
ENABLE_TOOL_CALL="${ENABLE_TOOL_CALL:-true}"
# ---------------------------------------------------------------------------
# Preflight
# ---------------------------------------------------------------------------
if [ ! -d "${MODEL_DIR}" ]; then
echo "ERROR: model directory not found: ${MODEL_DIR}"
exit 1
fi
MISSING=()
for f in config.json preprocessor_config.json video_preprocessor_config.json \
generation_config.json chat_template.jinja tokenizer_config.json; do
[ -f "${MODEL_DIR}/${f}" ] || MISSING+=("$f")
done
if [ ${#MISSING[@]} -gt 0 ]; then
echo "ERROR: missing config files in ${MODEL_DIR}: ${MISSING[*]}"
exit 1
fi
if [ -n "${DRAFT_DIR}" ] && [ ! -d "${DRAFT_DIR}" ]; then
echo "ERROR: SPEC=${SPEC} but draft checkpoint not found: ${DRAFT_DIR}"
exit 1
fi
# SGLang reads the chat template out of tokenizer_config.json. The stock
# Qwen3.8 template crashes on enable_thinking=false, hardcodes xhigh, and
# poisons history with blank think blocks.
if ! grep -q "qwen3.8-froggeric" "${MODEL_DIR}/chat_template.jinja" 2>/dev/null; then
echo "WARNING: stock Qwen 3.8 chat template detected in ${MODEL_DIR}." >&2
echo " Install the fix and restart:" >&2
echo " bash /root/myvllm/qwen38-fixed-chat-template/install-fixed-chat-template.sh" >&2
fi
# The vLLM containers grab the same four GPUs at 0.88. They cannot coexist.
for c in qwen38-vllm qwen38-dflash-vllm; do
if docker ps --format '{{.Names}}' | grep -qx "$c"; then
echo "ERROR: container '$c' is running and holds the same 4 GPUs."
echo " docker stop $c"
exit 1
fi
done
# ---------------------------------------------------------------------------
# Budget echo -- sanity-check before waiting 2 minutes for a startup OOM.
# ---------------------------------------------------------------------------
case "${MAMBA_SSM_DTYPE}" in
float32) SSM_BYTES=4 ;;
bfloat16|float16) SSM_BYTES=2 ;;
*) echo "ERROR: unknown MAMBA_SSM_DTYPE=${MAMBA_SSM_DTYPE}"; exit 1 ;;
esac
case "${KV_CACHE_DTYPE}" in
fp8_e4m3) KV_BYTES=1 ;;
bfloat16|auto) KV_BYTES=2 ;;
*) echo "ERROR: unknown KV_CACHE_DTYPE=${KV_CACHE_DTYPE}"; exit 1 ;;
esac
awk -v tp="${TP_SIZE}" -v ssm="${SSM_BYTES}" -v kvb="${KV_BYTES}" \
-v slots="${MAX_MAMBA_CACHE_SIZE}" -v mf="${MEM_FRACTION_STATIC}" \
-v ctx="${CONTEXT_LENGTH}" -v run="${MAX_RUNNING_REQUESTS}" -v spec="${SPEC}" \
-v pfg="${DISABLE_PREFILL_CUDA_GRAPH}" '
BEGIN {
card = 23.99; # GiB usable per 4090
base = 1.72; # CUDA ctx + NCCL + dist init
wts = 51.75 / tp + 0.11;
st = 48 * (48*128*128*ssm + 10240*3*2) / tp / 1048576; # MiB/slot/card
kvtok = 16 * 4 * 256 * 2 * kvb / tp / 1024; # KiB/token/card
mmres = 0.10; # multimodal feature-transport reserve, logged at startup
budget = card * mf;
pool = slots * st / 1024;
kv = budget - base - wts - pool - mmres;
toks = (kv > 0) ? kv * 1048576 / kvtok : 0;
free = card - budget - ((pfg == "true") ? 0.21 : 1.76);
printf " TP=%d spec=%s ctx=%d max_running=%d prefill_graphs=%s\n",
tp, spec, ctx, run, (pfg == "true") ? "off" : "on";
printf " per card: budget %.2f GiB (mem-fraction-static %s of %.2f GiB)\n", budget, mf, card;
printf " baseline %.2f GiB (CUDA ctx + NCCL + dist init)\n", base;
printf " weights %.2f GiB\n", wts;
printf " GDN state %.2f GiB (%d slots x %.1f MiB)\n", pool, slots, st;
printf " mm reserve %.2f GiB\n", mmres;
printf " KV pool %.2f GiB -> ~%.0fk tokens (%.0f KiB/token)\n", kv, toks/1000, kvtok;
printf " free (est.) %.2f GiB after graphs\n", free;
if (kv < 1.0) print " !! KV pool under 1 GiB -- lower MAX_RUNNING_REQUESTS or MAMBA_SSM_DTYPE=bfloat16";
if (toks > 0 && toks < ctx) printf " !! KV pool (%.0fk) < context-length (%.0fk)\n", toks/1000, ctx/1000;
}'
# ---------------------------------------------------------------------------
# Launch
# ---------------------------------------------------------------------------
SGLANG_ARGS=(
--model-path /models/Qwen3.8-27B
--served-model-name "${SERVED_MODEL_NAME}"
--trust-remote-code
--tp-size "${TP_SIZE}"
--context-length "${CONTEXT_LENGTH}"
--mem-fraction-static "${MEM_FRACTION_STATIC}"
--kv-cache-dtype "${KV_CACHE_DTYPE}"
--attention-backend "${ATTENTION_BACKEND}"
--chunked-prefill-size "${CHUNKED_PREFILL_SIZE}"
--mamba-ssm-dtype "${MAMBA_SSM_DTYPE}"
--mamba-radix-cache-strategy "${MAMBA_RADIX_STRATEGY}"
--max-mamba-cache-size "${MAX_MAMBA_CACHE_SIZE}"
--max-running-requests "${MAX_RUNNING_REQUESTS}"
--cuda-graph-max-bs-decode "${CUDA_GRAPH_MAX_BS_DECODE}"
--enable-metrics
--api-key "${API_KEY}"
--host 0.0.0.0
--port "${PORT}"
)
SGLANG_ARGS+=("${SPEC_ARGS[@]}")
# Plain `[ x ] && ...` returns 1 when the test fails, which under `set -e`
# aborts the script. Use if-blocks.
if [ "${DISABLE_CUSTOM_ALL_REDUCE}" = "true" ]; then SGLANG_ARGS+=(--disable-custom-all-reduce); fi
if [ "${DISABLE_PREFILL_CUDA_GRAPH}" = "true" ]; then SGLANG_ARGS+=(--disable-prefill-cuda-graph); fi
if [ "${DISABLE_DECODE_CUDA_GRAPH}" = "true" ]; then SGLANG_ARGS+=(--disable-decode-cuda-graph); fi
if [ "${LMHEAD_ALL_TO_ALL}" = "true" ]; then SGLANG_ARGS+=(--enable-tp-lm-head-all-to-all); fi
# Qwen3.8 thinks by default and emits <think>...</think>. qwen3_coder is the
# cookbook's tool parser for this checkpoint (vLLM's qwen3_xml has no SGLang
# equivalent name).
if [ "${ENABLE_TOOL_CALL}" = "true" ]; then
SGLANG_ARGS+=(
--reasoning-parser qwen3
--tool-call-parser qwen3_coder
)
fi
MOUNTS=(-v "${MODEL_DIR}:/models/Qwen3.8-27B:ro")
if [ -n "${DRAFT_MOUNT}" ]; then
MOUNTS+=(-v "${DRAFT_DIR}:${DRAFT_MOUNT}:ro")
fi
# Current SGLang consolidates Triton / FlashInfer / Inductor / DeepGEMM / CUDA
# driver caches under one SGLANG_CACHE_DIR (default /root/.cache/sglang), so
# the old ~/.cache/flashinfer and ~/.triton mounts are dead weight.
mkdir -p /root/.cache/sglang
# ---------------------------------------------------------------------------
# Symm-mem kill switch (see "PCIe-only collectives" above for why).
# sitecustomize.py is imported automatically at interpreter startup on every
# rank (multiprocessing spawn re-runs startup), so the patch covers rank 0
# too -- the one whose failure mode is an uncatchable SIGFPE.
# ---------------------------------------------------------------------------
PATCH_DIR="${PATCH_DIR:-/root/myvllm/sglang-patches}"
mkdir -p "${PATCH_DIR}"
cat > "${PATCH_DIR}/sitecustomize.py" <<'PYEOF'
"""Injected by qwen38-sglang.sh via PYTHONPATH=/patches.
torch symmetric-memory rendezvous hard-crashes on this box (WSL2, 4x PCIe
RTX 4090): rank 0 dies with SIGFPE inside the native _SymmetricMemory
rendezvous, which no Python try/except can catch. SGLang's
MultimemAllGatherer._build() already handles a *raising* symm-mem call by
falling back to NCCL, so this converts the native crash into a catchable
RuntimeError before any native code runs.
Disable with SGLANG_PATCH_DISABLE_SYMM_MEM=0 (e.g. on a future NVLink box).
"""
import os
if os.environ.get("SGLANG_PATCH_DISABLE_SYMM_MEM", "1") != "0":
try:
import torch.distributed._symmetric_memory as _sm
def _blocked(*_args, **_kwargs):
raise RuntimeError(
"torch symmetric memory disabled by sitecustomize "
"(PCIe-only / WSL2 box: rendezvous SIGFPEs on rank 0)"
)
_sm.empty = _blocked
_sm.rendezvous = _blocked
except Exception as exc: # never brick the interpreter
import sys
print(f"[sitecustomize] symm-mem patch skipped: {exc}", file=sys.stderr)
PYEOF
TTY_FLAG=""
if [ -t 0 ]; then
TTY_FLAG="-it"
fi
# NOTE: no CUDA_VISIBLE_DEVICES / CUDA_DEVICE_ORDER inside the container.
# --gpus "device=3,2,1,0" already makes the container see exactly those four,
# renumbered 0-3. (An earlier revision blamed them for the symm-mem crash --
# wrong: the crash reproduced without them.)
docker run $TTY_FLAG --rm \
--name qwen38-sglang \
--gpus "\"device=${GPU_DEVICES}\"" \
--network host \
--ipc=host \
--shm-size 32g \
--ulimit memlock=-1 \
--ulimit stack=67108864 \
"${MOUNTS[@]}" \
-v /root/.cache/sglang:/root/.cache/sglang \
-v "${PATCH_DIR}:/patches:ro" \
-e PYTHONPATH=/patches:/sgl-workspace/sglang/python \
-e SGLANG_PATCH_DISABLE_SYMM_MEM="${SGLANG_PATCH_DISABLE_SYMM_MEM:-1}" \
-e PYTORCH_NVML_BASED_CUDA_CHECK=1 \
-e SGLANG_DISABLE_MULTIMEM_AG=1 \
-e TRANSFORMERS_OFFLINE=1 \
-e HF_DATASETS_OFFLINE=1 \
-e HF_HUB_OFFLINE=1 \
-e HF_HOME=/root/HuggingFaceCache \
-e HUGGINGFACE_HUB_CACHE=/root/HuggingFaceCache \
-e NCCL_P2P_DISABLE=1 \
-e NCCL_IB_DISABLE=1 \
-e NCCL_SHM_DISABLE=0 \
"${IMAGE}" \
sglang serve "${SGLANG_ARGS[@]}"
各設定の意図
| 設定 | 理由 |
|---|---|
--tp-size 4 |
BF16 51.75 GiB を 4 枚に分散 |
--context-length 131072 |
vLLM 版と揃えた。262144 は PROFILE=longctx で |
--mem-fraction-static 0.85 |
CUDA graph はこの外側。3.6 GiB 空けておく |
--max-mamba-cache-size |
max_running × S を明示ピン留め。OOM するのはこの数字
|
--max-running-requests 8 |
投機時に 48 へリセットされる罠の無効化を兼ねる |
--mamba-radix-cache-strategy extra_buffer |
既定。no_buffer は overlap scheduler が黙って死ぬ |
--cuda-graph-max-bs-decode 8 |
未指定だと 4 枚構成では 8 よりずっと上を自動選択する |
--disable-prefill-cuda-graph |
68 秒と 1.55 GiB に見合わない(Ada の GDN prefill は Triton) |
--attention-backend flashinfer |
SM89 では実質一択 |
--disable-custom-all-reduce |
PCIe-only 3 枚超では SGLang 自身が要求してくる |
--kv-cache-dtype bfloat16 |
fp8 は投機と併用する前に単独で検証 |
--reasoning-parser qwen3 |
既定で <think> を出力する |
--tool-call-parser qwen3_coder |
vLLM の qwen3_xml に相当する SGLang 側の名前がこれ |
PYTHONPATH=/patches |
第4節の symm-mem キルスイッチ |
7. 動作確認
7.1 テキスト
curl -s localhost:8000/v1/chat/completions \
-H "Authorization: Bearer sk-123456" \
-H "Content-Type: application/json" -d '{
"model": "qwen3.8-27b",
"messages": [{"role": "user", "content": "RAG について日本語で説明してください。"}],
"temperature": 1.0, "top_p": 0.95, "top_k": 20, "max_tokens": 2048
}'
reasoning_content が分離されて返れば --reasoning-parser qwen3 が効いています。
7.2 起動ログで確認する 3 点
docker logs qwen38-sglang 2>&1 | grep -iE "KV cache|mamba|max_running"
-
KV プールのトークン数 — 6 節の見積もりから 1 割以上ずれていたら、
--max-mamba-cache-sizeか--mem-fraction-staticの解釈がずれています -
max_running_requestsの値 — 投機を有効にしたのに 48 になっていたら、5.1 節の罠に落ちています -
overlap scheduler が有効か —
no_bufferを選ぶと黙って無効化されます
7.3 メトリクス
--enable-metrics を付けてあるので Prometheus 形式で拾えます。投機を有効にしたときの受容長はここで見ます。
curl -s localhost:8000/metrics | grep -iE "spec|accept"
投機デコードは受容率が低くてもエラーを出さず、ただ遅くなります。 有効化したら必ず数字を見てください。
8. 実測
8.1 素の decode
同一マシン・同一チェックポイント・投機なしでの比較です。
| エンジン | 構成 | decode(単発) |
|---|---|---|
| vLLM | BF16 TP=4、融合 GDN decode(GDN decode kernel: cuda) |
29.4 tok/s |
| SGLang | BF16 TP=4、SPEC=none
|
約 40 tok/s |
約 1.35 倍。 前回記事で分解した「理論上限 72 tok/s、実測 31 tok/s、差分の 17 ms がオーバーヘッド」という構図に当てはめると、SGLang はこの固定オーバーヘッドを 4〜5 ms ほど削っている ことになります。overlap scheduler の効果だと思っていますが、そこまでの分解はまだできていません。
8.2 CUDA graph の実測
| 起動時間 | free 領域 | KV プール | |
|---|---|---|---|
| decode graph のみ(既定) | +約 7 秒 | 約 0.21 GiB | 281,143 tokens |
| prefill graph も有効 | +約 68 秒 | 約 1.55 GiB | 281,143 tokens |
KV は 1 トークンも変わりません。 3.3 節で書いたとおりです。
8.3 まだ測れていないこと
正直に書いておきます。
- 長文入力(16K 以上)でのベンチ。 vLLM 側では同時 8 で TTFT p50 19 秒という破綻を確認していますが、SGLang の chunked prefill が同じ壊れ方をするかは未測定です
-
投機デコード(EAGLE / DFlash2 / DSpark)の実測。 スクリプトには入れましたが、既定は
noneのままです - fp8 KV。 5.3 節の理由で後回しにしています
-
--mamba-ssm-dtype bfloat16にしたときの品質影響。 メモリは確実に半分になりますが、GDN 状態を bf16 に落とすことが長い生成でどう効くかは測っていません
いずれも取れたら追記します。
9. 最終的なパラメータ
| パラメータ | 値 | 決定理由 |
|---|---|---|
| イメージ | lmsysorg/sglang:dev-qwen38-27b-dflash2 |
GDN カーネル + DFlash2 パス同梱 |
| 並列方式 | TP=4 | BF16 51.75 GiB |
--context-length |
131072 | vLLM 版と揃えた。KV 281,143 で同時実行 2.14x |
--mem-fraction-static |
0.85 | graph はこの外。3.6 GiB を空ける |
--max-running-requests |
8 | 投機時 48 リセットの罠も同時に潰す |
--max-mamba-cache-size |
40(= 8 × 5) | 1 枚 1.43 GiB。OOM するのはここ |
--mamba-ssm-dtype |
float32 | KV を返してほしいときだけ bfloat16 |
--kv-cache-dtype |
bfloat16 | fp8 は単独検証してから |
--disable-prefill-cuda-graph |
有効 | 68 秒 + 1.55 GiB に見合わない |
--cuda-graph-max-bs-decode |
8 | 未指定だと自動でずっと大きい値になる |
--attention-backend |
flashinfer | SM89 では一択 |
--speculative-algorithm |
使わない | 他所の 2.2 倍はこの箱では継承できない |
| symm-mem |
sitecustomize.py で無効化 |
rank 0 の SIGFPE 回避(第4節) |
10. まとめ
- RTX 4090 24GB × 4枚(WSL2 + Docker)で、Qwen3.8-27B は SGLang でも BF16 フル精度で動きます。 ただし公式 cookbook に Ada(SM89)の verified cell はありません
- 素の decode は SGLang が速い。 同一条件で vLLM 29.4 tok/s に対し 約 40 tok/s、約 1.35 倍
- 代わりに KV プールは狭い。 実測 281,143 トークン(vLLM は固定で 440,673)。131,072 での同時実行は 2.14x(vLLM 3.36x)。速度か同時実行かの二択 になります
-
--mem-fraction-staticは--gpu-memory-utilizationではありません。 CUDA graph はこの予算の外側に積まれます。0.85 で 1 枚 3.6 GiB 空けており、上げるなら--cuda-graph-max-bs-decodeも下げてください -
24GB で最初に効くのは GDN の状態プール です。
max_running × S(既定 S=5)で決まり、コンテキスト長に依存しません。--max-mamba-cache-sizeで明示ピン留めするのが確実です -
投機アルゴリズムを指定すると
--max-running-requestsが 48 にリセットされます。 240 スロット = 約 8.6 GiB/枚 で起動 OOM です。常に明示的に渡してください - prefill CUDA graph は割に合いません。 起動 +68 秒・1.55 GiB を払っても KV は 1 トークンも増えず、Ada の GDN prefill はどのみち Triton です。decode graph(0.21 GiB / 7 秒)だけ残します
-
そして WSL2 + PCIe-only では rank 0 が SIGFPE(exit code -8)で即死します。 原因は TP>1 で無条件に武装する multimem all-gather の symm-mem rendezvous。
TORCH_SYMM_MEM_DISABLE_MULTICASTでは止まりません(SIGFPE のほうが先に起きる)。sitecustomize.pyで_symmetric_memory.{empty,rendezvous}を例外に置き換える のが今のところ確実な手です。SGLang 側は例外なら NCCL に落ちる設計なので、これで筋が通ります
日本語文書 OCR という自分の本命用途で言うと、単発レイテンシが効く対話・抽出系は SGLang、長文を並列で流す RAG 系は vLLM、という使い分けに落ち着きそうです。同じポートで名前も揃えてあるので、スクリプトを切り替えるだけでどちらにも寄せられます。投機デコードと長文ベンチの実測は次回まとめます。
参考リンク
- SGLang Cookbook: Qwen3.8-27B
- Qwen/Qwen3.8-27B(Hugging Face モデルカード)
- sgl-project/sglang
- sgl-project/sglang#36110(
SGLANG_DISABLE_MULTIMEM_AG、執筆時点で未マージ) - froggeric/Qwen-Fixed-Chat-Templates
- 前回:RTX 4090 × 4枚で Qwen3.8-27B を vLLM (Docker) で動かす
- 関連:Qwen3.8-27B の投機デコード、実測で 2 倍になった(DFlash2 vs MTP)
- 前々回:RTX 4090 × 4枚で Qwen3.6-27B ファインチューン「Fable-Fusion-711」を vLLM (Docker) で動かす
- 前々々回:RTX 4090 × 4枚で Meta の新モデル Muse Glimmer 30B を vLLM (Docker) で動かす