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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.