VPS Performance Tuning: 12 Kernel Settings Measured With Real Before/After Benchmarks

Most “12 settings to tune” articles hand you a wall of sysctl values with no evidence they help. This one is different: every setting below was applied to a 2 vCPU / 4 GB KVM instance, and each row was measured before and after with the same tool so you can see whether the change actually moved a number. If a knob only helps a specific workload, that is stated plainly.

The baseline is a fresh Debian 12 box on a provider handing out a 2 vCPU / 4 GB instance with NVMe storage. You can replicate the methodology on any cloud VPS instance provisioned for benchmarking, and the measurement harness is the same one described in the main performance guide on the site.

How every measurement was taken

# CPU + scheduler latency
sysbench cpu --cpu-max-prime=20000 --threads=2 run

# Memory bandwidth
sysbench memory --memory-block-size=1M --memory-total-size=20G run

# Disk random 4k read, queue depth 32, 30s
fio --name=randread --ioengine=libaio --rw=randread --bs=4k \
    --iodepth=32 --direct=1 --runtime=30 --time_based --filename=/dev/nvme0n1

# TCP single-stream throughput across a 40 ms link
iperf3 -c <peer> -t 30 -O 5

Each sysctl change is applied with sysctl -w, the microbenchmark re-run three times, and the median recorded. Scheduler changes require the workload to be restarted because the scheduler is chosen at task creation.

1. Scheduler: latency vs throughput

kernel.sched_min_granularity_ns = 2000000
kernel.sched_wakeup_granularity_ns = 3000000
kernel.sched_migration_cost_ns = 500000

On the sysbench CPU test the throughput delta was under 1% — these are not throughput knobs. Where they matter is tail latency: a PHP-FPM workload with pm.max_requests=0 showed p99 request latency drop from 41 ms to 33 ms after lowering sched_wakeup_granularity_ns. Skip these if you run batch jobs; adopt them if you serve interactive traffic.

2. TCP congestion control

net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq

Default CUBIC gave 118 Mbit/s single-stream on the 40 ms link; BBR gave 273 Mbit/s — a 2.3x gain that is almost entirely because BBR ignores loss as a congestion signal. On a 2 ms localhost-to-localhost benchmark BBR showed no benefit, confirming the win is latency-driven, not CPU-driven.

3. Send and receive buffers

net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
net.ipv4.tcp_window_scaling = 1

Without autotuning the window capped throughput at roughly 60 Mbit/s regardless of link speed. Raising the max to 16 MB let BBR fill the pipe to 273 Mbit/s. The cost is memory: a single busy socket can now hold 32 MB. On a 4 GB box, watch ss -m output — if the sum of socket buffers approaches the container memory limit, trim the max values.

4. Connection backlog

net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 8192
net.core.netdev_max_backlog = 8192
net.ipv4.tcp_syncookies = 1

Under a 5000-connection wrk ramp, the default 128 backlog dropped 412 SYN packets; after raising it to 4096 the drop count returned to zero. ss -lnt shows a Send-Q that grows monotonically if the backlog is too small — that is the signal to raise it.

5. File descriptor and port limits

fs.file-max = 2097152
net.ipv4.ip_local_port_range = 10240 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15

Ephemeral port exhaustion appeared at ~28000 concurrent outbound connections with the default 32768-60999 range. Widening the range and enabling tcp_tw_reuse lifted the ceiling past 55000. Remember to raise the per-process limit too, via limit_nofile in the systemd unit — the kernel sysctl alone does not lift the process cap.

6. Dirty page writeback

vm.dirty_ratio = 10
vm.dirty_background_ratio = 3
vm.dirty_expire_centisecs = 1500
vm.dirty_writeback_centisecs = 500

The default 20% dirty ratio on a 4 GB box lets 800 MB of dirty pages accumulate, producing multi-second IO stalls when writeback finally fires. Dropping to 10% halved the maximum %util spike measured by iostat -x 1 during an rsync copy from 98% to 61%. This is the single most impactful change for boxes that do both serving and background copying.

7. Swappiness and overcommit

vm.swappiness = 10
vm.vfs_cache_pressure = 50
vm.overcommit_memory = 0

Lowering swappiness from 60 to 10 kept hot anonymous pages resident; a PostgreSQL workload saw p95 query time drop 12% because pages stopped migrating to the NVMe swap partition. Do not set swappiness to 0 — that disables proactive reclaim and makes OOM events more likely under pressure.

8. Transparent huge pages

echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag

Switching from always to madvise removed periodic 40-80 ms latency spikes caused by khugepaged compaction. Databases that explicitly call madvise(MADV_HUGEPAGE) still get huge pages; everything else runs on 4k pages with far more predictable latency. This is a tail-latency fix, not a throughput fix.

9. CPU frequency governor

cpupower frequency-set -g performance
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor

On bare metal the performance governor cut sysbench CPU variance substantially. Inside a KVM guest the governor is often virtualised and the file may not exist — if scaling_governor is missing, your host controls frequency and there is nothing to tune here. Check before assuming you changed anything.

10. I/O scheduler and queue depth

echo none > /sys/block/nvme0n1/queue/scheduler
echo 1024 > /sys/block/nvme0n1/queue/nr_requests
echo 2 > /sys/block/nvme0n1/queue/rq_affinity

For NVMe, none (no scheduler) beat mq-deadline by 8% on random 4k IOPS because the device has more queue depth than any single software scheduler can exploit. For SATA SSDs and spinning disks, keep mq-deadline. Setting the scheduler to none on a SATA device that is actually a bottleneck will hurt, so identify your device type with lsblk -d -o name,rota,tran first.

11. Logging and journald

[Journal]
SystemMaxUse=200M
RateLimitIntervalSec=30s
RateLimitBurst=1000
ForwardToSyslog=no

Journald defaulted to unbounded growth and filled a 40 GB disk after six weeks of verbose app logging. Capping at 200 MB plus a rate limit stopped both the disk-fill and the CPU cost of journal flushing. Pair this with Compress=yes (the default on Debian 12) and verify with journalctl --disk-usage.

12. Filesystem mount options

Kernel settings alone will not fix a filesystem doing synchronous metadata writes. In /etc/fstab, noatime removed a measurable stream of metadata writes — on a web-serving box atime updates were generating ~200 write IOPS that provided zero value.

UUID=... / ext4 defaults,noatime,errors=remount-ro 0 1

What did NOT help

Two widely copy-pasted tweaks measured no improvement: setting vm.overcommit_memory=1 without a workload that needs it (it just removes a safety check), and raising net.core.rmem_max beyond 32 MB (the autotuner never asks for it). Apply changes one at a time, re-measure, and revert anything that does not move a number you care about.

Persisting the values safely

Do not edit /etc/sysctl.conf directly on modern systems; drop a file in /etc/sysctl.d/99-tuning.conf so package upgrades do not clobber it, then validate with sysctl --system and confirm each key took by reading it back. A typo in a sysctl name is silently ignored, so always verify rather than trusting the add path.

Leave a Reply