Why CPU Pinning and NUMA Even Matter
Most Proxmox VE hosts run for years without anyone touching CPU scheduling. The default behavior — let the Linux kernel's CFS scheduler bounce vCPU threads across whatever physical cores are free — works fine for the vast majority of workloads: web servers, small databases, file shares, a handful of LXC containers. But once you're running latency-sensitive workloads on a multi-socket or high-core-count host — a real-time telemetry pipeline, a game server, a NUMA-aware database like PostgreSQL or SQL Server, or a VM that needs predictable jitter for VoIP or industrial control — the scheduler's default behavior becomes the bottleneck. This article covers two related but distinct performance features in Proxmox VE: NUMA topology awareness and CPU pinning (affinity), how they interact, and how to configure both correctly without breaking migration or starving your host.
These are not beginner settings. Misconfigured pinning can make performance worse than doing nothing, and it can silently break live migration between hosts with different topologies. Read the whole article before touching a production VM.
NUMA vs. CPU Pinning: Two Different Problems
It's easy to conflate these because they're configured in the same "Processor" area of the VM hardware tab, but they solve different problems:
- NUMA (Non-Uniform Memory Access) is about memory locality. On multi-socket servers, each physical CPU socket has its own bank of RAM attached directly to it. A core on socket 0 can access socket 0's RAM quickly, but reaching across the interconnect to socket 1's RAM costs extra latency. NUMA-awareness means the hypervisor tries to keep a VM's vCPUs and its allocated memory on the same physical node.
- CPU pinning (affinity) is about scheduling determinism. It ties specific vCPU threads to specific physical CPU threads so the Linux scheduler can't migrate them around, reducing cache thrashing and giving you predictable, reproducible performance.
You can use either independently, but for the workloads that actually need this level of tuning — low-latency, high-throughput, NUMA-aware applications — you typically want both, configured to agree with each other. Pinning a VM's vCPUs to cores on socket 0 while its NUMA policy pulls memory from socket 1 will actively hurt performance.
Step 1: Map Your Host's Physical Topology
Before changing anything in a VM's configuration, you need to know what your host actually looks like. Don't guess — a "24-core CPU" might be 12 physical cores with hyperthreading across 2 NUMA nodes, or it might be a single-socket, single-node part where none of this matters at all.
Run these on the Proxmox host shell (not inside a VM):
# Confirm the host is NUMA-aware at all
numactl --hardware
# See core/thread/socket layout
lscpu
# Visual topology map (install hwloc if missing: apt install hwloc)
lstopo --no-graphics
numactl --hardware output looks like this on a 2-socket host:
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 24 25 26 27 28 29 30 31 32 33 34 35
node 0 size: 128762 MB
node 0 free: 98122 MB
node 1 cpus: 12 13 14 15 16 17 18 19 20 21 22 23 36 37 38 39 40 41 42 43 44 45 46 47
node 1 size: 128978 MB
node 1 free: 111004 MB
node distances:
node 0 1
0: 10 21
1: 21 10
This tells you three critical things: which logical CPU IDs belong to which NUMA node, how much RAM is local to each node, and the relative "distance" (latency cost) of crossing nodes — here, crossing costs roughly 2.1x more than staying local.
If numactl --hardware reports a single node, or your host is a single-socket workstation-class CPU, NUMA tuning has nothing to do and you can skip straight to the CPU pinning section — most consumer and single-socket server CPUs (including AMD Ryzen and most Intel desktop/workstation parts) present as one NUMA node.
Identifying Hyperthread Siblings
If hyperthreading (Intel) or SMT (AMD) is enabled, each physical core presents as two logical CPU IDs. Pinning two different VMs' vCPUs to sibling threads of the same physical core means they compete for the same execution units — you get almost none of the isolation benefit you were trying to achieve. Find sibling pairs with:
grep -E 'processor|core id' /proc/cpuinfo | paste - -
Logical CPUs that share the same core id value are siblings on the same physical core. Plan your pinning map around this — for latency-sensitive workloads, either pin to one thread per physical core and leave the sibling idle, or pin both siblings of a physical core to the same VM.
Step 2: Enabling NUMA in Proxmox VE
Via the GUI
Select the VM → Hardware → Processors → Edit, and check the Enable NUMA box. This alone tells QEMU to expose a NUMA topology to the guest OS and to let Proxmox's automatic placement try to keep vCPUs and memory co-located — it does not require manual per-node configuration for most cases.
Via the CLI
qm set 100 --numa 1
For simple cases — one VM, evenly divisible cores, no need to pin memory to a specific node — this is often all you need, provided your socket/core count in the VM matches the host's physical layout reasonably closely. As a rule of thumb: if your VM needs 4 or fewer cores and under roughly 64 GB RAM, keep it to a single virtual socket so the guest and hypervisor can trivially fit it inside one physical NUMA node without splitting memory across nodes.
Manual Per-Node NUMA Configuration
For precise control — pinning a VM's memory and vCPUs to an exact physical node — use the numa[n] configuration parameters, either through qm set or by editing /etc/pve/qemu-server/<vmid>.conf directly:
qm set 100 --numa0 cpus=0-5,hostnodes=0,memory=16384,policy=bind
Parameter breakdown:
cpus=— which of the guest's virtual CPU IDs belong to this virtual NUMA node (not host CPU IDs)hostnodes=— which physical host NUMA node this virtual node should be backed bymemory=— how much RAM (in MB) to allocate to this virtual nodepolicy=— the memory allocation policy:bind(only allocate from the specified host node, fail if unavailable — strictest and most predictable),preferred(try the specified node first, fall back to others if full), orinterleave(spread allocation evenly across host nodes — rarely what you want for this use case)
For a VM that should live entirely on host NUMA node 1 with 32 GB of memory and 8 vCPUs:
qm set 100 --sockets 1 --cores 8 --numa 1 \
--numa0 cpus=0-7,hostnodes=1,memory=32768,policy=bind
The policy=bind setting is important here: it makes memory allocation fail loudly if node 1 doesn't have 32 GB free, rather than silently spilling over to node 0 and quietly reintroducing the cross-node latency you were trying to eliminate.
Step 3: CPU Pinning with Affinity
NUMA configuration controls memory locality and the topology QEMU exposes to the guest, but it doesn't by itself stop the host scheduler from moving vCPU threads between physical cores over time. That's what affinity does — it wraps the VM's QEMU process launch in taskset, restricting it to a specific set of host logical CPUs.
# Pin VM 100 to host logical CPUs 0-5
qm set 100 --affinity 0-5
# Pin to a non-contiguous set
qm set 100 --affinity 0,2,4,6,8,10
The affinity string uses standard cpuset list format: comma-separated values and ranges (0-4,9 or 0-2,7,12-14). This setting takes effect the next time the VM starts — it does not apply retroactively to a running VM without a restart.
Confirm it took effect once the VM is running:
taskset -pc $(cat /var/run/qemu-server/100.pid)
This should print the exact CPU set you configured. You can also check live scheduling with htop (press F2 → Display options → enable "Detailed CPU time" and per-core view) to visually confirm the VM's threads stay confined to the expected cores under load.
Making Affinity and NUMA Agree
If you set both --numa0 hostnodes=1 and --affinity, the affinity range must use the same physical host cores that belong to NUMA node 1 — cross-reference against the numactl --hardware output from Step 1. Using node 0's memory (via a `hostnodes=0` binding) while pinning to node 1's cores defeats the entire purpose and typically performs worse than doing no tuning at all, since you've now guaranteed every memory access crosses the node interconnect.
Step 4: Reserve Host Cores with isolcpus
Pinning a VM to specific cores stops the Linux scheduler from moving that VM's threads elsewhere, but it doesn't stop the scheduler from putting other host processes — including other unpinned VMs, backup jobs, or system daemons — on those same cores. For genuinely deterministic, jitter-free performance, exclude the cores from the host's general scheduling pool entirely using the isolcpus kernel boot parameter.
Edit the bootloader configuration. On a host using systemd-boot (common on ZFS-root installs):
vi /etc/kernel/cmdline
# Add to the existing line, e.g.:
# quiet isolcpus=8-15
proxmox-boot-tool refresh
On a host using GRUB:
vi /etc/default/grub
# Edit GRUB_CMDLINE_LINUX_DEFAULT to add isolcpus=8-15
update-grub
Reboot for this to take effect. After reboot, cores 8-15 are invisible to the general scheduler and will only run processes explicitly pinned to them via taskset/affinity — exactly the cores you're assigning to your latency-sensitive VM. Reserve this technique for hosts where you can dedicate a meaningful chunk of cores permanently; on small hosts, isolating cores away from the general pool can starve everything else, including Proxmox's own management processes.
Warning: isolcpus is a host-wide, boot-time setting that affects every guest and every host process — plan it before deploying pinned VMs, not as a quick reactive fix, and document which cores are reserved for which workload so a future admin doesn't accidentally pin a second VM to an already-committed core.
CPU Limits and Shares Are Not Pinning
It's worth being explicit about two settings that are often confused with pinning but do something entirely different:
cpulimitcaps the total amount of host CPU time a VM may consume, expressed in core-equivalents:qm set 100 --cpulimit 4caps the VM at 4 cores' worth of time regardless of how many vCPUs it has, even if the host is otherwise idle. This is a hard ceiling, not a placement hint.cpuunitssets relative scheduling priority (CPU shares) between VMs when the host is under contention. Default is 1024 on cgroup v1 hosts and 100 on cgroup v2 hosts (check withqm config <vmid>if unsure which your host uses). A VM withcpuunits=2000gets roughly twice the CPU time of one at the 1024 default when both are competing for the same cores — but only when there's contention; it does nothing on an idle host.
Neither of these controls which physical cores a VM runs on. You can combine cpulimit/cpuunits with affinity — for example, pinning a VM to 8 dedicated cores and additionally capping it at 6 cores' worth of time to leave headroom — but they solve a fairness/throttling problem, not a locality problem.
Worked Example: Pinning a Database VM
Say you have a 2-socket host (verified via numactl --hardware as shown in Step 1), with node 1 covering logical CPUs 12-23 and 36-47 (hyperthreaded pairs), and you want to dedicate node 1 entirely to a PostgreSQL VM that needs consistent low-latency memory access:
# 1. Reserve node 1's cores from the general scheduler at boot
# (edit cmdline/grub as shown above, then reboot)
# isolcpus=12-23,36-47
# 2. Stop the VM if running
qm stop 100
# 3. Configure sockets/cores to match the physical node's core count
qm set 100 --sockets 1 --cores 12
# 4. Bind NUMA memory and topology to host node 1
qm set 100 --numa 1 --numa0 cpus=0-11,hostnodes=1,memory=65536,policy=bind
# 5. Pin the vCPU threads to node 1's physical logical CPUs
qm set 100 --affinity 12-23,36-47
# 6. Start and verify
qm start 100
taskset -pc $(cat /var/run/qemu-server/100.pid)
numastat -p $(cat /var/run/qemu-server/100.pid)
numastat -p <pid> after some uptime shows you the actual memory access split between nodes — for a correctly bound VM, you should see the overwhelming majority of pages allocated from node 1, with near-zero "other_node" allocations.
Live Migration Implications
This is the tradeoff nobody skips reading about twice: a VM pinned with --affinity to specific host logical CPU IDs is tied to that host's physical layout. Live-migrating it to another node in your cluster with a different core count, socket layout, or CPU ID numbering will either fail outright or silently reintroduce the exact scheduling problems you were trying to avoid, because the affinity range you configured may point at completely different physical cores — or cores that don't exist — on the destination host.
If you run pinned VMs in a cluster:
- Standardize hardware across nodes that will host pinned workloads, or maintain a per-node mapping of which affinity ranges are valid where.
- Clear or reconfigure
--affinityas part of any migration runbook for these VMs rather than assuming it transfers cleanly. - Consider whether HA (High Availability) is even appropriate for a pinned VM — automatic failover to a node with a different topology can do more harm than the downtime you were protecting against.
Troubleshooting
Performance got worse after pinning
The most common cause is pinning across NUMA nodes, or pinning two competing VMs to sibling hyperthreads of the same physical core. Re-check your lscpu/numactl --hardware map and confirm your affinity range and NUMA hostnodes= value agree.
VM won't start after setting numa0
With policy=bind, QEMU refuses to start if the specified host node doesn't have enough free memory. Check available memory per node with numactl --hardware (the "free" line) — other VMs or the host's ZFS ARC cache may already be consuming it. Either free memory on that node, lower the VM's allocation, or switch to policy=preferred if strict binding isn't a hard requirement.
Affinity setting seems to have no effect
Affinity is applied at VM process launch — a config change on a running VM does not retroactively re-launch it. Stop and start the VM (a reboot from inside the guest OS is not sufficient; the QEMU process itself must restart).
htop shows the "pinned" VM still using cores outside its range
Check that you pinned the QEMU process and not just one of its threads — taskset -pc <pid> against the main QEMU PID (from /var/run/qemu-server/<vmid>.pid) should reflect all its threads. Also confirm you didn't leave stale isolcpus ranges from a previous configuration that no longer match your current pinning plan.
Frequently Asked Questions
Do I need this for a typical homelab or small business host?
No. If you're running a single-socket CPU, or your workloads aren't latency-sensitive, the default scheduler behavior is well-tuned and this adds complexity without measurable benefit. This is squarely a tool for multi-socket hosts running specific NUMA-aware or latency-critical applications.
Does this apply to LXC containers too?
LXC containers support cpulimit and cpuunits the same way VMs do, but true per-thread CPU affinity pinning via the Proxmox GUI/API is a VM (QEMU) feature. For containers, host-level cgroup cpuset controls can achieve similar isolation but must be configured more manually.
Can I pin some VMs and leave others unpinned on the same host?
Yes, and this is the normal pattern — reserve a subset of cores via isolcpus for your pinned, latency-sensitive VMs, and leave the remaining cores for the general pool of unpinned VMs and containers.
Will this help a VM that's simply CPU-starved?
No. Pinning and NUMA tuning improve consistency and locality for CPU time you already have — they do not create additional CPU capacity. If a VM is starved because the host is oversubscribed, the fix is adding capacity or reducing overcommit, not pinning.
Summary
NUMA configuration keeps a VM's memory access local to its assigned physical socket; CPU affinity keeps its vCPU threads pinned to specific physical cores so the scheduler can't move them around. Used together and configured to agree — same physical node, matched core ranges, and ideally paired with isolcpus to reserve those cores from general contention — they deliver the deterministic, low-jitter performance that latency-sensitive workloads need. Used carelessly, or configured so the NUMA memory binding and the CPU affinity range point at different physical nodes, they actively degrade performance below the untouched default. Map your host topology first with numactl --hardware and lscpu, plan around hyperthread sibling pairs, and treat live migration compatibility as part of the design — not an afterthought.