Привет, Хабр!
Сервис переезжает с односокетной машины на двухсокетную: ядер вдвое больше, памяти вдвое больше, частота та же. Разработчики ждут прироста, эксплуатация готовит графики. Средняя задержка действительно чуть падает, а вот 99-й перцентиль вместо этого растёт — было 40 миллисекунд, стало 120.
Процессор не загружен, память свободна, диск простаивает, в приложении не изменилось ни строчки. Причина в том, что «памяти вдвое больше» на такой машине означает не то, что вы подумали: половина этой памяти физически подключена к другому процессору, и обращение к ней стоит заметно дороже.
Разберёмся, откуда берётся эта разница, как понять, что она бьёт именно по вам, и что с этим делать — вплоть до случаев, когда правильный ответ противоположен интуиции и локальность памяти надо не улучшать, а сознательно ломать.
Половина памяти лежит не там, где вы думаете
На односокетной машине все ядра ходят в память одинаково. Как только сокетов становится два, картина меняется: каждый процессор получает собственные каналы памяти, а до чужих добирается через межпроцессорную шину.
Топологию показывает numactl:
numactl --hardware
available: 2 nodes (0-1) node 0 cpus: 0 1 2 3 4 5 6 7 node 0 size: 32768 MB node 0 free: 28456 MB node 1 cpus: 8 9 10 11 12 13 14 15 node 1 size: 32768 MB node 1 free: 29012 MB node distances: node 0 1 0: 10 21 1: 21 10
Матрица расстояний внизу — самое интересное в этом выводе. Числа относительные, а не наносекунды, но соотношение читается прямо: обращение к своей памяти условно стоит 10, к чужой — 21. На реальных двухсокетных машинах локальный доступ занимает около 100 наносекунд, удалённый — около 170, и это даёт прибавку к задержке в 30–50 процентов на каждом промахе кеша.
К тому же штраф платит не вся нагрузка равномерно. Пока рабочий набор помещается в кеш, разница не видна вовсе. Как только приложение начинает регулярно ходить в память — обходить хеш‑таблицу, читать буферы, гулять по графу объектов — прибавка начинает копиться, и первым это видно на хвосте распределения, а не на средней.
Как понять, что штраф платите именно вы
Догадки здесь не нужны, ядро считает промахи само.
numastat
node0 node1 numa_hit 4821330012 4903221847 numa_miss 182034471 190882301 numa_foreign 190882301 182034471 other_node 373847112 388291043
Счётчик numa_hit — обращения, попавшие в свою память, numa_miss — те, что ушли на чужой узел. Отношение второго к сумме и есть доля удалённых обращений. Ориентир такой: пока она держится ниже пяти процентов, на латентности это почти не сказывается; на десяти и выше хвост распределения начинает разъезжаться заметно.
Разложить это по конкретному процессу помогает тот же инструмент с ключом:
numastat -p $(pgrep -f myapp)
Per-node process memory usage (in MBs) for PID 14822 (myapp) Node 0 Node 1 Total Huge 0.00 0.00 0.00 Heap 3814.22 198.41 4012.63 Stack 1.94 0.12 2.06 Private 412.08 38.77 450.85 ----------------- --------------- --------------- --------------- Total 4228.24 237.30 4465.54
Вот это и есть картина проблемы: 3,8 гигабайта кучи лежат на нулевом узле, а потоки приложения планировщик раскидал по всем шестнадцати ядрам. Половина из них ходит в память через шину на каждом обращении.
Ещё точнее показывает раскладка страниц по областям памяти процесса:
grep -E 'N0=|N1=' /proc/$(pgrep -f myapp)/numa_maps | head -5
7f2a40000000 default anon=262144 dirty=262144 N0=262144 kernelpagesize_kB=4 7f2a80000000 default anon=131072 dirty=131072 N0=131072 kernelpagesize_kB=4
Все страницы на нулевом узле, ни одной на первом — типичный результат политики, о которой дальше.
Померить разницу можно за пять минут
Прежде чем что‑то настраивать, полезно узнать цену удалённого доступа именно на своей машине — она заметно различается между поколениями процессоров.
Простейший замер делается тем же numactl и любой программой, упирающейся в память. Возьмём обход массива, который заведомо не влезает в кеш:
#include <stdio.h> #include <stdlib.h> #include <time.h> #define N (512UL * 1024 * 1024 / sizeof(long)) // 512 МБ int main(void) { long *a = malloc(N * sizeof(long)); for (size_t i = 0; i < N; i++) a[i] = i; // первое касание struct timespec t0, t1; clock_gettime(CLOCK_MONOTONIC, &t0); long sum = 0; for (int pass = 0; pass < 10; pass++) for (size_t i = 0; i < N; i += 8) // шаг 64 байта — по кеш-линиям sum += a[i]; clock_gettime(CLOCK_MONOTONIC, &t1); double sec = (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9; printf("%.2f s, sum=%ld\n", sec, sum); return 0; }
Дальше запускаем два раза: сначала процессор и память с одного узла, потом процессор с одного, а память с другого.
gcc -O2 numa_probe.c -o numa_probe numactl --cpunodebind=0 --membind=0 ./numa_probe # локально numactl --cpunodebind=0 --membind=1 ./numa_probe # удалённо
2.41 s, sum=... 3.58 s, sum=...
Полторы разницы на чистом обходе памяти — это верхняя оценка штрафа для вашего железа. Реальное приложение платит меньше, потому что часть обращений попадает в кеш, но соотношение по хвосту задержек будет похожим.
Если под рукой есть инструмент от Intel, матрицу задержек между всеми узлами он выдаёт сразу:
mlc --latency_matrix
Полученное соотношение стоит записать — дальше все решения про привязку и чередование сверяются именно с ним.
Почему всё оседает на одном узле
Ядро по умолчанию размещает страницу там, где её впервые тронули, а не там, где её потом читают. Политика называется «первое касание», и для большинства нагрузок она разумна: кто первым обратился, тот, скорее всего, и будет работать дальше.
Ломается она на типичном сценарии запуска сервиса. Главный поток поднимается на каком‑то ядре, аллоцирует кучу, прогревает кеши, читает конфиги и справочники — и все эти страницы оседают на узле, где он оказался. Потом стартует пул рабочих потоков, планировщик раскидывает их по всем ядрам, и половина начинает ходить за данными на чужой узел.
Приложение при этом написано правильно, ошибок в нём нет, а половина потоков платит тридцать процентов сверху на каждом обращении к памяти.
Балансировка, которая помогает не всем
Ядро умеет это чинить самостоятельно. Механизм автоматической балансировки периодически сканирует память процессов, замечает страницы, к которым ходят с чужого узла, и переносит их поближе. Заодно он может переносить и сами потоки.
sysctl kernel.numa_balancing
kernel.numa_balancing = 1
Для смешанной нагрузки, где процессы приходят и уходят, это работает хорошо. А вот на сервисе, чувствительном к задержке, механизм сам становится источником проблемы: миграция страницы стоит процессорного времени и сбрасывает кеши, и происходит она в непредсказуемый момент. На графике это выглядит как периодические всплески, не коррелирующие ни с чем.
Отсюда развилка: либо вы полагаетесь на автоматику и терпите джиттер, либо выключаете её и расставляете привязки руками.
sysctl -w kernel.numa_balancing=0
Выключать имеет смысл только вместе со вторым шагом, если снять балансировку и не привязать процессы, станет хуже, чем было.
Привязка процесса к узлу
Самый прямой способ — запускать процесс так, чтобы и его потоки, и его память жили на одном узле:
numactl --cpunodebind=0 --membind=0 ./myapp
Строгая привзка по памяти означает, что при нехватке места на узле аллокация упадёт, а не уедет на соседний. Иногда это не то, что нужно, и тогда берут мягкий вариант:
numactl --cpunodebind=0 --preferred=0 ./myapp
Для сервисов под systemd то же самое задаётся в юните, и это надёжнее, чем обёртка в скрипте запуска:
[Service] ExecStart=/usr/bin/myapp CPUAffinity=0-7 NUMAPolicy=bind NUMAMask=0
Проверить, что привязка применилась, стоит сразу после рестарта:
numastat -p $(pgrep -f myapp) | tail -3 taskset -cp $(pgrep -f myapp)
Схема хорошо работает, пока приложение помещается в один узел. Сервис, которому нужно 48 гигабайт на машине с двумя узлами по 32, привязать к одному узлу невозможно, и для него нужен другой подход.
Когда локальность надо ломать намеренно
Первое касание оптимизирует локальность, но ничего не говорит о равномерности: если рабочий набор целиком осел на одном узле, все обращения со всех ядер идут в одни и те же каналы памяти, и они становятся узким местом.
Замеры на многопоточных нагрузках показывают, что в таких случаях средняя задержка обращения к памяти при политике первого касания может быть более чем вдвое хуже, чем при равномерном размазывании по узлам. Локальность формально лучше, а работает медленнее — потому что упёрлись не в расстояние, а в пропускную способность одного узла.
Лечится это чередованием:
numactl --interleave=all ./myapp
Страницы раскладываются по узлам по очереди, часть обращений становится удалёнными сознательно, зато нагрузка на каналы памяти выравнивается. Для больших разделяемых структур, к которым ходят потоки со всех ядер, это часто оказывается лучшим вариантом из доступных.
Выбор между привязкой и чередованием решается замером, а не рассуждением, и правило примерно такое: если рабочий набор влезает в узел и потоки можно удержать рядом с ним — привязывайте. Если набор больше узла или к нему одинаково активно ходят все ядра — чередуйте.
Что умеют сами приложения
Часть работы можно не делать руками — крупные среды выполнения и базы про топологию знают и умеют под неё подстраиваться.
Виртуальная машина Java с соответствующим флагом раскладывает молодое поколение кучи по узлам и старается размещать объекты рядом с потоком, который их создал:
java -XX:+UseNUMA -XX:+UseParallelGC -jar app.jar
Флаг работает не со всеми сборщиками, и проверить, что он применился, стоит:
java -XX:+PrintFlagsFinal -XX:+UseNUMA -version | grep -i usenuma
PostgreSQL про NUMA сам ничего не решает, и для него привязку задают снаружи — либо через юнит systemd, либо через обёртку в скрипте запуска. Классическая рекомендация для однонодовой базы с большим буферным кешем — привязать к одному узлу целиком; для базы, которой не хватает одного узла по памяти, берут чередование.
Посмотреть, куда планировщик определил конкретный поток прямо сейчас, можно так:
ps -o pid,tid,psr,comm -L -p $(pgrep -f myapp) | head
PID TID PSR COMMAND 14822 14822 3 myapp 14822 14831 11 worker-1 14822 14832 2 worker-2 14822 14833 10 worker-3
Колонка PSR — номер ядра. Потоки на ядрах 2, 3 сидят на нулевом узле, на 10, 11 — на первом, а память, как мы уже видели, лежит на нулевом. Половина воркеров ходит через шину.
В контейнерах об этом никто не позаботится
Оркестратор по умолчанию про топологию не думает: под получает квоту процессора и лимит памяти, а на каких физических ядрах он окажется и откуда возьмётся память — как повезёт. Контейнер вполне может получить ядра с обоих узлов и память с третьего.
В голом Docker привязка задаётся двумя флагами:
docker run -d \ --cpuset-cpus=0-7 \ --cpuset-mems=0 \ myapp:latest
В Kubernetes для этого существует менеджер топологии на уровне kubelet, который согласует выделение процессора, памяти и устройств так, чтобы всё пришло с одного узла. Включается он в конфигурации kubelet и работает только для подов с гарантированным качеством обслуживания, то есть с целыми ядрами и совпадающими requests и limits:
# конфигурация kubelet cpuManagerPolicy: static topologyManagerPolicy: single-numa-node
resources: requests: cpu: "8" # целое число, не 7500m memory: "16Gi" limits: cpu: "8" memory: "16Gi"
Дробная квота процессора здесь не подойдёт: менеджер выделяет физические ядра целиком, а под с cpu: 7500m под это условие не попадает и остаётся размазанным по узлам.
Проверяется результат изнутри пода:
kubectl exec -it myapp-0 -- sh -c 'cat /sys/fs/cgroup/cpuset.cpus.effective; cat /sys/fs/cgroup/cpuset.mems.effective'
0-7 0
Восемь ядер с нулевого узла и память только с него — под получил всё с одного сокета, и внутри него локальность гарантирована.
Кому это вообще нужно
Заниматься топологией стоит не всем, и полезно очертить границу.
Выигрыш в 10–30 процентов получают приложения, которые действительно упираются в память: базы данных с большим буферным кешем, кеши в оперативной памяти, виртуальные машины, вычислительные задачи с большим рабочим набором. Для них разница между локальным и удалённым доступом складывается в заметное число, потому что промахов кеша много.
Сервис, который на каждый запрос ходит в базу по сети и ждёт ответа десять миллисекунд, никакой разницы не увидит: сорок наносекунд на обращении к памяти теряются на фоне сетевого круга. Тратить время на привязки здесь незачем.
Отдельно стоит сказать про односокетные машины и облачные инстансы небольшого размера — там узел один, и весь разговор беспредметен. Проверяется это первой же командой из статьи: одна строка в выводе numactl --hardware означает, что дальше можно не читать.
С чего начинать, если подозрения есть
Сначала посмотрите топологию и убедитесь, что узлов больше одного. Затем снимите numastat под боевой нагрузкой и посчитайте долю удалённых обращений — до пяти процентов можно расходиться, выше десяти стоит разбираться дальше. Потом посмотрите numastat -p по своему процессу: перекос памяти на один узел при потоках, размазанных по всем ядрам, и есть та самая картина.
Дальше пробуйте два варианта и меряйте оба на своей нагрузке, а не на синтетике. Привязку к одному узлу — если рабочий набор туда влезает. Чередование по всем узлам — если не влезает или если к данным одинаково ходят все ядра. Автоматическую балансировку выключайте только вместе с привязкой, иначе получите худший из вариантов.
И перед тем как менять что‑либо в проде, зафиксируйте базовую линию по хвосту задержек, а не по средней. Средняя на этих изменениях шевелится слабо, вся разница живёт в 99-м перцентиле — там же, где её и заметили пользователи, когда сервер стал вдвое мощнее.

Если сервер «тормозит» при нормальной загрузке CPU, свободной памяти и спокойном диске, проблема может прятаться глубже привычных метрик. Важно уметь быстро локализовать источник задержек, проверить гипотезу на продакшене и понять, что именно стоит менять — чтобы не тратить часы на бессистемный тюнинг и принимать решения на основе измерений, а не догадок.
Разобраться в диагностике Linux‑систем и инструментах наблюдаемости можно на открытых уроках OTUS:
19 августа, 20:00. «Почему сервер тормозит: первая диагностика Linux для начинающего администратора». Записаться
23 сентября, 20:00. «eBPF: рентгеновское зрение для production». Записаться
Полный список бесплатных уроков августа смотрите в дайджесте.