KV キャッシュをディスクの一級市民にする: DwarfStar の KVC と DSV4 ペイロードを読む【第4回/全8回】
本シリーズは、DeepSeek V4 Flash / Pro 専用推論エンジン DwarfStar(
ds4)のコードを読み解く連載です。
第4回は、README の中心思想である「KV cache is a first-class disk citizen」が、どのようなファイル形式と再利用ロジックに落ちているかを見ます。主な参照箇所:
README.md,ds4.h,ds4.c,ds4_kvstore.c,ds4_kvstore.h読みどころ: 「ファイル名がトークン列ではなくレンダリング後バイト列の SHA1」という一点が、ステートレス API でプレフィックスを再利用できる鍵になっています。
連載「ds4.c を読み解く」全8回
- 第1回 なぜ専用エンジンを書くのか
- 第2回 非対称2bit量子化とimatrix
- 第3回 Metalグラフと圧縮KV
- 第4回 ディスクKVキャッシュ(本記事)
- 第5回 サーバとDSMLツール呼び出し
- 第6回 TCPパイプライン分散推論
- 第7回 ネイティブエージェント
- 第8回 ステアリング・MTP・評価基盤
TL;DR
- DS4 のディスク KV キャッシュは、単なるプロンプト文字列のキャッシュではない。チェックポイントのトークン ID、logits、raw KV、圧縮 KV、compressor/indexer の frontier まで保存する。
- ファイルは外側の
KVCエンベロープと内側のDSV4セッションペイロードに分かれる。 -
KVCのキーはトークン ID 列ではなく、レンダリング後バイト列プレフィックスの SHA1。これは、同じバイト列が次回トークナイザにより違うトークン境界で綴られる可能性を吸収するため。 -
DSV4ペイロードはグラフ状態の実体で、13 個の u32 ヘッダ、チェックポイントトークン、次トークン logits、層ごとの raw/圧縮/indexer 状態を持つ。 - cold 保存では末尾 32 トークンを切り詰め、2048 トークン境界に整列する。BPE 境界の揺れと compressor 境界を避けるための実用的な設計。
- 退避スコアはトークン密度、ヒット数、6 時間半減期、アンカー理由を組み合わせる。単なる LRU ではない。
1. なぜディスク KV キャッシュが重要なのか
OpenAI/Anthropic 互換のチャット/補完 API は基本的にステートレスです。クライアントは毎リクエストで会話履歴全体を送り直します。
ローカル LLM サーバにとって、これはかなり重い問題です。長いシステムプロンプト、ツール定義、過去の会話、ファイル内容などが毎回再送されるからです。
DS4 の README はこの問題に対して、次の方針を取ります。
- ライブのメモリキャッシュは現在の 1 セッション用
- 無関係なセッションに切り替えると、RAM 上の KV は置き換わる
- 置き換わったセッションを再 prefill なしで復元するには、ディスク KV キャッシュが必要
つまりディスク KV は「メモリが足りない時の退避」ではなく、複数セッションとサーバ再起動をまたいでプレフィックスの prefill を再利用するための主機能です。
README の言葉を借りれば、圧縮 KV と高速 SSD の時代には、KV キャッシュは RAM だけのものではありません。
2. KVC と DSV4: 外枠と中身を分ける
DS4 のディスクキャッシュファイルは、大きく 2 層に分かれています。
KVC fixed header
rendered text length
rendered text bytes
DS4 session payload
optional trailer sections
外側の KVC は、キャッシュのルックアップと管理のためのエンベロープです。
内側の DSV4 ペイロードは、実際のグラフチェックポイントです。ここにトークン ID、logits、KV テンソルが入ります。
ds4_kvstore.c の冒頭のコメントは、この分離を明確にしています。
/* Shared disk KV checkpoint file support.
*
* The low-level file layout and payload helpers are intentionally shared.
* The ds4-server still owns the automatic byte-prefix cache policy ...
* ds4-agent uses only the same durable format for explicit sessions ...
*/
サーバは自動キャッシュポリシーを持ちます。一方、ネイティブエージェントは同じ永続フォーマットを明示的なセッション保存に使います。ファイル形式を共有し、ポリシーは利用者ごとに分ける構造です。
3. KVC ヘッダ: ルックアップとキャッシュポリシーのメタデータ
README によると、KVC 固定ヘッダは 48 バイトです。コードでは次の定義があります。
#define DS4_KVSTORE_FIXED_HEADER 48u
#define KV_CACHE_MAGIC0 'K'
#define KV_CACHE_MAGIC1 'V'
#define KV_CACHE_MAGIC2 'C'
#define KV_CACHE_VERSION 1u
ヘッダの主なフィールドは以下です。
| オフセット | フィールド | 役割 |
|---|---|---|
| 0 | magic "KVC"
|
キャッシュファイル判定 |
| 3 | version | KVC エンベロープのバージョン |
| 4 | quant bits | ルーテッドエキスパートの量子化ビット、2 or 4 |
| 5 | reason | cold / continued / evict / shutdown など |
| 6 | extension flags | tool map などのトレーラ有無 |
| 8 | cached token count | チェックポイントのトークン数 |
| 12 | hit count | キャッシュヒット回数 |
| 16 | ctx size | 書き込み時のコンテキストサイズ |
| 20 | payload ABI | グラフペイロードの ABI |
| 24 | creation time | Unix time |
| 32 | last-used time | Unix time |
| 40 | payload bytes | DSV4 ペイロードのバイト数 |
コード上は ds4_kvstore_fill_header() がリトルエンディアンで詰めます。
h[0] = KV_CACHE_MAGIC0;
h[1] = KV_CACHE_MAGIC1;
h[2] = KV_CACHE_MAGIC2;
h[3] = KV_CACHE_VERSION;
h[20] = KV_CACHE_PAYLOAD_ABI;
KVC は「このファイルは何のプレフィックスか」「今のランタイムで読めるか」「どれを退避すべきか」を判断するためのメタデータを持っています。
4. キャッシュキーはトークン ID ではなくレンダリング後バイト列の SHA1
ここが DS4 のディスク KV キャッシュの重要な設計です。
ファイル名は <sha1>.kv です。この SHA1 はチェックポイントのトークン ID ではなく、レンダリング後バイト列プレフィックスに対して計算されます。
ds4_kvstore.h のコメントは理由を明確に書いています。
/* The file name is the rendered byte prefix, not the token sequence.
* The payload still carries the exact tokens and graph state; the hash only
* answers "does this checkpoint represent the bytes at the front of the
* incoming prompt?" */
なぜトークン ID ではなくバイト列なのか。
チャットクライアントは、前回モデルが生成したテキストを次回リクエストで JSON 履歴として送り返します。このとき、同じ見た目のテキストでもトークナイザの境界が変わることがあります。例えば、前回は 1 トークンとして生成された文字列が、正準的なプロンプトレンダリング後には 2 トークンに分かれることがありえます。
DS4 はレンダリング後バイト列のプレフィックスが一致するかを先に見ます。一致したら、ペイロード内の正確なチェックポイントトークン ID とグラフ状態を正として復元し、残りのサフィックスだけをトークナイズします。
つまり:
- ルックアップはレンダリング後バイト列
- 復元後の状態はペイロード内の正確なトークン
- 追加分だけレンダリングサフィックスからトークナイズ
この設計により、BPE 境界の揺れによるキャッシュミスを減らしています。新しいプロンプトが来たときの判定フローはこうです。
5. DSV4 ペイロード: グラフチェックポイントの中身
内側のセッションペイロードは ds4.h で定義されています。
#define DS4_SESSION_PAYLOAD_MAGIC UINT32_C(0x34565344) /* "DSV4" */
#define DS4_SESSION_PAYLOAD_VERSION UINT32_C(2)
#define DS4_SESSION_PAYLOAD_U32_FIELDS 13u
ds4.c の Session Snapshot Payloads コメントが、保存対象を説明しています。
* The payload is model-specific rather than self-describing.
* The fixed header records enough shape information to reject a file written
* for a different DS4 runtime, then the body writes: checkpoint tokens,
* last logits, per-layer compressed row counts, raw SWA rows in logical order,
* compressed attention rows, and the compressor/indexer frontiers.
ヘッダの 13 フィールドは以下です。
| index | field |
|---|---|
| 0 | magic DSV4
|
| 1 | payload version |
| 2 | saved context size |
| 3 | prefill chunk size |
| 4 | raw KV ring capacity |
| 5 | raw sliding-window length |
| 6 | compressed KV capacity |
| 7 | checkpoint token count |
| 8 | layer count |
| 9 | raw/head KV dimension |
| 10 | indexer head dimension |
| 11 | vocabulary size |
| 12 | live raw rows serialized below |
ペイロード本体は次の順です。
-
u32[token_count]チェックポイントのトークン ID -
float32[vocab_size]次トークン logits -
u32[layer_count]圧縮アテンション行のカウント -
u32[layer_count]ratio-4 indexer 行のカウント - 層ごとの raw SWA 行
- 圧縮層のアテンション圧縮行
- compressor の frontier 状態
- ratio-4 層の indexer 圧縮行
- indexer の frontier 状態
logits まで保存している点が重要です。復元後に「次トークンをサンプリングするためだけに 1 回デコードを進める」必要がありません。プレフィックスを prefill した直後の次トークン分布から、そのまま続行できます。
6. raw リングは論理順で保存する
raw KV はリングバッファなので、メモリ上では物理的に折り返している可能性があります。しかしペイロードは物理配置ではなく論理順で書きます。
GPU 経路の保存実装には、次のコメントがあります。
/* Write the raw ring in logical position order. The file does not care
* where the rows happened to live physically in the source graph. */
この設計により、保存元ランタイムのリングの物理状態をファイル形式に漏らさず、ロード時には現在のセッションキャッシュに順序通り復元できます。
一方、圧縮行は追記専用で、行 0 からライブカウントまでが連続です。疎アテンションはプレフィックス全体の圧縮行から選ぶ可能性があるため、ライブ行数まで保存します。
7. frontier 状態も保存する理由
圧縮 KV 行だけ保存すれば十分、とはなりません。compressor は ratio 境界に達していない途中ウィンドウを持つからです。
DSV4 ペイロードは圧縮行に加えて、compressor の frontier テンソルを保存します。
attn_state_kvattn_state_score- ratio-4 層では
index_state_kv - ratio-4 層では
index_state_score
これらがないと、チェックポイントの次トークンから compressor を正しく継続できません。特に ratio 4 / ratio 128 の境界にいる場合、次に圧縮行が吐かれるタイミングと内容がずれます。
DS4 のペイロードは「すでに完成した KV 行」だけでなく、「次の圧縮行を作る途中状態」まで保存するため、ロード後に prefill の途中からでも正確に続けられます。
8. cold 保存は末尾を捨て、2048 境界に寄せる
ディスク KV キャッシュは、保存すればするほど良いわけではありません。プロンプトの末尾は、次回リクエストでサフィックスが付いたときに BPE のマージ境界が変わりやすいからです。
DS4 の既定は次の通りです。
#define KV_CACHE_DEFAULT_MIN_TOKENS 512
#define KV_CACHE_DEFAULT_COLD_MAX_TOKENS 30000
#define KV_CACHE_DEFAULT_BOUNDARY_TRIM_TOKENS 32
#define KV_CACHE_DEFAULT_BOUNDARY_ALIGN_TOKENS 2048
#define KV_CACHE_DEFAULT_CONTINUED_INTERVAL_TOKENS 10000
ds4_kvstore_store_len() は、保存候補のトークン数から 32 トークンを切り詰め、その後 2048 境界へ切り下げます。
int stable = tokens - trim;
if (align > 0) stable -= stable % align;
この 2048 整列には、トークナイザだけでなくバックエンドの prefill チャンクスケジュールとの整合もあります。コメントには、2048 整列が compressor 行の確定を cold のフルプロンプトと一致させる、と書かれています。
保存するプレフィックスを少し短くすることで、次回のバイトプレフィックスのヒット率とグラフ継続の安定性を上げているわけです。
9. 保存理由: cold / continued / evict / shutdown
README は、キャッシュチェックポイントが保存されるタイミングを 4 種類に分けています。
| 理由 | 役割 |
|---|---|
cold |
長い初回プロンプトが安定プレフィックスに達した後、生成前 |
continued |
prefill/生成が整列フロンティアに達した時 |
evict |
無関係なリクエストがライブセッションを置き換える前 |
shutdown |
サーバがクリーンに終了する時 |
コード上の enum には、エージェント用の理由も追加されています。
DS4_KVSTORE_REASON_AGENT_SYSTEM = 5,
DS4_KVSTORE_REASON_AGENT_SESSION = 6,
サーバの自動キャッシュとエージェントの明示的なセッション保存が、同じ低レベル形式に乗っていることがここにも表れています。
10. 退避は単純 LRU ではない
キャッシュディレクトリには容量予算があります。ds4_kvstore_open() の既定は 4096MB、サーバ起動時には --kv-disk-space-mb で指定できます。
どのファイルを消すかは、単純な LRU ではありません。ds4_kvstore_entry_eviction_score() は、ヒット数、経過時間、トークン数、ファイルサイズ、理由を組み合わせます。
effective_hits *= exp2(-elapsed / DS4_KVSTORE_HIT_HALF_LIFE_SECONDS);
score = (effective_hits + 1.0) * tokens / file_size;
DS4_KVSTORE_HIT_HALF_LIFE_SECONDS は 6 時間です。
#define DS4_KVSTORE_HIT_HALF_LIFE_SECONDS (6ull * 60ull * 60ull)
さらに cold / evict / shutdown はアンカー理由として、スコアにソフトな事前重みがかかります。
#define KV_CACHE_ANCHOR_REASON_SCORE_FACTOR 2.0
continued チェックポイントは長い生成の途中の中継点なので、プレフィックス関係や最近のヒットを見て重み付けが変わります。
この設計は実用的です。長いプレフィックスほど再利用価値が高い一方、巨大すぎるファイルは容量効率が悪い。最近ヒットしたキャッシュは残したいが、古いヒットはワークロードが変わった可能性がある。DS4 はそれをスコアにしています。
11. オプショントレーラ: tool-id map
サーバでは、ツール呼び出しの exact DSML replay もディスクキャッシュと関係します。
README によると、オプションの tool-id map セクションは KTM マジックを持ち、API ツール呼び出し ID から、モデルが実際にサンプルした DSML ブロックへの対応を保存します。
これはモデル状態ではなく補助的な再生メモリです。
なぜ必要かというと、クライアントは次ターンで正規化 JSON を送り返します。しかしモデルが前ターンで生成した DSML テキストと 1 バイトでも違えば、レンダリング後プロンプトが変わり、KV チェックポイントと合わなくなります。
tool-id map により、サーバ再起動後でもツール呼び出し ID を見て「当時モデルが出した正確な DSML ブロック」を復元できます。第5回で扱う DSML ツール処理の基盤です。
12. mmap しない設計
README は、KVC ファイルを通常の read / write I/O で扱い、mmap しないと説明しています。
理由は、DS4 プロセスはすでに巨大なモデル GGUF を mmap しているからです。復元のたびにキャッシュファイルを mmap すると、VM マッピングの圧力が増えます。DS4 はペイロードのバイト列を既存の Metal テンソル / CPU バッファにコピーし戻す設計を選んでいます。
ds4.c のペイロードコメントにも、復元はチェックポイントのバイト列を既存のグラフバッファにコピーする、と書かれています。
* This payload is intentionally not mmaped: restoring a checkpoint copies
* bytes back into the already allocated Metal tensors ...
これは macOS の巨大 mmap と VM 圧力を意識した、かなり実装寄りの判断です。
13. 分散でもペイロードは同じ
分散推論では、各ワーカーが自分の層スライスの KV を持ちます。しかし README は、保存される DSV4 ペイロードはトポロジ非依存だと説明しています。
保存時にはコーディネータがワーカー所有の層テンソルを集め、通常の層順テンソルストリームに統合します。ロード時には、現在のルートに合わせて再度ワーカーに配ります。
つまりディスクファイルは「2 台構成だったか」「3 台構成だったか」を覚えません。覚えるのは、DS4 のモデルレイアウトに対する層ごとのセッション状態です。
この分離のおかげで、分散構成を変えても互換ランタイムなら同じキャッシュファイルを読み得ます。
13.5. 運用上の注意: キャッシュにはプロンプト本文が残る
ここまで見たとおり、KVC ファイルには「rendered text bytes」、つまりキャッシュ対象プレフィックスのトークナイザ復号テキストがそのまま入ります。README も、KV キャッシュファイルにはプロンプトが verbatim(逐語)で格納されるため、挙動が怪しいときはキャッシュディレクトリごと削除してよい(ディレクトリは disposable)、hexdump で中身を調べられる、と説明しています。
これは設計上は合理的(バイトプレフィックス照合のための同一性であり、人間が中身を確認できる)ですが、運用面ではプライバシー/情報管理上の注意点になります。
ディスク KV キャッシュ(既定 --kv-disk-dir 配下、エージェントは ~/.ds4/kvcache)には、システムプロンプト・ツール定義・会話・読み込んだファイル内容などが逐語テキストとして残ります。秘匿情報を扱う運用では、保存先ディレクトリの配置・アクセス権限・暗号化ボリュームの利用・不要キャッシュの削除方針を、通常のログや一時ファイルと同等以上に管理してください。共有マシンや持ち出し端末では特に注意が必要です。
14. まとめ
DwarfStar のディスク KV キャッシュは、よくある「プロンプトテキストをキャッシュする」仕組みよりずっと深いです。
- ルックアップの同一性はレンダリング後バイト列の SHA1
- 正確な状態は
DSV4ペイロード内のトークン ID とグラフテンソル - raw KV は論理順、圧縮 KV はライブ行、frontier 状態も保存
- 次トークン logits も保存し、復元後すぐサンプリングできる
- BPE 境界対策として 32 トークン切り詰めと 2048 整列を行う
- 退避は 6 時間半減期のヒットスコアとトークン密度を組み合わせる
- ツールの exact replay 用トレーラも同じファイルエンベロープに乗せられる
この設計があるから、ステートレスな OpenAI/Anthropic 互換クライアントから長い会話履歴が何度も送られても、DS4 はプレフィックスの prefill を再利用できます。
次回は、そのサーバ層を見ます。OpenAI /v1/chat/completions、OpenAI Responses /v1/responses、Anthropic /v1/messages を受けながら、DeepSeek V4 の DSML ツール呼び出しをどう exact replay しているのかを追います。
本記事は クイックイタレート株式会社 のローカル LLM 研究の一環として、
公開リポジトリ antirez/ds4 のコードを読み解いたものです。行番号・定数・ベンチ値は閲覧コミット ba00a8a(2026-05-30)/README 取得日 2026-06-01 時点のものです。ds4-agent は alpha、エンジン本体は beta 品質で活発に変化するため、引用箇所は各自で最新の README / ソースに当たって再確認してください。
クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。