Baseline First: Profile Before You Tune
Never tune blind. Before changing a single kernel parameter, collect a baseline with perf, vmstat, iostat, and ss. You need numbers to compare against, and you need to know which subsystem is actually the bottleneck.
Run vmstat at 1-second intervals for 60 seconds and save the output. Look at the 'si' and 'so' columns first - if you see nonzero swap-in or swap-out, memory is your primary problem, not CPU or I/O. Look at 'b' (blocked processes) to detect I/O saturation before chasing CPU metrics.
For CPU profiling, perf stat gives you hardware counter data in seconds. Pay attention to instructions-per-cycle (IPC). A value below 1.0 on a modern x86 CPU indicates memory latency stalls, not compute bottlenecks. Tuning CPU affinity when the real problem is cache thrash wastes your time.
On our test server we saw a PostgreSQL workload reporting 90% CPU usage in top but perf stat showed IPC of 0.62 and cache-miss rate of 18%. The fix was huge pages, not CPU pinning.
# Capture 60-second baseline
vmstat 1 60 > /tmp/vmstat_baseline.txt
iostat -xz 1 60 > /tmp/iostat_baseline.txt
ss -s > /tmp/ss_baseline.txt
# Hardware counter baseline for PID 12345
perf stat -p 12345 sleep 30
# Check current IPC and cache misses
perf stat -e cycles,instructions,cache-misses,cache-references -p 12345 sleep 10
Kernel Parameter Tuning with sysctl
The sysctl interface controls hundreds of kernel parameters at runtime. We apply a production sysctl profile on every server at provisioning time and version-control it. The values below are tested on kernel 6.6 LTS with high-throughput web and database workloads.
vm.swappiness controls how aggressively the kernel swaps anonymous pages. The default is 60. On servers with adequate RAM and latency-sensitive workloads, set it to 10. Setting it to 0 does not disable swap entirely on kernels >= 3.5 - it just makes the kernel strongly prefer reclaiming file cache over swapping. On pure-RAM database servers we set it to 1.
vm.dirty_ratio and vm.dirty_background_ratio control when the kernel starts writing dirty pages to disk. Defaults are 20% and 10% of total RAM respectively. On a 256GB server that means up to 51GB of dirty data before forced writeback, which creates massive I/O spikes. We set dirty_ratio to 5 and dirty_background_ratio to 2 on database servers to smooth out write patterns.
net.core.somaxconn defaults to 4096 on most distributions. For high-connection services, raise it to 65535. Pair this with the application's listen backlog setting - nginx and haproxy have their own backlog parameters that must be raised to match.
fs.file-max sets the system-wide file descriptor limit. The default 1048576 is sufficient for most workloads, but large-scale Redis or Kafka deployments can exhaust it. We set it to 2097152 on those systems.
# /etc/sysctl.d/99-performance.conf
# Memory management
vm.swappiness = 10
vm.dirty_ratio = 5
vm.dirty_background_ratio = 2
vm.overcommit_memory = 1
vm.overcommit_ratio = 50
# Virtual memory pressure
vm.vfs_cache_pressure = 50
# Network core
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65536
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
# TCP stack
net.ipv4.tcp_rmem = 4096 87380 134217728
net.ipv4.tcp_wmem = 4096 65536 134217728
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65535
# File descriptors
fs.file-max = 2097152
# Apply immediately
# sysctl -p /etc/sysctl.d/99-performance.conf
CPU Scheduling and NUMA Awareness
On multi-socket systems, NUMA topology is often the biggest source of latency that sysadmins overlook. A process running on socket 0 accessing memory allocated on socket 1 pays a 40-80ns penalty per access on AMD EPYC systems. Across millions of operations per second, this compounds into measurable throughput loss.
Check your NUMA topology first with numactl --hardware. Then use numastat to see whether your running processes are generating remote NUMA hits. If the 'numa_miss' counter is climbing, you have a NUMA problem.
For single-threaded or lightly-threaded services, pin processes to a specific NUMA node using numactl at launch. For multi-threaded services like PostgreSQL, use the numa_balancing kernel parameter - set it to 1 (default on most distributions) and let the kernel's automatic NUMA balancing handle page migration. On kernel 6.6+ the NUMA balancer is significantly improved over 5.x.
CPU governor selection matters under variable load. The 'performance' governor eliminates frequency scaling latency at the cost of power consumption. The 'schedutil' governor (default on many distros since kernel 5.x) is reactive within microseconds and is a reasonable default for most workloads. For latency-critical services like real-time trading or packet processing, use 'performance' and disable C-states via the kernel command line.
Isolating CPUs from the scheduler using isolcpus is appropriate for latency-sensitive single-threaded tasks. We use it on systems running DPDK packet processing. Set isolcpus=4-7 in GRUB_CMDLINE_LINUX, then assign your process to those cores with taskset.
# Check NUMA topology
numactl --hardware
numastat -p $(pgrep postgres | head -1)
# Run a process on NUMA node 0 only
numactl --cpunodebind=0 --membind=0 /usr/bin/myapp
# Check current CPU governor
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
# Set all CPUs to performance governor
for cpu in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
echo performance > $cpu
done
# Pin process to specific CPUs
taskset -cp 4-7 $(pgrep myapp)
# Check NUMA balancing status
cat /proc/sys/kernel/numa_balancing
I/O Scheduler Selection and Block Device Tuning
The I/O scheduler choice has a significant impact on throughput and latency, and the right choice depends on your storage hardware. On NVMe SSDs, use none (also called noop) - the drive has its own internal command queue and the kernel scheduler adds overhead without benefit. On SATA SSDs, mq-deadline is our standard choice. On spinning disks, bfq gives better latency fairness at the cost of some throughput.
Verify your current scheduler with the command below, then change it at runtime per device. For persistent changes, use a udev rule rather than rc.local.
Read-ahead tuning is frequently overlooked. The default 128KB read-ahead is a compromise. For sequential workloads (log processing, backups, media streaming), increase it to 1MB or 4MB. For random-access workloads (databases), reduce it to 0 or 16KB. PostgreSQL and MySQL both perform better with minimal read-ahead because they implement their own prefetching.
For NVMe drives, also check the queue depth. NVMe supports up to 65535 queues with 65535 commands per queue. The kernel default nr_requests is 64. For high-throughput NVMe workloads, raise this to 1024. We tested a sequential write benchmark on Samsung 990 Pro NVMe and saw throughput increase from 4.2GB/s to 5.8GB/s by raising nr_requests from 64 to 512.
If you run LVM, ensure the chunk size aligns with your workload. A misaligned LVM stripe can cut RAID performance by 30-50% due to partial-stripe writes. Use pvdisplay and check PE size against your RAID stripe width.
# Check current I/O scheduler for nvme0n1
cat /sys/block/nvme0n1/queue/scheduler
# Set scheduler at runtime
echo none > /sys/block/nvme0n1/queue/scheduler
echo mq-deadline > /sys/block/sda/queue/scheduler
# Persistent via udev rule
# /etc/udev/rules.d/60-ioschedulers.rules
# ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="none"
# ACTION=="add|change", KERNEL=="sd[a-z]*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline"
# Adjust read-ahead (in 512-byte sectors, so 2048 = 1MB)
blockdev --setra 2048 /dev/nvme0n1
# Adjust NVMe queue depth
echo 512 > /sys/block/nvme0n1/queue/nr_requests
# Check current read-ahead
blockdev --getra /dev/nvme0n1
Huge Pages: Transparent vs Static
Huge pages reduce TLB pressure by mapping 2MB or 1GB regions instead of 4KB pages. On memory-intensive workloads like databases, caches, and JVM applications, this reduces TLB miss rate and measurably improves throughput. PostgreSQL documentation recommends static huge pages. The Linux kernel's Transparent Huge Pages (THP) feature is a different beast.
THP is enabled by default on most distributions. It works well for some workloads and causes latency spikes in others. The problem is compaction: the kernel periodically tries to collapse 4KB pages into 2MB huge pages, and this compaction process causes latency spikes of 10-100ms. Redis, MongoDB, and real-time applications consistently perform better with THP disabled. PostgreSQL benefits from static huge pages but not THP.
For PostgreSQL, calculate your shared_buffers value (typically 25% of RAM), convert to huge pages needed (shared_buffers / 2MB), and set vm.nr_hugepages accordingly. Allow a 10% buffer above what PostgreSQL reports needing - we use the output from pg_config and add 10%.
For JVM applications (Kafka, Elasticsearch), use the -XX:+UseHugePages and -XX:+UseLargePages JVM flags. The JVM will use huge pages automatically if they are available. On our Elasticsearch test cluster, enabling huge pages reduced GC pause times by 15% and improved query throughput by 8% on a 512GB node with 300GB heap.
For DevOps teams automating this configuration at scale, tools like taskbotshub.ai can codify these tuning profiles as repeatable automation tasks that apply consistently across fleets of servers during provisioning.
# Check THP status
cat /sys/kernel/mm/transparent_hugepage/enabled
# Disable THP (add to /etc/rc.local or systemd unit for persistence)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
# Calculate huge pages for PostgreSQL shared_buffers = 32GB
# 32GB / 2MB = 16384 pages, add 10% buffer = 18000
echo 18000 > /proc/sys/vm/nr_hugepages
# Verify allocation
grep HugePages /proc/meminfo
# Persistent sysctl entry
echo 'vm.nr_hugepages = 18000' >> /etc/sysctl.d/99-hugepages.conf
# Check current huge page usage
cat /proc/meminfo | grep -i huge
Network Stack Tuning for High-Throughput Services
TCP BBR congestion control, introduced in kernel 4.9 and significantly improved in BBR v3 landing in kernel 6.x, outperforms CUBIC on high-latency or lossy links. On same-datacenter traffic the difference is minimal, but on inter-region or internet-facing services we consistently see 15-25% throughput improvement with BBR enabled. Check your kernel version first - BBR v2 is available from 5.13+, BBRv3 patches are available in 6.6 LTS.
For services handling tens of thousands of concurrent connections, the key bottlenecks are socket buffer sizes, connection tracking table size, and port range. The sysctl values in the kernel tuning section above cover socket buffers. For conntrack, check current usage with conntrack -C and compare against net.netfilter.nf_conntrack_max. On busy NAT gateways or firewalls, conntrack table exhaustion causes connection drops that look like network flaps.
Receive-side scaling (RSS) distributes incoming packets across CPU cores using hardware queues. Verify your NIC supports RSS with ethtool -l eth0. If combined channels equals 1, RSS is either not supported or not configured. Modern NICs (Intel E810, Mellanox ConnectX-6) support 64+ queues. Set the channel count to match the number of physical CPU cores on the socket closest to the NIC's PCIe slot.
For services using epoll extensively (nginx, haproxy, custom event-loop servers), tune net.core.netdev_max_backlog. Under packet bursts, if the NIC delivers packets faster than the kernel can process them, they queue here. The default 1000 is too low for 10GbE and above. We set it to 65536.
Softirq CPU balance matters at high packet rates. Check /proc/net/softnet_stat - the third column counts dropped packets due to backlog overflow. If it's climbing, use RPS (Receive Packet Steering) to distribute softirq processing across cores even on NICs without hardware RSS support.
# Enable BBR
modprobe tcp_bbr
echo bbr > /proc/sys/net/ipv4/tcp_congestion_control
echo 'net.ipv4.tcp_congestion_control = bbr' >> /etc/sysctl.d/99-performance.conf
# Verify BBR is active
sysctl net.ipv4.tcp_congestion_control
# Check conntrack usage vs limit
conntrack -C
sysctl net.netfilter.nf_conntrack_max
# Increase conntrack table (for busy NAT)
sysctl -w net.netfilter.nf_conntrack_max=2097152
# Check NIC queue configuration
ethtool -l eth0
# Set RSS queues to 16
ethtool -L eth0 combined 16
# Configure RPS for eth0 on a 16-core system (all cores)
echo ffff > /sys/class/net/eth0/queues/rx-0/rps_cpus
# Check for softnet drops
cat /proc/net/softnet_stat | awk '{print $1, $2, $3}' | head -20
ulimit and Resource Limits for Production Services
System-wide sysctl changes mean nothing if per-process resource limits cap your service. Check effective limits for a running process - do not rely on /etc/security/limits.conf alone, because systemd services inherit limits from their unit file, not from PAM.
The nofile limit (open file descriptors) is the most commonly hit limit. Default is 1024 in many distributions. nginx, Kafka, Redis, and Elasticsearch all need values in the hundreds of thousands. For systemd services, set LimitNOFILE in the unit file. For non-systemd processes, set limits in /etc/security/limits.conf and verify the PAM session module includes pam_limits.so.
The nproc limit (max threads) bites JVM services. Elasticsearch on a loaded node can spawn thousands of threads between Lucene, network IO, and GC. The default nproc of 4096 is too low. We set it to 131072 for Elasticsearch and Kafka nodes.
Stack size (stack) defaults to 8MB. Most services are fine with this. If you run recursive algorithms or deep call stacks (some Haskell or Erlang services), you may need to increase it. More commonly, you want to decrease it for services that spawn thousands of threads - each thread reserves stack space in virtual memory, and with the default 8MB stack and 10,000 threads you need 80GB of virtual address space reserved.
Verify limits are applied by checking /proc/PID/limits after the service starts. We automate this check in our deployment pipeline to catch configuration drift before it becomes a production incident.
# Check effective limits for a running process
cat /proc/$(pgrep -f elasticsearch | head -1)/limits
# systemd unit override for Elasticsearch
# /etc/systemd/system/elasticsearch.service.d/limits.conf
[Service]
LimitNOFILE=524288
LimitNPROC=131072
LimitMEMLOCK=infinity
# Reload and restart
systemctl daemon-reload
systemctl restart elasticsearch
# /etc/security/limits.conf entries for non-systemd services
# kafka soft nofile 524288
# kafka hard nofile 524288
# kafka soft nproc 131072
# kafka hard nproc 131072
# Verify limits for all running Java processes
for pid in $(pgrep java); do
echo "=== PID $pid ==="
grep -E 'Max open files|Max processes' /proc/$pid/limits
done
Profiling Persistent Bottlenecks with perf and BPF
When standard metrics do not explain a performance problem, perf record and BPF tools give you function-level visibility into kernel and userspace behavior. These are not exotic tools - they ship with most distributions and run on production systems with minimal overhead.
perf record with -g (call graph) and --call-graph dwarf gives you a flamegraph-ready profile. Run it for 30 seconds against a PID, then use perf script piped to Brendan Gregg's flamegraph tools. The SVG output shows exactly which functions consume CPU cycles. We have found slow regex engines, inefficient serialization code, and accidental O(n^2) loops this way - none of which appeared in application-level profiling.
For I/O latency, bpftrace provides one-liner tracing without kernel recompilation. The biolatency tool from bcc-tools shows block I/O latency distribution as a histogram. If you see a bimodal distribution with a tail beyond 10ms on NVMe, suspect IRQ affinity mismatches or power management interference.
for network tracing, tcpretrans from bcc-tools shows retransmits with process context, which is far more useful than netstat -s aggregate counts. A single misbehaving process generating retransmits shows up immediately.
off-CPU analysis is valuable for services that appear CPU-idle but have high latency. perf record -e sched:sched_switch captures scheduler events. The resulting flamegraph shows time spent waiting for locks, disk I/O, or network - not time executing. On one production system, this revealed a configuration sync daemon taking exclusive file locks and blocking our main application for 200ms every 30 seconds.
# CPU flamegraph - 30 second sample of PID 12345
perf record -F 99 -p 12345 -g --call-graph dwarf sleep 30
perf script | /opt/flamegraph/stackcollapse-perf.pl | \
/opt/flamegraph/flamegraph.pl > /tmp/flamegraph.svg
# Block I/O latency distribution (requires bcc-tools)
/usr/share/bcc/tools/biolatency -d nvme0n1 30
# TCP retransmits with process context
/usr/share/bcc/tools/tcpretrans
# bpftrace one-liner: show slow block I/O (>5ms)
bpftrace -e '
kprobe:blk_account_io_start { @start[arg0] = nsecs; }
kprobe:blk_account_io_done /@start[arg0]/ {
$lat = (nsecs - @start[arg0]) / 1000;
if ($lat > 5000) { printf("%s %d us\n", comm, $lat); }
delete(@start[arg0]);
}'
# Off-CPU analysis
perf record -e sched:sched_switch -ag sleep 30
perf script | /opt/flamegraph/stackcollapse-perf.pl | \
/opt/flamegraph/flamegraph.pl --color=io > /tmp/offcpu.svg
Automating Tuning Profiles Across Server Fleets
Manual sysctl changes do not survive reboots unless committed to /etc/sysctl.d/. They do not survive server rebuilds unless version-controlled and applied via automation. The tuning work in this guide is only durable if it is codified and applied consistently across your fleet.
Ansible is the standard tool for this. Write a role that applies your sysctl profile, sets scheduler rules via udev, configures hugepages, and applies ulimit changes via systemd unit overrides. Test the role in staging before fleet-wide application - some sysctl changes like vm.overcommit_memory = 2 are catastrophic on systems with insufficient swap.
For teams integrating performance tuning into CI/CD pipelines, AI-assisted DevOps platforms like taskbotshub.ai can help generate and validate tuning playbooks, flag parameter conflicts, and track configuration drift across environments.
Version your sysctl profiles just like code. When a kernel upgrade changes a parameter's behavior (this happened with net.ipv4.tcp_tw_recycle, which was removed in 4.12), you want to find and update every instance. Store profiles in git, tag them with the kernel version range they were validated against, and run automated smoke tests after applying them to verify the expected behavior.
For naming and documenting internal tuning profiles, teams running multiple environments sometimes use external services to manage internal project identifiers. If you are organizing tuning profiles by environment or application name and need a consistent naming scheme, a service like nicename.me can help establish clean, memorable identifiers for internal projects and documentation.
Benchmark after every tuning change. Use fio for storage, iperf3 for network, and sysbench for CPU and memory. A 10% improvement in one metric that causes a 5% regression in another may or may not be the right trade-off depending on your workload - but you need numbers to make that decision.
# Ansible task example: apply sysctl performance profile
- name: Apply performance sysctl profile
ansible.posix.sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
sysctl_file: /etc/sysctl.d/99-performance.conf
reload: yes
loop:
- { name: 'vm.swappiness', value: '10' }
- { name: 'vm.dirty_ratio', value: '5' }
- { name: 'net.core.somaxconn', value: '65535' }
- { name: 'net.ipv4.tcp_congestion_control', value: 'bbr' }
- { name: 'vm.nr_hugepages', value: '{{ hugepages_count }}' }
# fio benchmark: sequential read on NVMe
fio --name=seqread --rw=read --bs=1M --size=10G \
--numjobs=4 --iodepth=32 --runtime=60 \
--filename=/dev/nvme0n1 --direct=1
# iperf3 baseline
iperf3 -c 10.0.0.1 -t 30 -P 4 -Z