Привет, Хабр!

Сервис переезжает с односокетной машины на двухсокетную: ядер вдвое больше, памяти вдвое больше, частота та же. Разработчики ждут прироста, эксплуатация готовит графики. Средняя задержка действительно чуть падает, а вот 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». Записаться

Полный список бесплатных уроков августа смотрите в дайджесте.

Комментарии (0)