KVTide is a vendor-neutral harness for studying tiered KV-cache I/O over NVMe key-value
namespaces. It stands up a pure-software NVMe-KV target and drives it with two competing
data paths — a userspace kernel-bypass initiator and a kernel io_uring_cmd
initiator — over the exact same matrix, so the architecture question "which data path, at
what cost?" gets numbers instead of opinions. Everything runs in a QEMU guest; no KV
hardware required.
Inference serving tiers its KV-cache: accelerator memory, host DRAM, local NVMe, then
shared or remote storage. The interesting boundary is where addressing changes — block
storage wants offsets, but a shared cache tier wants keys, and the NVMe Key-Value
command set speaks that natively: Store, Retrieve,
Exist, Delete against 16-byte keys instead of LBAs. Two very
different host data paths can drive such a namespace:
/dev/ngXnY) and userspace submits KV commands
via io_uring_cmd (here through xNVMe).The kernel path composes with everything the kernel gives you (scheduling, cgroups, observability, the block layer's buffer machinery); the bypass path buys raw speed. KVTide exists to quantify that trade both ways, reproducibly.
one guest, loopback TCP
┌────────────────────────────────────────────────┐
│ SPDK nvmf_tgt (cores 0-1) │
│ kvmalloc KV bdev ← NVMe-oF/TCP :4420 │
└──────────────▲──────────────────▲──────────────┘
│ │
userspace │ │ kernel nvme-tcp
TCP socket │ │ KV namespace: /dev/ngXnY
│ │
┌──────────────┴──────┐ ┌────────┴───────────────┐
│ spdk_nvme_perf (KV) │ │ xnvme_kv_perf │
│ kernel-bypass │ │ io_uring_cmd passthru │
└─────────────────────┘ └────────────────────────┘
same initiator core budget, same
op/value-size/queue-depth matrix, one CSV
The target is the SPDK kvmalloc KV bdev — an in-memory key-value store
exported over NVMe-oF/TCP on loopback. CPU accounting sums busy jiffies on every core
except the target's busy-polling reactor cores, so the initiator-side cost —
including the kernel path's softirq and kworker time that process accounting would miss —
is measured the same way for both.
# in a knlp checkout, on the bench host (a QEMU guest is fine)
make defconfig-kvtide-ab
make
Plain make runs the staged pipeline; the target stays up afterwards so cells
can be re-run, and make kvtide-target-down tears it down.
| Stage | What it does |
|---|---|
| kvtide-doctor | verifies host prerequisites, one PASS/FAIL line each |
| kvtide-fetch | fetches pinned sources (the SPDK KV stack rides a single Gerrit change ref — no full clone) |
| kvtide-build | builds the target and the enabled initiators under one prefix; nothing touches the system |
| kvtide-target-up | hugepages, nvmf_tgt, KV bdev, subsystem, listener, then the kernel nvme-tcp attach when needed. Never runs SPDK setup.sh; local NVMe stays kernel-owned |
| kvtide-bench | runs the op × value-size × queue-depth matrix per initiator, one CSV row per cell |
| kvtide-report | renders the table plus per-cell A/B IOPS ratios |
Four defconfigs pick the initiator shape:
kvtide-ab (both — the A/B), kvtide-spdk,
kvtide-xnvme, and kvtide-nixl, which adds the
NIXL XNVME_KV plugin —
a storage backend that moves buffers to and from KV namespaces over the same
io_uring_cmd path — and runs its unit and integration tests against the same
target. Every knob (pins, target address/NQN/core mask, the matrix) lives in the
KVTide NVMe-KV bench harness Kconfig menu.
Representative cells from a validated run (QEMU guest, 8 vCPUs, Debian 6.12 kernel, loopback TCP, 8 s per cell):
| cell | spdk | xnvme | ratio |
|---|---|---|---|
| store 4K qd64 | 1,075,250 IOPS | 120,164 IOPS | 8.9× |
| retrieve 4K qd64 | 869,465 IOPS | 92,380 IOPS | 9.4× |
| retrieve 64K qd64 | 8.3 GiB/s | 3.2 GiB/s | 2.6× |
| store 64K qd16 | 125,039 IOPS | 38 IOPS | — |
The bypass initiator wins ~9× on raw small-op IOPS and ~5× on per-core
efficiency; the gap compresses to ~2.6× once cells go bandwidth-bound. One
disclosure on the recorded run: its driver passed the SPDK core selection as a bare
number, which SPDK parses as a hex mask, so the spdk cells actually ran three
workers — their full CPU cost (≈1.9 cores vs xnvme's ≈1.0) is in the CSV's
init_cpu_cores column, which is what the per-core comparison divides by. The
harness now pins spdk with the exact -c [N] core-list syntax, so fresh runs
measure a single-worker spdk.
The 38-IOPS cell is real and reproducible: large-value stores at queue depth collapse on
the kernel nvme-tcp solicited-write (R2T) path when writes exceed the target's
in-capsule data size (a harness knob, KVTIDE_TARGET_INCAPSULE) — the
userspace initiator never takes that path. That cell is exactly why the harness exists:
it is a kernel-side data-path problem you can now regress, bisect, and fix with a
one-command reproducer.
knlp ships as a kdevops plugin, and the plugin can provision every KVTide build dependency. That makes the guest a disposable, reproducible bench node — and because kdevops also builds and boots kernels, you can put a kernel of choice under the xNVMe initiator and A/B the kernel side too:
# kdevops side: provision a guest with knlp + KVTide deps make kdevops-plugin-add URL=https://github.com/mcgrof/knlp # kdevops resolves +fragments from ~/.config/kdevops/defconfigs/configs mkdir -p ~/.config/kdevops/defconfigs/configs cp ~/.config/kdevops/plugins/knlp/defconfigs/configs/knlp-kvtide.config \ ~/.config/kdevops/defconfigs/configs/ make defconfig-<your-base>+knlp-kvtide make make bringup # on the guest: knlp is already cloned at the data path cd /data/knlp make defconfig-kvtide-ab make
kdevops carries block-layer buffer-pool validation tooling of its own — the
blk_iobuf_pool RFC page,
defconfig-iobuf-nvme
and the
iobuf_bench scripts —
so one workflow covers both layers: boot the kernel branch under test, run the KVTide
matrix on top, diff the CSVs. The xNVMe initiator's passthrough commands exercise exactly
the kernel paths that block-layer work touches; the userspace initiator doubles as the
control arm that skips the kernel entirely.
The harness can also carry the kernel itself. defconfig-kvtide-ab-linux adds
a kvtide-linux stage that fetches a Linux tree (shallow), bases its config on
the running kernel's, enables CONFIG_BLK_IOBUF_POOL, builds, installs, and
then gates the pipeline on actually running it — one reboot, and the second
make benches on the kernel under test. Every bench CSV gets a
.meta sidecar recording uname -r, so runs on different kernels
stay distinguishable.
# default kernel under test: the blk_iobuf_pool v3 series from # git.kernel.org/pub/scm/linux/kernel/git/mcgrof/linux.git make defconfig-kvtide-ab-linux make # builds target + initiators + kernel, installs, asks for ONE reboot # reboot into the listed kernel, then: make # gate passes; bench runs on the kernel under test # any other tree/branch, as environment variables at defconfig time: make defconfig-kvtide-ab-linux LINUX_TREE=<git url> LINUX_BRANCH=<ref>
The LINUX_TREE / LINUX_BRANCH overrides resolve when the
defconfig loads (they need python3 kconfiglib, which fills the deliberately omitted
symbols); the defaults point at
mcgrof/linux blk-iobuf-pool-v3.
The stage installs a kernel and updates the bootloader — use it on disposable bench
hosts, which is exactly what a kdevops guest is.
Both modes are validated: the QEMU guest flow, and the full bare-metal flow on a rented
12-core box (Ubuntu 24.04, two datacenter NVMe drives) — kernel built and booted from the
default blk-iobuf-pool-v3 branch, one reboot, clean 24-cell A/B on the kernel
under test. The perf harness only assumes enough cores to keep the target mask and the
initiator pin disjoint. Two bare-metal routes:
# via kdevops: DECLARED_HOSTS flips any config from guest bringup to # existing machines at defconfig time (SKIP_BRINGUP + declared hosts; # the knlp plugin's inventory hook respects declared-hosts mode) make defconfig-<your-base>+knlp-kvtide DECLARED_HOSTS="metal1 metal2" make make bringup # or directly, in a knlp clone on the machine make defconfig-kvtide-ab && make
The kernel stage is deliberately the exception: it installs a kernel and updates
the bootloader, so it refuses to run where systemd-detect-virt reports no
virtualization. Testing a kernel on real hardware is an explicit opt-in —
make defconfig-kvtide-ab-linux-baremetal (the same
LINUX_TREE/LINUX_BRANCH overrides apply), mirroring kdevops' own
defconfig-iobuf-baremetal precedent for bare-metal block-layer testing.
| Component | Source | Pin |
|---|---|---|
| SPDK KV target stack | SPDK Gerrit change 28307 (in review) | refs/changes/07/28307/12 |
| xNVMe | github.com/xnvme/xnvme | a5bf2a65 |
| NIXL + XNVME_KV plugin | github.com/mcgrof/nixl | 20260717-xnvme-kv |
The SPDK KV command set support (the kvmalloc bdev, NVMe-oF KV namespaces and
spdk_nvme_perf KV mode) is pending on SPDK Gerrit; a single change ref carries
the whole stack plus its base. A small vendored patch fixes a GCC 15/AVX10 build probe
and an RPC decoder collision until those land upstream. The harness README
(tools/reproduce/kvtide/README.md) documents every default.