tk3.biz
ブログ一覧に戻る Back to Blog

コンテキストを 128K まで伸ばしたら、壁はメモリではなく待ち時間だった コンテキストを 128K まで伸ばしたら、壁はメモリではなく待ち時間だった

AIローカルLLMQwenllama.cppベンチマーク測定

はじめに

前回のベンチでは、深度 32K までしか測っていません。 使っている Qwen3.6-35B-A3B は 262,144 トークンまで扱えます。 その 8 分の 1 しか確かめていないことになります。

測る前に、仮説を立てました。

128K まで伸ばすと KV キャッシュが膨らみ、メモリが足りなくなる。 そうなれば、第2回で不採用にした KV 量子化が必須になる。判断が「条件付き」に変わる。

結果から言うと、この仮説は外れました。 ただ、外れた理由のほうが面白い話でした。 そして、本当の壁は別のところにありました。

まず、128K は載るのか

1 時間級の計測に入る前に、成立するかだけを 1 回で確かめました。

llama-bench -hf unsloth/Qwen3.6-35B-A3B-MTP-GGUF:UD-Q4_K_XL
            -ngl 99 -fa 1 -b 8192 -p 0 -n 128 -d 131072 -r 1
tg128 @ d131072 → 10.05 t/s   (所要 18.4 分)

動きました。ただし 1 条件 1 回で 18 分です。 いきなり 4 条件 × 数回の本番を組んでいたら、落ちたときに数時間を失うところでした。

メモリ: 上限の 256K でも 28 GiB

llama-server を -c だけ変えて起動し、実メモリを比べました。

コンテキスト長ごとのllama-serverの実メモリ。8Kで22.55 GiB、64Kで23.76、128Kで25.14、256Kで27.89。モデル本体21.27 GiBに対して、それ以外の部分はわずか。搭載93.6 GiBの3割に収まる

-cKV実メモリ8K からの増分
8,192f1622.55 GiB—
65,536f1623.76 GiB+1.21 GiB
131,072f1625.14 GiB+2.59 GiB
131,072q8_024.00 GiB+1.45 GiB
262,144f1627.89 GiB+5.34 GiB

モデルの上限 256K まで起動して 28 GiB。 搭載 93.6 GiB の 3 割です。

KV を q8_0 にして浮くのは、128K で 1.14 GiB。 この機体でメモリを理由に量子化する理由はありません。仮説は外れました。

なぜ KV がこんなに小さいのか

128K ぶんの KV が 2.6 GiB しかないのは、一般的な構成から見ると小さすぎます。 GGUF のメタデータを読みました。

qwen35moe.block_count             = 41
qwen35moe.full_attention_interval = 4
qwen35moe.ssm.state_size          = 128
qwen35moe.attention.head_count_kv = 2
qwen35moe.attention.key_length    = 256
qwen35moe.attention.value_length  = 256

full_attention_interval = 4 と ssm.* が答えでした。 このモデルは SSM(状態空間モデル)とフルアテンションのハイブリッドです。

本体40層のうち、4層に1層だけがフルアテンションでKVキャッシュを持つ。残り30層はSSMで状態が固定サイズ。1トークンあたりのKVは、全層アテンションなら81,920バイト、実際の構成の理論値は20,480バイト、実測は22,632バイト

KV キャッシュがコンテキストに比例して伸びるのは、フルアテンション層だけです。 SSM 層が持つのは固定サイズの状態で、何トークン読んでも増えません。

フルアテンション層は 4 層に 1 層、本体 40 層のうち 10 層。理論値を出すと、

10 層 × (K + V) × 2 ヘッド × 256 次元 × 2 バイト = 20,480 B/token

もし全 40 層がフルアテンションなら 81,920 B/token で、4 倍です。 「KV が小さい」のではなく、**「KV を持つ層が 4 分の 1 しかない」**わけです。

検算で自分の間違いを見つけた

最初、実測と理論が「3% で一致した」と書きかけました。

検算すると、実測を GiB(2³⁰)、理論を 10⁹ バイトで比べていました。 単位を揃えるとこうなります。

KV理論実測超過
f1620,480 B22,632 B+2,152 B
q8_010,880 B12,670 B+1,790 B

一致ではなく、約 1 割の超過でした。

ただ、超過分は KV の型によらず**ほぼ一定(約 2 KB/token)**です。 KV とは別に、コンテキスト長に比例して確保されるバッファがあると見られます。 中身までは確かめていません。構造の説明そのものは変わりません。

生成速度: 128K で 4 割強の減速

深度を振って生成速度を測りました(f16、1 回の起動内、各 2 回)。 後述する割り込み監視つきの最終計測の値です。

深度tg128深度 0 比
017.38—
32,76814.77−15%
131,0729.72−44%

128K まで積んでも毎秒 10 トークン弱出ます。読める速度です。 32K の −15% は、前回の −15.7% とも一致しました。

ここに辿り着くまでに、計測を 4 回やり直しています。

自分の自動起動に、計測を汚された

本番の 1 回目は、深度 3 点 × KV 4 通りを 1 回の起動内で振っていました。 途中まではまともでしたが、後半にこんな値が出ます。

K / V深度 0深度 32K
q8_0 / q8_09.6114.67

KV が空の深度 0 のほうが、32K 積んだ状態より遅い。 物理的にありえません。

プロセスを調べると、計測の途中で llama-server が起動していました(26 GB)。 起動元は serve.ps1。CLI の回で組んだ 「llm を叩いたときにサーバが止まっていたら自動で起動する」仕組みです。

llama-bench   25.21 GB
llama-server  26.07 GB   ← 13:51 に割り込み
コミット      109.9 / 111.3 GB

自分で作った便利機能が、自分の計測を汚したわけです。

前回「比較は 1 回の起動の中で振る」と書きました。 それ自体は正しいのですが、外から割り込まれれば同じ起動の中でも比較は成り立ちません。

2 回目からは、llama-server の出現を 10 秒ごとに記録する監視ジョブを併走させました。

KV 量子化: 128K では K だけ量子化が速い

監視つきの 2 回目です(深度 128K、各 2 回)。

K / V深度 0深度 128K
f16 / f1617.139.90
f16 / q8_017.719.37
q8_0 / f1618.7911.51
q8_0 / q8_018.7210.44

K だけ量子化(q8_0 / f16)が最速で、f16 比 +16%。

ここで立ち止まりました。深度 0 の値が、実行順に上がっています。

17.13 → 17.71 → 18.79 → 18.72

深度 0 では KV の型は効かないはずです。これは時間経過によるドリフトで、 後に測った q8_0 / f16 が得をしていた可能性があります。

そこで 3 回目は順序を逆にして、q8_0 / f16 を先に測りました。

順番K / V深度 0深度 128K
先q8_0 / f1618.6911.30
後f16 / f1618.5810.65

不利な先頭に置いても +6.1%。差は本物で、+16% は順序で水増しされていました。

さらに 4 回目(後述の最終計測)では、f16 → q8_0 → f16 と挟んで測りました。 最初と最後の f16 が揃えば、途中でドリフトしていないと言えます。

順K深度 0深度 32K深度 128K
1f1617.1614.759.65
2q8_017.4515.0610.75
3f1617.5914.799.79

32K の f16 が 14.75 と 14.79 で揃っています。この回はドリフトしていません。

K だけ量子化したときの損得をまとめるとこうなります。

深度計測K だけ q8_0
32K前回の記事(各 5 回)−3.5%
32K4 回目(挟み込み)+2.0%
128K2 回目+16%(順序で水増し)
128K3 回目(順序反転)+6.1%
128K4 回目(挟み込み)+10.6%

32K では差が無く、128K では 6〜11% 速い。 32K の ±3% はばらつきの範囲です。 V を量子化するのは、測ったどの深度でも損でした(4 回目では V は測っていません)。

第2回の判断は、予想とは違う理由で「条件付き」になったことになります。 メモリではなく速度の理由で、しかも 100K を超える深さに限った話です。

本当の壁: 128K を読ませるだけで 21〜24 分

生成速度はまだ読める水準でした。問題はその前です。

実サーバに 32K / 64K / 128K のプロンプトを実際に投げ、取り込みにかかる時間を測りました。 時間帯を変えて 2 回です。

プロンプトの長さと取り込み時間。1回目は128Kで21.5分、2回目は24.2分で、どちらも後ろほど急に伸びる。最初の速度349 t/sが続けば128Kでも6.3分で済むはずだった

プロンプト1 回目(16 時台)2 回目(23 時台)
32K149 秒(2.5 分)167 秒(2.8 分)
64K397 秒(6.6 分)469 秒(7.8 分)
128K1,291 秒(21.5 分)1,451 秒(24.2 分)

128K の文書を渡すと、最初の 1 文字が返ってくるまで 21〜24 分かかります。

追記(2026-09-27): 次の回で 3 回目を測ったところ、 128K の初回読み込みは 17.6 分でした。3 回で 17.6〜24.2 分と、同じ設定でも 4 割近い幅があります。 なお、同じ文書に 2 回目以降の質問をする場合は、読み直しが末尾の 515 トークンだけで済み、10.7 秒でした。

進行ログを見ると、後ろのチャンクほど遅くなっています。

位置その 8K の所要(1 回目)(2 回目)
0 → 8K23.5 秒26.1 秒
32K → 40K52.7 秒62.5 秒
64K → 72K82.0 秒95.1 秒
112K → 120K129.1 秒143.7 秒

新しく読むトークンは、それまでの全トークンを参照します。 1 チャンクの所要がほぼ直線的に伸びるので、合計は長さの 2 乗に近く伸びます。

MTP(投機的デコード)が足を引っ張っている可能性も疑い、1 回目の直後に外して測りました。

プロンプト取り込み(MTP あり)取り込み(MTP なし)生成(あり)生成(なし)
32K149.1 秒148.8 秒16.23 t/s14.52 t/s
64K397.4 秒377.2 秒13.77 t/s12.40 t/s

取り込みはほぼ同じです。遅さは MTP のせいではなく、アテンションそのものの重さです。 生成は MTP ありが約 1 割速く、第2回で測った効果(散文 +12.6%)と同じ傾向でした。 ただしどちらも 1 回ずつなので、1 割前後の差は後述のばらつきと同じ大きさです。

見積もりも外していた

計測前、私は「131K のプリフィルは約 6 分」と見積もっていました。 短いプロンプトの処理速度(約 350 t/s)がずっと続く前提で割り算していたからです。

実際は 21〜24 分。3.4〜3.8 倍の見込み違いです。 短い入力で測った速度を長い入力に外挿してはいけない、という当たり前の話でした。

裏で何か動いていなかったか、測り直した

ここまで書いてから、気になりました。監視していたのは llama-server の起動だけです。 ほかのプロセスが CPU や GPU を使っていても、この監視では気づけません。

実際、1 回目の f16 には引っかかる値がありました。

実行深度 0深度 32K劣化
前回の記事(各 5 回)18.5715.65−16%
今回の 1 回目17.4513.25−24%

割り込みの前に終わっていたので「無傷」と判断していましたが、根拠は 「llama-server がまだ起動していなかった」ことだけでした。

そこで監視を広げました。10 秒ごとに、llama 以外のプロセスが使った CPU と GPU を記録します。

# 前回サンプルからの CPU 時間の差分を、プロセスごとに積む(llama-* は除外)
$pct = ($s - $prev[$p.Id]) / $dt / $ncpu * 100
# GPU はエンジンごとの使用率カウンタを pid で引き、llama 以外を合計
Get-Counter '\GPU Engine(*)\Utilization Percentage'

これを併走させて、ベンチ(f16 → q8_0 → f16 の挟み込み)とプリフィルを全部やり直したのが 4 回目です。

区間llama 以外の CPU(中央値 / 最大)llama 以外の GPU(最大)
ベンチ(70 分)6.6% / 11.5%4.7%
プリフィル(37 分)6.6% / 8.9%4.2%

6.6% は大半がこの作業に使っているアプリ自身で、計測前の平常時(7.3%)と同じ水準です。 今回は裏で何も動いていません。

その結果、変わったことが 2 つあります。

  1. 32K の劣化は −15%。前回の記事と一致し、1 回目の −24% のほうが外れ値でした。 原因は突き止められませんが、「無傷」の判断は甘かったことになります
  2. 32K での K 量子化の損得は +2.0%。前回の −3.5% と合わせて、差は無いと見るべきでした

一方で、割り込みが無くても値は動きました。プリフィルは 1 回目より 12〜18% 遅く、 最初の 8K チャンクから一様に遅い。途中で何かが割り込んだ形ではなく、 マシン全体の状態(熱や電力)の差と見ています。

監視は、見ているものしか守ってくれません。 そして全部を見張っても、1 割前後はまだ動きます。

設定はどうするか

変えません。

項目現状判断
-c65,536据え置き。メモリ的には 256K も可能だが、64K の取り込みで既に 6.6〜7.8 分
KVf16 / f16据え置き。K 量子化が得をするのは 100K 超だけ

コードレビューに投げているのは 2 万トークン前後です。 その深さでは K を量子化しても速くならず、変える理由がありません。 128K 級の入力は、取り込みだけで 20 分を超えます。 対話的に使う範囲では、128K を常用する場面がありません。

「一晩かけて巨大な文書を読ませる」ような使い方をするなら、 そのときだけ -c 131072 -ctk q8_0 で起動する、という選択肢は残ります。

まとめ

  • 128K は動く。 256K でもメモリは 28 GiB で、搭載の 3 割
  • メモリが小さい理由はハイブリッド構成。KV を持つのは 40 層中 10 層だけ
  • 生成速度は 128K で −44%(毎秒 10 トークン弱)。読める水準
  • K だけ量子化は 128K で 6〜11% 速い。 32K では差が無い。V の量子化は損
  • 本当の壁はプリフィル。 128K の取り込みに 21〜24 分
  • 計測中に自分の自動起動が割り込んだ。監視は llama-server だけでなく、CPU・GPU 全体を見る
  • 全部を見張っても、実行ごとに 1 割前後は動く

外れた仮説は「メモリが足りなくなる」でした。 外れたおかげで、モデルの構造と、待ち時間という本当の制約が見えました。

関連記事 Related Posts

ローカルLLMのベンチマークの取り方(チェックリスト付き) ローカルLLMのベンチマークの取り方(チェックリスト付き)

ローカルLLMの速度や品質を測るときに、結果を信用できるものにする手順をまとめました。同じ条件でも1割ぶれる、順番で結果が変わる、裏の処理で5倍ぶれる、採点器が壊れている。実際に踏んだ失敗から作った、測る前・測っている間・測ったあとのチェックリストです。 ローカルLLMの速度や品質を測るときに、結果を信用できるものにする手順をまとめました。同じ条件でも1割ぶれる、順番で結果が変わる、裏の処理で5倍ぶれる、採点器が壊れている。実際に踏んだ失敗から作った、測る前・測っている間・測ったあとのチェックリストです。

ベンチで「効果を見つけた」と思ったら、測定のぶれだった ベンチで「効果を見つけた」と思ったら、測定のぶれだった

過去に下した設定判断を検証しようとして、逆の結果が出ました。喜びかけたところで物理的におかしい点に気づき、測り直したら元の判断が正しかった。別々に起動したベンチを比較してはいけない、という話です。 過去に下した設定判断を検証しようとして、逆の結果が出ました。喜びかけたところで物理的におかしい点に気づき、測り直したら元の判断が正しかった。別々に起動したベンチを比較してはいけない、という話です。

ローカルLLMの設定、効くもの・効かないもの一覧(実測つき) ローカルLLMの設定、効くもの・効かないもの一覧(実測つき)

Ryzen AI ミニPCでローカルLLMを動かすときに試した設定を、効果の大きさ順に1枚にまとめました。Vulkanは5.2倍、MoEは5.7倍。一方で、効かなかったもの、逆効果だったもの、そもそも無効だったものもあります。すべてこの機体での実測値です。 Ryzen AI ミニPCでローカルLLMを動かすときに試した設定を、効果の大きさ順に1枚にまとめました。Vulkanは5.2倍、MoEは5.7倍。一方で、効かなかったもの、逆効果だったもの、そもそも無効だったものもあります。すべてこの機体での実測値です。