本記事の執筆と調査には Anthropic の Claude とそのリサーチ機能を使用しています。M5 Max でのパッチ作成や実験には Claude Code を使用しています。
はじめに
Qwen3.8-Flash-Next の 51B N-gram 埋め込みをめぐるシリーズの 8 本目です。
扱ってきた問題は 1 つです。51.2B パラメータの N-gram 埋め込みテーブル
(per_layer_token_embd.weight の 1 本、176.9B 中の 29%)を、128 GB の機械にどう収めるか。
これまでの答えは「4 ビット級に量子化したうえで、自前のパッチでディスクに残す」でした。
今回は、量子化しない実装と比べます。antirez の DwarfStar(ds4)が 2026-09-14 に Qwen3.8-Flash-Next 対応を取り込みました。こちらは N-gram テーブルを BF16 のまま 95.37 GiB としてディスクに置き、必要な行だけ読みます。
置き場所は両者とも同じでディスクです。違うのは精度(BF16 対 IQ4_NL)と読み方(GGUF から行を直読みするか、mmap して lazy read するか)、そして llama.cpp 側は第 3 弾の非公式パッチが要ることです。
同じ M5 Max 128GB で、同じモデル、同じ深さ、同じ反復数で測りました。先に結論を書きます。測った全指標で DwarfStar が上でした。深さ 65,536 で生成が 23.20 対 41.91 t/s(1.8 倍)、prefill が 293.8 対 920.5 t/s(3.1 倍)。常駐は wired 実測 75.2 GiB 対 起動ログの resident model 69.73 GiB で、少ない方が速い。代償はディスク 87 GiB が 165 GiB になることだけです。
ベンチだけの話ではありません。この記事を書いたあと常用を DwarfStar に移し、クライアントも OpenCode v2 に替えました。いまは ds4-server と OpenCode v2 の組み合わせで、自作の SystemVerilog シミュレータ(sukimasim)のデバッグに使っています。直近 28.7 時間で
1,781 リクエスト、その 97.2% がツール往復の完了で、出力上限による打ち切りもエラーも 0 件でした。深さの中央値は 138,962 トークンです。ベンチで見た深さ耐性が、エージェントに道具を呼ばせ続ける使い方でもそのまま出ています(追記 5)。
ただし、この比較にはエキスパートの量子化差が混ざっています。純粋な「N-gram の持ち方」比較ではありません。そこは後半で切り分けます。
この記事の要点
| 項目 | 内容 |
|---|---|
| 比較対象 | 同一モデル Qwen3.8-Flash-Next を llama.cpp(unsloth UD-IQ4_XS、87.24 GiB)と DwarfStar(antirez qwen38-q4k、165.11 GiB)で実行 |
| N-gram の扱い | 実体は per_layer_token_embd.weight 1 本・51.2B。llama.cpp は iq4_nl 26.8 GiB へ量子化し、分割パッチでディスクに残す。DwarfStar は bf16 95.37 GiB をそのままディスクに置き行単位で読む。置き場所は同じで、違うのは精度と読み方 |
| 生成(深さ 65,536) | llama.cpp 23.20 t/s に対し DwarfStar 41.91 t/s(1.8 倍) |
| prefill(深さ 65,536) | llama.cpp 293.8 t/s に対し DwarfStar 920.5 t/s(3.1 倍) |
| 深さ耐性 | llama.cpp は 32K を境に生成が 36.4 → 26.0 t/s と急落。DwarfStar は 2K から 65K まで 46.2 → 41.9 t/s(−9%)でほぼ平坦 |
| 常駐メモリ | llama.cpp は wired 実測で 75.2〜75.5 GiB(分割パッチが無いと 101〜102 GiB)。DwarfStar は起動ログで resident model 69.73 GiB |
| MTP | DwarfStar は GGUF 同梱の MTP を --mtp で使え、採択率 85.4%、短文生成が 53.3 → 70.4 t/s(+32%)。llama.cpp 側の UD-IQ4_XS には MTP ヘッド(blk.48)自体が無く、外部の下書き GGUF も無いので未使用 |
| セットアップ | ds4 のビルドは 9.03 秒・警告 0。モデル取得は 165.11 GiB を 30.9 分(実測 96 MB/s) |
| 品質(PPL) | 測り方を揃えた比較で llama.cpp 3.0356 対 DwarfStar 2.7007。DwarfStar が 11.0% 低い(良い)。ただしエキスパート量子化の差を含み、採点範囲も llama.cpp は窓の後半のみ(DwarfStar に不利な側に残る) |
| ドキュメントとの食い違い | 3 件(ds4-bench が --mtp を拒否、--chdir 不在、DS4_QWEN4_PLE_EVICT_TOKENS 不在)。本文に詳細 |
| 実運用(追記 1) | 8 時間・357 リクエスト・エラー 0。時間加重平均 26.18 t/s(llama.cpp は 9.9 だった)。深さ 200k–250k の decode 中央値 52.04 t/s で、深さで落ちない |
| 実運用(追記 2) | 43 時間・1,863 リクエスト・101 万トークン・エラー 0。深さ耐性は維持(100k 以降が 42〜44 t/s)だが「200k が最速」は取り下げ。平均は 19.26 t/s へ下がり、原因はフル prefill が 19 回から 328 回に増えたこと |
| ディスク KV(追記 2) | 43 時間で保存 324 件に対し復元 0 件。容量ではない。常駐では一度切って 124.9 GiB を解放した |
| ディスク KV の訂正(追記 3) | 「原理的に一致しない」は誤り。保存経路 4 つのうち cold と prefill 中の continued は接頭辞を書き、両方とも別々の理由で止めていた。A/B で復元 0 件 → 3/3、2 巡目の処理トークンが 83.6% 減。2 パスで再現 |
| 作り置きの被覆(追記 3) |
cold / continued は 2048 の倍数へ切り下げて書き、evict / shutdown は全長を書く。作り置き直後に取れるのは前者だけで 87.3%。100% が要るならサーバーを正常終了させてから取る |
| 実運用(追記 4) | 46.8 時間・2,500 リクエストで復元 11 件(43.1 時間の cold 無効では 0 件だった)。省けた prefill は 1,349,632 トークンで、同窓のフル prefill 総量 6,515,860 の 20.7%。ただし被覆率は 25.4〜99.5% と二山で、フル prefill 率も窓の切り方だけで 0.4〜3.4 件/時と動くため、7.8 → 1.4 件/時の減少は帰属させられない |
| 実運用(追記 5) | OpenCode v2 へ移行後 28.7 時間で 1,781 リクエスト、length 打ち切り 0・エラー 0。97.2% が tool_calls 完了で、深さ中央値 138,962・200k 超 294 件でも decode 中央値 39.03 t/s |
| 弱い所 | 品質はエキスパート量子化の差を含むパッケージ比較で、N-gram 単独の比較ではない。ベンチは反復 2 回、冷却なし、プロンプト素材 1 種 |
情報源の区分
前作までと同じ 3 区分です。
| 区分 | 内容 | 主な情報源 |
|---|---|---|
| A | 公式一次情報 | antirez/ds4 のソースと docs/QWEN38_FLASH_NEXT.md(コミット 9139e2a)、./download_model.sh と --help の実出力、GGUF のメタデータとテンソル表 |
| B | 二次情報 | GitHub の PR #990 / #991 の状態(API 経由で取得) |
| C | 本記事の実測 |
ds4-bench と llama-bench の出力、起動ログ、--inspect のテンソル集計、自作の GGUF テンソル読み取りスクリプト |
そもそも何が違うのか
両者のテンソル構成を実測しました。まず llama.cpp 側、unsloth UD-IQ4_XS の 3 シャード合計です。
| 型 | パラメータ | 割合 |
|---|---|---|
| iq4_nl | 87,271,260,160 | 49.3% |
| iq3_s | 78,852,915,200 | 44.6% |
| q8_0 | 8,408,268,800 | 4.8% |
| iq4_xs | 1,677,721,600 | 0.9% |
| q6_k | 635,699,200 | 0.4% |
| f32 | 78,373,760 | 0.04% |
| bf16 | 19,660,800 | 0.01% |
| 合計 | 176,943,899,520 | 100% |
パラメータの 93.9% が iq4_nl と iq3_s、つまり 3〜4 ビットです。
ただし、この 166.1B がまるごと N-gram テーブルというわけではありません。大半はエキスパートです。
N-gram はテンソル 1 本に収まっています。
| 値 | |
|---|---|
| テンソル名 | per_layer_token_embd.weight |
| 次元 | 160 × 320,001,536 |
| パラメータ | 51,200,245,760(51.2B、全体の 29%) |
| 型 | iq4_nl |
| サイズ | 26.8 GiB(27,465 MiB) |
題名の「51B」はこの数字です。第 3 弾で Metal バッファから外したのも、この 1 本でした。
起動ログにそのまま出ます。
add: tensor per_layer_token_embd.weight (size = 27465 MiB) lazy read enabled
load_tensors: MTL0 buffer for file 1 skips a lazily-read range of 27465 MiB
パッチの有無で wired を比べると 75.2 GiB 対 101.2 GiB で、差の 26.0 GiB は
この lazy 範囲(26.8 GiB)とほぼ一致します。パッチ版では N-gram が常駐しません。
残る 125.7B のエキスパートなどは RAM に載ります。
この対比は毎回、同じ upstream タグの無改造版を別に建てて取り直しています。3 版とも同じでした。
| upstream | パッチ版の wired | 無改造の wired | 差 |
|---|---|---|---|
| b11050 | 75.2 GiB | 102.1 GiB | 26.9 GiB |
| b11056 | 75.5 GiB | 102.3 GiB | 26.8 GiB |
| b11065 | 75.2 GiB | 101.2 GiB | 26.0 GiB |
温度 0 の出力も、3 版とも無改造版と 4/4 一致しました。パッチが変えるのは置き場所だけで、
生成結果は変えていません。
次に DwarfStar 側、./ds4 --inspect の出力です。
ds4: Qwen BF16 n-grams: 95.37 GiB, disk reads only
model: Qwen3.8-Flash-Next Q40Routed (converted fast-pack)
arch: qwen4exp
gguf: v3, 58 metadata keys, 1256 tensors
file size: 165.11 GiB
logical parameters: 179.55 B
tensor types:
f32 472 tensors, 0.28 GiB
f16 298 tensors, 1.23 GiB
q8_0 337 tensors, 3.63 GiB
q4_k 98 tensors, 43.07 GiB
bf16 2 tensors, 96.55 GiB
mxfp4 49 tensors, 20.34 GiB
bf16 のテンソルが 2 本で 96.55 GiB あり、そのうち 95.37 GiB が N-gram テーブルです(1 行目の Qwen BF16 n-grams: 95.37 GiB がその内訳)。起動時に「disk reads only」と明示され、メモリ収支には一切現れません。
ここで 2 つのファイルのパラメータ総数が違うことに気づきます。unsloth 側は 176,943,899,520、
ds4 側は 179,551,050,368(logical parameters: 179.55 B)です。テンソル名まで数えると、
差の 2,607,150,848 は blk.48.* の 32 本ぴったりでした。
| 層 | パラメータ | MTP ヘッド | |
|---|---|---|---|
| unsloth UD-IQ4_XS |
blk.0〜blk.47(48 層) |
176,943,899,520 | 無し |
| ds4 qwen38-q4k |
blk.0〜blk.48(49 層) |
179,551,050,368 | blk.48.nextn.* |
49 層目が MTP(NextN)ヘッドで、blk.48.nextn.eh_proj / enorm / hnorm /
hc_head_norm / hc_head_down / hc_head_up が入っています。後で出てくる
「llama.cpp 側で MTP が使えない」は、エンジンの制限ではなく、こちらの GGUF に
ヘッドが入っていないという話です。同じモデルと呼んでいますが、配布物としては同じではありません。
同じテーブルに対する答えが、片方は「4 ビット級に潰して 26.8 GiB にし、パッチでディスクに残す」、もう片方は「16 ビットのまま 95.37 GiB をディスクに置いて行だけ読む」です。並べるとこうなります。
| llama.cpp(パッチ版) | DwarfStar | |
|---|---|---|
| 精度 | iq4_nl(4 ビット級) | bf16(16 ビット) |
| サイズ | 26.8 GiB | 95.37 GiB |
| 置き場所 | ディスク | ディスク |
| 読み方 | mmap して lazy read | GGUF から行を直読み(mmap しない) |
| 必要なもの | 非公式パッチ(上流未マージ) | 既定の動作 |
公式ドキュメントにはこう書かれています。
The 95.37 GiB n-gram table stays on disk. The runtime reads selected rows directly from the GGUF, without mapping or preloading the table.
さらにこの一文があります。量子化で失われた精度は後から戻せない、という趣旨です。
Expanding an old quantized table to BF16 does not restore its lost precision.
前提が 2 日で変わっていた
作業を始める前に、ds4 に Qwen 対応があるかを確認しました。ここで前提が 1 つ崩れました。
手元にあった 09-12 時点のクローン(bd66c40)には、Qwen の文字列がソースにもドキュメントにも download_model.sh にも 1 件もありませんでした。当時の理解は「DwarfStar は DeepSeek 専用エンジンで Qwen は動かない」で、実際そのとおりでした。
ところが GitHub の PR を確認すると、状況が変わっていました。
| PR | 内容 | 状態 |
|---|---|---|
| #991 | Qwen3.8 flash next | マージ済み(2026-09-14 更新) |
| #990 | Add an experimental Qwen 3.8 Flash Next port on Metal | オープン(別実装の競合 PR) |
origin/main を取り直すと 17 コミット先の 9139e2a になり、そのうち 10 本以上が Qwen 関連でした。フォークも PR ブランチも不要で、main をそのまま使えます。
9139e2a Document and download self-contained Qwen BF16 n-gram releases
97ba327 Read Qwen native BF16 n-grams directly from the GGUF
e68bcc8 Package Qwen with original BF16 n-grams
923b819 Harden Qwen checkpoints, speculative decode and tool calls
bab2029 Keep the Qwen Metal graph out of CUDA and ROCm builds
このリポジトリは速く動きます。2 日前の調査結果はもう古い、という前提で毎回確認する必要があります。
セットアップ
| 項目 | 値 |
|---|---|
| ハードウェア | Apple M5 Max(MacBook Pro)、ユニファイドメモリ 128 GB、内蔵 SSD 2 TB、macOS 26.6.2(25G83) |
| ds4 | コミット 9139e2a(2026-09-14 13:46:19 +0200) |
| ビルド |
make -j18 で 9.03 秒、警告 0、エラー 0 |
| モデル |
./download_model.sh qwen38-q4k で 165.11 GiB。30.9 分、実測平均 96 MB/s |
| llama.cpp | ブランチ ple-metal-split(b10931 + 分割パッチ b0fa106c0)、unsloth UD-IQ4_XS 87.24 GiB |
ds4 側の起動は 1 行です。N-gram のサイドカーも専用フラグも要りません。
./ds4 -m gguf/Qwen3.8-Flash-Next-Q4.gguf -p "..." --temp 0
起動時のメモリ計画はこうなりました。ctx 32768 での値です。
ds4: Metal model views created in 1.266 ms, residency requested in 9821.309 ms
(mapped 71400.44 MiB from offset 10.51 MiB)
ds4: memory: KV 1.04 GiB (raw 0.81 + compressed 0.23) + buffers 7.41 GiB
+ resident model 69.73 GiB = 78.18 GiB planned
resident model 69.73 GiB は、ファイルサイズ 165.11 GiB から N-gram の 95.37 GiB を引いた 69.74 GiB とほぼ一致します(差は表示の丸め)。ロードは 9.8 秒でした。
測定条件
公平性のために揃えたものと、揃えられなかったものを分けて書きます。
揃えたもの。同一マシン、同一時間帯、他のサーバーは停止して Metal を排他。prefill は 2,048 トークン単位、生成は各深さで 128 トークン、反復 2 回。深さは 2,048 から 65,536。
| 項目 | ds4-bench | llama-bench |
|---|---|---|
| prefill | フロンティア間 2,048 トークン | -p 2048 |
| 生成 | 各フロンティアで 128 トークン | -n 128 |
| 深さ | --ctx-start 2048 --ctx-max 65536 --step-incr 2048 |
-d 0,8192,16384,32768,49152,65536 |
| 反復 | 2 | -r 2 |
| flash attention | 既定 |
-fa 1(常駐と同じ設定) |
揃えられなかったもの。エキスパートの量子化です。ds4 側は Q4_K(gate/up)と MXFP4(down)、llama.cpp 側は IQ4_XS です。同じ「4 ビット級」ではありますが同一ではありません。したがって以下の速度差は、N-gram の持ち方(精度と読み方)とエキスパート量子化の差の合計です。分離するには llama.cpp 側を UD-Q4_K_XL で測り直す必要があり、今回はやっていません。
結果 ― 生成
| 深さ | llama.cpp UD-IQ4_XS | DwarfStar qwen38-q4k | 差 |
|---|---|---|---|
| 0 / 2,048 | 41.28 | 46.17 | +12% |
| 8,192 | 38.41 | 46.97 | +22% |
| 16,384 | 36.43 | 46.81 | +28% |
| 32,768 | 26.02 | 47.07 | +81% |
| 49,152 | 25.09 | 42.67 | +70% |
| 65,536 | 23.20 | 41.91 | +81% |
図の緑は、記事を書いた後に取った実運用の集計です。ベンチを打ち切った 65,536 より先も落ちていないことが分かります。薄い破線が最初の 8 時間(後述の追記 1)、実線が 43 時間まで伸ばしたもの(追記 2)です。8 時間時点では最も深い帯が 52.04 t/s で最速に見えましたが、これは n=52 の中央値で、43 時間ぶんの n=293 で取り直すと 43.10 に落ち着きました。落ちない、という読みのほうは変わりません。
llama.cpp 側は 16,384 から 32,768 の間で 36.43 → 26.02 t/s と急落します。
DwarfStar 側は 2,048 の 46.17 t/s から 65,536 の 41.91 t/s まで、下げ幅が 9% です。深さに対してほぼ平坦でした。
結果 ― prefill
| 深さ | llama.cpp | DwarfStar | 差 |
|---|---|---|---|
| 0 / 2,048 | 697.5 | 1,197.3 | +72% |
| 16,384 | 560.8 | 1,070.4 | +91% |
| 32,768 | 323.0 | 1,079.6 | +234% |
| 65,536 | 293.8 | 920.5 | +213% |
prefill の差はさらに大きく、深さ 32,768 以降で 3 倍前後になります。DwarfStar 側は 65,536 でも 920 t/s を保ちました。
MTP ― さらに 32%
DwarfStar の GGUF には MTP が同梱されていて、--mtp だけで投機デコードが効きます。別ファイルは要りません。短文生成(200 トークン、temp 0)で 2 回ずつ測りました。
| 条件 | prefill | 生成 |
|---|---|---|
| MTP なし | 256.56 / 262.19 t/s | 53.19 / 53.46 t/s |
| MTP あり | 256.55 / 251.97 t/s | 68.89 / 71.95 t/s |
ds4: Qwen3.8 mtp: 89 verify cycles, 76 drafts accepted (85.4%)
生成が平均 53.3 → 70.4 t/s で +32%、採択率 85.4%。temp 0 なので 2 回とも同一の採択率でした。prefill は影響を受けません。
llama.cpp 側は投機デコードを使えていません。理由は上のとおりで、unsloth の
UD-IQ4_XS には MTP ヘッド(blk.48)が入っていないからです。外部の下書き GGUF も
公開されていないので、同梱・外部のどちらの経路も塞がっています。起動ログにも
no implementations specified for speculative decoding と出ます。したがって短文での
比較は 41.28 対 70.4 t/s、1.7 倍になります。
品質 ― 測り方を揃えるまでが本番
速度とメモリで勝っていても、品質が落ちていては意味がありません。PPL はこれまで測っていなかったので、ここで測りました。素材は wikitext-2 の test(wiki.test.raw)です。
最初の測定は失敗でした。両者の既定の測り方が違ったからです。
| 採点の仕方 | 採点したトークン | PPL | |
|---|---|---|---|
llama.cpp(llama-perplexity 既定) |
2,048 トークンの独立窓を 64 個。各窓の後半だけ採点 | 65,472 | 3.9179 ± 0.03316 |
DwarfStar(--perplexity-file) |
50,000 トークンを 1 本の連続文脈 | 50,000 | 2.700696 |
この 2 つを並べて「DwarfStar が 31% 良い」と書いたら誤りです。両方の実装を読むと、採点の仕方がここまで違います。
ds4_cli.c の run_perplexity_file は、セッションを 1 つ作って先頭 32 トークンだけ流し込み、そこから 1 トークンずつ「次の実トークンの logprob を取って、その実トークンを食わせる」を繰り返します。単一の連続シーケンスに対する teacher-forced NLL で、採点対象は 33 番目以降の全トークンです。
llama-perplexity は違います。入力を n_ctx ごとの独立窓に割り、さらに const int first = n_ctx/2 として各窓の後半しか採点しません(count += n_ctx - first - 1)。既定の 2,048 なら、1 窓あたり採点は 1,023 トークンで、各トークンが見られる文脈は 1,024〜2,047 トークンです。
深い文脈ほど次のトークンは当てやすいので、連続採点の PPL は系統的に低く出ます。差の大半は測り方です。
そこで llama.cpp 側を単一窓に揃えて測り直しました。
llama-perplexity -m <UD-IQ4_XS> -f wiki.test.raw -c 51200 --chunks 1 -ngl 999 -fa on
| 採点トークン数 | 各トークンが見た文脈 | PPL | |
|---|---|---|---|
| llama.cpp UD-IQ4_XS(単一窓 51,200) | 25,599(窓の後半のみ) | 25,600〜51,199 | 3.0356 ± 0.03728 |
| DwarfStar qwen38-q4k(単一窓 50,000) | 50,000 | 32〜50,031 | 2.700696 |
揃えた上で、DwarfStar が 11.0% 低い(良い)という結果になりました。
⚠ 揃ったのは「1 本の長い文脈で採点する」ところまでで、採点する範囲はまだ違います。
llama.cpp は窓の後半しか採点しないので、採点対象は全部が深いトークンです。DwarfStar は
32 トークン目から採点を始めるので、文脈がほとんど無い浅いトークンも混ざります。つまり
条件は DwarfStar に不利な側へ残っていて、それでも 11.0% 低いという読み方になります。
この差をどう読むかには注意が要ります。比較しているのは「N-gram を BF16 でディスクに置き、エキスパートを Q4_K/MXFP4 にしたパッケージ」と「N-gram を 3〜4 ビットに潰し、エキスパートを IQ4_XS にしたパッケージ」です。N-gram の精度だけを切り出した比較ではありません。KLD と top-1 一致による分解には、同一トークン列に対する logits を両実装から出して突き合わせる必要があり、今回はそこまでやっていません。
なお llama.cpp 側の既定条件での 3.9179 は、以前に測った同じ UD-IQ4_XS の 3.9143 とほぼ一致しました。ビルドが b10701 から b10931 に変わっても再現しています。測定系そのものは信用してよさそうです。
所要時間には大きな差が出ました。llama.cpp の単一窓が 1 分 46 秒だったのに対し、DwarfStar は 21 分 0 秒です。連続採点は 1 トークンずつ decode するため、prefill でまとめて処理できる llama.cpp 側より原理的に遅くなります。品質測定の目的には影響しませんが、条件を振って何度も測る用途には向きません。
実運用データの窓 ― 追記 1〜5 の対応
ここから先は実運用のログを集計した追記が続きます。窓が 6 つあるので、先に対応を示します。
| 期間 | 長さ | ディスク KV の設定 | 主に見たもの | |
|---|---|---|---|---|
| 追記 1 | 09-16 22:07 → 09-17 06:06 | 8.0 時間 | 保存は有効、cold は無効 |
深さ耐性、時間加重平均 26.18 t/s |
| 追記 2 | 09-16 22:10 → 09-18 17:17 | 43.1 時間 | 同上 | 復元 0 件、フル prefill 7.8 件/時 |
| 追記 3 | 合成シナリオの A/B | ― |
cold を 0 と 262144 で振る |
復元 0 件 → 3/3、処理トークン −83.6% |
| 追記 4 | 09-18 18:42 → 09-20 17:28 | 46.8 時間 | 保存も cold も有効 |
復元 11 件、省いた prefill 20.7% |
| 追記 5 | 09-19 12:48 → 09-20 17:28 | 28.7 時間 | 同上 | OpenCode v2 へ移行後の安定性 |
| (追従後) | 09-20 17:28 → 09-21 15:01 | 21.6 時間 | 同上 | ds4 を 25 コミット追従させた後のデータ |
追記 1 の窓は追記 2 の窓に含まれます。追記 4 は設定を変えた後の別の窓で、
長さを追記 2 に揃えるまで 6 回に分けて伸ばし、集計は 7 つの小窓に割ってあります。追記 5 はその追記 4 の窓の内側で、
クライアントを OpenCode v2 に替えてからの部分にあたります。
最後の 1 行だけは ds4 の実装が違うので、上の 5 つとは別扱いです。
クライアントも途中で変わっています。追記 1 は OpenCode の v1 ベータ、
追記 2 の 43 時間は Claude Code を 3 プロジェクトで並行させた時間帯を含み、
追記 5 は OpenCode v2 です。フル prefill の頻度が窓ごとに動くのは、主にここの違いです。
用語を 2 つだけ先に決めておきます。
| どこにある | いつ消える | この記事での呼び方 | |
|---|---|---|---|
| スロット上の KV | RAM(Metal バッファ) | 別の会話に使われたら | ライブ KV |
.kv ファイル |
ディスク(--kv-disk-dir) |
容量の上限で押し出されたら | ディスク KV |
ライブ KV が外れたときの保険がディスク KV です。ライブ KV は最初から効いていて、
効いていなかったのはディスク KV の方でした。
追記 1: 8 時間の実運用 ― 深さで落ちませんでした
記事を書いた後、常用を DwarfStar に移して 8 時間動かしました。2026-09-16 22:07 から 09-17 06:06 まで、
opencode2 のバックエンドとして、いつもどおり自作 SystemVerilog シミュレータの作業です。
完了リクエスト 357 件、生成 156,293 トークン、エラー 0 件(finish は tool_calls 344 と stop 13 のみ)。
llama.cpp 版を同じやり方で集計したときは、ベンチの 36.7 t/s が実運用の時間加重平均で
9.9 t/s まで落ちていました。深さが速度を決めていたからです。今回はそうなりませんでした。
| 深さ帯 | 件数 | 生成トークン | decode 中央値 | 範囲 |
|---|---|---|---|---|
| 0k–50k | 20 | 10,015 | 46.57 t/s | 33.2–61.2 |
| 50k–100k | 96 | 50,452 | 35.04 t/s | 9.9–55.0 |
| 100k–150k | 88 | 36,929 | 38.56 t/s | 16.2–59.6 |
| 150k–200k | 81 | 29,240 | 44.68 t/s | 31.3–58.3 |
| 200k–250k | 52 | 24,007 | 52.04 t/s | 28.4–60.9 |
KV 再利用のある差分リクエスト(337 件)だけを取った値です。ほぼ純粋な decode 時間になります。
深さ 200k–250k が最速でした。llama.cpp 版は 60–80k で 14.5 t/s、240–260k で 7.0 t/s と
単調に落ちていたので、対照的です。ベンチを 65,536 で打ち切っていたので「その先は落ちるかもしれない」と
書きましたが、実運用のデータでは落ちませんでした。
| llama.cpp UD-Q4_K_XL(以前の集計) | 今回(DwarfStar qwen38-q4k) | |
|---|---|---|
| ベンチ(warm 短文) | 36.7 t/s | 41.9〜70.4 t/s |
| 実運用の時間加重平均 | 9.9 t/s | 26.18 t/s |
| ベンチに対する比 | 27% | 63% |
| 深さ 200k 超の decode | 7.0 t/s | 52.04 t/s |
実運用で 2.6 倍です。ベンチとの乖離も 27% から 63% に縮まりました。
この節の「深さ 200k–250k が最速」は、後の追記 2 で取り下げました。52 件の中央値では
決められていませんでした。深さで decode が落ちない、という主張のほうは 5 倍のデータでも
保っています。
体感を決めていたのは decode ではありませんでした
全体の時間加重平均 26.18 t/s と、差分リクエストだけの 39.57 t/s には差があります。この差の正体は
はっきりしていて、キャッシュが外れたときの再 prefill です。
| 件数 | 生成トークン | 所要 | 見かけの t/s | |
|---|---|---|---|---|
| 差分(KV 再利用あり) | 337 | 150,643 | 63.5 分 | 39.57 |
フル prefill(ctx=0 から) |
20 | 5,650 | 36.0 分 | 2.61 |
2 つの行を足すと 357 件で、完了リクエストの数と一致します。20 件のフル prefill が 36.0 分を
使っていて、これは処理時間の 36% です。生成は 5,650 トークンしか出ていません。深さの中央値は 126,811 トークン、所要の中央値は 122 秒、最長は深さ 238,007 の 293 秒でした。
prefill 自体は速いのです。供給トークンを実時間で割った実効レートは中央値 894 t/s、深さ 200K 超の
3 件でも 1,045 t/s 出ています。集計中にも深さ 238,007 のフル prefill が走っていて、180K を通過する
時点で 1,066 t/s、平均 1,269 t/s でした。
つまり問題は prefill の速度ではなく、フル prefill が起きる回数です。1 回で 2 分から 5 分を持って
いかれるので、20 回で 36 分になります。ここを減らす工夫は decode の最適化より効きます。
llama.cpp では深さそのものが decode を殺していました。DwarfStar では decode が深さに耐えるので、
残ったボトルネックがキャッシュミスに移っています。問題が 1 段進んだ形です。
追記 2: 43 時間まで伸ばしたら、速度の話ではなくなりました
上の 8 時間にさらに 35 時間を足して、同じ構成で 43.1 時間になりました。2026-09-16 22:10 から
09-18 17:17 までです。途中でモデルも設定も変えていません(起動ログの resident model 69.73 GiB と
KV 8.33 GiB は全期間で同一でした)。完了リクエストは 357 件から 1,863 件、生成は 156,293 から
1,016,965 トークンになりました。エラーは引き続き 0 件です。
結論から書くと、深さ耐性という主張は 5 倍のデータでも保ちました。ただし 8 時間時点で書いた
「最も深い帯が最速」は取り下げます。サンプルが足りていませんでした。
| 深さ帯 | 8 時間(件数 / 中央値) | その後の 35 時間 | 43 時間 全体 |
|---|---|---|---|
| 0k–50k | 20 件 / 46.57 t/s | 73 件 / 36.89 t/s | 93 件 / 38.90 t/s |
| 50k–100k | 96 件 / 35.04 t/s | 223 件 / 38.08 t/s | 319 件 / 36.87 t/s |
| 100k–150k | 88 件 / 38.56 t/s | 318 件 / 43.78 t/s | 406 件 / 43.45 t/s |
| 150k–200k | 81 件 / 44.68 t/s | 340 件 / 43.67 t/s | 421 件 / 43.92 t/s |
| 200k–250k | 52 件 / 52.04 t/s | 241 件 / 42.29 t/s | 293 件 / 43.10 t/s |
真ん中の列と右の列は別物です。前者は 8 時間より後だけ、後者は 8 時間を含む全期間です。
200k–250k の 52.04 t/s は 52 件の中央値でした。43 時間ぶん(293 件)で取り直すと
43.10 t/s に落ち着きます。一方で 100k 以降の 3 帯が 43.1〜43.9 t/s で揃い、
0–50k の 38.90 t/s より速いという関係は変わりません。
深さで decode が落ちない、という主張はそのままです。最速の帯がどこかという細部だけが、
52 件では決められていませんでした。
変わったのは全体の平均です。時間加重平均は 26.18 t/s から 19.26 t/s に下がりました。
decode が遅くなったからではありません。差分リクエストだけの見かけの速度は、むしろ
39.57 から 41.05 t/s へ上がっています。下がった原因はフル prefill の回数です。
| 8 時間 | 43 時間 | |
|---|---|---|
| 差分(KV 再利用あり) | 337 件 / 63.5 分 | 1,532 件 / 336.3 分 |
| フル prefill | 19 件 / 36.0 分 | 328 件 / 543.4 分 |
| フル prefill の頻度 | 1 時間あたり 2.4 件 | 1 時間あたり 7.6 件 |
この表の件数は live kv cache miss の行で数えています。追記 4 では ctx=0 から始まった
リクエストで数えていて、同じ 43 時間でも 338 件・7.8 件/時になります。2 つの定義は
数トークンの微小なリクエストの扱いで少しずれます。どちらで数えたかを明示せずに
並べると比較が壊れるので、追記 4 の比較は後者の定義で揃えてあります。
フル prefill が 543 分、つまり 9 時間を使っています。差分と合わせた処理時間 879 分のうちの 62% で、
8 時間時点の 36% から倍近くに増えました。回数でいえば 19 回が 328 回、17 倍です。
表の 8 時間の列は、今回の集計スクリプトで測り直した値です。追記 1 では同じフル prefill を
「39.3 分」と書きましたが、あちらはリクエスト間の待ち時間も含めて測っていました。
ここから先は finish までの所要だけを見ています(36.0 分)。
件数も 1 件ずれます。ctx=0 で数えると 20 件、live kv cache miss で数えると 19 件です。
差は 09-16 22:10:39 ctx=0..761 gen=184 finish=stop 4.243s の 1 件で、ライブ KV がまだ
無い状態から始まったため live kv cache miss の行が出ていません。生成トークンの
5,650 と 5,466 の差 184 も、ちょうどこの 1 件です。所要は 4.2 秒なので、どちらで数えても
合計は 36.0 分のままです。
ディスク KV は 43 時間で一度も復元していませんでした
前節で「ここを減らす工夫は decode の最適化より効く」と書いた場所です。その保険になるはずの
ディスク KV を調べたら、動いていませんでした。
| 項目 | 実測 |
|---|---|
上限(--kv-disk-space-mb) |
131072 = 128 GiB |
| 実使用 | 127.8 GiB / 50 ファイル(上限に張り付き) |
| 1 エントリのサイズ | 中央値 2.57 GiB、最大 5.70 GiB |
| 43 時間の保存 | 324 件 |
| 43 時間の追い出し | 326 件(理由はすべて disk-cache-full) |
| 43 時間の復元 | 0 件 |
復元を示す kv cache hit text が、43 時間のログに 1 行もありません。追い出された分だけでなく、
残っていた 50 件も含めて一度も読まれていないということです。ソースを見ると復元は
!multimodal && cached == 0 で試みられます。今回のミスは 335 件、画像つきは 6 件だけなので、
ほぼ毎回試みた上で一致しなかったことになります。ミスの理由は全件 token-mismatch で、
共通接頭辞は中央値 9,136 トークン、4 割は 200 トークン未満でした。
ここで原因を 2 回続けて読み違えます。1 回目は容量(枠を広げれば効くはず)、2 回目は保存の形
(追い出し時の保存は「プロンプト + 生成」なので接頭辞にならず、原理的に一致しない)。
容量説は 8 月にも同じ誤診をしていて、予算を 32 GB から 128 GiB へ 4 倍にした後の今回も
結果は同じでした。保存の形の説も、追記 3 のとおり誤りです。正しい原因はそちらに書きます。
そこで常駐ではディスク KV を切りました(--kv-disk-dir ごと渡さない)。スナップショットを消すと
ディスクの空きは 198 GiB から 323 GiB になりました。124.9 GiB ぶんです。失ったものはありません。
復元は元から 0 件だったからです。
誤解のないように書いておくと、ライブ KV、つまりメモリ上のスロットの再利用はよく効いています。
43 時間のうち 1,532 件がそれで継続できていました。働いていなかったのはディスクへの退避だけです。
追記 3: 効く経路を自分で止めていました
前節の「保存が生成を含むから原理的に一致しない」は誤りでした。効かないのではなく、
効く経路を私が止めていたのです。保存経路は 1 つではなく、全部で 4 つあり、
前節で見ていたのはそのうち 3 つでした。
| 保存の種類 | 書く内容 | 復元 |
|---|---|---|
evict(追い出し時) |
プロンプト + 生成トークン | できない |
shutdown(終了時) |
同上 | できない |
continued(一定間隔) |
prefill 中なら接頭辞、decode 中は生成を含む | 前者だけできる |
cold(prefill の最中) |
プロンプトの接頭辞だけ | できる |
cold は生成を含みません。プロンプトを途中で切った接頭辞をそのまま書くので、
復元側が要求する条件をはじめから満たしています。continued も prefill 中に走った分は
同じ性質を持ちますが、こちらは別の理由で 2026-08-08 に切ってありました。
生成の途中に書かれた分が一度も当たらないまま 128 GiB を食い潰していたためです。
そして cold が発火する条件は、プロンプト長が --kv-cache-cold-max-tokens 以下であることです。
この値を、私は 0 にしていました。0 だと 1 件も書かれません。
接頭辞を書ける経路は 2 つあって、両方とも別々の理由で止まっていたことになります。
前節の 43 時間は、復元できるエントリが 1 件も作られない設定で測ったものでした。
「保存が生成を含むから一致しない」は evict と shutdown、それに生成の途中で書かれた
continued については正しく、それを全体に広げたのが誤りでした。
なぜ 0 にしていたのか
このフラグには以前、別の意味がありました。上限を超えるエントリはディスクからヒットした
瞬間に削除される、という使い捨ての挙動です。200k 級の会話を行き来する使い方では
「復元できるのは 1 回だけ」になってしまうので、それを避けるために 0 にしていました。
その実装は upstream が 2026-08-04 に削除しています。
24fa85e Keep disk KV checkpoints after successful loads
The cold-prompt save limit no longer deletes deeper cold or continued
checkpoints after their first restore. Disk-budget eviction remains
responsible for reclaiming files.
削除されたこと自体には気づいていて、手元のリポジトリの README にも書いていました。
問題はそこで「戻す積極的な理由が無いので 0 のままでよい」と判断したことです。
フラグの意味は「使い捨てのしきい値」から「接頭辞を書き出す上限」へ反転していたので、
0 は中立ではなく無効化でした。前提が消えた設定は、放っておくと中立でなくなることがあります。
A/B で確かめました
3 つのエージェント(それぞれ約 12,000 文字の system プロンプトを持つ)に別々の質問を
2 巡させるだけの合成シナリオです。スロットは 2 個なので、2 巡目は必ずライブ KV を外して
ディスクを引きに行きます。
測定順の影響が大きいので、腕の順番を入れ替えて 2 パス取りました。
| ラン | 順序 | cold_max |
復元 | 2 巡目 prefill / 1 巡目 prefill |
|---|---|---|---|---|
| cold 無効 | 1 番目 | 0 | 0 件 | 0.904 |
| cold 有効 | 2 番目 | 262144 | 3 件 | 0.187 |
| cold 有効 | 1 番目 | 262144 | 3 件 | 0.186 |
| cold 無効 | 2 番目 | 0 | 0 件 | 1.016 |
左が上の表、右は追記 4 の実運用です。順序を入れ替えても、cold を有効にした側は
0.187 と 0.186 で一致しました。2 巡目に処理したトークンは 19,609 から 3,225 へ、
83.6% 減っています。出力は temperature 0 で 4 ラン × 6 本すべて完全一致でした。
復元は生成を変えません。
2 パス取ったのは正解でした。同じ仕事をする 1 巡目の prefill が、4 つのランを通して
38.27 → 27.87 → 25.17 → 23.25 秒と単調に速くなっています。95.37 GiB の N-gram テーブルを
ディスクから読む構成なので、page cache が暖まるほど速くなるためです。腕をまたいだ実時間の
比較は成り立ちません。だから各ランの中で 1 巡目と 2 巡目を比べています。
ログには復元がこう出ます。
kv cache hit text tokens=6144 text=20686 quant=4 key=token-text load=29.2 ms file=...
chat ctx=6144..6657:513 prompt start
chat ctx=6144..6657:513 prefill chunk 513/513 (100.0%) chunk=0.00 t/s avg=523.23 t/s 0.980s
ctx=0.. ではなく ctx=6144.. から始まっているのが、省けた分です。
作り置きすると、どの経路が書いたかで被覆が変わる
「max_tokens=0 で prefill だけ流す」作り置きも測りました。4 つの経路は、書く中身だけでなく
書く長さも違います。cold と continued は prefill の最中に走って 2048 の倍数へ切り下げ、
evict と shutdown はリクエストが終わった後に全長を書きます。
切り下げは「プロンプト長から 32 を引いて 2048 の倍数へ」で、実測 3 点が一致しました。
| プロンプト | 保存 | 切り捨て | 被覆 |
|---|---|---|---|
| 11,732 | 10,240 | 1,492 | 87.3% |
| 6,655 | 6,144 | 511 | 92.3% |
| 5,822 | 4,096 | 1,726 | 70.4% |
被覆が 100% にならないのはアラインメントであって不具合ではありません。2048 の境界を
少し超えた直後が最も低く(5,822 の行)、次の境界の直前が最も高くなります(6,655 の行)。
切り捨てられるのは最大 2,079 トークン(2047 + 32)なので、プロンプトが長いほど割合は小さくなります。
落とし穴は持ち出すときです。作り置き直後に .kv をコピーすると、手に入るのは切り詰めた方だけ。
全長を書く evict と shutdown はリクエストが終わった後にしか走りません。全長が欲しいなら
作り置きの後にサーバーを正常終了させます(SIGTERM で全スロットが書き出される。実測 56〜61 ms)。
消費のしかたでも変わります。会話を続ける(user → assistant → user)と接頭辞が保たれるので
全長エントリが当たって 100%。質問文そのものを書き換えると user ターンの内側で分岐するので、
全長エントリは使えず、手前で切られた cold だけが残って 87.3% になります。
cold を切っていると、この使い方では復元が 0% です。
実運用で効くのは前者です。会話は末尾に伸びていくので、以前のプロンプトは常に新しい
プロンプトの接頭辞になります。
まだ分かっていないこと
このシナリオは、接頭辞が一致することを設計上保証しています。実際の Claude Code は会話の
途中に注意書きを差し込みますし、compaction で履歴を書き換えます。接頭辞がどのくらいの頻度で
壊れるかは分かりません。常駐では --kv-disk-dir と --kv-cache-cold-max-tokens 262144 を
対で戻したので、40 時間ほど溜めてから復元件数を数える予定です。0 件のままなら、また切ります。
(この宿題には追記 4 で最初のデータが出ました。)
代償もあります。ミスのたびに evict と cold の両方を書くので、保存回数が 1.5 倍に
なりました。evict のほうは原理的に復元されないので、ここは無駄が残っています。止める
フラグは無いので、減らすならソースに手を入れることになります。
もう 1 つ。上で使った切り下げの計算は、本来の第 1 候補ではありません。ds4 はまず
会話の区切りで切ろうとしますが、この構成のモデル(qwen4exp)には区切りを示す専用トークンが
無いので(ChatML なので <|im_start|>user という文字列で表現されます)、常に代替の
切り下げに落ちます。専用トークンを持つモデルでは保存される位置が変わるはずで、そちらは
測っていません。
追記 4: 実運用でも復元しました(46.8 時間)
設定を戻してから 46.8 時間ぶんです(2026-09-18 18:42:05 から 09-20 17:28:19 まで)。
終端は恣意的に決めたものではありません。この時刻に ds4 を 25 コミット追従させ、
スロット選択と KV 退避の実装が変わったので、そこで窓を閉じています。
追記 2 のベースラインが 43.1 時間なので、長さも揃いました。
| 指標 | 43.1 時間(cold を切っていた頃) | 46.8 時間(cold 有効) |
|---|---|---|
復元(kv cache hit text) |
0 件 | 11 件 |
cold 保存 |
0 件 | 57 件 |
evict / shutdown 保存 |
324 件 | 71 件 |
| 完了リクエスト | 1,863 件 | 2,500 件 |
うち継続(ctx=0 から始まらなかった) |
1,533 件(82.3%) | 2,439 件(97.6%) |
| フル prefill | 1 時間あたり 7.8 件 | 1 時間あたり 1.4 件 |
長さは揃いましたが、使い方は揃っていません。継続率が 82.3% 対 97.6% で、
ベースライン側は 3 プロジェクトで 9 セッションを並行させていた時間帯を含みます。
そこは後述します。
復元 11 件の中身
件数より、1 件ごとにどれだけ省けたかを見た方が実態に合います。
| 時刻 | 復元 | プロンプト | 被覆率 | 残り prefill |
|---|---|---|---|---|
| 09-19 00:14:30 | 237,568 | 238,689 | 99.5% | 1,121 |
| 09-19 00:14:39 | 237,568 | 240,052 | 99.0% | 2,484 |
| 09-20 04:53:11 | 184,320 | 186,455 | 98.9% | 2,135 |
| 09-20 05:26:32 | 51,200 | 141,446 | 36.2% | 90,246 |
| 09-20 05:32:51 | 51,200 | 152,487 | 33.6% | 101,287 |
| 09-20 06:42:04 | 51,200 | 201,544 | 25.4% | 150,344 |
| 09-20 09:31:34 | 182,272 | 183,232 | 99.5% | 960 |
| 09-20 09:37:35 | 53,248 | 68,464 | 77.8% | 15,216 |
| 09-20 10:56:36 | 53,248 | 143,220 | 37.2% | 89,972 |
| 09-20 12:02:36 | 90,112 | 203,136 | 44.4% | 113,024 |
| 09-20 15:17:22 | 157,696 | 159,230 | 99.0% | 1,534 |
合計 1,349,632 トークンを prefill せずに済ませています。被覆率の中央値は 77.8%。
分布は二山です。5 件は 98.9% 以上で残り prefill が 960〜2,484 トークンしかなく、
残る 6 件は 25〜78% で 1.5〜15 万トークンを結局 prefill しています。「復元が起きた」だけでは、
どちらの話か分かりません。高い方の実物です。
09:31:34 kv cache hit text tokens=182272 text=636142 quant=4 key=token-text load=1049.9 ms
09:31:34 chat ctx=182272..183232:960 TOOLS prompt start
182,272 トークンを約 1 秒で復元し、prefill は 960 トークンだけ。フル prefill なら
実効 500〜900 t/s で 3 分半から 6 分かかる分量です。
読み取れることが 3 つあります。1 つめは時間差で、6 時間 54 分前(21:59:23)に書いた
tokens=184320 の cold が 04:53:11 に使われています。ライブ KV はとっくに無い間隔で、
ディスク KV が狙っているのはこの場面です。
2 つめ。同じファイルが 3 回使われました。upstream の 24fa85e(ヒット後もチェックポイントを
残す変更)がそのまま効いています。以前の版は読み込み時に消していました。
3 つめ。ただし使い回すほど被覆率は落ちます。その 3 回は 36.2% → 33.6% → 25.4% で、
会話が伸びたぶんだけ下がりました。残し続けることが常に得とは限らず、3 回目は
より長い接頭辞を書き直した方が効いた場面です。
保存の側は設計どおりでした。tokens=51200 trimmed=1519 はプロンプト長から 32 を引いて
2048 の倍数へ切り下げた値そのもので、合成シナリオの挙動が実運用でも出ています。
変わったのは量ではなく中身で、43 時間のときは保存 324 件が全部「生成込み」だったのに対し、
いまは 128 件のうち 57 件が cold です(追記 3 の図の右側)。
窓を 7 つに割って数えました
伸ばすたびに何が動いたかを並べます。横に足すと 46.8 時間ぶんになります。
| 指標 | 16.8h | +5.5 | +4.5 | +8.5 | +3.0 | +5.3 | +3.2 | 計 |
|---|---|---|---|---|---|---|---|---|
| 復元 | 2 件 | 0 件 | 0 件 | 3 件 | 1 件 | 4 件 | 1 件 | 11 件 |
cold 保存 |
19 件 | 18 件 | 5 件 | 3 件 | 2 件 | 5 件 | 5 件 | 57 件 |
| フル prefill(件/時) | 1.5 | 3.5 | 1.1 | 0.4 | 0.7 | 0.9 | 1.6 | 1.4 |
効果の大きさは、まだ 1 つの形でしか言えません
期間は 43.1 対 46.8 時間で、フル prefill は 7.8 対 1.4 件/時。それでもこの差を
ディスク KV の効果として読むことはできません。継続率が 82.3% 対 97.6% で、
ベースライン側は 3 プロジェクトで 9 セッションを並行させていた時間帯を含みます。
揃っていないのは長さではなく使い方の方でした。窓を 7 つに割ると 1.5 → 3.5 → 1.1 →
0.4 → 0.7 → 0.9 → 1.6 件/時と動きます。設定は何も変えていないので、動いたのは使い方の側です。
同じ設定で窓の切り方だけで 9 倍近く動く以上、7.8 件/時との差は帰属させられません。
言える形が 1 つあります。復元で省けた prefill は 1,349,632 トークンで、これは同じ窓の
フル prefill が実際に処理した 6,515,860 トークン(64 件、深さ中央値 93,257)の 20.7% です。
中央値の深さのフル prefill を 14.5 回ぶん省いた計算になります。ただしこれは
「フル prefill が減った」ではなく「起きた prefill のうちこれだけ戻した」という意味で、
前者を言うにはベースラインとの比較が要ります。
件数では測れません。被覆率が 25.4% から 99.5% まで開いていて、1 件あたりで省けた
prefill は 960 から 237,568 トークンまで 248 倍の幅があります。
確実なのはここまでです。cold チェックポイントは合成シナリオの外でも書かれ、時間をまたいで
復元され、同じものが 3 度使われ、被覆率は大きくばらつく。7.8 対 1.4 件/時という形で
効果を言うには、43 時間と同じ「複数セッションを行き来する」使い方でのデータが要ります。
追従後の 21.6 時間
窓を閉じた理由が ds4 の追従だったので、新しい実装でのデータも別に取りました。
2026-09-20 17:28:19 から 09-21 15:01:06 まで、21.6 時間ぶんです。
| 指標 | 追従前 46.8 時間 | 追従後 21.6 時間 |
|---|---|---|
| 復元 | 11 件(0.24 件/時) | 7 件(0.32 件/時) |
cold 保存 |
57 件(1.2 件/時) | 35 件(1.6 件/時) |
| 完了リクエスト | 2,500 件 | 1,772 件 |
| 継続率 | 97.6% | 97.9% |
| フル prefill | 1.4 件/時 | 1.7 件/時 |
⚠ 追従で変わったのは 4d7028e(reuse-aware slot routing via shared live-state probe)と
ada805a(staleness-aware eviction tiers in slot routing)で、まさにスロットの選び方と
KV の捨て方です。前後を同じ列に並べて比べてはいけません。
腰を据えて比べるなら、追従後だけで 43 時間ぶん溜める必要があります。
安定性は追記 5 と同じ定義で数えました。
| 指標 | 値 |
|---|---|
| 完了リクエスト | 1,772 件 |
finish の内訳 |
tool_calls 1,736 / stop 35 / length 1 |
| ログ中のエラー行 | 0 件 |
| 生成トークン | 1,163,539 |
| 時間加重平均 | 32.13 t/s |
| 深さの中央値 / 最大 | 166,139 / 224,928 |
| 深さ 200k 超の decode 中央値 | 40.08 t/s(375 件) |
エラーは 0 件で、98.0% がツール往復の完了です。追記 5 の 97.2% と同じ水準で、sukimasim の
デバッグを続けている側からは、実装が入れ替わったことは分かりませんでした。length の 1 件は
再起動直後に私が投げた確認用のリクエスト(ctx=0..62、出力上限 16 トークン)で、実運用の
打ち切りではありません。
最初の 4.2 時間で見えた差は、窓を伸ばしたら消えました
この節は最初 4.2 時間で書きました。そのときは深さ 200k 超の decode 中央値が 30.67 t/s
(72 件)で、追記 5 の 39.03 t/s(294 件)より低く、原因は切り分けられないと書きました。
窓を 5 倍にしたら 40.08 t/s(375 件)になり、差は消えました。
| 窓 | 深さ 200k 超 | decode 中央値 |
|---|---|---|
| 追記 5(28.7 時間) | 294 件 | 39.03 t/s |
| 追従後の最初の 4.2 時間 | 72 件 | 30.67 t/s |
| 追従後の 21.6 時間 | 375 件 | 40.08 t/s |
原因が追従なら、窓を伸ばしても戻りません。72 件で見えた低い値のほうが、窓の取り方
(その時間帯の使い方と、測定中に熱圧力が上がっていたこと)で動いた数字でした。
この記事で何度か書いている「窓を伸ばして同じ結論が出るかを見る」が、今度は自分の
書いた数字に当たった形です。数えた時点で n=72 と書いてはいましたが、
それでも 2 つの数字を並べた表は載せてしまっていました。
再起動を 3 回はさみました
09-21 の朝、llama.cpp の判定のために ds4-server を 3 回止めています(07:34 / 07:37 / 07:46)。
実装は変えていないので窓は割っていませんが、ディスク KV にとっては意図しない試験になりました。
再開後の最初のリクエストは、フル prefill ではなく復元で始まっています。
07:46:49 listening on http://127.0.0.1:8000
07:55:07 kv cache hit text tokens=110592 ... load=496.9 ms
07:55:07 chat ctx=110592..118882:8290 TOOLS prompt start
07:55:24 chat ctx=110592..118882:8290 gen=595 finish=tool_calls 16.643s
110,592 トークンが 497 ミリ秒で戻り、prefill したのは残りの 8,290 だけです。
プロンプトの 93% が戻ってきたことになります。
復元が無ければ 118,882 トークンを頭から処理することになり、実測レート(約 1,250 t/s)なら
95 秒前後かかります。プロセスをまたいで残ることがディスク KV の存在理由なので、
狙いどおりの動き方ではあります。ただし n=1 で、しかも私が止めた直後の 1 件です。
⚠ 復元 7 件のうち 2 件はこの再起動の後に起きています。数を増やしたのは私の操作です。
数えるときに 3 回間違えました
どれも「何を数えているか」の取り違えです。
1 つめ。復元が起きているのにモニタが 0 件と表示していました。集計をプロセスの寿命で
区切っていたためで、復元 2 件は再起動より前に起きていました。ディスク KV は再起動を
またいで残るのが存在意義なので、プロセス単位の窓は見たいものを構造的に隠します。
設定が変わったときだけ切り直す窓に直しました。
2 つめ。フル prefill を 1 時間あたり 0.2 件と書きましたが、正しくは 1.7 件でした
(当時の 12 時間窓での値)。正規表現を chat ctx=0\.\.\d+:\d+ prompt start と
連結で書いていて、実際の行に入る TOOLS フラグで落ちていました。エラーも警告も出ず
0 件になります。0 件は「起きていない」と「数えられていない」の区別がつきません。
3 つめ。「ライブ継続 N 件」はクライアントに依存する指標でした。数えていた
anthropic live continuation match は Anthropic 互換経路でしか出ないので、
OpenAI 互換の OpenCode から叩くと、KV を再利用していても継続に数えられません。
実際、窓を 26.8 時間へ伸ばしたとき、増えた 165 件のうちこの行が出たのは 6 件だけでした。
指標を「完了のうち ctx=0 から始まらなかったもの」に置き換えています。
ログ行を数えるときは、その行がどの経路でだけ出るのかまで確かめることです。
追記 5: OpenCode v2 と組み合わせて 29 時間、エラー 0
追記 1 のクライアントは OpenCode の v1 ベータ(opencode2)で、追記 2 の 43 時間には
3 プロジェクトで Claude Code を並行させた時間帯が混ざっていました。その後 OpenCode を
v2(@opencode/cli 2.0.10)へ全面移行したので、組み合わせが変わっています。
| サーバー | ds4-server(qwen38 = Qwen3.8-Flash-Next Q4、ctx 262144、スロット 2) |
| クライアント | OpenCode v2 |
| 作業 | 自作 SystemVerilog シミュレータ(sukimasim)のデバッグ |
移行してから 28.7 時間ぶんを数えました(2026-09-19 12:48 〜 09-20 17:28)。
| 指標 | 値 |
|---|---|
| 完了リクエスト | 1,781 件 |
finish の内訳 |
tool_calls 1,731 / stop 50 |
出力上限での打ち切り(length) |
0 件 |
| ログ中のエラー行 | 0 件 |
| 生成トークン | 1,097,827 |
| 時間加重平均 | 31.77 t/s |
| 深さの中央値 / 最大 | 138,962 / 233,507 |
| 深さ 200k 超 | 294 件(decode 中央値 39.03 t/s) |
97.2% が tool_calls で終わっています。エージェントが道具を呼び続ける使い方で、
1,731 回のツール往復が全部成立したということです。length での打ち切りが 0 件なのは、
出力上限 16,000 に当たった応答が 1 つも無かったという意味です。
深さの中央値が 138,962 トークンで、200k を超えたものも 294 件あります。それでも
decode の中央値は 39.03 t/s で、追記 2 で見た「深さで落ちない」がそのまま出ています。
時間加重平均の 31.77 t/s は、追記 2 の 19.26 t/s より高い値です。ただしこれは
ワークロードの差(フル prefill の少なさ)を含むので、速くなったとは読めません。
期間中に ds4-server は 2 回止まっていますが、どちらも私が止めたものです。llama.cpp の
追従で判定を通すのに flash-next を起動する必要があり、Metal は排他なので 3 分ほど
明け渡しました。落ちたわけではありません。
⚠ この 1,781 件は 8000 番に来たすべてです。ds4-server のログにはクライアントの区別が
出ないので、OpenCode v2 のぶんだけを切り出した数ではありません。OpenCode 側のログでは、
この期間に最も触ったディレクトリが sukimasim でした。
ドキュメントとの食い違い 3 件
作業中に、公式ドキュメントの記述と実物が合わない箇所が 3 件ありました。
1 件目。docs/QWEN38_FLASH_NEXT.md にこうあります。
Use the same model options with
ds4-agent,ds4-server, ords4-bench. Add--mtpfor speculative decoding using the built-in MTP weights.
実際には ds4-bench は --mtp を受け付けず、ds4-bench: unknown option: --mtp で即座に終了します。--help にも該当項目がありません。MTP 有りの深さ特性を測るには ds4 本体を使う必要があります。
2 件目。別ディレクトリから起動するときに Metal シェーダの解決のため --chdir を付ける、という情報がありましたが、--help に --chdir はありません。リポジトリ直下からの実行が前提です。
3 件目。DS4_QWEN4_PLE_EVICT_TOKENS という環境変数でページ破棄を制御できる、という情報がありましたが、ソースにもドキュメントにも存在しません。N-gram は最初からディスク直読みなので、常駐量を抑える概念自体がありません。実在する DS4_QWEN4_* は、測定に使ったコミット 9139e2a のソースに現れる名前で 63 種類(getenv で実際に読んでいるものは 33 種類)あり、その中に該当するものはありませんでした。
弱い所
品質は測りましたが、エキスパート量子化の差を含む「パッケージ同士」の比較です。N-gram の精度だけを切り出した比較ではありません。また ds4 側の PPL には誤差幅が出ません(単一値の出力)。窓を 1 本に揃えた後も、採点する範囲は揃っていません。llama.cpp は窓の後半 25,599 トークンだけを採点し、DwarfStar は 32 トークン目から 50,000 トークンを採点します。浅い文脈のトークンを含むぶん DwarfStar 側に不利な残り方なので、11.0% という差は控えめな値です。
比較に交絡があります。置き場所は両者ともディスクで同じなので、速度差を「RAM かディスクか」に帰属させることはできません。残る候補は 3 つです。N-gram の精度そのもの(bf16 対 iq4_nl)、読み方(行の直読み対 mmap + lazy read)、そしてエキスパートの量子化差(Q4_K/MXFP4 対 IQ4_XS)。このうちエキスパート差を消すには llama.cpp 側の UD-Q4_K_XL との比較が要ります。
反復が 2 回で、条件間の冷却を入れていません。プロンプト素材も 1 種類です。ばらつきは幅で示すに留めます。
DwarfStar 側の Qwen 対応は、マージから 2 日の時点で測ったものです。長時間運用は追記 1 と追記 2 で 43 時間ぶん埋めましたが、それでも 2 日分でしかありません。数週間動かしたときの安定性は分かりません。
43 時間の実運用データには、3 つのプロジェクトで Claude Code を並行させた時間帯が含まれます。セッションを跨ぐたびにライブ KV が外れるので、フル prefill が 1 時間に 7.8 回という頻度はこの使い方に固有です。とくに後半 35 時間だけを取ると 9.0 回/時まで上がります。1 セッションだけを深く使えば、もっと少なくなります。
追記 3 の A/B は合成シナリオです。同じ system プロンプトに別の質問を投げるので、接頭辞が一致することを設計上保証しています。追記 4 で実運用でも復元することは確認しましたが、フル prefill 減少(7.8 → 1.4 件/時)をディスク KV に帰属させることはできていません。期間は 43.1 対 46.8 時間で、継続率が 82.3% 対 97.6% と使い方が違います。同じ 46.8 時間を 7 つに割ると 0.4〜3.5 件/時で、窓の切り方だけで 9 倍近く動きます。言えているのは「起きた prefill のうち 20.7% ぶんをディスクから戻した」ところまでです。
教訓
巨大な補助テーブルは、量子化してから置くより、精度を保ったまま置く方が速いことがある。直感に反しますが、測ると差は深さとともに開きました。どちらもディスクから読んでいるので、差が出た理由を「RAM かディスクか」には帰属させられません。常駐量が 76 対 69.73 GiB と違うぶん作業領域の余裕が違った、というのが今の見立てですが、エキスパートの量子化差も混ざっているので確定ではありません。
前提の確認は毎回やる。「main に Qwen は無い」は 09-12 時点では正しく、09-14 には誤りになりました。2 日で覆ります。
ドキュメントは実物で裏を取る。今回は 3 件が実物と食い違いました。いずれも --help を読めば 1 分で分かるもので、推測で進めていたら原因不明のエラーに時間を使ったはずです。
説明がついたと思ったら、その説明で説明できない数字が残っていないか探す。追い出しが全件 hits=0 なのを「枠が足りない」で説明した気になっていましたが、枠の話なら復元は何度か起きているはずです。実際は 0 件でした。その 1 行を見ていれば、容量ではないと最初に分かります。このサーバーは 8 月にも同じ誤診をして、予算を 32 GB から 128 GiB へ増やしていました。2 度目です。
「原理的に無理」と結論する前に、経路を全部数える。上の誤診を正したつもりで書いた「保存が生成を含むから原理的に一致しない」も、やはり間違いでした。保存経路は 4 つあり、私は 3 つを見て結論を書いていました。残る 1 つが答えでした。しかも数え直した後にもう一段ありました。4 つのうち 1 つは走るタイミングによって書く中身が変わるので、経路の数は 4 ではなく 5 と数えるべきだったのです。43 時間・1,863 リクエストという実測が付いていても、見ていない場所があれば結論は変わります。データの量は網羅性の代わりになりません。
ログを数える正規表現は、フィールドを連結で書かない。間に 1 つ挿さっただけでエラーも警告も出さずマッチ 0 件になります。モニタが 356 件のキャッシュミスを 1 件も数えていなかったのも、記事の数字を一度間違えたのも(追記 4)、どちらもこれでした。0 件は「起きていない」と「数えられていない」の区別がつかないので、書いたら生の grep -c と件数を突き合わせることです。
実運用の集計は、窓を伸ばして同じ結論が出るかを見る。追記 4 を 16.8 時間で書いたあと 6 回伸ばしたら、フル prefill は 1.5 → 3.5 → 1.1 → 0.4 → 0.7 → 0.9 → 1.6 件/時と動きました。設定は何も変えていません。動いたのは使い方の側です。1 つの窓で出た数字は、その窓のワークロードの数字でもあるので、差をどこに帰属させるかは窓を割って確かめないと決まりません。
指標がどの経路でだけ出るログ行に依存していないか確かめる。「ライブ継続」を Anthropic 互換経路にしか出ない行で数えていたので、同じサーバーを OpenAI 互換のクライアントから叩き始めた途端、継続が継続として数えられなくなりました。クライアントに依存しない定義(ctx=0 から始まったかどうか)に置き換えて解決しています。
パッチが壊れていないかは、保存しておいた出力と比べても分からない。温度 0 の出力を参照ファイルと突き合わせる検証を続けていましたが、upstream が Metal のカーネルを触るたびに出力が変わり、参照は 4 回壊れました。1 タグしか保たなかったこともあります。しかも変化は一方向ですらなく、ある版の出力が 2 版前の値に戻ったこともありました。有効なのは、同じタグの無改造版を建てて突き合わせることです。こちらは 3 版とも 4/4 一致でした。参照との照合は「いつ何が変わったか」の記録であって、パッチの判定ではありません。
前提が消えた設定は中立ではない。--kv-cache-cold-max-tokens 0 は、upstream の古い挙動を避けるための設定でした。その挙動が削除されたことには気づいていたのに、「戻す積極的な理由が無い」と据え置きました。実際にはフラグの意味が反転していて、接頭辞を書ける 2 つの経路のうち 1 つを止めていたのです。もう 1 つも別の理由で止めていたので、ディスク KV は結果として「書くだけの機能」になっていました。upstream を追ったら、影響を受けた設定を「今は何をしているか」で読み直す必要があります。
まとめ
同じモデルを 2 つの実装で同条件比較しました。N-gram を 4 ビット級に潰して 26.8 GiB にする llama.cpp と、BF16 のまま 95.37 GiB で持つ DwarfStar です。どちらもテーブルはディスクに置いて行だけ読みます。
測った全指標で DwarfStar が上でした。深さ 65,536 で生成 1.8 倍、prefill 3.1 倍、常駐メモリは 5.5 GiB 少ない。MTP を入れると短文で 1.7 倍になります。品質も、測り方を揃えた PPL で 11.0% 低い(良い)値でした。代償はディスク使用量が 87 GiB から 165 GiB に増えることだけです。
実運用でも安定しています。ds4-server に OpenCode v2 をつないだ構成で、自作の SystemVerilog シミュレータ(sukimasim)のデバッグに常用しています。直近 28.7 時間で 1,781 リクエスト、うち 97.2% がツール往復の完了で、出力上限による打ち切りもエラーも 0 件でした。深さの中央値は 138,962 トークン、200k を超えたものが 294 件あっても decode の中央値は 39.03 t/s です。ベンチの深さ耐性が、エージェントの使い方でもそのまま出ています(追記 5)。記事を書いている途中で ds4 を 25 コミット追従させましたが、その後の 21.6 時間も 1,772 リクエストでエラー 0、98.0% がツール往復の完了で、デバッグの手は止まりませんでした。
品質測定では、既定のまま並べると 31% 差という誤った結論が出るところでした。片方はチャンク分割、片方は連続採点で、差の大半が測り方だったためです。engine をまたいで数字を比べるときは、まず採点の仕方を読みに行く必要があります。
長時間運用も埋めました(追記 1 と追記 2)。8 時間では時間加重平均 26.18 t/s、エラー 0、深さ 200k 超でも decode 52.04 t/s。そこから 43 時間、1,863 リクエスト、101 万トークンまで伸ばしても、エラーは 0 のままでした。深さで decode が落ちないという性質も 5 倍のデータで変わりません。ただし最速の帯という細部は取り下げました。52 件では決められていませんでした。
代わりに次の宿題が見えました。残ったボトルネックはキャッシュミス時の再 prefill です。8 時間では 19 回で 36 分、43 時間では 328 回で 9 時間、処理時間の 62% を占めました。深さが decode を殺さなくなったぶん、問題が 1 段先へ進んだ形です。
その保険になるはずのディスク KV は、調べたら 43 時間で一度も復元していませんでした。容量のせいだと思って 8 月に予算を 4 倍にしていたのですが、それは的外れでした。
ここで「保存が生成を含むので原理的に一致しない」と結論し、常駐では切って 124.9 GiB を返しました。ところがその数時間後、これも間違いだと分かります。保存経路は 4 つあり、そのうち cold と、prefill 中に走った continued が、生成を含まない接頭辞を書きます。そして私は前者を --kv-cache-cold-max-tokens 0 で、後者を --kv-cache-continued-interval-tokens 0 で、別々の理由で止めていました。前者だけを有効にして A/B すると復元は 0 件から 3/3 になり、再訪時の prefill は 1 巡目の 0.187 倍、処理トークンは 83.6% 減りました。順序を入れ替えた 2 パスで一致しています。
同じ現象に 3 回説明をつけて、2 回外しました。1 回目は容量、2 回目は保存の形、3 回目がフラグです。実測の量は増えていましたが、外していた理由はいつも同じで、見ていない場所があったことでした。
設定を戻してから 46.8 時間で、復元が 11 件起きました(追記 4)。cold を切っていた 43.1 時間では一度も起きなかったことです。237,568 トークンが約 1 秒で戻り、4 分から 8 分かかるはずの prefill が数秒で済んだ回もあれば、25.4% しか覆えず 15 万トークンを prefill し直した回もあります。合計 1,349,632 トークン、同窓のフル prefill 総量の 20.7% を省きました。ただし 7.8 → 1.4 件/時という減少そのものをディスク KV に帰属させることはできません。期間は揃いましたが使い方が違い、その差は窓を 7 つに割るだけでも 9 倍近い幅で再現してしまうからです。
参考リンク
本文で参照した一次情報です。
| 種別 | リンク |
|---|---|
| antirez/ds4(DwarfStar 本体) | https://github.com/antirez/ds4 |
| Qwen セットアップの公式ドキュメント | https://github.com/antirez/ds4/blob/9139e2ae58a41503968a500f36f75895c1ba63fc/docs/QWEN38_FLASH_NEXT.md |
| PR #991「Qwen3.8 flash next」(マージ済み) | https://github.com/antirez/ds4/pull/991 |
| PR #990「Add an experimental Qwen 3.8 Flash Next port on Metal」(オープン) | https://github.com/antirez/ds4/pull/990 |
24fa85e(追記 3 の起点。cold_max の意味が反転したコミット) |
https://github.com/antirez/ds4/commit/24fa85eafa9428186c599d58860a7f0fba229aea |
| 比較相手の llama.cpp | https://github.com/ggml-org/llama.cpp |
| 分割パッチ(ブランチ) | https://github.com/kurochan001/llama.cpp/tree/ple-metal-split |
| Unsloth GGUF(llama.cpp 側で使った UD-IQ4_XS) | https://huggingface.co/unsloth/Qwen3.8-Flash-Next-GGUF |
| 品質測定に使った素材(wikitext-2 の test) | https://huggingface.co/datasets/wikitext |







