# NBD / QEMU Performance ## Summary Hive VMs using NBD-backed boot volumes showed ~38x lower IOPS and ~35-40x higher latency compared to a Proxmox VM on the same host. The root cause was not the NBD transport itself, but missing QEMU tuning flags: no iothread, no cache policy, no multiqueue. Proxmox applies all of these by default; Hive applied none. **Status: Implemented.** All boot volumes and hot-attached data volumes now get dedicated iothreads, `cache=none`, and multiqueue. Auxiliary volumes (cloud-init, EFI) are excluded — they are small, rarely read, and don't benefit from these flags. The viperblock LRU cache was also doubled from 64MB to 128MB for main volumes. ## What Changed ### QEMU flags (boot volumes) `hive/daemon/daemon.go` — `StartInstance()` boot drive setup: - Boot volume drive gets `cache=none` - A dedicated iothread (`ioth-os`) is created - virtio-blk-pci device gets `iothread=ioth-os,num-queues=` Before: ``` -drive file=nbd:unix:/path.sock,format=raw,if=none,media=disk,id=os -device virtio-blk-pci,drive=os,bootindex=1 ``` After: ``` -object iothread,id=ioth-os -drive file=nbd:unix:/path.sock,format=raw,if=none,media=disk,id=os,cache=none -device virtio-blk-pci,drive=os,iothread=ioth-os,num-queues=2,bootindex=1 ``` ### QEMU flags (hot-attached data volumes) `hive/daemon/daemon_handlers.go` — `handleAttachVolume()`: Hot-attached volumes (e.g. a 1TB data drive attached to a running instance) also get a per-volume iothread via QMP: 1. `object-add` creates `ioth-` 2. `blockdev-add` connects the NBD backend 3. `device_add` creates `virtio-blk-pci` with `iothread=ioth-` On detach (`handleDetachVolume()`), cleanup is: 1. `device_del` removes the guest device 2. `blockdev-del` removes the block node 3. `object-del` removes the iothread (best-effort, non-fatal if it fails) ### Excluded: cloud-init and EFI volumes These auxiliary volumes are intentionally excluded from performance tuning: - **Cloud-init** (`v.CloudInit`): Mounted as `if=virtio,media=cdrom`. Read once at boot, tiny payload. No iothread, no cache tuning. - **EFI** (`v.EFI`): Currently skipped entirely (`continue` in the volume loop). No QEMU drive emitted. - **Viperblock cache**: Volumes with names ending in `-cloudinit` or `-efi` get `cache_size=0` in both the viperblock in-process LRU and the nbdkit plugin. Main volumes get 128MB (32768 blocks at 4096B block size). ### Viperblock LRU cache doubled `hive/services/viperblockd/viperblockd.go`: - `defaultCache` changed from `(64 * 1024 * 1024) / DefaultBlockSize` → `(128 * 1024 * 1024) / DefaultBlockSize` - This is 16384 → 32768 blocks (at `DefaultBlockSize = 4096`) - Applies to both the in-process viperblock golang-lru cache and the `cache_size` parameter passed to the nbdkit plugin - Cloud-init/EFI volumes still get `cache_size=0` ### Struct changes `hive/vm/vm.go`: ```go type Drive struct { File string `json:"file"` Format string `json:"format"` If string `json:"if"` Media string `json:"media"` ID string `json:"id"` Cache string `json:"cache,omitempty"` // NEW — e.g. "none" } type IOThread struct { // NEW ID string `json:"id"` } type Config struct { // ... existing fields ... Drives []Drive `json:"drives"` IOThreads []IOThread `json:"io_threads,omitempty"` // NEW // ... } ``` `Config.Execute()` emits `-object iothread,id=...` before `-drive` args, and appends `cache=` to drive option strings when set. ## Benchmark Comparison (pre-fix) Test: `fio --rw=randrw --rwmixread=70 --bs=4k --numjobs=4 --iodepth=32 --ioengine=libaio --direct=1` | Metric | Proxmox VM (direct) | Hive VM (nested, NBD) | Delta | |---|---|---|---| | Read IOPS | 85,600 | 2,243 | 38x lower | | Write IOPS | 36,800 | 970 | 38x lower | | Read BW | 334 MiB/s | 8.8 MiB/s | 38x lower | | Write BW | 144 MiB/s | 3.8 MiB/s | 38x lower | | Read avg latency | 1.0 ms | 41.6 ms | 40x higher | | Write avg latency | 1.1 ms | 35.5 ms | 32x higher | | Read p99 latency | ~2.9 ms | ~75 ms | 26x higher | | Write p99 latency | ~3.0 ms | ~68 ms | 23x higher | | Disk util | 97% | 79% | Guest not saturating device | Note: Proxmox test used 512 MiB/job (2 GiB total), Hive test used 128 MiB/job (512 MiB total). This affects cache behavior but does not explain a 38x gap. The 79% utilization with 35-41 ms average latency in the Hive VM indicates requests were queueing behind a serialization point, not that the physical disk was saturated. ## Root Cause Analysis **I/O path comparison:** Proxmox VM: ``` fio → virtio-blk (iothread, multiqueue) → QEMU block layer (cache=none, aio=io_uring) → qcow2 file → host FS → physical disk ``` Hive VM (nested inside Proxmox, before fix): ``` fio → virtio-blk (no iothread, single queue) → QEMU block layer (defaults) → NBD client → Unix socket → nbdkit → viperblock → [outer VM disk path] ``` **What Proxmox set that Hive did not (now fixed):** | Flag | Proxmox | Hive (before) | Hive (after) | |---|---|---|---| | `iothread` | Yes (`iothread-virtio0`) | No | Yes (`ioth-os`, `ioth-`) | | `cache` | `none` | Default (writeback) | `none` | | `num-queues` | Implicit (1, but with iothread) | 1, no iothread | vCPU count (boot), not set (hot-attach) | | `aio` | `io_uring` | Default (threads) | Default (threads) — correct for NBD | The iothread was the highest-impact missing flag. Without it, all block I/O processing ran in the main QEMU event loop alongside vCPU emulation. **Remaining factors (not addressed by QEMU flags):** 1. **QEMU NBD client has a 16 in-flight request limit** — The NBD protocol allows negotiating queue depth, but QEMU's built-in NBD client caps concurrent requests at 16. With `numjobs=4 --iodepth=32` (128 potential in-flight), requests queue inside QEMU before they even reach the socket. This is a hard protocol-level limit that cannot be changed via flags. 2. **Unix socket buffer size** — The default socket buffer (`net.core.wmem_default`, typically 212992 bytes) can bottleneck when many small 4K NBD request/response frames are in flight. Tuning `SO_SNDBUF`/`SO_RCVBUF` on the NBD socket or increasing system defaults may help throughput. 3. **Double virtualization overhead** — The benchmark Hive VM ran inside a Proxmox VM. Every I/O traverses two virtualization boundaries. This is a test environment artifact and won't apply on bare-metal deployments. ## QEMU Best Practices for NBD Backends ### iothread An iothread is a dedicated QEMU thread for processing virtio-blk requests. Without one, I/O completion runs in the main loop and competes with vCPU scheduling. For any workload beyond trivial single-threaded sequential I/O, an iothread is required. Each volume should have its own iothread. Boot volumes get `ioth-os`, hot-attached volumes get `ioth-`. ### cache=none Sets QEMU's block layer to skip host page caching. For file backends, this means `O_DIRECT`. For NBD socket backends, there is no file to `O_DIRECT` on — but the flag still controls QEMU's internal write-back cache and flush forwarding behavior. `cache=none` ensures `FLUSH` commands from the guest are forwarded through NBD to the server, which is correct for data integrity. ### aio= (not meaningful for NBD) The `aio=` option selects the host async I/O engine for file operations: - `aio=threads` — POSIX AIO via thread pool (default) - `aio=io_uring` — Linux io_uring submission queue - `aio=native` — Linux native AIO (`io_submit`) These engines optimize `preadv`/`pwritev` syscalls on file descriptors. QEMU's NBD client does not use these syscalls — it uses coroutine-based socket I/O through `QIOChannel`. Setting `aio=io_uring` on an NBD drive changes nothing in the I/O path. The default (`aio=threads`) is correct and we intentionally omit this flag. ### virtio-blk multiqueue (num-queues) By default, virtio-blk exposes a single virtqueue. With multiple vCPUs, all cores contend on that single queue's notification and completion path. Setting `num-queues=N` (where N = vCPU count) allows each vCPU to submit I/O to its own queue, eliminating cross-CPU contention. Requires guest kernel support (Linux 3.13+, always available on modern distros). Currently applied to boot volumes only (where the vCPU count is known from `Config.CPUCount`). Hot-attached volumes do not set `num-queues` because the device_add QMP path doesn't easily support it — the iothread alone provides the primary benefit. ### NBD protocol limits QEMU's NBD client implementation enforces a maximum of 16 concurrent in-flight requests per connection. This is defined in the QEMU source and cannot be changed via command-line flags. At high queue depths, this becomes a bottleneck regardless of other tuning. Potential future mitigation: multiple NBD connections to the same export (requires changes to both QEMU invocation and nbdkit/viperblock). ## Viperblock Cache Architecture The viperblock cache operates at two layers: 1. **In-process golang-lru** — Viperblock's Go process maintains a hashicorp/golang-lru cache of recently accessed blocks. This avoids round-trips to the S3-compatible backend (predastore) for hot blocks. 2. **nbdkit plugin cache** — The nbdkit plugin (which runs as a separate process) has its own `cache_size` parameter that controls a similar LRU cache within the C plugin. Both caches are sized identically via `defaultCache`: | Volume type | Cache size | Blocks (4KB each) | |---|---|---| | Main (boot, data) | 128 MB | 32,768 | | Cloud-init (`-cloudinit` suffix) | 0 (disabled) | 0 | | EFI (`-efi` suffix) | 0 (disabled) | 0 | Cloud-init and EFI volumes are small (typically < 1MB), read once at boot, and would waste memory with a cache enabled. ## Reference: QEMU Commands ### Proxmox outer VM (relevant flags only) ```sh /usr/bin/kvm \ -object iothread,id=iothread-virtio0 \ -drive file=/var/lib/vz/images/146/vm-146-disk-0.qcow2,if=none,id=drive-virtio0,cache=none,aio=io_uring,discard=on,format=qcow2,detect-zeroes=unmap \ -device virtio-blk-pci,drive=drive-virtio0,id=virtio0,iothread=iothread-virtio0,bootindex=100 \ -smp 4,sockets=1,cores=4,maxcpus=4 \ -m 8192 \ -cpu host ``` ### Hive inner VM (before fix) ```sh qemu-system-x86_64 \ -enable-kvm -cpu host -smp 2 -m 4096 \ -drive file=nbd:unix:/run/user/1000/nbd-vol-xxx.sock,format=raw,if=none,media=disk,id=os \ -device virtio-blk-pci,drive=os,bootindex=1 \ -M q35 ``` ### Hive inner VM (after fix) ```sh qemu-system-x86_64 \ -enable-kvm -cpu host -smp 2 -m 4096 \ -object iothread,id=ioth-os \ -drive file=nbd:unix:/run/user/1000/nbd-vol-xxx.sock,format=raw,if=none,media=disk,id=os,cache=none \ -device virtio-blk-pci,drive=os,iothread=ioth-os,num-queues=2,bootindex=1 \ -M q35 ``` ### Hot-attach QMP sequence (after fix) ```json {"execute": "object-add", "arguments": {"qom-type": "iothread", "id": "ioth-vol-abc123"}} {"execute": "blockdev-add", "arguments": {"node-name": "nbd-vol-abc123", "driver": "nbd", "server": {"type": "unix", "path": "/run/user/1000/nbd-vol-abc123.sock"}, "export": "", "read-only": false}} {"execute": "device_add", "arguments": {"driver": "virtio-blk-pci", "id": "vdisk-vol-abc123", "drive": "nbd-vol-abc123", "iothread": "ioth-vol-abc123", "bus": "hotplug1"}} ``` ### Hot-detach QMP sequence (after fix) ```json {"execute": "device_del", "arguments": {"id": "vdisk-vol-abc123"}} {"execute": "blockdev-del", "arguments": {"node-name": "nbd-vol-abc123"}} {"execute": "object-del", "arguments": {"id": "ioth-vol-abc123"}} ``` ## Files Modified | File | Change | |---|---| | `hive/vm/vm.go` | Added `Cache` field to `Drive`, added `IOThread` struct and `IOThreads` to `Config`, updated `Execute()` to emit `-object iothread` and `cache=` | | `hive/daemon/daemon.go` | Boot drive setup: `cache=none`, iothread, `num-queues=` | | `hive/daemon/daemon_handlers.go` | Hot-attach: QMP `object-add` iothread + `iothread` in `device_add`. Hot-detach: QMP `object-del` cleanup | | `hive/daemon/daemon_test.go` | Updated detach test assertions to expect `object-del` in QMP command sequence | | `hive/vm/vm_test.go` | Added `TestExecute_IOThreadAndCache`, `TestExecute_NoCacheWhenEmpty`, `TestExecute_MultipleIOThreads` | | `hive/services/viperblockd/viperblockd.go` | Doubled `defaultCache` from 64MB (16384 blocks) to 128MB (32768 blocks) | ## Diagnostic Tests Run inside the Hive guest VM to measure the impact of changes. **Baseline per-I/O overhead** (isolates single-request latency): ```sh fio --name=lat1 --directory=$HOME/bench --rw=randrw --rwmixread=70 \ --bs=4k --size=512M --numjobs=1 --iodepth=1 \ --ioengine=libaio --direct=1 --group_reporting ``` **Queue depth scaling** (detects serialization): ```sh fio --name=qdtest --directory=$HOME/bench --rw=randrw --rwmixread=70 \ --bs=4k --size=512M --numjobs=1 --iodepth=32 \ --ioengine=libaio --direct=1 --group_reporting ``` If `iodepth=1` latency is already in the tens of ms, the NBD/viperblock backend is fundamentally slow per-I/O. If `iodepth=1` is reasonable but `iodepth=32` explodes, there is a serialization or flush amplification problem in the stack. **Before/after comparison** (run with target QEMU flags applied): ```sh fio --name=randrw_70_30 --directory=$HOME/bench --rw=randrw --rwmixread=70 \ --bs=4k --size=512M --numjobs=4 --iodepth=32 \ --ioengine=libaio --direct=1 --group_reporting ``` ## Future Work - **Post-fix benchmarks**: Re-run fio with the new QEMU flags and 128MB cache to quantify improvement - **Bare-metal benchmarks**: The pre-fix numbers were from a nested Proxmox environment (double virtualization). Bare-metal numbers will show the true NBD overhead - **NBD 16-request limit**: Investigate multi-connection NBD or QEMU patches to raise the in-flight cap - **Socket buffer tuning**: Test `SO_SNDBUF`/`SO_RCVBUF` sizing on NBD Unix sockets - **nbdkit threading**: nbdkit `--threads` parameter (defaults to 16 for parallel plugins) — verify this matches or exceeds the NBD in-flight limit - **Dynamic cache sizing**: Scale viperblock cache based on available system memory rather than a fixed 128MB