KV-Cache Management (kvd)#

One-pager

The design of infera.kvd — the per-host daemon that builds a tiered KV cache below the engine’s GPU cache and offloads / onboards blocks across GPU HBM → host RAM → NVMe → network. This page is the mechanism: the tiers, the zero-copy transport, the on-disk format, and how it’s tuned. For how to turn it on and the usage modes, see KV-Cache Offload.

digraph cache_tiers { rankdir=TB; bgcolor="transparent"; node [shape=box style="rounded,filled" fontname="Helvetica,Arial,sans-serif" fontsize=11 margin="0.22,0.13"]; edge [fontname="Helvetica,Arial,sans-serif" fontsize=10]; L1 [fillcolor="#ffe3e3" color="#cc6666" label=<<b>L1 · GPU HBM</b> — engine prefix cache<br/><font color="#996666">fastest · smallest · not shared · lost on restart</font>>]; L2 [fillcolor="#fff3cd" color="#caa300" label=<<b>L2 · host RAM shared arena</b> (kvd) — zero-copy mmap<br/><font color="#998400">cross-engine on one host · lost on restart</font>>]; L3 [fillcolor="#e2efdd" color="#6a9a4a" label=<<b>L3 · local NVMe / NFS / WekaFS</b> (kvd)<br/><font color="#5e8048">durable · shared via mount</font>>]; L4 [fillcolor="#dbe7ff" color="#5577cc" label=<<b>L4 · distributed</b> (kvd) — Mooncake / LMCache<br/><font color="#5b6f9e">durable · cross-node</font>>]; L1 -> L2 [label="miss → ask kvd" penwidth=1.7 color="#5577cc"]; L2 -> L3 [label="miss" penwidth=1.7 color="#5577cc"]; L3 -> L4 [label="miss (only if L4 is the chosen --long-backend)" style=dashed color="#8a8a8a"]; L1 -> L3 [label="GPU-direct (hipFile/AIS): engine reads/writes\lL3 chunk files directly, bypassing the daemon\l" style=dashed color="#8a8a8a" constraint=false]; }

Higher tiers are faster and smaller; a miss falls through to the next tier down. L1 is the engine’s own GPU cache; L2–L4 are kvd’s. The long tier is one --long-backend — local tablespace (L3) or a distributed backend (L4), never both.

The daemon owns L2 (the RAM arena) and, with GPU-direct off, also does the L3 disk I/O via its tablespace writer. With GPU-direct on (hipFile/AIS) the engine reads/writes the L3 chunk files directly (DMA straight into GPU memory); those files are connector-owned, so the daemon’s --long-bytes doesn’t bound them — the connector caps that tier via INFERA_KVD_L3_BUDGET_BYTES / INFERA_KVD_L3_FREE_FLOOR.

Why a separate daemon#

The engine’s own KV cache (L1) lives in GPU memory: it’s fast, but small, not shared between engines, and gone on restart. kvd adds the tiers below it:

Tier

Storage

Managed by

Restart-durable

Shared

L1

GPU HBM

the engine (vLLM/SGLang)

No

No

L2

host pinned RAM (memfd shared arena)

kvd

No

cross-engine, one host, zero-copy

L3

local NVMe / NFS / WekaFS

kvd

Yes

via the shared mount

L4

distributed (Mooncake / Redis / S3 / NIXL / WEKA)

kvd

Yes

cross-node

What fires when — a GET#

digraph tier_lookup { rankdir=TB; bgcolor="transparent"; node [shape=box style="rounded,filled" fillcolor="#eef2f7" color="#5577cc" fontname="Helvetica,Arial,sans-serif" fontsize=11 margin="0.2,0.12"]; edge [fontname="Helvetica,Arial,sans-serif" fontsize=10]; R [label="Request"]; L1 [shape=diamond style=filled fillcolor="#fff3cd" color="#caa300" label="L1 GPU\ncache?"]; U [label="use it" fillcolor="#e2efdd" color="#6a9a4a"]; K [shape=diamond style=filled fillcolor="#fff3cd" color="#caa300" label="ask kvd"]; M [label="CopyFree mmap read" fillcolor="#e2efdd" color="#6a9a4a"]; P [label="load → promote to L2" fillcolor="#e2efdd" color="#6a9a4a"]; C [label="engine computes prefill\n→ kvd stores it"]; R -> L1; L1 -> U [label="hit"]; L1 -> K [label="miss"]; K -> M [label="L2 hit (RAM)"]; K -> P [label="L3 hit (disk)"]; K -> C [label="miss"]; }

A SET flows the other way: new blocks land in L2; eviction from L2 writes back to the long tier (L3/L4), unless the request marked them ephemeral. These offloads (and the load-backs) are asynchronous — they run off the token-generation path, so spilling a block to disk never stalls decode, and kvd batches the writes for throughput.

The zero-copy win (L2)#

The RAM tier is a memfd-backed pinned shared arena. When a vLLM/SGLang worker opts in at the UDS handshake, kvd passes the arena’s file descriptor over SCM_RIGHTS and the engine mmaps the same region. A get() then returns just (slot_offset, length, version) — the engine reads bytes straight from its own mmap. No socket body, no bytes(...) copy. The gain vs the legacy inline-bytes path is largest on NFS-backed L3 (prefetch warms the arena; the bytes path can’t benefit).

L3 on-disk design — the tablespace#

The L3 disk tier never writes one OS file per cached block. Instead it pre-allocates a small, fixed set of large “container” files and packs blocks into fixed-size slots inside them — the same pattern databases use for a tablespace (and 3FS for its chunk engine). The file count is a constant (max_bytes / container_bytes), so a 64 GB region with 1 GB containers is 64 files no matter how many blocks it holds — which is what keeps filesystem metadata (inode lookup, dentry cache, the restart scan) from becoming the bottleneck the way one-file-per-block did past ~100K entries, especially on NFS.

digraph tablespace_layout { rankdir=TB; bgcolor="transparent"; node [fontname="Helvetica,Arial,sans-serif", fontsize=11]; edge [fontname="Helvetica,Arial,sans-serif", fontsize=10, color="#8a8a8a"]; subgraph cluster_region { label=<<b>&lt;region_dir&gt;/</b> &#8212; file count = max_bytes / container_bytes (constant)>; labeljust="l"; fontname="Helvetica,Arial,sans-serif"; fontsize=11; fontcolor="#444444"; style="rounded"; color="#cccccc"; margin=14; c0 [shape=plaintext margin=0 label=< <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="8" COLOR="#9fb6cf"> <TR><TD COLSPAN="4" BGCOLOR="#eef2f7" ALIGN="LEFT"><b>containers/0000.bin</b> · pre-allocated, fixed size</TD></TR> <TR> <TD BGCOLOR="#cdeccd">slot 0<BR/><b>block A</b></TD> <TD BGCOLOR="#f4f4f4"><FONT COLOR="#9a9a9a">slot 1<BR/>free</FONT></TD> <TD BGCOLOR="#cdeccd">slot 2<BR/><b>block B</b></TD> <TD BGCOLOR="#f4f4f4"><FONT COLOR="#9a9a9a">slot 3<BR/>free</FONT></TD> </TR> </TABLE>>]; c1 [shape=plaintext margin=0 label=< <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="8" COLOR="#9fb6cf"> <TR><TD COLSPAN="3" BGCOLOR="#eef2f7" ALIGN="LEFT"><b>containers/0001.bin</b></TD></TR> <TR> <TD BGCOLOR="#cdeccd">slot 0<BR/><b>block C</b></TD> <TD BGCOLOR="#f4f4f4"><FONT COLOR="#9a9a9a">slot 1<BR/>free</FONT></TD> <TD BGCOLOR="#f4f4f4"><FONT COLOR="#9a9a9a">&#8230;</FONT></TD> </TR> </TABLE>>]; log [shape=note style=filled fillcolor="#dbe7ff" color="#5577cc" label="index.log\nappend-only journal\n(fsync per PUT)"]; snap [shape=note style=filled fillcolor="#dbe7ff" color="#5577cc" label="index.snapshot.json (+ .bak)\nperiodic compacted index"]; c0 -> c1 [style=invis]; c1 -> log [style=invis]; log -> snap [style=invis]; } bit [shape=box style="rounded,filled" fillcolor="#fff3cd" color="#caa300" label="RAM\nfree-slot bitset + key → (container, slot) index"]; bit -> c0 [label=" allocates /\l locates slots\l" style=dashed]; bit -> c1 [style=dashed]; snap -> bit [label=" startup: load snapshot…" style=bold color="#5577cc" fontcolor="#5577cc"]; log -> bit [label=" …then replay journal tail" style=bold color="#5577cc" fontcolor="#5577cc"]; }

  • Slots. Each container is carved into fixed --tablespace-slot-bytes slots (default 64 KB, sized to the typical packed-KV block). A value larger than one slot is rejected (the caller recomputes) — slots are not chained.

  • Allocator. A bitset in RAM tracks free slots; allocation and the key (container, slot) index are in-memory. Disk durability is the journal, not the bitset.

Write path & crash safety#

Every PUT is slot-reserve → write+fsync data → append+fsync journal, so a crash can never surface a half-written block:

digraph write_path { rankdir=LR; bgcolor="transparent"; node [shape=box style="rounded,filled" fillcolor="#eef2f7" color="#5577cc" fontname="Helvetica,Arial,sans-serif" fontsize=11 margin="0.18,0.12"]; edge [color="#5577cc" penwidth=1.4 arrowsize=0.8]; r [label="1\nreserve slot\n(RAM bitset)"]; w [label="2\npwrite value at\nslot offset + fsync"]; j [label="3\nappend PUT to\nindex.log + fsync"]; i [label="4\nupdate in-memory\nindex"]; r -> w -> j -> i; }

A crash between steps 2 and 3 leaves a slot written on disk but with no journal record — on restart it’s simply seen as free and overwritten later, never served as a phantom block. Steps 3 and 4 are atomic from a restart’s point of view (step 4 is RAM-only). On restart kvd loads index.snapshot.json (written atomically on graceful shutdown / periodic compaction), then replays any journal entries appended since the snapshot — so both a clean shutdown and a hard kill recover the full index. The journal is plain JSON on purpose: cat index.log shows the operation history, and a parse failure is obvious (vs. an opaque embedded-DB corruption).

Storage-aware auto-tuning#

kvd inspects the backing device at startup so you don’t hand-tune per storage class:

  • --io-mode auto (default) — O_DIRECT on NVMe, buffered on NFS/SATA.

  • IO concurrency is selected internally by device class (no CLI flag); the L3 writer is plain pwrite/O_DIRECT — no extra dependencies to build or ship. For the striped region you can cap intra-shard parallelism with --long-workers-per-shard (default 8).

  • Low NFS wsize → a startup WARN with the exact remount command.

Sizing principles#

  • L2 (--max-bytes) — as much host RAM as you can spare for KV; this is the hottest, zero-copy tier. On a big-RAM host (~3 TB/host on this fleet) L2 can be large.

  • L3 (--long-bytes) — the working-set’s durable footprint; sized to hold the prefixes you want to survive restarts.

  • Grow capacity as you go down — each tier should be ≥ the one above (L3 ≥ L2 ≥ the engine’s GPU L1). A lower tier smaller than the one above it just churns: blocks evict before they’re ever reused.

Verify it’s running#

After starting the daemon and at least one engine, send the same prompt twice (or any prompt that shares a long prefix), then check the daemon counters:

python -m infera.kvd.statctl --socket /var/run/infera-kvd.sock
# healthy output: sets_total > 0, gets_total > 0, hits_total > 0

hits_total == 0 after a repeated prompt usually means the engine isn’t connected to the daemon — confirm INFERA_KVD_SOCKET is set and points to the same socket path. Under GPU-direct the daemon counters stay 0 by design (the connector owns the files directly); use the engine’s External prefix cache hit rate log line and check for .kvcache files under INFERA_KVD_HIPFILE_ROOTS instead.