Skip to main content
RunBook Academy

Proxmox VEXVII · Performance EngineeringContention

Containing a noisy neighbour

Advanced⏱ ~30 minpidstatsystemd-cgtop

What you'll learn

  • Attribute host-level contention to a specific guest using per-process and cgroup evidence
  • Distinguish cpuunits (relative weight) from cpulimit (hard cap) and choose correctly
  • Apply per-disk I/O bandwidth and IOPS throttles to a running VM or container
  • Verify a limit is in effect from the cgroup rather than from the configuration file
  • Recognise when a limit is the right fix and when it is hiding a capacity problem

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

Not yet marked complete on this device.

Somebody opens a ticket saying their VM is slow. Then a second person does. Then a third, from a different team, about an unrelated application. All three guests happen to live on the same node.

That shape — several unrelated guests degrading together on one host — is the signature of a noisy neighbour, and it is one of the few performance problems where the answer really is a configuration change on a single guest rather than a capacity purchase. Provided you can prove which guest, which is where most attempts at this go wrong.

Step 1: prove it is contention, not the node

Before hunting a guest, establish that guests are waiting for something. Utilisation will not tell you; pressure will.

Read-only / Safeis anything waiting, and for what?
cat /proc/pressure/cpu
cat /proc/pressure/io
cat /proc/pressure/memory

uptime
nproc

A load average well above nproc with CPU pressure climbing is CPU contention. I/O pressure climbing with CPU pressure flat is a storage queue. Memory pressure means reclaim, which is a different lesson.

This matters because the containment tool differs by resource, and applying a CPU limit to an I/O problem does nothing except make one team believe they were throttled for no reason.

Step 2: attribute it to a guest

Proxmox puts every guest in its own cgroup, which makes attribution mechanical rather than a guessing game.

Read-only / Safewhich guest is consuming the host?
systemd-cgtop --depth=3 -n 4

# the same question from the process side
pidstat -u -p ALL 2 3 | sort -k8 -rn | head -15
Read-only / SafeVMID 141 is the whole story
# systemd-cgtop --depth=3 -n 1
Control Group                    Tasks   %CPU   Memory  Input/s Output/s
/                                  1204  812.4    198.2G        -        -
qemu.slice                          188  784.1    186.4G        -        -
qemu.slice/141.scope                 22  681.3     34.1G        -        -
qemu.slice/117.scope                 14   38.7     16.0G        -        -
qemu.slice/122.scope                 12   31.2     32.0G        -        -
lxc/205                               9    9.4      2.1G        -        -

Illustrative output

For I/O the equivalent attribution is per-cgroup byte counters.

Read-only / Safewhich guest is generating the I/O?
# VMs
grep . /sys/fs/cgroup/qemu.slice/*.scope/io.stat 2>/dev/null

# containers
grep . /sys/fs/cgroup/lxc/*/io.stat 2>/dev/null

# and the pressure each guest is itself experiencing
grep . /sys/fs/cgroup/qemu.slice/*.scope/io.pressure 2>/dev/null

Step 3: choose the right lever

Proxmox exposes three distinct CPU controls and they are not interchangeable. The most common mistake in this whole subject is reaching for a hard cap when a weight was wanted.

OptionTypeAcceptedDefaultEffect
cpuunitsRelative weight1 - 262144, clamped to 1 - 10000100 (cgroup v2)Share of CPU when contended. No effect on an idle host.
cpulimitHard cap0 - 1280 (none)Total host CPU time in cores. 2.5 means 250%. Applies always.
cores / socketsTopologyHow many vCPUs exist at all. Changing it needs a guest restart.

The documentation describes cpuunits as “often called CPU shares or CPU weight… a relative weight”, and cpulimit as “a floating point value representing CPU time in percent, so 1.0 is equal to 100%, 2.5 to 250%”.

The cpuunits range has a wrinkle worth knowing. The qm man page documents 1 - 262144, but the API schema adds that the value “will be clamped to [1, 10000] in cgroup v2” — and PVE 8 and later run cgroup v2 exclusively. So a guest set to 50000 is silently running at 10000, and two guests set to 50000 and 200000 have the same weight as each other rather than a 4:1 ratio. Stay inside 1 - 10000 and the number you set is the number in effect.

The difference in practice:

  • cpuunits costs nothing when the host is not contended. A guest with cpuunits 50 on a quiet node runs exactly as fast as one with cpuunits 400. The weight only decides who yields when the host runs out of CPU. That makes it the safe, always-appropriate setting.
  • cpulimit applies even on an empty host. A guest capped at 2.0 cores can never exceed 200% of one core’s worth of host CPU time, at 3 a.m. on a node with 62 idle cores. That is sometimes what you want — chargeback, a test guest, a batch job you have promised will never interfere — and it is very often not.
Configuration changelower the weight of the noisy guest — the reversible first move
VMID=141

qm set "$VMID" --cpuunits 50

qm config "$VMID" | grep -E 'cpuunits|cpulimit|cores'
Configuration changeor raise the weight of the victims instead
for VMID in 117 122; do
qm set "$VMID" --cpuunits 400
done

for VMID in 117 122; do
qm config "$VMID" | grep -E 'cpuunits'
done
Service impact possiblehard-cap a guest that must not exceed a share
VMID=141

qm set "$VMID" --cpulimit 4

qm config "$VMID" | grep cpulimit

Containers use the same options through pct:

Configuration changethe container equivalents
CTID=205

pct set "$CTID" --cpuunits 50
pct set "$CTID" --cpulimit 2

pct config "$CTID" | grep -E 'cpuunits|cpulimit'

Step 4: I/O throttles

CPU weight does nothing for a guest saturating a storage backend. That needs a per-disk throttle, set on the disk parameter rather than on the guest as a whole.

Configuration changethrottle a single disk's bandwidth and IOPS
VMID=141

qm set "$VMID" --scsi0 \
local-zfs:vm-141-disk-0,iothread=1,mbps_rd=200,mbps_wr=100,iops_rd=5000,iops_wr=2000

qm config "$VMID" | grep scsi0

Step 5: verify from the cgroup, not the config file

qm config tells you what you asked for. The cgroup tells you what the kernel is enforcing. These disagree more often than you would like — after a failed live apply, after a migration, after a guest restart that picked up a pending change you had forgotten about.

Read-only / Safewhat is the kernel actually enforcing?
VMID=141
CG="/sys/fs/cgroup/qemu.slice/$VMID.scope"

cat "$CG/cpu.weight"
cat "$CG/cpu.max"
cat "$CG/io.max" 2>/dev/null

# and the effect: how much throttled time has this guest accumulated?
grep -E 'nr_throttled|throttled_usec' "$CG/cpu.stat"
Read-only / Safethe cap is real, and the guest is hitting it
# cat /sys/fs/cgroup/qemu.slice/141.scope/cpu.max /sys/fs/cgroup/qemu.slice/141.scope/cpu.stat
400000 100000
usage_usec 8841200311
user_usec 7710455102
system_usec 1130745209
nr_periods 812204
nr_throttled 190338
throttled_usec 41882200914

Illustrative output

nr_throttled is the number that closes the loop. A cap that is set but never hit contributes nothing; a cap being hit constantly means the guest is permanently constrained, which is a different conversation from containing an occasional burst.

When a limit is the wrong answer

Containment is a tourniquet. It is the right response to one guest behaving abnormally, and the wrong response to a node that is simply full.

The distinction is whether the sum of what the guests need fits.

Read-only / Safeis this a noisy neighbour, or an overloaded node?
echo "physical cores: $(nproc)"

qm list | awk 'NR>1 && $3=="running" {print $1}' | while read -r VMID; do
qm config "$VMID" | awk -v id="$VMID" '/^cores:/ {c=$2} /^sockets:/ {s=$2} END {print id, (c?c:1)*(s?s:1)}'
done | awk '{t+=$2} END {print "configured vCPUs on running guests:", t}'

cat /proc/pressure/cpu

If the node is oversubscribed and every guest genuinely wants its allocation, throttling one guest just chooses a victim. The honest answers are to migrate guests off, add a node, or right-size the guests — and that is the xvii-performance-right-sizing conversation, informed by the N x u / (N - 1) arithmetic from Part II.

Common mistakes

  • Reaching for cpulimit when cpuunits was wanted. A weight costs nothing on an idle host; a cap applies always and turns a bursty guest into a permanently slow one.
  • Trusting qm config as verification. The cgroup is what the kernel enforces. cpu.weight, cpu.max and nr_throttled are the truth.
  • Throttling the victim. The culprit shows high throughput and low pressure; the victims show low throughput and high pressure. Read io.stat and io.pressure together.
  • Setting an I/O ceiling at the guest’s normal requirement. A binding throttle produces guest task hangs and read-only remounts, not a gentle slowdown. Set it above the working figure.
  • Not telling the guest’s owner. A throttled guest looks, from inside, exactly like a slow one. The follow-up ticket gets investigated from scratch by someone who does not know.
  • Containing a guest on a node that is simply full. A limit then just chooses which team suffers. Compare configured vCPUs against physical cores before deciding.
  • Assuming the consumer is a guest. Backups, scrubs, resilvers and Ceph recovery are host-side and need entirely different levers.
  • Throttling Ceph recovery with osd_recovery_sleep on Squid or Tentacle. mClock is the default scheduler there and forces those settings to zero. Change the mClock profile instead.

Key takeaways

  • Establish contention with /proc/pressure/* before hunting a guest; utilisation cannot answer the question.
  • Attribute with systemd-cgtop — PVE names scopes by VMID, so the offender identifies itself.
  • cpuunits is a relative weight, default 100, and has no effect on an uncontended host. It is the safe first move. Keep values inside 1 - 10000; cgroup v2 clamps anything higher.
  • cpulimit is a hard cap in cores, range 0 - 128, default 0, and applies unconditionally. Use it only when a guaranteed ceiling is the actual requirement.
  • I/O throttles go on the disk parameter and are enforced by QEMU, not by the cgroup — so io.max stays empty even when they are active.
  • Verify from cpu.weight, cpu.max and nr_throttled, not from qm config.
  • CFS quota stops every vCPU at once, so a throttled multi-vCPU guest loses more performance than the quota fraction suggests.
  • Assign weights at guest creation in tiers. A cluster where everything is at the default has no contention policy.
  • A limit on a full node just picks a victim. Check configured vCPUs against physical cores first.

Knowledge check

Knowledge check · 5 questions

  1. Q1. A guest bursts to 12 cores for ninety seconds each hour, degrading its neighbours. You want it to yield during contention but still finish quickly when the host is quiet. Which change fits?

  2. Q2. Because I/O throttles set via mbps_rd and iops_wr on a VM disk are enforced by QEMU rather than by the cgroup controller, the guest cgroup io.max file can be empty while a throttle is fully in force.

  3. Q3. A team reports their reporting VM is slow. The host it runs on is idle, the guest sits at 100% CPU, and there is no contention anywhere on the node. What should you check before investigating the application?

  4. Q4. systemd-cgtop shows the host heavily loaded but no guest scope consuming anything unusual, and the pressure is on I/O. Which are plausible host-side consumers worth checking? Select all that apply.

  5. Q5. On a Proxmox VE 9 node with Ceph Squid or Tentacle, which is the correct way to bias I/O toward client workloads while recovery is running?

Passing score: 75%. Answers are checked in this browser.