How the paged + tiered + semantic-prefetch stack hangs together. This doc is for the engineer modifying or extending the tier code; it is not a "how do I configure the cache" doc (that's USER-GUIDE.md).
The full epic context is in Jira MAD-126 but Jira
isn't where engineers read documentation while debugging. The
authoritative source for design decisions is this file plus the per-
decision ADRs under adr/.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Hot (VRAM) │ ───▶ │ Warm (host) │ ───▶ │ Cold (SSD) │
│ │ ◀─── │ │ ◀─── │ │
└─────────────┘ └─────────────┘ └─────────────┘
GPU buffers host RAM per-layer files at
sized by staging ${ssd_path}/paged/
--kv-tiered buffers instance-${ID}/L*.{k,v}.bin
HOT%
A KV block — block_size tokens × n_kv_heads × head_dim of K and
V data per attention layer — lives in exactly one tier at any moment.
The tier is identified by the block's physical id range:
- IDs
[0, n_blocks_total)→ GPU pool (hot) - IDs
[n_blocks_total, n_blocks_total + n_warm_blocks)→ CPU pool (warm) - Cold tier is keyed by
(seq_id, logical_block_idx)rather than by physical id —KvtcStoreinsrc/memory-tier/mt-kvtc-store.{h,cpp}maps those into per-layer file offsets.
Movement between tiers is one-directional per call but fully bidirectional in aggregate:
| From | To | Trigger | Mechanism |
|---|---|---|---|
| Hot | Warm | evict_lru_to_warm() from ensure_blocks_for |
ggml_backend_tensor_get into warm_k_/warm_v_ host buffers |
| Warm | Hot | restore_block_from_warm() from semantic restore or kernel demand-fault |
ggml_backend_tensor_set from host buffers back into the GPU layer tensor |
| Warm | Cold | spill_one_to_cold() when warm pool fills |
KvtcStore::write to SSD; warm slot freed |
| Cold | Hot | restore_semantic_paged() cold-fault path |
KvtcStore::read → host buffer → ggml_backend_tensor_set |
| Cold | (drop) | Cold pool full + new spill needed | drop_oldest_cold_block(); data is lost, table entry becomes kInvalidBlockId |
Block contents are F16 (or whatever --cache-type-k/v is set to) on
hot; F16 in warm_k_/v_ host RAM; int4-with-scale on cold. The
int4 cold compression is per-block: each block stores a single
scale: f32 followed by ceil(n_elts/2) packed int4 nibbles. Cosine
similarity ≈ 0.99 against the unquantized baseline; this is acceptable
because cold blocks were already going to be re-attended-to in
combination with their original-precision neighbors. See
src/memory-tier/mt-quant.cpp.
Adapted from vLLM's BlockTable / BlockPool design (Apache 2.0). The key idea: tokens are not stored contiguously per sequence; they're stored in fixed-size physical blocks, and a per-sequence logical→physical table records which block holds which range.
seq 0 logical view physical pool view
┌────────┬────────┬────────┐ ┌────────┐ block 7
│ tok0 │ tok16 │ tok32 │ ├────────┤
│ ... │ ... │ ... │ │ tok0… │ block 0
│ tok15 │ tok31 │ tok47 │ ├────────┤
└────────┴────────┴────────┘ │ tok16… │ block 4
↓ ↓ ↓ ├────────┤
table_[0] = [0, 4, 7] │ ... │
└────────┘
Why this matters:
- Eviction is block-granular, not byte-granular. The evict path
moves a single block (~16 tokens × per-token K/V row size) at a
time. That's a clean unit for
ggml_backend_tensor_get/set. - CoW is cheap.
seq_cpincrements per-block refcounts inBlockPoolrather than copying bytes. A branched agent workflow creating 10 conversation forks pays one block-table copy per fork and zero K/V copies until a fork actually writes new tokens. See ADR-A10. - Holes are first-class. A partial
seq_rmcan wipe one logical block in the middle of a sequence by setting that table entry tokInvalidBlockId. The paged-attn kernel readskInvalidBlockId→ returns-INFINITYfor the wiped positions → softmax weights → zero contribution. No cache-side gymnastics required.
The block size is fixed at construction (default 16 tokens). The
paged-attn kernel
(ggml/src/ggml-cuda/mt_pagedattn.cu)
supports (head_size, block_size) ∈ {(128, 16), (64, 16), (256, 16), (128, 32)} and K cache types {F16, Q8_0, TURBO4_0}.
(See adr/A1-hybrid-paged-primary.md.)
User direction 2026-05-10: "hybrid+paged is THE primary path; pure-
attention is least concern." All new tier features land on
llama_kv_cache_paged. Pure-attention via mt::llama_memory_tiered
stays passthrough; the legacy llama_kv_cache_tiered /
server_tiered_cache paths got removed in MAD-127.
The mt::llama_memory_tiered wrapper survives in a thin form for two
specific responsibilities:
- bge-small embed model ownership. The wrapper holds a single
EmbeddingModelinstance shared across server lifetime, and exposesembed_text(string) → vector<float>to whoever needs to compute a fingerprint or query. - Recurrent-state backup on hybrid models. The recurrent half of
a hybrid model (Gated Delta Net, Mamba, etc.) uses
llama_memory_recurrent, whoseclear()loses everything. The tiered wrapper backs up the per-seq recurrent state into host RAM onseq_rmso it can be restored later.
Everything else — block management, eviction, persistence, semantic
prefetch — is in llama_kv_cache_paged.
llama_memory_i (interface)
└── llama_memory_hybrid (composition for hybrid models)
├── mem_attn → llama_kv_cache_paged ◀── the active tier layer
└── mem_recr → llama_memory_recurrent (recurrent-only state)
mt::llama_memory_tiered (thin wrapper, optional)
└── inner_ → llama_memory_hybrid (when wrapping a hybrid)
or → llama_kv_cache_paged (when wrapping pure paged)
For hybrid models routed through the tier stack, the runtime stack is:
server-side dispatch
↓
llama_context::decode
↓
llama_memory_hybrid (split into attn vs recr ubatches)
├─→ mem_attn = llama_kv_cache_paged ──── paged-attn kernel dispatch
└─→ mem_recr = llama_memory_recurrent ── recurrent kernel dispatch
The mt::llama_memory_tiered wrapper sits outside this stack when
present — it intercepts seq_rm etc. for the recurrent backup
behavior, then delegates to its inner cache.
The server-context helper mt_get_paged_cache(llama_memory_i*) peels
through three nestings (raw paged / hybrid / tiered+hybrid) to return
the underlying llama_kv_cache_paged*. See
tools/server/server-context.cpp
near the mt_get_paged_cache definition.
(See adr/A4-single-threading.md.)
llama_kv_cache_paged and its BlockPool / BlockTable are NOT
internally locked. The server's main loop is the single mutator; HTTP
worker threads communicate with it via server_queue's task channel
and never touch the cache directly.
Concretely:
- Tier-counter
_totalaccessors (e.g.evict_h2w_total()) returnuint64_tfrom the cache. They'revolatile-style monotonic counters that the/metricsHTTP handler reads from a different thread — this is the only cross-thread read on the cache. It's safe-ish on x86_64 / aarch64 for monotonic 64-bit counters (atomic loads are single instructions). - Any future async path (semantic prefetch on a worker thread, bge- small embed batched off the critical path, etc.) must gate on a real concurrency design — not "by accident."
- Debug builds include a thread-id assertion (
check_thread_id_()inllama_kv_cache_paged) that traps if anyone other than the registered main thread mutates the cache.
This contract is documented in
src/llama-kv-cache-paged.h
near the class declaration and in
src/memory-tier/mt-block-pool.h.
(See adr/A5-persistence-explicit.md.)
The cache supports explicit save/restore via the server's /slots/save
and /slots/restore endpoints. Crash recovery is not automatic;
the model is "clean shutdown saves, restart restores."
State written by state_write():
- Block table per seq (logical→physical mapping).
- Hot-tier K/V tensors (or skip + reprefill — caller-configurable).
- Warm-tier K/V buffers.
- Cold-tier index sidecar (CIDX v1 magic =
0x58444943). - BlockSemanticIndex fingerprints (PSFI v1 magic =
0x49465350).
Format magic numbers and version bytes let future format changes be detected and rejected (rather than silently corrupting state).
The cold-tier sidecar is the recovery hinge: per-layer K/V files
contain the actual block data, but without the sidecar's
(seq, lblock) → file_offset mapping the cache can't read them back.
Hard crashes that miss the sidecar write produce orphan files —
scripts/army/cleanup-cold.sh removes them.
(See adr/A6-multi-instance.md.)
Multiple llama-server processes can share one --kv-tier-ssd-path
without colliding because each writes cold-tier files under a
per-instance subdirectory:
${ssd_path}/paged/
├── instance-main-r9700/
│ ├── L0.k.bin
│ ├── L0.v.bin
│ ├── ...
│ ├── instance.lock # flock'd while the process is alive
│ └── index.bin # CIDX v1 sidecar
├── instance-main-6900xt/
│ └── ...
--instance-id defaults to the process PID but is typically set
explicitly by the boot script for stable cold-resume across restarts.
The flock-based lockfile prevents accidental double-start: a second
process trying to open the same instance subdir fails fast with a clear
error rather than silently corrupting the on-disk state.
(See adr/A2-fingerprint-at-prefill.md and adr/A3-semantic-prefetch-only.md.)
Two paths share the same BlockSemanticIndex storage in
src/memory-tier/mt-semantic.{h,cpp}:
When the server processes a prompt, after the prefill batch lands
and the block table is fully populated, the server walks the new seq's
complete logical blocks (any block with n < block_size of
its tokens written is skipped) and:
- Decodes the original tokens for that block back to text via
slot.prompt.tokenspluscommon_token_to_piece. - Calls
mt::llama_memory_tiered::embed_text(text)to get an L2-normalized 384-dim BGE-small embedding. - Calls
llama_kv_cache_paged::record_paged_block_fingerprint(seq, lblock, embedding, tier=Hot).
CPU cost is ~5ms × n_complete_blocks per prefill, off the GPU
critical path. Skipped when the block already has a fingerprint
(the has_paged_fingerprint(seq, lblock) short-circuit).
After every prefill the server also computes an embedding of the
most recent complete block (the "query" for purposes of semantic
recall) and calls
llama_kv_cache_paged::restore_semantic_paged(seq, query_embedding, top_k, threshold). The cache scores its stored fingerprints against
the query, picks the top-K above threshold, and faults each matching
block back from warm/cold to hot before kernel dispatch.
Eviction in the paged cache is internal — the server doesn't see eviction events. Fingerprints written at eviction time would be strictly behind the data they describe (the data has already been evicted) and the timing is hard to control. Writing at prefill time makes the fingerprint write a clean, predictable, additive operation attached to the server's ordinary task lifecycle.
(See adr/A3-semantic-prefetch-only.md.)
bge-small drives prefetch only, not eviction. Eviction stays on the
hybrid attention/recency/frequency policy in
src/memory-tier/mt-eviction.{h,cpp}. The reasons:
- Training-task mismatch: bge-small is trained for retrieval, not for inference cache predictiveness.
- Hot-path latency: every eviction decision would need an embed call.
- Doesn't fix the structural problem (hot-pool fragmentation under multi-seq load — that's the MAD-120 admission control's job).
The paged-attn kernel
(ggml/src/ggml-cuda/mt_pagedattn.cu)
takes the layer's K and V tensors plus the block_table tensor and
context-lens / q-lens (per-batch tensors maintained by
llama_kv_cache_paged::prepare_batch_tensors).
Dispatch table:
type_k: F16 | Q8_0 | TURBO4_0
(head, block):
(128, 16)
(64, 16)
(256, 16)
(128, 32)
Aborts on unsupported tuples with a clear error. Hybrid models with attention layers that fall outside this dispatch table cannot use paged-attn until the kernel is extended.
kInvalidBlockTableEntry (matches mt::kInvalidBlockId) is handled
in the kernel: any (seq, position) whose physical block id is the
sentinel returns -INFINITY as its attention logit. After softmax
this contributes zero weight to the attention output — equivalent to
"that token doesn't exist." Used by partial seq_rm and by cold-drop.
┌────────────────────────────┐
│ ensure_blocks_for(seq, N) │
└─────────┬──────────────────┘
│
┌───────────────────────▼─────────────────────────┐
│ pool_.alloc_gpu() │
└─┬─────────────────────────────────────────────┬──┘
│ ok │ kInvalidBlockId
│ │
▼ ▼
┌────────────┐ ┌──────────────────────────┐
│ DONE │ │ evict_lru_to_warm() │
└────────────┘ └──┬─────────────────────┬──┘
│ ok │ false (warm full)
▼ │
┌──────────────┐ │
│ retry alloc │ ▼
└──┬───────────┘ ┌────────────────────┐
│ ok │ spill_one_to_cold() │
▼ └──┬─────────────────┬┘
┌────────────┐ │ ok │ false (cold full)
│ DONE │ ▼ ▼
└────────────┘ ┌────────────────┐ ┌─────────────────────────┐
│ retry alloc │ │ drop_oldest_cold_block() │
└────────────────┘ └──┬───────────────────────┘
│
▼
┌──────────────────────────┐
│ retry; on failure: false │
└──────────────────────────┘
The bottom escalation (drop_oldest_cold_block → data loss with
sentinel) is the last resort. The drop counter
(paged_evict_cold_to_drop_total) is the operator's signal to
re-tune sizing.
| File | What's in it |
|---|---|
src/llama-kv-cache-paged.h/.cpp |
The hot/warm-tier cache. Block table, eviction, semantic restore, state save/load, multi-instance lockfile. |
src/memory-tier/mt-block-pool.h/.cpp |
Physical block allocator (GPU + CPU pools, refcounting, watermark). |
src/memory-tier/mt-block-table.h/.cpp |
Per-seq logical→physical mapping. |
src/memory-tier/mt-semantic.h/.cpp |
SemanticIndex (chunk-level) + BlockSemanticIndex (per-block); save/load PSFI v1. |
src/memory-tier/mt-tiered.h/.cpp |
Thin mt::llama_memory_tiered wrapper: bge-small ownership, recurrent backup. |
src/memory-tier/mt-quant.h/.cpp |
int4 / int8 quant helpers including the per-block scaled int4 used for cold compression. |
src/memory-tier/mt-kvtc-store.h/.cpp |
Cold-tier file I/O. |
src/memory-tier/mt-eviction.h/.cpp |
TokenMetadataStore and the hybrid eviction policy. |
src/memory-tier/mt-mover-attn.h/.cpp |
Attention K/V mover for mt::llama_memory_tiered. |
src/memory-tier/mt-mover-recurrent.h/.cpp |
Recurrent state mover. |
src/memory-tier/mt-embed.h/.cpp |
EmbeddingModel wrapper around the bge-small gguf. |
src/memory-tier/mt-config.h/.cpp |
TieredConfig parsing. |
src/memory-tier/mt-capacity.h/.cpp |
TierCapacityManager for non-paged tiered (legacy). |
tools/server/server-context.cpp |
Server-side dispatch: prefill-time fingerprint write trigger, semantic restore call, /metrics extension, /slots tier residency, MAD-141 admission deadlock guard. |
ggml/src/ggml-cuda/mt_pagedattn.cu |
The paged-attn kernel + dispatch. |
tests/test-mt-*.cpp |
Unit tests for tier primitives. |
tests/test-paged-*.cpp |
Integration tests against llama_kv_cache_paged. |
tests/stress/stress-paged-multi-seq.py |
HTTP-driven stress driver. |
scripts/test/run-army-matrix.sh |
Per-device matrix runner. |
scripts/army/*.sh |
Boot scripts. |