Benchmarking VPS Disk I/O with fio and dd: Reading IOPS, Latency, and Throttle Limits

Almost every “my VPS feels slow” ticket ends with a dd test that says 1.2 GB/s and proves nothing. This is a job-file-first approach to disk benchmarking that produces numbers you can actually act on: random IOPS at realistic queue depth, tail latency, and a repeatable way to catch storage throttling in the act.

Why dd numbers are misleading

# the classic (and useless) test
dd if=/dev/zero of=/var/tmp/test bs=1M count=2048 conv=fdatasync
# 2.0 GB copied (2147483648 bytes), 1.5 s, 1.3 GB/s

That 1.3 GB/s is the host page cache plus log buffer merging, not the device. dd cannot issue parallel requests, cannot report latency percentiles, and cannot express a mixed read/write pattern. It is still useful for exactly one thing: finding out whether a full disk will accept writes at all.

Install fio and run a 60-second 4K random test

sudo apt update && sudo apt -y install fio
mkdir -p /var/tmp/fiotest

cat > /var/tmp/fiotest/rand4k.fio <<'EOF'
[global]
directory=/var/tmp/fiotest
size=2G
runtime=60
time_based=1
group_reporting=1
lat_percentiles=1
percentile_list=50:95:99:99.9
norandommap=1
randrepeat=0

[randwrite-qd1]
rw=randwrite
bs=4k
iodepth=1

[randwrite-qd32]
rw=randwrite
bs=4k
iodepth=32
EOF

sudo fio /var/tmp/fiotest/rand4k.fio

Reading the output

MetricExample (good NVMe tier)Example (throttled shared storage)
randwrite 4k qd1 IOPS18,000420
randwrite 4k qd32 IOPS96,0001,900
clat p50 / p990.05 ms / 0.31 ms2.3 ms / 41 ms
Sequential 1M read3.1 GB/s210 MB/s
IOPS at 90 s vs 5 s-4%-88%

Two rules of thumb: a healthy NVMe-backed plan keeps qd1 4K writes above 5,000 IOPS and p99 latency under 1 ms. If qd32 barely improves on qd1 (under 2x), the provider is capping queue depth or IOPS – parallelism is being queued artificially.

Sequential and mixed workloads

cat > /var/tmp/fiotest/seq.fio <<'EOF'
[global]
directory=/var/tmp/fiotest
size=4G
runtime=45
time_based=1
group_reporting=1
[seqread]
rw=read
bs=1M
iodepth=8
[mixed-70-30]
rw=randrw
rwmixread=70
bs=16k
iodepth=16
EOF
sudo fio /var/tmp/fiotest/seq.fio --output-format=normal,terse

The mixed 70/30 test is the closest single proxy for a database plus web server sharing one volume. If mixed IOPS is less than 40% of the pure-write figure, expect query latency to spike whenever a backup or image resize runs.

Catching throttling in the act

Run the same job continuously and print a line per 10-second window. A flat curve means real capacity; a step down that never recovers means a token-bucket cap. The watchdog below logs the cliff with a timestamp.

for i in $(seq 1 30); do
  fio --name=watch --directory=/var/tmp/fiotest --rw=randwrite --bs=4k \
      --iodepth=16 --size=1G --runtime=10 --time_based --group_reporting \
      --output-format=json 2>/dev/null | \
  python3 -c "import json,sys;d=json.load(sys.stdin)['jobs'][0];print(f\"{__import__('time').strftime('%H:%M:%S')} iops={d['write']['iops']:.0f} p99={d['write']['clat_ns']['percentile']['99.000000']/1e6:.2f}ms\")"
done | tee /var/tmp/fiotest/ramp.log

Confirm a provider cap rather than a slow device by checking the accounting counters – container-based VPSes expose the throttle events directly:

cat /sys/fs/cgroup/io.stat 2>/dev/null
cat /sys/fs/cgroup/blkio/*.throttle.io_service_bytes 2>/dev/null
iostat -x 5 3 | egrep 'Device|nvme|vda|sda'

Pick the test that matches your workload

Workloadfio parametersMetric to watch
MySQL / PostgreSQL OLTPrandrw, bs=16k, rwmixread=70, iodepth=32p99 clat
Redis AOF or WAL-heavy writeswrite, bs=4k, iodepth=1, fsync=1IOPS at qd1
Backup and log shippingwrite, bs=1M, iodepth=8sustained MB/s
Static file servingrandread, bs=128k, iodepth=16MB/s and CPU cost
Container image pullsrandread, bs=1M, numjobs=4aggregate MB/s

Add --fsync=1 for database-style writes and the numbers will fall sharply on consumer-grade storage. That gap between buffered and fsync throughput is frequently larger than the difference between two hosting tiers, which is exactly why a single buffered test can lead you to the wrong conclusion.

Turn the numbers into a decision

  • Keep the JSON output of every run. A month-old baseline is the only way to prove a node got busier.
  • Benchmark at the traffic hour, not at 3 a.m., and note the time in the filename.
  • Test the volume you actually use – separate data volumes can be faster or slower than the root disk.
  • Re-run after any plan change; provider migrations reset your assumptions.

Keep a baseline file, not a screenshot

A benchmark is only useful if it can be compared later. Write results to a dated file with the environment attached, so a support ticket or a plan upgrade can be judged against evidence rather than memory.

mkdir -p ~/bench
OUT=~/bench/$(date -u +%Y%m%dT%H%MZ)-$(hostname).txt
{
  echo "== environment"; lscpu | egrep 'Model name|CPU\(s\)'; uname -r; df -h /
  echo "== randread 4k qd32"
  fio --name=r --directory=/var/tmp/fiotest --rw=randread --bs=4k --iodepth=32 \
      --size=2G --runtime=30 --time_based --group_reporting --output-format=json
} >> "$OUT" 2>&1
grep -o '"iops" : [0-9.]*' "$OUT" | head -3

Run it quarterly, and again the day after any performance ticket. The difference between the same iops measurement at 03:00 and at 09:00 tells you more about a provider than a year of uptime graphs.

If your own numbers show a cliff under load, no guest-side tuning will recover it – the storage class is the limit. Comparing guarantees, dedicated vCPU, and dedicated volumes is the next step; the VPS plans with full root access page lays out the tiers this test was written against.

Leave a Reply