Skip to main content
RunBook Academy

Proxmox VEXVII · Performance EngineeringStorage performance

Storage I/O performance

Intermediate⏱ ~26 minfioiostat

What you'll learn

  • Diagnose storage I/O bottlenecks at the layer that actually owns them
  • Use fio to measure baseline performance without destroying a guest disk
  • Read iostat, zpool iostat and ceph osd perf output and say what each column means
  • Choose a cache mode from what it does to guest flush commands, not from its name
  • Recognise when cache, queue depth, and disk type matter
  • Tune VM disk configuration for performance

Prerequisites

Verified against Proxmox VE 9.2.4 · Proxmox Backup Server 4.2.5 · Ceph Squid / Tentacle · Debian 13 (Trixie) · Linux kernel 7.0 (PVE 9.2 default) · 2026-08-12

Not yet marked complete on this device.

Why this matters in production

Storage is the most common performance bottleneck. Diagnosing it correctly is the difference between a quick fix and a long outage.

It is also the layer most often blamed wrongly, because every slow thing feels like a slow disk from inside a guest. The discipline in this lesson is to measure at each layer and let the numbers say which one owns the problem.

The four numbers

(Recap from Part V.) For every storage workload, know:

  • IOPS (operations per second)
  • Latency (time per operation)
  • Throughput (bytes per second)
  • Queue depth (concurrent in-flight I/Os)

These are not four independent measurements. Three of them are related, and the relationship is the single most useful thing to know when reading a benchmark:

IOPS = queue depth / latency

A device at 1 ms latency serving one request at a time does 1,000 IOPS. The same device at 1 ms latency with 32 requests in flight does 32,000. So a benchmark reporting a big IOPS number tells you nothing until you know the queue depth it used — and a vendor figure quoted at queue depth 256 describes a workload almost no guest generates.

The corollary matters more. If your workload is single-threaded and synchronous — a database committing a transaction, a filesystem writing its journal — its queue depth is 1, and only latency determines its speed. Adding faster devices with more parallelism does not help it at all. This is why “we moved to all-NVMe and the database is the same speed” is a recognisable outcome.

Where the bottleneck lives

flowchart TB
  A[Application write] --> B[Guest kernel + virtio-scsi]
  B --> C[QEMU]
  C --> D[Host block layer]
  D --> E[Storage layer]
  E --> F[Physical device]

Each step can be the bottleneck. Measure at each step.

Guest-side measurement

Read-only / Safewhat does the guest think its disk is doing?
iostat -xz 1 5
Read-only / Safeiostat -xz, and the three columns that matter
# iostat -xz 1
Device  r/s     w/s    rMB/s  wMB/s  r_await  w_await  aqu-sz  %util
sda     412.0  1880.0   6.44   29.38     0.71    18.42   35.12   99.60

Illustrative output

Reading that capture:

  • w_await of 18.4 ms against r_await of 0.71 ms. Writes are 26 times slower than reads. That asymmetry points at the write path specifically — replication, a full cache, or a device whose write latency collapses under sustained load.
  • aqu-sz of 35 means 35 requests are outstanding on average. The guest is asking for far more parallelism than it is getting back.
  • %util at 99.6% is the column most often misread. On a device that can service many requests concurrently — every SSD, every NVMe, every SAN LUN — %util says only that something was in flight, not that the device is saturated. An NVMe drive at 100% %util may be at 5% of its capability. Use latency and queue depth to judge saturation; %util is only meaningful for a single-queue spinning disk.

Host-side measurement

Read-only / Safethe same question, one layer down
iostat -xz 1 5

cat /proc/pressure/io

The comparison is the point. Three cases, three different investigations:

Guest latencyHost latencyOwns the problem
HighHighThe storage backend or the device
HighLowQEMU, an I/O throttle, or the guest’s own configuration
LowHighAnother guest, or a host process — backup, scrub, recovery

ZFS

Read-only / Safeper-vdev latency and throughput
zpool iostat -v 1 3

zpool iostat -w 1 1

arcstat 1 3

An average latency figure hides the tail, and the tail is what a user experiences. -w prints a histogram: a pool whose average is 2 ms but which has a bucket at 500 ms is a pool with a problem, and its average will never say so.

Ceph

Read-only / Safeper-OSD latency, and whether recovery is running
ceph osd perf

ceph -s
ceph osd pool stats

Benchmarking with fio

fio is the standard Linux storage benchmark. Run it on the host to measure storage-level performance, and inside the guest to measure what the guest actually gets.

Service impact possibleallocate a scratch volume and benchmark it
STORAGE=local-zfs
SCRATCH=vm-9999-disk-0

pvesm alloc "$STORAGE" 9999 "$SCRATCH" 32G
TARGET=$(pvesm path "$STORAGE:$SCRATCH")
echo "benchmark target: $TARGET"

fio --name=randread4k --filename="$TARGET" \
  --ioengine=libaio --direct=1 --bs=4k --rw=randread \
  --iodepth=32 --numjobs=4 --runtime=30 --time_based \
  --group_reporting
Destructiveclean up the scratch volume
STORAGE=local-zfs
SCRATCH=vm-9999-disk-0

pvesm list "$STORAGE" | grep 9999
pvesm free "$STORAGE:$SCRATCH"

VM disk tuning

Common optimisations:

SettingEffect
iothread=1Gives the disk its own I/O thread instead of sharing the main loop
discard=onLets the guest return freed blocks to thin storage
ssd=1Advertises the disk as non-rotational to the guest
virtio-scsi-singleModern paravirtualised controller, one controller per disk
aio=io_uringDefault on PVE 9; lower syscall overhead than the older engines

virtio-scsi-single and iothread=1 belong together: the -single variant gives each disk its own controller, which is what makes a per-disk I/O thread possible. Setting iothread=1 on a shared virtio-scsi controller does considerably less.

Cache modes, by what they do to flushes

The cache mode table is usually presented as a performance ranking. It is better understood as two independent questions: does the host page cache get used, and what does the guest disk cache advertise.

ModeHost page cacheGuest disk cacheNotes
noneDisabledwritebackPVE default. Balances performance and safety, better writes
writethroughRead cache onlywritethroughBetter reads; an fsync for each write
writebackRead and writewritebackFaster; relies on the guest sending flushes
directsyncDisabledwritethroughSafest and slowest; an fsync for each write
unsafeRead and writewritebackIgnores flush commands from the guest

Production considerations

Common mistakes

  • Reading %util as saturation. On any device that services requests concurrently it means only that something was in flight. Use latency and queue depth.
  • Quoting an IOPS figure without its queue depth and block size. The number is meaningless without them.
  • Benchmarking with a write pattern against a live guest disk. It overwrites the guest’s filesystem with no prompt.
  • Comparing a vendor benchmark at queue depth 256 with a database at queue depth 1. They describe different machines.
  • Setting iothread=1 without virtio-scsi-single. The per-disk controller is what makes the per-disk thread useful.
  • Treating cache=unsafe as a performance option. It discards flush commands and is the only mode that removes the guest’s durability guarantee.
  • Optimising the wrong layer. Compare guest latency against host latency first; the answer tells you where to work.
  • Enabling discard=on and assuming TRIM works. The guest must run fstrim, and every layer beneath must pass the discard through.
  • Averaging away the tail. zpool iostat -w shows the histogram; the average never reveals a 500 ms bucket.

Key takeaways

  • IOPS = queue depth / latency. A synchronous, flush-heavy workload has queue depth 1 and is bound purely by latency; parallelism cannot help it.
  • Measure at each layer and compare. Guest latency high with host latency low means the problem is between them, not in the storage.
  • %util is not saturation on a concurrent device. Latency and queue depth are.
  • Use zpool iostat -w and ceph osd perf to find the tail and the outlier; one slow OSD slows every write to every PG it belongs to.
  • Benchmark against an allocated scratch volume, never a live guest disk, and record the full fio command line with the result.
  • Four of the five cache modes honour guest flushes; unsafe does not, and it is the only one that removes a durability guarantee.
  • cache=none is the PVE default and is the right starting point.
  • virtio-scsi-single plus iothread=1 belong together.
  • TRIM needs the disk option, the guest timer, and a storage layer that passes discards through.

Knowledge check

Knowledge check · 5 questions

  1. Q1. Which tool is the standard for measuring Linux storage I/O?

  2. Q2. An SSD without TRIM accumulates write amplification and slows down over its service life.

  3. Q3. A guest disk shows %util at 99.6% on an NVMe-backed storage. What does this tell you?

  4. Q4. A database guest is migrated from local NVMe to a healthy Ceph cluster and its transaction rate halves, despite the cluster benchmarking higher on aggregate IOPS. Which statements explain this? Select all that apply.

  5. Q5. Name one VM disk setting that affects performance.

Passing score: 75%. Answers are checked in this browser.