はじめに
TabbyAPI(exllamav3)でQwen3.6系のEXL3量子化モデルを個人運用していたが、OpenWebUI + Hermes(自作エージェント)の同時利用でリクエストが直列処理になってしまう問題があった。Continuous Batchingを持つSonar(Aphrodite Engine、vLLMのフォーク)への移行を検証したところ、EXL3量子化とQwen3.6系のハイブリッドMamba/GDNアーキテクチャの組み合わせで、upstreamに未報告の実バグを2つ踏んだ。原因を特定してローカルパッチを当て、最終的にPRとして提出した。
その後の副産物として、新しく見つけた9BモデルであるOrnith-1.0-9Bを同じ環境で動かしたところ、27Bモデルを速度・精度・コンテキスト長のすべてで上回るという結果になったので、それも含めて書く。
環境: WSL2 Ubuntu, RTX 5070 ×2 (12GB×2)
発見した2つの実バグ
Aphrodite-engine 0.21.0はEXL3量子化に対応しているが、Qwen3.6/Qwen3.5系のハイブリッドMamba/GDNアーキテクチャ(大半の層がlinear_attention、4層に1層だけfull_attention) + LoRA有効時の組み合わせでバグを踏んだ。
バグ1: EXL3のin_proj_qkv層のshard_id不一致
症状: --enable-lora指定時、以下のエラーで起動失敗。
ValueError: Missing EXL3 tensors for ...in_proj_qkv: suh[0], suh[1], suh[2], ...
原因: aphrodite/model_executor/models/qwen3_5.pyのload_weights()は、LoRA有効時にGDN層のin_proj_qkvの重みをタプル(0, 1, 2)という単一キーでロードする:
if self.enable_lora:
stacked_params_mapping.extend([
("in_proj_qkv", "in_proj_qkv", (0, 1, 2)), # ← タプル1個で1回だけ呼ばれる
("in_proj_z", "in_proj_z", 0),
])
しかしaphrodite/model_executor/layers/quantization/exl3.pyの_shard_ids_for_layer()には、LoRA無効時の統合レイヤー名in_proj_qkvz向けの分岐しかなく、LoRA有効時の単体レイヤー名in_proj_qkv向けの分岐が存在しなかった。そのため汎用フォールバックに落ちて[0, 1, 2]という3つの別々の整数キーを期待してしまい、実際にロードされたタプルキー(0, 1, 2)と一致しない。
exllamav3自体のアーキテクチャ定義(architecture/qwen3_5.py)を確認したところ、GDN層のqkv投影は常にq+k+vを1つの融合trellis行列として量子化する仕様であり、LoRAの有無に関わらず一貫していることを確認した。
修正(1行追加):
# aphrodite/model_executor/layers/quantization/exl3.py
if prefix.endswith("in_proj_qkv"):
return [(0, 1, 2)]
バグ2: LoRAレイヤーのデバイス判定がEXL3を認識しない
症状:
ValueError: Unsupported base layer: MergedColumnParallelLinear(...)
原因: aphrodite/lora/layers/utils.pyの_get_lora_device()は、量子化方式ごとに決め打ちの属性名(weight, weight_packed, qweight, w2_weight等)でデバイスを判定していたが、EXL3の属性名(suh/svh/trellis/mcg/mul1)がリストに無かった。EXL3+LoRAの組み合わせがAphrodite全体として一度も配線されていなかったことを意味する。
修正:
elif hasattr(base_layer, "trellis"):
return torch.device("cuda", torch.cuda.current_device())
EXL3はCUDA専用実装のため、常にcurrent_deviceで問題ない。
環境要因: WSL2でのlibcuda.soリンクエラー
flashinferのJITビルド時にcannot find -lcuda。/usr/local/cudaがconda-forge由来のCUDAツールキットを指すシンボリックリンクになっており、そちらのdevスタブは非標準レイアウトだったため、PyTorchが期待する$CUDA_HOME/lib64/stubs/libcuda.soが見つからなかった。専用の独立スタブディレクトリを作成しLIBRARY_PATHに追加することで、既存環境に触れずに解決した。
upstreamへの還元
発見した2つのバグはdphnAI/sonar(Aphrodite-engine)にPRとして提出済み:
- PR #1711 — EXL3
in_proj_qkvshard_id不一致の修正 - PR #1712 — LoRAデバイス判定にEXL3を追加
ベンチマーク: Qwen3.6-27B-exl3-3.08bpw
- TP=2, KVキャッシュFP8, gpu-memory-utilization=0.88
- LoRA無し(有効時はメモリ予算がさらに厳しくなるため今回は見送り)
CUDAグラフ有効化の効果
--enforce-eager(CUDAグラフ無効)で動かすと速度が出なかったので通常モードに切り替えた。
| 並列数 | eagerモード(合計t/s) | CUDAグラフ有効(合計t/s) |
|---|---|---|
| 1 | 11.23 | 32.62 |
| 2 | 20.39 | 60.32 |
| 3 | 32.28 | 85.60 |
| 4 | 44.88 | 99.81 |
| 5 | 56.06 | 125.68 |
| 6 | 64.81 | 146.67 |
単発速度は約2.9倍、6並列合計では約2.3倍に向上。トレードオフとしてCUDAグラフ用バッファの分だけKVキャッシュプールが減少する(eagerモード143,360トークン → CUDAグラフ有効111,177トークン)。
精度検証
基本問題8問(算数・事実確認・指示追従等)は8/8正解。長文コンテキストのneedle-in-haystackテストでは、45,917トークン先に埋め込んだ秘密コードも正確に抽出できた。ハイブリッドMamba/GDNアーキテクチャ(大半がlinear_attention)でも長距離検索が機能することの実証になった。
最終構成(N=1〜8スケーリング)
max-model-len=81920, max-num-seqs=8, max-num-batched-tokens=4096(理由は後述)
| 並列数 | 合計t/s |
|---|---|
| 1 | 31.92 |
| 4 | 117.24 |
| 8 | 194.11 |
Chunked Prefillのチャンクサイズ(--max-num-batched-tokens)実験
Continuous Batchingではchunked prefillがデフォルトで有効になっている。1人が数万トークンの巨大リクエストを送りながら、他5人が短いリクエストを同時に送る、という混在ワークロードで、チャンクサイズによる「公平性」と「総スループット」のトレードオフを実測した。
Qwen3.6-27Bでの結果
| chunk size | 完了時刻の幅(公平性) | 全体完了(総スループット) |
|---|---|---|
| 256 | 約60.9秒(最悪) | 72.1秒(最遅) |
| 512 | 約0.95秒 | 59.2秒 |
| 2048(デフォルト) | 約5.48秒 | 51.1秒 |
| 4096 | 約0.89秒(最良) | 52.7秒(僅差2位) |
256まで小さくすると、公平性・速度の両方が悪化する「行き過ぎ」領域に入る。4096がバランス最良と判断し、最終構成に採用した。
新モデル発見: Ornith-1.0-9B、同一アーキテクチャで27Bを圧倒
作業の途中で、DeepReinforce AIが公開したOrnith-1.0というエージェント型コーディング特化モデルファミリーを見つけた(9B Dense / 31B Dense / 35B MoE / 397B MoEの4サイズ、MITライセンス)。35B版はQwen3.5-35B-A3Bをベースにした、まさに今回格闘していたのと同じハイブリッドMamba/GDN(linear_attention + full_attention)構成。9B版もQwen3_5アーキテクチャ(GDNハイブリッド、MoEではなくdense)で、既存パッチがそのまま使えた。
コミュニティが公開しているEXL3 6bpw量子化版(AnuAmba/Ornith-9B-6bpw-exl3)を使い、同じSonar環境で動かして27Bと対決させた。
VRAM効率とコンテキスト長
| Qwen3.6-27B(3.08bpw) | Ornith-9B(6bpw) | |
|---|---|---|
| 重みサイズ(1GPUあたり) | 6.63 GiB | 4.42 GiB |
| KVプール(max-num-seqs=8時) | 111,177トークン | 508,713トークン |
| 実用可能な最大コンテキスト | 81,920トークン(concurrency 1.36〜1.75x) | モデルのネイティブ最大長262,144トークンをそのまま、しかもconcurrency 2.00x |
9Bはパラメータ数こそ少ないが6bpwという緩めの量子化のおかげで重みが軽く、モデルの謳い文句である256Kコンテキストを妥協なく、しかも2人同時利用分の余裕付きで確保できた。
速度・精度対決(同一プロンプト、同一トークン予算)
ここでの「切れた」は、max_tokensを各タスクごとに固定した予算(finish_reason: lengthでAPIが打ち切り)で、reasoningモデルなので思考(<think>...</think>)もこのmax_tokensの中に含まれることが原因。予算はタスクの難易度に応じて600〜800トークンとやや厳しめに設定しており(FizzBuzz生成600、二分探索デバッグ800、エージェント計画立案800、多段階計算600)、27Bは思考が長くなりがちで、この予算内に本文(思考後の実回答)を書き始める前に打ち切りが来てしまうケースが多かった。
| テスト | max_tokens | 27B | 9B |
|---|---|---|---|
| FizzBuzzコード生成 | 600 | ❌ 途中で構文が切れて壊れたコード(15.6s) | ✅ 完走、正しいコード(4.5s) |
| 二分探索デバッグ | 800 | ❌ 思考の途中で予算切れ、回答なし(20.4s) | ❌ 同じく予算切れ、回答なし(8.4s) |
| エージェント計画立案 | 800 | ❌ 思考の途中で予算切れ、回答なし(20.4s) | ⚠️ 思考は予算内に完了、回答本文の途中で切れる(8.4s) |
| 多段階計算 | 600 | ❌ 思考の途中で予算切れ、実質未回答(15.3s) | ✅ 完走、正解(6.1s) |
驚いたのは速度差(2〜3倍)以上に、**同じトークン予算(600〜800)で27Bは4問中3問が「思考だけで予算を使い切り、実際の回答本文を一度も出力しないままfinish_reason: lengthで打ち切られる」**という結果だったこと(</think>タグが一度も出現していない=ユーザーに渡る本文が空)。9Bは同じ予算でもずっと簡潔に考えて、本文に到達する余裕を残していた。モデルの大きさよりも「思考の冗長さ」がトークン予算内で答えに到達できるかを直接左右する、という実例になった。実運用でこの手の「思考だけで力尽きる」事故を避けるには、reasoningモデルには余裕を持ったmax_tokensを設定するか(前述の通り本番では実質無制限にしている)、思考トークン自体を短くするようプロンプト側で指示する必要がある。
チャンクサイズ実験の再現性
同じchunked prefillのチャンクサイズ実験を9Bでもやり直したところ、27Bと全く同じ傾向(4096が最良)が、より極端な形で再現された。
| chunk size | 完了時刻の幅 | 全体完了 |
|---|---|---|
| 256 | 24.29秒(最悪) | 29.66秒 |
| 512 | 17.94秒 | 25.38秒 |
| 2048 | 1.10秒 | 21.84秒 |
| 4096 | 0.21秒(最良) | 18.47秒(最速) |
| 8192 | 0.73秒 | 21.08秒 |
4096を頂点にした綺麗な山型で、小さすぎても大きすぎても悪化する。「4096が最適」という結論はモデルサイズに関わらず一貫していた。
実際に何ユーザーまで同時処理できるか(N=1〜300)
Ornith-9Bは256Kコンテキストが使えることが分かったので、最終的に「1人あたりの上限は256Kフルで、同時ユーザー数はどこまで許容できるか」を実測した。短めのプロンプトを使い、並列数Nを1→300まで段階的に増やしながらエラーの有無とスループットを見た。
| 並列数 | 合計tok/s | 状態 |
|---|---|---|
| 1 | 54.21 | |
| 8 | 417.39 | |
| 16 | 779.58 | |
| 32 | 959.02 | |
| 64 | 1096.49 | ピーク付近 |
| 96 | 1006.46 | |
| 128 | 1063.43 | 頭打ち |
| 192 | 1061.84 | 頭打ち |
| 256 | 1060.70 | 頭打ち |
| 300 | 1064.37 | エラーなし |
結果として、300並列でも一度もエラーは出なかった。--max-num-seqsの自動デフォルト値(256)を超えても、リクエストは拒否されずキューに積まれて順番に処理されるだけだった。つまり「壊れる上限」という形の答えは今回の範囲では存在しない。
その代わり、N=64付近でGPU計算能力が飽和し、それ以降は合計スループットが1000〜1100 tok/sで頭打ちになることが分かった。実用上の「ユーザー数制限」は、エラーになる人数ではなく「1人あたりの体感速度が落ち始める人数」で考えるべき、という結論になった。
最終本番構成
以上を踏まえて、Ornith-9Bの最終構成を次のように決定した:
aphrodite run /home/cadsv/exl3_served/Ornith-9B-6bpw-exl3 \
--tensor-parallel-size 2 \
--kv-cache-dtype fp8 \
--gpu-memory-utilization 0.88 \
--max-model-len 262144 \
--max-num-seqs 64 \
--max-num-batched-tokens 4096 \
--enable-auto-tool-choice \
--tool-call-parser qwen3_coder
-
max-model-len=262144: モデルのネイティブ最大長をそのまま採用。KVプールに十分な余裕がある(481,637トークン、concurrency 1.84x)ため妥協不要と判断 -
max-num-seqs=64: スループットが飽和するN=64を採用。「壊れる上限」ではなく「体感速度を維持できる同時ユーザー数の目安」としての設定 -
max_tokensはクライアント側で意図的に絞らない限り実質無制限として運用。ただし生成は当然KVキャッシュの制約を受けるので、プロンプト分を差し引いた残りコンテキスト(最大262,144トークン)を使い切ったところで打ち切られる。「無制限にすれば256Kを使い切るまで生成できる」というのがこの設定の意味になる
まとめ
- EXL3 + Qwen3.6/3.5ハイブリッドMamba/GDN + Aphrodite-engineの組み合わせは、2箇所のローカルパッチで動作した(upstream未報告バグ、PR提出済み)
- チャンクサイズ(
--max-num-batched-tokens)は大きければ良いわけでも小さければ良いわけでもなく、今回の環境・ワークロードでは4096が明確な最適点だった。モデルサイズが変わっても同じ傾向が再現されたのは興味深い - パラメータ数だけでモデルを比較するのは危険で、量子化の緩さ・VRAM効率・思考の簡潔さまで含めて評価する必要がある。今回は9Bが27Bを速度・精度・コンテキスト長すべてで上回った
- 2×12GBという決して大きくないVRAM構成でも、量子化とチューニング次第で256Kコンテキストの実用運用まで到達できた