Konrad Kowalski (rootsher)Principal Platform & Reliability Architect101110110100110100110011111010000101001111010100

NUMA, SMT i cache: kiedy topologia hardware zaczyna mieć znaczenie

data
kategoria
Computer Science
także w
Capacity & Performance
czytanie
4 min / 736 słów

Do tej pory patrzyliśmy na CPU głównie jako na ilość dostępnego czasu wykonania.

To wystarcza do zrozumienia:

text
requests
limits
throttling
contention
PSI

Ale na pewnym poziomie abstrakcja:

text
1 CPU = 1 CPU

przestaje być wystarczająca.

Dwa workloady mogą dostać tyle samo CPU time, a mimo to wykonać zupełnie różną ilość pracy.

Powód: CPU nie jest jednorodnym zasobem.

Znaczenie zaczyna mieć:

text
na którym logical CPU działa task
z kim współdzieli physical core
gdzie znajduje się jego pamięć
co znajduje się w cache

To właśnie moment, w którym hardware zaczyna przeciekać przez abstrakcję Kubernetes.


Logical CPU nie oznacza physical core

Na maszynie z SMT system może widzieć:

text
16 physical cores
32 logical CPUs

Każdy physical core może wystawiać dwa logical CPUs:

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

Dla Linux schedulera są to dwa osobne miejsca wykonania.

Możemy więc mieć:

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

jednocześnie.

Ale oba taski nadal działają na tym samym fizycznym rdzeniu.

To oznacza, że współdzielą część zasobów hardware:

text
execution units
load/store resources
branch prediction
cache
frontend

Dlatego:

text
2 logical CPUs
!=
2 pełne physical cores

SMT contention

Załóżmy dwa identyczne workloady.

Przypadek A:

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

Przypadek B:

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

W obu przypadkach scheduler widzi dwa logical CPUs.

Ale performance może być zupełnie inny.

Jeżeli oba workloady intensywnie wykorzystują te same execution resources, na siblingach SMT zaczną sobie przeszkadzać.

Ważne jest to, że oba nadal mogą być:

text
running

Nie czekają w runqueue.

Nie są throttlowane.

CPU usage wygląda poprawnie.

A mimo to throughput spada.

To inny rodzaj contention niż klasyczne:

text
więcej runnable tasks niż CPU

Tutaj problemem nie jest brak czasu schedulerowego.

Problemem jest współdzielenie fizycznych zasobów rdzenia.


CPU time nie jest jednostką wykonanej pracy

Załóżmy task wykonujący się przez:

text
100 ms CPU time

Przy pustym SMT siblingu może w tym czasie wykonać:

text
1 000 000 operacji

Przy mocno obciążonym siblingu:

text
700 000 operacji

W obu przypadkach accounting CPU pokaże:

text
100 ms

Ale realny performance jest inny.

To bardzo ważny wniosek:

text
CPU time
!=
stała ilość pracy

I dlatego millicore nie jest jednostką performance.


Cache locality

CPU nie czyta każdej wartości bezpośrednio z RAM.

Pomiędzy procesorem a pamięcią znajduje się hierarchia cache:

text
L1
L2
L3 / LLC
RAM

Im bliżej dane znajdują się CPU, tym tańszy jest dostęp.

Jeżeli task działa na jednym CPU i wielokrotnie używa tych samych danych, część z nich może znajdować się w cache.

Teraz scheduler migruje task:

text
CPU 2 -> CPU 11

Nowy CPU może nie mieć tych samych danych w lokalnych cache.

Task zaczyna więc odbudowywać locality.

W efekcie:

text
scheduler migration
        |
        v
cache misses
        |
        v
więcej dostępu do niższych poziomów cache / RAM
        |
        v
większe latency

Sama migracja nie musi być droga.

Koszt pojawia się przez utratę lokalności danych.


Dwa workloady mogą konkurować o cache

Załóżmy dwa procesy korzystające z tego samego shared LLC.

Każdy ma working set:

text
20 MB

A dostępny shared cache ma:

text
24 MB

Osobno każdy workload może działać bardzo dobrze.

Razem:

text
20 MB + 20 MB > 24 MB

zaczynają wzajemnie wypierać swoje dane.

Rośnie liczba:

text
cache misses

a więcej requestów trafia do RAM.

CPU nadal może wyglądać na całkowicie zajęte.

Oba procesy są running.

Nie ma throttlingu.

A performance spada.

To kolejny przykład sytuacji:

text
CPU capacity wygląda dobrze

ale:

text
hardware locality jest zła

NUMA

Na większych serwerach RAM również nie jest jednolity.

Typowa topologia może wyglądać:

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

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

CPU należące do node 0 mają szybszy dostęp do pamięci node 0.

Jeżeli CPU z node 0 odwołuje się do pamięci node 1, mamy:

text
remote memory access

Dane muszą przejść przez interconnect pomiędzy NUMA nodes.

To zwykle oznacza:

text
większe latency
mniejszy bandwidth
większy koszt komunikacji

CPU może być dobrze przypięte, a performance nadal zły

Załóżmy workload wykonujący się wyłącznie na:

text
CPU 2-5

wszystkie należą do:

text
NUMA node 0

To wygląda sensownie.

Ale pamięć aplikacji została zaalokowana głównie na:

text
NUMA node 1

Mamy więc:

text
CPU -> node 0
RAM -> node 1

Każdy intensywny dostęp do pamięci jest remote.

CPU placement jest poprawny.

Memory placement nie.

Dlatego dla workloadów memory-intensive samo przypięcie CPU nie gwarantuje dobrego performance.


First touch

Linux często wykorzystuje zasadę zwaną:

text
first touch

Fizyczna strona pamięci jest zwykle umieszczana blisko CPU, który faktycznie pierwszy zacznie z niej korzystać.

Załóżmy:

text
thread init -> NUMA node 0

inicjalizuje dużą strukturę:

text
50 GB

Później worker threads wykonują się na:

text
NUMA node 1

Możemy skończyć z:

text
workers -> node 1
memory  -> node 0

Aplikacja nie musi wiedzieć, że właśnie stworzyła sobie ogromną ilość remote memory traffic.


Jak te trzy rzeczy się łączą

Wyobraźmy sobie workload:

text
4 CPU

Na papierze wygląda to prosto.

Ale możliwe są dwa placementy.

Dobry

text
NUMA node 0

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

Memory -> node 0

Mamy:

text
brak SMT sibling contention
dobra cache locality
local memory

Gorszy

text
NUMA node 0

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

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

Memory -> NUMA node 1

Formalnie workload nadal może mieć:

text
4 logical CPUs

ale realna charakterystyka wykonania jest dużo gorsza.


Dlaczego Kubernetes nie może całkowicie ukryć hardware

Kubernetes operuje abstrakcją zasobów:

text
CPU
memory

To wystarcza dla większości workloadów.

Ale scheduler Kubernetes nie interpretuje każdego:

text
1000m

jako gwarancji identycznej mocy obliczeniowej.

Na poziomie hardware:

text
CPU A != CPU B

jeżeli różnią się:

text
SMT sibling load
cache locality
NUMA locality

To nie oznacza, że model Kubernetes jest zły.

Po prostu jest abstrakcją.

A każda abstrakcja ma granicę.


Kiedy topologia zaczyna mieć realne znaczenie

Nie każdy workload powinien przejmować się NUMA i SMT.

Dla typowego lekkiego serwisu webowego różnice mogą być niewielkie.

Znaczenie rośnie przy workloadach:

text
CPU-bound
memory-bound
low-latency
duże JVM
in-memory databases
packet processing
HPC
ML inference

Im bardziej performance zależy od pojedynczych mikrosekund, cache missów i memory bandwidth, tym ważniejsze staje się fizyczne rozmieszczenie zasobów.


Model mentalny

Do tej pory mogliśmy myśleć:

text
workload
   |
   v
CPU time

Teraz trzeba rozszerzyć model:

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

Każdy z tych poziomów może wpływać na realny performance.

Najważniejsze:

text
1 CPU

oznacza ilość capacity w modelu schedulera.

Nie oznacza:

text
stałej liczby instrukcji na sekundę

Bo realna wydajność zależy również od tego:

text
z kim współdzielisz physical core,
jak wygląda cache locality,
i jak daleko znajduje się pamięć.

To moment, w którym kończy się czysta abstrakcja CPU time, a zaczyna fizyka maszyny.