はじめに
ローカルで LLM を動かす手段は、用途によっていくつかレイヤーがあります。
- 手軽に触りたい (個人利用 / 試作): Ollama や LM Studio。インストール 1 ステップでモデルが動き、GGUF / Q4_K_M などの量子化も自動で扱ってくれる
- 本格運用したい (複数ユーザー / API サーバ化 / スループット重視): vLLM と SGLang。OpenAI 互換 API を喋り、continuous batching と KV cache 最適化で並列処理に耐える
- 極限まで最適化したい (商用 / 大規模 GPU クラスタ): TensorRT-LLM。NVIDIA 公式で性能トップクラスだが、エンジンビルドと量子化キャリブレーションに専門知識が必要で、モデルごとに builder スクリプトを書くフェーズが発生する
この記事で扱う vLLM と SGLang は、ちょうど真ん中のレイヤーです。Ollama より速く、TensorRT-LLM より圧倒的にセットアップが軽い (どちらも基本は docker run 一発)。Hugging Face のモデルをほぼそのまま投入でき、量子化済みチェックポイント (FP8, AWQ, GPTQ) もそのまま読めます。**「個人開発から本格運用までを 1 つのスタックでカバーできる」**という位置づけが、両エンジンが二大選択肢になっている理由です。
ただ、どちらも論文と GitHub では華々しい数字が並ぶ一方、いざ手元の RTX 4070 12GB に同じモデルを載せて比べようとすると、起動オプションも測定指標もそれぞれ違うため、「正しく同じ条件で動かす」だけで一仕事になります。
このシリーズは Qwen3.5-4B FP8 (RedHatAI/Qwen3.5-4B-FP8-dynamic) を題材に、vLLM と SGLang を 12GB GPU で 同じ土俵に乗せた上で比較した記録です。全 4 回の予定で、今回 ① は土台づくりにあたります。
- ① 本記事: 環境セットアップ、両エンジンの起動オプション対応表、attention backend の起動可否マトリクス
- ② 次回: backend 単発比較 — context 長を 3 段 (512 / 2048 / 8192 tokens) に変えて tok/s を測る
- ③: 並列負荷比較 — concurrency 1 / 2 / 4 / 8 でのスループットと p95 latency
- ④: streaming 計測で TTFT と ITL を分離し、scheduler チューニングの効果を検証
この記事単体でも読めるように: vLLM・SGLang の基本的な違い、共通条件の設計根拠、起動時に出た問題と対処を一通りカバーしています。数字の比較は ② 以降ですが、「なぜその数字が信用できるか」の根拠がここにあります。「いきなり tok/s 表が見たい」方は ② からでも読めますが、実験条件が揃わないと数字は嘘になるので、まずは条件合わせの話を書いておきます。
なぜ二つのエンジンを比較するか
vLLM と SGLang は、表向きはどちらも「OpenAI 互換 API を喋る高速 LLM サーバ」です。が、内部実装は別物です。
ひと言で区別するなら:
- vLLM: 「KV cache をページ単位で管理する PagedAttention」を発明した UC Berkeley 発のエンジン。エコシステムが最も広く、speculative decoding (投機的デコーディング) の実装が充実している
- SGLang: 同じ UC Berkeley → LMSYS グループが作った後発エンジン。「RadixAttention」という、共通プレフィックスの KV cache を tree 構造で共有する仕組みが特徴
| 観点 | vLLM | SGLang |
|---|---|---|
| 主な強み | PagedAttention、speculative decoding 充実 | RadixAttention による prefix 共有、scheduler 効率 |
| 既定の attention backend | RTX 4070 では FlashAttention を優先選択 | FlashInfer / Triton (GPU 世代で fallback) |
| 主要パラメータ名 |
--gpu-memory-utilization --max-model-len
|
--mem-fraction-static --context-length
|
| API ポート (例) | 8000 | 30000 |
オプション名すら違うため、「同じ条件」を 1 つずつ写経しても、実は VRAM 割り当てが大きくずれていた、ということが起きます。今回は 同じモデル / 同じ prompt / 同じ生成長 / 同じ context limit / 同じ concurrency に揃え、揃えられない部分は caveat として明示する方針で進めます。
共通条件
GPU : RTX 4070 12GB (Ada Lovelace, SM 8.9)
OS : Ubuntu (Xorg + GNOME, GPU 常時消費 ~0.71 GiB)
モデル : RedHatAI/Qwen3.5-4B-FP8-dynamic
量子化 : FP8 (compressed-tensors, pre-quantized)
context length : 2048 (Phase 1 の short は 512, long は 8192 まで)
concurrency : Phase 1 = 1, Phase 2 = 1/2/4/8
temperature : 0
warmup : 1 回 (結果破棄)
measured runs : 5 回、中央値を採用
API : OpenAI 互換 `/v1/chat/completions`
thinking : `enable_thinking=false` で無効化
Qwen3.5 は thinking モードがデフォルト有効ですが、ベンチでは出力の構造を揃えるために chat_template_kwargs.enable_thinking=false を毎回指定しています。これを忘れると vLLM 側だけ <think>...</think> 分の出力 tokens が増え、tok/s の見かけが歪みます。
Docker でバージョンを固定する
両エンジンとも nightly 系イメージを使っており、latest タグはじわじわ動きます。再現性のために、本記事で使用したバージョンは次のとおりです。
# vLLM (本記事のバージョン: v0.21.1rc1.dev243)
docker pull vllm/vllm-openai:nightly
# SGLang
docker pull lmsysorg/sglang:latest
コンテナ内で pip show vllm / pip show sglang を叩くと実バージョンが取れます。nightly を pin したい場合は起動直後にこの 2 行を控えておくと再現実行が楽になります。
両エンジンに共通する Docker 上の落とし穴を 2 つ挙げておきます。
-
--shm-size: vLLM は1gでも動きますが、SGLang は公式 example が32gを推奨しています。tokenizer worker や IPC で /dev/shm を使うため、絞りすぎると無言で固まります。 -
--ipc=host: SGLang は推奨。これも IPC 経由の worker 起動に効きます。
起動オプション対応表
vLLM と SGLang は「同じ役割の起動オプション」が違う名前になっていることが多く、写経で揃えると意味がズレることがあります。主要なものを並べておきます。
| 目的 | vLLM | SGLang |
|---|---|---|
| モデル指定 | --model RedHatAI/Qwen3.5-4B-FP8-dynamic |
--model-path RedHatAI/Qwen3.5-4B-FP8-dynamic |
| 最大文脈長 | --max-model-len 2048 |
--context-length 2048 |
| GPU メモリ割り当て | --gpu-memory-utilization 0.93 |
--mem-fraction-static 0.85 |
| バッチトークン上限 | --max-num-batched-tokens 4096 |
--chunked-prefill-size 4096 |
| attention backend | --attention-backend flashinfer |
--attention-backend flashinfer |
| KV cache dtype | --kv-cache-dtype fp8 |
--kv-cache-dtype fp8_e4m3 |
| CUDA graph 無効化 | --enforce-eager |
--disable-cuda-graph |
| メトリクス公開 | デフォルト有効 (/metrics) |
--enable-metrics |
特に注意したいのが メモリ割り当ての考え方:
-
vLLM の
--gpu-memory-utilizationは「GPU 全体のうちこの割合まで使ってよい」上限です。0.93 なら 12GB の 93% ≈ 11.16GB が上限。 -
SGLang の
--mem-fraction-staticは「サーバ起動時に静的に確保するブロックの比率」。0.85 だと、起動時に約 85% を予約し、残りを動的に使う配分です。
数値の絶対比較で「vLLM 0.93 と SGLang 0.85 は揃っている」と言えないため、今回はそれぞれ「OOM せず安定動作する最大値」を選び、起動後の peak VRAM を実測して横並びに見ています。
RTX 4070 (SM 8.9) 固有の制約
両エンジン共通で、RTX 4070 では以下の制約があります。
-
--enforce-eagerまたは--disable-cuda-graphがほぼ必須: 4B クラスを 12GB に乗せる場合、CUDA graph のキャプチャで(batch_size × max_seq_len)のサンプルバッファを取られ、OOM の原因になります。Phase 1/2 では両エンジンとも CUDA graph 無効で計測しています。 -
FA3 (FlashAttention 3) は Hopper 系前提: SGLang の
fa3は SM 9.x (H100 など) 向けで、SM 8.9 では一部だけ動きます。後述する起動マトリクスでは「CUDA graph を切れば smoke は通った」というステータスで記録。 -
flash_attn+ 長文 (max_model_len 8192) の不安定: vLLM のflash_attnbackend は短い context (2048) では smoke が通るのに、8192を要求した瞬間に起動失敗するケースがありました。詳細は次節。
Phase 0: 起動可否マトリクス (smoke)
各 backend を一度だけ起動し、/health と 32 tokens の chat completion で素通しを確認した結果です。max_model_len = 2048 で測定。
| framework | backend | status | 備考 |
|---|---|---|---|
| vLLM | auto | smoke_ok | ログでは FLASH_ATTN が自動選択 |
| vLLM | flash_attn | smoke_ok | RTX 4070 で起動確認 |
| vLLM | flashinfer | smoke_ok | 起動確認、/metrics 取得可 |
| vLLM | triton_attn | smoke_ok | 起動確認、初回 JIT で TTFT スパイク |
| SGLang | auto | smoke_ok | ログでは flashinfer が選択 |
| SGLang | flashinfer | smoke_ok | 起動確認 |
| SGLang | triton | smoke_ok | 起動確認 |
| SGLang | fa3 | smoke_ok_with_caveat | default CUDA graph では失敗。--disable-cuda-graph で起動 |
ここまでは全 backend が「とりあえず動く」段階です。問題は次のフェーズで context を伸ばしたときに出ます。
Phase 1 起動時に出た問題: flash_attn × 長文 context の再現性
Phase 1 では同じ条件で max_model_len を 8192 まで伸ばしました。初回の計測ではすべての backend が起動して完走できたのですが、後日 nightly イメージを更新して 再現実行したとき、vllm flash_attn だけが profile_run 段階で 900 秒のタイムアウトに引っかかって起動できなくなりました。
| backend | 初回計測 (Phase 1) | 再現実行 (image 更新後) |
|---|---|---|
| vLLM flash_attn | ok | startup_timeout (900s) |
| vLLM flashinfer | ok | ok |
| vLLM triton_attn | ok | ok |
| SGLang flashinfer | ok | ok |
| SGLang triton | ok | ok |
max_model_len 8192 × enforce_eager × FP8 weight × 12GB VRAM という条件で、flash_attn だけがバージョンアップ後の profile_run を完走できなくなった、という状況でした。max_model_len を 4096 まで絞れば起動するため、KV cache pool が小さい運用なら現役で使える選択肢ですが、8192 を要求する条件では他 backend より安定性が一段落ちることは記録しておく価値があります。
次回 ② の比較データは初回計測のものを採用しています。flash_attn の数字も「初回は安定して取れた」事実に基づいて掲載します。
Phase 1 で測る対象
Phase 0 を通った backend のうち、Phase 1 の正規比較は次の 5 構成です。
| config | framework | backend | max_model_len |
|---|---|---|---|
| vllm_flash_attn | vLLM | flash_attn | 8192 |
| vllm_flashinfer | vLLM | flashinfer | 8192 |
| vllm_triton_attn | vLLM | triton_attn | 8192 |
| sglang_flashinfer | SGLang | flashinfer | 8192 |
| sglang_triton | SGLang | triton | 8192 |
workload は context 長で 3 段:
| workload | prompt tokens | max_tokens |
|---|---|---|
| short_ctx512 | ~540 | 256 |
| mid_ctx2048 | ~2,135 | 512 |
| long_ctx8192 | ~7,395 | 512 |
prompt は同じ日本語テキストを切り出して使い、tokenizer は両エンジンで RedHatAI/Qwen3.5-4B-FP8-dynamic の付属 tokenizer に揃えています。
ベンチクライアントの作り方
ありがちな失敗が「同じプロンプトを投げたつもりが、tokenizer 違いで実際の入力長がズレている」というものです。今回は次のように対策しました。
- 入力 token 数を測定用クライアント側で事前計算し、ベンチ条件として記録
- 各リクエストの API レスポンスに含まれる
usage.prompt_tokens/usage.completion_tokensを別途取得 - tokenizer 違いで 1〜2 tokens ずれることは許容しつつ、
completion_tokensが要求値 (256 / 512) と一致していることを毎回確認
中央値だけをサマリーにするのではなく、5 回分の生データ (framework / backend / workload / prompt_tokens / completion_tokens / elapsed_ms / tok_per_s) を保持しておくと、外れ値が混ざったときに後から確認できます。今回の比較は全てこの形で取得した上で集計しています。
計測上の注意点: warmup と Triton JIT
同じ RTX 4070 12GB + Qwen3.5-4B FP8 を題材にした別シリーズ ローカルゲーミングPCでLLM - VRAM 12GB 環境で FP8 KV Cache は実用投入できるか — テキスト性能評価 でも触れたとおり、Triton 系 backend は 初回リクエストでカーネルが JIT コンパイルされます。具体的な数字は次回 ② で出しますが、最初の 1 リクエストだけ TTFT が秒オーダーで突出する現象が出ます。
対策は単純で、各 workload 計測の直前に throwaway warmup を 1 回入れて、その結果は捨てます。
1. throwaway warmup (同 prompt / 同 max_tokens、結果は破棄)
2. 計測 × 5 回 → 中央値を summary に
warmup を入れるかどうかで vllm_triton_attn の short_ctx512 平均 tok/s が 1 割以上動くため、両エンジン両 backend で例外なく入れています。
peak VRAM の取り方
両エンジンとも /metrics を Prometheus 形式で公開していますが、ラベル付けが違うため数値の意味を完全に揃えにくいです。そこで今回は nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits の値を 200ms 間隔でサンプリングし、計測ウィンドウ中の最大値を peak としました。エンジン非依存に揃えるのが目的です。
実測 peak VRAM の詳細は ③ の結果と合わせて報告します。ここでは「vLLM は 10.2〜10.7 GB、SGLang は 10.8〜11.3 GB に収まる」とだけ述べておきます。SGLang が多めなのは --mem-fraction-static 0.85 で起動時に静的に確保するためです。
結論
本記事で揃えた準備の到達点は次のとおりです。
| 観点 | 状態 |
|---|---|
| 共通条件 | モデル / context / temperature / warmup / 計測回数を揃え済み |
| 起動可否 | vLLM 3 backend + SGLang 2 backend = 5 構成が正規比較に到達 |
| 安定性の注記 | vLLM flash_attn × max_model_len 8192 は再現実行で startup_timeout が出る挙動あり |
| caveat | SGLang fa3 は --disable-cuda-graph 限定で smoke のみ通過 |
| メモリの単位合わせ |
--gpu-memory-utilization と --mem-fraction-static は意味が違うため、peak VRAM 実測で横並びを担保 |
この準備編から持ち帰れる実用的な知見は以下の通りです。
-
両エンジンは「同じ役割の起動オプション」が違う名前 で生えている。
--gpu-memory-utilizationと--mem-fraction-staticは意味も違う。写経で揃えると条件がずれる -
RTX 4070 12GB で 4B を動かすには
--enforce-eager/--disable-cuda-graphがほぼ必須。CUDA graph キャプチャで OOM するため -
attention backend は smoke レベルではほぼ全て通るが、
max_model_len 8192のような尖った条件では vLLM のflash_attnが再現性を欠くケースがあった - Triton 系 backend は warmup 必須。初回 JIT で TTFT が秒オーダーで突出する
-
peak VRAM は
/metricsではなくnvidia-smiサンプリングで取る。エンジン間のラベル差を回避するため
ここまでで「同じ条件で 5 構成を比較できる土台」が整いました。準備編としてはここで一旦完結します。
次回 ② で何を測るか
次回 ② では、ここで揃えた 5 構成について context 長 512 / 2048 / 8192 の単発リクエスト性能を比較します。具体的には次の問いに答えます。
- attention backend (flash_attn / flashinfer / triton_attn) を変えると tok/s はどれだけ動くのか
- フレームワーク (vLLM vs SGLang) を変えるとどれだけ動くのか
- context 長を伸ばしたとき、両エンジンの優劣はどう変化するのか
「FlashAttention は速い」「Triton は遅い」といった巷の通説が、このスケールでどこまで成り立つのかを実測で確かめます。
実験環境: RTX 4070 12GB / Ubuntu / vLLM nightly (v0.21.1rc1.dev243) / SGLang lmsysorg/sglang:latest / モデル: RedHatAI/Qwen3.5-4B-FP8-dynamic
関連記事 (同じハードウェア・同じモデルでの FP8 KV cache 検証):