Konrad Kowalski (rootsher)Principal Platform & Reliability Architect101110111111111000100001101110111100010000000100

NUMA, SMT and cache: when hardware topology starts to matter

date
category
Computer Science
also in
Capacity & Performance
reading
4 min / 887 words

So far we have looked at CPU mostly as an amount of available execution time.

That is enough to understand:

text
requests
limits
throttling
contention
PSI

But at a certain level the abstraction:

text
1 CPU = 1 CPU

stops being sufficient.

Two workloads can get the same amount of CPU time and still do completely different amounts of work.

The reason: CPU is not a homogeneous resource.

What starts to matter is:

text
which logical CPU the task runs on
who it shares a physical core with
where its memory lives
what is in the cache

This is the moment where hardware starts leaking through the Kubernetes abstraction.


A logical CPU does not mean a physical core

On a machine with SMT the system may see:

text
16 physical cores
32 logical CPUs

Each physical core may expose two logical CPUs:

text
Physical Core 0
├── CPU 0
└── CPU 16

For the Linux scheduler those are two separate places to execute.

So we can have:

text
Task A -> CPU 0
Task B -> CPU 16

at the same time.

But both tasks still run on the same physical core.

That means they share part of the hardware resources:

text
execution units
load/store resources
branch prediction
cache
frontend

That is why:

text
2 logical CPUs
!=
2 full physical cores

SMT contention

Assume two identical workloads.

Case A:

text
Core 0 -> workload A
Core 1 -> workload B

Case B:

text
Core 0
├── logical CPU -> workload A
└── logical CPU -> workload B

In both cases the scheduler sees two logical CPUs.

But performance can be completely different.

If both workloads use the same execution resources intensively, they will start getting in each other's way on SMT siblings.

What matters is that both can still be:

text
running

They are not waiting in a runqueue.

They are not being throttled.

CPU usage looks fine.

And yet throughput drops.

That is a different kind of contention from the classic:

text
more runnable tasks than CPUs

Here the problem is not a shortage of scheduler time.

The problem is sharing the physical resources of a core.


CPU time is not a unit of work done

Assume a task executing for:

text
100 ms CPU time

With an empty SMT sibling it may execute in that time:

text
1 000 000 operations

With a heavily loaded sibling:

text
700 000 operations

In both cases CPU accounting will show:

text
100 ms

But real performance is different.

That is a very important conclusion:

text
CPU time
!=
a fixed amount of work

And that is why a millicore is not a unit of performance.


Cache locality

A CPU does not read every value straight from RAM.

Between the processor and memory sits a cache hierarchy:

text
L1
L2
L3 / LLC
RAM

The closer the data sits to the CPU, the cheaper the access.

If a task runs on one CPU and repeatedly uses the same data, some of it may sit in cache.

Now the scheduler migrates the task:

text
CPU 2 -> CPU 11

The new CPU may not have that same data in its local caches.

So the task starts rebuilding locality.

As a result:

text
scheduler migration
        |
        v
cache misses
        |
        v
more access to lower cache levels / RAM
        |
        v
higher latency

The migration itself does not have to be expensive.

The cost comes from losing data locality.


Two workloads can compete for cache

Assume two processes using the same shared LLC.

Each has a working set of:

text
20 MB

And the available shared cache has:

text
24 MB

Separately each workload may perform very well.

Together:

text
20 MB + 20 MB > 24 MB

they start evicting each other's data.

The number of:

text
cache misses

grows, and more requests go to RAM.

The CPU may still look entirely busy.

Both processes are running.

There is no throttling.

And performance drops.

That is another example of a situation where:

text
CPU capacity looks fine

but:

text
hardware locality is bad

NUMA

On larger servers RAM is not uniform either.

A typical topology may look like this:

text
NUMA node 0
├── CPU 0-15
└── Memory A

NUMA node 1
├── CPU 16-31
└── Memory B

CPUs belonging to node 0 have faster access to node 0's memory.

If a CPU from node 0 reaches into node 1's memory, we have:

text
remote memory access

The data has to travel across the interconnect between NUMA nodes.

That usually means:

text
higher latency
lower bandwidth
higher cost of communication

CPU may be pinned well and performance may still be bad

Assume a workload executing only on:

text
CPU 2-5

all of which belong to:

text
NUMA node 0

That looks sensible.

But the application's memory was allocated mostly on:

text
NUMA node 1

So we have:

text
CPU -> node 0
RAM -> node 1

Every memory-intensive access is remote.

The CPU placement is correct.

The memory placement is not.

That is why, for memory-intensive workloads, pinning CPUs alone does not guarantee good performance.


First touch

Linux often uses a rule called:

text
first touch

A physical page of memory is usually placed close to the CPU that actually starts using it first.

Assume:

text
thread init -> NUMA node 0

initialises a large structure:

text
50 GB

Later the worker threads execute on:

text
NUMA node 1

We may end up with:

text
workers -> node 1
memory  -> node 0

The application does not have to know it has just created an enormous amount of remote memory traffic.


How these three things connect

Imagine a workload with:

text
4 CPU

On paper it looks simple.

But two placements are possible.

The good one

text
NUMA node 0

Core 0 -> workload
Core 1 -> workload
Core 2 -> workload
Core 3 -> workload

Memory -> node 0

We have:

text
no SMT sibling contention
good cache locality
local memory

The worse one

text
NUMA node 0

Core 0:
  sibling 1 -> workload
  sibling 2 -> another workload

Core 1:
  sibling 1 -> workload
  sibling 2 -> another workload

Memory -> NUMA node 1

Formally the workload may still have:

text
4 logical CPUs

but the real execution characteristics are much worse.


Why Kubernetes cannot fully hide the hardware

Kubernetes works with a resource abstraction:

text
CPU
memory

That is enough for most workloads.

But the Kubernetes scheduler does not interpret every:

text
1000m

as a guarantee of identical compute power.

At the hardware level:

text
CPU A != CPU B

if they differ in:

text
SMT sibling load
cache locality
NUMA locality

That does not mean the Kubernetes model is bad.

It is simply an abstraction.

And every abstraction has a boundary.


When topology starts to really matter

Not every workload should worry about NUMA and SMT.

For a typical lightweight web service the differences may be small.

It matters more for workloads that are:

text
CPU-bound
memory-bound
low-latency
large JVMs
in-memory databases
packet processing
HPC
ML inference

The more performance depends on individual microseconds, cache misses and memory bandwidth, the more important the physical placement of resources becomes.


Mental model

So far we could think:

text
workload
   |
   v
CPU time

Now the model has to be extended:

text
workload
   |
   v
logical CPU
   |
   v
physical core
   |
   +--> SMT sibling
   |
   +--> cache
   |
   v
NUMA node
   |
   v
memory

Every one of those levels can influence real performance.

Most importantly:

text
1 CPU

means an amount of capacity in the scheduler's model.

It does not mean:

text
a fixed number of instructions per second

Because real performance also depends on:

text
who you share a physical core with,
what cache locality looks like,
and how far away the memory is.

That is the moment where the clean CPU-time abstraction ends and the physics of the machine begins.