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?— Read-only, run inside the guest. The -x flag gives the extended columns that actually answer the question; without it you get throughput only.
iostat -xz 1 5
Read-only / Safeiostat -xz, and the three columns that matter— Illustrative. r_await and w_await are the average latency in milliseconds as the guest experiences it; aqu-sz is the average queue depth; %util is the share of time at least one request was outstanding.
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— Read-only. Run on the host and compare against the guest capture. Latency present at the host is a host or storage problem; latency present only in the guest is above the host block layer — QEMU, the guest kernel, or a throttle.
iostat -xz 1 5
cat /proc/pressure/io
The comparison is the point. Three cases, three different investigations:
Guest latency
Host latency
Owns the problem
High
High
The storage backend or the device
High
Low
QEMU, an I/O throttle, or the guest’s own configuration
Low
High
Another guest, or a host process — backup, scrub, recovery
ZFS
Read-only / Safeper-vdev latency and throughput— Read-only. The -v flag breaks the pool down by vdev and device, which is how you find the one disk dragging a mirror. -w gives latency histograms instead of averages, which is where tail latency hides.
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— Read-only. osd perf reports commit and apply latency per OSD. One OSD far above its peers is usually a failing device rather than a cluster-wide problem, and it drags every operation that touches it.
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— Creates a temporary volume on the target storage, benchmarks it, and removes it. This consumes real IOPS on shared storage, so it will affect running guests — run it in a window, not during an incident.
Destructiveclean up the scratch volume— Removes the volume and everything on it. Confirm the volume name is the scratch one and not a guest disk before running this — pvesm free does not prompt.
Gives the disk its own I/O thread instead of sharing the main loop
discard=on
Lets the guest return freed blocks to thin storage
ssd=1
Advertises the disk as non-rotational to the guest
virtio-scsi-single
Modern paravirtualised controller, one controller per disk
aio=io_uring
Default 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.
Mode
Host page cache
Guest disk cache
Notes
none
Disabled
writeback
PVE default. Balances performance and safety, better writes
writethrough
Read cache only
writethrough
Better reads; an fsync for each write
writeback
Read and write
writeback
Faster; relies on the guest sending flushes
directsync
Disabled
writethrough
Safest and slowest; an fsync for each write
unsafe
Read and write
writeback
Ignores 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
Q1. Which tool is the standard for measuring Linux storage I/O?
Q2. An SSD without TRIM accumulates write amplification and slows down over its service life.
Q3. A guest disk shows %util at 99.6% on an NVMe-backed storage. What does this tell you?
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.
Q5. Name one VM disk setting that affects performance.
Passing score: 75%. Answers are checked in this browser.