Virtual Disk Performance Tuning: Filesystem, Scheduler and Mount Options Verified with fio

Storage is the component most often misdiagnosed on a virtual server, because the visible device is a virtual block device whose latency, queue depth and throttling behaviour are all decided one layer below the guest. Two identical-looking plans can differ by 20x in random-write IOPS, and a filesystem tuned for local NVMe will behave badly on a network-attached volume. This guide separates the layers — device, scheduler, filesystem, mount options — shows how to measure each with fio, and gives the mount options that actually change results. Everything here has been run on an instance listed under the provider’s VPS plan specifications, which is the first thing to check: the plan’s advertised IOPS class sets the ceiling no tuning can exceed.

Step 1: Identify What Kind of Device You Have

Before tuning, find out whether the block device is local NVMe, a paravirtualised virtio disk backed by a host SSD, or a network volume. The rotational flag and the queue parameters reveal almost all of it:

<code>lsblk -o NAME,SIZE,ROTA,TYPE,MODEL,MOUNTPOINT,DISC-GRAN,DISC-MAX
cat /sys/block/vda/queue/rotational
cat /sys/block/vda/queue/nr_requests
cat /sys/block/vda/queue/scheduler
cat /sys/block/vda/queue/wbt_lat_usec 2>/dev/null</code>

A rotational value of 1 on a virtual disk is common and meaningless — the hypervisor reports HDD semantics for a network volume to avoid guests disabling merging. The reliable signal is measured latency: local NVMe shows p99 write latency in the low hundreds of microseconds, network-attached storage in the low milliseconds with far more variance.

Step 2: Characterise the Device With fio

One benchmark is not enough. Measure four workloads that map to real applications: sequential read (backups restoring, large file serving), sequential write (log-heavy workloads), random 4K read (database index lookups) and random 4K write with queue depth (database commits, journaling).

<code>fio --name=seqread  --rw=read      --bs=1M --size=2G --numjobs=1 --iodepth=8  \
    --ioengine=libaio --direct=1 --runtime=30 --time_based --group_reporting
fio --name=seqwrite --rw=write     --bs=1M --size=2G --numjobs=1 --iodepth=8  \
    --ioengine=libaio --direct=1 --runtime=30 --time_based --group_reporting
fio --name=randread --rw=randread  --bs=4k --size=1G --numjobs=4 --iodepth=32 \
    --ioengine=libaio --direct=1 --runtime=30 --time_based --group_reporting
fio --name=randwrite --rw=randwrite --bs=4k --size=1G --numjobs=4 --iodepth=16 \
    --fsync=1 --ioengine=libaio --direct=1 --runtime=30 --time_based --group_reporting</code>

Always use --direct=1 to bypass the page cache, and --fsync=1 for the write test, otherwise you measure RAM. A typical mid-range virtual disk produces something in this shape:

Workload Throughput / IOPS p50 latency p99 latency
Sequential read 1M QD8 1.1 GB/s — —
Sequential write 1M QD8 620 MB/s — —
Random read 4K QD128 48,000 IOPS 0.9 ms 4.1 ms
Random write 4K fsync 6,800 IOPS 1.2 ms 11.4 ms

The p99/p50 ratio is the number to watch. Read latency that is 4x the median means queueing somewhere in the path — hypervisor, network link or throttle. Latency that is 12x the median on fsync writes is normal for anything involving a network round trip and durability.

Step 3: Scheduler and Queue Depth

For NVMe and virtio-blk devices none is usually correct: the device has its own parallel queues and the kernel scheduler adds overhead without benefit. For older virtio or SATA-backed virtual disks, mq-deadline bounds tail latency better than none under mixed load.

<code># compare without rebooting
echo none        > /sys/block/vda/queue/scheduler
echo mq-deadline > /sys/block/vda/queue/scheduler

# persist
cat >/etc/udev/rules.d/60-io-scheduler.rules <<'EOF'
ACTION=="add|change", KERNEL=="vd[a-z]", ATTR{queue/scheduler}="none"
ACTION=="add|change", KERNEL=="nvme[0-9]n[0-9]", ATTR{queue/scheduler}="none"
EOF</code>

Also raise nr_requests for NVMe devices serving database workloads; the default of 64 on some kernels queues too shallowly for high-IOPS random reads:

<code>echo 1024 > /sys/block/vda/queue/nr_requests</code>

Measure before and after with the random-read fio job. On a virtio disk with a shared backend the gain is often under 3% — the queue was never the limit — which is exactly the kind of result that stops you from chasing the same knob on every host.

Step 4: Filesystem Choice

Three candidates matter on a server: ext4, XFS and btrfs. Measure them on the same device, not across machines.

Filesystem Random 4K write (fsync) Concurrent small files Notes
ext4 6,900 IOPS Fast Mature, best-tested, shrinking not supported
XFS 7,300 IOPS Very fast Scales on large directories, can grow online only
btrfs 3,100 IOPS Moderate Copy-on-write costs on small writes; snapshots excellent

btrfs’s write amplification from copy-on-write is real and shows up most on database-style random writes. It remains the right answer when you need cheap snapshots or checksums, and the wrong answer for an InnoDB data directory on a modest disk.

With the filesystem chosen, format with intent — the inode ratio on XFS matters for workloads that create millions of small files:

<code>mkfs.xfs -f -m reflink=1 -i maxpct=10 -d agcount=8 /dev/vda2
mkfs.ext4 -F -O ^has_journal -E stride=0 -m 1 /dev/vda2   # only if you have a real journal elsewhere</code>

Do not disable the ext4 journal on a VPS. The performance gain is in the noise compared with the recovery risk after a host power event.

Step 5: Mount Options That Matter

These are the options that change measured behaviour, not folklore:

  • noatime — eliminates a metadata write on every file read. On a workload with millions of small reads this is a measurable reduction in write IOPS. Use relatime if anything on the box depends on access times.
  • commit=60 (ext4) — increases the journal commit interval from 5s. Big throughput win for write-heavy sequential workloads, but up to 60s of data loss on a hard host reset. Choose deliberately.
  • discard=async (ext4) or nodiscard — never use synchronous discard on a virtual disk; it turns every delete into a blocking round trip. Prefer a scheduled fstrim.timer.
  • logbsize=256k (XFS) — larger log buffers improve metadata-heavy throughput.
  • data=ordered is the ext4 default and the right one unless you have measured otherwise.
<code># /etc/fstab
UUID=xxxx / ext4 defaults,noatime,commit=60,discard=async 0 1</code>

Step 6: Kernel Writeback Path

Dirty page writeback is where application latency and storage latency meet. If pages accumulate too long, a burst of writeback stalls the application in balance_dirty_pages:

<code>sysctl -w vm.dirty_background_ratio=5
sysctl -w vm.dirty_ratio=15
sysctl -w vm.dirty_expire_centisecs=1500
sysctl -w vm.dirty_writeback_centisecs=300</code>

On a 2 GB instance these values cap background writeback at about 100 MB and foreground stalls at about 300 MB. Watch grep -E 'nr_dirty|nr_writeback' /proc/meminfo under load; steady-state nr_dirty far above the background threshold means the disk cannot absorb the write rate and the application is being throttled by the kernel.

Verification and the Repeatability Rule

Run every fio test three times and report the median. Virtualised storage has enough variance that a single run can be off by 30%. Pin the benchmark to a fixed file size larger than RAM to defeat caching, and keep the same jobs/iodepth across comparisons or you are comparing queue depths rather than devices.

<code>for i in 1 2 3; do
  fio --name=randread --rw=randread --bs=4k --size=1G --numjobs=4 --iodepth=32 \
      --ioengine=libaio --direct=1 --runtime=20 --time_based --output-format=json \
    | jq -r '.jobs[0].read.iops'
done</code>

If the third run is dramatically slower than the first, you have found a burst-credit throttle — many plans allow a high IOPS burst that decays after tens of gigabytes. That is a provisioning fact, not a tuning problem, and the fix is a plan whose sustained IOPS matches your write rate rather than more sysctl edits.

Leave a Reply