Proxmox VEXVII · Performance EngineeringHost tuning
Host tuning: governors, C-states, THP and NUMA balancing
What you'll learn
- Measure the CPU frequency governor, idle policy and achieved frequency on a running node
- Distinguish a throughput problem from a wakeup-latency problem using PSI and turbostat
- Decide between transparent hugepage modes for a KVM host, and set defrag correctly
- Recognise when automatic NUMA balancing fights an explicit VM NUMA topology
- Persist host tuning so it survives a reboot and a kernel upgrade
Prerequisites
Verified against Proxmox VE 9.2.4 · Proxmox Backup Server 4.2.5 · Ceph Squid / Tentacle · Debian 13 (Trixie) · Linux kernel 7.0 (PVE 9.2 default) · 2026-08-12
There is a class of performance ticket that survives every VM-level fix. The guest has enough vCPU. It has enough RAM. Its disk is on NVMe. The host graph is flat at 30% CPU. And the application still misses its latency target by a factor of three.
The settings in this lesson are the usual answer. They live on the host,
they apply to every guest on it, and none of them show up in qm config.
An operator who has only ever tuned VMs cannot find them, which is why
this ticket gets escalated rather than solved.
The one idea that makes the rest make sense
The Linux course states it as a rule and it is worth repeating here because every measurement in this lesson depends on it:
Utilisation is not saturation.
A host at 30% CPU utilisation is not a host with 70% of its performance in reserve. Utilisation says what fraction of wall-clock time a CPU spent executing something. It says nothing about how fast it executed, nor about how long a task waited before it got a turn. A node parked at its lowest frequency with deep idle states enabled is at 30% utilisation and delivering perhaps a third of the work per second it is capable of.
The four levers below each attack a different version of that gap.
| Lever | The gap it closes | Costs |
|---|---|---|
| Frequency governor | Clock speed under bursty load | Power, heat |
| C-states / idle policy | Wakeup latency, measured in microseconds | Power, heat |
| Transparent hugepages | Guest memory address-translation overhead | Latency spikes if defrag is wrong |
| NUMA balancing | Remote memory access on multi-socket hosts | Page-fault overhead; fights explicit pinning |
Measure first: what is this node actually doing?
# Which scaling driver and governor?
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_driver
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
# What is every core actually clocked at, right now?
grep 'MHz' /proc/cpuinfo | sort -u -t: -k2 -n | head
# Which idle states exist, and how deep do they go?
cpupower idle-info
# The consolidated view
cpupower frequency-info# cpupower frequency-infoanalyzing CPU 0:
driver: intel_pstate
CPUs which run at the same hardware frequency: 0
CPUs which need to have their frequency coordinated by software: 0
maximum transition latency: Cannot determine or is not supported.
hardware limits: 800 MHz - 3.70 GHz
available cpufreq governors: performance powersave
current policy: frequency should be within 800 MHz and 3.70 GHz.
The governor "powersave" may decide which speed to use
within this range.
current CPU frequency: 1.19 GHz (asserted by call to hardware)
boost state support:
Supported: yes
Active: yesIllustrative output
The line that matters is the last frequency reading, not the governor
name. A busy node under the powersave governor should still be near its
maximum; a node sitting at 1.19 GHz while the application complains is
either genuinely idle between bursts, or the frequency ramp is losing the
race against a bursty workload.
Saturation, not utilisation: read PSI
Pressure Stall Information is the metric that answers “is anything waiting?” — which is the question utilisation cannot answer.
cat /proc/pressure/cpu
cat /proc/pressure/io
cat /proc/pressure/memory# cat /proc/pressure/cpusome avg10=8.11 avg60=7.44 avg300=6.90 total=48213991Illustrative output
Lever 1 — the frequency governor
On modern Intel and AMD server parts the relevant driver is
intel_pstate or amd-pstate, and it exposes only two governor names:
powersave and performance. Both are hardware-driven; powersave here
is not the classic cpufreq powersave that pins the CPU to its minimum.
It is a demand-following policy, and it is the right default for most
hosts.
performance pins the requested P-state to the maximum. It does not
change your electricity bill by a rounding error — on a dense node it is
tens of watts per socket, continuously.
cpupower frequency-set -g performance
# verify it took, on every CPU rather than just cpu0
grep . /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor | sort -uLever 2 — C-states and wakeup latency
This is the lever most often needed and least often understood, because its symptom does not look like a performance problem at all.
A CPU in a deep idle state (C6, C7) has powered down parts of its cache and clock domains. Waking it takes time — tens to low hundreds of microseconds. That is invisible to a throughput benchmark, which keeps the core busy so it never idles. It is very visible to a workload made of many short, latency-sensitive wakeups: small network requests, a synchronous database commit, an NFS or Ceph client round trip.
cpupower idle-info
# how much time is actually spent in each state?
grep . /sys/devices/system/cpu/cpu0/cpuidle/state*/name
grep . /sys/devices/system/cpu/cpu0/cpuidle/state*/latency
grep . /sys/devices/system/cpu/cpu0/cpuidle/state*/time# cpupower idle-infoCPUidle driver: intel_idle
CPUidle governor: menu
analyzing CPU 0:
Number of idle states: 4
Available idle states: POLL C1 C1E C6
POLL:
Latency: 0
C1:
Latency: 2
C1E:
Latency: 10
C6:
Latency: 133Illustrative output
There are two places to limit idle depth, and they are not equivalent.
In firmware. The BIOS setting (often “Package C-State Limit”, “CPU Power Management” or a “Performance” system profile) is the durable answer and the one that also governs package-level states the OS cannot reach. Part II covers it as a pre-install decision, and it is the right place to make it.
On the kernel command line. intel_idle.max_cstate=1 or the generic
processor.max_cstate=1 caps how deep the OS will go. This is the
reversible one, which makes it the right tool for testing whether
C-states are implicated before you book a firmware window.
proxmox-boot-tool status
# GRUB installs only — edit GRUB_CMDLINE_LINUX_DEFAULT, then:
update-grubcat /etc/kernel/cmdline
# after editing that single line:
proxmox-boot-tool refreshLever 3 — transparent hugepages
A guest’s memory is backed by host pages, and every guest memory access walks two page tables — the guest’s own and the host’s EPT or NPT. Larger host pages mean shallower walks and fewer TLB entries burnt on the same amount of guest RAM, which is why THP is generally a win on a KVM host and why it is enabled by default.
The setting that goes wrong is not enabled. It is defrag.
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/defrag
grep -E 'AnonHugePages|HugePages_Total' /proc/meminfo# cat /sys/kernel/mm/transparent_hugepage/enabled /sys/kernel/mm/transparent_hugepage/defrag[always] madvise never
always defer [defer+madvise] madvise neverIllustrative output
defrag=always means a page fault that cannot immediately find a free
hugepage will synchronously compact memory to build one. On a host
whose memory is fragmented — which is any host that has been up for
months running VMs of assorted sizes — that compaction can stall the
faulting task for milliseconds. The symptom is periodic latency spikes
with no corresponding CPU or I/O event, which is exactly the kind of
thing that gets blamed on the storage.
defer+madvise is the safe production setting on a hypervisor: the
kernel does not stall the fault, it wakes kswapd and khugepaged to do
the work in the background, and applications that explicitly asked with
madvise still get synchronous treatment.
echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag
cat /sys/kernel/mm/transparent_hugepage/defragLever 4 — automatic NUMA balancing
On a multi-socket host, memory attached to the other socket costs more to reach — typically 1.5 to 2 times the local latency. Automatic NUMA balancing is the kernel periodically unmapping pages, catching the resulting faults, and migrating pages or tasks so they end up on the same node.
It is a good default for a host running ordinary VMs whose placement nobody has thought about. It is actively harmful in one specific case.
numactl --hardware
cat /proc/sys/kernel/numa_balancing
numastat
numastat -c qemuThe harmful case: a VM that has been given an explicit NUMA topology
(qm set --numa 1) and pinned with --affinity has had its placement
decided deliberately. Automatic balancing does not know that, and will
keep sampling, faulting and migrating pages that were already in the
right place. You pay the page-fault overhead for a migration that has
nothing to gain.
sysctl -w kernel.numa_balancing=0
sysctl kernel.numa_balancingLever 5 — swappiness
The Proxmox ZFS documentation recommends lowering vm.swappiness to 10
on servers. The default of 60 tells the kernel to be fairly willing to
swap anonymous pages out in favour of keeping page cache. On a hypervisor
the anonymous pages in question are guest RAM, and swapping guest RAM
to disk produces the worst latency in the entire stack: the guest has no
idea it happened and cannot compensate.
sysctl -w vm.swappiness=10
free -h
sysctl vm.swappinessOn a ZFS-root node there is an additional interaction worth knowing: the ARC is not page cache and is not reclaimed by the same path, so a node with an unbounded ARC and low swappiness can still end up with memory pressure that looks inexplicable. That is a storage-backend concern and the per-backend tuning lesson covers it.
Persisting all of it
Everything above is lost at the next reboot unless you write it down somewhere the boot process reads. Three mechanisms, three kinds of setting.
cat > /etc/sysctl.d/90-pve-host-tuning.conf <<'EOF'
vm.swappiness = 10
kernel.numa_balancing = 0
EOF
sysctl --system
sysctl vm.swappiness kernel.numa_balancingcat > /etc/systemd/system/pve-host-tuning.service <<'EOF'
[Unit]
Description=Proxmox host performance tuning
After=multi-user.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag'
ExecStart=/usr/bin/cpupower frequency-set -g performance
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now pve-host-tuning.service
systemctl status pve-host-tuning.service --no-pagerKernel command-line settings — C-state limits, IOMMU — go in
/etc/default/grub plus update-grub on GRUB installs, or
/etc/kernel/cmdline plus proxmox-boot-tool refresh on systemd-boot
installs. proxmox-boot-tool status tells you which one this node is.
Verification that can fail
A tuning change you cannot prove did anything is a tuning change you should not have made. Each lever has a check that returns a value you can compare against the one you recorded first.
echo "== governor =="
grep . /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor | sort | uniq -c
echo "== achieved frequency =="
grep 'MHz' /proc/cpuinfo | sort -u -t: -k2 -n | tail -3
echo "== idle depth =="
cpupower idle-info | grep -E 'Available idle|Latency'
echo "== THP =="
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/defrag
grep AnonHugePages /proc/meminfo
echo "== numa =="
sysctl kernel.numa_balancing
numastat | head -6
echo "== pressure =="
cat /proc/pressure/cpuThe one that most often disagrees with expectation is the governor line.
cpupower frequency-set applies to every online CPU, but a core that was
offline at the time — or a node where the systemd unit ran before all
cores were up — ends up with a mixed policy. sort | uniq -c across all
CPUs catches that; reading cpu0 alone does not.
Common mistakes
- Reading
cpu0and assuming the rest match. Governor and idle policy are per-CPU. Aggregate withsort | uniq -cacross all of them. - Treating
powersaveunderintel_pstateas the old cpufreqpowersave. It is a demand-following policy, not a minimum-frequency pin, and it is a reasonable default. - Concluding the host is fine because utilisation is low. C-state exit latency and compaction stalls consume no CPU time and appear in no utilisation graph. Read PSI.
- Leaving
defrag=alwayson a long-uptime hypervisor. Synchronous compaction on a fragmented host produces millisecond latency spikes that get blamed on storage. - Disabling NUMA balancing on a node with unpinned guests. It helps
there. Disable it only where explicit
numaandaffinityhave already decided placement. - Confusing THP with
qm set --hugepages. Different mechanisms, different costs; one is host-wide and opportunistic, the other is per-VM, pinned, and disables ballooning. - Tuning one node in a cluster. Live migration then makes performance depend on placement, with nothing in any log to explain it.
- Not persisting sysfs settings.
sysctl.ddoes not cover sysfs. The change survives until the next reboot and then quietly disappears.
Key takeaways
- Utilisation is not saturation. A host at 30% CPU can still be the
bottleneck;
/proc/pressure/cpuis the metric that says so. - The governor request is not the achieved frequency.
Bzy_MHzfromturbostatis what the application experiences. - C-state exit latency is invisible to every utilisation graph and shows up as bad latency with fine throughput, worse when the host is less busy.
- Cap C-states on the kernel command line to test the hypothesis; fix it in firmware once you have proved it.
- THP is a win on KVM hosts because it shortens the two-level EPT/NPT
walk. Set
defrag=defer+madviseto get it without compaction stalls. - Disable automatic NUMA balancing only where explicit
numaandaffinityhave already placed the guests. - Lower
vm.swappinessto 10, but keep swap — a hypervisor with no swap reaches the OOM killer instead, and the OOM killer kills guests. - Persist sysctl in
/etc/sysctl.d, sysfs in a systemd unit, and cmdline via the bootloaderproxmox-boot-tool statusnames. - Tune per class of node and keep the class uniform, or live migration turns tuning into an unattributable performance mystery.
Knowledge check
Knowledge check · 5 questions
Q1. A guest misses its latency target. Host CPU utilisation is flat at 25%, storage is idle, and the guest has ample vCPU and RAM. Throughput benchmarks inside the guest look normal. Which host-level cause best fits this evidence?
Q2. On a long-uptime PVE node, guests show periodic multi-millisecond latency spikes with no matching CPU or I/O event. /sys/kernel/mm/transparent_hugepage/defrag reads "[always] defer defer+madvise madvise never". What is happening and what is the fix?
Q3. Automatic NUMA balancing should be disabled on any multi-socket Proxmox host, because explicit placement is always better than the kernel guessing.
Q4. Which statements about persisting host tuning on PVE 9 are correct? Select all that apply.
Q5. After setting the performance governor, turbostat reports Bzy_MHz of 2.4 GHz while cpupower frequency-info reports hardware limits up to 3.7 GHz. What is the most likely explanation?
Passing score: 75%. Answers are checked in this browser.