【2026-09-18 追記】続編があります
本記事の192K構成は、この4日後(2026-09-17)にVRAMの崖を踏んで96Kまで下げています。
設定として起動できることと、日常的に使えることは別でした。そのまま真似すると崖を踏みます。
その後、素の sglang 0.5.19 に載せ替えて再構成しました。並列は5→8、decodeは約40→52 tok/s、
同じ資料の2回目は1.7秒→0.21秒になっています。最大コンテキストは約122Kと本記事より短いですが、
112Kを907 tok/sで安定して流せる値に落ち着きました。
→ Escha-W2 + OSCAR INT2 KV を素の sglang 0.5.19 に載せ替えたら、新機能が1.81GB握っていた話
VRAMの崖の見分け方(使用率では分からない。電力と温度で見る)も続編に書きました。
コンシューマGPU 2枚(RTX 5070 12GB × 2、WSL2)で、Qwen3.8-27B dense を2bit量子化した Escha-W2 を動かし、コンテキスト長を 192K(196,608トークン) まで伸ばすまでの手順をまとめます。
「まず動かす」→「KVキャッシュも2bit化して長コンテキストにする」→「実運用に耐える設定にする」の順で、実際に必要だったコード修正も全部書きます。私は途中で本番を3回落としているので、その落とし穴も共有します。
【2026-09-16 訂正・全文差し替え】この記事には誤りがありました
公開後、第2部「実際に必要だったコード修正」の記述が事実と違っていたことが分かったので、該当部分を全面的に書き直しました。訂正は3点です。
① 「実際に必要だった修正」を4点として書いていましたが、実際の統合は10ファイルに及びます(そのままコピーできるのが3本 / 手でマージが必要なのが6本 / 移植不要が6本)。修正1〜4は統合の代わりではなく、統合を終えたあとに追加で必要になるものです。必要な作業量を大幅に過小に見せていました。
② 回転行列を「その場で生成できる」と書きながら、生成方法を書いていませんでした。 較正なし(Hadamard)なら較正データもGPUも重みも不要で、数十行のスクリプトで作れます。生成スクリプトを公開しました。
③ 「修正4」の記述が誤りでした。 「ヘルパー関数3つ(_get_scale_group_size / _get_shared_kv_scale_group_size / _safe_block_h)がMoE版で移植漏れしていて、dense版の decode_attention.py からコピーが必要」と書きましたが、素のwheelを展開して確認したところ、dense版のwheelにもOSCAR関連のコードは一切入っていません。前2つはPR #32129側のヘルパーで、_safe_block_h は私が書いた関数です。この記事のdense構成でも自分で入れる必要があります。 しかも _safe_block_h が実際に効くのはdense版の方でした(TP=2で kv_group_num = 6)。
訂正した正しい手順は、コピペで進められる手順書として書き直しました。 dense版・MoE版の両方に対応し、ファイル単位の対応表・回転行列の生成スクリプト・統合チェックスクリプトまで入っています。これから作業する方はこちらをどうぞ。
👉 【手順書】dense でも MoE でも通る — sglang の未マージPR(OSCAR INT2 KV)を Escha-W2 ランタイムに統合する
MoE版(Qwen3.6-35B-A3B)側の経緯はこちら: 配布wheelにsglangのPRを自力で統合する
第1部・第3部(VRAMの崖、prefix cache、systemd)の実測と結論に変更はありません。訂正した箇所には本文中でも 🔺 を付けています。
できるようになること
| モデル | Qwen3.8-27B dense(2bit量子化) |
| GPU | 12GB × 2(TP=2) |
| コンテキスト長 | 196,608 |
| 同じ会話の2ターン目以降 | 1.7秒(192Kのプロンプトでも) |
| コールドprefill | 907 tok/s(192Kで212秒) |
| 同時実行 | 約5 |
必要なもの
- NVIDIA GPU 2枚(合計24GB。12GB×2で検証)
- CUDA 12系ドライバ、Python 3.12
- ディスク空き 15GB程度(モデル実体は約9.5GB)
第1部: まず動かす
ステップ1: 環境構築
Escha Labsが配布しているランタイム(Apache-2.0)を使います。SGLangのフォークがwheelに同梱されているので、別途sglangを入れてはいけません。
python3.12 -m venv .venv && source .venv/bin/activate
pip install -U pip wheel
# ★ここが最重要。torchのバージョンは厳密に固定する
pip install "torch==2.9.*" --index-url https://download.pytorch.org/whl/cu128
# ランタイム本体(同梱sglangフォークと依存一式が入る)
hf download EschaLabs/escha-runtime-qwen3dense --local-dir ./escha-runtime-qwen3dense
pip install ./escha-runtime-qwen3dense/sglang/escha-*.whl
torch==2.9.*は提案ではなくハード要件です。torch>=2.9と書くと新しいマイナーに解決され、import eschaがundefined symbol: _ZN3c10...で落ちます。コンパイル済み拡張がlibtorchのABIにリンクされていて、そのABIはPyTorchのマイナー間で互換がありません。
導入確認:
python -c "import torch, escha, sglang; print(torch.cuda.is_available(), hasattr(torch.ops.escha, 'escham_decode_gemv'), escha.__version__)"
True True <version> が出ればOKです。
ステップ2: モデルを取得する
hf download EschaLabs/Qwen3.8-27B-Escha-W2 --local-dir ./Qwen3.8-27B-Escha-W2
実体は safetensors 2ファイルで合計 約9.5GB。27Bのdenseモデルがこのサイズに収まるのが2bit量子化の威力です。TP=2で1枚あたり約4.8GBずつ持つので、12GBのカードに載ります。
ステップ3: とりあえず起動する
MODEL=./Qwen3.8-27B-Escha-W2 \
CTXLEN=131072 \
ATTN_BACKEND=triton \
bash ./escha-runtime-qwen3dense/sglang/serve.sh --tp-size 2
curl -s http://127.0.0.1:30000/v1/models | python3 -m json.tool
ATTN_BACKEND=tritonは RTX 50系(sm_120)では必須です。 flashinferを使うとhybrid-GDNアーキテクチャでassertに引っかかります。
この時点の性能
| 項目 | 値 |
|---|---|
| KVプール | 154,242トークン(KVはfp8_e4m3) |
| 実用的な同時実行 | 8並列まで |
| decode | 約40 tok/s |
並列数を上げていくと、8までは素直にスケールしますが、12で明確に詰まります。
| 並列数 | TTFT平均 | TTFT最大 |
|---|---|---|
| 8 | 240ms | 243ms |
| 12 | 2,218ms | 6,458ms |
スループット自体も8並列時より低下します。起動ログの max_running_requests が実用上限です。
つまずきポイント: Vision版が起動しない
Vision版(-vision)を使う場合、No processor registered for architecture: Qwen3_5ForConditionalGeneration で起動できないことがあります。
そしてモデルの変更 centraly/Qwen3.8-27B-Escha-W2-vision
原因: sglang/srt/models/qwen2_5_vl.py が transformers から Qwen2_5_VisionRotaryEmbedding をimportしますが、transformers 5.17.0 では Qwen2_5_VLVisionRotaryEmbedding にリネームされています。このファイルのimportが失敗すると、Qwen-VL系processorを一括登録するファイルごと読み込みがスキップされます。
修正(1行):
# sglang/srt/models/qwen2_5_vl.py
from transformers import Qwen2_5_VLVisionRotaryEmbedding as Qwen2_5_VisionRotaryEmbedding
第2部: KVキャッシュも2bitにして長コンテキストにする
ここからが本題です。重みは2bitになりましたが、KVキャッシュはfp8のままなので、コンテキストを伸ばすとVRAMを食います。KVもint2まで落とします。
OSCARとは
KVキャッシュを2bitに量子化する手法です。単純に丸めると精度が壊れるので、回転行列(Hadamard変換)をかけて値の分布をならしてから量子化します。これにより外れ値の影響が薄まり、2bitでも実用精度が保てます。
結果はこうなりました。
| KVの形式 | KVプール |
|---|---|
| fp8_e4m3 | 154,242トークン |
| int2 | 611,851トークン |
約4倍です。decode速度は40.3〜40.4 tok/sで、int2化による目立った劣化はありませんでした。事実確認・計算・論理推論を混ぜた10問でも全問正解です。
🔺【2026-09-16 訂正】正直な注意: これは「フラグを足すだけ」ではありません
OSCARの実装元SGLangフォークと、Escha Runtimeが同梱するSGLangフォークは別系統です。パッチを機械的に当てることはできません。git apply は通りませんし、ファイルを上書きコピーするとランタイムが壊れます。
どのくらいの作業かを先に書きます。PR #32129 がwheel側で触ることになるファイルは10本で、内訳はこうです。
| 種別 | 本数 | 内容 |
|---|---|---|
| そのままコピーできる | 3 | wheel側に存在しないファイル |
| 手でマージが必要 | 6 | escha側が既に改造済みのファイル。上書きすると壊れる |
| 移植しない | 6 | wheel側に対応物が無い/このモデルには不要 |
インストール作業そのものは30分程度ですが、この統合だけで半日かかると思ってください。CUDAカーネルを書ける必要はありませんが、diffを読んで関数をファイル間で移す作業になります。
ファイル単位の完全な対応表(どのシンボルをどこへ持っていくか、どのファイルを無視してよいか)は、パッチ一式・回転行列の生成スクリプトと一緒にこちらに置きました。
以下は、その対応表に沿って統合したあとにさらに必要だった修正です。
訂正前はこの節を「実際に必要だった修正」4点として書いていました。 修正1〜4は統合作業の代わりではなく、統合を終えたあとに追加で必要になるものです。
修正1: KVセルサイズの計算にint2分岐を足す
sglang/srt/model_executor/model_runner_kv_cache_mixin.py の get_cell_size_per_token:
if self.kv_cache_dtype == "int2":
kv_size = 0.25 # 2bit = 0.25 byte/要素
else:
kv_size = torch._utils._element_size(self.kv_cache_dtype)
理由: "int2" は文字列で、PyTorchのdtypeではありません。そのまま _element_size() に渡すと TypeError で落ちます。
修正2: プールサイズ計算の型を整える
同ファイルの profile_max_num_token:
return int(int(rest_memory * (1 << 30)) // cell_size)
理由: 修正1で cell_size がfloat(0.25の倍数)になるため、int // float の結果がfloatになります。そのままだとKVプールのshapeにfloatが混ざり、torch.zeros が TypeError を投げます。
🔺【2026-09-16 追記】修正3: 回転行列を用意する
OSCARは回転行列を外部ファイルから読みます。このフォークではデータ不要のフォールバックを移植していないので、パス指定は必須です(未設定だとサーバが起動を拒否します)。
較正なし(Hadamard)なら、GPUも重みも較正データも要りません。中身はSylvester構成のHadamard行列を sqrt(head_dim) で割ったものです。
import math, pathlib, torch
def hadamard(n):
h = torch.ones(1, 1)
while h.shape[0] < n:
h = torch.cat([torch.cat([h, h], 1), torch.cat([h, -h], 1)], 0)
return h
head_dim, num_layers = 256, 64 # config.json の text_config から読む
rot = (hadamard(head_dim) / math.sqrt(head_dim)).to(torch.float32)
assert (rot @ rot.T - torch.eye(head_dim)).abs().max().item() == 0.0
state = {"layers": {i: {"rotation": rot.clone()} for i in range(num_layers)}}
out = pathlib.Path("/path/to/oscar_rotations"); out.mkdir(parents=True, exist_ok=True)
for name in ("k", "v"):
torch.save(state, out / f"{name}_rotation_hadamard_hd{head_dim}.pt")
export SGLANG_OSCAR_K_ROTATION_PATH=/path/to/oscar_rotations/k_rotation_hadamard_hd256.pt
export SGLANG_OSCAR_V_ROTATION_PATH=/path/to/oscar_rotations/v_rotation_hadamard_hd256.pt
コマンド1つで済むように、config.json を読んで層数とhead_dimを自分で判断する版を上のリポジトリに置いてあります(escha_oscar/tools/make_hadamard_rotations.py)。
直感に反するが正しい点が2つあります。
-
層数ぶん作るが、使われるのは一部の層だけ。これはハイブリッドモデルで、
config.jsonのlayer_typesがfull_attentionとしている層(このdenseモデルなら64層中16層)だけがKVキャッシュを持ちます。参照は層インデックスなので、全層ぶん作るのが正解で、多いぶんには無害です - 全層が同じ行列。較正なしの回転は構成上どの層でも同じになります。層ごとに違えば較正版、全層同じなら較正なし版だと判別できます
較正版が要るかどうかはモデル次第です。同じ環境のMoE版では、較正した回転行列がむしろ逆効果でした(識別テストで 較正なし 40/41 に対し 較正あり 37/41)。まず較正なしで動かし、識別力のあるテストで効果を確認してから較正に投資してください。
🔺【2026-09-16 訂正】修正4: _safe_block_h — 静かな正しさのバグ
この節は公開時の内容が誤っていたため、全面的に書き直しました。
公開時、ここに「MoE版ランタイムにはヘルパー関数3つが移植漏れしている。dense版の decode_attention.py からコピーが必要」と書きましたが、誤りです。素のwheelを展開して確認したところ、_get_scale_group_size / _get_shared_kv_scale_group_size はPR #32129側のヘルパーで、_safe_block_h は私が書いた関数でした。dense版・MoE版どちらのwheelにも、OSCAR関連のコードは一切入っていません。どちらを使う場合も自分で入れる必要があります。
この関数が何を直すのかも書いておきます。grouped decodeのカーネルはヘッドをこう見ています。
VALID_BLOCK_H = min(BLOCK_H, kv_group_num)
cur_head = cur_head_id * VALID_BLOCK_H + arange(BLOCK_H)
cur_kv_head = cur_head_id // cdiv(kv_group_num, BLOCK_H)
cur_head はフラットなクエリヘッド番号、cur_kv_head はブロック番号由来で、両者が一致するのはヘッドブロックが1つのKVグループに収まるときだけです(BLOCK_H >= kv_group_num または kv_group_num % BLOCK_H == 0)。条件を外すと、一部のクエリヘッドが別のKVヘッドのキャッシュを読みます。assertも落ちず、NaNも出ず、ベンチのスコアにしか現れません。
このdenseモデルでこそ効きます。 TP=2時は1GPUあたり 12クエリヘッド / 2 KVヘッド なので kv_group_num = 6。2のべき乗ではないので、BLOCK_H の値次第で上の条件を外します(MoE版は 8 なので構成上安全です)。_safe_block_h は2のべき乗に切り上げて VALID_BLOCK_H == kv_group_num を保証する関数で、実装はリポジトリのパッチに入れてあります。
MoE版を使う場合の追加
MoEを2枚のGPUで動かすには --ep-size 2(expert parallel)が必須です。--tp-size 2 だけだとexpert重みが両方のGPUに複製されて11GB超になり載りません。またMoE版では --enable-mixed-chunk がint2カーネルと衝突してクラッシュします(dense版では問題なく使えます)。
第3部: 実運用に耐える設定にする
int2 KVで「256Kコンテキストでも起動できる」ようになりました。しかしこれが罠でした。
20万トークンのリクエストを実際に流したら、本番が固まりました。
落とし穴①: int2 KVは「プール外」でVRAMを使う
固まったときのGPUがこれです。
GPU0: 100% utilization, 11,704 MiB, 43.67 W
GPU1: 100% utilization, 11,833 MiB, 39.21 W ← 定格250Wに対して40W
利用率100%なのに電力が定格の2割。 演算器が回っているのではなく、待ち合わせで詰まっています。このカードでは約11,830 MiBを超えると、この「VRAMの崖」に落ちます。
KVプールは起動時に確保済みなので、使用率が上がってもVRAMは増えないはずです。実際ログ上はどの容れ物も増えていません。
full token usage: 0.29 → 0.68 (プール内。起動時に確保済み)
mamba usage: 0.10 → 0.10 (完全に横ばい)
プロンプト長を変えてGPUのピークを実測しました。
| プロンプト長 | 起動時 | ピーク | 増分 |
|---|---|---|---|
| 100,676 トークン | 10,756 MiB | 11,655 MiB | +899 MB |
| 192,716 トークン | 9,953 MiB | 11,843 MiB | +1,890 MB |
シーケンス長に比例しています。犯人はこれでした。
full-attention層は、prefillのチャンクごとに「それまでの全KV履歴」をfp16に復元する。
int2で保存したKVも、attentionの計算に使う瞬間には元の精度に戻す必要があります。そのワークスペースはプールの外に確保され、シーケンス長に線形比例します。
full-attention層 16 × KVヘッド 4 × head_dim 256 × 2(K+V) × 2byte × L
L = 192,716 のとき ≈ 1.9 GB (実測 +1,890 MB とほぼ一致)
int2 KVは、プールを1/8にする代わりに、復元バッファをシーケンス長に比例して消費します。 プールの数字だけ見て「61万トークン入る」と喜ぶと足をすくわれます。「起動できた」と「実際に流せる」は別物でした。
なお崖は二値のスイッチではありません。触れた時点で速度に効きます。
| 構成 | ピーク | コールド192K |
|---|---|---|
| 崖を越える設定 | 11,843 MiB | 273.5秒(705 tok/s) |
| 崖を避ける設定 | 11,526 MiB | 212.5秒(907 tok/s) |
避けるだけで29%速くなりました。
落とし穴②: prefix cacheが一度も効いていなかった
ログのこの数字に気づきました。
#new-token: 2048, #cached-token: 0
#cached-token が常に0。 prefix cachingを有効にしているのに一度も再利用できていません。エラーは一切出ません。
このモデルはattention層とlinear-attention(GDN)層のハイブリッドです。prefixを再利用するにはKVだけでなく、同じ位置のmamba再帰状態のチェックポイントも必要で、チェックポイントはチャンク境界でしか作られません。
必要チェックポイント数 ≈ KVプールのトークン数 ÷ chunked_prefill_size
当時は プール300,000 / CHUNK 2048 で 必要147個に対し実装56個。全然足りていませんでした。
「キャッシュが当たらないならプールを増やせばいい」は完全に逆効果です。 プールを増やすと必要数が増えます。正しい方向はCHUNKを上げることです。
落とし穴③: CHUNKとmambaスロットは逆を向いている
2つの要求が衝突します。
- mambaスロットを減らしたい(1スロット36MB。崖を避けたい)
- スロット数 ≧ プール ÷ CHUNK が必要(cacheを効かせたい)
後者のためにCHUNKを上げると、今度は1ラウンドあたりの活性メモリが増えてピークが上がります(CHUNK 2048→6144で約+456MB)。
合計VRAMを最小化する点を探した結果がこれです。
| 構成 | 並列 | cache | ピーク | コールド192K |
|---|---|---|---|---|
| CHUNK 2048 / mamba 40 | 8 | 不可 | 崖越え | ハング |
| CHUNK 6144 / mamba 40 | 8 | 可 | 11,843(崖越え) | 273.5秒 |
| CHUNK 8192 / mamba 28 | 5 | 可 | 11,526 | 212.5秒 |
そしてprefix cacheが生き返りました。
1回目(コールド): 212.5秒
2回目(同一再送): 1.7秒 ← #cached-token: 192,704 / 192,716
チャットは構造上、毎ターン「前回までの全文 + 新しい発言」になるので、2ターン目以降は常に効きます。体感の変化はここが一番大きいです。
代償として、長文prefillの最中に届いた別リクエストの待ち時間は伸びます(11.4秒。CHUNK 2048時代は1.73秒)。ただし以前は毎ターン212秒だったので、トータルでは圧倒的に改善です。
完成した設定
#!/usr/bin/env bash
exec >> /home/user/escha_server.log 2>&1
export MODEL=/path/to/Qwen3.8-27B-Escha-W2
export ATTN_BACKEND=triton
# 長コンテキスト運用の中核4点
export CTXLEN=196608 # コンテキスト長 192K
export CHUNK=8192 # prefillの1ラウンド当たりトークン数
export MAXMAMBA=28 # mamba状態スロット数
export MEM=0.87 # VRAM予算の比率
export MAXREQ=12 RADIX=1 PORT=30000
export CUDA_GRAPH_BS="1 2 4 8 12"
# OSCAR int2 KV の回転行列
export SGLANG_OSCAR_K_ROTATION_PATH=/path/to/k_rotation.pt
export SGLANG_OSCAR_V_ROTATION_PATH=/path/to/v_rotation.pt
exec bash /path/to/escha-runtime-qwen3dense/sglang/serve.sh \
--tp-size 2 \
--kv-cache-dtype int2 --kv-cache-quant-group-size 64 \
--max-total-tokens 220000 \
--enable-mixed-chunk \
--watchdog-timeout 600 \
--mamba-ssm-dtype float16 \
--mamba-scheduler-strategy extra_buffer
各設定の意味
| 設定 | 意味と注意 |
|---|---|
--kv-cache-dtype int2 |
KVを2bitに。コンテキストを伸ばす最大の武器。ただし上記の復元バッファに注意 |
--kv-cache-quant-group-size 64 |
32→64でスケールバッファが半減し約0.25GB浮く。128や96は起動が通るのにリクエスト時に落ちる(グループ数が2のべき乗かつ4の倍数である必要がある) |
--mamba-ssm-dtype float16 |
mamba状態が1スロット80MB→36MBに半減。実質必須 |
--mamba-scheduler-strategy extra_buffer |
RADIX=1だけでは無意味。 これが無いとキャッシュが保持されず、再送しても8.51→8.53秒と効果ゼロ |
CHUNK と MAXMAMBA
|
セットで決める。 MAXMAMBA ≧ max-total-tokens ÷ CHUNK を守る(今回 220,000÷8192=27 ≦ 28) |
--max-total-tokens |
比率任せにせず絶対値で。コンテキスト長以上、ただし大きすぎると上の条件式が厳しくなる |
--watchdog-timeout 600 |
長すぎると復帰前に上流が諦める。私は1200秒にしていて20分ハングさせました |
MEM=0.87 |
これ以上上げると崖に触ります |
本番はsystemdに載せる
~/.config/systemd/user/escha-dense.service:
[Unit]
Description=Escha dense Qwen3.8-27B-W2 (sglang TP2, int2 KV)
After=network.target
StartLimitIntervalSec=1800
StartLimitBurst=3
[Service]
Type=simple
ExecStartPre=/home/user/bin/escha-preflight.sh
ExecStart=/usr/bin/env bash /home/user/escha_run.sh
MemoryHigh=20G
MemoryMax=26G
OOMPolicy=stop
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
loginctl enable-linger $USER
systemctl --user enable --now escha-dense.service
SIGKILLを撃ち込んで95秒で自動復帰することを確認しています。
なぜsystemdなのか: WSLごと落とした話
サーバがウォッチドッグで自死していたので起動コマンドを叩き、反応がないのでもう一度叩きました。その8秒後にWSLごと落ちました。
kernel: ZMQbg/IO/1 invoked oom-killer: ... global_oom
kernel: Out of memory: Killed process (sglang::schedul) anon-rss:10,575,616kB
kernel: Mem-Info: inactive_anon:7,760,647 pages (= 29.6GiB / 31GiB)
VRAMではなくホストのRAMでした。1インスタンスが実メモリ10.1GiBを使うので、31GiB環境に2つ並べば当然こうなります。「気をつける」では再発するので、同一ユニットの二重起動を構造的に拒否するsystemdに移しました。
systemdでのつまずき2つ
MemoryMax はページキャッシュも数えます。 最初20Gにしたらロード中に殺されました。
anon 10.34 GiB ← 実メモリ
file 9.42 GiB ← モデル読み込みで載ったページキャッシュ
捨てれば済むキャッシュのせいで殺されていました。MemoryHigh を併用すると上限に当たる前に回収してくれます。
pgrep -f は自分を殺します。 起動前ガードを書くときこれで3回自爆しました。
pgrep -f "sglang.launch_server" # ← 呼び出し元シェルのargvにもこの文字列が載る
/proc/<pid>/comm で実際のプロセス名を見て絞ります。
for pid in $(pgrep -f "$PAT"); do
case "$(cat /proc/$pid/comm)" in
sglang*|python*) alive="$alive $pid" ;;
esac
done
256Kを諦めた理由
当初の目標は256Kでしたが、数字を出して諦めました。
262,144トークンには復元バッファが約1.9GB必要です。それをmambaスロットから捻出すると24スロット(実効並列4〜5)まで削ることになり、さらにcacheの条件が 262,144 ÷ 6144 = 43 > 24 で満たせなくなります。つまり毎回フルprefillで、実測ベースで約437秒。
12GBカード2枚のdenseモデルで256Kは、動くけれど実用にならないが結論でした。
補足: 長コンテキストを狙うならattention構成を見る
同じ環境でMoE版(35B-A3B)は262Kが通ります。MoEだからではなくattention構成が軽いからです。
| dense 27B | MoE 35B-A3B | |
|---|---|---|
| 層数 / うちfull-attention | 64 / 16 | 40 / 10 |
| num_key_value_heads | 4 | 2 |
| int2 KVの実コスト | 8.0 KiB/token | 2.5 KiB/token |
16層×4ヘッド と 10層×2ヘッド の差で3.2倍。パラメータ数ではなくattention構成が長コンテキストのコストを決めます。
まとめ
- 27B denseの2bit量子化(Escha-W2)は、wheel 1つとモデル9.5GBで12GB×2に載る。ここまでは簡単
- KVのint2化(OSCAR)でプールは約4倍になるが、フラグを足すだけでは済まない。10ファイル分の手作業の統合(コピー3・手マージ6)が要る
- int2 KVはプールを1/8にする代わり、復元バッファをシーケンス長に比例して消費する
- 「起動できた」と「実際に流せる」は別物。 最大長を1本流してGPUピークを測るまで検証は終わっていない
- VRAMの崖は二値ではなく、触れた時点で速度に効く(705 → 907 tok/s)
- hybrid SSMのprefix cacheには
スロット数 ≧ プール ÷ CHUNKという条件があり、プールを増やすのは逆効果 - 本番は最初からsystemdに載せる
同じようにKV量子化で長コンテキストを狙う方は、プールのトークン数ではなく、最大長を1本流したときのGPUピークを確認してみてください。私はそこを測らずに「256K対応」と判断して、本番を3回落としました。
続き: コピペで進められる手順書
この記事の第2部(統合部分)は、dense版・MoE版の両方に対応した手順書として書き直しました。ファイル単位の対応表・回転行列の生成スクリプト・統合チェックスクリプト・dense/MoEの差分一覧まで入っています。
👉 【手順書】dense でも MoE でも通る — sglang の未マージPR(OSCAR INT2 KV)を Escha-W2 ランタイムに統合する
パッチ・スクリプト一式: https://github.com/Nakanokensetsu/sglang
訂正履歴
-
2026-09-16: 第2部を全面的に書き直し。①修正4の「dense版からコピーが必要」は誤り(どちらのwheelにもOSCARは入っていない、
_safe_block_hは自作関数)②「必要な修正4点」→実際は10ファイルの統合 ③回転行列の生成手順を追記。第1部・第3部の実測と結論は変更なし
環境
- GPU: RTX 5070 12GB × 2(TP=2、WSL2)
- Python 3.12 / torch 2.9系(cu128)
- エンジン: Escha Runtime
qwen3dense(SGLangフォーク同梱、Apache-2.0) - モデル: EschaLabs/Qwen3.8-27B-Escha-W2(2bit)
- KVキャッシュ: int2(グループサイズ64)