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

kvio
GPU-free KV-cache-offload storage-IO toolkit

Project, issue, replay, and benchmark the NVMe I/O that LLM KV-cache offload produces — from real model geometry, on real hardware, without a GPU or a model. Validate command geometry against a capture or apply sustained KV-like pressure directly to a storage tier.

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.

07kvio bench: sustained storage pressure

kvio bench asks how much KV-like I/O a storage tier can sustain, what its tail latency is under load, and how large transfers affect another workload on the same drive. It runs fio without a GPU or model server and saves the resolved workload, fio output, result rows, hardware details, and kernel settings for each run.

complementary, not interchangeable Use bench for controlled device and kernel A/B comparisons. Use capture plus iolog replay when observed arrival times, offsets, and command order must be preserved. Neither result should be presented as the other.
ProfileEvidenceWhat it represents
restoresyntheticConcurrent random reads standing in for chats whose KV must return from storage.
restore-calibratedmeasuredFour synchronous workers restoring whole 7 MiB Qwen2.5-1.5B KV objects.
prefixsyntheticConcurrent sequential reads standing in for shared-prefix KV reloads.
qos-sustain-4ksyntheticLarge restore-like reads beside a sustained 4 KiB reader on the same drive.
evictsyntheticWrites standing in for KV demotion when the memory tier is full.

Run and compare

  ebpf-syscall
make kvio-test ./kvio bench --list-profiles # WARNING: preconditions and overwrites the selected raw namespace sudo ./kvio bench /dev/nvmeXnY \ --yes-really-use-device --size 8GiB \ --runtime 20 --ramp-time 3 --reps 3 \ --output-dir results/baseline ./kvio bench-compare results/baseline/results.jsonl \ results/candidate/results.jsonl
raw-device contract Use only a verified-empty, disposable, unmounted raw namespace. The acknowledgement does not bypass safety checks: kvio refuses mounted or undersized targets, partitions, holders, and signatures unless signatures receive their own explicit acknowledgement.

The comparison reports median bandwidth, IOPS, whole-I/O p50 and p99 latency, fio system CPU, and target-controller interrupts per GiB. Keep the device, region, runtime, and repetition count identical across an A/B test.

Know which profiles are measured

Only restore-calibrated is derived from a recorded KV-cache setup. David traced LMCache 0.5.3 with vLLM 0.27.1, Qwen2.5-1.5B-Instruct, TP1, bf16, and 256-token chunks. One whole object is:

2 (K and V) × 28 layers × 2 KV heads × 128 values per head
× 2 bytes (bf16) × 256 tokens = 7,340,032 bytes = 7 MiB

The run used four synchronous disk workers and reported 108 stores and 200 restores. This source profile is one calibration point, not a universal object size. Use --calibrated-block-size for another model, tensor-parallel rank, dtype, or chunk size.

synthetic QoS qos-sustain-4k does not claim that LMCache issues 4 KiB reads or that its mix came from a production trace. It measures how sustained large reads delay one possible latency-sensitive neighbor. Treat it as an optional same-device interference test, not a universal KV-cache QoS workload.

Add a sustained profile from a capture

--profile FILE adds a workload without changing Python. Each JSON profile must label its evidence measured or synthetic, identify the source, and define each fio job's operation, block size, queue depth, and worker count. The resolved profile is copied into run.json.

{
  "schema_version": 1,
  "name": "service-a-restore",
  "description": "Sustained restore shape from service A capture 17",
  "evidence": {
    "kind": "measured",
    "source": "/srv/kvio/captures/service-a-17.jsonl"
  },
  "jobs": [{
    "name": "restore", "rw": "randread", "bs": "32MiB",
    "iodepth": 1, "numjobs": 4
  }]
}

Lineage

Davidlohr Bueso wrote the standalone kvspill prototype. Its workload shapes, preconditioning, fio result parsing, and median A/B comparison became the starting point for kvio bench. They were merged with his permission and credit under this repository's Apache-2.0 license, then integrated with kvio's CLI, evidence labels, external profile format, result artifacts, and raw-device safety checks. There is no separate kvspill command in this tree; the old hostname records that history.

bare-metal validated The integrated benchmark ran on a verified-empty Samsung PM9A3 in a Latitude m4-metal-medium. All five profiles, the 32 MiB calibrated override, comparison, OS-disk refusal, and tuning cleanup completed. Queue and hugepage settings matched their pre-run values afterward.

08Fidelity 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.

09Validated 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.