A VPS Disk Benchmark Needs Latency, Not Just IOPS
Last edited on August 11, 2026

One fio run on the reproduced VPS delivered 1,562 read IOPS at queue depth 1. Changing only queue depth to 32 raised the headline to 52,649 IOPS—about 34 times higher—while median completion latency rose from 0.264 ms to 0.440 ms and p99.9 rose from 5.210 ms to 5.997 ms. More work finished per second because more work was outstanding; individual operations did not become faster.

That contrast is why a VPS disk benchmark needs a declared workload, latency percentiles, repeated windows and safe target controls beside IOPS. The method below measures four different questions: low-queue responsiveness, high-queue parallel throughput, a bounded 70/30 read/write mix, and one fdatasync after every write. No one profile is a universal VPS grade.

Use this workflow if you can connect over SSH, install or run fio, create a 512 MiB temporary file, and schedule a quiet test window authorized by the provider. The reproduced environment was Debian 13, Linux 6.12.96, ext4 and fio 3.39. Every write targets one guarded regular file under /var/tmp; nothing points at a raw block device.

IOPS and Latency Answer Different Buyer Questions

IOPS counts completed input/output operations per second. Bandwidth counts bytes per second. Completion latency (clat in fio output) measures how long an individual operation takes after submission. A percentile then answers a distribution question: p50 is the median, p95 covers 95% of observed operations, and p99.9 leaves 0.1% above it.

Queue depth changes the experiment. At depth 1, one operation is outstanding, so the result emphasizes serial responsiveness. At depth 32, fio can keep 32 requests in flight with an asynchronous engine. Storage can overlap work and report far more IOPS even when each operation waits longer. The official fio documentation treats workload pattern, block size, I/O engine and depth as separate inputs for exactly this reason.

Profile Question Fixed inputs Primary evidence Buyer use
4 KiB random read, QD1 How quickly does one small read finish? 1 job, direct I/O, 12 s p50, p95, p99, p99.9 Interactive and lightly queued work
4 KiB random read, QD32 How much parallel read work can the path retire? Same file and read pattern IOPS plus latency Busy queues and upper concurrency
4 KiB 70/30 random, QD4 How does a modest mixed queue behave? 1 job, fixed mix Read/write IOPS and tails Database-shaped comparison, not a database promise
8 KiB random write + fdatasync What does one durability request cost? 1 job, one sync per write Sync p50 through p99.9 Transaction-like durability sensitivity

Capacity is a different decision. Voxfor’s website storage sizing method measures how many bytes a workload needs and how much operational reserve to leave; it does not predict response time. Likewise, QCOW2 allocation evidence explains physical versus virtual file size, not IOPS or tail latency.

Percentile vocabulary can also mislead across domains. Do not import 95th-percentile bandwidth billing arithmetic into fio: bandwidth billing ranks interval rates and usually discards a contract-defined upper tail, whereas fio latency percentiles rank individual I/O completion times. Same word, different population and decision.

Freeze a Safe Scope and Prepare the Target

Oracle’s current fio command reference warns against write workloads on an in-use device. For a VPS comparison, a regular file inside the filesystem that will hold the application is safer than a raw system disk. It includes filesystem behavior, which is appropriate when the future workload also uses that filesystem.

Keep every block in one Bash shell so LAB, MARKER and TARGET remain defined. The guard refuses an existing path, rejects tmpfs, requires 2 GiB free, fixes the test-file size at 512 MiB and declares the target as a nonexistent regular-file path.

set -Eeuo pipefail
LAB=/var/tmp/voxfor-fio-vps-storage-lab
MARKER="$LAB/.voxfor-fio-vps-storage-lab"
TARGET="$LAB/fio-target.bin"
EXPECTED_MARKER='voxfor-fio-vps-storage-lab-v1'

if [[ -e "$LAB" ]]; then
  printf 'Refusing existing lab path: %s\n' "$LAB" >&2
  exit 2
fi
mkdir -m 700 "$LAB"
printf '%s\n' "$EXPECTED_MARKER" > "$MARKER"

[[ "$(findmnt -T /var/tmp -n -o FSTYPE)" != tmpfs ]]
[[ ! -b "$TARGET" && ! -e "$TARGET" ]]
df -B1 --output=avail /var/tmp |
  awk 'NR==2 { exit !($1 >= 2147483648) }'
{
  printf 'tested_at_utc=%s\n' "$(date -u +%FT%TZ)"
  printf 'fio_version=%s\n' "$(fio --version)"
  printf 'kernel=%s\n' "$(uname -r)"
  printf 'filesystem=%s\n' "$(findmnt -T /var/tmp -n -o FSTYPE)"
  printf 'target_type=regular-file\n'
  printf 'target_size_bytes=536870912\n'
} | tee "$LAB/environment.txt"

direct=1 asks fio to use non-buffered I/O where the platform supports it. It keeps the guest page cache from becoming the result, but it does not prove that every hypervisor, controller or storage-backend cache was bypassed. A 512 MiB file and 12-second windows deliberately bound impact; longer, larger steady-state tests may be needed for purchase acceptance after provider approval.

Populate the file once and force its final data to stable storage before read tests. The post-command checks prove that the target is a regular, non-symlink file with the expected byte count.

fio --name=prepare-file --filename="$TARGET" --size=512M \
  --rw=write --bs=1M --ioengine=psync --direct=1 \
  --end_fsync=1 --output-format=json \
  --output="$LAB/prepare.json"

[[ -f "$TARGET" && ! -L "$TARGET" && ! -b "$TARGET" ]]
[[ "$(stat -c %s "$TARGET")" -eq 536870912 ]]

Run these commands only when competing work is quiet enough to make the window meaningful. Record backups, deployments, package updates and known maintenance beside the receipt rather than pretending a noisy window is a permanent storage label.

Low Queue Depth Exposes Responsiveness and Variation

Start with 4 KiB random reads, one job and depth 1. Three identical runs belong to one evidence block because they answer one question: how stable is the low-queue result during this comparison window? randrepeat=1 keeps fio’s pseudorandom sequence reproducible; it does not make changing host contention disappear.

for run in 1 2 3; do
  fio --name="qd1-randread-run-$run" \
    --filename="$TARGET" --size=512M \
    --rw=randread --bs=4k --ioengine=libaio --direct=1 \
    --iodepth=1 --numjobs=1 --time_based=1 --runtime=12 \
    --randrepeat=1 --group_reporting=1 \
    --percentile_list=50:95:99:99.9 \
    --output-format=json --output="$LAB/qd1-run-$run.json"
done

Across the reproduced runs, read IOPS fell from 1,561.7 to 1,269.6; the min-to-max span was 20.21% of the three-run mean. Median latency ranged from 0.264 to 0.309 ms, while p99.9 ranged from 5.210 to 6.783 ms. Selecting only the fastest run would hide material short-window variation.

LinuxBlog’s 2026 VPS IOPS and latency comparison is useful because it publishes low-depth raw results and makes tail latency visible. This article does not reuse its 0.3 ms line as a universal NVMe cutoff: instance class, filesystem, throttles, backend topology, test size and workload all affect the observation. Compare declared candidates under the same profile and treat application requirements as the acceptance boundary.

Higher Queue Depth Shows Parallel Capacity, Not Faster I/O

Now change only iodepth from 1 to 32. The file, engine, block size, job count, random sequence and runtime remain fixed, so the contrast isolates outstanding concurrency more cleanly than a different all-purpose benchmark script would.

fio --name=qd32-randread \
  --filename="$TARGET" --size=512M \
  --rw=randread --bs=4k --ioengine=libaio --direct=1 \
  --iodepth=32 --numjobs=1 --time_based=1 --runtime=12 \
  --randrepeat=1 --group_reporting=1 \
  --percentile_list=50:95:99:99.9 \
  --output-format=json --output="$LAB/qd32.json"

The QD32 run reached 52,648.7 IOPS and 205.66 MiB/s. Against QD1 run 1, read IOPS rose roughly 34×, p50 rose about 67%, and p99.9 rose about 15%. A high-depth headline therefore describes parallel capacity under that queue, not the time a serial request will wait.

QD1 and QD32 fio result comparisonThe same 4 KiB random-read file test rises from 1,562 IOPS at queue depth one to 52,649 IOPS at queue depth thirty-two, while median latency rises from 0.264 to 0.440 milliseconds and p99.9 latency rises from 5.210 to 5.997 milliseconds.QD1QD324 KiB read IOPS1,56252,649p50 latency0.264 ms0.440 msp99.9 latency5.210 ms5.997 ms
Same file, fio engine, 4 KiB random-read pattern and 12-second window: QD32 retires far more work per second while both displayed latency percentiles rise. The values describe this reproduced host, not a universal VPS threshold.

ARM’s block-storage fio learning path connects its profile to a declared logging workload and then compares storage types against that requirement. Apply the same discipline here: a worker pool may benefit from parallel capacity, while a serial metadata lookup or lightly loaded database query may care more about low-queue latency.

Mixed I/O and fdatasync Add Workload Boundaries

Read-only tests omit write interference. A bounded depth-4, 70/30 random mix adds concurrent reads and writes without claiming to simulate every database. Keep the ratio and depth identical across candidate servers.

fio --name=qd4-mixed-70-30 \
  --filename="$TARGET" --size=512M \
  --rw=randrw --rwmixread=70 --bs=4k \
  --ioengine=libaio --direct=1 \
  --iodepth=4 --numjobs=1 --time_based=1 --runtime=12 \
  --randrepeat=1 --group_reporting=1 \
  --percentile_list=50:95:99:99.9 \
  --output-format=json --output="$LAB/mixed.json"

This host completed 4,608.2 read IOPS and 1,975.6 write IOPS. Read p99.9 was 4.948 ms; write p99.9 was 5.014 ms. Those values are useful only beside the exact ratio, depth and direct-I/O settings. Calling the profile “database performance” would be broader than the evidence.

Durability-sensitive applications may wait for a flush rather than a normal write completion. The next profile uses synchronous psync, an 8 KiB random write and fdatasync=1, which asks fio to synchronize file data after every write. It is intentionally separate from the mixed profile because it answers a different reader decision.

fio --name=fdatasync-8k-write \
  --filename="$TARGET" --size=512M \
  --rw=randwrite --bs=8k --ioengine=psync --direct=1 \
  --iodepth=1 --numjobs=1 --fdatasync=1 \
  --time_based=1 --runtime=12 --randrepeat=1 \
  --group_reporting=1 --percentile_list=50:95:99:99.9 \
  --output-format=json --output="$LAB/fdatasync.json"

Observed write throughput was 573.9 IOPS or 4.48 MiB/s. More importantly, fio’s sync-latency distribution showed p50 0.545 ms, p95 2.703 ms, p99 4.145 ms and p99.9 6.652 ms. Compare that distribution when the intended application confirms durability on commits; ignore it when the application does not use this persistence pattern.

Turn Seven JSON Files Into One Comparison Receipt

Raw JSON preserves more evidence than a screenshot or copied IOPS line. The parser below reads the first grouped job in each file, extracts identical percentiles, computes QD1 spread and keeps read and write directions separate. No provider label or pass threshold is embedded.

python3 - "$LAB" <<'PY' | tee "$LAB/summary.txt"
import json, pathlib, statistics, sys

lab = pathlib.Path(sys.argv[1])
def load(name):
    return json.loads((lab / name).read_text())['jobs'][0]
def pct(metric, value):
    return int(metric['clat_ns']['percentile'][f'{value:.6f}'])
def line(name, direction, job):
    metric = job[direction]
    return (
        f'{name} {direction}_iops={metric["iops"]:.1f} '
        f'{direction}_mib_s={metric["bw_bytes"]/1048576:.2f} '
        f'{direction}_p50_ms={pct(metric,50)/1e6:.3f} '
        f'{direction}_p95_ms={pct(metric,95)/1e6:.3f} '
        f'{direction}_p99_ms={pct(metric,99)/1e6:.3f} '
        f'{direction}_p99_9_ms={pct(metric,99.9)/1e6:.3f}'
    )

qd1 = [load(f'qd1-run-{run}.json') for run in (1, 2, 3)]
for index, job in enumerate(qd1, 1):
    print(line(f'qd1_run={index}', 'read', job))
values = [job['read']['iops'] for job in qd1]
print(
    f'qd1_repeat_iops_min={min(values):.1f} '
    f'qd1_repeat_iops_max={max(values):.1f} '
    f'qd1_repeat_iops_spread_pct='
    f'{(max(values)-min(values))/statistics.mean(values)*100:.2f}'
)
print(line('qd32', 'read', load('qd32.json')))
mixed = load('mixed.json')
print(line('mixed_qd4', 'read', mixed))
print(line('mixed_qd4', 'write', mixed))
sync_job = load('fdatasync.json')
print(line('fdatasync_qd1', 'write', sync_job))
table = sync_job['sync']['lat_ns']['percentile']
print(
    'fdatasync_latency '
    f'p50_ms={int(table["50.000000"])/1e6:.3f} '
    f'p95_ms={int(table["95.000000"])/1e6:.3f} '
    f'p99_ms={int(table["99.000000"])/1e6:.3f} '
    f'p99_9_ms={int(table["99.900000"])/1e6:.3f}'
)
print('receipt=complete')
PY
environment=Debian 13 kernel=6.12.96+deb13-amd64 filesystem=ext4 fio=3.39
qd1_run=1 read_iops=1561.7 read_mib_s=6.10 read_p50_ms=0.264 read_p95_ms=2.023 read_p99_ms=3.064 read_p99_9_ms=5.210
qd1_run=2 read_iops=1503.6 read_mib_s=5.87 read_p50_ms=0.272 read_p95_ms=2.146 read_p99_ms=3.588 read_p99_9_ms=5.865
qd1_run=3 read_iops=1269.6 read_mib_s=4.96 read_p50_ms=0.309 read_p95_ms=2.507 read_p99_ms=4.047 read_p99_9_ms=6.783
qd1_repeat_iops_min=1269.6 qd1_repeat_iops_max=1561.7 qd1_repeat_iops_spread_pct=20.21
qd32 read_iops=52648.7 read_mib_s=205.66 read_p50_ms=0.440 read_p95_ms=1.696 read_p99_ms=3.129 read_p99_9_ms=5.997
mixed_qd4 read_iops=4608.2 write_iops=1975.6 read_p99_9_ms=4.948 write_p99_9_ms=5.014
fdatasync_qd1 write_iops=573.9 write_mib_s=4.48 sync_p50_ms=0.545 sync_p99_9_ms=6.652
receipt=complete

The reproduced receipt is complete when the marker matches, the target remains a non-symlink regular file of exactly 536,870,912 bytes, all seven expected fio JSON files exist, every fio job reports zero errors, the parser reaches receipt=complete, and the QD1, QD32, mixed and fdatasync rows all expose the declared metrics. A faster number is not itself a pass; acceptance belongs to the workload requirement and a like-for-like candidate comparison.

Compare candidates only after freezing fio version, filesystem, file size, block size, read/write mix, engine, depth, jobs, runtime and test window. Preserve all three QD1 runs rather than an average alone. If a result changes materially across time, collect CPU steal-time evidence and host activity before blaming storage. Collect route-and-direction network tests separately; fio does not measure Internet path quality.

Synthetic storage results cannot guarantee application performance. Add the real database, queue, build, backup or website acceptance test after fio narrows the shortlist. When lifetime billing is part of that shortlist, Voxfor’s lifetime VPS plans provide candidate configurations, but the same fio receipt still has to run on the exact provisioned instance before storage becomes a deciding claim.

FAQ: Buyer Questions That Change the Interpretation

Is fio safe to run on a VPS?

fio is safe only inside an authorized, bounded scope. Use a disposable regular file on the intended filesystem, refuse raw devices, confirm free space, avoid production peaks and remove only the exact guarded path. Write tests can consume I/O capacity and affect neighbors or applications even when they do not overwrite a device.

Which fio metric matters most for a database VPS?

No single fio metric represents every database. Begin with 4 KiB QD1 latency for lightly queued reads, then add a read/write mix, depth and durability pattern based on the actual engine. Finish with an application-level transaction benchmark because locks, cache hit rate, WAL or redo behavior and query shape are outside fio.

Why can queue depth increase IOPS while latency gets worse?

Queue depth gives storage more outstanding operations to overlap, so total completions per second can rise. Each request can still spend longer waiting in the queue or backend. Read IOPS and latency percentiles together; high-depth throughput does not prove low-depth responsiveness.

Does direct=1 bypass every storage cache?

No. direct=1 requests non-buffered I/O and normally bypasses the guest operating system’s page cache. It does not reveal or disable every hypervisor, controller, network-storage or device cache. Record the setting as part of the profile, not as proof of a cache-free physical path.

How many times should a VPS storage benchmark run?

Run several identical windows and keep every raw result. Repeat at representative times when contention may change, then compare spread, median and tail values rather than saving only the peak. Three short runs are a useful minimum receipt here, not a universal steady-state standard.

Can fio prove that a VPS will make a website fast?

No. fio characterizes synthetic storage behavior. Website speed also depends on application code, database design, object/page caching, CPU scheduling, memory pressure, network paths and external services. Use fio to isolate one resource decision, then test the complete customer journey.

Remove Only the Guarded Test Lab

Retain the environment file, summary and raw JSON in an approved evidence location if the comparison supports a purchase or support ticket. The disposable copy below is safe to remove only after its exact path, marker, target type and byte count still match. Files are enumerated; no recursive deletion or broad glob is used.

[[ "$LAB" == /var/tmp/voxfor-fio-vps-storage-lab ]]
[[ -d "$LAB" && ! -L "$LAB" ]]
[[ -f "$MARKER" && "$(cat "$MARKER")" == "$EXPECTED_MARKER" ]]
[[ -f "$TARGET" && ! -L "$TARGET" && ! -b "$TARGET" ]]
[[ "$(stat -c %s "$TARGET")" -eq 536870912 ]]

rm -f -- \
  "$TARGET" \
  "$LAB/prepare.json" \
  "$LAB/qd1-run-1.json" \
  "$LAB/qd1-run-2.json" \
  "$LAB/qd1-run-3.json" \
  "$LAB/qd32.json" \
  "$LAB/mixed.json" \
  "$LAB/fdatasync.json" \
  "$LAB/environment.txt" \
  "$LAB/summary.txt" \
  "$MARKER"
rmdir -- "$LAB"
[[ ! -e "$LAB" ]]
printf 'cleanup=exact-guarded-path-absent\n'

If a test should stop, cancel only the active fio process and run the exact guarded cleanup after it exits. Do not delete /var/tmp, a parent directory, an unresolved variable or any raw device. Cleanup is accepted when /var/tmp/voxfor-fio-vps-storage-lab is absent and the application workload, mount and surrounding filesystem remain unchanged.

The retained outcome is a comparison receipt, not a trophy IOPS number: same profile, visible latency tails, repeated windows, workload-shaped boundaries and no leftover test file.

Share this Post

Leave a Reply

Your email address will not be published. Required fields are marked *