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.
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.)
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.
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.
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
| Backend | Join | Status |
|---|---|---|
raw_block / io_uring_cmd | user_data and offset-join | proven — offset-join in exact agreement with the user_data ground truth |
| NVMe KV command set | key-join (key is in the SQE) | proven — unmodified clients, exact per-object |
POSIX O_DIRECT on a file (XFS) + FIEMAP | offset-join | proven — 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 / GDS | offset-join | projected — same shape, pending a GPU-direct-capable box |
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
| Dependency | Why | Notes |
|---|---|---|
LMCache kvio branch | the raw_block engine + the semantic trace_id/part/components wiring | git clone -b kvio https://github.com/mcgrof/LMCache; run against source on PYTHONPATH, or install |
rust raw_block ext | lmcache_rust_raw_block_io does the io_uring_cmd passthrough | maturin develop --release; needs rustup ≥ 1.87 (24.04 apt rust 1.75 fails) |
| PyTorch (CPU is fine) | tensor buffers + the asymmetric codec | FP8 casts work on CPU; no GPU needed |
an NVMe char device /dev/ngXnY | io_uring_cmd passthrough target | use an empty, unmounted namespace — never the OS disk |
| eBPF toolchain | build nvme_uring_cmd_monitor | clang, libbpf-dev, libelf-dev, and bpftool via apt install linux-tools-generic |
| kernel ≥ 5.19 | io_uring_cmd (NVMe passthrough) | CONFIG_IO_URING=y; Ubuntu 24.04 (6.8) works |
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)
# 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
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
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
python kvio_replay.py --record /tmp/kvio_record.json --device /dev/ng0n1 --iters 5
# reissues the exact store-all-then-load-all command stream
--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.
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.
/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.
| Model | KV family | TP | Objects | Exact-match | WAPE |
|---|---|---|---|---|---|
| Llama-3.2-1B | GQA | 1 | 230 | 100.0% | 0.0000% |
| Llama-3.2-3B | GQA | 1 | 230 | 100.0% | 0.0000% |
| Qwen3-8B | GQA · head_dim | 1 | 394 | 100.0% | 0.0000% |
| Qwen3-14B | GQA · head_dim | 1 | 392 | 100.0% | 0.0000% |
| Llama-3.1-8B | GQA | 2 | 456 | 100.0% | 0.0000% |
| Llama-3.1-8B | GQA | 4 | 912 | 100.0% | 0.0000% |
| Llama-3.1-70B | GQA | 4 | 916 | 100.0% | 0.0000% |
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.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.