CPU Pinning and NUMA for Game Servers: Locking Threads to Cores

Modern game servers rarely use every core you give them — most run their simulation on one or two threads while the rest of the box idles. On a shared or multi-instance machine, the OS scheduler can bounce those hot threads between cores, dragging cache and latency with them. CPU pinning (affinity) and NUMA awareness fix exactly that: they lock threads to specific cores and keep memory access local, which translates directly into steadier tick rates and lower worst-case ping. It is one of the cheapest performance upgrades available, because it costs nothing but configuration time.

When Pinning Actually Helps

  • Multiple game instances on one box — each server gets its own cores, so a busy Minecraft world cannot steal CPU from a Rust server on the same machine.
  • Latency-sensitive simulations — single-thread games like Project Zomboid or ARK benefit when the main thread never migrates.
  • Dedicated boxes with background services — backups, monitoring agents, and databases get their own cores instead of competing.

Skip pinning on a single-instance server with plenty of idle cores — the scheduler already keeps the hot thread resident, and you gain nothing but complexity. Pinning is a fix for contention, not a free speed boost.

Checking Your Topology First

Before pinning anything, find out how cores and memory are laid out. On Linux:

lscpu -e          # core/socket/NUMA mapping per CPU
lscpu | grep NUMA # NUMA node layout
taskset -pc $$    # current affinity of a shell

A two-socket EPYC or Xeon box has two NUMA nodes, each with its own memory. Threads that run on node 0 but allocate memory on node 1 pay a cross-node penalty — small per access, but it adds up at thousands of ticks per minute. Single-socket machines have one node and none of these concerns, which is why many game hosts prefer them despite the lower core ceiling.

Pinning with taskset and systemd

For a quick test, launch the server pinned to cores 0–1:

taskset -c 0,1 ./server.sh start

For something that survives reboots, use a systemd unit with CPUAffinity:

[Service]
ExecStart=/srv/games/minecraft/start.sh
CPUAffinity=0,1
MemoryMax=8G

Or pin by PID at runtime with taskset -p -c 0,1 $(pgrep -f java). To pin all threads of a multithreaded server, walk /proc/PID/task/* and set affinity per thread — a short loop does it:

for t in /proc/$(pgrep -f server)/task/*; do
  taskset -p -c 0,1 ${t##*/}
done

Remember that core numbers from lscpu -e are physical IDs; on a hyperthreaded CPU, cores 0 and 1 may share a physical core. If you pin two busy instances to sibling threads, you get contention instead of isolation — check the CPU column of lscpu -e and give each instance its own physical core.

NUMA-Aware Memory Placement

Pinning CPU without pinning memory gets you half the benefit. Use numactl to keep the process’s memory on the same node as its cores:

numactl --cpunodebind=0 --membind=0 ./server.sh start

On a single-socket box there is one node, so this is a no-op — but on dual-socket hardware, membind prevents the “local CPU, remote RAM” pattern that shows up as erratic lag spikes. Check allocation with numastat -p PID and /proc/PID/numa_maps. If the game allocates a huge world buffer at startup, keep it on the same node as the threads that touch it every tick.

Realistic Expectations

ScenarioExpected change
Two game servers on one box, cores split 4+4Worst-case tick spikes drop 30–50%; average tick time unchanged
Single server, idle boxNo measurable difference
Dual-socket, cross-node traffic eliminatedP99 latency improves; average latency roughly flat

The pattern to notice: pinning smooths the tail, not the average. Your players will feel it as fewer lag spikes during raids and server-wide events, not as a lower baseline ping. That is exactly the profile a gaming community notices.

Pitfalls to Avoid

  • Pinning to logical (HT) siblings — two busy threads on one physical core contend for execution units; verify with lscpu -e.
  • Over-pinning — leaving the OS with no cores for interrupts, SSH, and monitoring causes system-wide stalls. Reserve at least one core for the kernel.
  • Pinning before measuring — capture baseline tick times with top -H or a tick-rate mod first; otherwise you cannot tell if the change helped.
  • Ignoring cgroup limits — if you run containers, set --cpuset-cpus at the container level instead of relying on in-container taskset.

Pinning is a tuning step, not a substitute for correct sizing. If the box is under-powered, affinity just makes the shortage more consistent. Pair it with real capacity planning — and if you would rather have a host that handles the CPU layout for you, bare-metal game server hosting gives you full BIOS and scheduler control without shared-neighbor noise. Start with the baseline measurement, pin one instance, compare the tail latency, and only then roll the change out to the rest of your fleet.

Leave a Reply