◆ kvio · storage-IO for LLM KV-cache offload

kvio
GPU-free KV-cache-offload storage-IO projector & replayer

Project, issue, and replay the NVMe I/O that LLM KV-cache offload produces — from real model geometry, on real hardware, without a GPU or a model. Every device command is attributed back to the KV object that caused it via a cross-layer trace_id join, and validated byte-for-byte against the projection.

engine: LMCache raw_block — passthrough, POSIX, io_uring join: offset-join — engine-free, backend-decoupled tracer: eBPF nvme_uring_cmd_monitor / nvme_tp_monitor needs: an NVMe char device (/dev/ngXnY) GPU: not required validated: byte-exact on real NVMe source: mcgrof/LMCache @ kvio

01What it is

Storage and systems engineers need to evaluate the disk I/O of LLM KV-cache offload — command sizes, counts, volume, latency — but that I/O normally only exists behind a GPU running a model through vLLM + LMCache. kvio removes the GPU from that loop.

The key observation: storage I/O geometry is content-independent. How many NVMe commands a KV store/load produces, and how big each one is, depends only on the KV block size and the device's transfer limit — not on the actual tensor values. So real model dimensions + fake bytes reproduce the real offload I/O pattern. kvio computes the block size from real model geometry, issues that store/load workload through LMCache's real raw_block NVMe-passthrough engine, and confirms the result against the actual device commands captured by an eBPF tracer.

scope A GPU is only needed to capture real access patterns and timing — which chunk is stored when, hit vs. miss. The I/O geometry for any given model is fully determined and reproduced here, GPU-free.

02How it works

kvio is a two-phase model layered on top of LMCache's real storage engine.

1 Project / generate

From a model config (the LMCache KV-cache calculator's modelconfig.json) and a chunk size, compute the KV block bytes with the calculator's exact geometry — MHA/GQA, GQA-with-head_dim, DeepSeek MLA, Hunyuan CLA.

Llama-3.1-8B, 256-tok chunk → 32 MiB KV block

2 Issue / replay

Push that store/load workload through LMCache RawBlockCore on a real device — POSIX, io_uring, or io_uring_cmd NVMe passthrough. The payload is zeros; the engine splits each block into max_data_transfer_size-sized commands (its knob, bounded above by the device's MDTS — on passthrough there is no block layer, so userspace owns the split) exactly as it would for real KV.

32 MiB block → 256 × 128 KiB NVMe commands

A recorded workload is captured as a compact kvio_record.json manifest — per-object payload sizes + device geometry, a few KB — that can be replayed later to reissue the identical command stream on any device.

03Mapping a KV object to its NVMe commands

A single KV store or load does not map to a single disk operation. LMCache hands its raw_block engine one KV object; the engine splits it into many NVMe commands (each capped at the device's maximum transfer size). Down at the device, the eBPF tracer sees only those raw commands — an offset, a length, a direction — with no idea which object produced them. This layer's whole job is to reconnect them: take one object and find the exact commands it caused, and go the other way too.

Our original implementation — a tag threaded through the stack

kvio gives every KV-object operation a unique integer we call its trace_id — literally "this store/load, of this object." The trick is getting that label all the way down to the device tracer. io_uring already hands each submission a 64-bit scratch field, user_data, which the kernel returns untouched on completion; programs normally use it as a completion counter to match a finished I/O back to the request that started it. That counter was already there. Our original change simply packed the trace_id into the unused high half of that same field:

user_data = (trace_id << 32) | counter

trace_id — the object's id, the part kvio added (high 32 bits).   counter — io_uring's own completion counter, already there (low 32 bits, untouched).

Because the low half is unchanged, io_uring's completion matching still works exactly as before. The eBPF tracer reads user_data off each NVMe command and recovers the object as trace_id = user_data >> 32 — one logical object joined to its N wire commands. The catch: this needs a change to LMCache's engine. (Every io_uring request carries a user_data field — not just passthrough — but it never travels on the NVMe wire, and ordinary POSIX I/O has no request cookie at all, so this join only reaches as far as the passthrough path our monitor can tie together.)

why K vs V matters A KV cache stores two tensors for every block of tokens — the Keys (K) and the Values (V). Traditionally they are bundled into a single object and written together with the same data type, so knowing "which object" is enough. Asymmetric quantization changes that: K and V can use different data types / precisions (for example K kept at higher precision than V), making them different-sized halves of the same block. Being able to attribute device bytes to K versus V — not just to the object — is what lets you see that split down at the disk.

The current implementation — no LMCache change needed

The tag works, but needing an engine change — and a user_data field that only passthrough has — is a real limitation. The current kvio removes it entirely: it attributes each device command to a KV object using only two things the trace and the semantic record already carry — the command's byte offset and its timestamp. No engine change — it works across any NVMe path the Linux driver tracepoint can see (POSIX, io_uring on a file, local cuFile/GDS).

Space — the device offset

Each object is written to its own slot: a distinct, non-overlapping byte range on the device. The object that owns a command is simply the one whose slot the command's offset falls into.

Time — the monotonic window

The same slot gets reused for later objects, so we also require the command's timestamp to fall inside that object op's [start, end] window — the same monotonic clock on both sides — which separates one use of a slot from the next, and a store from a load.

what is FIEMAP? On a raw block device the object's slot offset and the command's offset are already the same number. But a file backend writes at a file offset, while the device reports a physical block address (LBA) — and only the filesystem knows where a file's bytes actually landed on disk. FIEMAP is the Linux ioctl that answers exactly that: ioctl(fd, FS_IOC_FIEMAP, …) returns a file's extents — each one a (file offset → physical device block) mapping. kvio runs it once to build a file→LBA table; after that the object slots and the device commands live in the same coordinate system and the offset+time join just works.

Worked examples — one command, mapped to its object

Illustrative records, trimmed to the fields that do the join.

1 · io_uring_cmd passthrough — the object's tag rides in the command:

semantic  {"op":"store", "object_id":42, "trace_id":42, "bytes":262144}
command   {"op":"write", "slba":57344, "bytes":131072, "user_data":"0x0000002a_00000005"}
join      user_data >> 32  =  0x2a  =  42   →   object 42

2 · NVMe Key-Value command set — the 16-byte object key is inside the command, so no cookie is needed at all:

semantic  {"op":"store", "key_hex":"a7f3c1…9b", "bytes":262144}
command   {"op":"kv_store", "key_hex":"a7f3c1…9b", "bytes":262144}
join      key_hex matches   →   same object      (unmodified client, zero engine change)

3 · POSIX O_DIRECT on a file — offset + time, using FIEMAP to line the offsets up:

fiemap    file 0x0700000..0x0740000  →  device LBA 57344     (once, via FS_IOC_FIEMAP)
semantic  {"object_id":7, "slot_dev_off":29360128, "bytes":262144,
           "ts_start":1828074395351013, "ts":1828074395894120}
command   {"op":"write", "slba":57344, "bytes":131072, "ts":1828074395502771}
join      offset  57344 × 512 = 29360128  ∈  object 7's slot [29360128, 29622272)
          time    1828074395502771       ∈  [ts_start, ts]      →  the store
                                                                   →  object 7

The 256 KiB object splits into two 128 KiB writes; the second (slba 57600) lands in the same slot and joins to the same object. cuFile / GDS uses this identical offset+time path — no engine change — pending a GPU-direct-capable box.

the shift Because attribution no longer depends on the engine, kvio stops being a passthrough tool and becomes backend-decoupled across the NVMe paths the Linux driver tracepoint can observe — POSIX pread/pwrite, io_uring on a file, local cuFile/GDS. (Paths that own the controller in user space — SPDK/VFIO, xNVMe-SPDK — bypass that tracepoint and are out of scope.) Capture the device commands with nvme_tp_monitor, attribute them per KV object by offset+time, and replay the command stream elsewhere with device-shape fidelity (payload-free: no bytes, no keys). The user_data path becomes just the ground-truth calibration arm.

Validation status

BackendJoinStatus
raw_block / io_uring_cmduser_data and offset-joinproven — offset-join in exact agreement with the user_data ground truth
NVMe KV command setkey-join (key is in the SQE)proven — unmodified clients, exact per-object
POSIX O_DIRECT on a file (XFS) + FIEMAPoffset-joinproven — every object command attributed to the correct object; op (write/read) recovered from the time window; filesystem-overhead commands rejected; block-layer-merged commands spanning two objects split and byte-attributed (192 real merges validated)
cuFile / GDSoffset-joinprojected — same shape, pending a GPU-direct-capable box
one command isn't always one object The Linux block layer merges adjacent I/O, so a single NVMe command can span the tail of one object and the head of the next — this happens whenever objects are smaller than the device's maximum command size. The join intersects each command's whole byte range with the object slots: a command fully inside one slot goes to that object; a command that straddles a boundary has its bytes attributed to each object it touches and is reported separately — never guessed onto one. Proven on real hardware: 192 merged 128 KiB commands, each spanning four 32 KiB objects, split correctly.
other AI workloads When a workload has no clean object abstraction the way a KV cache does, the same capture still drives a device-exact fio replay (fio --read_iolog --direct=1 from the driver trace): the command stream is reproduced faithfully at the device layer even where per-object attribution isn't available.

04Components

kv_cache_offload_io LMCache

Real-model-geometry workload generator. kv_geometry.py (a Python port of the KV-cache calculator) + run_kv_offload_io.py. Lives in LMCache examples/kv_cache_offload_io on the kvio branch.

raw_block LMCache

The real engine: rust lmcache_rust_raw_block_io + RawBlockCore, io_uring_cmd NVMe passthrough. Emits the semantic trace_id record when LMCACHE_KVIO_TRACE is set.

kvio_plan ebpf-syscall

The projector: model/params → device ops, NVMe command count, per-command sizes, total bytes, fragmentation vs. MDTS. No I/O.

kvio_replay ebpf-syscall

--record kvio_record.json reissues a whole recorded object set (store-all-then-load-all) on a real device and measures latency/throughput.

nvme_uring_cmd_monitor ebpf-syscall

The eBPF tracer: one JSONL record per nvme_setup_cmd, carrying user_data so each wire command is attributable.

kvio_validate ebpf-syscall

Joins tracer + semantic traces by trace_id and scores fidelity: exact-match, WAPE, and command-size total-variation distance.

05Dependencies

DependencyWhyNotes
LMCache kvio branchthe raw_block engine + the semantic trace_id/part/components wiringgit clone -b kvio https://github.com/mcgrof/LMCache; run against source on PYTHONPATH, or install
rust raw_block extlmcache_rust_raw_block_io does the io_uring_cmd passthroughmaturin develop --release; needs rustup ≥ 1.87 (24.04 apt rust 1.75 fails)
PyTorch (CPU is fine)tensor buffers + the asymmetric codecFP8 casts work on CPU; no GPU needed
an NVMe char device /dev/ngXnYio_uring_cmd passthrough targetuse an empty, unmounted namespace — never the OS disk
eBPF toolchainbuild nvme_uring_cmd_monitorclang, libbpf-dev, libelf-dev, and bpftool via apt install linux-tools-generic
kernel ≥ 5.19io_uring_cmd (NVMe passthrough)CONFIG_IO_URING=y; Ubuntu 24.04 (6.8) works
device safety The passthrough target is written to. Always confirm the namespace is empty and unmounted (lsblk, nvme list) — the OS disk's /dev/ng is off-limits, and which namespace is empty differs per box.

06How to run

1 · Generate real-geometry offload I/O (GPU-free)

  LMCache examples/kv_cache_offload_io
# real model geometry -> real io_uring_cmd passthrough I/O for fake KV blocks python run_kv_offload_io.py --model meta-llama/Llama-3.1-8B-Instruct \ --dtype bfloat16 --chunk-tokens 256 --num-chunks 8 \ --device /dev/ng0n1 --engine uring_cmd \ --record /tmp/kvio_record.json --trace /tmp/sem.jsonl

2 · Capture the wire trace alongside it

  ebpf-syscall
sudo ./nvme_uring_cmd_monitor --dur 90 --lba-size 512 --jsonl /tmp/nvme.jsonl & # ... run step 1 with LMCACHE_KVIO_TRACE=/tmp/sem.jsonl ...

3 · Validate projection vs. real device commands

  ebpf-syscall/examples/lmcache
python kvio_validate.py --tracer /tmp/nvme.jsonl --semantic /tmp/sem.jsonl \ --lba-bytes 4096 --mdts-bytes 131072 # exact-match 8/8, WAPE 0.0000%, per-cmd size 8/8, size-dist TV 0.0000

4 · Replay a recorded workload elsewhere

  ebpf-syscall/examples/lmcache
python kvio_replay.py --record /tmp/kvio_record.json --device /dev/ng0n1 --iters 5 # reissues the exact store-all-then-load-all command stream
alignment Pass --lba-bytes equal to the raw_block block_align (4096), not the device LBA (512) — the projector rounds command tails to that alignment. Mismatched, the geometry looks off by a fraction of a percent; matched, it is exact.

07Fidelity metrics

Three complementary scores, joined per object by trace_id. AUC is deliberately not used — there is no class label; the geometry is deterministic.

= Exact-match

Per object, do the measured command count and total device bytes equal the projection? The strictest check.

% WAPE

Weighted Absolute Percentage Error: total mispredicted bytes ÷ total measured bytes. 0% = the I/O volume is exactly right.

Size-dist TV

Total-variation distance between projected and measured command-size distributions (log2 bins). 0 = identical shape — every command the right size.

why both The same total bytes in the same command count can still hide a wrong split (256K+768K vs 512K+512K). WAPE and count both pass; TV catches it. WAPE = right volume, TV = right shape. Both zero = the device I/O is reproduced byte-for-byte and command-for-command.

08Validated results

On an 8× H100 server with real Samsung Gen5 NVMe (io_uring_cmd passthrough on /dev/ng), the projection was validated against a real GPU-driven vLLM + LMCache offload — the previously hardware-gated step is now closed.

real GPU, kernel-verified vLLM (Llama-3.1-8B) on an H100 offloading KV to /dev/ng1n1: 230/230 objects exact (cmds and bytes), WAPE 0.0000%, size-distribution TV 0.0000, over 58,729 real NVMe commands captured by the eBPF tracer and joined by trace_id. Roundtrip proof: repeated prompts (temp 0) regenerated identical outputs from NVMe-loaded KV vs. recomputed KV. The GPU-free generator, run at the same geometry, reproduced that device command stream indistinguishably.

Scale & parity campaign — 7 models, 1B → 70B, TP 1/2/4

Each cell: real GPU capture → wire-validate → replay the recorded manifest → regenerate GPU-free from the calculator. Every real leg was exact.

ModelKV familyTPObjects Exact-matchWAPE
Llama-3.2-1BGQA1230100.0%0.0000%
Llama-3.2-3BGQA1230100.0%0.0000%
Qwen3-8BGQA · head_dim1394100.0%0.0000%
Qwen3-14BGQA · head_dim1392100.0%0.0000%
Llama-3.1-8BGQA2456100.0%0.0000%
Llama-3.1-8BGQA4912100.0%0.0000%
Llama-3.1-70BGQA4916100.0%0.0000%
tensor-parallel sharding is exact Under TP=N, vLLM runs one KV worker per rank, so a logical chunk becomes N per-rank objects (same chunk hash, distinct kv_rank): object count scales ×N, per-rank payload ÷N. The 70B case shards its 80 MiB chunk into exactly 4 × 20 MiB per-rank objects (2 of 8 KV-heads each), 161 store / 160 load commands apiece — and the calculator-driven generator now reproduces that sharded pattern byte-for-byte.
load vs recompute — capacity, not speed On this H100 + Gen5-NVMe rig, loading KV from NVMe is still slower than recomputing prefill on the GPU, but the gap collapses with scale: load ÷ recompute falls from ~6.9× (1B) to ~2.2× (70B, TP4). The crossover — where offload beats recompute — lies beyond 70B, or on slower GPUs / faster storage. (n=2/cell, ~QD1; directional, not a rigorous latency benchmark — tokenizers differ across families.)
bottom line Capture wiring, the cross-layer trace_id join, fidelity metrics, and record/replay are byte-faithful on real NVMe, now proven against a real GPU offload across 7 models and TP degrees. The GPU-free generator and the recorded-manifest replay both reproduce the real device command stream exactly, so anyone can simulate a model's KV-offload I/O — including its TP sharding — with no GPU.
case study The same eBPF attribution found a concrete engineering win: LMCache's KV loader ran at ~11% of a Gen5 NVMe (single-threaded, QD~1). See The QD~1 KV-load bottleneck — found with eBPF, fixed with parallel loads for the wire evidence, how to reproduce it, and the ~2.8× fix.