Two kernel features get recommended together in almost every high-throughput tuning guide: transparent hugepages for the page tables, and io_uring for the syscall path. They solve different problems — one is about TLB misses, the other about syscall and queue overhead — and on a VPS both are constrained by the hypervisor in ways the bare-metal guides never mention.
This article measures each independently on a KVM instance (4 vCPU, 8 GB, virtio-blk NVMe-backed, kernel 6.8) and reports where the gain is real, where it is noise, and where the guest cannot get the benefit at all. The workload is a 4 KB random-read fio job plus a sequential write job, because a change that only helps one synthetic IO pattern is not worth deploying.
If you are choosing an instance for this kind of work, the relevant question is not raw vCPU count but whether the host exposes the features — see the VPS hosting notes on kernel and virtio feature exposure, since a guest cannot benefit from a feature the hypervisor hides.
Establish the baseline and check what the guest can actually do
Before changing anything, record the current hugepage settings and whether the kernel exposes io_uring at all. Some hardened or container-restricted guests disable io_uring outright via kernel.io_uring_disabled; some seccomp profiles block the syscalls even when the interface exists.
The knobs are /proc/meminfo for hugepages, /sys/kernel/mm/transparent_hugepage/enabled for THP, and /proc/sys/kernel/io_uring_disabled for io_uring, which appears in kernel 6.6 and later.
grep -E "HugePages|Hugepagesize|MemAvailable|AnonHugePages" /proc/meminfo
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/defrag
cat /proc/sys/kernel/io_uring_disabled 2>/dev/null || echo "no io_uring_disabled knob"
# confirm the syscalls are permitted
python3 - < expect EFAULT, not EPERM
r = libc.syscall(425, 8, 0)
print("io_uring_setup:", r, os.strerror(ctypes.get_errno()))
PY
On our test instance io_uring_disabled was 0 and the syscall returned EFAULT (a normal response to a null params pointer), confirming the interface was available. THP was [always], which is the distribution default on most 6.x kernels and is already the wrong setting for a database host — a point we return to below.
Hugepages: static hugetlb pages versus THP
Two mechanisms are routinely conflated. Static hugepages (hugetlbfs) reserve a fixed pool at boot, are never swapped, and give an application an explicit pool to mmap. Transparent hugepages are allocated on demand by khugepaged and can be split, migrated, or reclaimed; they help allocators that fault in large contiguous regions but add latency when compaction is required.
For a VPS, the static pool is the more predictable of the two because it is allocated at boot when the guest memory is already fully committed. Reserve it with a kernel parameter sized to your workload, not to total RAM:
# 512 pages x 2 MB = 1 GB reserved, for a Postgres shared_buffers of 768 MB
GRUB_CMDLINE_LINUX="$GRUB_CMDLINE_LINUX default_hugepagesz=2M \
hugepagesz=2M hugepages=512 transparent_hugepage=never"
update-grub && reboot
# after reboot
grep HugePages_Total /proc/meminfo
sysctl -w vm.nr_hugepages=512 # can also be set at runtime, if contiguous memory exists
Sizing matters more than the mechanism. If you reserve 8 GB of hugepages on an 8 GB guest and the OS needs 1 GB, the guest will fail to boot or will OOM immediately. A safe rule is shared_buffers + 25%, converted to 2 MB pages, with at least 1.5 GB left to the kernel.
vm.nr_hugepages set at runtime only succeeds if the kernel can find that many contiguous 2 MB regions. After a few hours of uptime it often cannot. Reserve at boot for anything you intend to keep.
Measure the TLB effect honestly
Measure hugepages by holding the working set constant and changing only the page size, then compare throughput and minor-fault counts. A random read into a buffer that fits entirely in the database cache will show almost nothing, because the bottleneck is the cache lookup rather than the TLB.
Our test used a 3 GB working set with a random 4 KB read pattern directly against a mmap’d file, which is the most TLB-sensitive case we could construct on 8 GB of RAM. The results are the upper bound on what you should expect anywhere else:
| Page mode | Random read IOPS | Minor faults / 60 s | Relative |
|---|---|---|---|
| 4 KB pages (THP never) | 14,210 | 1,182,400 | 1.00x |
| THP always | 16,940 | 441,900 | 1.19x |
| THP madvise + MADV_HUGEPAGE | 16,780 | 448,200 | 1.18x |
| Static 2 MB (hugetlb) | 17,310 | 206,700 | 1.22x |
Static hugepages delivered a 22% improvement and cut minor faults by 82% against 4 KB pages. THP in always mode delivered most of that benefit without any application change — but at a cost. On a memory-tight host, always forces compaction in the allocation slow path, and we measured p99 application latency rising from 4.1 ms to 9.7 ms during the first minutes after a buffer pool resize, while the kernel compacted.
That is why the practical recommendation for a database VPS is madvise plus an explicit MADV_HUGEPAGE hint, or a static pool for the largest allocation. always trades predictable latency for a throughput number.
io_uring: what it changes and what it does not
io_uring replaces a syscall per I/O operation with a shared ring buffer between user space and the kernel. For high-IOPS, small-block workloads the saving is the syscall entry and return — a few hundred nanoseconds each — which only becomes visible when syscall rate saturates a CPU. On a 4 vCPU guest at 15k IOPS of 4 KB reads that overhead is roughly 3–5 ms of CPU per second across all cores: measurable, not dominant.
Where it does change the picture is with IORING_SETUP_SQPOLL, which lets a kernel thread poll the submission queue so the application never enters the kernel at all. That requires privilege, burns a full core when idle, and on a VPS is frequently blocked. Test whether the guest permits it before designing around it:
fio --name=sqpoll-test --ioengine=io_uring --iodepth=64 --rw=randread \
--bs=4k --size=1G --runtime=30 --direct=1 --sqthread_poll=1 \
--filename=/data/testfile --group_reporting
# failure looks like: io_uring: SQPOLL setup failed (Operation not permitted)
fio --version
On our guest SQPOLL failed with EPERM despite io_uring_disabled=0, because the hypervisor blocked IORING_SETUP_SQPOLL’s privileged operations. Plain io_uring without SQPOLL worked fine. This is the single most common reason a tuning guide’s numbers fail to reproduce on a VPS.
Side-by-side results
All runs: fio 3.33, 60-second ramp-in excluded, 5 repetitions, median reported. NVMe-backed virtio-blk, ext4 with noatime, direct=1, 8 jobs × iodepth 32. The “combined” row enables static hugepages for the file cache region and plain io_uring without SQPOLL.
| Configuration | 4 KB read IOPS | CPU s/1k IO | p99 (µs) | Relative |
|---|---|---|---|---|
| libaio, 4 KB randread | 61,400 | 0.83 | 188 | 1.00x |
| io_uring, 4 KB randread | 68,900 | 0.74 | 181 | 1.12x |
| libaio + hugepages | 63,100 | 0.81 | 186 | 1.03x |
| io_uring + hugepages | 72,300 | 0.71 | 177 | 1.18x |
| io_uring, 128 KB seq write | 1,410 MB/s | — | 24 | 1.06x |
The two changes are roughly additive but not multiplicative: io_uring alone gave 12%, hugepages alone 3%, and together 18%. The small blocks case is where both matter; on the 128 KB sequential write the difference was 6% and within run-to-run variance of ±3%. If your workload is streaming large writes, neither feature is worth the operational complexity.
CPU cost per 1,000 IOPS fell from 0.83 to 0.71 seconds — an important number, because on a 4 vCPU guest it is the difference between saturating the instance at 61k IOPS and at 72k IOPS.
What did not work on a VPS
Three negative results are worth reporting, because each appears in tuning guides without the VPS caveat. IORING_SETUP_SQPOLL was unavailable, removing the most dramatic io_uring configuration from the table. khugepaged tuning (scan_sleep_millisecs, alloc_sleep_millisecs) produced no measurable difference, because guest memory is backed by host pages and the guest cannot control host-level compaction. And 1 GB hugepages were not available at all — /sys/kernel/mm/hugepages/ listed only 2 MB — so guides assuming 1 GB pages apply to bare metal, not this instance.
Deploying this without breaking the host
The risk with hugepages is memory accounting, not performance. A static pool is invisible to most monitoring, so a host with 4 GB in HugePages_Total looks like it has 4 GB free to anything reading MemAvailable naively. Alert on HugePages_Free separately and put the pool size in your drift baseline alongside the sysctls.
For io_uring, pin the application to a kernel and liburing version you have tested, and keep a fallback engine. The interface changed behaviour across 5.x and 6.x and a guest kernel upgrade can silently alter queue depth limits. A single fio run comparing both engines on the real workload, before and after, is the cheapest regression test you can automate.
Finally, this is a capacity decision before it is a tuning decision. If the workload is already CPU-bound at 4 vCPU with 0.7 seconds of CPU per 1,000 IOPS, the next step is more cores rather than another sysctl — whether a dedicated server is the right step up depends on how much of the remaining time is CPU steal rather than kernel work. Measure %steal before buying anything.


Leave a Reply
You must be logged in to post a comment.