Servers Administration

Methodical Linux Performance Tuning: tuned, sysctl, and I/O Schedulers

Most “performance tuning” you find online is a list of sysctl values copied from a 2014 forum post, applied without measurement, on hardware and kernels that no longer resemble the original. That is not tuning — it is cargo culting, and it regresses as often as it helps. Half of those pasted lines set knobs whose defaults are already correct on a modern kernel; a good fraction set knobs that were removed years ago (tcp_tw_recycle is the canonical landmine); and almost none of them were ever measured against a baseline on the machine they now run on. Real tuning is a loop: measure a baseline, form a hypothesis about the bottleneck, change one class of parameter, measure again, keep it only if it helped, and revert cleanly if it did not. Everything in this article is in service of that loop.

This is an Expert-level reference for tuning a modern distribution — kernel 5.15+ or 6.x, tuned 2.18+, util-linux, ethtool, and the bcc/bpftrace toolchain — end to end. It covers the layers that actually determine latency and throughput on a real server: tuned as the coherent orchestration layer; the virtual-memory subsystem (dirty ratios, swappiness, THP, hugepages); the CPU power and topology story (governors, C-states, IRQ affinity, isolcpus/nohz_full, NUMA locality); the block I/O layer (blk-mq schedulers per device class, queue depth, readahead); the network stack (socket buffers sized from bandwidth-delay product, backlogs, congestion control, NIC offloads, RSS/RPS, ring buffers); and the observability toolkit (perf, bpftrace, sar, PSI) that tells you the truth. Each layer is enumerated exhaustively — every knob, its default, when to change it, the trade-off, and the gotcha — in scannable tables, backed by real commands you can paste.

The unifying discipline is the USE method (Brendan Gregg): for every resource — CPU, memory, network, disk — check Utilization, Saturation, and Errors before you touch a single tunable. By the end you will localise a slow service to one resource with vmstat, iostat, mpstat, sar and /proc/pressure, change one coherent bundle through a tuned child profile, run tuned-adm verify, re-run the identical benchmark, and keep the change only if the number you decided to optimise — p99 latency, IOPS, throughput, or PSI saturation — actually moved.

What problem this solves

A server is “slow” and everyone has a theory. The database team blames the disk; the network team blames the app; someone opens a ticket for a “bad NVMe firmware.” Meanwhile the actual cause — remote-NUMA memory access, a NIC dumping every interrupt on CPU 0, transparent hugepage defrag stalling a latency-critical thread, or a TCP window three orders of magnitude too small for a 10 GbE fat pipe — sits there, perfectly measurable, unmeasured. The pain is real and expensive: tail latency that trips SLOs, throughput that plateaus below line rate, p99 that spikes without pattern, and a fleet that behaves differently after a hardware refresh because the tuning was implicit in the old topology.

What breaks without a method: engineers paste a “10 sysctls every server needs” list, restart, and either see no change (the defaults were already right) or a regression they cannot attribute (they changed twelve things at once). They set vm.swappiness=0 believing it disables swap (it does not) and get OOM-killed sooner. They enable net.ipv4.tcp_tw_recycle from a stale guide and break every client behind a NAT. They set a spinning-disk I/O scheduler on NVMe and add latency the device would never have had. Without a baseline none of this is caught — the change is judged by feeling, and feeling rationalises whatever it got.

Who hits this: anyone running latency-sensitive services (databases, RPC fan-out, trading, real-time media), high-throughput pipes (storage backends, streaming, bulk transfer), or multi-socket hardware where NUMA locality is invisible until it bites. It hits hardest on large-memory hosts (the default 20% dirty ratio becomes a 50 GB writeback cliff), on multi-queue NICs at 25/100 GbE (a single queue saturates one core), and on modern NVMe (where the OS scheduler is pure overhead). The fix is almost never “add hardware” — it is “measure, find the ceiling you actually hit, change the one class of knob that governs it, and prove the change with the same benchmark.”

To frame the whole field before the deep dive, here is every resource this article tunes, the USE signal that tells you it is the bottleneck, the primary knob layer, and the first tool to reach for:

Resource Utilization signal Saturation signal The knob layer First tool
CPU %usr+%sys per core (mpstat) run-queue r > nr_cpus (vmstat), cpu some PSI Governor, C-states, IRQ/isolcpus, NUMA mpstat -P ALL, /proc/pressure/cpu
Memory used/available (free) si/so swap-in/out, mem some PSI, reclaim swappiness, dirty ratios, THP, hugepages free -m, /proc/pressure/memory, sar -B
Disk / block I/O %util per device (iostat) aqu-sz queue depth, io full PSI Scheduler, nr_requests, read_ahead_kb iostat -xz, /proc/pressure/io
Network throughput vs line rate drops, backlog, retransmits Socket buffers, backlogs, offloads, RSS/RPS ss, ethtool -S, sar -n DEV
Interrupts / softirq per-CPU IRQ rate sitime on one core; NET_RX drops IRQ affinity, RSS/RPS, ring buffers /proc/interrupts, mpstat -I

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable as a Linux administrator: systemd units and drop-ins, /proc and /sys layout, editing files under /etc/sysctl.d/ and /etc/tuned/, and reading dmesg/journalctl. You should know basic hardware terms — cores vs threads (SMT/Hyper-Threading), sockets, NUMA nodes, NVMe vs SATA — and basic networking (TCP handshake, RTT, MTU). Root or sudo is assumed; several knobs live in /sys and /proc/sys and require it. A test machine you can push under representative load, and a benchmark that resembles your real workload (fio for storage, a closed-loop generator for RPC), are essential — you cannot tune what you cannot measure.

This sits in the Servers / Administration track and is downstream of the platform basics. It pairs tightly with Mastering systemd: Units, Timers, Resource Control, and Service Hardening — because systemd cgroups (CPUAffinity, NUMAPolicy, IOSchedulingClass) are increasingly the right place to express per-service tuning. It complements Modern Linux Networking: Bonding, VLANs, and Firewalls with nftables and firewalld for the interface layer beneath the socket tuning here, and Building a Linux Audit Trail with auditd and eBPF Runtime Visibility for the eBPF tooling that also powers bpftrace observability. For the architectural framing of why you tune — right-sizing, caching, load testing — see Well-Architected Performance Efficiency Pillar: Right-Sizing, Caching, and Load Testing, and for the metrics discipline behind the numbers, Monitoring and Observability Basics: Logs, Metrics, and Traces.

A quick map of who owns which layer during a performance incident, so you localise fast and call the right person:

Layer What lives here Who usually owns it Regressions it causes
Hardware / firmware CPU, DIMMs, NIC, NVMe, BIOS power profile Platform / DC BIOS power-save throttling, wrong NUMA interleave
Kernel / cmdline isolcpus, intel_idle.max_cstate, hugepages, iommu OS / platform Boot-time posture; irreversible without reboot
tuned profile Coherent sysctl+CPU+disk+THP bundle OS / platform A stock profile overriding your intent silently
sysctl drop-ins VM, net, fs kernel parameters OS / app Copied constants; conflicts between drop-ins
NIC / ethtool Offloads, RSS/RPS, ring buffers, IRQ coalescing Network / OS Interrupts on one core; RX drops from small rings
Application Threads, connection reuse, buffer sizes, NUMA-awareness App / dev Per-request sockets, cross-NUMA threads

Core concepts

Six mental models make every later decision obvious.

You cannot claim a win without a number to beat. Tuning is a control loop, not a checklist. Every change is a hypothesis (“the dirty ratio is causing writeback stalls”) tested against a recorded baseline (workload, kernel, active profile, p99, IOPS, PSI) by changing exactly one class of knob and re-running the identical benchmark. Change three things and it got faster, and you learned nothing — you cannot revert the two that hurt while keeping the one that helped. One class of knob per iteration is the entire discipline.

The USE method localises the bottleneck before you touch a knob. For each resource ask, in order: is it highly Utilized (busy), is it Saturated (work queueing because it is full), are there Errors? Saturation is the strongest signal — a resource can be 100% utilized and fine (a batch job wants that), but saturation means demand exceeds capacity and something is waiting. On modern kernels, Pressure Stall Information (PSI) in /proc/pressure/{cpu,io,memory} is the most honest saturation metric: some avg10 is the percent of time at least one task stalled in the last 10 s; full avg10 is the percent of time every non-idle task stalled. A rising io full avg10 means the whole machine is blocked on storage — no interpretation required.

tuned is the right orchestration layer; hand-editing single knobs fights it. tuned sets dozens of coherent knobs — sysctl, CPU governor, disk scheduler, THP, IRQ posture — as a single named, revertible bundle chosen for a workload class. Hand-edit /etc/sysctl.conf while a tuned profile is active and you fight whatever the profile already applied; the two can silently disagree. The correct pattern is to let tuned own the bundle and express your measured deltas as a child profile that include=s a stock one and overrides only what you have a reason to change — then tuned-adm verify to prove nothing else overrode you.

Power management trades latency for watts, and defaults favour watts. A modern CPU scales frequency down (cpufreq governors) and drops idle cores into deep sleep (C-states). Both add wakeup jitter — tens to hundreds of microseconds to bring a core back to full speed from a deep C-state. Free efficiency for a batch job; a tail-latency generator for a latency-critical service. The performance governor pins frequency high; capping C-states (via the PM-QoS /dev/cpu_dma_latency interface that tuned’s force_latency drives, or the blunt intel_idle.max_cstate=1 cmdline) bounds wakeup latency. You buy predictable latency with power and heat.

On multi-socket hardware, memory has a locality tax. Remote-NUMA memory costs roughly 1.5×–2× the access latency of local memory, at lower bandwidth. A thread on socket 1 reaching into socket 0’s DIMMs pays that tax on every cache miss. This is the most common invisible regression on large machines — it shows up not as high disk or CPU utilization but as latency that a higher core count makes worse by spreading work wider. numactl binds CPU and memory to a node; numastat proves remote-access pain (numa_foreign/numa_miss climbing).

The right knob for one device is wrong for another. There is no universal I/O scheduler, readahead value, or TCP buffer size — the correct value is a function of the device (NVMe vs SATA SSD vs HDD) and the workload (random OLTP vs sequential scan; short vs long-RTT pipe). Modern kernels use the multi-queue block layer (blk-mq) where none fits NVMe (the device reorders better than the OS) and mq-deadline fits SATA. TCP buffers must be sized from bandwidth-delay product (BDP = bandwidth × RTT), not a magic constant. Per-device, per-workload, measured — never one number for the whole fleet.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary at the end repeats these for lookup; this is the mental model side by side.

Concept One-line definition Where it lives Why it matters to performance
USE method Check Utilization, Saturation, Errors per resource Methodology Localises the bottleneck before you tune
PSI Pressure Stall Information: some/full stall time /proc/pressure/{cpu,io,memory} The most honest saturation signal (kernel 4.20+)
tuned Orchestrator setting coherent knob bundles by profile /etc/tuned, tuned-adm Revertible, named, verifiable tuning
sysctl Runtime kernel parameters (net.*, vm.*, fs.*) /proc/sys, /etc/sysctl.d The individual knobs tuned and you set
cpufreq governor Policy choosing CPU frequency /sys/.../cpufreq/scaling_governor performance = no frequency jitter
C-state CPU idle sleep depth (C0 active … C6 deep) /sys/.../cpuidle, intel_idle Deep C-state exit latency = wakeup jitter
IRQ affinity Which CPUs service a device’s interrupts /proc/irq/<n>/smp_affinity Keeps softirq off latency-critical cores
NUMA node A socket’s local CPUs + memory numactl --hardware Remote memory = 1.5–2× latency tax
isolcpus / nohz_full Cores removed from the scheduler / tickless Kernel cmdline Dedicated cores for latency-critical threads
blk-mq scheduler Multi-queue block I/O scheduler /sys/block/<dev>/queue/scheduler none for NVMe, mq-deadline for SATA
THP Transparent Hugepages (auto 2 MB pages) /sys/kernel/mm/transparent_hugepage Defrag stalls hurt latency-sensitive DBs
Hugepages Explicitly reserved large pages (2 MB / 1 GB) vm.nr_hugepages, hugetlbfs Fewer TLB misses for big-memory DBs
BDP Bandwidth-Delay Product = bandwidth × RTT Calculation The correct in-flight TCP buffer size
Congestion control Algorithm pacing TCP send rate net.ipv4.tcp_congestion_control bbr survives loss; cubic collapses on it
RSS / RPS Receive-Side Scaling (HW) / Packet Steering (SW) NIC / rps_cpus Spreads packet processing across cores
Ring buffer NIC RX/TX descriptor queue ethtool -g Too small → RX drops under burst
PM QoS Latency tolerance request to the kernel /dev/cpu_dma_latency Caps C-state depth (tuned force_latency)

1. Establish a baseline with the USE method

Before changing anything, capture the system under representative load and write the numbers down — workload, kernel version, active tuned profile, and headline metrics. First, the saturation signals (queueing — work waiting because a resource is full):

# CPU + run-queue saturation. Watch 'r' (runnable) and 'b' (blocked) columns,
# plus 'wa' (iowait) under 'cpu'. r consistently > nr_cpus means CPU saturation;
# high 'b' + 'wa' means tasks blocked on I/O.
vmstat 1 10

# Per-CPU utilization and steal time (steal = a hypervisor took your cycles).
# %idle near 0 on one CPU while others idle = a single-core bottleneck (softirq/IRQ).
mpstat -P ALL 1 5

# Per-device disk: %util (utilization), aqu-sz (avg queue depth = saturation),
# r_await / w_await (latency in ms). High await with modest %util points at the device;
# high %util + high aqu-sz means the queue is the bottleneck.
iostat -xz 1 5

# Memory pressure and reclaim: si/so (swap in/out per sec) should be ~0 on a healthy
# server; pgscank/pgscand (kswapd/direct reclaim scanning) rising = memory pressure.
sar -B 1 5
free -m

# Network throughput, drops and errors per interface
sar -n DEV 1 5
sar -n EDEV 1 5   # errors and drops specifically

Then read PSI — the north-star saturation metric on any kernel 4.20+:

# 'some' = at least one task stalled; 'full' = ALL non-idle tasks stalled.
# avg10/avg60/avg300 are % of time stalled over 10s/60s/5min windows; 'total' is a counter (us).
cat /proc/pressure/cpu /proc/pressure/io /proc/pressure/memory

Treat the PSI averages as your headline numbers. For latency-sensitive work, capture a wall-clock distribution of your actual workload if you can; for storage microbenchmarks, fio with a percentile list is the reference:

# Storage baseline: 4k random read, direct I/O (bypass pagecache), queue depth 32, 60s.
# Report p99/p99.9 latency, not just IOPS — averages hide tail pain.
fio --name=baseline --filename=/data/fiotest --direct=1 --rw=randread \
    --bs=4k --iodepth=32 --numjobs=4 --group_reporting --runtime=60 --time_based \
    --ioengine=io_uring --percentile_list=50:95:99:99.9

# Record the headline numbers and the environment:
uname -r                    # kernel
tuned-adm active            # current profile
nproc; free -g              # cores and RAM
numactl --hardware | head   # NUMA topology

The metric that matters is not “faster” but which dimension you decided to optimise. Decide before you look. Here are the USE signals per resource, the exact command, and what “bad” looks like — the reference you scan at the start of every investigation:

Resource Utilization command Saturation command Error command “Bad” looks like
CPU mpstat -P ALL 1 (%idle) vmstat 1 (r col); /proc/pressure/cpu mpstat %steal (hypervisor) r > nr_cpus sustained; cpu some avg10 high
Memory free -m; sar -r 1 vmstat 1 (si/so); /proc/pressure/memory dmesg OOM; /proc/vmstat oom_kill si/so > 0; mem full avg10 > 0
Disk iostat -xz 1 (%util) iostat (aqu-sz); /proc/pressure/io iostat errors; smartctl aqu-sz high + await climbing; io full
Network sar -n DEV 1 (rx/tx) ss -ti (cwnd, retrans); backlog sar -n EDEV 1; ethtool -S drops rxdrop/txdrop > 0; retransmits climbing
File descriptors cat /proc/sys/fs/file-nr (near fs.file-max) EMFILE/ENFILE in app logs allocated near file-max
Sockets ss -s (summary) TIME_WAIT count; ephemeral range netstat -s (listen drops) listen overflows; port range exhausted

PSI as a first-class metric

PSI changes how you triage. Before PSI, distinguishing “the CPU is busy doing useful work” from “tasks are stalled waiting” required correlating run-queue length, iowait, and reclaim counters by hand; PSI collapses that into one honest number. The full line is the killer signal: full avg10 = 40 on /proc/pressure/io means that for 40% of the last ten seconds every non-idle process was blocked on I/O — a machine on its knees, no matter what “the disk is only 60% utilized” suggests. Wire PSI into monitoring and alerting; it is a leading indicator that a ceiling is being hit, often before utilization looks alarming.

PSI file some avgN means full avgN means Alert threshold (starting point)
/proc/pressure/cpu ≥1 task waiting for a runnable CPU (n/a — CPU has no “full”) some avg60 > 20 sustained
/proc/pressure/io ≥1 task blocked on I/O every task blocked on I/O full avg60 > 5 = investigate
/proc/pressure/memory ≥1 task stalled in reclaim every task stalled reclaiming full avg60 > 0 = memory-bound

2. tuned: profiles, custom profiles, and tuned-adm

tuned is the right starting layer because it sets dozens of coherent knobs — sysctl, CPU governor, disk scheduler, transparent hugepages, IRQ posture — as a single named, revertible bundle. Hand-editing /etc/sysctl.conf in isolation fights whatever the active profile already applied, and the two disagree silently.

tuned-adm list          # show available profiles (stock + any custom)
tuned-adm active        # what is applied now
tuned-adm recommend     # what tuned thinks fits this machine (VM? bare metal? laptop?)
tuned-adm profile_info  # summary of the active profile

The stock profiles map cleanly to workload classes. The -performance family disables power saving in exchange for latency; the -throughput family sizes buffers up; virtual-* profiles account for running under or over a hypervisor:

Profile Intended use Notable behaviour
balanced General desktop/server default Moderate power saving; schedutil/powersave governor
throughput-performance Bulk compute, batch, databases Governor performance, larger VM dirty ratios, mq-deadline/none disk, THP-friendly
latency-performance Low-latency services force_latency=cstate.id_no_zero:1 caps C-states, governor performance, THP off
network-latency Trading, RPC fan-out Builds on latency-performance; disables THP, sets busy_read/busy_poll, lowers net.core coalescing
network-throughput Bulk transfer, streaming Large TCP buffers/backlogs; net.core.rmem_max/wmem_max raised
virtual-guest VMs (guest side) Higher vm.dirty_ratio, elevator sensible under a hypervisor
virtual-host Hypervisor host (KVM) THP on for guest backing, sched_migration_cost raised
powersave Battery / density Aggressive C-states and frequency scaling
hpc-compute HPC nodes performance governor, numa_balancing off, hugepages-friendly

To see exactly what a stock profile changes — never guess, read the source:

# Every stock profile lives here; read the one you are about to inherit
cat /usr/lib/tuned/latency-performance/tuned.conf
cat /usr/lib/tuned/throughput-performance/tuned.conf

# What plugins/units a profile touches (sysctl, cpu, disk, vm, sysfs, bootloader)
tuned-adm profile_info throughput-performance

Authoring a custom child profile

Never edit a stock profile in place — it is owned by the package and will be overwritten on upgrade. Instead create a child profile in /etc/tuned/<name>/tuned.conf that inherits with include= and overrides only the deltas you have measured a reason for. The child profile is where all your tuning lives, version-controlled and revertible:

# /etc/tuned/kv-postgres/tuned.conf
[main]
summary=PostgreSQL 16 on NVMe, dual-socket, derived from throughput-performance
include=throughput-performance

[sysctl]
# Writeback: fixed-byte thresholds on a large-RAM host (see VM section)
vm.dirty_background_bytes=268435456   # start background flush at 256 MB dirty
vm.dirty_bytes=1073741824             # hard-block writers at 1 GB dirty
vm.swappiness=1                       # avoid swap but allow under true duress
vm.zone_reclaim_mode=0                # never reclaim local pagecache to avoid remote alloc
# Network for a busy DB accepting many short-lived connections
net.core.somaxconn=4096
net.ipv4.tcp_max_syn_backlog=8192

[vm]
transparent_hugepages=never           # Postgres manages its own buffers; THP defrag stalls hurt

[cpu]
governor=performance
# Cap C-states for predictable wakeup latency (leave C1, disallow deep sleep)
force_latency=cstate.id_no_zero:1

[disk]
# devices.udev.regex matches by kernel name; set scheduler + readahead per class
devices=nvme*n*
elevator=none
readahead=256

[bootloader]
# Reserve explicit hugepages for shared_buffers (requires reboot to take)
cmdline=transparent_hugepage=never

Apply and confirm it took effect:

tuned-adm profile kv-postgres
tuned-adm verify           # asserts EVERY setting in the profile is actually live

tuned-adm verify is the step almost everyone skips. It re-reads each knob and tells you if something else on the box — a competing systemd unit, a stale /etc/sysctl.d/ drop-in, an elevator= kernel arg — overrode your profile. If verify fails it names the setting; fix the conflict before you trust any later measurement. The [main] include= chain is transitive, so kv-postgresthroughput-performance(base); you only write the leaf deltas.

The tuned plugin sections you will actually use, and what each controls:

Section Controls Example key(s) Notes
[main] Profile metadata + inheritance summary, include include is the whole point — inherit, don’t copy
[sysctl] Any /proc/sys parameter vm.swappiness, net.core.somaxconn Applied via the sysctl plugin; verifiable
[vm] Transparent hugepages transparent_hugepages=never Cleaner than echoing to /sys
[cpu] Governor, C-state latency, energy-perf-bias governor, force_latency, energy_perf_bias force_latency drives PM-QoS
[disk] Per-device scheduler + readahead + spindown devices, elevator, readahead devices= regex targets a device class
[sysfs] Arbitrary /sys writes /sys/kernel/mm/...=... Escape hatch for knobs without a plugin
[bootloader] Kernel cmdline args cmdline=isolcpus=... nohz_full=... Requires reboot; use for isolcpus/hugepages/cstate
[net] NIC-level tuning via ethtool features, channels, coalesce Ring buffers, offloads, RSS channels
[scheduler] CPU affinity / IRQ isolation isolated_cores, default_irq_smp_affinity Pin app and IRQs off isolated cores

The tuned-adm verbs you will use, and exactly what each does:

Command What it does When to run it
tuned-adm active Prints the currently applied profile First thing, always — know your starting posture
tuned-adm list Lists all available profiles Discovering options
tuned-adm recommend Suggests a profile from hardware/virt detection Fresh box; sanity-check the default
tuned-adm profile <name> Applies a profile (persists across reboot) Activating your custom profile
tuned-adm profile <a> <b> Merge multiple profiles (later wins on conflict) Combining e.g. latency-performance mssql
tuned-adm verify Asserts every setting in the profile is live After every apply — catches conflicts
tuned-adm off Deactivates tuned entirely (reverts to kernel defaults) Isolating whether tuned is the variable
tuned-adm profile_info Shows the active profile’s summary/plugins Understanding what a profile touches

3. Virtual memory: dirty ratios, swappiness, THP, hugepages

The VM subsystem decides when dirty (modified, not-yet-written) pages flush to disk and how aggressively the kernel reclaims memory under pressure. Defaults assume a general-purpose machine; servers with fast storage and large RAM want different behaviour.

sysctl vm.dirty_ratio vm.dirty_background_ratio vm.dirty_bytes vm.dirty_background_bytes
sysctl vm.swappiness vm.vfs_cache_pressure vm.min_free_kbytes
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/defrag

Dirty ratios and writeback

vm.dirty_background_ratio is the percentage of available memory at which the kernel starts flushing dirty pages in the background (asynchronously, via the writeback threads); vm.dirty_ratio is the hard ceiling at which a process that dirties a page is blocked synchronously until enough is flushed. On a box with 256 GB RAM, the default 20% ceiling means 50+ GB can go dirty before a synchronous stall — a latency cliff where every writer freezes while a huge writeback drains. Lower the thresholds, and on large-memory machines prefer the _bytes variants for a fixed, predictable trigger (setting a _bytes value zeroes the corresponding _ratio):

# /etc/sysctl.d/91-vm.conf
# Fixed-byte thresholds are clearer than ratios on big-RAM hosts.
vm.dirty_background_bytes = 268435456   # start background flush at 256 MB dirty
vm.dirty_bytes           = 1073741824   # hard-block writers at 1 GB dirty

# Database hosts: keep the working set in RAM, swap only under true duress
vm.swappiness = 1

# Free pagecache/dentry aggressiveness; raise on inode-heavy NFS/file servers
vm.vfs_cache_pressure = 100

# Emergency reserve so atomic allocations and network RX don't fail under pressure.
# Raise on high-throughput/large-RAM hosts (but not absurdly — wastes RAM).
vm.min_free_kbytes = 1048576

The four writeback/reclaim knobs, side by side:

Knob Controls Default Change when Trade-off / gotcha
vm.dirty_background_ratio % dirty to start async flush 10 Prefer _bytes on large RAM Ratio of available, not total, memory
vm.dirty_ratio % dirty to block writers 20 Lower on large RAM to cap stall size Hitting it freezes all writers synchronously
vm.dirty_background_bytes Bytes dirty to start async flush 0 (uses ratio) Large-RAM host wanting a fixed trigger Setting it zeroes dirty_background_ratio
vm.dirty_bytes Bytes dirty to block writers 0 (uses ratio) Large-RAM host, predictable ceiling Setting it zeroes dirty_ratio
vm.dirty_expire_centisecs Age (1/100 s) before a dirty page is eligible 3000 (30 s) Lower for durability-sensitive writeback Too low = constant small writes
vm.dirty_writeback_centisecs How often writeback threads wake 500 (5 s) Rarely; 0 disables periodic writeback 0 risks large data loss on crash

Swappiness — and why 0 is a trap

vm.swappiness (0–200 on modern kernels, default 60) biases the kernel between reclaiming pagecache and swapping out anonymous memory. Lower means “prefer dropping pagecache over swapping.” A few hard-won rules:

vm.swappiness Behaviour Use for Risk
0 Avoid swap until reclaim nearly impossible Never-swap intent, understanding OOM OOM-kills sooner instead of swapping
1 Swap only under true memory duress Databases, latency-critical services Minimal; the sane “avoid” value
10 Swap reluctantly General servers preferring cache
60 Balanced (default) General-purpose / desktop May swap active pages under cache pressure
100+ Swap eagerly, favour pagecache File servers with cold anon memory Latency if hot pages get swapped

Transparent Hugepages (THP)

Transparent Hugepages let the kernel automatically back memory with 2 MB pages instead of 4 KB, reducing TLB misses — a real win for some workloads and a real hurt for others. Databases and JVMs that manage their own memory (PostgreSQL, MySQL/InnoDB, Redis, MongoDB, Oracle, Java heaps) routinely recommend never, because THP’s background defragmentation (khugepaged) and synchronous allocation stalls (collapsing/splitting huge pages) cause unpredictable latency spikes. Set it explicitly rather than leaving it on madvise (the modern default) by accident:

# Runtime (evaporates on reboot)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

# Confirm which mode is active — the [bracketed] value is live
cat /sys/kernel/mm/transparent_hugepage/enabled   # always [madvise] never

Make it durable through tuned ([vm] transparent_hugepages=never) or the kernel cmdline (transparent_hugepage=never), not a one-shot echo. The three THP modes:

THP mode Behaviour Best for The gotcha
always Kernel uses huge pages everywhere it can HPC, big scientific arrays, some analytics khugepaged defrag + alloc stalls hurt latency
madvise Only where the app calls madvise(MADV_HUGEPAGE) Apps that opt in deliberately Silent default; may still stall on collapse
never Disabled; only explicit hugepages used PostgreSQL, MySQL, Redis, Mongo, JVM heaps None for self-managing apps — the safe DB default

Explicit hugepages for databases

Distinct from THP, explicit hugepages are pages you reserve (2 MB or 1 GB) that an application maps deliberately via hugetlbfs or MAP_HUGETLB. A database with a large shared buffer (PostgreSQL shared_buffers, Oracle SGA) benefits: fewer, larger page-table entries mean fewer TLB misses on the hot buffer, and the memory is pinned (never swapped or split). Reserve them at boot for reliability (fragmentation makes runtime reservation of large counts unreliable):

# Inspect current hugepage state
grep Huge /proc/meminfo
cat /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

# Reserve 8192 × 2 MB pages = 16 GB (runtime; may partially fail if fragmented)
sysctl -w vm.nr_hugepages=8192

# 1 GB hugepages must be reserved on the kernel cmdline (rarely available at runtime):
#   default_hugepagesz=1G hugepagesz=1G hugepages=16   -> 16 GB in 1 GB pages

For durable reservation, set vm.nr_hugepages in /etc/sysctl.d/ (early boot) or the cmdline. Grant the DB’s group access via vm.hugetlb_shm_group and configure the app to use them (huge_pages = on in postgresql.conf). Explicit hugepages vs THP, decision-wise:

Aspect Explicit hugepages Transparent Hugepages (THP)
How allocated Reserved by admin, mapped by app deliberately Kernel promotes 4K pages automatically
Page sizes 2 MB and 1 GB 2 MB only
Swappable No — pinned Can be split/reclaimed
Latency behaviour Predictable (no runtime defrag) Unpredictable stalls from khugepaged
Best for DB shared buffers (Postgres, Oracle SGA) General apps that opt in via madvise
Set via vm.nr_hugepages / cmdline + app config /sys/.../transparent_hugepage / tuned

4. CPU: governors, C-states, IRQ affinity, isolcpus, nohz_full

For latency-sensitive services, the enemy is the kernel saving power. Frequency scaling and deep C-states add tens to hundreds of microseconds of wakeup jitter that shows up directly in your p99.

# Which cpufreq driver and governor are active?
cpupower frequency-info

# Per-core current governor and available governors
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors

# Which idle driver and C-states exist, and their exit latencies (us)
cpupower idle-info

cpufreq governors

The governor is the policy that chooses core frequency. On modern Intel/AMD the driver is often intel_pstate/amd-pstate (which exposes only performance and powersave), or the generic acpi-cpufreq (which exposes the full set including schedutil, ondemand, conservative). For latency, performance pins every core at max frequency — no ramp-up delay on a wakeup:

# Pin all cores to max frequency (cleanest via tuned latency-performance, but direct works)
cpupower frequency-set -g performance

# Verify it stuck
cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor | sort -u
Governor Behaviour Best for Trade-off
performance Max frequency always Latency-critical, HPC, databases Highest power/heat; no frequency jitter
powersave (pstate) Lowest frequency, ramps under load Density, battery Ramp-up latency on bursty load
schedutil Scheduler-driven, modern default General servers Good balance; slight ramp latency
ondemand Legacy load-based scaling Older kernels/acpi-cpufreq Coarser than schedutil
conservative Like ondemand but gradual Slowly-varying load Slow to reach max on spikes

C-states — the bigger latency lever

C-states are deeper wins and deeper traps than governors. When a core idles into a deep C-state (C6), the exit latency to wake it can be tens to hundreds of microseconds — which can dwarf a service that processes a request in 20 µs. The robust, supported way to cap idle depth is the PM-QoS interface: request a maximum tolerable CPU wakeup latency in microseconds, and the kernel refuses any C-state whose exit latency exceeds it.

# Read each C-state's exit latency (us) and residency
cpupower idle-info
grep . /sys/devices/system/cpu/cpu0/cpuidle/state*/latency
grep . /sys/devices/system/cpu/cpu0/cpuidle/state*/name

# Hold /dev/cpu_dma_latency open requesting <=5us wake latency. The kernel then
# refuses C-states whose exit latency exceeds 5us. Keep the fd open; closing releases it.
exec 4<>/dev/cpu_dma_latency
printf '\x05\x00\x00\x00' >&4   # 5 (us) as a 32-bit little-endian int
# ... constraint holds while fd 4 stays open ...

In practice you let tuned own this with force_latency in a [cpu] section rather than scripting the device by hand — latency-performance sets force_latency=cstate.id_no_zero:1, meaning “allow only C-states with id ≤ 1 (C1), never deep sleep.” For boot-time guarantees on dedicated latency nodes, the kernel cmdline arg intel_idle.max_cstate=1 (Intel) or processor.max_cstate=1 idle=poll is the blunt, permanent instrument — but idle=poll burns 100% CPU on idle cores (extreme, only for the most latency-sensitive isolated cores).

C-state control Mechanism Effect When to use
PM QoS (force_latency) tuned [cpu] / /dev/cpu_dma_latency Cap wakeup latency dynamically Default, reversible way to bound C-states
intel_idle.max_cstate=N Kernel cmdline (Intel) Hard-cap idle depth at boot Dedicated latency nodes, permanent
processor.max_cstate=N Kernel cmdline (generic) Hard-cap idle depth at boot Non-Intel or as a fallback
idle=poll Kernel cmdline Never idle; spin instead Extreme low-latency; wastes power/heat
cpupower idle-set -d N Runtime Disable a specific C-state Testing which C-state costs you

IRQ affinity and softirq

An interrupt (IRQ) fires on whichever CPU the kernel or irqbalance assigned it; the follow-on softirq (e.g. NET_RX for received packets) runs there too. On a busy NIC, a single queue’s softirq can saturate one core to 100% %soft while others idle — a classic single-core bottleneck that mpstat -P ALL reveals instantly. Spread interrupts off your application cores:

# See which CPUs handle each IRQ and per-CPU interrupt counts (NIC IRQs named e.g. eth0-rx-0)
grep -E 'CPU|eth0' /proc/interrupts

# Per-CPU softirq breakdown — a lone CPU high in %soft is the tell
mpstat -I SCPU -P ALL 1 3

# Pin IRQ 142 to CPU2 (mask 0x4). Masks are hex bitmasks of CPUs (bit0=cpu0).
echo 4 > /proc/irq/142/smp_affinity
# Or as a CPU list:
echo 2 > /proc/irq/142/smp_affinity_list

# irqbalance distributes automatically; disable it if you pin manually and want it to stay
systemctl status irqbalance

The principle: keep NIC interrupt handling, the kernel softirq, and the application thread on cores that share an L3 cache, and away from cores doing latency-critical user work. On NUMA boxes that means same-socket as the NIC’s PCIe root. Many multi-queue NIC drivers ship a set_irq_affinity.sh script that spreads each queue’s IRQ to a distinct core — the right starting point.

isolcpus, nohz_full, rcu_nocbs — dedicated cores

For the hardest latency requirements (packet processing, trading, real-time), you carve cores out of the general scheduler entirely so nothing runs on them except the thread you pin. This is boot-time configuration via the kernel cmdline:

# Kernel cmdline (via tuned [bootloader] or grub) for a 16-core box dedicating cores 8-15:
#   isolcpus=8-15 nohz_full=8-15 rcu_nocbs=8-15 irqaffinity=0-7
# - isolcpus: remove 8-15 from the scheduler's load balancing (only explicit pins run there)
# - nohz_full: make 8-15 tickless (no periodic scheduler tick) when running one task
# - rcu_nocbs: offload RCU callbacks off 8-15 to the housekeeping cores
# - irqaffinity: default IRQs to the housekeeping cores 0-7

# Pin an application thread to an isolated core at runtime
taskset -c 8 ./my-latency-app
# Or via a systemd unit:  [Service] CPUAffinity=8-15

The tuned [scheduler] plugin expresses this cleanly with isolated_cores=8-15, which sets the cmdline and moves IRQs and kernel threads off those cores. The cost is real: isolated cores are unavailable to everything else, so you trade fleet-wide capacity for a few cores of near-jitter-free execution. Only do it when the USE method proves scheduler/tick jitter is your bottleneck.

CPU-isolation knob What it removes from the core Set where Cost
isolcpus= Scheduler load-balancing (no auto-placed tasks) cmdline / tuned [scheduler] Core idle unless explicitly pinned
nohz_full= The periodic scheduler tick (when 1 task) cmdline Needs rcu_nocbs; timekeeping caveats
rcu_nocbs= RCU callback processing cmdline Offloads work to housekeeping cores
irqaffinity= Default IRQ delivery cmdline Concentrates IRQs on housekeeping cores
taskset -c / CPUAffinity (pins a process TO cores) runtime / systemd Manual placement responsibility

5. NUMA awareness and memory locality

On multi-socket servers, memory attached to a remote socket costs 1.5×–2× the access latency of local memory, and remote bandwidth is lower. A process scheduled on socket 1 reaching into socket 0’s DIMMs pays that tax on every cache miss. This is the single most common invisible regression on large machines, and it gets worse with more cores because work spreads wider across nodes.

# Topology: nodes, the CPUs in each node, memory per node, and the inter-node distance matrix
numactl --hardware

# Per-node hit/miss stats. numa_miss / numa_foreign climbing = remote-memory pain.
numastat -m

# Rich topology (cores, caches, PCIe/NIC attachment per node) — invaluable for IRQ+NUMA
lstopo-no-graphics

numactl --hardware prints a distance matrix where local is 10 and remote is typically 20+ (a relative cost, not nanoseconds). numastat gives the counters that prove pain: numa_hit (allocations satisfied from the intended node), numa_miss/numa_foreign (allocations that had to come from another node), and other_node (allocations on a node other than where the process runs). Climbing numa_foreign is the smoking gun.

Binding vs interleaving

For a single dominant process (a database, a JVM, a packet processor), pin both its CPUs and its memory to one node so allocations stay local:

# Run on node 0's CPUs, allocate only from node 0's memory
numactl --cpunodebind=0 --membind=0 /usr/lib/postgresql/16/bin/postgres -D /data/pg

# Inspect a running process's NUMA placement and per-node memory
numastat -p $(pgrep -f 'postgres: checkpointer' )
cat /proc/$(pgrep -of postgres)/numa_maps | head

For services that legitimately span sockets (a big in-memory cache where any core may touch any key), --interleave=all spreads allocations round-robin so no single node’s memory bandwidth becomes the bottleneck:

numactl --interleave=all /usr/bin/redis-server /etc/redis/redis.conf

The systemd-native way to bind a managed service is cleaner than wrapping ExecStart in numactl:

# /etc/systemd/system/postgresql@.service.d/numa.conf
# Templated unit: postgresql@0 binds node 0, postgresql@1 binds node 1
[Service]
NUMAPolicy=bind
NUMAMask=%i
NUMA policy Behaviour Best for Set via
bind (--membind) Allocate only from listed node(s); fail/OOM if full Single dominant process (DB, JVM) numactl --membind / systemd NUMAPolicy=bind
preferred Prefer a node, fall back to others if full Mostly-local process tolerating overflow numactl --preferred / NUMAPolicy=preferred
interleave Round-robin across nodes Bandwidth-bound spanning caches numactl --interleave=all / NUMAPolicy=interleave
local (default) Allocate on the node of the running CPU General; works if the scheduler keeps it local default; NUMAPolicy=default
cpunodebind Restrict CPUs to a node (pairs with membind) Pinning both CPU and memory numactl --cpunodebind

zone_reclaim_mode — the silent killer

Beware vm.zone_reclaim_mode. On some NUMA topologies older kernels defaulted it to on, causing the kernel to aggressively reclaim local pagecache rather than allocate one page from a remote node — devastating for file-cache-heavy workloads, which lose their cache to avoid a cheap remote allocation. Confirm it is 0 and keep it off unless you have a specific measured reason:

sysctl vm.zone_reclaim_mode   # want: 0
# If not zero:
sysctl -w vm.zone_reclaim_mode=0

Also relevant is kernel.numa_balancing (Automatic NUMA Balancing) — the kernel periodically unmaps pages and migrates them toward the node accessing them. Helpful for workloads that move between nodes, but its page-fault overhead can hurt a process you have already pinned. If you bind explicitly, disable it (sysctl -w kernel.numa_balancing=0, or numa_balancing=disable on cmdline). The NUMA-related sysctls:

Knob Default Effect Set to
vm.zone_reclaim_mode 0 (modern) Reclaim local pagecache before remote alloc 0 — almost always
kernel.numa_balancing 1 Auto-migrate pages toward accessing node 0 if you pin explicitly
kernel.numa_balancing_scan_delay_ms 1000 How soon after fork to start scanning Leave unless balancing tuning

6. Block I/O schedulers and queue depth per device class

The scheduler that is right for a spinning disk is wrong for NVMe, and vice versa. Modern kernels ship the multi-queue block layer (blk-mq) with these relevant schedulers:

# Which schedulers exist and which is active (the [bracketed] one)
cat /sys/block/nvme0n1/queue/scheduler   # e.g. [none] mq-deadline kyber bfq

# Is the device rotational? 0 = SSD/NVMe, 1 = HDD
cat /sys/block/sda/queue/rotational
Scheduler Best for Why
none NVMe / fast SSD The device has deep internal queues and reorders better than the OS; software scheduling only adds latency
mq-deadline SATA SSD, HDD, mixed, predictable latency Lightweight; bounds worst-case latency with per-read/write deadlines; prevents starvation
kyber High-IOPS SSD wanting latency targets Token-based; throttles to hit configurable read/write latency targets; low overhead
bfq Desktops, interactive, latency-fairness across competing apps Proportional-share fairness per process/cgroup; CPU cost too high for high-IOPS server paths

Set per device at runtime, then make it durable:

# Set none on NVMe (runtime)
echo none > /sys/block/nvme0n1/queue/scheduler
# Set mq-deadline on a SATA SSD (runtime)
echo mq-deadline > /sys/block/sda/queue/scheduler

Runtime echoes do not survive reboot. Make the choice durable and device-class-aware with a udev rule, so a node with mixed media gets the right scheduler on each device automatically:

# /etc/udev/rules.d/60-ioscheduler.rules
# NVMe -> none (device queues + reordering beat the OS scheduler)
ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*", ATTR{queue/scheduler}="none"
# Non-rotational SATA/SAS (SSD) -> mq-deadline
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline"
# Rotational (HDD) -> mq-deadline (bfq only if you need latency-fairness on a shared disk)
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="mq-deadline"

Reload rules and re-trigger without a reboot: udevadm control --reload && udevadm trigger --subsystem-match=block. The tuned [disk] plugin (devices=nvme*n*, elevator=none) is the alternative that keeps the choice inside your profile — pick one mechanism, not both, or tuned-adm verify will fight the udev rule.

Queue depth and readahead

Two supporting knobs per device fine-tune the OS-side queue and prefetch behaviour:

# Deeper OS queue for high-parallelism NVMe (default often 256 on NVMe)
echo 1023 > /sys/block/nvme0n1/queue/nr_requests

# Readahead tuned to access pattern
echo 128  > /sys/block/nvme0n1/queue/read_ahead_kb   # small-random OLTP: minimal prefetch
echo 4096 > /sys/block/sdb/queue/read_ahead_kb       # sequential scans/backups: aggressive prefetch

The complete per-device block-queue knob set:

Knob (/sys/block/<dev>/queue/) Controls Typical default Change for
scheduler Active blk-mq scheduler none (NVMe) / mq-deadline (SATA) Match device class (see table above)
nr_requests OS-side request queue depth 256 (NVMe) / 128 Raise for high-IOPS parallel NVMe
read_ahead_kb Sequential readahead window 128 Raise for sequential, lower for random
rotational Hint: 1=HDD, 0=SSD Auto-detected Correct only if mis-detected
add_random Contribute to entropy pool 1 (HDD) / 0 (SSD) Set 0 on SSD (avoids contention)
rq_affinity Complete I/O on issuing CPU/socket 1 2 = strict same-CPU completion (cache locality)
write_cache write-back vs write-through device-set Match to power-loss-protection reality
max_sectors_kb Max I/O size per request device max Rarely; align to device/RAID stripe
nomerges Disable request merging 0 1 or 2 to test merge overhead on fast NVMe

For mq-deadline and kyber, per-scheduler tunables live in /sys/block/<dev>/queue/iosched/:

Scheduler tunable Scheduler Controls Default
read_expire mq-deadline Read deadline (ms) before prioritised 500
write_expire mq-deadline Write deadline (ms) 5000
writes_starved mq-deadline Reads served before a starved write 2
fifo_batch mq-deadline Requests dispatched per batch 16
read_lat_nsec kyber Target read latency (ns) 2000000
write_lat_nsec kyber Target write latency (ns) 10000000

7. The network stack: sockets, backlogs, congestion control

Network tuning is where copied values do the most damage, because the right buffer size is a function of the bandwidth-delay product, not a constant. Compute, do not guess: BDP (bytes) = bandwidth (bytes/s) × RTT (seconds). A 10 GbE link (1.25 GB/s) at 10 ms RTT needs ~12.5 MB of in-flight buffer per stream; a 1 GbE LAN at 0.2 ms needs ~25 KB. Sizing a LAN server’s buffers at 16 MB wastes memory; sizing a WAN transfer’s at 256 KB caps you far below line rate.

# Inspect current limits
sysctl net.core.rmem_max net.core.wmem_max
sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem
sysctl net.ipv4.tcp_congestion_control net.core.default_qdisc

# Available congestion-control algorithms (bbr may need a module)
sysctl net.ipv4.tcp_available_congestion_control

# Live per-socket internals: cwnd, rtt, retransmits, send/recv queue
ss -tin

Socket buffers sized from BDP

TCP autotuning grows the window between the min and max you set. Leave default modest and let it grow to max; size max from your worst-case BDP:

# /etc/sysctl.d/90-net-throughput.conf
# Max socket buffer the kernel will grant (bytes). Size for worst-case BDP.
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216

# TCP autotuning: min / default / max. Let it grow to max under load.
net.ipv4.tcp_rmem = 4096 131072 16777216
net.ipv4.tcp_wmem = 4096 16384  16777216

# Autotuning ON (default; do not disable)
net.ipv4.tcp_moderate_rcvbuf = 1
Knob Controls Default (approx) Size from Gotcha
net.core.rmem_max Max receive socket buffer 212992 Worst-case BDP Ceiling for SO_RCVBUF; apps can request up to this
net.core.wmem_max Max send socket buffer 212992 Worst-case BDP Ceiling for SO_SNDBUF
net.ipv4.tcp_rmem min/default/max RX autotune 4096/131072/6291456 Set max = BDP Middle value is per-socket default
net.ipv4.tcp_wmem min/default/max TX autotune 4096/16384/4194304 Set max = BDP Autotuning grows toward max under load
net.ipv4.tcp_mem Global TCP memory pages (min/pressure/max) auto-sized to RAM Rarely — kernel sizes it In pages, not bytes; total across all sockets
net.core.optmem_max Max ancillary buffer per socket 20480 Rarely Affects cmsg/control data

Backlogs and connection acceptance

Three separate queues govern connection acceptance and packet ingress; conflating them is a classic mistake:

# /etc/sysctl.d/90-net-conn.conf
# Backlog of FULLY-ESTABLISHED sockets waiting for the app to accept() them
net.core.somaxconn = 4096
# Backlog of HALF-OPEN (SYN received, not yet ACKed) connections
net.ipv4.tcp_max_syn_backlog = 8192
# Per-CPU packet ingress queue when packets arrive faster than the stack drains them
net.core.netdev_max_backlog = 16384
# Reuse TIME_WAIT sockets for new OUTBOUND connections (safe; NOT tcp_tw_recycle)
net.ipv4.tcp_tw_reuse = 1

The critical distinction: somaxconn is the accept queue depth — but it is a ceiling, not a guarantee. The application must also request a large listen() backlog (nginx backlog=, or the runtime default), or the kernel caps at whatever the app asked for. If a busy server drops connections under a burst, check netstat -s | grep -i 'listen' for “times the listen queue of a socket overflowed.”

Backlog knob Queue it sizes Default Symptom when too small Confirm overflow with
net.core.somaxconn Accept queue (established, awaiting accept()) 4096 (recent) / 128 (old) Connection resets/timeouts under burst ss -lnt (Recv-Q on listener); nstat TcpExtListenOverflows
net.ipv4.tcp_max_syn_backlog SYN queue (half-open) 1024–4096 SYN drops during SYN floods/bursts nstat TcpExtTCPReqQFullDrop
net.core.netdev_max_backlog Per-CPU ingress queue (softirq) 1000 RX drops when packets outpace stack /proc/net/softnet_stat col 2 (drops)
net.ipv4.tcp_tw_reuse (reuse TIME_WAIT for new outbound) 2 (loopback only) / 0 Ephemeral port exhaustion on chatty clients ss -s TIME_WAIT count

A critical correction to old internet folklore: never enable net.ipv4.tcp_tw_recycle. It was removed entirely in kernel 4.12 because it broke connections from clients behind NAT (it dropped packets whose timestamps looked out of order across NATed sources). The safe sibling is tcp_tw_reuse (reuse TIME_WAIT sockets for outbound connections only), shown above. If a guide tells you to set tcp_tw_recycle, treat the rest of that guide as suspect.

Congestion control and qdisc

The congestion-control algorithm decides how fast TCP sends. cubic (the default) is loss-based: it treats any packet loss as congestion and cuts its window hard — which collapses throughput on fat, lossy, or long-RTT paths where loss is often not congestion. bbr models the path’s bandwidth and RTT instead, so sporadic loss does not tank it — the modern choice worth testing for WAN, wireless, or high-BDP links. BBR pairs with the fq qdisc for correct pacing:

# Enable BBR (module usually auto-loads) and the fq qdisc it needs
modprobe tcp_bbr
sysctl -w net.ipv4.tcp_congestion_control=bbr
sysctl -w net.core.default_qdisc=fq

# Confirm on a live connection
ss -tin | grep -o 'bbr.*' | head
Congestion control Model Best for Trade-off
cubic Loss-based (default) LAN, low-loss paths Collapses throughput on non-congestive loss
bbr Bandwidth+RTT model WAN, high-BDP, lossy/wireless Needs fq qdisc; can be aggressive to cubic neighbours
reno Classic loss-based Compatibility/reference Conservative; rarely chosen deliberately
dctcp ECN-based (datacenter) Low-latency DC fabrics with ECN Requires ECN end to end

Other TCP knobs worth knowing (change deliberately, not reflexively):

Knob Controls Default Note
net.ipv4.tcp_fin_timeout Seconds in FIN-WAIT-2 60 Lower reclaims sockets faster; rarely needed
net.ipv4.tcp_keepalive_time Idle secs before keepalive probe 7200 Lower for faster dead-peer detection
net.ipv4.ip_local_port_range Ephemeral port range 32768–60999 Widen for very chatty outbound clients
net.ipv4.tcp_slow_start_after_idle Reset cwnd after idle 1 Set 0 for long-lived bursty connections
net.ipv4.tcp_mtu_probing PMTU black-hole detection 0 Set 1 where PMTUD is broken (tunnels)
net.ipv4.tcp_notsent_lowat Bytes unsent before app notified -1 Lower for latency-sensitive senders

8. The NIC layer: offloads, ring buffers, RSS/RPS, coalescing

Below the socket sits the NIC, and its defaults are tuned for general use, not your workload. ethtool is the tool for all of it.

# Driver, link speed, and feature (offload) state
ethtool eth0
ethtool -k eth0          # offloads: GRO, GSO, TSO, LRO, checksum, RSS
ethtool -g eth0          # ring buffer sizes (current vs max)
ethtool -l eth0          # channel (queue) counts for RSS
ethtool -c eth0          # interrupt coalescing
ethtool -S eth0          # per-queue stats and drop counters (the truth source)

Ring buffers

The NIC ring is the descriptor queue between the card and the kernel. Too small and a burst overflows it, dropping packets before the stack even sees them (ethtool -S shows rx_dropped/rx_no_buffer/rx_fifo_errors). Raise RX/TX rings toward the device max for bursty high-throughput workloads:

# Current: RX 512, max 4096 — raise RX to 4096 for burst tolerance
ethtool -G eth0 rx 4096 tx 4096

The trade-off: larger rings add a little latency (packets can sit longer) and use more memory, but for throughput and burst tolerance they are usually the right move. Confirm the drop counter stops climbing after the change under the same load.

Offloads

Offloads move per-packet work (segmentation, checksums, coalescing) from the CPU to the NIC or into batched kernel paths, raising throughput and cutting CPU. They are on by default and usually want to stay on for throughput — but a few (notably LRO) merge packets in ways that break forwarding/routing and some latency-sensitive paths:

Offload What it does Keep on for Turn off for
GRO (Generic Receive Offload) Coalesces RX packets in software before the stack Throughput on end hosts Very latency-sensitive RX (adds small delay)
LRO (Large Receive Offload) Coalesces RX in hardware (lossy of headers) Pure end-host throughput Any router/bridge/forwarder — breaks it
GSO (Generic Segmentation Offload) Defers TX segmentation to just before the NIC Throughput (default on) Rarely off
TSO (TCP Segmentation Offload) NIC segments large TCP sends Throughput, lower CPU Debugging; some virt paths
RX/TX checksum NIC computes checksums Almost always Rarely (debugging corruption)
RSS (Receive-Side Scaling) HW hashes flows across RX queues/CPUs Multi-core throughput Never off; tune queue count instead
# Turn LRO off on a forwarding host (routers/bridges must not coalesce)
ethtool -K eth0 lro off
# Turn GRO off only if you measure latency benefit and can afford the CPU
ethtool -K eth0 gro off

RSS and RPS — spreading packet processing

A single RX queue’s softirq processes on one CPU; at high packet rates that one core saturates while others idle. RSS (Receive-Side Scaling) is the hardware solution: the NIC hashes each flow to one of several RX queues, each with its own IRQ pinned to a different CPU. RPS (Receive Packet Steering) is the software fallback for NICs with too few hardware queues — the kernel redistributes received packets across CPUs after the IRQ:

# RSS: how many hardware queues (channels) does the NIC expose, and set them
ethtool -l eth0
ethtool -L eth0 combined 8      # 8 RX/TX queues -> spread IRQs across 8 cores

# RPS (software): steer RX from queue 0 to CPUs 0-7 (hex bitmask)
echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus
# RFS (flow steering) so a flow lands on the CPU running its app thread
sysctl -w net.core.rps_sock_flow_entries=32768
echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
Mechanism Layer Spreads Use when
RSS Hardware Flows across HW RX queues → IRQs → CPUs NIC has enough HW queues (modern 10G+)
RPS Software (kernel) Packets across CPUs after IRQ NIC has too few HW queues
RFS Software Flows to the CPU running the consuming app Cache locality between softirq and app
XPS (Transmit Packet Steering) Software TX across queues by CPU Multi-queue TX locality
aRFS Hardware-accelerated RFS HW steers flow to app’s CPU NIC + driver support aRFS

Interrupt coalescing

Coalescing batches interrupts — the NIC waits a few microseconds or a few packets before interrupting, trading a little latency for far fewer interrupts (and thus more throughput and less CPU). Lower it for latency, raise it for throughput:

# Adaptive coalescing (driver picks) is a good default; or set explicit values:
ethtool -C eth0 adaptive-rx on adaptive-tx on
ethtool -C eth0 rx-usecs 50 rx-frames 64     # throughput-leaning
ethtool -C eth0 rx-usecs 5  rx-frames 8      # latency-leaning
Coalescing knob Controls Latency-leaning Throughput-leaning
rx-usecs µs to wait before RX interrupt Low (5) Higher (50–100)
rx-frames Packets to batch before RX interrupt Low (8) Higher (64)
adaptive-rx Driver auto-tunes coalescing on (good default) on
tx-usecs/tx-frames Same for TX Low Higher

9. Observability: perf, bpftrace, and sar

The coarse USE tools (vmstat, iostat, mpstat, PSI) tell you which resource is the bottleneck. To attribute it to a specific function, syscall, or off-CPU wait — the difference between “the CPU is busy” and “the CPU is busy in pg_checksum_page because you enabled data checksums” — you need perf and bpftrace. And to see trends over time rather than a live snapshot, you need sar.

sar — historical system activity

sar (from sysstat) records system metrics to /var/log/sa/ on a cron/timer schedule and lets you replay any window — indispensable for “it was slow at 03:00” investigations where you were asleep. Enable collection (systemctl enable --now sysstat), then query historically:

# CPU utilization every 10 min for today
sar -u
# Memory + swap; -B for paging (pgscan/pgsteal = reclaim pressure)
sar -r ; sar -B ; sar -S
# Block device throughput and await, per device
sar -d -p
# Network throughput and errors per interface
sar -n DEV ; sar -n EDEV
# Replay a specific past day's file and window
sar -u -f /var/log/sa/sa15 -s 03:00:00 -e 04:00:00
sar flag Reports Bottleneck it reveals
-u Per-CPU %user/%system/%iowait/%steal CPU saturation, iowait, hypervisor steal
-r / -S Memory / swap utilization Memory pressure, swap usage
-B Paging: pgscan, pgsteal, majflt Reclaim pressure, thrashing
-d -p Per-device tps, await, %util Disk latency and saturation over time
-n DEV / -n EDEV Interface throughput / errors+drops Network saturation and drops
-q Run-queue length and load average CPU saturation trend
-w Context switches + task creation Scheduler churn

perf — sampling profiler and event counter

perf samples the CPU (or hardware counters) to build a statistical picture of where cycles go. The flow is record → report, and a flame graph turns the report into a visual where width = time spent. Use it to find the hot function, cache-miss hotspots, or scheduler behaviour:

# System-wide CPU profile for 30s, then an interactive report
perf record -F 99 -a -g -- sleep 30
perf report --stdio | head -40

# Hardware counters: cache misses, branch misses, IPC for a command
perf stat -e cycles,instructions,cache-references,cache-misses,branch-misses ./myapp

# Off-CPU / scheduler latency: why a thread is NOT running
perf sched record -- sleep 10 ; perf sched latency

# Flame graph (needs Brendan Gregg's FlameGraph scripts)
perf record -F 99 -a -g -- sleep 30
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg
perf subcommand What it shows Use for
perf record/report Sampled call stacks (on-CPU) Finding the hot function; flame graphs
perf stat Hardware/software event counts (IPC, misses) Cache/branch behaviour, IPC per workload
perf top Live top functions by samples Watching a hotspot in real time
perf sched Scheduler latency, off-CPU time Why a thread waits/doesn’t run
perf trace strace-like syscall summary (lower overhead) Syscall latency and counts
perf mem/perf c2c Memory access + cache-line contention NUMA/false-sharing hotspots

bpftrace — ad-hoc kernel tracing with eBPF

bpftrace runs safe eBPF programs to answer questions the counters can’t — per-syscall latency histograms, off-CPU stacks, block-I/O size distributions, TCP retransmit sources — with near-zero overhead and no reboot. It is the sharpest instrument for “why is this slow” once USE has pointed at a resource:

# Distribution of block I/O size (bytes) issued to the device
bpftrace -e 'tracepoint:block:block_rq_issue { @bytes = hist(args->bytes); }'

# read() syscall latency histogram (microseconds) by process
bpftrace -e 'tracepoint:syscalls:sys_enter_read { @s[tid] = nsecs; }
             tracepoint:syscalls:sys_exit_read /@s[tid]/ {
               @us[comm] = hist((nsecs - @s[tid]) / 1000); delete(@s[tid]); }'

# Off-CPU time: where threads block (kernel stack) and for how long
bpftrace -e 'kprobe:finish_task_switch { @[kstack] = count(); }'

# Which process is issuing the most block I/O
bpftrace -e 'tracepoint:block:block_rq_issue { @[comm] = count(); }'

The BCC toolkit ships ready-made tools built on the same machinery — reach for these before writing bespoke bpftrace:

Tool Answers Layer
biolatency / biosnoop Block I/O latency distribution / per-I/O trace Disk
execsnoop / opensnoop New processes / file opens fleet-wide Process/FS
tcplife / tcpretrans TCP session summaries / retransmit sources Network
runqlat / runqlen Scheduler run-queue latency / length CPU
cachestat Pagecache hit/miss ratio Memory
offcputime / oncputime Off-CPU and on-CPU stack time CPU/blocking
funclatency Latency of a specific kernel/user function Any

The observability discipline mirrors the tuning discipline: use these tools to attribute a bottleneck before changing a knob, and to validate that the change moved the specific metric — a flame graph before and after, or a biolatency histogram that shifted left. Remember that perf and eBPF are privileged (see Security notes); use targeted probes on production, not always-on broad tracing.

Architecture at a glance

There is no single diagram for performance tuning because there is no single “system” — there is a stack of resources, each with its own bottleneck, its own USE signal, and its own knob layer, and the whole point of the method is to walk that stack top to bottom rather than reach for one favourite knob. Picture the machine as five layers a request or a byte passes through, and picture the diagnostic loop wrapped around all of them.

Start at the top with the application — a thread that wants CPU, memory, a socket, and a block device. Beneath it sits the kernel scheduler and CPU layer: which core the thread runs on (at full frequency or crawling out of a deep C-state), whether an interrupt storm on the same core is stealing its cycles, and — on a multi-socket box — whether the memory it touches is local or a remote-NUMA round trip. Below that is the virtual-memory layer: hugepages vs 4 KB pages, THP defrag stalling the thread, a wall of dirty pages about to block every writer synchronously. Below that, two parallel I/O paths: the block layer (right or wrong scheduler, a queue full or starved, a readahead window matched or mismatched to the access pattern) and the network stack (a socket buffer sized to the bandwidth-delay product or three orders of magnitude off, a backlog that absorbs a burst or drops it, a NIC ring that overflows, interrupts piled on one core or spread across eight). At the very bottom sits the hardware and its firmware — the NVMe device, the DIMMs on each node, the NIC and its offload engines, the BIOS power profile that may be quietly throttling everything above it.

Now wrap the loop around it. The USE method is the entry point into this stack: vmstat/mpstat//proc/pressure/cpu point you at the CPU layer, free/sar -B//proc/pressure/memory at the VM layer, iostat//proc/pressure/io at the block layer, ss/sar -n DEV/ethtool -S at the network layer. The saturation signal — a run queue longer than the core count, a PSI full line climbing, a NIC drop counter ticking, a queue depth pinned high — tells you which layer to open, so you tune that layer and only that layer. tuned is the coherent bundle you apply across the layer; tuned-adm verify proves it took; perf/bpftrace attribute the stall to a specific function or off-CPU wait when the coarse tools are ambiguous; and the re-run of the identical baseline closes the loop. The architecture in one sentence: measure the stack from the top, localise to one layer by its saturation signal, change one coherent bundle there, and prove it against the same benchmark before moving down.

Real-world scenario

A payments platform team ran their core PostgreSQL 16 fleet on dual-socket AMD EPYC servers with NVMe and 512 GB RAM, on kernel 6.1 with tuned throughput-performance. After a hardware refresh (more cores, faster NVMe, same architecture), p99 query latency got worse — from a steady 4 ms to a spiky 7–11 ms — despite the faster hardware. The on-call narrative was “the new NVMe firmware is bad,” and someone had already opened a vendor ticket.

The USE method told a different story, layer by layer. iostat -xz 1 showed NVMe %util under 20% with sub-millisecond w_await and a shallow queue — storage was idle, not the bottleneck, and /proc/pressure/io full avg10 sat at 0. mpstat -P ALL 1 showed no single-core saturation and negligible %steal. free -g showed plenty of headroom and si/so at zero — not memory-starved. But numastat -p on the postgres backends showed numa_foreign climbing into the millions per minute: the Linux scheduler was spreading connection backends across both sockets while shared_buffers lived on node 0, so roughly half the connections were doing every buffer access across the inter-socket link. The “slow disk” was actually remote-NUMA memory latency, and the refresh made it worse precisely because more cores let the scheduler spread work wider across the two nodes than the old, smaller box ever had.

Two secondary findings compounded it. sysctl vm.zone_reclaim_mode returned 1 — it had silently re-enabled on the new kernel for this NUMA topology, so the kernel was reclaiming local pagecache rather than making a cheap remote allocation, throwing away hot cache. And cat /sys/kernel/mm/transparent_hugepage/enabled showed [madvise] (not never) — the refresh had reset it, reintroducing occasional khugepaged defrag stalls that showed up as the p99 spikes.

The fix was locality and posture, not faster hardware. They stopped fighting the scheduler and instead turned one cross-NUMA database into two node-local ones: a templated systemd unit bound postgresql@0 to node 0 and postgresql@1 to node 1, each with its own shared_buffers local to its node.

# /etc/systemd/system/postgresql@.service.d/numa.conf
# postgresql@0 binds node 0; postgresql@1 binds node 1
[Service]
NUMAPolicy=bind
NUMAMask=%i

They folded the other two fixes into the tuned child profile so they would survive the next kernel bump: [sysctl] vm.zone_reclaim_mode=0 and [vm] transparent_hugepages=never, then tuned-adm profile kv-postgres && tuned-adm verify to prove all three were live. They changed one class of knob at a time — NUMA binding first (re-measured), then zone_reclaim_mode (re-measured), then THP (re-measured) — so each delta was attributable.

The result: p99 dropped from the spiky 7–11 ms to a steady 4.3 ms, and — the part the team cared about most — the variance collapsed; the tail became predictable. No firmware was changed and the vendor ticket was closed. The runbook line they wrote: “On multi-socket hardware, prove memory locality with numastat before blaming any other resource — and re-pin your tuned posture after every kernel upgrade, because the defaults will drift back.”

Advantages and disadvantages

Methodical, measurement-driven tuning through tuned + sysctl + per-device knobs is powerful, but it is not free — the discipline itself has costs, and so does each knob. Weigh it honestly:

Advantages (why the method + these knobs help) Disadvantages (why they bite)
The USE method + PSI localise the bottleneck to one resource fast — no guessing which layer Requires a representative benchmark and a recorded baseline; without them you cannot judge a change
tuned applies coherent, named, revertible bundles — one command to apply, one to revert A stock profile can silently override a hand-set sysctl; you must verify and know inheritance
Per-device I/O schedulers and per-node NUMA binding fix the specific device/topology, not the fleet Per-device/per-node tuning is fiddly and must be re-checked after hardware or kernel changes
Sizing TCP buffers from BDP keeps fat pipes full without wasting RAM on LAN servers Wrong BDP either caps throughput (too small) or wastes memory (too large) — you must compute it
Capping C-states / pinning performance governor removes tail-latency jitter for latency-critical nodes Costs real watts and heat; wrong on a batch/density fleet where efficiency matters more
isolcpus/nohz_full give near-jitter-free cores for the hardest latency needs Isolated cores are lost to everything else — a capacity trade-off, and boot-time (needs reboot)
perf/bpftrace attribute a stall to a function or off-CPU wait — no more hand-waving Requires kernel symbols/tracepoints and skill to read; misread flame graphs mislead
Explicit hugepages cut TLB misses and pin DB buffers predictably Reserved at boot; wrong count wastes RAM or starves the app; fragmentation blocks runtime reservation
Defaults are safe but generic — tuning wins real latency/throughput when a workload deviates Many defaults are already right on modern kernels; over-tuning adds risk with no gain

The method is right whenever a workload deviates from “general purpose”: latency-critical services, high-BDP or high-throughput pipes, multi-socket hardware, big-memory databases. It is wrong to apply reflexively — modern defaults are sensible, and changing a dozen knobs “because a guide said so” adds regression risk with no measured gain. Every knob here can hurt if set without a baseline to prove it helped, which is exactly why the loop — baseline, one change, re-measure, keep-or-revert — is non-negotiable.

Hands-on lab

This lab establishes a baseline, applies a tuned child profile that changes one coherent bundle (VM writeback + THP + I/O scheduler), verifies it, and re-measures — the full loop, on a single test box. It is safe to run on a disposable VM or lab server; every change is reverted at the end. You need sudo, tuned, fio, sysstat (for sar/iostat/mpstat), and a scratch filesystem at /data (adjust paths). Do not run destructive fio against a device with real data.

Step 1 — Record the environment and the active profile.

uname -r
tuned-adm active
nproc; free -g
lsblk -d -o NAME,ROTA,SIZE,MODEL    # ROTA 0 = SSD/NVMe, 1 = HDD
cat /sys/block/nvme0n1/queue/scheduler   # note the [active] one
sysctl vm.dirty_ratio vm.swappiness
cat /sys/kernel/mm/transparent_hugepage/enabled

Expected: prints your kernel (e.g. 6.1.x), the current profile (often balanced or throughput-performance), core/RAM counts, and the current I/O scheduler in brackets. Write these down — they are the baseline environment.

Step 2 — Capture a storage baseline with fio.

# 4k random read, direct I/O, QD32, 4 jobs, 30s. Report p99/p99.9.
sudo fio --name=base --filename=/data/fiotest --size=2G --direct=1 --rw=randread \
    --bs=4k --iodepth=32 --numjobs=4 --group_reporting --runtime=30 --time_based \
    --ioengine=io_uring --percentile_list=50:95:99:99.9 | tee /tmp/fio-baseline.txt
grep -E 'IOPS|99.00th|99.90th' /tmp/fio-baseline.txt

Expected: an IOPS number and latency percentiles, e.g. IOPS=180k, 99.00th=[ 210], 99.90th=[ 450] (microseconds). Record IOPS, p99, p99.9 — these are the numbers every later change is judged against.

Step 3 — Watch PSI under the same load (in a second terminal). While Step 2 runs, in another shell:

watch -n1 'cat /proc/pressure/io /proc/pressure/cpu'

Expected: io some avg10 rises during the run; io full avg10 should stay low if storage is not the ceiling. Note the peak io some/full — a saturation baseline.

Step 4 — Author and apply a tuned child profile (one coherent bundle).

sudo mkdir -p /etc/tuned/lab-storage
sudo tee /etc/tuned/lab-storage/tuned.conf >/dev/null <<'EOF'
[main]
summary=Lab: throughput-performance + explicit VM writeback, THP off, NVMe none
include=throughput-performance

[sysctl]
vm.dirty_background_bytes=268435456
vm.dirty_bytes=1073741824
vm.swappiness=1

[vm]
transparent_hugepages=never

[disk]
devices=nvme*n*
elevator=none
readahead=128
EOF

sudo tuned-adm profile lab-storage

Expected: no error; tuned-adm active now shows lab-storage.

Step 5 — Verify the profile actually took (the step everyone skips).

sudo tuned-adm verify
sysctl vm.dirty_bytes vm.swappiness             # expect 1073741824 and 1
cat /sys/kernel/mm/transparent_hugepage/enabled # expect [never]
cat /sys/block/nvme0n1/queue/scheduler          # expect [none]

Expected: tuned-adm verify prints “Verfication succeeded, current system settings match the preset profile.” If it reports a mismatch, a stale /etc/sysctl.d/ drop-in or an elevator= cmdline is fighting the profile — resolve that before trusting the re-measure.

Step 6 — Re-run the identical fio baseline and diff.

sudo fio --name=tuned --filename=/data/fiotest --size=2G --direct=1 --rw=randread \
    --bs=4k --iodepth=32 --numjobs=4 --group_reporting --runtime=30 --time_based \
    --ioengine=io_uring --percentile_list=50:95:99:99.9 | tee /tmp/fio-tuned.txt
echo "=== BASELINE ==="; grep -E 'IOPS|99.00th|99.90th' /tmp/fio-baseline.txt
echo "=== TUNED    ==="; grep -E 'IOPS|99.00th|99.90th' /tmp/fio-tuned.txt

Expected: on an NVMe device that was already on none, the storage numbers may be unchanged — which is a valid, honest result proving the scheduler was not your bottleneck. The VM/THP changes matter under a write-heavy or large-dirty workload; re-run with --rw=randwrite to see the dirty-ratio effect. The lesson is the method, not a guaranteed win: you changed one bundle, verified it, and re-measured against a recorded number.

Step 7 — (Optional) Prove a NUMA/CPU observation with a quick tool.

numactl --hardware | head          # topology and distance matrix
cat /proc/pressure/cpu             # cpu saturation right now
mpstat -P ALL 1 3                  # per-core; look for a lone saturated core

Step 8 — Teardown (revert everything).

# Revert to the profile you recorded in Step 1 (e.g. throughput-performance or balanced)
sudo tuned-adm profile throughput-performance
sudo tuned-adm verify
sudo rm -rf /etc/tuned/lab-storage
sudo rm -f /data/fiotest /tmp/fio-baseline.txt /tmp/fio-tuned.txt
tuned-adm active                   # confirm you are back to the original profile

Expected: active profile is back to the original; the custom profile directory and test files are gone. Nothing you changed here persists.

Common mistakes & troubleshooting

Performance work goes wrong in predictable ways. This is the symptom → root cause → confirm → fix playbook; scan for your symptom and jump to the row:

# Symptom Root cause Confirm (exact command) Fix
1 “Tuning” changed nothing Default was already correct on this kernel Diff baseline vs after; both identical Accept it; that knob was not your bottleneck
2 Change helped but you can’t say which Changed multiple knob classes at once (no single-variable measurement exists) Revert all; change one class per iteration
3 Set a sysctl but it’s not live tuned profile overrides your /etc/sysctl.d/ drop-in tuned-adm verify (reports mismatch); sysctl <key> Move the delta into the tuned child profile
4 OOM-killer fires under memory pressure vm.swappiness=0 prevents swapping, kills instead dmesg -T | grep -i oom; sysctl vm.swappiness Use swappiness=1 (avoid-but-allow), not 0
5 Every client behind NAT randomly fails tcp_tw_recycle set (removed since 4.12; broke NAT) sysctl net.ipv4.tcp_tw_recycle (errors on 4.12+) Remove it; use tcp_tw_reuse=1 for outbound
6 Throughput plateaus far below line rate TCP buffers too small for the BDP ss -tin (small cwnd/rcv-space); compute BDP Raise rmem_max/wmem_max/tcp_rmem to BDP
7 p99 latency spikes with no disk/CPU cause THP khugepaged defrag stalls cat /sys/kernel/mm/transparent_hugepage/enabled = [always]/[madvise] Set never for self-managing DBs/JVMs, durably
8 Writers freeze periodically vm.dirty_ratio too high on large RAM → huge sync writeback sar -B 1 (pgpgout spikes); vm.dirty_ratio = 20 Set vm.dirty_bytes/dirty_background_bytes
9 One CPU pinned at 100% %soft, others idle All NIC IRQs/softirq on one core (no RSS/RPS) mpstat -I SCPU -P ALL 1; cat /proc/interrupts RSS (ethtool -L combined N) / RPS / IRQ affinity
10 Latency worse after adding cores/sockets Remote-NUMA memory access (work spread wider) numastat -p <pid> (numa_foreign climbing) Bind process CPU+memory to one node (numactl)
11 File-cache-heavy app slow, memory “free” vm.zone_reclaim_mode=1 reclaims local cache sysctl vm.zone_reclaim_mode = 1 Set vm.zone_reclaim_mode=0
12 NVMe latency higher than expected Legacy bfq/scheduler adding software overhead cat /sys/block/nvme0n1/queue/scheduler = [bfq]/[mq-deadline] Set none; make durable via udev rule
13 Sequential reads slow, disk under-utilized read_ahead_kb too small for the scan pattern cat /sys/block/<d>/queue/read_ahead_kb Raise read_ahead_kb (e.g. 4096) for sequential
14 Connections dropped/reset under burst somaxconn / app listen() backlog too small nstat -az | grep ListenOverflow; ss -lnt (Recv-Q) Raise somaxconn and the app’s backlog
15 RX packets dropped under load NIC ring buffer overflow ethtool -S eth0 | grep -i drop; -g shows small ring Raise rings: ethtool -G eth0 rx 4096 tx 4096
16 Router/bridge corrupts or drops flows LRO coalescing on a forwarding host ethtool -k eth0 | grep large-receive = on ethtool -K eth0 lro off on forwarders
17 Latency jitter on an otherwise idle box Deep C-state exit latency on wakeup cpupower idle-info (high C6 exit latency) force_latency / intel_idle.max_cstate=1
18 Frequency never reaches max under load powersave/schedutil ramp latency cat .../scaling_governor; turbostat (low MHz) performance governor (tuned latency-performance)
19 %steal high in mpstat Hypervisor is taking your CPU cycles mpstat -P ALL 1 (%steal > 0) Not a guest tuning issue — raise with the host/provider
20 Config lost after reboot Runtime echo/sysctl -w not persisted Value differs before/after reboot Persist via tuned profile / /etc/sysctl.d/ / cmdline
21 numa_balancing thrash on a pinned process Auto NUMA balancing fighting your explicit pin sysctl kernel.numa_balancing = 1 while pinned Set kernel.numa_balancing=0 when binding manually

Two meta-mistakes invalidate everything: changing more than one class of knob per iteration (you can never attribute the result), and never re-running the identical baseline (you judge by feeling). Catch either and reset — an unattributable “win” is a future regression waiting to happen.

Best practices

Security notes

Performance tuning and security intersect more than people expect — several knobs and mitigations trade one for the other, and the observability tooling is privileged.

Cost & sizing

Performance tuning’s cost is mostly opportunity and risk, not licence fees — the software (tuned, sysstat, bpftrace, ethtool) is free and in-distro. But the choices you make have real hardware and power implications:

Rough sizing guidance: a latency-critical database node justifies performance governor + capped C-states + NUMA binding + explicit hugepages (accepting the watts); a batch/analytics fleet is better on throughput-performance (or balanced) with default power saving, where efficiency matters more than the tail. The cost model is hardware not bought (the win) versus watts, heat, and reserved RAM (the price of aggressive postures) — apply the expensive postures only where measurement proves they pay.

Interview & exam questions

1. Explain the USE method and why saturation is the strongest signal. USE = for each resource (CPU, memory, disk, network) check Utilization, Saturation, and Errors. Saturation — work queueing because the resource is full — is the strongest signal because a resource can be 100% utilized yet fine (a batch job wants that), but saturation means demand exceeds capacity and something is waiting. On modern kernels, PSI (/proc/pressure/*) is the most honest saturation metric.

2. What does PSI full avg10 on /proc/pressure/io mean? It is the percentage of the last 10 seconds during which every non-idle task was stalled waiting on I/O. Unlike the some line (≥1 task stalled), full means the whole machine was blocked — a definitive “storage is the bottleneck” signal, regardless of what device utilization suggests.

3. Why should you never edit a stock tuned profile in place, and what do you do instead? Stock profiles under /usr/lib/tuned are package-owned and overwritten on upgrade, so your edits vanish. Instead create a child profile in /etc/tuned/<name>/tuned.conf that include=s the stock profile and overrides only your measured deltas; apply with tuned-adm profile and prove it with tuned-adm verify.

4. Does vm.swappiness=0 disable swap? What should a database use? No — 0 makes the kernel avoid swapping until reclaim is nearly impossible, which can trigger the OOM killer sooner (it kills rather than swaps). Databases should use 1 (“avoid but allow”). To truly disable swap you swapoff -a and remove it from fstab.

5. Why is tcp_tw_recycle dangerous and what replaces it? It was removed in kernel 4.12 because it dropped packets from clients behind NAT (it rejected connections whose TCP timestamps looked out of order across different sources sharing a NAT). The safe replacement is tcp_tw_reuse=1, which reuses TIME_WAIT sockets for new outbound connections only, without the NAT hazard.

6. How do you size TCP socket buffers correctly? From the bandwidth-delay product: BDP = link bandwidth × RTT. Set net.core.rmem_max/wmem_max and the max value of tcp_rmem/tcp_wmem to at least the worst-case BDP so a single stream can keep the pipe full. A 10 GbE link at 10 ms RTT needs ~12.5 MB; a 1 GbE LAN at 0.2 ms needs ~25 KB — which is why one constant for the whole fleet is wrong.

7. When is none the right I/O scheduler and why? For NVMe and fast SSDs. Those devices have deep internal queues and reorder requests better than the OS can, so a software scheduler only adds latency and CPU. mq-deadline suits SATA SSD/HDD (bounds worst-case latency, prevents starvation); bfq suits interactive desktops needing per-process fairness but is too CPU-costly for high-IOPS server paths.

8. What is remote-NUMA latency and how do you prove and fix it? On multi-socket servers, memory on a remote node costs ~1.5–2× the local access latency. A process spread across sockets while its memory lives on one node pays that tax constantly — and more cores make it worse by spreading work wider. Prove it with numastat -p <pid> (climbing numa_foreign); fix it by binding CPU+memory to one node (numactl --cpunodebind=N --membind=N or systemd NUMAPolicy=bind), and keep vm.zone_reclaim_mode=0.

9. Why do THP hurt some databases, and what should they set? Transparent Hugepages trigger background defragmentation (khugepaged) and synchronous huge-page collapse/split, causing unpredictable latency spikes for apps that manage their own memory (PostgreSQL, MySQL, Redis, Mongo, JVM heaps). Those should set THP to never (durably, via tuned or cmdline) and, if they want large pages, use explicit reserved hugepages instead.

10. What causes one CPU to sit at 100% %soft while others idle, and how do you fix it? All of a NIC’s interrupts/softirq are landing on one core (no RSS, or IRQs pinned to one CPU). Confirm with mpstat -I SCPU -P ALL 1 and /proc/interrupts. Fix by enabling multiple hardware queues (ethtool -L eth0 combined N) so RSS spreads flows across cores, or use RPS/IRQ affinity to distribute, keeping IRQ, softirq, and the app thread L3-local.

11. Difference between isolcpus, nohz_full, and IRQ affinity? isolcpus removes cores from the scheduler’s load balancing (only explicitly pinned tasks run there); nohz_full makes a core tickless (no periodic scheduler tick) when running a single task, cutting jitter; IRQ affinity (smp_affinity) controls which CPUs service a device’s interrupts. Together they carve near-jitter-free cores: isolate them, make them tickless, and steer IRQs to the housekeeping cores instead.

12. What is the difference between C-states and P-states (governors), and which matters more for latency? P-states (cpufreq governors) set the frequency of an active core; C-states set how deeply an idle core sleeps. Governors add ramp-up latency; C-states add wakeup (exit) latency that can be far larger (tens–hundreds of µs for C6). For latency-critical work, C-states are usually the bigger lever — cap them via PM-QoS (force_latency) or intel_idle.max_cstate=1, and pin the performance governor to remove frequency jitter too.

These map to vendor-neutral Linux Foundation LFCS/LFCE and RHCE/RHCSA performance-tuning objectives, and the eBPF/observability portions align with Linux Foundation eBPF and general SRE competency. A compact revision map:

Question theme Cert / competency Objective area
USE method, PSI, baselining SRE / LFCE Performance analysis and capacity
tuned profiles + verify RHCSA/RHCE, LFCS System tuning and configuration
sysctl (vm/net/fs) LFCE, RHCE Kernel runtime parameters
NUMA, CPU governors, C-states LFCE / SRE Hardware-aware tuning
I/O schedulers, block queue LFCE, RHCE Storage performance
Network stack, BDP, congestion LFCE / SRE Network performance
perf / bpftrace / eBPF LF eBPF / SRE Observability and tracing

Quick check

  1. You change five sysctls and a scheduler at once, reboot, and latency improves. What is wrong with concluding your tuning “worked”?
  2. /proc/pressure/io shows full avg10 = 35 under load, but iostat shows the NVMe device at only 40% %util. What do you conclude and which metric do you trust?
  3. Your database on a dual-socket box got slower after a refresh that added cores. Name the most likely cause and the one command that confirms it.
  4. A guide tells you to set net.ipv4.tcp_tw_recycle=1 and vm.swappiness=0. What is wrong with each?
  5. You need to make an NVMe-none scheduler choice survive reboot on a mixed-media host without hand-editing every device. What mechanism do you use?

Answers

  1. You changed more than one class of knob at once, so the improvement is unattributable — you cannot tell which change helped, and one of them may be hurting while another over-compensates. Revert everything and change one class per iteration, re-measuring each. An unattributable win is a future regression.
  2. Trust PSI. io full avg10 = 35 means that for 35% of the last 10 seconds every non-idle task was blocked on I/O — the machine is I/O-bound regardless of the %util figure (which can under-report on multi-queue devices or when latency, not busy-time, is the ceiling). Storage is your bottleneck; investigate device latency (await) and queue depth.
  3. Remote-NUMA memory latency: more cores let the scheduler spread the process’s threads wider across both sockets while its memory sits on one node, so more accesses cross the inter-socket link. Confirm with numastat -p <pid> — a climbing numa_foreign/numa_miss is the smoking gun. Fix by binding CPU+memory to one node.
  4. tcp_tw_recycle was removed in kernel 4.12 because it breaks clients behind NAT (out-of-order timestamps across NATed sources get dropped) — never set it; use tcp_tw_reuse=1 for outbound. vm.swappiness=0 does not disable swap; it makes the kernel OOM-kill sooner rather than swap — use 1 on databases for “avoid but allow.”
  5. A udev rule keyed on device class (KERNEL=="nvme[0-9]*n[0-9]*"ATTR{queue/scheduler}="none"; KERNEL=="sd[a-z]", ATTR{queue/rotational}=="0"mq-deadline) in /etc/udev/rules.d/, or the tuned [disk] plugin (devices=nvme*n*, elevator=none) — one mechanism, not both, so tuned-adm verify does not fight the udev rule.

Glossary

Next steps

You can now run the tuning loop end to end: baseline with USE, localise to one resource, change one coherent bundle through a tuned profile, verify it, and re-measure. Build outward:

linuxperformancetunedsysctlnumaio-schedulerbpftracekernel-tuning
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading