0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Sonar (Aphrodite Engine) で EXL3 の未報告バグを2つ見つけて直した話、あとGDNハイブリッドモデルの謎速度チューニング

0
Last updated at Posted at 2026-07-15

はじめに

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.pyload_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_qkv shard_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コンテキストの実用運用まで到達できた
0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?