1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RTX 4090 × 4枚で Qwen3.8-27B を vLLM (Docker) で動かす — 262K コンテキストと reasoning_effort の設計

1
Last updated at Posted at 2026-08-14

はじめに

2026年8月、Alibaba の Qwen チームから Qwen3.8 シリーズが公開されました。本記事で扱う Qwen3.8-27B は、その中で唯一の「手元で動かせるサイズ」の dense モデルです。Apache 2.0、27B、ネイティブ 262,144 トークン(YaRN で最大 1M)、しかも画像と動画を理解するネイティブ VL モデルという構成になっています。

本記事では、このモデルを RTX 4090 24GB × 4枚(WSL2 + Docker)で BF16 フル精度・画像入力ありで動かす ところまでを扱います。

  • アーキテクチャは Qwen3.5 系のハイブリッドアテンションを継承しています。前回 Fable-Fusion-711(Qwen3.6-27B ベース)の記事 で書いた「64層中48層が線形アテンション」という KV 設計の話がそのまま効いてきます
  • 一方で Qwen3.6 からの非互換な差分が3つ あります。ここを踏まないと起動しないか、静かに性能を落とします
  • そして今回いちばん重要な発見として、公式の day-0 Docker タグと、公式レシピが推奨する MTP の両方が、RTX 4090 では地雷です。 どちらも融合 GDN カーネルを無効化します。エラーは出ません。ただ遅くなるだけです(第2節・第10節)
  • 新しい reasoning_effort と、既定でオンになった preserve_thinking は、24GB クラスのカードでは運用パラメータそのものです。単なる品質オプションではありません
  • ベンチマークも取りました。入力が短いうちは同時 8 で 183 tok/s と快適ですが、入力 16K で同時 8 にすると TTFT が 19 秒に跳ねます。 その理由まで含めて第8節に書きました

同じマシンでの検証は Muse Glimmer 30B の記事 が最初、Fable-Fusion-711 の記事 が2本目で、本記事が3本目になります。数値の比較は同一環境なので、あわせて読むと差分が見えると思います。

1. Qwen3.8-27B とは

1.1 モデル概要

公式モデルカードと config.json から拾った値です。

項目 内容
開発 Qwen Team(Alibaba)
リリース 2026年8月
ライセンス Apache 2.0
architectures Qwen3_5ForConditionalGeneration
model_type qwen3_5
総パラメータ数 27B(HF 表示は Vision encoder 込みで 28B params)
層数 64
Hidden dimension 5120
レイヤ構成 16 × (3 × (Gated DeltaNet → FFN) → 1 × (Gated Attention → FFN))
Gated Attention Q 24 heads / KV 4 heads、head dim 256、RoPE 次元 64(partial_rotary_factor 0.25)
Gated DeltaNet linear attention heads V 48 / QK 16、head dim 128
FFN 中間次元 17,408
語彙サイズ 248,320(padded)
コンテキスト長 262,144(ネイティブ)/ YaRN で最大 1,000,000
MTP Multi-Token Prediction ヘッドを同梱(multi-step で学習)
入出力 入力: テキスト + 画像 + 動画 / 出力: テキスト
BF16 チェックポイント 約 52 GiB(28B params × 2 byte)

Qwen3.6-27B と並べると、層数・hidden・FFN・語彙・full_attention_interval まで完全に同じです。骨格は Qwen3.5 系のまま、post-training とデータで殴っている世代、という理解でだいたい合っています。

1.2 ベンチマーク(公式)

公式が公開している抜粋です。比較対象に前回・前々回で扱ったモデルが入っているのが個人的にはありがたいところです。

テキスト

ベンチマーク Qwen3.8-27B Qwen3.6-27B Muse Glimmer-30B
Terminal Bench 2.1 73.0 63.4 51.7
SWE-bench Pro 61.7 53.5 51.2
DeepSWE 1.1 42.2 13.3
CoWorkBench 70.7 61.0
IFBench 79.5 69.1 77.0
GPQA Diamond 89.2 87.8 83.5
LiveCodeBench v6 90.3 83.9

VL

ベンチマーク Qwen3.8-27B Qwen3.6-27B Muse Glimmer-30B
OSWorld-Verified(Computer use) 84.3 63.9 65.9
AndroidWorld 81.9 70.3
OmniDocBench 1.5(文書) 91.1 89.4 75.8
CharXiv (RQ) 83.7(CI あり 90.2) 78.4 78.8
RealWorldQA 85.9 84.1

日本語の文書 OCR 用途で私が毎回見ている OmniDocBench 1.5 が 91.1。前回の Fable-Fusion-711 のベースである Qwen3.6-27B が 89.4、Muse Glimmer 30B が 75.8 だったので、24GB × 4 のローカル機で回せるモデルとしては現状かなり上位です。

なお SWE-bench Pro などは Claude Code harness・256K コンテキスト・temp 1.0 での評価と注記されています。ローカルで 64K に絞って測ると同じ数字は出ません。 ベンチの前提条件は必ずセットで読んでください。

1.3 Qwen3.6-27B からの差分(起動前に必ず読む)

Qwen3.6 の設定を流用しようとして私が引っかかった、あるいは引っかかりかけた差分が3つあります。

(a) vLLM のバージョン要件が上がっている

前回 Fable-Fusion-711 で使った vllm/vllm-openai:v0.21.0 では起動しません。Qwen3.8 系は vLLM 0.27.1 以上が必要です。さらに MTP(投機デコード)を使う場合は gated-delta-net 側の修正が必要で、これはリリースタグにまだ入っていません。

公式は day-0 の専用タグ vllm/vllm-openai:qwen38 を配布していますが、これは使わないでください。 このビルドでは融合 GDN decode カーネルが Ada で有効になりません。詳細と確認方法は第2節に書きました。結論だけ言うと nightly のコミット単位タグ nightly-ac7509e2b… を使います。

(b) transformers >= 5.8.0

config.json が transformers 5.8.0 で書かれています。vLLM 自体は独自の Qwen3_5Config でパースするので config 側は問題ないのですが、Qwen3-VL のプロセッサ(画像・動画の前処理)が古い transformers だと解決できません。公式イメージを使っていれば同梱版で満たされますが、自前環境に pip で入れている方は要確認です。

(c) attn_output_gate: true

これが地味に効きます。Qwen3.8-27B は attention の出力ゲートを q_proj に融合しており、q_proj[6144, 5120] ではなく [12288, 5120] になっています。後半の半分は residual に書き戻す値に対する乗算ゲートです。

  • 量子化との相性が悪い。乗算ゲートに乗る量子化誤差は、加算的な projection に乗る誤差より素直に悪化します。コミュニティの NVFP4 ビルドでも、この部分の扱いが品質差になっていると報告されています

なお、これで重みが目に見えて増えるかというとそうでもありませんでした。実測のチェックポイントは 51.75 GiB で、Qwen3.6-27B 世代とほぼ同じです。当初「1 GiB ほど増えるはず」と見積もっていましたが、外れています(第5節)。

BF16 で動かす分には (c) は「重みが 1 GiB 増える」だけの話です。量子化版に手を出すときに思い出してください。

1.4 KV の経済性:ここは Qwen3.6 と同じ

前回の記事で書いた内容がそのまま使えます。数字だけ再掲します。

KV キャッシュ(full attention 層のみ、トークン長に比例)

4 KV heads × 256 head_dim × 2 (K/V) × 2 byte = 4 KB / 層 / token
4 KB × 16 層 = 64 KB / token

線形アテンションの状態(シーケンス単位、トークン長に非依存)

48 value heads × 128 × 128 × 4 byte (fp32) = 3 MiB / 層 / シーケンス
3 MiB × 48 層 = 約 144 MiB / シーケンス

結論も同じです。

  • コンテキストを伸ばすのは安い(27B で 128K が 8 GiB)
  • 同時実行数を増やすのは高い(1 seq あたり 144 MiB が固定で乗る)

--max-num-seqs を安易に上げてはいけない、というのは Qwen3.5 系ハイブリッドに共通の作法です。

2. 検証環境

項目 内容
GPU NVIDIA GeForce RTX 4090 24GB × 4
GPU 接続 PCIe のみ(NVLink なし、P2P 非対応)
OS Windows + WSL2 (Ubuntu)
ドライバ 591.86 / CUDA 13.1
ホスト RAM WSL2 に約 114GB 割り当て
コンテナ Docker(nvidia runtime 有効)
イメージ vllm/vllm-openai:nightly-ac7509e2b…qwen38 タグではありません。理由は 2.1
vLLM 0.27.2rc1.dev77+gac7509e2b

前回・前々回とまったく同じ物理マシンです。イメージだけが毎回変わります。

2.1 day-0 タグ qwen38 を使ってはいけない理由

Muse Glimmer のときと同じパターンで、Qwen3.8 も 公式 bot が day-0 の専用タグを Docker Hub に push しています。

docker pull vllm/vllm-openai:qwen38

Docker Hub の layer 詳細から中身を確認しました。

項目 内容
push 元 vllmbot(vLLM 公式)
ベース Ubuntu 24.04 / CUDA 13.0.1 / Python 3.12
NCCL 2.30.7
FlashInfer 0.6.17(PR #4266 / #4455 のパッチ適用済み)
vLLM ビルド commit 3a0914114705fa38d4c3171d0746c1a6b6f10209
TORCH_CUDA_ARCH_LIST 7.5 8.0 8.6 8.9
圧縮サイズ 7.06 GB(amd64)
ENTRYPOINT ["vllm", "serve"]

TORCH_CUDA_ARCH_LIST に 8.9 が入っているので Ada 向けカーネルはビルドされています。ここまでは良さそうに見えます。私も一度これで書きかけました。

しかし、このビルドは融合 GDN decode カーネルが Ada で使えません。

モデルカードの Community #51 に、RTX 5090 × 4(SM120)での詳細な調査が投稿されています。そこで「影響を受けるビルド」として名指しされているのが 0.1.dev19754+g3a0914114 ―― まさにこの qwen38 タグです。

原因は vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py のこの1行です。

def _validate_fused_gdn_decode_kernel(self, vllm_config: VllmConfig) -> None:
    ...
    or not current_platform.is_device_capability_family(100)
    ):
        raise ValueError(...)

SM100 しか通しません。 RTX 4090 は 8.9 なので弾かれ、Triton/FLA の参照実装に落ちます。起動ログにはこう出ます。

qwen_gdn_linear_attn.py:157] Using Triton/FLA GDN prefill kernel (requested=auto, head_k_dim=128).
qwen_gdn_linear_attn.py:495] Qwen3.5 GDN decode kernel: triton

皮肉なことに、この融合カーネルの実体は fused_gdn_decode_kernel.compute_89 としてビルドされています。compute_89 はまさに Ada、つまり RTX 4090 そのものです。 自分のカード向けにコンパイル済みのカーネルが、SM100 判定ひとつで使えない状態でした。

現在の main ではこの判定が has_device_capability(80) に緩和されています。8.9 ≥ 8.0 なので通ります。同じ環境でログはこう変わります。

qwen_gdn_linear_attn.py:505] GDN decode kernel: cuda

というわけで、このモデルでは nightly を使います。 ただし nightly は毎日中身が変わるタグなので、そのまま使うと再現性がありません。digest で固定します。

2.2 コミット単位の不変タグで取得する

nightly は毎日中身が変わるタグなので、そのまま使うと再現性がありません。vLLM はコミットごとの不変タグも publish しているので、そちらを使います。

vllm/vllm-openai:nightly-<40桁のコミットSHA>

私が使ったのはこれです。

docker pull vllm/vllm-openai:nightly-ac7509e2b1db40fec2f03dde1ed4e9dfdc2338c9
docker tag  vllm/vllm-openai:nightly-ac7509e2b1db40fec2f03dde1ed4e9dfdc2338c9 \
            vllm-openai:qwen38-verified

このコミットを選んだ理由は、単に新しいからではありません。 2.1 で引用した Community #51 の実測 ―― GDN decode kernel: cuda が出て 72.3 tok/s が出た、あのビルドがまさに ac7509e2b です。「動くことが公開の場で確認されている唯一のビルド」を選んだ、というのが理由です。

日々の nightly はこれより新しいので、GDN 修正が消えることはまずありません。ただし修正以外の変更が何十コミットも入っているので、何かおかしいときの切り分け対象が増えます。まず既知の良品で動かして、そこから前に進むほうが早いです。

以降はこの vllm-openai:qwen38-verified をスクリプトから参照します。タグ名に「検証済み」と書いてあることに意味があります。 次節のチェックを通ったイメージだけがこの名前を持つ、というルールにしておくと事故が減ります。

2.3 別のビルドを使う場合は digest で固定する

新しい nightly を試したくなったときは、タグ名ではなく digest で固定してください。まず当日の digest を調べます。

docker manifest inspect vllm/vllm-openai:nightly \
  | python3 -c "import sys,json; m=json.load(sys.stdin); print([x['digest'] for x in m['manifests'] if x['platform']['architecture']=='amd64' and x['platform']['os']=='linux'][0])"

manifest だけを取得するので数 KB です。イメージ本体はまだ落ちてきません。

DIGEST=sha256:xxxxxxxx
docker pull vllm/vllm-openai@${DIGEST}

回線の都合で Docker Hub から直接引けない場合は、pull-through ミラー経由でも構いません。不変タグか digest 指定なら内容はハッシュで検証されるので、どこから引いても中身が同一であることが保証されます。

docker pull <ミラーのホスト>/vllm/vllm-openai:nightly-ac7509e2b1db40fec2f03dde1ed4e9dfdc2338c9

逆に nightly という動くタグ名でミラーから引くのは避けてください。 ミラーはタグ → digest の対応をキャッシュしており、その TTL は外からは分かりません。数日前のビルド(= GDN 問題が残っているビルド)が返ってくる可能性があります。 しかもエラーは出ないので、気づくのは性能を測ったときです。

2.4 起動前に 5 秒で判定する

52 GiB のモデルをロードしてから「Triton でした」と分かるのは時間の無駄です。イメージの中のソースを直接見れば、モデルを触らずに数秒で判定できます。

docker run --rm --entrypoint grep vllm-openai:qwen38-verified -n "device_capability" \
  /usr/local/lib/python3.12/dist-packages/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py

実際の出力です。

127:    if current_platform.is_device_capability(90):
130:        current_platform.is_device_capability_family(100)
164:    if active_backend == "flashinfer" and current_platform.is_device_capability(90):
524:            or not current_platform.has_device_capability(80)

見るべきは最後の1行だけです。

判定行 意味
has_device_capability(80) がある 合格。 8.9 ≥ 8.0 なので Ada が通ります
代わりに is_device_capability_family(100) になっている 不合格。2.2 のタグを引き直してください

ここで引っかからないでください。 修正済みのビルドでも is_device_capability_family(100) は 130 行目に残っています。そちらは prefill バックエンドの選択ロジックで、decode カーネルの判定とは別物です。 「100 という文字列があるからダメだ」と読むと誤判定します。

判定行だけを直接見るならこうします。

docker run --rm --entrypoint grep vllm-openai:qwen38-verified -n -A2 \
  "_validate_fused_gdn_decode_kernel" \
  /usr/local/lib/python3.12/dist-packages/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py

ついでに分かることがひとつ。127 / 130 / 164 行目が示しているのは、GDN の prefill カーネルが SM90(Hopper)と SM100 ファミリーにしか開かれていない、ということです。 ここは修正されていません。つまり RTX 4090 では、decode は融合カーネルに乗りますが prefill は Triton/FLA のままです。 #51 の指摘どおりで、ソースを見ても確かにそうなっています。

キャッシュが効く使い方なら影響は限定的ですが、長いプロンプトを毎回投げる用途では TTFT がこの制約に縛られます。 第11節で FP8 の TTFT を「横ばい〜やや悪化」と見積もっているのは、これも理由のひとつです。

ビルドされている vLLM のバージョンも見ておきます。

docker run --rm --entrypoint python3 vllm-openai:qwen38-verified -c "import vllm; print(vllm.__version__)"

0.1.dev19754+g3a0914114 と出たら qwen38 タグのビルドです。ミラー経由でタグ指定した場合などに起こり得ます。

パスは site-packages ではなく dist-packages です。Ubuntu ベースのイメージなので、システム Python 側の慣習に従っています。私はここで一度つまずきました。イメージのバージョンによって変わる可能性もあるので、No such file or directory が出たら探してください。

docker run --rm --entrypoint find vllm-openai:qwen38-verified / -name "qwen_gdn_linear_attn.py" 2>/dev/null

2.5 起動後にも確認する

事前チェックを通っていても、起動ログで最終確認します。私の環境(RTX 4090 × 4、SM89)で実際に出た2行です。

qwen_gdn_linear_attn.py:158] Using Triton/FLA GDN prefill kernel (requested=auto, head_k_dim=128).
qwen_gdn_linear_attn.py:505] GDN decode kernel: cuda

GDN decode kernel: cuda ―― Ada でも融合カーネルに乗りました。 #51 の報告は RTX 5090(SM120)でのものだったので、SM89 での確認は私の知る限りこれが最初です。同時に prefill は Triton/FLA のままで、2.4 節でソースを読んで予想したとおりの結果になっています。

docker logs qwen38-vllm 2>&1 | grep -i "GDN decode kernel"
出力 意味
GDN decode kernel: cuda 正常。 融合カーネルが効いています
GDN decode kernel: triton 参照実装に落ちています。2.2 からやり直してください

triton のとき何が起きているかは、nvidia-smi dmon を見るとはっきりします。#51 の実測値です。

sm 100%   mem 9-11%   pwr ~150W / 575W

SM が振り切れているのにメモリコントローラはほぼ遊んでいて、消費電力は TDP の 1/4。 帯域律速でも演算律速でもなく、細かいカーネルが大量に直列実行されている状態です。64層中48層が GatedDeltaNet なので、ここが参照実装に落ちると1ステップまるごと支配されます。

2.6 検証が通ったらイメージを保存しておく

不変タグを使っているので nightly のように中身が入れ替わることはありませんが、nightly 系のタグはいつまで残るか保証がありません。 一度検証を通したイメージは、その場でファイルに落としておくと安心です。

docker save vllm-openai:qwen38-verified | zstd -T0 > ~/vllm-qwen38-verified.tar.zst
# 復元
zstd -dc ~/vllm-qwen38-verified.tar.zst | docker load

そして当然ですが、docker pull vllm/vllm-openai:nightly を定期実行するような運用にはしないでください。ある朝いきなり別のビルドになります。

2.7 バージョン選択のまとめ

ビルド 日付 GDN 判定 判断
v0.21.0(FF711 で使用) モデルがロードできない
v0.27.1(最新リリース) 2026/08/11 緩和前 serve はできるが Triton に落ちる
qwen38(day-0 タグ) 2026/08/13 緩和前 #51 が名指しした問題のビルド
nightly-ac7509e2b… 2026/08/14 緩和後 これを使う。#51 が実測したビルド

v0.27.2 はまだ存在しません。 nightly のバージョン文字列に出てくる 0.27.2rc1.devNN は次のリリース番号から逆算された開発版表記であって、そういうタグが切られているわけではありません。つまり 「GDN 修正が入った安定版」という選択肢は現時点で存在しない ということです。安定版を取るか性能を取るかの二択で、私は後者を選び、代わりに digest 固定と docker save で再現性を担保しました。

最後にもうひとつ、ENTRYPOINT が vllm serve に変わっています。 従来の python3 -m vllm.entrypoints.openai.api_server ではありません。--model は引き続き受理されるので既存スクリプトはそのまま動きますが、docker run ... <image> --help の出力が変わるので混乱しないでください。

3. 事前準備

3.1 GPU を空ける

BF16 約 52 GiB を TP=4 で分散するので、4枚とも空いている必要があります。

nvidia-smi
docker ps

私の環境では MinerU と OCR のコンテナが GPU0 / GPU3 を掴んでいるので、毎回止めています。

docker update --restart=no <container-name>
docker stop <container-name>

3.2 WSL2 のメモリ確認

BF16 52 GiB を読み込むので、ホスト側のメモリが足りないとロード中に固まります。

free -g

足りない場合は Windows 側の C:\Users\<user>\.wslconfig を編集して wsl --shutdown します。

[wsl2]
memory=64GB
swap=32GB

3.3 モデルのダウンロード

Hugging Face から取る場合:

pip install -U "huggingface_hub[cli]"
hf download Qwen/Qwen3.8-27B \
  --local-dir /root/HuggingFaceCache/Qwen3.8-27B

ModelScope ミラーから取る場合:

pip install -U modelscope
modelscope download --model Qwen/Qwen3.8-27B \
  --local_dir /root/HuggingFaceCache/Qwen3.8-27B

WSL2 では /mnt/c/...(9p 経由)ではなく /root/...(ext4)に置いてください。読み込み速度が段違いです。

ダウンロード後、ファイルの欠落を必ず確認します。 前回 ModelScope のコミュニティミラーで preprocessor_config.json などが欠けていて起動できなかった経験があるので、それ以来こうしています。

cd /root/HuggingFaceCache/Qwen3.8-27B
for f in config.json generation_config.json chat_template.jinja \
         preprocessor_config.json video_preprocessor_config.json \
         tokenizer.json vocab.json model.safetensors.index.json; do
  [ -f "$f" ] && echo "OK   $f" || echo "MISS $f"
done

Qwen 公式リポジトリであれば全部揃っています。サードパーティのミラーやファインチューンから取った場合は、欠けていたら公式の Qwen/Qwen3.8-27B から同名ファイルを持ってきてください。

4. ハマりどころ

4.1 NVFP4 も MXFP4 も Ada では使えない

界隈では NVFP4 版の話をよく見かけますし、公式レシピも NVFP4 なら 1枚に収まる(24.6 GiB)と書いています。ただし NVFP4 のカーネルは Blackwell 専用です。RTX 4090 は Ada(compute capability 8.9)なので使えません。MXFP4 も vLLM 側の実装がこのアーキテクチャで読めません。

前々回の Muse Glimmer の記事にも同じことを書きましたが、毎回引っかかるポイントなので再掲しておきます。4090 で選べるのは BF16 か FP8 の2択です(FP8 については第11節)。

4.2 動画の longest_edge は絶対に上げない

画像入力を使う人が今回いちばん気をつけるところです。

公式モデルカードには、時間スケールの動画を扱うなら video_preprocessor_config.jsonlongest_edge469,762,048(= 224k video token 相当) に上げることを推奨する、という記述があります。

{"longest_edge": 469762048, "shortest_edge": 4096}

これはデータセンター級のメモリを前提にした推奨です。24GB × 4 の構成で真似すると即座に破綻します。 vLLM は起動時の profiling で最悪ケース分の encoder budget を予約するので、実際に動画を投げなくても KV キャッシュを削られます。

対処は前回と同じ考え方です。まず動画を使わないなら、そもそも予約させません。

--limit-mm-per-prompt '{"image":3,"video":0}'

画像側の preprocessor_config.json も、Qwen3.6 と同じ patch 16 / merge 2 であれば 1 画像あたりのトークン数は「ピクセル数 ÷ 1024」 です。既定の longest_edge: 16777216 は 1 画像で 16,384 トークンを意味します。

longest_edge 1画像あたり 3画像合計
16,777,216(既定) 16,384 tok 49,152 tok
4,194,304 4,096 tok 12,288 tok
2,097,152 2,048 tok 6,144 tok

スクリーンショットや文書 1 ページなら 4,194,304 で十分です。

設定できているかは起動ログで確認できます。

Encoder cache will be initialized with a budget of 16384 tokens,
and profiled with 1 image items of the maximum feature size.

この 16384 は既定値(16,777,216 ÷ 1024)です。つまりこの行が出ているうちは、下の変更がまだ効いていません。 私は一度これを忘れたまま起動して、ログを見て気づきました。正しく反映されていれば budget of 4096 tokens になります。

cd /root/HuggingFaceCache/Qwen3.8-27B
python3 - << 'EOF'
import json
p = 'preprocessor_config.json'
d = json.load(open(p))
d['size']['longest_edge'] = 4194304
json.dump(d, open(p, 'w'), indent=4)
print(d['size'])
EOF

--mm-processor-kwargs で渡す方法もありますが、ビルドによって受理されるキーが違うので、設定ファイルを直接書き換えるほうが確実です。

4.3 Prefix caching と Mamba 層の組み合わせは相変わらず experimental

起動ログに出ます。

Mamba cache mode is set to 'align' for Qwen3_5ForConditionalGeneration
by default when prefix caching is enabled
Warning: Prefix caching in Mamba cache 'align' mode is currently enabled.
Its support for Mamba layers is experimental.

64 層中 48 層が線形アテンションなので、この experimental の重みは軽くありません。マルチターンで出力が微妙におかしくなったら、まず --enable-prefix-caching を外して切り分けてください。

ただし今回は preserve_thinking が既定オンで、これは prefix cache のヒット率を上げるための機能でもあります。両方を同時に疑わないよう、切り分けは片方ずつ。

4.4 無視してよい警告

[transformers] `Qwen2VLImageProcessorFast` is deprecated.

preprocessor_config.jsonimage_processor_type が旧名のまま。互換動作します。

Custom allreduce is disabled because it's not supported on more than two PCIe-only GPUs

→ 4090 は P2P 非対応なので PYNCCL バックエンドが選ばれます。動作に問題はありませんが、通信は遅くなります。ここは構成上どうにもなりません。

SymmMemCommunicator: Device capability 8.9 not supported, communicator is not available.

→ 4090 は symmetric memory 非対応。PYNCCL にフォールバックします。上の custom allreduce と同じ話です。

[ERROR] `min_frames` is part of Qwen3VLVideoProcessorInitKwargs, but not documented.
[ERROR] `max_frames` is part of Qwen3VLVideoProcessorInitKwargs, but not documented.

[ERROR] と出ますが無害です。 transformers の docstring 検証が引っかかっているだけで、動作には影響しません。worker ごとに何度も出るので驚きますが、放置して構いません。

tl.make_block_ptr is deprecated. Use TensorDescriptor or tl.make_tensor_descriptor instead.

→ Triton の非推奨警告。GDN の prefill が Triton 実装なので出ます(2.4 節)。

Using 'pin_memory=False' as WSL is detected

→ WSL2 検出。0.21.0 以降は素直にフォールバックしてくれるので、Muse Glimmer のときのような UVA is not available クラッシュは起きません。

なお Multi-modal warmup completed in 19.242s と出れば正常です。Muse Glimmer のときに出ていた Multi-modal warmup failed は、このモデル・このビルドでは発生しませんでした。

5. メモリ設計

5.1 実測値

まず起動ログから拾えた実測値です。

Checkpoint size: 51.75 GiB
Model loading took 13.11 GiB memory and 21.452167 seconds
Free memory on device (22.26/23.99 GiB) on startup.
Desired GPU memory utilization is (0.88, 21.11 GiB).
Actual usage is 13.37 GiB for consumed memory (weights + non-torch),
1.4 GiB for peak activation, and 0.17 GiB for CUDAGraph memory.
Available KV cache memory: 6.34 GiB
GPU KV cache size: 399,242 tokens
Maximum concurrency for 131,072 tokens per request: 3.05x

1枚あたりの内訳はこうなります。

用途 実測
重み + non-torch 13.37 GiB
peak activation 1.4 GiB
CUDA graph 0.05〜0.17 GiB
KV キャッシュ 6.34 GiB
合計(util 0.88 の枠) 21.11 GiB

チェックポイントが 51.75 GiB、TP=4 で 13.11 GiB / 枚。4.1 節で「attn_output_gate のぶん重みが増える」と書きましたが、実測では Qwen3.6-27B 世代とほぼ同じサイズでした。 ここは私の見立てが外れています。

5.2 KV の内訳を計算で確かめる

1.4 節の計算と突き合わせます。

KV プール合計   6.34 GiB × 4 = 25.36 GiB
線形状態        144 MiB × 8 seq = 1.13 GiB
差し引き        24.23 GiB
24.23 GiB ÷ 64 KB/token ≒ 39.7 万 token

実測の 399,242 token とよく一致します。1.4 節の「full attention 16 層だけが 64 KB/token」という見積もりが正しいことの裏取りになっています。

前回 Fable-Fusion-711(Qwen3.6-27B ベース)の実測が 391,759 token だったので、ほぼ同じ着地です。

ログにはこんな行も出ます。

Setting attention block size to 784 tokens to ensure that attention page size is >= mamba page size.
Padding mamba page size by 0.13% to ensure that mamba page size and attention page size are exactly equal.

ハイブリッドモデル特有の挙動です。 通常の attention だけのモデルならブロックサイズは 16 程度ですが、GatedDeltaNet の状態ページに合わせるため 784 トークン単位になります。短いリクエストが大量に来る用途では、ブロック内の端数が無駄になる点は頭に入れておいてください。

5.3 コンテキスト長を決める

プールが約 40 万トークンなので、コンテキストは同時実行数と直接トレードオフします。

--max-model-len 同時実行数
65,536 約 6.1x
131,072 3.05x(実測)
262,144 約 1.5x
1,000,000 起動しない

262K は諦めます。 ネイティブ対応しているのは事実ですが、この構成では実質シングルセッション専用機になります。まして 1M(YaRN)は、KV だけで 64 GB 必要なので論外です。

私は 131,072 を選びました。理由は3つ。

  1. 画像 3 枚で 12,288 トークンを消費する(4.2 節の設定時)
  2. reasoning_effort の既定が xhigh で、思考が長い(第9節)
  3. preserve_thinking が既定オンで、マルチターンの履歴に思考が積み上がる

64K だとエージェント用途で数ターンで詰まります。

なお、この「262K を諦める」という判断は BF16 前提の話です。 FP8 版なら KV プールが 2 倍になるので結論が変わります(第11節)。

5.4 KV プールを固定して 1 割増やす

ここが一番おいしい調整です。 vLLM は起動ログで、いくつにすればいいかを自分で教えてくれます。

Replace gpu_memory_utilization config with `--kv-cache-memory=6599518843` (6.15 GiB)
to fit into requested memory, or `--kv-cache-memory=7834867200` (7.3 GiB)
to fully utilize gpu memory. Current kv cache memory in use is 6.34 GiB.

つまり util 0.88 はまだカードを使い切っていません。「fully utilize」まで上げれば 6.34 → 7.3 GiB です。

ただしこの行は rank ごとに違う値が出ます。 私の環境では rank0 が 7.3 GiB(7834867200)、rank1〜3 が 7.18 GiB(7704512000)でした。小さいほうに合わせないと、一部の rank だけ OOM します。

さらに GPU0 はディスプレイ出力も担当しており、Windows 側の操作で数百 MiB 単位で変動します。実測上限ぴったりを入れると「ある日ブラウザを開いたら起動しなくなる」ので、少し余裕を残します。

KV_CACHE_MEMORY="${KV_CACHE_MEMORY:-7516192768}"   # 7.0 GiB

期待できる効果です。

既定(util 0.88 まかせ) --kv-cache-memory 固定
KV / 枚 6.34 GiB 7.0 GiB
KV トークン数 399,242 約 44 万
131,072 での同時実行 3.05x 約 3.36x

起動時間も縮みます。 固定すると profiling を丸ごとスキップできるからです。私の初回起動は init engine (profile, create kv cache, warmup model) took 138.39 s でした。

起動しなくなったら 7000000000(6.52 GiB)あたりまで下げてください。

なお、CUDA graph の見積もりが過大なのも効いています。ログの Estimated CUDA graph memory: 0.23 GiB に対し実測は 0.05〜0.17 GiB でした。vLLM 自身がこう言っています。

The current --gpu-memory-utilization=0.8800 is equivalent to
--gpu-memory-utilization=0.8705 without CUDA graph memory profiling.
To maintain the same effective KV cache size as before, increase to 0.8895.

--kv-cache-memory を固定すればこの差は関係なくなるので、そちらで解決するのが素直です。

5.5 コンパイルキャッシュを永続化する

初回起動のうち 39 秒は torch.compile です。キャッシュは保存されています。

saved AOT compiled function to /root/.cache/vllm/torch_compile_cache/...
torch.compile took 38.74 s in total

ただし docker run --rm だとコンテナごと消えるので、毎回コンパイルし直しになります。 ホスト側にマウントしてください。

-v /root/.cache/vllm:/root/.cache/vllm \

2 回目以降の起動が目に見えて速くなります。

6. 起動スクリプト

~/myvllm/qwen38-vllm.sh として保存し chmod +x してください。前回の ff711-vllm.sh からの差分は、イメージ・limit-mm-per-prompt の video 0・--kv-cache-memory の固定・MTP を使わないことの4点です。

すべての値は第8節のベンチマーク結果に基づいています。 長文入力向けに PROFILE=longctx も用意しました。

#!/usr/bin/env bash
set -euo pipefail

MODEL_DIR="${MODEL_DIR:-/root/HuggingFaceCache/Qwen3.8-27B}"
# Local tag for the immutable nightly build. NOT the qwen38 day-0 tag: that
# build gates the fused GDN decode kernel behind is_device_capability_family(100),
# so Ada (8.9) silently falls back to Triton. Verified on this box:
# "GDN decode kernel: cuda". See section 2.
#   docker pull vllm/vllm-openai:nightly-ac7509e2b1db40fec2f03dde1ed4e9dfdc2338c9
#   docker tag  vllm/vllm-openai:nightly-ac7509e2b1db40fec2f03dde1ed4e9dfdc2338c9 vllm-openai:qwen38-verified
IMAGE="${IMAGE:-vllm-openai:qwen38-verified}"
SERVED_MODEL_NAME="${SERVED_MODEL_NAME:-qwen3.8-27b}"

# default | longctx
# default: measured 183 tok/s at concurrency 8 (in 1024 / out 256), TPOT 35ms.
# longctx: 16K-token inputs starve decode because the GDN *prefill* kernel is
#          still Triton/FLA on Ada. At concurrency 8 with 16K input this box
#          gives TTFT p50 19s / p99 53s. Smaller chunks + fewer seqs. UNMEASURED.
PROFILE="${PROFILE:-default}"
PORT="${PORT:-8000}"
API_KEY="${API_KEY:-sk-dummy}"

# Fixed 4-GPU setup. Measured: checkpoint 51.75GiB, 13.11GiB/card at TP=4,
# 13.37GiB/card once non-torch allocations are counted.
GPU_DEVICES="${GPU_DEVICES:-3,2,1,0}"
TP_SIZE="${TP_SIZE:-4}"

# Native 262144, but the KV pool here is 440,673 tokens total (measured).
# 131072 gives 3.36x concurrency; 262144 makes this a single-session box.
MAX_MODEL_LEN="${MAX_MODEL_LEN:-131072}"

# GPU0 also drives the display (~600MiB, fluctuates). 0.90 is the ceiling.
GPU_MEMORY_UTILIZATION="${GPU_MEMORY_UTILIZATION:-0.88}"

# 48 of 64 layers are Gated DeltaNet: ~144MiB/seq recurrent state that does
# NOT shrink with context. Long context is cheap here; concurrency is not.
# At in=1024 the TPOT penalty from 1 -> 8 seqs is only +12% (31.1 -> 35.0ms),
# so 8 is nearly free. At in=16384 it is not: see section 8.2.
if [ "${PROFILE}" = "longctx" ]; then
  MAX_NUM_SEQS="${MAX_NUM_SEQS:-4}"
  MAX_NUM_BATCHED_TOKENS="${MAX_NUM_BATCHED_TOKENS:-4096}"
else
  MAX_NUM_SEQS="${MAX_NUM_SEQS:-8}"
  MAX_NUM_BATCHED_TOKENS="${MAX_NUM_BATCHED_TOKENS:-8192}"
fi

# util 0.88 alone only yields 6.34GiB/card = 399,242 tokens = 3.05x.
# Pinning 7.0GiB gives 440,673 tokens = 3.36x (both measured on this box).
# The log reports 7.18GiB/card as "fully utilize" on ranks 1-3 and 7.3 on
# rank 0 - take the min and keep headroom for GPU0's display output.
# Also skips profiling, so startup is faster and deterministic.
# Leave empty on the very first run, then pin from your own log.
KV_CACHE_MEMORY="${KV_CACHE_MEMORY:-7516192768}"

# video:0 matters. vLLM reserves worst-case encoder budget at profiling time,
# and the official long-video longest_edge is 469,762,048 (~224k tokens).
LIMIT_MM_PER_PROMPT="${LIMIT_MM_PER_PROMPT:-{\"image\":3,\"video\":0}}"

# Keep this false. On Qwen3.8-27B, MTP makes the fused GDN decode kernel
# unreachable (the code requires num_v_heads == 8 * num_k_heads; this model
# is 48/16 = 3), and every step becomes a spec step, so the non-speculative
# fused path is never taken either. Measured as a ~3.6x net LOSS. See section 10.
ENABLE_MTP="${ENABLE_MTP:-false}"
NUM_SPECULATIVE_TOKENS="${NUM_SPECULATIVE_TOKENS:-3}"

ENABLE_TOOL_CALL="${ENABLE_TOOL_CALL:-true}"
# "Prefix caching in Mamba cache 'align' mode is experimental".
# If multi-turn output goes subtly wrong, set this to false FIRST.
ENABLE_PREFIX_CACHING="${ENABLE_PREFIX_CACHING:-true}"

if [ ! -d "${MODEL_DIR}" ]; then
  echo "ERROR: model directory not found: ${MODEL_DIR}"
  exit 1
fi

# Third-party mirrors have shipped incomplete config sets before.
MISSING=()
for f in preprocessor_config.json video_preprocessor_config.json \
         generation_config.json chat_template.jinja; 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

VLLM_ARGS=(
  # positional: --model is deprecated with the `vllm serve` entrypoint
  /models/Qwen3.8-27B
  --served-model-name "${SERVED_MODEL_NAME}"
  --tensor-parallel-size "${TP_SIZE}"
  --max-model-len "${MAX_MODEL_LEN}"
  --gpu-memory-utilization "${GPU_MEMORY_UTILIZATION}"
  --max-num-seqs "${MAX_NUM_SEQS}"
  --max-num-batched-tokens "${MAX_NUM_BATCHED_TOKENS}"
  --limit-mm-per-prompt "${LIMIT_MM_PER_PROMPT}"
  --generation-config auto
  --api-key "${API_KEY}"
  --host 0.0.0.0
  --port "${PORT}"
)

[ -n "${KV_CACHE_MEMORY}" ] && VLLM_ARGS+=(--kv-cache-memory "${KV_CACHE_MEMORY}")

if [ "${ENABLE_PREFIX_CACHING}" = "true" ]; then
  VLLM_ARGS+=(--enable-prefix-caching)
fi

if [ "${ENABLE_MTP}" = "true" ]; then
  VLLM_ARGS+=(--speculative-config \
    "{\"method\":\"mtp\",\"num_speculative_tokens\":${NUM_SPECULATIVE_TOKENS}}")
fi

# Qwen3.8 thinks by default and emits <think>...</think>.
# No --chat-template: the checkpoint ships chat_template.jinja itself.
if [ "${ENABLE_TOOL_CALL}" = "true" ]; then
  VLLM_ARGS+=(
    --enable-auto-tool-choice
    --reasoning-parser qwen3
    --tool-call-parser qwen3_coder
  )
fi

docker run --rm -it \
  --name qwen38-vllm \
  --gpus "\"device=${GPU_DEVICES}\"" \
  --network host \
  --ipc=host \
  --shm-size 32g \
  --ulimit memlock=-1 \
  --ulimit stack=67108864 \
  -v "${MODEL_DIR}:/models/Qwen3.8-27B:ro" \
  -v /root/.cache/vllm:/root/.cache/vllm \
  -e CUDA_DEVICE_ORDER=PCI_BUS_ID \
  -e CUDA_VISIBLE_DEVICES=0,1,2,3 \
  -e PYTORCH_NVML_BASED_CUDA_CHECK=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 VLLM_WORKER_MULTIPROC_METHOD=spawn \
  -e NCCL_P2P_DISABLE=1 \
  -e NCCL_IB_DISABLE=1 \
  -e NCCL_SHM_DISABLE=0 \
  "${IMAGE}" \
  "${VLLM_ARGS[@]}"

各設定の意図

設定 理由
--tensor-parallel-size 4 BF16 52.7GiB を4枚に分散。PP は 24GB カードでは OOM(Muse Glimmer で検証済み)
--max-model-len 131072 262K だと同時実行 1.5x まで落ちる。画像・思考のトークン消費も考慮
--gpu-memory-utilization 0.88 GPU0 の画面出力分の余裕。0.90 に上げても得られるのは数万トークン
--max-num-seqs 8 線形アテンションのシーケンス状態が 144MiB/seq と高価。4 に下げても 0.6GiB しか戻らない
--max-num-batched-tokens 8192 encoder budget と連動。画像を複数枚扱うので下げすぎない
--limit-mm-per-prompt '{"image":3,"video":0}' 動画の worst-case 予約を殺すのが目的。今回いちばん効く
--reasoning-parser qwen3 既定で <think> を出力する
--tool-call-parser qwen3_coder Qwen 公式の vLLM レシピと同じ
--generation-config auto 同梱の generation_config.json(temperature 1.0 / top_p 0.95 / top_k 20)を採用
--speculative-config 既定オフ イメージ側には入っているはずだが、受理率を測るまで採用しない(第10節)
NCCL_P2P_DISABLE=1 4090 はハードウェアとして P2P 非対応
--trust-remote-code 不要 このイメージではネイティブサポート済み

7. 動作確認

7.1 テキスト

curl -s localhost:8000/v1/chat/completions \
  -H "Authorization: Bearer sk-dummy" \
  -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 に分離されて返ってきます。parser が効いている証拠です。

7.2 思考モードの切り替え

{
  "chat_template_kwargs": {"enable_thinking": false}
}

非思考(instruct)モードで使う場合、サンプリングパラメータは公式推奨が別になります。--generation-config auto でサーバ側に入るのは思考モードの値なので、クライアントから明示的に送る必要があります。

用途 temperature top_p top_k presence_penalty
思考モード 1.0 0.95 20 0.0
instruct(思考なし) 0.7 0.80 20 1.5

7.3 画像入力

curl -s localhost:8000/v1/chat/completions \
  -H "Authorization: Bearer sk-dummy" \
  -H "Content-Type: application/json" -d '{
  "model": "qwen3.8-27b",
  "messages": [{"role": "user", "content": [
    {"type": "image_url", "image_url": {"url": "https://example.com/invoice.png"}},
    {"type": "text", "text": "この請求書の請求元・請求金額・支払期限を JSON で抽出してください。"}
  ]}],
  "temperature": 1.0, "top_p": 0.95, "max_tokens": 2048
}'

usage.prompt_tokens を必ず確認してください。4.2 節の longest_edge を変更していないと、1 枚で 16,384 トークン持っていかれます。

7.4 OCR 用途での注意:誤字が勝手に直る

日本語文書の OCR が本命用途なので、これは書いておかないといけません。

Community #61 に、原文の誤字を明示的に「そのまま出力せよ」と指示しても、モデルが勝手に修正してしまうという報告が上がっています。解像度やエンコーダを変えても再現するとのことで、執筆時点で回答は付いていません。

OmniDocBench 1.5 が 91.1 という数字はレイアウト理解とテキスト抽出の総合力の話であって、「原文への忠実さ」を保証するものではありません。 ベンチマークが高いことと、1文字も変えずに転記することは別の能力です。

請求書や申込書のように原本の表記をそのまま残す必要がある業務では、ここは事前に検証すべきポイントです。私が試すつもりの緩和策は3つ。

  • enable_thinking: false にする(思考の過程で「直したほうが自然」と判断している可能性がある)
  • temperature を下げる(公式推奨から外れますが、抽出タスクでは妥当な範囲です)
  • 出力を JSON スキーマに制約し、「読み取った文字列」と「正規化後の文字列」を別フィールドとして両方出させる

3つめは根本対策に近いと思っています。モデルに「直すな」と言うより、「直す前と後の両方を出せ」と言うほうが素直です。 検証したら別記事にします。

7.5 動画入力(使うなら別インスタンスで)

--limit-mm-per-promptvideo:0 にしているので、上記のサーバは動画を受け付けません。動画を試すなら、コンテキストと同時実行数を大幅に下げた別プロファイルを用意してください。同居させると画像・テキストの性能を丸ごと道連れにします。

8. 性能実測

vllm bench serve で同時実行 1 / 4 / 8 を測りました。このマシンの性格がはっきり出たので、設定を決める前にこちらを読んでください。

測定条件は --ignore-eos 付き、サンプリングはサーバ既定(temperature 1.0 / top_p 0.95 / top_k 20)です。思考モードは既定のままオンなので、出力トークンの中身はほぼ思考です。

8.1 入力 1024 / 出力 256

同時実行 出力 tok/s 合計 tok/s TTFT p50 TTFT p99 TPOT p50 ITL p99
1 31.0 161.5 405 ms 420 ms 31.1 ms 32.9 ms
4 106.1 552.3 483 ms 1,677 ms 33.5 ms 35.5 ms
8 183.2 953.5 1,279 ms 3,549 ms 35.0 ms 37.7 ms

1本あたりに直すと 31.0 → 26.5 → 22.9 tok/s、スループットは同時 8 で 5.90 倍に伸びます。

注目すべきは TPOT の平坦さです。 同時実行を 8 倍にしても 31.1 → 35.0 ms、わずか +12% しか悪化していません。これは decode が完全に帯域律速であることの証拠で、バッチングがほぼタダで効いている状態です。1.4 節で書いた「線形アテンションのおかげでコンテキストは安い」という設計が、そのまま性能に出ています。

理論値と突き合わせておきます。重み 51.75 GiB を 4 枚で割ると 12.94 GiB / 枚 / ステップ、RTX 4090 の帯域を約 939 GiB/s とすると 13.8 ms、つまり 72 tok/s が上限です。実測 31 tok/s(32 ms)なので、差分の約 18 ms が allreduce とカーネル起動のオーバーヘッドということになります。P2P 非対応の PCIe 4枚構成では、ここは構造的に取り返せません。

8.2 入力 16384 / 出力 128 — ここで破綻します

同じマシンで入力だけ 16K にすると、絵が一変します。

同時実行 合計 tok/s TTFT p50 TTFT p99 TPOT p50 ITL p50 ITL p99
1 2,160 3.6 s 7.4 s 31.3 ms 31.7 ms 34.3 ms
4 3,543 5.8 s 26.8 s 41.7 ms 34.9 ms 3,182 ms
8 2,257 19.4 s 53.0 s 307.7 ms 38.0 ms 3,268 ms

同時実行 8 は実用になりません。 TTFT の中央値が 19 秒、p99 が 53 秒です。しかも同時 8 は同時 4 より合計スループットまで落ちています

原因は ITL の中央値と p99 の乖離を見れば分かります。p50 が 38 ms なのに p99 が 3,268 ms。 大半の decode ステップは正常なのに、ときどき 3.3 秒待たされています。これは chunked prefill のチャンクが 1 ステップを丸ごと占有しているためです。

そしてなぜ prefill がそんなに遅いのか ―― 2.4 節で確認したとおり、GDN の prefill カーネルは 4090 では Triton/FLA のままだからです。 逆算すると、このマシンの prefill スループットは 2,500〜3,500 tok/s 程度。16,384 トークンの入力 1 本で約 5 秒かかる計算で、実測とよく合います。

決定的な証拠が nvidia-smi に出ます。16K・同時 8 を流している最中の値です。

GPU-Util 96-100%   Pwr 170-186W / 450W

SM は振り切れているのに消費電力は TDP の 4 割。これは 2.5 節で引用した #51 の「Triton 参照実装に落ちているときのサイン」と同じ状態です。decode 側は融合カーネルに乗っていますが、prefill 側は今まさにこの状態で動いています。

8.3 結論:このマシンの適性

用途 推奨 実測の根拠
対話・短めのプロンプト 同時 8 183 tok/s、TPOT 35 ms、TTFT 1.3 秒
バランス重視 同時 4 106 tok/s、TTFT 0.5 秒、ITL p99 35 ms
長文入力(10K 超) 同時 1〜2 同時 1 なら TTFT 3.6 秒・TPOT 31 ms で安定
長文入力 × 高同時実行 やらない TTFT p99 53 秒

要するに、この構成は「短〜中程度の入力を数本並列で回す機械」です。 長文 RAG のように毎回 10K トークン以上を投げる用途では、同時実行を絞るか、そもそも別の構成を検討してください。

長文入力が主用途なら、起動パラメータを変えたプロファイルを別に用意するのが現実的です。私は起動スクリプトに PROFILE=longctx を用意しました(第6節)。--max-num-batched-tokens を 4096 に下げてチャンクを小さくし、--max-num-seqs を 4 に絞ることで ITL の跳ねを抑える狙いです。この効果はまだ実測していませんので、採用前にご自身の入力長で測ってください。

9. reasoning_effortpreserve_thinking

Qwen3.8 の目玉であると同時に、24GB クラスでは運用パラメータそのものです。ここが今回いちばん書きたかったところです。

9.1 reasoning_effort

xhigh(既定)/ medium / low の3段階です。

{
  "reasoning_effort": "medium"
}

既定が xhigh である点に注意してください。公式は「エージェントタスクでは reasoning に 262,144 トークン、最終出力に 131,072 トークンを割り当てること」を推奨していますが、これは 1M コンテキスト前提の話です。131K で運用する我々は、思考だけでコンテキストを食い尽くします。

一方で、モデルカードにはこういう注意書きもあります。

マルチターンのエージェントタスクでは、reasoning effort を下げても総所要時間が減るとは限らない。ターンあたりの応答は速くなるが、分析が不十分になって失敗と再試行が増え、結果的にレイテンシとトークン消費が増えることがある。

私の運用方針はこうしています。

用途 reasoning_effort
文書 OCR・抽出・要約(単発) low
通常の対話・コード生成 medium
エージェントループ・難しいデバッグ xhigh(ただし max_tokens を明示的に絞る)

xhigh を使う場合でも、max_tokens を無指定にしないでください。131K の枠を思考が単独で使い切ることがあります。

9.2 preserve_thinking

Qwen3.6 ではオプトインでしたが、Qwen3.8 では既定オンになりました。過去ターンの思考ブロックをすべて保持します。

メリットは公式の説明どおりで、意思決定の一貫性が上がり、同じ思考を繰り返さなくなり、KV キャッシュのヒット率も上がります。

デメリットは単純で、マルチターンで履歴が急速に膨らむことです。131K の枠しかない我々の構成では、長いエージェントループで先に上限に当たります。

最新ユーザーメッセージ分の思考だけを残すには、こうします。

{
  "chat_template_kwargs": {"preserve_thinking": false}
}

判断基準:ターン数が読めるワークフロー(10ターン以内)は既定のまま、終わりが見えないエージェントループは false。前者は prefix cache のヒット率で得をし、後者はコンテキスト枯渇のリスクのほうが大きい、という整理です。

なお reasoning_effortchat_template_kwargs は、vLLM のビルドによって受理される渡し方(トップレベル / extra_body / chat_template_kwargs 内)が違います。まず 1 リクエスト投げて、usage と返ってきた reasoning_content の長さが期待どおり変わるかを確認してから本番に入れてください。 静かに無視されても、エラーは出ません。

10. MTP は有効化しないでください

Qwen3.8-27B は MTP ドラフトヘッドを同梱しており、vLLM 公式レシピも投機デコードとして有効化することを勧めています。

--speculative-config '{"method":"mtp","num_speculative_tokens":3}'

このモデルでは、これを有効にすると劇的に遅くなります。

10.1 何が起きるか

理屈のうえでは MTP はこの構成に向いています。1ステップの重み読み出し量も allreduce の回数も変えずに、出力トークン数だけ増やせるからです。PCIe 律速のマシンでは、固定オーバーヘッドを複数トークンに薄める唯一の手段になり得ます。

ところが実装側に条件があります。Community #51 で報告されている _can_use_fused_gdn_mtp_decode() の判定です。

def _can_use_fused_gdn_mtp_decode(self, attn_metadata) -> bool:
    return (
        attn_metadata.spec_sequence_masks is not None
        and attn_metadata.num_decodes == 0
        and attn_metadata.num_spec_decodes > 0
        ...
        and self.num_v_heads == 8 * self.num_k_heads      # <-- ここ
        ...
    )

Qwen3.8-27B は linear_num_value_heads: 48 / linear_num_key_heads: 16 なので 比率は 3。8 ではありません。この条件は絶対に成立しません。

さらに悪いことに、MTP を有効にすると全ステップが投機ステップになるため(num_decodes == 0 and num_spec_decodes > 0)、非投機側の融合パスにも入れません。入口が両方とも閉じます。

結果、64層中48層の GatedDeltaNet が丸ごと Triton 参照実装に落ちます。しかも gdn_decode_kernel 自体は cuda と表示されたままなので、起動ログを見ても気づけません。 フォールバックはステップごとに、黙って起きます。

10.2 実測(#51 より、RTX 5090 × 4 / SM120)

構成 コンテキスト 中央値 tok/s
main、MTP あり 60–100K 19.9
main、MTP なし 約 85–145K 72.3

MTP を切っただけで 3.6 倍。 受理率自体は 80.4%、1ステップあたり約 1.8 トークンと悪くないのですが、1ステップのコストが約 4 倍になるので完全に負けます。

メモリ面でも切ったほうが得です。

MTP あり MTP なし
GPU KV cache 428,609 tokens 611,156 tokens
262144 での同時実行 1.64x 2.33x

10.3 4090(SM89)ではどうか

#51 の測定は RTX 5090(SM120)で行われたものです。ただし 9.1 の判定はモデルの config を見ているだけで、GPU アーキテクチャに依存しません。 48/16 = 3 は 4090 でも変わらないので、同じ罠に落ちます。

数値そのものは移植できません(5090 は 32GB・帯域も別)。ですが方向は確実です。私の構成でも ENABLE_MTPfalse のままにしています。

10.4 補足:method 名の揺れ

将来この制約が緩和されたときのために書いておくと、method の名前はバージョンによって違います。

表記 出どころ
mtp vLLM 公式レシピ(Qwen3.8)
qwen3_5_mtp コミュニティ量子化版の README
qwen3_next_mtp Qwen3.6 世代の表記

そして毎回書いていることですが、投機デコードは受理率が低いときエラーを出さずに単に遅くなります。 今回はそれ以前の問題(カーネルのフォールバック)でしたが、有効化するなら必ず実測してください。

docker logs qwen38-vllm 2>&1 | grep -iE "acceptance|accepted|spec_decode|drafted"
curl -s localhost:8000/metrics | grep spec_decode

「公式レシピに書いてあるから」で有効化しないでください。 レシピは SM100 系のデータセンター GPU を前提に書かれています。

10.5 DFlash はどうか

Muse Glimmer の記事を読んだ方は気になると思うので触れておきます。

DFlash(z-lab/dflash)自体は vLLM の main に入っており、--speculative-config '{"method":"dflash","model":"<drafter>",...}' で使えます。ただし DFlash は別途ドラフトモデルの重みが必要で、執筆時点で Qwen3.8-27B 用のものは公開されていません。 z-lab が出しているのは Qwen3.6-27B / Qwen3.6-35B-A3B / Qwen3-Coder-Next などです。

仮に出たとしても、2B 前後のドラフトモデルを追加でロードすることになります。24GB × 4 で KV プールが約 39 万トークンしかない BF16 構成では、そのコストは軽くありません。

11. FP8 版という選択肢 — 本命はこちらかもしれない

公式の Qwen/Qwen3.8-27B-FP8 は、ブロックサイズ 128 の fine-grained FP8 量子化です。公式は「性能はオリジナルとほぼ同等」としています。

「24GB に収まらないから量子化する」という話ではありません。 BF16 は TP=4 で収まっています。FP8 を選ぶ理由は別のところにあります。

11.1 なぜ FP8 か:KV プールが 2 倍になる

重みが約 26 GiB(BF16 の約半分)になります。TP=4 で分散すると、浮いた分がまるごと KV に回ります。

BF16 TP=4 FP8 TP=4
重み / 枚 約 13.2 GiB 約 6.5 GiB
KV プール / 枚(util 0.88) 約 6.1 GiB 約 12.8 GiB
KV プール合計 約 24.4 GiB 約 51 GiB
KV トークン数(64 KB/token) 約 39 万 約 82 万
--max-model-len 262144 での同時実行 1.5x 3.1x

5.2 節で「262K は諦めます」と書きましたが、FP8 ならその判断が変わります。 ネイティブ 262,144 を使ったうえで同時実行 3x が確保でき、--max-num-seqs も 16 まで上げられます(線形状態 144 MiB × 16 = 2.3 GiB、まだ余裕があります)。

さらに --kv-cache-dtype fp8 を重ねると 32 KB/token になるので、計算上は 1M(YaRN)の KV 32 GiB も射程に入ります。ここは私もまだ試していません。

11.2 速度はどうなるか(予測)

先に結論を書くと、FP8 で買えるのは主にコンテキストであって、速度は「たぶん少し速い」程度です。

decode は帯域律速なので、重みが半分になれば1ステップあたりの読み出しも半分です。ただし この構成のボトルネックである PCIe allreduce は FP8 では 1 バイトも減りません(活性値は BF16 のまま通信します)。

第8節の BF16 実測(同時 1 で TPOT 31.1 ms)を分解すると、こうなります。

BF16 TP=4(実測ベース) FP8 TP=4(予測)
重み読み出し / 枚 / ステップ 12.94 GiB → 約 13.8 ms 約 6.5 GiB → 約 7 ms
allreduce ほか(変化なし) 約 17 ms 約 17 ms
TPOT 31.1 ms(実測) 約 24 ms

期待できるのは 1.3x 程度(31.1 → 24 ms 前後)、というのがアムダール則からの上限です。カーネルの出来の問題ではありません。実測で判明した「オーバーヘッド 17 ms」は FP8 でも一切減らないので、ここが天井を決めます。

ただしこの試算には前提があります。「decode が帯域律速であること」です。 2.2 節で見たとおり、融合 GDN カーネルが効かず Triton に落ちている状態では SM 100% / メモリ 9-11% ―― 帯域律速ではありません。 その状態では重みを半分にしても何も速くなりません。

つまり FP8 の速度メリットは GDN decode kernel: cuda が出ていることが前提です。ログを確認してから FP8 を評価してください。順序を間違えると「FP8 は効果なし」という誤った結論に着地します。

一方 prefill(TTFT)は compute-bound です。しかも Ada では 2つの意味で Triton に縛られます。

  • block-scaled FP8 の CUTLASS カーネルが使えません(CUTLASS の block-scaled FP8 は SM90 以降)
  • GDN の prefill カーネル自体が SM90 / SM100 ファミリー限定です(2.4 節でソースを確認したとおり、ここは修正されていません)

チューニング済みの cuBLAS BF16 に対して Triton FP8 GEMM が勝てる保証はないので、TTFT は横ばいか、やや悪化を見込んでおくのが安全です。

なお 4.1 節で書いたとおり NVFP4 は Blackwell 専用ですが、FP8 は Ada にテンソルコアがあります。ここは混同しないでください。

11.3 起動スクリプト

~/myvllm/qwen38-fp8-vllm.sh として保存します。BF16 版との差分は、モデルディレクトリ・MAX_MODEL_LENMAX_NUM_SEQSkv-cache-dtype の4点だけです。

#!/usr/bin/env bash
set -euo pipefail

MODEL_DIR="${MODEL_DIR:-/root/HuggingFaceCache/Qwen3.8-27B-FP8}"
# Same digest-pinned image as the BF16 script - see section 2.
IMAGE="${IMAGE:-vllm-openai:qwen38-verified}"
SERVED_MODEL_NAME="${SERVED_MODEL_NAME:-qwen3.8-27b-fp8}"
PORT="${PORT:-8000}"
API_KEY="${API_KEY:-sk-dummy}"

# Block-128 fine-grained FP8: weights ~26GiB (~6.5GiB/card at TP=4).
# All TP=4 shards divide cleanly: FFN 17408/4, hidden 5120/4,
# q_proj 12288/4, KV 4 heads -> 1/card, GDN 48V/16QK -> 12/4, vocab 248320/4.
GPU_DEVICES="${GPU_DEVICES:-3,2,1,0}"
TP_SIZE="${TP_SIZE:-4}"

# The whole point of FP8 here: ~51GiB of KV pool instead of ~24GiB,
# so the native 262144 becomes usable at ~3x concurrency.
MAX_MODEL_LEN="${MAX_MODEL_LEN:-262144}"

GPU_MEMORY_UTILIZATION="${GPU_MEMORY_UTILIZATION:-0.88}"

# Raised from 8: the linear-attention state is 144MiB/seq and the
# larger pool can absorb 16 of them (2.3GiB).
MAX_NUM_SEQS="${MAX_NUM_SEQS:-16}"
MAX_NUM_BATCHED_TOKENS="${MAX_NUM_BATCHED_TOKENS:-8192}"

# fp8 KV halves 64KB/token to 32KB. Verified working on Ada in an
# earlier deployment on this same box. Set to auto to compare.
KV_CACHE_DTYPE="${KV_CACHE_DTYPE:-fp8}"

# Empty on the first run; pin it afterwards from the startup log.
KV_CACHE_MEMORY="${KV_CACHE_MEMORY:-}"

LIMIT_MM_PER_PROMPT="${LIMIT_MM_PER_PROMPT:-{\"image\":3,\"video\":0}}"

ENABLE_MTP="${ENABLE_MTP:-false}"
NUM_SPECULATIVE_TOKENS="${NUM_SPECULATIVE_TOKENS:-3}"

ENABLE_TOOL_CALL="${ENABLE_TOOL_CALL:-true}"
ENABLE_PREFIX_CACHING="${ENABLE_PREFIX_CACHING:-true}"

if [ ! -d "${MODEL_DIR}" ]; then
  echo "ERROR: model directory not found: ${MODEL_DIR}"
  exit 1
fi

MISSING=()
for f in preprocessor_config.json video_preprocessor_config.json \
         generation_config.json chat_template.jinja; 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

VLLM_ARGS=(
  --model /models/Qwen3.8-27B-FP8
  --served-model-name "${SERVED_MODEL_NAME}"
  --tensor-parallel-size "${TP_SIZE}"
  --max-model-len "${MAX_MODEL_LEN}"
  --gpu-memory-utilization "${GPU_MEMORY_UTILIZATION}"
  --max-num-seqs "${MAX_NUM_SEQS}"
  --max-num-batched-tokens "${MAX_NUM_BATCHED_TOKENS}"
  --kv-cache-dtype "${KV_CACHE_DTYPE}"
  --limit-mm-per-prompt "${LIMIT_MM_PER_PROMPT}"
  --generation-config auto
  --api-key "${API_KEY}"
  --host 0.0.0.0
  --port "${PORT}"
)

# No --quantization flag: vLLM auto-detects the fp8 block scales from
# quantization_config in config.json.

[ -n "${KV_CACHE_MEMORY}" ] && VLLM_ARGS+=(--kv-cache-memory "${KV_CACHE_MEMORY}")

if [ "${ENABLE_PREFIX_CACHING}" = "true" ]; then
  VLLM_ARGS+=(--enable-prefix-caching)
fi

if [ "${ENABLE_MTP}" = "true" ]; then
  VLLM_ARGS+=(--speculative-config \
    "{\"method\":\"mtp\",\"num_speculative_tokens\":${NUM_SPECULATIVE_TOKENS}}")
fi

if [ "${ENABLE_TOOL_CALL}" = "true" ]; then
  VLLM_ARGS+=(
    --enable-auto-tool-choice
    --reasoning-parser qwen3
    --tool-call-parser qwen3_coder
  )
fi

docker run --rm -it \
  --name qwen38-fp8-vllm \
  --gpus "\"device=${GPU_DEVICES}\"" \
  --network host \
  --ipc=host \
  --shm-size 32g \
  --ulimit memlock=-1 \
  --ulimit stack=67108864 \
  -v "${MODEL_DIR}:/models/Qwen3.8-27B-FP8:ro" \
  -e CUDA_DEVICE_ORDER=PCI_BUS_ID \
  -e CUDA_VISIBLE_DEVICES=0,1,2,3 \
  -e PYTORCH_NVML_BASED_CUDA_CHECK=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 VLLM_WORKER_MULTIPROC_METHOD=spawn \
  -e NCCL_P2P_DISABLE=1 \
  -e NCCL_IB_DISABLE=1 \
  -e NCCL_SHM_DISABLE=0 \
  "${IMAGE}" \
  "${VLLM_ARGS[@]}"

ダウンロードは BF16 版と同じ手順で、リポジトリ名だけ変えます。

hf download Qwen/Qwen3.8-27B-FP8 \
  --local-dir /root/HuggingFaceCache/Qwen3.8-27B-FP8

11.4 BF16 版との差分

項目 BF16 FP8 理由
--max-model-len 131072 262144 KV プールが 2 倍。ネイティブ長を使い切れる
--max-num-seqs 8 16 線形状態 144MiB×16=2.3GiB を吸収できる
--kv-cache-dtype 指定なし fp8 さらに 32KB/token に。まず auto と比較する
--quantization 指定しない config.jsonquantization_config から自動判定される
TP 4 4 分割は全次元きれいに割り切れる

--quantization fp8 を明示的に付けたくなりますが、付けないでください。 チェックポイント側のブロックスケール構成が上書きされて、per-tensor 量子化として解釈される可能性があります。

11.5 TP=2 という別の使い方

重み約 26 GiB は 2 枚(TP=2)にも収まります。上のスクリプトで GPU_DEVICES=1,0 / TP_SIZE=2 / MAX_MODEL_LEN=131072 にすれば動くはずです。

  • 残り 2 枚を MinerU や OCR コンテナに回せる
  • allreduce に参加するカードが半分になるので、単発の速度は TP=4 より速い可能性があります(PCIe 律速なので)

代わりに KV プールは約 10.6 GiB ≒ 16.5 万トークンまで落ち、131K だと同時実行 1.26x です。ほぼシングルセッション機になります。日常運用で GPU を空けたいなら TP=2、コンテキストと同時実行が欲しいなら TP=4 という切り分けです。

11.6 何を測れば判断できるか

BF16 TP=4 をベースラインに、この3つを比べれば決着します。

docker exec -it -e OPENAI_API_KEY=sk-dummy qwen38-fp8-vllm \
  vllm bench serve \
  --backend openai-chat \
  --base-url http://localhost:8000 \
  --endpoint /v1/chat/completions \
  --model qwen3.8-27b-fp8 \
  --dataset-name random \
  --random-input-len 1024 --random-output-len 256 \
  --num-prompts 32 --max-concurrency 8
見る数字 期待 これが外れたら
Available KV cache memory 約 12.8 GiB/枚 FP8 が効いていない。--quantization を付けていないか確認
TPOT(同時 1) 31.1 ms → 24 ms 前後 悪化するなら activation quant のオーバーヘッド側が勝っている
TTFT(入力1024・同時1) 405 ms から横ばい〜やや悪化 大幅悪化なら Triton カーネルの config が合っていない

TPOT の悪化が 1 割以内に収まるなら、この構成では FP8 が主力です。 速度が同じでコンテキストが 2 倍取れるなら、選ばない理由がありません。

なお、この「Ada + block-128 FP8 + TP=4 + PCIe-only」という組み合わせの実測値は、執筆時点で公開されているものを見つけられませんでした。上の予測値は BF16 側の実測から逆算したもので、実測が取れ次第この節は更新します。

12. 最終的なパラメータ

パラメータ 決定理由
イメージ nightly-ac7509e2b… qwen38 タグは融合 GDN カーネルが Ada で無効。0.21.0 は不可
並列方式 TP=4 PP は 24GB カードでは OOM
--max-model-len 131072 262K だと同時実行 1.5x。画像+思考の消費も考慮
--gpu-memory-utilization 0.88 GPU0 の画面出力分の余裕
--kv-cache-memory 7516192768(7.0 GiB) 399,242 → 440,673 token、同時実行 3.05x → 3.36x(5.4節)
--max-num-seqs 8 入力 1024 なら 1→8 で TPOT +12%。長文入力時は 4 に落とす
--max-num-batched-tokens 8192 長文入力主体なら 4096(第8節)
--limit-mm-per-prompt {"image":3,"video":0} 動画の worst-case 予約を殺す
preprocessor_config.jsonlongest_edge 4,194,304 既定 16M は 1画像 16,384 tok
video_preprocessor_config.json 触らない 公式推奨の 469,762,048 はこの構成では自殺行為
--speculative-config 使わない MTP は融合 GDN カーネルを潰す。実測で約 3.6 倍の損失
reasoning_effort medium(用途により変更) 既定 xhigh は 131K を食い尽くす
preserve_thinking ワークフロー次第 短いターンは既定オン、長いループは false

13. まとめ

  • RTX 4090 24GB × 4枚(WSL2 + Docker)で Qwen3.8-27B は BF16 フル精度・画像入力ありで動きます
  • イメージは不変タグ nightly-ac7509e2b…(#51 が実測したビルド)を使います。day-0 の qwen38 タグは使わないでください ―― 融合 GDN decode カーネルが is_device_capability_family(100) で SM100 に限定されており、Ada(8.9)は Triton に落ちます。カーネル自体は compute_89、つまり 4090 向けにビルドされているのに、です
  • イメージの中の qwen_gdn_linear_attn.pygrep すれば、モデルをロードせずに数秒で判定できます(2.4節)。見るのは has_device_capability(80) の1行だけ ―― 同じファイルに残っている is_device_capability_family(100) は prefill 側の別ロジックなので、混同すると誤判定します。起動後は GDN decode kernel: cuda をログで確認する
  • RTX 4090(SM89)でも GDN decode kernel: cuda を確認できました。 ただし GDN の prefill は 4090 では Triton/FLA のままです。 ここは修正されていないので、長いプロンプトを毎回投げる用途では TTFT がこの制約を受けます。triton と出ていたら 64層中48層が参照実装で動いています。nvidia-smi dmon で SM 100% / メモリ 10% 前後なら、それがこの状態のサインです
  • MTP は有効化しないでください。 num_v_heads == 8 * num_k_heads という判定に対しこのモデルは 48/16 = 3 で、融合カーネルの入口が両方閉じます。公式レシピの推奨に反しますが、実測で約 3.6 倍の損失です(19.9 → 72.3 tok/s)
  • アーキテクチャは Qwen3.5 系のまま。64層中48層が線形アテンションで、KV 64KB/token・シーケンス状態 144MiB/seq という経済性も同じです。コンテキストは安く、同時実行は高い
  • KV プールは実測 6.34 GiB / 枚・399,242 トークン、131,072 で同時実行 3.05x262K ネイティブ対応でも、この構成で選ぶべきは 131K です
  • --kv-cache-memory を明示的に固定すると KV が約 1 割増えます。 実測で 399,242 → 440,673 トークン、同時実行 3.05x → 3.36x--gpu-memory-utilization 任せだとカードを使い切りません。値は rank ごとに違うので小さいほうに合わせること
  • 性能実測(入力1024/出力256): 同時 8 で 183 tok/s、TPOT 35 ms、TTFT 1.3 秒。 同時実行を 1→8 にしても TPOT は +12% しか悪化せず、バッチングがほぼタダで効きます
  • ただし入力 16K になると同時 8 は破綻します(TTFT p50 19 秒 / p99 53 秒、同時 4 より合計スループットも低下)。GDN の prefill が Triton のままなので、prefill は 2,500〜3,500 tok/s しか出ません。このマシンは「短〜中程度の入力を数本並列で回す機械」です
  • Qwen3.6 からの実務的な差分は3つ:vLLM のバージョン要件transformers >= 5.8.0attn_output_gate による重み増と量子化耐性の低下
  • 動画の longest_edge を公式推奨に合わせてはいけません。 --limit-mm-per-promptvideo:0 を明示して worst-case 予約を切るのが正解です
  • reasoning_effort(既定 xhigh)と preserve_thinking(既定オン)は、24GB 構成では品質オプションではなくメモリ設計の一部です
  • NVFP4 は Blackwell 専用。Ada で選べるのは BF16 か公式 FP8 の2択です。FP8 は「24GB に収めるため」ではなく「KV プールを 2 倍にするため」に選ぶもので、262K ネイティブ長を同時実行 3x で使いたいならこちらです(第11節にスクリプトを載せました)

日本語文書の OCR という自分の本命用途では、OmniDocBench 1.5 が 89.4 → 91.1 と上がっているので、前回の Qwen3.6-27B 系から乗り換える価値は十分あると見ています。スループットの実測と、Qwen3.6-27B・Muse Glimmer 30B との日本語 OCR 比較は次回まとめます。

参考リンク

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?