Ваш сервіс «вузький за CPU». Дашборди так кажуть. CPU на 80–90%, затримки погані, і перша реакція команди — додати ядра.
Ви додаєте ядра, і нічого не змінюється. Або стає гірше. Вітаю: ви щойно зустріли справжнього боса — пам’ять.
Кеші процесора (L1/L2/L3) існують тому, що сучасні ядра можуть виконувати арифметику швидше, ніж система може підкидати їм дані.
Більшість виробничих проблем продуктивності — це не «CPU повільний». Це «CPU чекає». Цей текст пояснює кеші без балаканини, а потім показує,
як довести, що відбувається, на реальному Linux-хості командами, які можна виконати сьогодні.
Чому перемагає пам’ять (і чому CPU більшість часу чекає)
CPU — абсурдні. Сучасне ядро може виконувати кілька інструкцій за такт, робити спекулятивне виконання, перестановки, векторизацію й загалом поводитися як
переповнений кофеїном бухгалтер, що робить податкові звіти о 4 ранку. Тим часом DRAM відносно повільна. Ядро може завершувати інструкції за субнаносекунди;
звернення до DRAM може зайняти десятки або сотні наносекунд залежно від топології, навантаження й того, чи ви випадково зайшли в віддалий NUMA.
Практичний висновок: ваш CPU витрачає багато часу, просто чекаючи на завантаження з пам’яті. Не на диск. Не на мережу. Навіть не на «повільний код» у звичному сенсі.
Він чекає на наступну кеш-лінію.
Кеші — це спроба тримати CPU зайнятим, тримаючи часто використовувані дані поруч. Вони не «приємна надбудова». Вони — єдина причина, чому універсальні
обчислення працюють на нинішніх тактових частотах. Якби кожне звернення йшло в DRAM, ваші ядра провели б більшість тактів у стані екзистенційного жаху.
Ось ментальна модель, яка витримує роботу у виробництві: продуктивність визначається тим, як часто ви промахуєтесь по кешу та
наскільки дорогі ці промахи. Найдорожчі промахи — ті, що втікають за межі корпусу й йдуть у DRAM, а найпекельніші — віддалий NUMA DRAM,
доступ до якого йде через інтерконектор, поки інші ядра борються за пропускну здатність.
Одне правило великого пальця: коли шлях запиту зачіпає «багато речей», витрати — не арифметика; це слідування за покажчиками і кеш-промахи.
А якщо ви робите це паралельно в багатьох потоках, ви можете перетворити підсистему пам’яті на вузьке місце, а графіки CPU обдурять вас.
L1/L2/L3 простими словами
Уявіть рівні кешу як поступово більші й поступово повільніші «комори» між ядром і DRAM.
Назви історичні й прості: L1 найближчий до ядра, L2 далі, L3 зазвичай спільний для ядер сокета (не завжди), а потім DRAM.
Для чого кожен рівень
- L1 cache: крихітний і надзвичайно швидкий. Часто розділений на L1i (інструкції) і L1d (дані). Це перше місце, куди дивиться ядро.
- L2 cache: більше, трохи повільніше, зазвичай приватний для ядра. Ловить те, що випало з L1.
- L3 cache: набагато більший, повільніший, часто спільний між ядрами. Зменшує звернення до DRAM і діє як амортизатор при контенції.
Що означають «хіт» і «міс» в операційній практиці
Хіт кешу означає, що потрібні дані вже поруч; завантаження обробляється швидко й конвеєр рухається далі.
Міс кешу означає, що CPU мусить взяти дані з нижчого рівня. Якщо міс доходить до DRAM, ядро може сильно зупинитись.
Промахи виникають тому, що кеші обмежені, і тому, що реальні навантаження мають заплутані шаблони доступу. CPU намагається передбачати й підвантажувати дані,
але не може передбачити все — особливо код з покажчиками, випадковий доступ або структури даних більші за кеш.
Чому не можна «просто використати L3»
Люди інколи говорять, ніби L3 — це магічний спільний пул, який вмістить ваш робочий набір. Це не так. L3 — спільний, зазнає контенції і часто
інклюзивний або частково інклюзивний залежно від архітектури. Також пропускна здатність і затримка L3 кращі за DRAM, але це не безкоштовно.
Якщо робочий набір більше за L3 — ви йдете в DRAM. Якщо він більший за DRAM… це називається «своп», і це крик про допомогу.
Кеш-лінії, локальність і правило «доторкнувся — купив»
CPU не підвантажують по одному байту в кеш. Вони підвантажують кеш-лінії, зазвичай 64 байти на x86_64. Коли ви завантажуєте одне значення,
ви часто тягнете й сусідні значення. Це добре, якщо ваш код використовує сусідню пам’ять (просторова локальність). Це погано, якщо вам потрібне лише одне поле,
а решта — сміття, бо ви засмітили кеш непотрібними даними.
Локальність — це вся гра:
- Темпоральна локальність: якщо ви використаєте це знову скоро, кеш допомагає.
- Просторова локальність: якщо ви використовуєте сусідню пам’ять, кеш допомагає.
Бази даних, кеші та маршрутизатори запитів часто живуть або вмирають залежно від того, наскільки передбачувані їхні шаблони доступу. Послідовні скани можуть
бути швидкими, бо апаратні префетчери встигають за потоком. Випадкове слідування по покажчиках через великий хеш-табл може бути повільним,
бо кожен крок — це «сюрприз, йди в пам’ять».
Сухий операційний переклад: якщо ви бачите високий CPU, але також багато прострілених циклів (stalled cycles), у вас не «проблема обчислення».
У вас проблема з підгодовуванням ядра. Ваш найгарячіший шлях коду, ймовірно, домінований кеш-промахами або неправильною передбачуваністю розгалужень, а не математикою.
Жарт №1: кеш-промахи — це як «швидкі питання» в корпоративному чаті — кожне здається дрібницею, поки не зрозумієш, що весь день ти чекаєш на них.
Префетчинг: спроба CPU бути корисним
CPU намагаються виявляти шаблони й підвантажувати майбутні кеш-лінії. Це добре працює для стрімінгу та стрибкоподібного доступу. Працює погано для слідування по покажчиках,
бо адреса наступного завантаження залежить від результату попереднього.
Ось чому «я оптимізував цикл» іноді нічого не дає. Цикл не проблема; проблема — ланцюжок залежностей пам’яті.
Частина, яку ніхто не хоче відлажувати: когерентність і false sharing
У багатоядерних системах кожне ядро має власні кеші. Коли одне ядро записує в кеш-лінію, копії в інших ядрах повинні бути інвалідовані або оновлені, щоб усі бачили послідовний стан.
Це — когерентність кешу. Вона необхідна. Вона також пастка для продуктивності.
False sharing: коли потоки сваряться через кеш-лінію, яку вони насправді не «ділять»
False sharing — це коли два потоки оновлюють різні змінні, що випадково живуть у тій самій кеш-лінії. Логічно вони не ділять дані, але протокол когерентності
обробляє всю лінію як одиницю. Отже кожний запис викликає інвалідизації й передання права власності, і продуктивність падає вниз по схилу.
Симптоми: виглядає, ніби «більше потоків зробило повільніше» з великою кількістю часу CPU, але без реального прогресу. Ви побачите багато трафіку cache-to-cache
і промахів когерентності, якщо подивитесь правильними інструментами.
Жарт №2: false sharing — це коли дві команди «володіють» тією ж клітинкою таблиці; змінення коректні, але процес — ні.
Записо-важкі навантаження платять додатково
Читання можна шарити. Записи вимагають ексклюзивного права на лінію, що спричиняє дії когерентності. Якщо у вас гарячий лічильник, який оновлюють багато потоків,
цей лічильник стане послідовним вузьким місцем, навіть якщо «ядер багато».
Ось чому існують пер-поточні лічильники, шардові блокування і батчинг. Ви не витончений. Ви уникаєте рахунку за фізику.
NUMA: податок затримки при масштабуванні
На багатьох серверах пам’ять фізично приєднана до CPU-сокетів. Доступ до «локальної» пам’яті швидший, ніж до пам’яті, приєднаної до іншого сокета.
Це — NUMA (Non-Uniform Memory Access). Це не рідкісний випадок. Це за замовчуванням на багатьох реальних залізячних системах.
Ви можете ігнорувати NUMA, поки не зможете. Режим відмови проявляється, коли:
- ви масштабували потоки між сокетами,
- ваш алокатор розподіляє сторінки по вузлах,
- або планувальник мігрує потоки подалі від їхньої пам’яті.
Тоді затримки зростають, пропускна здатність перестає рости, і CPU виглядає «завантаженим», бо він чекає. Ви можете легко витратити тижні на тонке налаштування коду,
коли виправлення — закріпити процеси, змінити політику алокації або обрати менше сокетів з вищими тактовими частотами для чутливих до затримки навантажень.
Цікаві факти й історія, які можна повторити на нарадах
- «Memory wall» став загальною проблемою в 1990-х: швидкість CPU росла швидше за затримку DRAM, тому кеші стали обов’язковими.
- Кеш-лінії — це архітектурний вибір: 64 байти звично на x86, але інші архітектури використовували різні розміри; це баланс між пропускною здатністю й засміченням.
- L1 часто розділений на кеш інструкцій і даних, бо їх змішування призводить до конфліктів; доступи до коду і даних мають різні шаблони.
- Спільне використання L3 корисне: воно допомагає при читанні даних багатьма потоками і зменшує звернення до DRAM, але створює контенцію під навантаженням.
- Існують апаратні префетчери, бо послідовний доступ — поширений; вони можуть суттєво пришвидшити стрімінгове читання без змін коду.
- Протоколи когерентності (як варіанти MESI) — велика причина того, що мульти-ядерність «просто працює», але вони також накладають реальні витрати при контенції записів.
- TLB — це теж кеш: Translation Lookaside Buffer кешує трансляції адрес; промахи TLB можуть вдарити як промахи кешу.
- Великі сторінки зменшують навантаження на TLB, відображаючи більше пам’яті на запис; вони можуть допомогти деяким навантаженням і нашкодити іншим.
- Ранні сюрпризи масштабування у 2000-х навчали команди, що «більше потоків» — не план продуктивності, якщо пам’ять і блокування не опрацьовані.
Швидкий план діагностики
Коли система повільна, ви хочете швидко знайти обмежувальний ресурс, а не писати поему про мікроархітектуру. Це польовий чекліст.
Перше: підтвердьте, чи ви обмежені обчисленнями, чи чекаєте
- Перевірте завантаження CPU та метрики рівня виконання: черга на виконання, перемикання контекстів, навантаження IRQ.
- Подивіться на stalled cycles / промахи кешу за допомогою
perf, якщо можете. - Якщо інструкцій за такт мало, а промахів кешу багато — швидше за все це затримка пам’яті або обмеження пропускної здатності пам’яті.
Друге: вирішіть, чи це затримкова або пропускна проблема
- Затримкова: слідування по покажчиках, випадковий доступ, багато LLC-промахів, низька пропускна здатність пам’яті.
- Пропускна: стрімінг, великі скани, багато ядер читають/пишуть, висока пропускна здатність пам’яті близько до меж платформи.
Третє: перевірте NUMA і топологію
- Чи потоки працюють на одному сокеті, а виділення пам’яті йдуть на інший?
- Чи ви трете LLC між сокетами?
- Чи додаток чутливий до хвостових затримок (зазвичай так), роблячи віддалу пам’ять тихим вбивцею?
Четверте: перевірте «очевидне, але нудне»
- Чи відбувається свопінг або тиск на пам’ять (reclaim storms)?
- Чи досягаєте ви ліміти пам’яті cgroup?
- Чи насичено одне блокування або лічильник (false sharing, контендований mutex)?
Перефразована ідея (приписують): повідомлення Gene Kim про операції — швидкі цикли зворотного зв’язку кращі за героїчні вчинки — спочатку вимірюйте, потім змінюйте по одному пункту.
Практичні завдання: команди, виводи й рішення
Це призначено для виконання на Linux-хості, де ви діагностуєте продуктивність. Деякі команди потребують root або прав для perf.
Ідея — не запам’ятовувати команди, а пов’язати виводи з рішеннями.
Завдання 1: Визначити розміри кешів і топологію
cr0x@server:~$ lscpu
Architecture: x86_64
CPU(s): 64
Thread(s) per core: 2
Core(s) per socket: 16
Socket(s): 2
L1d cache: 32K
L1i cache: 32K
L2 cache: 1M
L3 cache: 35.8M
NUMA node(s): 2
NUMA node0 CPU(s): 0-31
NUMA node1 CPU(s): 32-63
Що це означає: у вас два сокети, два NUMA-вузли й L3 на сокет (часто). Ваш робочий набір, що вилітає з ~36MB на сокет,
починає платити DRAM-ціни.
Рішення: якщо сервіс чутливий до затримки, плануйте NUMA-усвідомленість (pinning, політика пам’яті) і тримайте гарячі структури даних маленькими.
Завдання 2: Перевірити розмір кеш-лінії (і припинити гадати)
cr0x@server:~$ getconf LEVEL1_DCACHE_LINESIZE
64
Що це означає: межі ризику false sharing — 64 байти.
Рішення: у низькорівневому коді вирівнюйте гарячі пер-поточні лічильники/структури на межі 64B, щоб уникнути пінг-понгу кеш-ліній.
Завдання 3: Підтвердити NUMA-відстані
cr0x@server:~$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
node 0 size: 256000 MB
node 0 free: 120000 MB
node 1 cpus: 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
node 1 size: 256000 MB
node 1 free: 118000 MB
node distances:
node 0 1
0: 10 21
1: 21 10
Що це означає: віддалена пам’ять ≈ 2× за «відстанню». Не буквально 2× затримка, але орієнтовно важливо.
Рішення: якщо ви чутливі до хвостових затримок, тримайте потоки й їхню пам’ять локальними (або зменшіть крос-сокетний трафік, обмеживши CPU affinity).
Завдання 4: Перевірити, чи ядро не «бореться» з вами автоматичним NUMA balancing
cr0x@server:~$ cat /proc/sys/kernel/numa_balancing
1
Що це означає: ядро може мігрувати сторінки, щоб «йти за» потоками. Іноді чудово, іноді — шумно.
Рішення: для стабільних, зафіксованих робочих навантажень можна вимкнути це (обережно, після тестів) або керувати розміщенням явно.
Завдання 5: Подивитися розміщення пам’яті процесу по NUMA
cr0x@server:~$ pidof myservice
24718
cr0x@server:~$ numastat -p 24718
Per-node process memory usage (in MBs) for PID 24718 (myservice)
Node 0 38000.25
Node 1 2100.10
Total 40100.35
Що це означає: процес переважно використовує пам’ять вузла 0. Якщо його потоки працюють на вузлі 1, ви платитимете віддалу ціну.
Рішення: вирівняйте CPU affinity і політику алокації пам’яті; якщо співвідношення випадкове, виправте планувальник або розміщення при запуску.
Завдання 6: Перевірити тиск пам’яті і свопінг (прірва продуктивності)
cr0x@server:~$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 0 0 1200000 80000 9000000 0 0 2 15 900 3200 45 7 48 0 0
5 0 0 1180000 80000 8900000 0 0 0 0 1100 4100 55 8 37 0 0
7 0 0 1170000 80000 8850000 0 0 0 0 1300 5200 61 9 30 0 0
Що це означає: немає своп-ін/аут (si/so = 0), отже ви не в категорії «все жахливо». CPU завантажений, але не чекає на IO.
Рішення: переходьте до аналізу кешу/пам’яті; не витрачайте час на звинувачення диска.
Завдання 7: Подивитися, чи ви обмежені пропускною здатністю (швидке зчитування пропускної здатності пам’яті)
cr0x@server:~$ sudo perf stat -a -e cycles,instructions,cache-references,cache-misses,LLC-loads,LLC-load-misses -I 1000 -- sleep 5
# time(ms) cycles instructions cache-references cache-misses LLC-loads LLC-load-misses
1000 5,210,000,000 2,340,000,000 120,000,000 9,800,000 22,000,000 6,700,000
2000 5,300,000,000 2,310,000,000 118,000,000 10,200,000 21,500,000 6,900,000
3000 5,280,000,000 2,290,000,000 121,000,000 10,500,000 22,300,000 7,100,000
Що це означає: instructions/cycle низький (приблизно 0.43 тут), а промахи кешу/LLC значні. CPU багато чекає.
Рішення: трактуйте це як домінування затримки пам’яті, якщо лічильники пропускної здатності не показують насичення; шукайте випадковий доступ, слідування по покажчиках або NUMA.
Завдання 8: Визначити топ-функції і чи вони затримують (профілювання з perf)
cr0x@server:~$ sudo perf top -p 24718
Samples: 2K of event 'cycles', Event count (approx.): 2500000000
18.50% myservice myservice [.] hashmap_lookup
12.20% myservice myservice [.] parse_request
8.90% libc.so.6 libc.so.6 [.] memcmp
7.40% myservice myservice [.] cache_get
5.10% myservice myservice [.] serialize_response
Що це означає: гарячі точки — це пошук/порівняння — класичні кандидати на кеш-промахи і невдалі передбачення гілок.
Рішення: перевірте структури даних: чи ключі розсипані? чи ви слідуєте по покажчиках? чи можна упакувати дані? чи можна зменшити кількість порівнянь?
Завдання 9: Перевірити міграцію планувальника (тихий каталізатор NUMA)
cr0x@server:~$ pidstat -w -p 24718 1 3
Linux 6.5.0 (server) 01/09/2026 _x86_64_ (64 CPU)
01:02:11 UID PID cswch/s nvcswch/s Command
01:02:12 1001 24718 1200.00 850.00 myservice
01:02:13 1001 24718 1350.00 920.00 myservice
01:02:14 1001 24718 1100.00 800.00 myservice
Що це означає: висока кількість контекстних перемикань може вказувати на контенцію локів або надто багато runnable-потоків.
Рішення: якщо затримки спайкові, зменшіть кількість потоків, дослідіть блокування або закріпіть критичні потоки, щоб зменшити міграцію.
Завдання 10: Перевірити чергу запуску і насичення по CPU (не плутайте «завантажений» з «прогресує»)
cr0x@server:~$ mpstat -P ALL 1 2
Linux 6.5.0 (server) 01/09/2026 _x86_64_ (64 CPU)
01:03:01 AM CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
01:03:02 AM all 62.0 0.0 9.0 0.1 0.0 0.5 0.0 0.0 0.0 28.4
01:03:02 AM 0 95.0 0.0 4.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0
01:03:02 AM 32 20.0 0.0 5.0 0.0 0.0 0.0 0.0 0.0 0.0 75.0
Що це означає: CPU0 завантажений, тоді як CPU32 переважно простає. Це може бути проблема афінності, один «гарячий» шард або вузьке горлечко блокування.
Рішення: якщо одне ядро гаряче, масштабування не відбудеться, поки ви не приберете футляр. Дослідіть розподіл роботи по ядрах і замки.
Завдання 11: Перевірити CPU affinity і обмеження cgroup
cr0x@server:~$ taskset -pc 24718
pid 24718's current affinity list: 0-15
Що це означає: процес зафіксовано на CPU 0–15 (підмножина одного сокета). Це може бути навмисно або випадково.
Рішення: якщо зафіксовано, переконайтесь, що пам’ять локальна для цього вузла; якщо випадково, виправте unit-файл або CPU set в оркестраторі.
Завдання 12: Перевірити рівень промахів LLC на процес (perf stat на PID)
cr0x@server:~$ sudo perf stat -p 24718 -e cycles,instructions,LLC-loads,LLC-load-misses -- sleep 10
Performance counter stats for process id '24718':
18,320,000,000 cycles
7,410,000,000 instructions # 0.40 insn per cycle
210,000,000 LLC-loads
78,000,000 LLC-load-misses # 37.14% of all LLC hits
10.001948393 seconds time elapsed
Що це означає: ≈37% LLC load miss — великий червоний прапорець, що ваш робочий набір не вміщується в кеш або доступ випадковий.
Рішення: зменшіть робочий набір, підвищіть локальність або змініть розкладку даних. Також перевірте NUMA-локальність.
Завдання 13: Знайти page faults і major faults (підказки про TLB і paging)
cr0x@server:~$ pidstat -r -p 24718 1 3
Linux 6.5.0 (server) 01/09/2026 _x86_64_ (64 CPU)
01:04:10 UID PID minflt/s majflt/s VSZ RSS %MEM Command
01:04:11 1001 24718 8200.00 0.00 9800000 4200000 12.8 myservice
01:04:12 1001 24718 7900.00 0.00 9800000 4200000 12.8 myservice
01:04:13 1001 24718 8100.00 0.00 9800000 4200000 12.8 myservice
Що це означає: високі minor faults можуть бути нормою (demand paging, mapped files), але якщо fault-и зростають під навантаженням,
це може корелювати зі шматуванням сторінок і тиском на TLB.
Рішення: якщо fault-и корелюють зі спайками затримки, перевірте поведінку алокатора, використання mmap і розгляньте великі сторінки тільки після вимірів.
Завдання 14: Перевірити статус transparent huge pages (THP)
cr0x@server:~$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
Що це означає: THP завжди увімкнено. Деякі бази даних люблять це, деякі сервіси, чутливі до затримки, ненавидять через поведінку алокації/компакції.
Рішення: якщо ви бачите періодичні паузи, протестуйте madvise або never у стейджингу і порівняйте хвостову затримку.
Завдання 15: Перевірити лічильники пропускної здатності пам’яті (інструменти Intel/AMD відрізняються)
cr0x@server:~$ sudo perf stat -a -e uncore_imc_0/cas_count_read/,uncore_imc_0/cas_count_write/ -- sleep 5
Performance counter stats for 'system wide':
8,120,000,000 uncore_imc_0/cas_count_read/
4,010,000,000 uncore_imc_0/cas_count_write/
5.001234567 seconds time elapsed
Що це означає: ці лічильники апроксимують транзакції DRAM; якщо вони високі і близькі до меж платформи, ви обмежені пропускною здатністю.
Рішення: якщо ви обмежені пропускною здатністю, додавання ядер не допоможе. Зменшіть обсяг даних у скануванні, стисніть, покращіть локальність або перемістіть роботу ближче до даних.
Завдання 16: Виявити контенцію блокувань (часто неправильно діагностують як «кеш-проблеми»)
cr0x@server:~$ sudo perf lock report -p 24718
Name acquired contended total wait (ns) avg wait (ns)
pthread_mutex_lock 12000 3400 9800000000 2882352
Що це означає: потоки витрачають реальний час у очікуванні блокувань. Це може посилювати ефекти кешу (лінії кешу «стрибають» з правом власності).
Рішення: зменшіть гранулярність блокувань, шардируйте або змініть алгоритм. Не «оптимізуйте пам’ять», якщо вузьке місце — mutex.
Завдання 17: Слідкувати за зайнятістю LLC і затримками пам’яті (якщо підтримується)
cr0x@server:~$ sudo perf stat -p 24718 -e cpu/mem-loads/,cpu/mem-stores/ -- sleep 5
Performance counter stats for process id '24718':
320,000,000 cpu/mem-loads/
95,000,000 cpu/mem-stores/
5.000912345 seconds time elapsed
Що це означає: інтенсивний трафік load/store вказує, що робота орієнтована на пам’ять. Поєднайте з метриками LLC misses, щоб вирішити,
чи це кеш-дружній робочий процес.
Рішення: якщо багато load-ів і високі промахи, зосередьтесь на локальності структур даних і зменшенні слідування по покажчиках.
Завдання 18: Підтвердити, що ви випадково не тротлите (частота має значення)
cr0x@server:~$ cat /proc/cpuinfo | grep -m1 "cpu MHz"
cpu MHz : 1796.234
Що це означає: частота CPU відносно низька (можливо через енергозбереження або теплові обмеження).
Рішення: якщо продуктивність погіршилася після зміни платформи, перевірте налаштування governor і теплові обмеження перед тим, як звинувачувати кеші.
Три корпоративні міні-історії з передової
Міні-історія 1: Інцидент через хибне припущення
Платіжний сервіс почав таймаутити щодня приблизно в один і той же час. Команда назвала це «насичення CPU», бо дашборди показували CPU на 90%,
і flame graph підсвічував JSON-парсинг та деякий хешинг. Вони зробили те, що роблять команди: додали інстанси, збільшили пул потоків і підняли пороги автоскейлінгу.
Інцидент став гіршим. Хвостові затримки виросли.
Хибне припущення було тонким: «високий CPU означає, що ядро зайняте обчисленнями». Насправді ядра були зайняті очікуванням. perf stat показав низький IPC
і високий LLC miss rate. Шлях запиту мав кешований lookup, який непомітно розріс: більше ключів, більше метаданих, більше об’єктів з покажчиками,
і робочий набір більше не вміщувався навіть близько до L3.
Зміна масштабування вивела систему на новий режим відмови. Більше потоків означало більше випадкових доступів паралельно, що збільшило memory-level parallelism,
але також — контенцію. Контролер пам’яті «нагрівся», пропускна здатність зросла, і середня затримка піднялася разом з нею. Класика: чим більше тиснеш,
тим сильніше пам’ять відповідає опором.
Виправлення не було героїчним. Вони зменшили накладні витрати об’єктів, упакували поля в суміжні масиви для гарячого шляху і обмежили набір enrichment на запит.
Вони також перестали пінити процес через обидва сокети без контролю розміщення пам’яті. Коли локальність покращилася, завантаження CPU залишилося високим,
але пропускна здатність піднялася, а хвостові затримки впали. Графіки CPU виглядали так само. Система поводилася інакше. Ось урок.
Міні-історія 2: Оптимізація, що відбилася бумерангом
Команда намагалася пришвидшити аналітичне API, «покращивши кеш». Вони замінили простий вектор структур на hash map з ключем-рядком, щоб уникнути лінійних сканів.
Мікробенчмарки на ноутбуці виглядали чудово. Продакшн не погодився.
Нова структура зруйнувала локальність. Старий код сканував суміжний масив: передбачувано, префетч-дружньо, кеш-дружньо. Новий код робив випадкові пошуки,
кожен з яких включав слідування по покажчиках, хешування рядка і кілька залежних завантажень. На реальних серверах під навантаженням це перетворило
цикл дружній до L2/L3 на DRAM-вечірку.
Гірше — хеш-таблиця внесла шлях зміни розміру зі спільним доступом. При спалахах трафіку відбувалися resize-операції, блокування конкурували, і кеш-лінії
«стрибають» між ядрами. Команда бачила вищий CPU і вирішила, що «потрібно більше CPU». Але «більше CPU» збільшило контенцію, і p99 стало гірше.
Вони відкочували зміни і реалізували нудне компромісне рішення: зберегли відсортований вектор для гарячого шляху і робили періодичні перебудови поза потоком запиту,
з стабільним вказівником на снапшот. Вони прийняли O(log n) з хорошою локальністю замість O(1) з жахливими константами. Продакшн знову став нудним, а це — успіх.
Міні-історія 3: Нудна, але правильна практика, що врятувала ситуацію
Сервіс поруч зі сховищем — багато читань метаданих, іноді записи — був перенесений на нову апаратну платформу. Усі очікували покращення. Його не було.
Були спорадичні спайки затримки і періодичні падіння пропускної здатності, але нічого очевидного: немає свопінгу, диски в порядку, мережа нормальна.
У команди була одна звичка, яка врятувала їх: «пакет триажу продуктивності», який вони запускали для будь-якої регресії. Він включав lscpu,
топологію NUMA, perf stat для IPC і LLC misses та швидку перевірку частоти CPU і governor-ів. Нудно. Надійно.
Пакет одразу показав дві несподіванки. По-перше, нові хости мали більше сокетів, і сервіс планувався через сокети без узгодженого розміщення пам’яті.
По-друге, частота CPU була нижчою під тривалим навантаженням через енергетичні налаштування в образі.
Виправлення були процедурними: вони оновили базове налаштування хоста (governor, налаштування прошивки там, де доречно) і закріпили сервіс на одному NUMA-вузлі
з пам’яттю, прив’язаною до нього. Жодних змін у коді. Затримки стабілізувалися. Ролл-аут завершився. Постмортем був короткий — і це розкіш.
Поширені помилки (симптоми → причина → виправлення)
1) «CPU високий, отже потрібен ще CPU»
Симптоми: CPU 80–95%, пропускна здатність не зростає, p95/p99 гіршає при додаванні потоків/інстансів.
Причина: низький IPC через кеш-промахи або затримки пам’яті; CPU «зайнятий очікуванням».
Виправлення: виміряйте IPC і LLC misses за допомогою perf stat; зменшіть робочий набір, покращіть локальність або вирішіть NUMA. Не масштабируйте потоки сліпо.
2) «Hash map завжди швидше за скан»
Симптоми: стало повільніше після переходу на «O(1)» структуру; perf показує гарячі точки в hashing/strcmp/memcmp.
Причина: випадковий доступ і слідування по покажчиках викликають звернення в DRAM; погана локальність перемагає великі-O на реальному залізі.
Виправлення: віддавайте перевагу суміжним структурам для гарячих шляхів (масиви, вектори, відсортовані вектори). Бенчмарк з продакшн-подібними даними й конкурентністю.
3) «Більше потоків = більше пропускної здатності»
Симптоми: пропускна здатність спочатку росте, потім падає; контекстні перемикання зростають; LLC misses ростуть.
Причина: насичення пропускної здатності пам’яті, контенція локів або false sharing стають домінантними.
Виправлення: обмежте кількість потоків близько до «коліна» кривої; шардируйте локи/лічильники; уникайте спільних гарячих записів; закріпіть потоки, якщо NUMA важлива.
4) «NUMA не має значення; Linux сам усе вирішить»
Симптоми: хороша середня затримка, жахлива хвостова; регресії при переході на багатосокетні хости.
Причина: віддалені звернення до пам’яті і крос-сокетний трафік; міграція планувальника руйнує локальність.
Виправлення: використовуйте numastat і numactl; закріпіть CPU і пам’ять; розгляньте запуск одного процесу на сокет для передбачуваності.
5) «Якщо вимкнути кеші, можна тестувати гірший випадок»
Симптоми: хтось пропонує вимкнути кеші або постійно їх очищати як стратегію тестування.
Причина: нерозуміння; сучасні системи не призначені для такого режиму і результати не відобразять реальність.
Виправлення: тестуйте з реалістичними робочими наборами і шаблонами доступу; використовуйте лічильники продуктивності, а не шкільні експерименти.
6) «Великі сторінки завжди допомагають»
Симптоми: THP увімкнено і періодичні паузи; активність компакції; спайки затримки під час росту пам’яті.
Причина: накладні витрати на алокацію/компакцію THP; невідповідність патернам алокації.
Виправлення: бенчмарк always vs madvise vs never; якщо використовуєте великі сторінки, виділяйте їх заздалегідь і моніторьте хвостову затримку.
Чек-листи / покроковий план
Чек-лист A: Доведіть, що це пам’ять, а не обчислення
- Зніміть топологію CPU:
lscpu. Зафіксуйте сокети/NUMA і розміри кешів. - Перевірте свопінг/тиск на пам’ять:
vmstat 1. Якщоsi/so> 0 — виправляйте пам’ять першочергово. - Виміряйте IPC і LLC misses:
perf stat(системно або по PID). Низький IPC + високі LLC misses = підозра на затримки пам’яті. - Знайдіть гарячі функції:
perf top. Якщо гарячі точки — lookup/compare/alloc, очікуйте проблеми з локальністю.
Чек-лист B: Визначте, чи затримково- чи пропускно-обмежено
- Якщо LLC miss rate високий, але лічильники пропускної здатності помірні: ймовірно, затримково-обмежене слідування по покажчиках.
- Якщо лічильники пропускної здатності близькі до меж платформи і ядра не допомагають: ймовірно, пропускно-обмежене сканування/стрім.
- Змініть одну річ і переміряйте: зменшіть конкурентність, зменшіть робочий набір або змініть шаблон доступу.
Чек-лист C: Виправте NUMA перед переписуванням коду
- Змапте NUMA-вузли:
numactl --hardware. - Перевірте пам’ять процесу по вузлах:
numastat -p PID. - Перевірте CPU affinity:
taskset -pc PID. - Вирівняйте: закріпіть CPU на один вузол і прив’яжіть пам’ять до того ж вузла (тестуйте у стейджингу).
Чек-лист D: Зробіть дані кеш-дружніми (нудне — перемагає)
- Сплющуйте структури з великою кількістю покажчиків у гарячих шляхах.
- Упаковуйте гарячі поля разом; відокремлюйте холодні поля (hot/cold split).
- Віддавайте перевагу масивам/векторам і передбачуваній ітерації замість випадкового доступу.
- Шардуйте записо-важкі лічильники; батчіть оновлення.
- Бенчмарк з продакшн-подібними розмірами; ефекти кешу з’являються, коли дані досить великі, щоб мати значення.
Питання й відповіді
1) Чи L1 завжди швидший за L2, а L2 завжди швидший за L3?
Загалом так у термінах затримки, але реальна продуктивність залежить від контенції, шаблону доступу й того, чи лінія вже присутня через префетчинг.
Також характеристики пропускної здатності відрізняються; L3 може давати високу агреговану пропускну здатність, але з більшою затримкою.
2) Чому мій CPU показує 90% використання, якщо він «чекає пам’яті»?
Тому що «використання CPU» здебільшого означає, що ядро не простає. Зупинений конвеєр все ще виконує інструкції, обробляє промахи, робить спекуляцію
і спалює цикли. Вам потрібні лічильники (IPC, промахи кешу, stalled cycles), щоб побачити очікування.
3) У чому різниця між кешем CPU і Linux page cache?
Кеші CPU — апаратні та крихітні (KB/MB). Linux page cache — керується ОС, використовує DRAM і кешує файлові дані (GBs).
Вони взаємодіють, але вирішують різні задачі на різних масштабах.
4) Чи можу я «збільшити L3 кеш» за допомогою софту?
Не буквально. Ви можете поводитися так, ніби у вас більше кешу, зменшивши гарячий робочий набір, покращивши локальність і уникаючи засмічення кешу.
5) Чому зв’язані списки і дерева з покажчиками працюють погано?
Вони руйнують просторову локальність. Кожен покажчик веде до іншої кеш-лінії, часто далеко. Це означає залежні завантаження і часті звернення до DRAM,
що зупиняє ядро.
6) Коли мені турбуватися про false sharing?
Коли у вас кілька потоків оновлюють різні поля/лічильники в щільних циклах і продуктивність погіршується з додаванням потоків.
Це часто буває в лічильниках метрик, кільцевих буферах і наївних масивах стану на підключення.
7) Чи завжди кеш-промахи — це погано?
Деякі промахи неминучі. Питання в тому, чи ваша робота структурована так, що промахи амортизуються (стрімінг), чи вони катастрофічні (випадкові залежні завантаження).
Ви оптимізуєте, щоб зменшити промахи на гарячому шляху, а не домагатися міфічних «нуль промахів».
8) Чи допоможуть швидші CPU вирішити проблеми з пам’яттю?
Іноді вони роблять їх гіршими. Швидші ядра можуть вимагати дані швидше і швидше вдаритися об «memory wall». Платформа з кращою пропускною здатністю пам’яті,
кращою NUMA-топологією або більшими кешами може бути важливішою за сирі GHz.
9) Чи варто закріпити все на одному сокеті?
Для сервісів, чутливих до затримки, закріплення на одному сокеті (і прив’язка пам’яті) може дати великий виграш: передбачувана локальність, менше віддалих звернень.
Для задач, орієнтованих на пропускну здатність, розподіл по сокетах може допомогти — якщо ви зберігаєте локальність і уникаєте спільних гарячих записів.
10) Яку метрику відслідковувати в дашбордах, щоб рано ловити проблеми кешу?
Якщо можете, експортуйте IPC (instructions per cycle) і LLC miss rate або stalled cycles з інструментів perf/PMU. Якщо ні, слідкуйте за патерном:
CPU піднімається, пропускна здатність не росте, затримка піднімається при масштабуванні. Цей патерн кричить «пам’ять».
Висновок: що робити наступного тижня
Кеші CPU — це не дрібниці. Це причина, чому «проста» зміна може зруйнувати p99 і чому додавання ядер часто приносить лише розчарування.
Пам’ять перемагає, бо вона задає темп: якщо ядру важко дешево отримати дані, воно не може робити корисну роботу.
Практичні наступні кроки:
- Додайте
perf stat(IPC + LLC misses) у стандартний набір інструментів інцидент-реагування для сторінок «CPU-bound». - Задокументуйте NUMA-топологію для кожного класу хостів і вирішіть, чи сервісам варто бути зафіксованими (і як) за замовчуванням.
- Аудитуйте гарячі шляхи на локальність: сплющуйте структури, розділяйте hot/cold поля і уникайте спільних гарячих записів.
- Бенчмарк з реалістичними розмірами наборів даних. Якщо ваш бенчмарк вміщується в L3 — це не бенчмарк; це демо.
- Коли пропонують оптимізацію, ставте одне питання: «Що це робить з промахами кешу і трафіком пам’яті?»