Optimization

Tuning Linux and NVMe Storage for High-IO Workloads on a VPS

High-IO workloads on a VPS live or die by storage latency, not marketing throughput numbers. A database that commits thousands of small transactions per second, a message queue, or a busy web cache all care about how long a single 4K write takes under contention, not how many gigabytes per second the disk can stream sequentially. The problem on a VPS is that you sit on top of a virtualization layer and, often, shared physical storage. Some kernel knobs still help; others do nothing because the bottleneck is a layer you do not control. The only honest way to tune is to measure first, change one thing, and measure again. This article walks through that loop with real tools and explains where tuning stops mattering.

Measure before you touch anything

Never tune from intuition. Establish a baseline with fio, the standard flexible I/O tester, and watch live behavior with iostat. The two workloads worth measuring are small random I/O at shallow queue depth (latency-bound, this is what databases feel) and higher queue depth (throughput and saturation behavior).

A single-queue random read latency test tells you the floor for one request in flight:

fio --name=randread-lat --filename=/var/lib/fiotest \
    --rw=randread --bs=4k --direct=1 --ioengine=libaio \
    --iodepth=1 --numjobs=1 --runtime=120 --time_based \
    --group_reporting

Read the p99 and p99.9 latency from the clat percentiles, not just the average. Tail latency is what users notice. Then test write behavior and higher concurrency by raising --iodepth and switching to --rw=randwrite or --rw=randrw. Use --direct=1 so you measure the device path, not the page cache, and size the test file larger than RAM so caching does not flatter the result.

Do not run write or mixed fio jobs against a raw device or a file that shares a filesystem with live data. Write tests destroy data in their target. Use a scratch file or a disposable volume.

While a job runs, watch the device from another shell:

iostat -xz 1

The columns that matter are r_await and w_await (average read and write service time in milliseconds), aqu-sz (average queue depth), and %util. On a VPS, %util near 100 with high await can mean either your own saturation or a busy noisy neighbor. That ambiguity is the whole story of shared virtualized storage, and we return to it at the end.

Pick the right I/O scheduler for NVMe

Modern NVMe devices have deep hardware queues and reorder internally, so a heavy software scheduler mostly adds CPU overhead and latency. For NVMe, the kernel default is usually none, and Red Hat explicitly recommends leaving it there. Check what your device uses:

cat /sys/block/nvme0n1/queue/scheduler

The current scheduler is shown in brackets. To set none at runtime:

echo none > /sys/block/nvme0n1/queue/scheduler

Guidance for 2026 is straightforward:

  • none for NVMe and high-end SSDs that manage their own queuing. This is the default and the safe choice.
  • mq-deadline for SATA SSDs and spinning disks with mixed workloads, and occasionally for NVMe when strict p99.9 tail-latency bounds matter more than raw throughput.
  • bfq when you need fairness, that is, to stop one process from starving others, at the cost of some throughput.

Inside a VPS you may only see a virtual block device such as vda, where the guest scheduler choice matters far less because the host controls the real device. Measure with fio before and after; if p99 does not move, leave it at the default.

Filesystem and mount options

Mount options give small, reliable wins without touching application code. The most impactful is noatime, which stops the kernel writing an access-time update every time a file is read. On a read-heavy workload that removes a large number of metadata writes.

# /etc/fstab
/dev/nvme0n1p1  /data  ext4  defaults,noatime  0  2

If some tool depends on access times, relatime is the conservative middle ground and is the modern default anyway. Other options worth knowing:

  • XFS: logbufs=8,logbsize=256k can improve write-heavy metadata performance at the cost of memory. XFS is a solid default for large files and parallel I/O.
  • ext4: keep journaling on for safety; a larger commit interval reduces flush frequency but widens the window of data loss on a crash. Understand that trade before changing it.
  • discard: prefer a periodic fstrim timer over the continuous discard mount option, which can add latency on some devices.

Do not disable journaling to chase a benchmark number. The throughput you gain is rarely worth the recovery risk on a production database.

Virtual memory and dirty-page writeback

Linux buffers writes in the page cache and flushes them later. Two sysctl knobs control when that flushing starts: vm.dirty_background_ratio (percentage of memory dirty before background flusher threads begin) and vm.dirty_ratio (the hard limit at which a writing process is blocked to flush synchronously). The defaults, often 10 and 20, allow a large backlog to accumulate, then flush it in a burst that stalls latency-sensitive I/O.

For database and other write-sensitive workloads, lower and smoother is usually better. Common starting points are a background ratio of 5 and a dirty ratio of 15, keeping the background value below the hard limit so flushing starts early:

# /etc/sysctl.d/99-storage.conf
vm.dirty_background_ratio = 5
vm.dirty_ratio = 15
vm.swappiness = 10
sysctl --system

On servers with very large RAM, the _bytes variants (vm.dirty_background_bytes, vm.dirty_bytes) give finer control than percentages, because 10 percent of 64 GB is a huge and bursty backlog. There are no universally correct values; the kernel documentation is explicit that they depend on hardware. Set, run your real workload, and compare p99 write latency.

Rather than hand-tuning every knob, you can start from a maintained profile with tuned:

tuned-adm profile throughput-performance
tuned-adm active

Profiles like throughput-performance set sensible scheduler, writeback, and CPU governor defaults, giving you a reasonable baseline to measure against before micro-tuning.

Database-oriented tips

Databases have their own caches and writeback logic, so kernel tuning should complement, not fight, the engine:

  • Align the database page size with your fio block size when benchmarking. PostgreSQL and MySQL/InnoDB use 8K and 16K pages respectively, so a 4K test understates their real behavior.
  • Let the database own most of the caching. Set InnoDB innodb_buffer_pool_size or PostgreSQL shared_buffers deliberately, and use direct I/O flush methods where supported so the engine, not the kernel, controls durability.
  • Put the write-ahead log or redo log on storage with predictable low latency; commit latency is dominated by fsync time on that path.
  • Keep vm.swappiness low so the database working set is not paged out under memory pressure.

Always benchmark with a workload that resembles production. A synthetic 100 percent random-read test proves little about a transactional commit path.

When tuning does not help

This is the honest part. On a VPS with shared, virtualized storage, several knobs have little or no effect because the real device, its scheduler, and its queue live on the host you do not control. A guest-side scheduler change on a thin virtual block device often does nothing measurable. If a hypervisor-imposed IOPS cap is your ceiling, no amount of sysctl tuning raises it. And when iostat shows high await with low local activity, you may be watching a noisy neighbor rather than your own saturation.

How to tell the difference: run the same fio baseline at different times of day and look at variance. Stable numbers point to a real, tunable local limit. Wide swings that correlate with nothing you did point to contention or a cap on the platform, which tuning cannot fix. NVMe-backed VPS plans give you the headroom to make local tuning worthwhile, but even then the discipline is the same: measure, do not guess.

Wrap-up

Effective storage tuning is a measurement loop, not a list of magic values. Baseline with fio at low and high queue depth, watch iostat during load, then change one variable at a time: scheduler to none for NVMe, noatime on the mount, sane dirty-ratio settings, and database caches sized on purpose. Re-measure p99 latency after each change and keep only what moves it. And accept the boundary: on shared virtualized storage, some limits are set by the platform, and the right response is to prove it with numbers rather than tune in the dark. Useful references are the kernel vm sysctl documentation and the fio documentation.