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:
requests
limits
throttling
contention
PSI
Ale na pewnym poziomie abstrakcja:
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ć:
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ć:
16 physical cores
32 logical CPUs
Każdy physical core może wystawiać dwa logical CPUs:
Physical Core 0
├── CPU 0
└── CPU 16
Dla Linux schedulera są to dwa osobne miejsca wykonania.
Możemy więc mieć:
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:
execution units
load/store resources
branch prediction
cache
frontend
Dlatego:
2 logical CPUs
!=
2 pełne physical cores
SMT contention
Załóżmy dwa identyczne workloady.
Przypadek A:
Core 0 -> workload A
Core 1 -> workload B
Przypadek B:
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ć:
running
Nie czekają w runqueue.
Nie są throttlowane.
CPU usage wygląda poprawnie.
A mimo to throughput spada.
To inny rodzaj contention niż klasyczne:
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:
100 ms CPU time
Przy pustym SMT siblingu może w tym czasie wykonać:
1 000 000 operacji
Przy mocno obciążonym siblingu:
700 000 operacji
W obu przypadkach accounting CPU pokaże:
100 ms
Ale realny performance jest inny.
To bardzo ważny wniosek:
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:
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:
CPU 2 -> CPU 11
Nowy CPU może nie mieć tych samych danych w lokalnych cache.
Task zaczyna więc odbudowywać locality.
W efekcie:
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:
20 MB
A dostępny shared cache ma:
24 MB
Osobno każdy workload może działać bardzo dobrze.
Razem:
20 MB + 20 MB > 24 MB
zaczynają wzajemnie wypierać swoje dane.
Rośnie liczba:
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:
CPU capacity wygląda dobrze
ale:
hardware locality jest zła
NUMA
Na większych serwerach RAM również nie jest jednolity.
Typowa topologia może wyglądać:
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:
remote memory access
Dane muszą przejść przez interconnect pomiędzy NUMA nodes.
To zwykle oznacza:
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:
CPU 2-5
wszystkie należą do:
NUMA node 0
To wygląda sensownie.
Ale pamięć aplikacji została zaalokowana głównie na:
NUMA node 1
Mamy więc:
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ą:
first touch
Fizyczna strona pamięci jest zwykle umieszczana blisko CPU, który faktycznie pierwszy zacznie z niej korzystać.
Załóżmy:
thread init -> NUMA node 0
inicjalizuje dużą strukturę:
50 GB
Później worker threads wykonują się na:
NUMA node 1
Możemy skończyć z:
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:
4 CPU
Na papierze wygląda to prosto.
Ale możliwe są dwa placementy.
Dobry
NUMA node 0
Core 0 -> workload
Core 1 -> workload
Core 2 -> workload
Core 3 -> workload
Memory -> node 0
Mamy:
brak SMT sibling contention
dobra cache locality
local memory
Gorszy
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ć:
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:
CPU
memory
To wystarcza dla większości workloadów.
Ale scheduler Kubernetes nie interpretuje każdego:
1000m
jako gwarancji identycznej mocy obliczeniowej.
Na poziomie hardware:
CPU A != CPU B
jeżeli różnią się:
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:
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ć:
workload
|
v
CPU time
Teraz trzeba rozszerzyć model:
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:
1 CPU
oznacza ilość capacity w modelu schedulera.
Nie oznacza:
stałej liczby instrukcji na sekundę
Bo realna wydajność zależy również od tego:
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.