The hybrid prefix cache (HPC) is the opt-in prefix-cache mode ninfer-serve --use-alt-prefix-caching
selects (ContextCacheOptions::mode = ContextCacheMode::Hybrid). It reuses prompt prefixes through
content-addressed 64-token KV blocks and sparse recurrent-state snapshots, kept on the Device and in
one pinned Host tier. The checkpoint catalog
(Resource scheduling and context cache,
ContextCacheMode::Legacy) is the default of the server and the Engine, and
--use-original-prefix-caching names it; the two modes coexist. Ian Ranson designed and built the
mode in Wallawalla47/ninfer-custom.
This document is the authority for the Hybrid mode. §0–§11 describe the design as implemented, marking the parts of the original design that were not built; §12–§15 cover optional features, tests, configuration and risks.
Scope: an alternative to NInfer's prefix-reuse, checkpoint-retention and cache-pressure
system for Qwen3_5ForCausalLM / Qwen3_5MoeForCausalLM on one RTX 5090 (sm_120a), with
max_concurrency 1..8, every KV profile (BF16, INT8-G64, FP8-E4M3FN-row256, NVFP4-G16, K8V4),
and every speculative backend (none, MTP, DFlash, DFlash2).
In this line the mode runs on one device: pipeline stages (--devices with more than one entry)
are rejected with it, and so are the checkpoint catalog's own policies (--auto-long-anchors,
--auto-prefix-grid, --context-cache-policy, --release-diverged-checkpoints,
--thorough-admission-search, --recency-eviction, --value-aware-demote, the disk tier). Host
restores and snapshot copies take the rank-stream form of the KV and StateImage copy routines, and a
Vision prefill uses the concurrent overlay window when one is configured. The measurements below are the original
author's on an RTX 5090.
Coexistence rules:
- The mode is fixed at Engine construction. Legacy-only options (
--device-state-slots,--host-state-slots,--host-kv-mib,--max-private-continuations,--max-shared-prefixes,--max-long-anchors-per-continuation,--long-anchor-spacing) require--use-original-prefix-caching; Hybrid-only options are rejected with it.--host-cache-mibapplies to both modes (in Hybrid mode it sizes the Host slab pool);--no-prefix-reusedisables the cache and is rejected with--use-original-prefix-cachingand with every mode-specific option. - Hybrid mode reuses the active-execution machinery unchanged:
LogicalKVPageStore,KVAddressSpaceStore, execution rows, the activeStateStoreimages, prefill/decode/speculative paths. It replaces only admission-source selection, retention, capture and pressure. Tree nodes hold non-writer references to logical pages; active address spaces map those pages exactly as a Legacy shared-prefix fork does. - Nothing in §14.1 is deleted while Legacy remains a supported mode.
Hybrid mode configures itself: the only capacity a deployment chooses is --host-cache-mib
(default 8192, 0 = Device only); every other value is derived from the rest of the configuration
(§14.2).
| area | state |
|---|---|
| §5 index, §9 eviction (device LRU, host superseded-first then GDSF, dead KV), §7.1 tap planner | implemented; host-only unit tests (ninfer_prefix_cache_index_test) |
| §5.4 automatic Device sizing | implemented: in Hybrid mode the Main pool is not clamped to C·L, and ninfer-serve defaults --kv-capacity to auto, so free VRAM becomes Device block cache |
| §5.4 unified Host slab pool | implemented: KV blocks, snapshot images (split over slabs) and snapshot tails share one pinned pool; GDSF and the dead-KV sweep decide the split at run time |
| §6 admission as the Engine's materialization transaction | implemented: staging reserves every Device page and the state slot, Host restores run on a dedicated restore stream, and activation forks the lane at once; its Device work queues behind the copies it reads, layer by layer (§6.4, §6.5, §12.1) |
| §6.6 prefetching the blocked head | implemented: Host-only path blocks are copied into spare Device cache while the FIFO head waits |
| §7.1 taps | implemented: exact taps (explicit, generation opener, structural) split the chunk; flexible taps (prompt tail, ladder) are realized at chunk boundaries at no extra forward pass |
| §7.4 tap publication | implemented: deferred until the anchoring blocks commit (a frontier inside a block, or an MTP backend one token behind); exact-frontier tails are copied into cache-owned pages |
| §7.6 block publication, write-through | implemented: blocks join the tree at every commit; a lane's blocks are written to Host in one batch when it releases them |
| §7.7 endpoint snapshots at finish and consistent abort | implemented |
| §6.2 cost model | implemented: the Engine's context-cost coefficients (prefill and Host-to-Device transfer) rank sources and value snapshots |
| §5.5 persistence across restarts | implemented (opt-in --prefix-cache-file) |
| §12.2 in-flight prefix coalescing | implemented: requests sharing a new prefix with a lane still prefilling wait for its snapshot at the divergence instead of prefilling the prefix again; in this line a request is admitted while another prefills only with --concurrent-prefill, so coalescing needs it |
| reuse-loss attribution | implemented: the request log's materialization record carries cached_prefix_tokens (longest cached block prefix, reusable or not), and restored_host_bytes |
| §7.2–§7.3 zero-split GDN state tap and phase alignment | not implemented: an exact tap costs one prefill split (about 15 ms per turn on 27B); flexible taps avoid it, and requests resuming from an endpoint skip the opener tap (§7.1) |
| §11.2 KV transfer Op | copy-engine path only (cudaMemcpy2DAsync runs over consecutive pages and slabs) |
| §6.3 persistent backfill proof | not issued: a blocked FIFO head is never overtaken (a proof with a growth reserve moved latency from short to long requests without a net gain, §6.3) |
| §12 optional features other than 12.1 and 12.2, §13.2 Op qualification | not implemented |
| §13.3 real-artifact scenarios | ninfer_qwen3_5_hybrid_prefix_real_test: Host vs Device restore exactness (with and without MTP), generation-opener and system-block reuse, Device-only mode, protocol cache hints, Vision, persistence across a restart; NINFER_HYBRID_KV_DTYPE runs them for every KV storage (bf16, int8, fp8, nvfp4, k8v4 pass). ninfer_ngram_concurrent_real runs on Hybrid with NINFER_NGRAM_TEST_CONTEXT_CACHE=hybrid |
| Legacy mode | Hybrid mode | |
|---|---|---|
| Unit of reuse | owner/checkpoint (private continuation, shared prefix, 5 checkpoint kinds) | content-addressed 64-token KV block + sparse state snapshot |
| Identity | per-owner token ledgers, session index, shortlist keys, markers | exact token path in one radix tree (hash only for lookup) |
| KV sharing across sessions | only through an optional shared-prefix publication that must beat a private baseline | unconditional: identical blocks are one physical page |
| State checkpoint capture | prefill chunk split at the frontier + copy | a copy of the committed state at a chunk boundary; only exact taps (client breakpoints, generation opener, structural boundaries) split a chunk (§7.1) |
| Admission | bounded heuristic search over complete targets; up to 250 ms planning allowance per admission boundary | O(prompt blocks) lookup + arithmetic capacity check, no search |
| Pressure | monotone degradation graph, joint post-state projection, ordered stage peaks, one global transaction | independent eviction per tier: device LRU and host GDSF (GreedyDual-Size-Frequency) |
| Host tier | variable-extent arena with allocator geometry and a separate StateImage slot count | one pinned slab pool; KV blocks and snapshots share it with no fixed split between them |
| Device ↔ host | demote on pressure (copy before release) | asynchronous write-through when a lane releases its blocks; device eviction never copies |
| Code | ≈25 k lines in runtime/engine/context_cache, program/{planning,transactions,storage}, host arena, resource contracts |
≈6.1 k lines in runtime/prefix_cache, program/prefix and the Engine's HybridResourceManager, plus ≈1.8 k lines of integration |
What the design changes for users:
- More reuse. Every committed full block of every request becomes reusable, including cancelled and partially prefilled requests. Snapshots are deduplicated by content, so one snapshot serves every session that shares its prefix. Tap density is limited only by host memory, not by per-continuation anchor counts or catalog slots.
- Lower TTFT. No planning search. Host restores never hold an admission: they overlap the restored request's first prefill pass layer by layer while other lanes keep decoding (§6.5). Only exact taps split a prefill chunk, and the template's rewrite frontiers no longer do.
- Throughput unchanged. Kernels, page layout, block tables and CUDA Graph keys are unchanged. A flexible tap costs one Device copy of the state image (about 0.25 ms) at a chunk boundary. Cold prefill and decode match Legacy at the same pool size (on an RTX 5090: 10.2k against 10.1k tok/s prefill, 84.6 against 84.9 tok/s decode).
- Better memory efficiency. Shared prefixes are stored once. KV that no snapshot can reach ("dead KV") is reclaimed first. The host pool has no fragmentation and no split between KV and state.
Evidence is taken from the Legacy sources and documents.
- Ownership instead of content. Reuse depends on which owner holds a checkpoint: private
continuation, shared prefix, SessionIndex binding, or one of five checkpoint kinds
(
SessionEndpoint,TurnClosure,ResponseReplay,LongAnchor,SharedStablePrefix). It is also bounded by catalog slots (max_private_continuations,max_shared_prefixes,max_long_anchors_per_continuation). Two sessions with the same 30 k-token system+tools prefix share KV only if a shared prefix was published. Publication must be strictly better than the private-only baseline (resource-scheduling §7.2, §8.3). - Planning cost on the critical path. Materialization runs a bounded heuristic search over complete pressure targets. The Program projects joint post-states, stage peaks and Host allocator geometry for each target (§7.3–§8.7). Every admission boundary has a 250 ms planning allowance. That time sits directly in TTFT for the head request and stalls the worker for everyone else.
- Split chunks. A checkpoint at an arbitrary frontier splits a prefill chunk (
text.cppprefill_split_frontier_), producing a short, inefficient chunk.rewrite_execution_frontiersadds more splits so replay matches the original decomposition. - Host fragmentation and partitioning. Host KV is a variable-extent arena whose feasibility
depends on extent geometry, not free bytes (paged-kv §5.3). Host StateImages are a separate
slot count.
--host-cache-mibresolves a static split between them at startup. - Fragility. Correctness depends on invariants spread across the ResourceManager and Program boundary: capability generations, resource revisions, owner edges, claims, seal windows, borrowed-read ownership, and absolute ResourceResult adoption. Recent fix history (planning races, shared catalog saturation, lease reclaim) shows the maintenance cost.
- Size. About 25 k lines:
context_cache/7.0 k,planning/pressure/recovery/request 6.6 k,transactions/4.5 k,storage/5.2 k, Host arena + prefix identity + resource contracts 1.8 k.
The fundamental constraint does not change. Qwen3.5 is hybrid: 48/64 (27B) or 30/40 (35B-A3B) layers are Gated DeltaNet with a recurrent state that cannot be rolled back. A prefix is therefore reusable only at a position where the complete recurrent state exists. HPC keeps that rule and builds everything else around making such states cheap to create, cheap to store, and exactly addressable.
| Source | Idea adopted |
|---|---|
| vLLM automatic prefix caching | Full blocks keyed by (parent, tokens, extra keys). Reference-counted. Freed blocks go to an LRU queue in reverse order, so tail blocks are evicted first. Evictable blocks count as free capacity. |
| vLLM hybrid/Mamba prefix caching | Recurrent state is cached only at block-aligned positions. A hit is the deepest aligned position that has state. |
| SGLang RadixAttention / MambaRadixCache | Radix tree over token blocks. Recurrent-state entries attach to tree nodes with their own eviction. Leaf-first eviction. |
| SGLang HiCache | Host tier with write-through, batched or kernel-based non-contiguous transfers, and layer-ordered loading. |
| llama.cpp context checkpoints | A few full-state checkpoints per sequence for recurrent/SWA models. |
| LMCache | Layer-wise pipelined KV loading overlapped with compute. |
| GreedyDual-Size-Frequency (Cherkasova, 1998) | Size- and cost-aware eviction with O(1) aging via an inflation value L. |
NInfer-specific additions in the design (1 and 2 are not implemented, §7.2):
- The GDN state tap: chunked GDN already produces the state at every 64-token intra-chunk boundary, so exposing it costs one extra state write.
- Phase alignment by identity-transition padding, so taps land on absolute page boundaries.
- One pinned slab pool for both KV and state.
- Dead-KV accounting: KV that no snapshot can reach has zero reuse value.
Per token, all full-attention layers, K+V including scales (from paged-kv §4.3):
| profile | per token/head | Qwen3.6/3.8-27B (16 FA × 4 KV heads) | page (64 tok) | 35B-A3B (10 × 2) | page |
|---|---|---|---|---|---|
| BF16 | 1024 B | 64.0 KiB | 4.00 MiB | 20.0 KiB | 1.25 MiB |
| INT8-G64 | 528 B | 33.0 KiB | 2.06 MiB | 10.3 KiB | 0.64 MiB |
| FP8-row256 | 516 B | 32.3 KiB | 2.02 MiB | 10.1 KiB | 0.63 MiB |
| K8V4 | 402 B | 25.1 KiB | 1.57 MiB | 7.9 KiB | 0.49 MiB |
| NVFP4-G16 | 288 B | 18.0 KiB | 1.13 MiB | 5.6 KiB | 0.35 MiB |
MTP adds one layer's worth of pages to the bundle (+1/16 for 27B). A DFlash draft with full-attention layers adds its own BF16 pool.
Fixed per-sequence state (qwen3_5-model "State and numerical boundaries", dflash "Backend storage"):
| component | 27B | 35B-A3B |
|---|---|---|
GDN recurrent, FP32 [128,128,Hv] × GDN layers |
144 MiB | 60 MiB |
conv history, (2·16·128 + Hv·128)·3 BF16 × GDN layers |
2.81 MiB | 1.41 MiB |
| continuation hidden | 10 KiB | 4 KiB |
| DFlash2 local rings (official config) | 40 MiB | config-derived |
| total (DFlash2) | 186.8 MiB (matches the 195,897,344 B Host StateImage in README) | — |
One 27B snapshot equals about 91 INT8 pages (≈5.8 k tokens of KV), 166 NVFP4 pages, or 47 BF16 pages. Snapshots dominate storage cost. Their placement is the main policy decision.
These are the design's planning values. The implementation ranks and values with the Engine's context-cost coefficients (§6.2, §11.4). On the measuring machine, whose GPU link runs at PCIe 5.0 x8, restores move 24–27 GB/s.
| operation | value used for design |
|---|---|
| HBM D2D copy of one 186.8 MiB image (read+write) | ≈ 0.25 ms |
| PCIe 5.0 x16 pinned H2D / D2H (practical) | ≈ 50 GB/s each direction |
| H2D snapshot | ≈ 3.9 ms |
| H2D 100 k tokens INT8 KV (3.38 GB) | ≈ 68 ms |
| Prefill, 27B, ~121 k context (README median) | ≈ 4.2 k tok/s, so 100 k tokens ≈ 24 s |
| GDN tap write (144 MiB) inside a chunk (zero-split tap, not implemented) | ≈ 0.09 ms |
| Write-through rate at 4.2 k tok/s INT8 prefill | ≈ 140 MB/s (0.3 % of PCIe) |
Consequences:
- A host-resident prefix is about 300× cheaper to restore than to recompute. The host tier carries most of the reuse value. The device tier is a latency optimisation.
- A state snapshot is cheap to create (a Device copy of the image takes about 0.25 ms) and cheap to move (4–8 ms over PCIe). Snapshots can be dense. The limit is host bytes, not time.
Frontend (gateway threads)
tokens, media digests, boundary hints, per-block chained hashes
│ PreparedPrompt
▼
┌──────────────────────── Engine worker (single mutation owner) ─────────────────────────┐
│ Scheduler (unchanged FIFO) ─ HybridResourceManager (the ResourceManager surface) │
│ │ quote / reserve / progress / reclaim / prefetch / terminal settlement │
│ ▼ │
│ Program │
│ ├─ PrefixIndex (model-agnostic, src/runtime/prefix_cache/) │
│ │ block tree ─ nodes: 64 tokens + extra key, device page ids, host slab, pins │
│ │ snapshots attached to nodes (+ optional tail) │
│ │ device LRU (tail-first) host eviction (superseded, GDSF, dead KV) │
│ │ TapPlanner CacheCostModel (prefill and H2D costs) │
│ ├─ Qwen3.5 binding (src/models/qwen3_5/program/prefix/) │
│ │ HybridPrefixCache: Host slab pool, residency, restore and write-through copies │
│ │ admission, taps, block commit, finish; StateImage parts; persistence │
│ ├─ Device typed KV pools (unchanged layout) + active block tables (unchanged) │
│ ├─ Device state pool: C active slots + D snapshot slots │
│ └─ streams: restore (dedicated), write-through (the Program's transfer stream) │
└────────────────────────────────────────────────────────────────────────────────────────┘
Ownership against AGENTS.md boundaries:
- Core owns physical primitives: the paged KV cache's record-addressed Host copies, which copy runs of consecutive pages and slabs as one strided transfer per plane.
- Ops gain nothing: the zero-split GDN tap (§7.2–§7.3) and a KV page transfer kernel (§11.2) are not implemented. Taps are Device copies of the committed StateImage.
- Program owns the whole cache: exact identity, physical residency, refs, eviction, taps.
There is no cross-layer logical/physical split, because the cache's policy decisions are
local physical facts. In Hybrid mode
ResourceManager,MaterializationPlanner,SharedCapturePlanner, portfolio value and the target/pressure machinery are not constructed or called; they remain the Legacy-mode implementation. - Runtime keeps the scheduler and the common request contracts.
HybridResourceManagerpresents the ResourceManager surface to the Engine core (§8.2), andruntime/prefix_cache/holds the model-agnostic index, tap planner and cost model. - Frontend supplies tokens, media digests, boundary hints and block hashes. It no longer produces cache "candidates", evidence flags or session keys for the cache.
Block b of a prompt covers absolute positions [64b, 64b+64).
BlockKey(b) = ( parent NodeId,
tokens[64b .. 64b+63], // TokenId, exact
extra = media_key(b) ) // 0 when no Vision item overlaps block b
media_key(b) = xxh3_64 over, for each Vision item whose token span overlaps block b and every
earlier item (Vision changes later MRoPE positions via rope_delta):
(content_digest[32], modality, grid, span begin/length)
lookup_hash(b) = xxh3_64(lookup_hash(b-1) || tokens || extra), lookup_hash(-1) = seed
lookup_hashonly selects a child in the parent's child map. A hit always comparestokensandextraexactly. A forced hash collision must not produce a false hit (tested, §13.1).- Everything else that affects KV or state is fixed for the Engine lifetime: model artifact, KV profile, speculative backend, RoPE/YaRN options. None of it is part of the key. Positions are a function of tokens and media, so they are covered by the key.
- Thinking mode, templates, tools and roles are all rendered into tokens, so they need no key field.
- The Frontend computes
lookup_hashfor every full block during preparation, off the worker, and stores it inPreparedPrompt::block_hashes.
The records below list the state the design keeps; PrefixIndex
(src/runtime/prefix_cache/prefix_index.h) holds it in its own layout.
struct BlockNode { // host-side; stored in a slab-allocated arena
NodeId id; uint32_t generation; // generation-checked handles
NodeId parent;
uint32_t depth; // block index b; the node covers [64b, 64b+64)
uint64_t lookup_hash, extra;
TokenId tokens[64]; // exact tokens (256 B)
ChildMap children; // small open-addressed map keyed by lookup_hash
// Residency
int32_t device_page[kMaxPools]; // per enabled pool; -1 when not device resident
int32_t host_slab; // -1 when not host resident
Residency device_state; // Absent | Resident | Filling(event)
Residency host_state; // Absent | Resident | Filling(event)
// Liveness
uint32_t active_refs; // active sequences whose table maps this node
uint32_t restore_pins; // in-flight transfers reading/writing this node
uint32_t live_snapshots_below; // snapshots whose path includes this node
uint64_t last_access_tick;
IntrusiveLink device_lru; // present iff active_refs==0 && device Resident
IntrusiveLink host_dead; // present iff live_snapshots_below==0 && no refs && host Resident
SnapshotId first_snapshot; // snapshots anchored here (intrusive list)
};A node is only inserted when all enabled pools have committed all 64 positions of the block (§7.6). A node may be device-only, host-only or both. A node with neither copy is deleted, along with its subtree (§9.4).
struct Snapshot {
SnapshotId id; uint32_t generation;
NodeId anchor; // node of the last full block below frontier (root if F<64)
uint32_t frontier; // F: the snapshot state is after tokens [0,F)
uint8_t tail_len; // F - 64*(anchor.depth+1), 0..63
TokenId tail_tokens[63]; // exact; only tail_len are meaningful
// Tail KV (only when tail_len>0): one private, immutable page bundle
int32_t tail_device_page[kMaxPools]; int32_t tail_host_slab;
// State image
int16_t device_slot; // index into the D device snapshot slots, or -1
SlabList host_slabs; // ceil(image_bytes / slab_bytes) slabs, or empty
Residency device_state, host_state;
uint32_t restore_pins;
SnapshotKind kind; // Tap | Endpoint | OutputBoundary | Boundary (§9.3)
bool superseded; // §9.3
// Host eviction (GDSF)
double gdsf_h; uint32_t freq;
uint64_t last_hit_tick;
};- Tap snapshots have
tail_len == 0: frontier on a page boundary, KV fully in the tree. - Endpoint snapshots are taken at request finish or cancel at an exact frontier. They own the partial last page, which moves from the finishing sequence without a copy.
- A snapshot is valid iff every node on
path(anchor), the tail bundle (if any) and the image each have at least one resident copy (device or host). Validity is maintained eagerly: removing the last copy of a node deletes its subtree's snapshots (§9.4). - There is at most one snapshot per
(anchor, tail tokens). Publishing a duplicate is a no-op that refreshes recency.
- Device KV pools: unchanged geometry, plane order, block-table matrix and consumer views
(paged-kv §3–§4, §6.4, §10–§11). A page group is in exactly one state:
Free,Active(owned by one sequence, private),Cached(owned by a node or snapshot tail), orFilling(transfer destination).available = free + evictable_cached. - Main pool size: automatic sizing keeps the capacity curve. In Hybrid mode the upper clamp
M_max = C·Lis removed (only the int32 token representation bounds it): pages beyond the active leases are cache-only device capacity.ninfer-servedefaults--kv-capacitytoautoin Hybrid mode. Active admission still requiresM ≥ max(L, C). - Device state pool:
Cactive slots plusD = device_snapshot_slotssnapshot slots in the existing slot-indexed layout (ssm_states [128,128,Hv,C+D]per layer, and so on). Snapshot slots are also the staging destination for taps (§7.4). DefaultD = C + 1with a Host tier (one resident snapshot per live conversation plus one staging slot) andC + 2without one. - HostSlabPool: one pinned allocation of
host_cache_bytes, divided into slabs. A slab holds one block bundle: the Main page packed with its Host page layout, then the backend page at the next 4 KiB boundary;slab_bytesis that record rounded up to 4 KiB. A snapshot image usesimage_slabs = ceil(image_bytes / slab_bytes)slabs, not necessarily contiguous (the image's packed byteolives in slabo / slab_bytes), and a snapshot with a tail one slab more. The free list is an index stack, O(1). There is no fragmentation and no geometry check: capacity isfree_slabs. A nonzero budget smaller than one snapshot plus one block is rejected at startup. The pool is pinned in chunks of at most 4 GiB of whole slabs (one very large pinned allocation can fail or stall under WDDM); a slab never crosses chunks. A strided copy run joins consecutive pages only while their slab records share a chunk and advance by one pitch no larger than the device's maximum copy pitch. Any two records form a candidate run, so records in different chunks, or gigabytes apart in one, must be split explicitly. A 52 GB Host tier otherwise produced an invalidcudaMemcpy2DAsyncthat aborted the server.
With persistent_file (--prefix-cache-file), the Host tier outlives the process. ninfer-serve
resolves the path to an absolute one at launch. It rejects a directory, a missing parent
directory, or --host-cache-mib 0, so an unusable location fails before any caching:
- Save, in the worker's orderly stop, which
Engine::stop()or the Engine's destruction starts.ninfer-servecallsstop()on a confirmed Ctrl+C (a second press within 5 s,serve/stop_control.h), Ctrl+Break,SIGTERMor console close. Running and queued requests then fail as Unavailable at the next unit boundary instead of holding the stop until they finish; the worker answers them before the cleanup that saves, so no answer waits for the save. One more Ctrl+C exits at once without saving: through the sharedPrefixCacheSaveControl(HybridPrefixCacheOptions::persistent_save) the save stops before its next slab or before the rename, deletes its temporary file and ends, andninfer-servewaits up to 2 s for that before exiting. A save not yet begun never begins, and one still in the Device drain is not waited for. The console-close handler blocks whilemainunwinds, but Windows ends the process about 5 s after a close, which leaves the temporary file until the next save. Either way an unfinished save leaves the previous file in place.Program::shutdown_cleanupreleases every lane first, so requests still in flight write their committed blocks through, and saves before the cleanup drops the cache. Every Host write lands, then every retained (not superseded, §9.3) Host-resident snapshot whose anchor path is Host-resident, and exactly those paths, are written parents first with their slab bytes (a temporary file renamed into place). Dead KV, superseded snapshots and Device-only content are not saved. A worker that ended on a fatal error saves nothing, so the previous file stays in place. A worker recovery (OOM or recoverable logic error) empties the cache like every cleanup, so a later save holds only what was cached after it. - Load, at Engine construction before any request: the file is used only when its fingerprint
(absolute artifact path, size and modification time, prefill signature, KV storage, speculative
backend, RoPE scaling, and the product binary's identity — build id plus executable size and
time, so any rebuild invalidates it) and its Host geometry equal the running Engine's, and its
size matches its tables. The file (format 3) holds the header, the block table, the snapshot
table and only then the slab bytes, so the loader chooses before it reads any slab. A Host tier
that holds the whole file restores all of it. A smaller one restores whole snapshots, greedily
by the GDSF density of §9.3 without its time-dependent base — (1 + hits) × the prefill a
restore saves from the root, per byte of image, tail and anchor path — each with the path
blocks it needs and no others, and reads only those slabs (
plan_host_restore); the startup log then warnsprefix cache partly restoredwith the kept and saved counts and the Host tier the file needs. A tier that holds none of the snapshots restores nothing, since a restore keeps only the blocks its snapshots resume through; the log then warnsprefix cache not restoredwith the tier the file needs, and that the save at shutdown replaces the file. Entries are rebuilt Host-only, parents before children, without evicting anything, and their first requests restore blocks and images through the ordinary Host restore path. A damaged file loads nothing. - The startup log shows the read of an accepted file as a progress phase (
loading prefix cache, thenprefix cache read | bytes | time | rate), then reports what was restored or why nothing was; the shutdown log reports the save.
PrefixCache::match(prompt) runs on the worker:
- Walk from the root. For block
b < floor((n-1)/64), find the child byblock_hashes[b], then compare tokens and extra exactly. Stop at the first miss. This gives the matched pathN_0..N_{k-1},kfull blocks. - Collect candidate snapshots: every snapshot anchored on the matched path (walk each node's
list) whose tail tokens equal
prompt[64·(anchor.depth+1) .. F), withF ≤ n − 1. At least one prompt token is always prefilled, so logits, continuation hidden and MTP/DFlash bridges come from the normal path. No full-hit path exists. - Frontiers inside a Vision token span are not valid resume points. Tap placement never creates them, and match rejects them defensively.
Complexity is O(k + snapshots on path). A 240 k prompt is 3,750 hash probes and exact 256 B compares: tens of microseconds.
Candidates: root, the deepest valid snapshot s*, and the deepest valid snapshot whose image and
path are fully device resident, s_dev. At most three. For each:
cost(s) = restore_seconds(s) + prefill_seconds(base=F_s, tokens=n-F_s)
restore_seconds = (host_path_bytes + host_image_bytes + host_tail_bytes) / measured_h2d_bw
+ per_batch_latency
prefill_seconds is the existing calibrated machine model (context_cost.cpp: linear tokens
plus B·S + S(S+1)/2 attention pairs, keyed by prefill_signature). Pick the minimum. Ties
favour fewer transferred bytes. This is O(1) and deterministic, and the cost model only ranks
feasible choices.
need_pages(s) = host_only_path_blocks(s) // H2D destinations (shared once restored)
+ (s.tail_len>0 ? 1 : 0) // private COW copy of the tail
+ growth_pages(prompt n, base F_s) // existing entitlement: remaining prompt +
// bounded output window + per-lane cushion
need_state_slot = 1 active slot (a lane implies a free active slot)
The request is admissible with s iff need_pages(s) ≤ available_pages, where evictable pages
exclude pages pinned by this admission's own path. If not, try the next cheaper-to-materialise
candidate: s_dev, then root.
- Root infeasible because active entitlements hold the pages:
TemporarilyBlocked. - Root infeasible even on an empty cache:
PermanentlyInfeasible.
This is the same classification as today, computed by arithmetic.
Backfill proof (engine-architecture §5.2) uses the same arithmetic:
need(borrower) + need(head root) ≤ free + all_evictable + Σ donor entitlements released.
Hybrid mode does not issue it: a blocked FIFO head is never overtaken.
A proof with this arithmetic needs a growth reserve: without one, a borrower can take the pages a long answer would grow into, and that answer then ends early (bounded completion, paged-kv §6.2). With a 16K-token reserve per active sequence it was safe, but it moved latency instead of removing it: queued short requests finished in 1.6 s instead of 35 s, while the long request already decoding slowed by about their prefill time and the blocked long head started that much later. A version worth adding has to bound the borrower's cost to the donor, keep the donor's decode rounds running while a borrower prefills, or suspend a borrower to the Host tier when a donor needs its pages (active-request preemption, a product change).
Every unpinned Device-resident cache entry (node or snapshot tail) owns its page bundle exclusively, so evicting one returns exactly one page to each pool and the arithmetic is exact. Exact-frontier tap tails are copied into their own pages for this reason (§7.4). Candidates with a block another admission is still filling are skipped.
Admission is the Engine's materialization transaction (hybrid_reserve_materialization, then
progress_context_transaction until Published). The first progress step stages it
(hybrid_stage), in the same Engine call as the quote:
- Re-validate the quoted snapshot and path. Pin every path node up to
F_s(removed from the device LRU) and pins. - Evict from the device LRU until the pools hold every page the admission needs, then reserve
them:
entitlement − F_s/64for the fork, plus one per host-only path block and host-only tail. Reserve the lane's StateImage destination slot. - If anything is host-only, open a restore batch on the dedicated restore stream (ordered after
the compute stream's queued work, since destinations may have been read by earlier kernels):
- host-only blocks: materialize destination pages from the reservation, adopt them as cache
references, mark the nodes
Filling, and copy the slabs in runs of consecutive pages and slabs; - host-only tail: the same into a cache-owned page (the snapshot's tail is Device-resident again afterwards);
- host-only image: copy the image slabs straight into the reserved destination slot. The batch is enqueued in the order a forward pass reads it (§6.5).
- host-only blocks: materialize destination pages from the reservation, adopt them as cache
references, mark the nodes
Activation (hybrid_activate) then builds the lane at once, without waiting for the copies: the
reservations return to the pools and the page-prefix fork takes them back; path pages are shared; a
partial tail is D2D-copied into a private page; the state is reset, D2D-copied from the snapshot's
device slot, or restored in place. The lane takes over the path pins and the snapshot pin is
dropped; a restore batch holds its own pins on the nodes and snapshot it copies until it lands.
Admission never evicts anything that another sequence has pinned. Evictions come from the device LRU (§9.1) and complete synchronously: dropping a device copy is bookkeeping, since the data is already backed up or deliberately discarded.
A restore batch never holds its admission. submit_restore enqueues it on the restore stream in
the order a forward pass reads it, recording an event after each part:
- the prelude: the snapshot tail bundle, every restored block's backend page, and the image's continuation hidden and DFlash local state;
- then, for each model layer in forward order, that layer's state: a full-attention layer's KV planes of every restored block, or a linear-attention layer's conv and recurrent state.
land_restore makes the compute stream wait for the prelude (activation copies the tail and the
MTP bridge reads the backend pages and hidden) and hands the lane a ticket for the per-layer
events. The lane's first prefill pass waits for each layer's event just before that layer
(TextContext::run_layers), so it computes the early layers while later ones are still arriving;
later passes are stream-ordered behind it. The chunk function itself looks the events up by
ticket (PrefillContext::take_layer_ready) just before its pass, because poll() retires a
landed batch and recycles its events, and program code between chunks polls: a batch already
retired has landed, so the pass has nothing to wait for. A lane released before its first pass makes the compute
stream wait for the whole batch, because the state slot it returns may still be a destination.
The index keeps the batch's nodes and tail Filling, and the batch keeps them and its snapshot
pinned, until poll() (every admission quote, and the block publication after each KV commit)
or drain() sees the last event. So no eviction,
dead-KV reclaim or Host write can free a slab or page a copy still reads or writes. Other admissions
skip Filling candidates for that short window.
Consequences:
- A Host revisit no longer waits for the host to notice the copy finished: under concurrency that was the rest of the other lane's decode round (~12 ms measured), and with an idle Engine a timer tick.
- The copy overlaps the restored request's own first prefill chunk: for a long context, the prefill of new tokens hides most of a multi-GB restore.
- Other lanes' decode rounds queue behind that prefill chunk as for any prefill. When the chunk is much shorter than the copy (a few new tokens after a very long restored context), they can wait up to the copy's remaining time once.
Failure: a CUDA error in a restore job is Engine-wide, as today. A request cancelled while staged (before activation) waits for the restore stream, returns every destination and reservation, and releases its pins.
When the Device pool cannot hold every conversation, each admission restores most of its path from the Host tier (the production agent log of 2026-09-26: 2.59 TB, a median 2.7 GB per turn). For a short turn the first prefill pass waits for those copies, and other lanes' decode queues behind it. The FIFO head usually waited seconds to minutes for a lane or for pages before that, so its restore can happen during the wait instead.
- When the head stays blocked (hybrid mode does not backfill, §6.3), the Engine asks the Program to
prefetch for it. The Program matches the prompt as a quote would, takes the source admission
would choose, and copies up to 256 of its Host-only path blocks into Device pages. They are
ordinary cached blocks:
Fillinguntil the batch lands, then unpinned at the MRU end of the device LRU. Admission later finds them Device-resident and restores only the rest. - Pages come from the free pool and from host-backed cached blocks, least recently used first. The head's path is pinned meanwhile. A prefetch never drops a block's last copy, never waits for a transfer, and never runs beside an open admission or another prefetch. Active leases may evict the prefetched blocks like any cache.
- One batch is about 550 MB for 27B INT8 KV (about 20 ms of PCIe). The Engine retries for the same head only while the last attempt copied blocks or the spare room grew, because matching walks the whole prompt path.
- A quote whose candidates cross a landing prefetch waits for it (
settle_prefetch) instead of skipping those candidates for a shallower source. A lane's own restore batches are never waited for synchronously (§6.5).
In the production log about 57 % of the restored bytes could have been copied this way. The rest did not fit beside the active sequences while the head waited.
plan_taps(n, base F_s, hints, existing frontiers, Vision exclusions) returns taps sorted by
position, each {p, placement} with F_s < p ≤ n − 1, p not strictly inside a Vision token span
(a candidate inside one moves to the span start), and no existing or already-planned snapshot at
or near p on this path.
Candidates, in priority order:
| priority | source | placement | rationale |
|---|---|---|---|
| 1 | explicit markers (OpenAI breakpoints, Anthropic cache_control, C++ PromptInput markers) |
exact | the client names reuse points |
| 2 | start of the final assistant generation opener | exact | the next turn re-renders the history after it (for example, stripped thinking) |
| 3 | end of tools, end of the leading System/Developer block | exact | compaction and new sessions sharing the preamble |
| 4 | n − 1 ("prompt tail") |
flexible | regenerate / retry of the same prompt |
| 5 | geometric ladder: for k = 0,1,…, n − G·2^k (G = tap_ladder_tokens), snapped back to a message boundary within the minimum gap |
flexible | edits deep in history. Spacing grows with distance, so there are about log2(n/G) taps |
- Exact taps split the prefill chunk at
p(one more forward pass each) and snapshot there. Semantic boundaries are where later prompts diverge, so the exact position is worth the split. - Flexible taps never split: the snapshot is taken at the end of the chunk that reaches
p, or at the start of the prompt's final chunk whenplies in it (the prompt tail always does). A chunk boundary costs nothing beyond the snapshot copy. - Semantic boundaries (generation opener, structural) closer than 64 tokens form one cluster and only its earliest position is kept: a short user turn puts the system-block end next to the opener, and a snapshot at the system-block end serves new conversations sharing it while the next turn recomputes fewer than 64 tokens.
- A snapshot serves every prompt that diverges after it, so proximity only makes a later
candidate redundant: a non-explicit candidate is dropped when an accepted tap, an existing
snapshot on the path or the base lies at most 64 tokens before it. Ladder candidates keep
tap_min_gap(defaultmax(1024, chunk)) on both sides instead. The opener therefore absorbs the prompt tail in chat traffic, leaving one split per request. - Only markers with explicit evidence (a client-named breakpoint) are priority 1. Protocol-automatic
markers (OpenAI default caching, Anthropic automatic
cache_control) are planned as structural boundaries, but they mark the conversation's latest turn rather than a prefix conversations share. - Explicit and structural taps (not protocol-automatic ones) are published as
Boundarysnapshots: conversations may share them, so a lineage moving past one supersedes it only while no other conversation has continued from it (§9.3). A cluster keeps this from any member it drops. - A request that resumed from an endpoint snapshot proves its client echoes generated turns token
for token (agent loops, preserved reasoning), so its own endpoint serves the next turn and the
generation opener is not tapped. Measured on a tool-calling agent trace (27B NVFP4, INT8 KV,
--preserve-thinking), the opener split cost about 15 ms of a ~70–130 ms turn TTFT. Requests that resumed from a tap keep the exact opener: their client re-renders history. - Hints are the Frontend's existing
message_boundariesplus the structural boundaries it already computes. - At most
max_new_taps(default 8 with a Host tier, 2 without) taps are kept per request.
Tap placement is a function of the prompt, so it is deterministic. Endpoint snapshots are exact (§7.7).
Exact taps are the only prefill splits in Hybrid mode. The chat template's rewrite execution
frontiers (after each assistant header, after <think>, after the reasoning close) split Legacy
prefill for its rewrite checkpoints and execution provenance. Hybrid captures nothing there, so a
Hybrid lane ignores them. Each split is a whole extra pass over the model. A short chat turn used
to prefill as four passes (reuse point, opener, <think>, reasoning close). Now it takes two (to
the opener, then the rest), or one when the opener is dropped, unless a client breakpoint or
structural boundary falls inside the new tokens.
Not implemented (§7.2 and §7.3 together form the zero-split tap). The implemented tap copies the lane's committed StateImage at a chunk boundary (≈0.25 ms D2D), so only exact taps pay a split. Build this only if measurement shows the remaining exact-tap split matters.
The chunked GDN kernel's intra-chunk grid is relative to the call start. To make every internal
boundary an absolute multiple of 64, the Op gains a phase = base % 64 parameter:
- The kernel treats the call as if it were left-padded by
phasevirtual tokens withk = 0, v = 0, g = 0, β = 0. For such a token,α = exp(0) = 1exactly anddelta = 0 · (v − αS·k) = 0exactly, soS ← 1·S + outer(0, k) = Sbit-exactly. - Padded outputs are not written.
- Padding is by predicated loads, with no workspace copy.
phase = 0is the current behaviour, bit for bit.
Prefill chunks remain --prefill-chunk tokens long. The first chunk after a non-aligned base
is chunk − phase tokens long, so every later chunk starts page-aligned. This is one boundary
change in TextContext::prefill. In Hybrid mode no prefill_split_frontier_ or
rewrite_execution_frontiers split is requested (both remain for Legacy).
Not implemented (the design of the zero-split tap, with §7.2).
For each prefill unit with taps T_u = T ∩ (unit_begin, unit_end], |T_u| ≤ taps_per_unit
(default 4, bounded by free snapshot slots):
- GDN state tap:
gated_delta_net(..., StateTapSet taps). For each tapj, the kernel writes the FP32 state after absolute positionp_jfor every value head intosnapshot_slot[j].ssm_state[layer].- Stores use
st.global.cs(streaming) to avoid displacing prefill working sets in L2. - Oracle: the FP64 naive recurrence evaluated to
p_j(op-development §numerical). - Exactness criterion: the tapped state is bit-identical to the final state of a separate
call on the same inputs ending at
p_jwith the same phase. Same chunk grid, same arithmetic.
- Stores use
- Conv tap: per GDN layer, copy the
kernel_width − 1 = 3pre-conv projected columns[p_j − 3, p_j), taken from the current unit's projection output or the previous conv history whenp_j − unit_begin < 3, into the slot's conv history. Exact copy, exact oracle. - Hidden tap: the continuation hidden at column
p_j − 1, stored for completeness. It is never used for a full hit (§6.1). - DFlash/DFlash2 ring tap: after the unit's context K/V materialisation, and before the
ring is overwritten, assemble
slot.ring = ring(positions [p_j − S, p_j)). Each position comes from the pre-unit ring if< unit_begin, otherwise from the unit's freshly materialised K/V. The materialiser writes this unit's context K/V to a unit staging buffer first; the ring commit and the tap gathers both read from it. Exact copy.- The DFlash Full (paged) pool is committed through the chunk at unit commit, so its pages belong to the tree like Main pages.
- MTP: MTP KV is paged. Tap snapshots carry no MTP state. A resume performs the Program's existing MTP bridge from the resumed frontier, as for any Program-resumed frontier today.
If no snapshot slot is free when the unit starts, the tap is skipped and counted
(taps_skipped_no_slot). In practice D2H of a slot (≈4 ms) finishes long before the next unit
(≥0.3 s).
At the chunk boundary the tap takes a staging device snapshot slot, copies the lane's committed
StateImage into it (including the continuation hidden of column p − 1, which prefill keeps while
taps remain) and waits as a pending tap of the lane. It is published as soon as the blocks it
anchors on are committed tree nodes:
- the anchor
p/64 − 1, which for an MTP backend (one token behind the text frontier) commits only after the next chunk even whenpis page-aligned; - for
p % 64 ≠ 0, blockp/64as well, whose firstp % 64columns are copied into a cache-owned tail page, so every cache entry keeps an exclusive page (§6.3).
Publication creates a Snapshot{Tap} with device_slot = the staging slot and enqueues D2H
write-through of the image (and tail) into host slabs; live_snapshots_below++ along the path.
Once write-through completes, the slot becomes an evictable device snapshot (§9.2). A finishing
lane publishes its remaining pending taps from its own last page, or drops them.
When F_s < 64k, blocks [F_s/64 .. k) already exist in the tree but are recomputed, because the
recurrent state must be rebuilt. The sequence writes them into its own private pages. At block
commit (§7.6), each such block finds the existing node, so the private page stays private
(Duplicate) and is freed at finish. The tree is not modified. KV-write elision, which maps the
existing pages and discards those writes, is an optional optimisation (§12.4).
After any commit that advances every enabled pool's committed frontier across a block end
64(b+1):
- If
child(N_{b−1}, key(b))exists with a Device copy: the sequence's page stays private (Duplicate). If it exists host-only, it adopts the sequence's page as its Device copy. Otherwise insert a node owning the sequence's page. The node stays pinned for this sequence. - D2H write-through of the lane's Device-only nodes is enqueued in one batch when the lane releases its path (finish, abort or cancel), before the nodes become evictable. Batching coalesces runs of consecutive pages and slabs into one strided copy per plane, and pinned nodes need no backup yet.
The block-table row is unchanged, so no GPU work is needed.
Only committed tokens count. Speculative provisional lead, rejected columns and MTP/DFlash lag are excluded by requiring every enabled pool's committed frontier (paged-kv §9.1).
Decode blocks become cacheable the moment they fill. A later request that shares a long generated output, such as an agent re-sending the assistant turn, can resume from the endpoint snapshot (§7.7).
At terminal settlement (Finish, or Cancel after at least one committed unit):
- If the request is cache-enabled, every pool has committed the frontier (the backend up to its
own frontier,
F_end − 1for MTP) andF_end − (deepest snapshot on path) ≥ 64:- The active StateImage itself becomes the snapshot image (frozen, no copy) in a device snapshot slot; if none is free, one is evicted (§9.2), otherwise the endpoint is skipped.
- The partial last page (
F_end % 64 ≠ 0) moves to the snapshot as its tail. There is no COW, because the writer is terminating. The backend page keeps its own committed extent. - Publish
Snapshot{Endpoint}and enqueue D2H write-through of the image and tail.
- For every pinned node:
active_refs--. A node that reaches 0 goes to the device LRU tail (§9.1), inserted in reverse block order, so deeper blocks are evicted before shallower ones. - Free
Duplicateand growth pages. Release the active state slot.
Disabled requests (Serve warmup, --no-prefix-reuse) never insert nodes or snapshots.
Their pages are freed at terminal.
struct HybridAdmissionQuote { // hybrid_quote(): the selected source, exact at admission
runtime::Readiness readiness; // Ready | TemporarilyBlocked | PermanentlyInfeasible
runtime::LaneId destination;
runtime::RequestPlanSummary summary; // carries the reuse frontier, reported as cached_tokens
std::shared_ptr<detail::HybridQuoteImpl> impl;
};
[[nodiscard]] HybridAdmissionQuote hybrid_quote(const PreparedPrompt&, const RequestBasePlan&,
runtime::LaneId destination);
[[nodiscard]] runtime::ContextTransactionReserveStatus
hybrid_reserve_materialization(HybridAdmissionQuote&&, PreparedPrompt&&,
runtime::CancellationFlagView); // §6.4; progressed by
// progress_context_transaction
[[nodiscard]] std::uint32_t hybrid_reclaim_device_kv(std::uint32_t main_pages,
std::uint32_t backend_pages); // §9.5
[[nodiscard]] std::optional<std::uint32_t> hybrid_prefetch(const PreparedPrompt&,
const RequestBasePlan&); // §6.6
[[nodiscard]] std::uint32_t hybrid_prefetch_room() const noexcept;
void set_hybrid_cost(const runtime::prefix_cache::CacheCostModel&); // §6.2
void set_hybrid_coalesce_wait_limit(double seconds); // §12.2
[[nodiscard]] HybridCachePersistence attach_hybrid_cache_file(...); // §5.5
[[nodiscard]] std::optional<HybridCachePersistence> hybrid_shutdown_save() const;
[[nodiscard]] HybridPrefixCacheStats hybrid_stats() const noexcept;
// Unchanged: advance_prefill, decode, append_forced_tokens, commit, abort_pending, finish, abort.Not called in Hybrid mode (retained for Legacy): inspect_admission, seal_identity,
begin_pressure_planning, shared_capture_split_prefill_work, start_resource_transaction,
finalize_context_transaction, inspect_capture, checkpoint_recovery_work,
begin_capture_pressure_planning, shared_capture_matches, skip_capture,
reserve_active_capture*, retained_device_kv_pages, release_continuation,
release_shared_prefix, resource_revision. The Hybrid methods throw std::logic_error in Legacy
mode and vice versa.
The Engine selects one of two resource managers at construction from ContextCacheMode. Legacy
keeps ResourceManager and the planners. Hybrid constructs HybridResourceManager, which presents
the same surface to the Engine core, so scheduling, prefill, decode and commit orchestration are
shared by both modes; it tracks lane ownership and forwards admission and terminal settlement,
while retention policy lives in the Program's index.
- Admission is the ordinary context transaction: quote, reserve, then
progress_context_transactionuntil the started sequence is published (§6.4). Restores never hold it (§6.5). ensure_base_planbuilds no candidates, and noCaptureOfferis produced: taps are internal to prefill.- Device KV lease settlement evicts from the device LRU (
hybrid_reclaim_device_kv) instead of releasing owners. - While the FIFO head stays blocked, the Engine asks for a prefetch of its Host-only path (§6.6).
HybridPrefixCacheStatsfeedrecord_prefix_selectionand the request-log fields.
PreparedPromptgainsblock_hashesandtap_hints(sorted{position, priority}), filled in both modes (cheap). Hybrid mode ignoresPreparedContextCachesession key, retention and opportunities, andPromptIdentityrewrite fields; Legacy ignores the new fields.ContextCacheHintsis unchanged. In Hybrid mode:- explicit-evidence markers map to priority-1 tap hints, automatic protocol markers to
Automatichints (planned as structural, published as ordinary taps); session_key,retention,allow_engine_automatic_shared_prefixesandupdate_session_indexare ignored. Content addressing finds session chains without them.
- explicit-evidence markers map to priority-1 tap hints, automatic protocol markers to
- External protocol behaviour is identical in both modes:
- OpenAI
prompt_cache_*and breakpoints, and Anthropiccache_controlvalidation, limits (four distinct breakpoints) and error cases are unchanged. - In Hybrid mode, breakpoints mean "tap here" (priority 1). Protocol write-policy modes
(
mode:"explicit", Anthropiccache_control-only requests) do not suppress automatic taps: taps are cheap and content-deduplicated, so markers can only add reuse. cached_tokensandcache_read_input_tokensreportreuse_tokens. The streamingmessage_starttiming is unchanged, because the quote fixes the value at admission.
- OpenAI
- One intrusive list of
Cacheddevice pages whose owner has no refs and no pins: nodes withactive_refs == 0 && restore_pins == 0, and snapshot tails withrestore_pins == 0. - On a hit, touched nodes move to the MRU end.
- On release, nodes are appended in reverse block order.
- Allocation pops from the LRU head:
- Host-backed entries (host
Resident): drop the device copy. Free. - Unbacked entries, which occur only when the host is disabled, full, or write-through is still filling: the node loses its last copy, so apply §9.4.
- Host-backed entries (host
Two lists are kept, backed and unbacked, and backed entries are always consumed first.
Slots hold at most D images. A sequence about to snapshot past its previous snapshot (the one
it resumed from, or its previous tap in the same prompt) supersedes it first (§9.3), so the slot
comes from its own lineage when it can. A tap or endpoint takes a slot in this order:
- a free slot;
- a slot owned by a superseded snapshot, oldest supersession first; the snapshot keeps its Host copy or, without one, is deleted;
- a slot owned by a Device-only snapshot (no Host copy: the Host tier is full, disabled or
evicted it): the one with the lowest GDSF priority (§9.3), and only when the new snapshot's
estimated priority (no hits, and the blocks since the sequence's previous snapshot its own) is
at least as high. The snapshot is lost, and
Lrises to its priority; - the least recently hit Host-backed snapshot's slot, which loses only its Device copy.
Otherwise the tap or endpoint is skipped. A Device-only owner's slot is its last copy, so value decides there rather than recency: least-recently-hit eviction let a short request's snapshots push out the snapshot a long conversation resumes from, and taking Host-backed slots first left owners that Host eviction had made Device-only in their slots for good.
Supersession. In a conversation the next request resumes from the newest snapshot of its
lineage, not from the one the current request resumed from. When a sequence is about to publish a
deeper snapshot on its path, the snapshot it resumed from becomes superseded, and so does the
previous tap it captured in the same prompt when the new snapshot is a tap, before the new snapshot
takes a Device slot or Host slabs: only a request diverging before the newer snapshot (a retry, an
edit) can still use them. The endpoint leaves the prompt's last tap retained, since a next turn
whose template re-renders the reply (stripped thinking) resumes from that tap. A hit on a superseded
snapshot retains it again, until that lineage moves on. A Boundary snapshot (a tap at a client
breakpoint or structural boundary, or at the divergence of coalesced requests, §12.2) may be shared
by conversations, so it is superseded only while no other conversation has continued from it: while
the tree below it is a single chain. Until then it is just its lineage's snapshot.
Allocation of k slabs:
- Dead KV sweep: pop host-resident nodes with
live_snapshots_below == 0,active_refs == 0and no pins, leaves first. These can never produce a hit. - Otherwise evict the oldest superseded snapshot entirely (both copies;
Lis unchanged), then repeat step 1. - Otherwise evict the retained snapshot with minimum
H, then repeat step 1 (its exclusive path has just become dead). UpdateL := H(victim). A victim still complete on the Device (image in a device slot and tail resident) only gives up its host copy and stays restorable.
A snapshot image write stops at step 3 when the victim is worth more than the new snapshot
(H(victim) > H(new)): GDSF never inserts what it would evict next, and the new snapshot stays
Device-only (§9.2). KV block writes (write-through at release) are not bounded this way.
H(s) = L_s + F(s) · C(s) / Z(s) // L_s: L when s was published or last hit
C(s) = prefill_seconds(base=F_a, tokens=F_s−F_a) − restore_seconds(image) // clamp ≥ 0
where a = nearest retained (not superseded) snapshot above s on its path, whatever its
tail (or root, F_a = 0)
Z(s) = image_bytes + tail_bytes + exclusive_path_bytes(s)
exclusive_path_bytes: host bytes of the nodes on path(s) below which s is the only
retained snapshot
F(s) = 1 + hits(s) // hits since publication
- Each snapshot records its nearest snapshot above it on its path (
ancestor) and the snapshots that record it (dependents); a new snapshot between them takes over the dependents below it, and a removed one hands its dependents to its ancestor.CandZare recomputed for the snapshot and for those whose nearest retained ancestor or exclusive path it changes, when it is published, hit, superseded, retained again or removed. - Valuing against the nearest retained ancestor makes a conversation's newest endpoint carry the
whole conversation-specific prefill (its superseded predecessors go first), and a shared
boundary its own prefix. Valuing only against aligned (
tail_len == 0) ancestors, as before, priced every tailed endpoint as a full re-prefill from the root: stale endpoints of long conversations crowded out shared boundaries and fresh endpoints, which were evicted before the next turn (the production agent log of 2026-09-26: 68 turns re-prefilled 758K tokens behind a stuck resume point, 24 subagent turns prefilled from the root beside a cached 19.8K-token shared prefix). Cuses the same calibrated model as §6.2, so eviction and admission agree about value.- Aging comes from
L, so snapshots that are no longer hit fall behind new ones without timers. - Victim selection scans the snapshot table, O(snapshots); valuation walks at most one path per
affected snapshot. Snapshot count is bounded by
host_cache_bytes / image_bytes(about 1,300 for a 230 GiB Host tier with 27B DFlash2 images), so the policy is independent of the tier size: a small tier reaches step 2 and 3 often, a large one rarely. - Pinned objects (
restore_pins,active_refs, in-flight fills) are never evicted.
A node whose device and host copies are both gone is deleted together with its subtree. Every
snapshot anchored in the subtree is deleted, and live_snapshots_below is decremented along the
paths. A node with active_refs > 0 always has a device copy, so this never affects an active
sequence. The subtree walk runs only in the unbacked cases of §9.1 and §9.2, and is bounded by
the subtree size.
- Active entitlement is unchanged: the bounded output window, extension at round boundaries, and bounded completion when the pool cannot extend (paged-kv §6.2).
- Cached pages never count against it:
available = free + evictable, so active growth always wins. - Nothing active is ever evicted.
- Each device page group and each host slab has exactly one owner state: Free/Active/Cached/Filling, or Free/Node/SnapshotImage/SnapshotTail/Filling.
- A tree node is immutable: its tokens, extra key and payload never change after insertion.
- Every node on an active sequence's mapped path has
active_refs > 0and a device-resident copy until the sequence releases it. - A snapshot exists only while it is valid (§5.3). Match returns only valid snapshots, and only
with
F ≤ n − 1. - Exact identity: a match implies exact equality of tokens and extra keys for every block and tail token. Hashes never decide equality.
- A Filling destination is never mapped into a block table, and its readers wait on its event. A Filling source is never freed.
- Taps are bit-identical to an unsplit run ending at the tap, for the same phase. Identity-padding for phase alignment is bit-exact.
Disabledrequests leave no nodes or snapshots behind.- The worker is the only mutator. Transfers change residency only in
poll_transfers()or at commit. - Cost models only rank choices. Capacity and validity checks never depend on predictions.
- Restores run on a dedicated non-blocking restore stream, ordered after the compute stream's queued work (destinations may have been read by earlier kernels), with an event after the prelude and after each layer (§6.5).
- Write-through runs on the Program's transfer stream. Every write shares that stream and every restore the restore stream, so each list completes in order.
- Stream priorities are not set. The design gave restores the highest and write-through the lowest priority; measurement has not shown the need.
Device plane order is unchanged: one page is planes contiguous slices, 64 planes for 27B INT8
plus MTP. The Host slab layout packs a block's Main page with its Host page layout and the backend
page at the next 4 KiB boundary, so a transfer is a pure copy with no requantisation, and restore is
bit-exact for every KV profile.
Implemented: the copy engines. Runs of consecutive device pages whose slab records advance by one
constant pitch (within one pinned chunk and the device's maximum copy pitch) move as one
cudaMemcpy2DAsync per plane; a restore orders the planes by model layer.
Not implemented, kept as options if restore bandwidth or SM interference ever matters: an SM copy
kernel reading pinned slabs through mapped pointers (16 B vector accesses, a capped grid so other
lanes' decode keeps its SMs, layer-major work items), and cudaMemcpyBatchAsync over coalesced
runs. The design's acceptance bar was ≥ 85 % of the contiguous cudaMemcpyAsync peak for 2 MiB
copies in each direction; the copy-engine path reaches 24–27 GB/s on the measuring machine's
PCIe 5.0 x8 link.
- A snapshot image copy is a set of Device copies over the image's parts (48 SSM + 48 conv + hidden + ring), about 0.25 ms for a 27B DFlash2 image.
- CUDA Graph keys, decode graphs and block-table publication are unchanged. Prefill is not graphed.
- All cache bookkeeping is host-side O(1) or O(path). Index arenas are reserved at startup from
M,Dandhost_cache_bytes.
Admission choice (§6.2) and Host valuation (§9.3) use the Engine's context-cost model: prefill chunk, token and attention-pair coefficients measured for the artifact's prefill signature, and the Host-to-Device transfer coefficients of the RTX 5090 class (a local preset can replace them). Uncalibrated terms keep the index's generic defaults. No separate calibration runs at startup.
- Layer-pipelined restore (implemented, §6.5). The first prefill pass's layer
lwaits on the restore's event for layerl(cudaStreamWaitEvent), so the restore overlaps compute; the admission itself never waits for the copies.- Gate: TTFT reduction ≥ 30 ms p50 on host-restore requests of ≥ 50 k tokens.
- In-flight prefix coalescing (implemented,
hybrid_await_sibling). An admissible FIFO head whose prompt shares more tokens with a lane still prefilling than the cache can serve now reportsTemporarilyBlocked, holding no pins, until that lane's snapshot at the divergence publishes; it is then admitted from it.- Target: the block boundary
⌊d/64⌋·64below the divergence pointd(at mostn-1), added to the sibling's remaining plan as an exact tap. A frontier inside a block would publish only once the sibling completes that block. The sibling's deepest planned exact tap at or belowdserves instead when it is at least that deep, or when prefilling the tokens between them costs no more than one chunk's fixed cost. - A snapshot the sibling has captured in the shared prefix but not yet published (its block or MTP backend page is incomplete) is also waited for. It publishes within the sibling's next chunk.
- It waits only when the prefill it saves exceeds the split it may add, and when twice the
sibling's predicted prefill up to the target (other lanes' decode rounds interleave) is within
half the Engine's queue timeout (
pending_timeout_ms). A waiting head stays in the FIFO. - Vision prompts do not coalesce: placeholder tokens are equal for different media.
- Every sibling prefill boundary re-arms admission, and the head stops waiting once the sibling passes the target, finishes or is cancelled. So a missing snapshot never blocks it for long.
- Gate: total prefill tokens reduced on the concurrent-shared-prefix trace, with no TTFT regression for other requests.
- Target: the block boundary
- Output-boundary snapshots (not implemented). The Frontend's output parser signals structural boundaries
(after
</think>, before<tool_call>), and the Program snapshots the state at that exact committed frontier.- Without speculation: D2D of the active slot at the round boundary.
- With speculation: a two-segment ReplaySSM Fold,
S0 → slotover the firstjrecords, thenslot → activeover the rest. This is bit-identical by the Fold contract. Conv and ring taps are handled as in §7.3. - Gate: reuse gain on thinking-stripped agent traces.
- KV-write elision (not implemented) for the recomputed region (§7.5): map existing pages and send those KV
writes to a scratch page.
- Gate: measurable page savings under pressure.
The test design follows. §13.1 and §13.3 are implemented (ninfer_prefix_cache_index_test,
ninfer_qwen3_5_hybrid_prefix_real_test, see Status); §13.2 applies to the unimplemented Ops.
- BlockTree
- insert/match with exact tokens and extra keys;
- a forced constant
lookup_hashmust not produce a false hit; - different media digest with the same tokens must miss;
- tail snapshot matches on exact tail tokens only;
F ≤ n − 1is enforced;- duplicate snapshot publication is a no-op.
- Liveness
live_snapshots_belowstays correct under interleaved publish, evict and subtree deletion;- losing the last copy deletes exactly the subtree's snapshots.
- Device LRU
- tail-first order after release;
- hits move the whole path to MRU;
- backed entries are consumed before unbacked;
- pinned entries are never returned.
- Host GDSF
- hand-computed cases: two siblings, nested ancestor/descendant, frequency;
- dead KV is reclaimed before any snapshot;
- the victim's exclusive path is freed;
Lis monotonic.
- Supersession and admission
- a lineage's previous snapshot is superseded, and a hit retains it again;
- a
Boundarysnapshot is superseded only while no other conversation continued below it; - Device slots go to superseded owners, then to the least valuable Device-only owner for a new snapshot worth as much, then to the least recently hit Host-backed owner;
- a Host image write displaces only snapshots worth no more than the new one.
- HostSlabPool
- exhaustion, reuse and chunked allocation;
- a slab never crosses chunks.
- TapPlanner (property tests over random prompts and boundaries)
- all taps 64-aligned, in
(F_s, n−1], outside Vision spans; - deduplicated against the path;
- priority order and budget respected;
- the ladder stays within about log2(n/G) + 1 taps;
- deterministic.
- all taps 64-aligned, in
- Admission arithmetic
- candidate fallback order;
- Blocked vs Infeasible classification;
- backfill proof on constructed states;
- quote staleness via
cache_epoch.
- GDN tap and phase
- FP64 naive-recurrence oracle at every tap, phases 0..63, T in {1, 63, 64, 65, 2048, 4096};
- bitwise equality of each tap with a truncated call on the same phase;
phase > 0with zero taps matches the unpadded oracle within the existing GDN criterion;phase = 0is bit-identical to the pre-change kernel.
- Conv, hidden and ring taps: exact byte comparison against a host reference gather, including
p − unit_begin < 3and ring wrap (p > S,unit length > S). paged_kv_transfer- exact bytes for BF16, INT8, FP8, NVFP4 and K8V4, with Main+MTP and Main+DraftFull bundles, random page IDs, both directions;
- bandwidth ≥ 85 % of the contiguous peak;
- a concurrent-decode interference measurement is recorded.
Each scenario runs for --kv-dtype in {bf16, int8, fp8, nvfp4, k8v4} × backend in {none, mtp,
dflash2}. Scenarios marked "full matrix" run for every combination; the rest use int8 × {none,
dflash2}.
| scenario | assertion |
|---|---|
| resume-equivalence (full matrix) | resumed vs root: first-token logits within tolerance; greedy top-1 agreement over 256 tokens ≥ the chunk-decomposition baseline (root with a different --prefill-chunk); CausalScoring NLL difference within the same baseline |
| multi-turn exact endpoint | turn k+1 reuses F_end(k) exactly, and the tail was COW'd |
| re-rendered history (thinking stripped) | reuse ≥ floor64(n_k − 1) |
| edit deep message | reuse = the deepest ladder tap below the edit |
| shared preamble across 8 sessions | device pages for the preamble counted once; all 8 hit |
| concurrency 8, identical prompts arriving together | correct outputs; no double restore of a Filling node |
| host-only restore (D snapshot slots evicted) | same outputs as device resume; Restoring observed |
| cancellation mid-prefill | taps and completed blocks published; next identical request reuses them |
| Vision: same image vs different image, identical tokens | hit vs miss; no resume inside a span |
| pressure with active growth | active request extends by evicting cache; bounded completion unchanged |
| host cache 0 | device-only cache works; unbacked eviction deletes subtrees correctly |
Disabled request |
tree and snapshot counts unchanged |
| protocol usage | cached_tokens / cache_read_input_tokens equal reuse_tokens; streaming message_start exact |
Fault injection covers:
- a failed host allocation at startup, which is a clear startup error;
- a forced CUDA error in a transfer job, which gives Engine-wide failure and cleanup with no leaked
pins (checked by
prefix_cache_statsafter restart); compute-sanitizer --tool memcheckon one concurrency-8 scenario, for the lifetime claims.
This list records what Hybrid mode does not use. It is deleted only if Legacy mode is later retired by an explicit decision.
src/runtime/engine/context_cache/:resource_manager.h,materialization_planner.h,shared_capture_planner.h,resource_search.h,context_portfolio_value.h,materialization_budget.h,seal_window_claim.h.context_cost.*is reduced to the machine model.src/models/qwen3_5/program/planning/:pressure.cpp,pressure_planner.{h,cpp},checkpoint_recovery.cpp,resource_projection.h,rebuild_work.h.request_plan.cppis reduced to base planning.src/models/qwen3_5/program/transactions/:materialization.cpp,capture.cpp,commit.cpp.src/models/qwen3_5/program/storage/:kv_store.h(logical pages, address spaces) andhost_kv_store.hare replaced.state_store.his reduced to active and snapshot slots.context.cppis rewritten.src/core/host_kv_arena.*andsrc/models/qwen3_5/program/prefix_identity.*.src/runtime/contract/resources.h: the pressure, target, candidate, checkpoint-ref and revision types.- Options: Legacy-only
device_state_slots,host_state_slots,host_kv_capacity_bytes,max_private_continuations,max_shared_prefixes,max_long_anchors_per_continuation,long_anchor_min_spacing_tokensand their CLI flags are rejected in Hybrid mode. - Tests asserting owner, catalog or pressure-target behaviour keep running in Legacy mode.
enum class ContextCacheMode : std::uint8_t { Legacy, Hybrid };
struct HybridPrefixCacheOptions { // used only when mode == Hybrid
std::optional<std::uint32_t> device_snapshot_slots; // --device-snapshot-slots
std::optional<std::uint32_t> max_new_taps; // --cache-taps-per-request
std::optional<std::uint32_t> tap_ladder_tokens; // --cache-tap-ladder (G)
std::optional<std::uint32_t> tap_min_gap_tokens; // --cache-tap-min-gap
std::filesystem::path persistent_file; // --prefix-cache-file (§5.5)
std::string persistent_identity; // set by the product binary
PrefixCacheSaveControl persistent_save; // abandons the save (§5.5)
};
struct ContextCacheOptions { // existing struct, extended
ContextCacheMode mode = ContextCacheMode::Legacy; // --use-alt-prefix-caching selects Hybrid
HybridPrefixCacheOptions hybrid;
// Hybrid mode: host_cache_budget_bytes (--host-cache-mib) sizes the one slab pool.
// ... existing Legacy fields unchanged ...
};Engine construction (normalize_engine_options) resolves every unset Hybrid value, and
Engine::options() reports the effective ones. With C = max_concurrency, chunk = prefill_chunk and a Host tier present when the budget is nonzero:
| value | default | reason |
|---|---|---|
host_cache_budget_bytes |
8 GiB (kDefaultHybridHostCacheBytes); 0 disables the Host tier |
the Legacy Host KV default |
device_snapshot_slots |
C + 1 with a Host tier, C + 2 without |
one resident snapshot per live conversation, plus staging; without Host the slots are the only snapshot storage |
max_new_taps |
8 with a Host tier, 2 without | without Host, taps would evict other conversations' snapshots |
tap_ladder_tokens |
max(4096, 2·chunk) |
ladder taps land on chunk boundaries; a finer ladder only duplicates them |
tap_min_gap_tokens |
max(1024, chunk) |
the same |
KV capacity (ninfer-serve) |
--kv-capacity auto unless given |
free VRAM becomes Device block cache |
Legacy capacity options (device_state_slots, catalogs, anchors) are rejected, and the Legacy
Host pools (host_state_slots, host_kv_capacity_bytes) are zero in Hybrid mode.
| risk | mitigation |
|---|---|
| Exact taps cost one prefill split each | Only semantic boundaries are exact (explicit, generation opener, structural); the prompt tail and ladder are flexible and cost no split. The proximity rule leaves one split per chat request, and none after an endpoint resume. The zero-split GDN tap (§7.2–§7.3) removes the rest if measurement shows it matters. |
| Snapshots crowd KV out of host memory | GDSF weighs bytes against recompute seconds on the same scale for both. Dead-KV sweep runs first. |
| Write-through competes with decode | Copy engines only, batched when a lane releases its blocks; decode rounds/s were unchanged with prefetch and write-through active. Write-through can be throttled when decode is active without changing correctness. |
| Unbacked device eviction deletes subtrees | Only happens with no or full host cache. Backed entries are always preferred. |
| Large pinned allocations on Windows | Chunked allocation; the startup ledger reports the resolved size. |
| Hybrid mode ignores session keys | Content addressing reproduces chain reuse; the protocol surface is unchanged in both modes. |