見出し画像

gguf-fit を作った ─ GGUF を読むだけで llama-server の起動設定を決める

Qwen3.8-27B の GGUF が手元に12個ある。Q3_K_S から UD-Q8_K_XL まで、Unsloth が配っているものを一式。どれを 24GB のカードに載せて ctx をどこまで伸ばせるか、決め方は起動して nvidia-smi を眺めて落とし、ctx を変えてまた起動する、の繰り返しだった。

ロードだけで6秒。12ファイル × ctx の候補 × KV の型2つを潰す気にはならないので、勘で2〜3個だけ試していた。

この情報のほとんどは、GPU を1秒も回さずにファイルの中に書いてある。ヘッダに構造のメタデータがあり、テンソル一覧には866本ぶんの型と形が並んでいる。読めば済む話を、毎回ロードして確かめていた。

量子化を比べるベンチを回していて、毎回ここで止まっていた

きっかけは自作のベンチのほうだった。バグ報告とソースを渡してパッチを書かせ、隠しテストで採点する SWE-Bench 風のやつを回している。量子化を変えたら点がどう動くかを見たかった。

比較には同じ条件で起動する必要がある。`--ctx-size` をいくつにするか、`-ctk q8_0` を入れるか、`--spec-type draft-mtp` はそもそもこのファイルで効くのか。毎回そこで止まっていた。

とどめは KV を f16 と q8_0 で比べたとき。q8_0 で VRAM がどれだけ減るか事前に分からず、起動するまで ctx を決められなかった。

利用してる自作のベンチマークソフト

今回作成した gguf-fit

それで、ヘッダとテンソル一覧を読むだけで起動設定を決める道具を書いた。gguf-fit という名前で、コマンドは3つ。

読む・測る・決める

3コマンド。Python、MIT ライセンス、uv で入る。

uv tool install git+https://github.com/zephel01/gguf-fit

一度だけ試すなら。

uvx --from git+https://github.com/zephel01/gguf-fit gguf-probe /models/*.gguf

gguf-probe が読む係。mmap するので 27B でも1秒くらいで、ディレクトリごと渡すと比較表と JSON が出る。JSON に絶対パスを記録してあるので、後段が `-m` を自分で書ける。実戦で打つのはこの2行。

gguf-probe --json --out gguf.json /models/Qwen3.8-27B-GGUF/*.gguf
gguf-plan gguf.json --pick Q5_K_M

1行目でディレクトリ内の12個を全部読んで gguf.json に落とす。2行目の `--pick` でそのうち Q5_K_M だけを選び、VRAM 予算に収まる起動コマンドを組む。gguf-calibrate は実機で測って見積り式を決める係で、`--write-config` を付けると gguf-fit.toml に書き戻す。以降の gguf-plan は計算値ではなく実測値で計画する。

`gguf-plan gguf.json --pick Q5_K_M` の出力はこうなる。

# ===== Qwen3.8-27B-Q5_K_M / ctx 131,072 / KV f16 =====
# estimate: model 18.47 + KV 8.63 + overhead 1.00 = 28.10 GiB / budget 31.4 GiB
# headroom 3.30 GiB
# native ctx = 262,144  / no rope scaling
# hybrid attention: only 17/65 layers hold KV = 68 KB/token
# KV f16 = 69.1 KB/token, measured here (gguf-calibrate), not derived from the GGUF

# --- llama-server ---
# found 4 MTP tensors -> adding --spec-type draft-mtp
llama-server -m /mnt/data/models/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q5_K_M.gguf \
  --port 8085 --device CUDA0 \
  -ngl 99 -fa on \
  --ctx-size 131072 --parallel 1 \
  --batch-size 2048 --ubatch-size 512 \
  --threads 16 \
  --spec-type draft-mtp

コメントで「計算値か実測値か」を書き分けた。68 KB/token は GGUF からの計算値、69.1 KB/token はこの機械の実測値。混ぜると答えが変わる話は後で書く。

自動で拾うのは VRAM、物理コア数、`--device` が要るかどうか。GPU は `llama-server --list-devices` から取るので、CUDA0 / ROCm0 / Vulkan0 を推測しなくていい。ただし llama.cpp はビルドに含むバックエンドしか出さないので、build-cuda と build-rocm を両方登録しないと ROCm 側が見えず、1回ハマった。

設定の優先順位は CLI > 環境変数 > 設定ファイル > 実測 > 組み込み既定で、`--show-config` でどれが効いているか出る。設定ファイルは最初に見つかった1つだけ読んでマージしない。マージすると「この値はどこから来たか」に答えられなくなる。

中身は Python だけ、テストに GPU もモデルも要らない

依存は gguf パッケージ1つ(3.10 のときだけ tomli が足される)。ビルドは hatchling、requires-python は 3.10 以上で 3.13 まで通してある。

テストは205件で 0.2秒で終わる。**GPU も GGUF ファイルも要らない。**型の分類も VRAM の計算も純粋関数にしてあり、テンソル名と型のリストを渡せば動くので CI が実機なしで回る。本体2,680行に対してテストが2,008行あるのは、実測で分かったことを1件ずつ固定していったから。

ruff はバージョンを固定しルールも明示してある。0.15 で「All checks passed」だったコードが 0.16 で10件出て、コードを触らずに CI が赤くなるのを避けたかった。

「Q6」と書いてあるファイルの、重みの過半は Q8_0 だった

12個まとめて読ませて最初に出てきたのがこれ。UD-Q6_K_XL の重みテンソルの内訳は Q8_0 が 55.1%、Q6_K が 25.9%、Q5_K が 19.0%。名前に Q6 と入っているのに、いちばん多い型は Q8_0 だった。

素の Q6_K のほうは Q6_K 88.0% / Q8_0 12.0% で、こちらは名前どおり。サイズは Q6_K が 22.88 GB、UD-Q6_K_XL が 25.92 GB。差は 3.04 GB ある。

これが効いてくるのはベンチのとき。「Q6_K と UD-Q6_K_XL でどっちが賢いか」は同じ bit 幅どうしの比較ではなく、3.04 GB 余計に積んだものと比べている。UD のほうが良い数字を出したとして、作り方が良いのか単に大きいのか、名前からは分けられない。

UD-Q4_K_XL はもっと極端で、最多の型が Q5_K の 64.2%。Q4_K は 19.2% しかない。残りが IQ4_XS 12.8% と Q6_K 3.8%。「Q4」と書いてあるファイルの中身の6割超が Q5_K だった。

標準の K-quant も名前ちょうどではないが、Q4_K_M が Q4_K 71.7%、Q5_K_M が Q5_K 71.7% と、「主たる型が7割で残りに上位を混ぜる」という素直な形をしている。

「Dynamic」が変えているのは層ではなく役割

Unsloth の Dynamic という名前から、自分は「層ごとに型を出し入れしている」ものを想像していた。逆だった。

このモデルには24種類の役割(attn_qkv.weight とか ffn_down.weight とか)がある。そのうち「同じ役割なのに層によって型が違う」ものが何個あるかを数えると、標準の _M だけが該当する。Q4_K_M が 3/24、Q5_K_M が 3/24、Q3_K_M が 2/24。UD-* は Q4/Q5/Q6/Q8 のどれも 0/24 だった。標準でも _S や Q6_K、Q8_0 は 0/24。

Q5_K_M で割れている役割は3つ。attn_qkv が Q6_K 24層 / Q5_K 24層、ffn_down が 33層 / 32層、attn_v が 9層 / 8層。ほぼ半々に割るのは llama.cpp の use_more_bits がやっていることで、Unsloth の工夫ではない。

UD が動かしているのは「どの役割に多く bit を割り当てるか」のほうで、層ごとの出し入れはしない。

65層あるのに、KV を持つのは17層だけ

qwen35 の block_count は 65。ところが attn_k と attn_v を実際に持っている層はテンソル一覧を数えると 17 しかない。番号は 3, 7, 11, 15, 19, 23, 27, 31, 35, 39, 43, 47, 51, 55, 59, 63, 64。4層おきに1つ、最後だけ 63 と 64 が連続する。残る48層は attn_qkv が1本あるだけの線形注意で、固定長の状態しか持たない。ctx を伸ばしても増えない層だ。

計算自体は簡単で、head_count_kv が 4、key_length と value_length が 256、f16 なら2バイト。1層あたり 4 × (256 + 256) × 2 = 4,096 B。17層で 69,632 B、つまり 68.0 KB/token になる。

これを block_count の 65 で数えると 266,240 B = 260 KB/token。3.8倍の過大見積りになる。ここを間違えると、載る載らないの判定は丸ごとひっくり返る。

ヘッダの block_count だけでは出ない数字で、テンソル一覧を1本ずつ見ないと分からない。ファイル名にもモデルカードにも書いていない。

開かないと分からないものはまだある。mtp_tensor_count が 4 で、実体は blk.64.nextn.eh_proj.weight ほか4本。これがあるファイルにだけ `--spec-type draft-mtp` を付ける。chat_template には thinking の分岐が入っていたので、サンプリングは temperature 1.0 / top_p 0.95 / top_k 20 / min_p 0.0 に寄せる。

どれもそのまま起動コマンドの1行に化ける。手で書くと毎回どこかを忘れる。

計算値は実測とずれる。ずれ方が f16 と q8_0 で違う

ここまでは全部 GGUF から計算した値で、GPU は1回も回していない。ただ計算は計算でしかないので、測るほうのコマンドも書いた。gguf-calibrate という。

ctx を変えて llama-server を起動し、`/health` を待ち、2,048トークンのリクエストを1回通し、nvidia-smi で起動前からの増分を読んで落とす。RTX 5090 / build-cuda / `-fa on` / Q5_K_M で ctx 32,768 と 65,536 の2点から、使用量 = 切片 + ctx × 単価の直線を引く。

増分は f16 が 21,822 MiB と 24,032 MiB、q8_0 が 20,948 MiB と 22,326 MiB。ここから出た単価が、f16 で 69.1 KB/token、q8_0 で 43.1 KB/token。

f16 は GGUF から計算した 68.0 の 1.02倍で、ほぼ計算どおり。ところが q8_0 は理論 36.1 に対して 43.1 で、1.19倍ある。KV を量子化しても、思ったぶんだけは減らない。

直線の当てはまりは気持ちがいいくらい良くて、切片は f16 19.15 GiB / q8_0 19.11 GiB、4点の最大誤差が 0 MiB。4回走らせても4点とも 1 MiB の差もなく一致した。

切片 19.15 GiB からファイルの 18.47 GiB を引くと 0.68 GiB。これがモデル以外に持っていかれるぶんだった。

0.531 と 0.624 のあいだで、「載る」が「載らない」になる

f16 と q8_0 の比を取ると 0.624。素朴に「1トークン1層あたり 64 バイトが 34 バイトになる」で計算すると 0.531 で、この差がそのまま判定に効く。

24GiB の予算で q8_0、ctx 131,072 を狙うとする。0.531 で計算すると 23.98 GiB でぎりぎり載る。実測の 0.624 だと 24.5 GiB 必要で、載らない。同じファイル、同じ ctx、同じカードで、使った係数だけが違う。

ctx 65,536 でも同じ。KV は f16 が 4.32 GiB、q8_0 が 2.69 GiB で、節約は 1.62 GiB。0.531 から予測すると 2.02 GiB なので、KV 量子化の得を 25% 多めに見積もることになる。

そもそも「24GB」は 24GiB ではない

単位のほうでも同じ規模のずれが出る。GGUF のバイト数を 10^9 で割ったものが size_gb で、これは GB。GPU の「24GB」は普通 GiB のことで 2^30 で割る。Q5_K_M は 19.83 GB、GiB に直すと 18.47 GiB。1.36 の差があって、GB のまま予算と比べると判定が反転しうる。gguf-fit は全部 GiB に揃えている。

ついでに言うと「24GB」のカードは llama.cpp から見ると 23.6 GiB しかない。手元の RTX 3090 を `--list-devices` に出させると総量 23.6 GiB、空きは 23.3 GiB と返ってくる。24.0 を予算に置いた時点で楽観的だった。

73.5 KB/token という、間違った値を自分で出していた

いま 69.1 と書いているこの数字を、最初は 73.5 KB/token だと思っていた。

きっかけは、同じサーバの同じ ctx 65,536 なのに読むタイミングで値が動くことに気づいたこと。ロード直後が 23,922 MiB。8トークンのリクエストを1回通したあとが 24,022 で +100。2,048トークンを1回通したあとが 24,032 で +110。本番のベンチを連続で回している最中は 24,064 で +142、しかも ±50 くらい揺れる。

面白いのはここから。+100 も +110 も +142 も、f16 と q8_0 でまったく同じ値だった。KV の量にも ctx にも比例しないので、これは KV ではなく推論そのものが確保する分だった。

やらかしたのは測り方のほう。ctx 32,768 をロード直後に測って、ctx 65,536 を推論中に測って、その2点で傾きを出していた。片方にだけ定数が乗っているので、定数がまるごと傾きに化ける。それで 73.5 KB/token という値が出ていた。条件をそろえると 69.1 になる。

ウォームアップを入れて測り直したときの動きが、いちばん納得できた。4点とも同じ量だけ上がり、傾きは1バイトも変わらず、切片だけが 19.045 GiB から 19.15 GiB になった。定数は切片に乗るべきもので、実際そこに乗った。

プロンプトを 8 から 2,048 トークンに、256倍にしても +10 MiB しか動かない。確保は「推論を1回でも通したか」でほぼ決まっていて、プロンプトの長さでは決まらない。だから gguf-calibrate は各点で必ず 2,048トークンのリクエストを1回通してから読む。ウォームアップは道具の都合ではなく、数字が変わる話だった。

24 GiB に、どれがどこまで入るか

実測値を入れて予算 24 GiB / overhead 1.0 の最大 ctx を出すとこうなる。Q5_K_M が f16 で 65,536、q8_0 で 106,496。Q6_K まで上げると f16 24,576 / q8_0 40,960 まで落ち、UD-Q6_K_XL の 24.14 GiB から上は入らない。Q3_K_S なら q8_0 で 262,144 とネイティブ ctx に届く。「長い ctx がほしいなら量子化を落とす」が数字で出る。

限界と、確認できていないこと

まず imatrix は読めない。同じ Q4_K でもキャリブレーションの中身は違いうるのに、GGUF はそれを記録していない。分かるのは「どの役割にどの型を割り当てたか」まで。

品質も分からない。「重みの 71.7% が Q5_K」は事実として読めるが、それが何点取るかは別の話だ。候補を絞る道具であって、測ることの代わりにはならない。

検証したのは1モデル・1環境だけ。Qwen3.8-27B / RTX 5090 / CUDA で測った 0.624 が、他のアーキや ROCm、Metal、別の llama.cpp バージョンで同じ保証はない。だから gguf-calibrate を同梱し、自分の環境で測り直す前提にしてある。

推論中の +142 MiB のうち 32 MiB は再現できていない。プロンプトを伸ばしても出ないので、連続運転で出てくる何か(スロットの回転や KV の defrag など)だと見ているが、確かめていない。

複数GPUに分割する構成も見ていない。`--vram` は1デバイスぶんの予算で、手元に3枚あるのに分割の見積りには手を付けていない。

置いてあるのはここです。

https://github.com/zephel01/gguf-fit

聞いてみたいのは、みなさんが量子化をどう選んでいるか。とりあえず Q4_K_M から始めるのか、載る中でいちばん大きいものを取るのか、毎回自分で回して決めているのか。あと、注意の形が混ざったモデルで KV の見積りがずれた経験があれば、モデル名を知りたいです。17/65 のような比率が他のアーキでどうなるのか、まだ分かっていません。コメントで教えてください。

LM-Studio, Ollama は後日対応予定。

#gguffit #GGUF #llamacpp #ローカルLLM #量子化 #Qwen3 #個人開発 #Python #VRAM #Unsloth #uv

いいなと思ったら応援しよう!

zephel01 サーバー代とコーヒー代になります☕ 役に立ったら応援よろしくお願いします!