Двухсокетный сервер на бумаге быстр, но запросы натыкаются на невидимую стену: задержка памяти то 80 наносекунд, то все 160. Причина проста. Ядро видит 128 логических процессоров и 512 ГБ как однородную массу, пока администратор не объяснит ему, что половина ядер живёт в одном процессоре с собственным контроллером памяти, а половина в другом. Память отвечает с разной скоростью в зависимости от расстояния, и зовётся всё это NUMA, Non-Uniform Memory Access. Хорошая новость в том, что Linux раскрывает всю раскладку железа через sysfs, а управлять размещением можно с точностью до отдельного ядра. Ниже рабочие команды, реальные параметры и числовые ориентиры, а по ходу выяснится, почему на некоторых машинах самое полезное действие это выключить один переключатель ядра.
Дерево топологии процессора и его следы в sysfs
Ядро выстраивает процессор в иерархию: поток SMT внутри физического ядра, ядра внутри кристалла die, кристаллы внутри сокета, сокеты внутри NUMA-узла. Каждый уровень этой пирамиды имеет собственную скорость обмена, и разница скоростей превращает сервер в лоскутное одеяло, где одинаковые операции стоят по-разному.
Первичный источник истины лежит в sysfs. Каталог /sys/devices/system/cpu/cpu0/topology/ содержит файлы с говорящими именами: thread_siblings_list перечисляет логические процессоры одной SMT-пары, core_cpus_list показывает все потоки ядра, core_id выдаёт номер ядра, core_siblings_list перечисляет ядра одного сокета, die_cpus_list собирает кристалл, physical_package_id называет сокет. На двухсокетном сервере со 128 потоками команда cat /sys/devices/system/cpu/cpu0/topology/thread_siblings_list возвращает "0,64", а для cpu1 уже "1,65": у AMD EPYC и на большинстве Intel Xeon SMT-соседей разделяет шаг 64, то есть первое физическое ядро даёт логические номера 0 и 64, второе даёт 1 и 65. Отсюда практический приём: если приложение упирается в пропускную способность кэшей, потоки разносят по разным ядрам, оставляя по одному SMT-соседу на ядро, taskset --cpu-list 0,2,4,6,8,10,12,14 ./app для 8-ядерного процессора с 16 потоками. Раскладка отдаёт потокам всю ёмкость L1 и L2.
Узлы NUMA живут по соседству: /sys/devices/system/node/node0 и node1. Файл cpulist внутри node0 перечисляет закреплённые за узлом процессоры, например "0-63", файл numastat копит счётчики распределителя памяти, а meminfo повторяет статистику из /proc/meminfo в разрезе узлов. Каталоги has_cpu и has_memory подсказывают, какие узлы вычислительные, а какие только память, что важно на серверах с CXL и HBM, где узел бывает без процессоров. Голый каталог ещё не карта: свернуть его в таблицу умеет lscpu.
lscpu как главная лупа по ядрам и NUMA-узлам
Утилита lscpu из состава util-linux читает sysfs и /proc/cpuinfo, сворачивая дерево в компактные таблицы. Версия 2.41 по умолчанию рисует строки CPU(s), Socket(s), Core(s) per socket, Thread(s) per core, NUMA node(s) и NUMA node0 CPU(s), по которым сразу видно, сколько сокетов и узлов в машине, а это разные сценарии настройки.
Настоящая сила кроется в ключах. lscpu -e выводит построчную карту: для каждого логического CPU видны NODE, CORE, SOCKET, ONLINE и разделяемые кэши L1d:L1i, L2, L3. Список колонок сокращают точечно: lscpu -e=CPU,NODE,CORE,SOCKET,L3. Ключ -a добавляет offline-процессоры, lscpu -e -a показывает всю раскладку, что спасает, когда часть ядер изолирована параметром isolcpus.
Для скриптов есть lscpu -p, он выдаёт данные без заголовков и оформления, готовые к разбору awk или cut, список колонок задаётся так же: lscpu -p=CPU,ONLINE,MHZ. Раскладку кэшей целиком показывает lscpu -C, например lscpu -C=NAME,ONE-SIZE,CPUS: видно, что L3 объёмом 32 MiB на деле не единый ресурс, а 8 экземпляров по 4 MiB, и что потоки одной SMT-пары делят общий L1. Связка потоков, делящих кэш, экономит трафик памяти, а разбросанные по разным L3 сегментам потоки множат промахи. lscpu -H перечисляет колонки, lscpu -J отдаёт JSON, lscpu -x печатает маски для sched_setaffinity. До версии 2.34 утилита считала размеры кэшей по ядрам, а с 2.37 следует за идентификаторами кэшей ядра, которые не обязаны начинаться с нуля.
Привязка процессов через numactl и taskset с точностью до ядра
Пакет numactl версии 2.0.19 остаётся главным инструментом политики памяти и привязки. numactl --show печатает текущую политику процесса: cpubind, membind, preferred. numactl --hardware рисует инвентаризацию: количество узлов, память каждого узла, список процессоров и матрицу node distances. На двухсокетной машине она выглядит как "10 21": локальный узел по определению имеет расстояние 10, удалённый 21, то есть прошивка считает память соседнего сокета в 2,1 раза дальше. Живые измерения дают обычно от полутора до двух раз штрафа на задержку, разницу между 80 и 150 наносекундами на обращение, и этот налог платится при каждом промахе кэша.
Рабочие связки таковы. numactl --cpunodebind=0 --membind=0 ./app запускает процесс строго на процессорах узла 0 с памятью того же узла, это базовый рецепт для баз данных и расчётных задач. numactl --physcpubind=+0-15,32-47 ./app выбирает конкретные логические процессоры, плюс перед списком означает относительную нумерацию внутри текущего cpuset. numactl --interleave=all ./app раскладывает память по узлам по кругу, когда рабочий набор превышает один узел, а numactl --weighted-interleave=all делает то же с весами из /sys/kernel/mm/mempolicy/weighted_interleave/node*. Политика --preferred=1 мягко смещает размещения на узел 1, --localalloc держит память рядом с текущим процессором, а --balancing просит ядро оптимизировать страницы, работает только вместе с --membind. Узлы адресуются и по оборудованию: --cpunodebind=netdev:eth0 привязывает процесс к узлу сетевой карты, есть спецификаторы pci:, ip:, block: и file:, а !0-7 означает все узлы, кроме первых восьми.
taskset из того же util-linux закрывает чистую привязку к процессорам. taskset --cpu-list 0-7 ./app запускает приложение на первых восьми ядрах, taskset -pc 12345 печатает текущую маску процесса, taskset -a -pc 0-3 12345 меняет её сразу для всех потоков. Маску можно задать и шестнадцатеричным словом: 0x32 означает процессоры 1, 4 и 5. Реже вспоминают про шаг: --cpu-list 0-10:2 разворачивается в 0,2,4,6,8,10, так выбирают по одному потоку на ядро. Границы тоже стоит знать: у per-CPU потока ksoftirqd/0 маску сменить нельзя, ядро вернёт Invalid argument, kswapd маску примет, но мигрировать сразу не обещает, а чужой процесс требует CAP_SYS_NICE.
Автоматический балансир NUMA и его выключатель
Ядро умеет двигать страницы за администратора: sysctl kernel.numa_balancing со значением 1 включает обычный режим NUMA_BALANCING_NORMAL, значение 2 добавляет NUMA_BALANCING_MEMORY_TIERING для перегонки горячих страниц в быструю память между разными типами узлов, 0 всё выключает. Механизм описан в документации: ядро периодически снимает отображение страниц, ловит последующие page fault и по факту промаха решает, мигрировать ли страницу на локальный узел. Аудит не бесплатный, и если нагрузка уже привязана к узлам, фичу положено выключать, иначе она лишь добавляет лишние прерывания.
Тонкими ручками управляют четыре параметра: kernel.numa_balancing_scan_delay_ms с задержкой 1000 мс перед первым сканом, numa_balancing_scan_period_min_ms 1000 и numa_balancing_scan_period_max_ms 60000 как границы периода, numa_balancing_scan_size_mb 256, задающий, сколько мегабайт адресного пространства задача просканирует за окно. На терабайтной базе с рандомным доступом такой скан подсаживает хвостовые задержки.
Выключают балансировщик на два хода. Динамически: sysctl -w kernel.numa_balancing=0, работает сразу и до перезагрузки. Несменяемо: параметр ядра numa_balancing=disable в cmdline загрузчика, поддержанный на X86, ARM64, PPC, RISCV и S390, а enable возвращает всё на место. Загрузочный вариант надёжнее: политика становится частью конфигурации машины, а не тихим sysctl-патчем после обновления.
За расползание задач по узлам отвечает иерархия доменов планировщика: базовый домен охватывает SMT-соседей, родительский собирает ядра узла, верхний перебрасывает баланс между NUMA-доменами. Структуру показывает sched_verbose в cmdline или /sys/kernel/debug/sched/verbose. В контроллере cpuset из cgroup v1 флаг cpuset.sched_load_balance, сброшенный в 0, вырезает узел из общей балансировки, а cpuset.sched_relax_domain_level от 1 до 5 задаёт радиус немедленной балансировки: 1 ограничивает её SMT-парой, 3 границей узла, 5 всей машиной.
Числа из numastat и cpuset-контроллер cgroups
Диагностика начинается с numastat. Без аргументов он печатает счётчики распределителя памяти в страницах: numa_hit считает размещения по плану, numa_miss ловит память, выделенную вопреки предпочтениям, numa_foreign зеркально показывает память, предназначавшуюся узлу, но осевшую на другом, interleave_hit отделяет чередующиеся размещения, local_node и other_node показывают, сколько памяти процесс получал рядом, а сколько за рубежом. Ключ -m разворачивает per-node разбивку в стиле /proc/meminfo, ключ -n переключает единицы на мегабайты, -p принимает PID или шаблон командной строки, и numastat -p postgres покажет, где легла память сервера. Ключ -c сжимает таблицу, -z прячет нули, -s сортирует по убыванию, NUMASTAT_WIDTH=200 спасает вывод на многонодовых системах. Полезно поставить watch -n1 numastat: устойчивый рост numa_miss это прямое показание к переносу процесса. Первый numastat был скриптом на Perl из 2003 года, версия 1.0.0 написана в 2012, свежие сборки идут с numactl 2.0.19.
Cgroup-контроллер cpuset превращает разовые привязки в постоянную политику, он живёт в ядре со времён Linux 2.6.12. В cgroup v2 рецепт выглядит так: mkdir /sys/fs/cgroup/db0, затем echo "0-15" > /sys/fs/cgroup/db0/cpuset.cpus и echo 0 > /sys/fs/cgroup/db0/cpuset.mems, после чего echo $$ > /sys/fs/cgroup/db0/cgroup.procs пересаживает текущий шелл в новую группу. Файлы cpuset.cpus.effective и cpuset.mems.effective показывают фактическую раскладку, на корневом уровне доступен cpuset.cpus.partition под изолированные партиции. В v1 те же рули зовутся cpuset.cpus и cpuset.mems, плюс флаг cpuset.memory_migrate: с единицей он перетаскивает размещённые страницы на новый узел при переносе задания между сокетами без остановки.
hwloc, lstopo и живые кейсы на двухсокетном сервере
Библиотека hwloc 2.15.0 собирает всю топологию в единое дерево и умеет отдавать её картинкой: lstopo topo.png сохраняет карту в PNG, на сервере без графики спасают lstopo-no-graphics и hwloc-ls с текстовым выводом, а lstopo --of ascii рисует схему прямо в терминале. Ключ -v добавляет детали, lstopo --distances печатает матрицы расстояний между узлами, --filter PU:none прячет SMT-потоки, ключи -l и -p переключают логические и физические номера, а --cof taskset печатает маски для вставки в taskset или numactl.
Знать раскладку мало, надо уметь её применять. hwloc-calc node:1 переводит описание в битовую маску первого узла, hwloc-bind --cpubind node:1 --membind node:1 ./app запускает процесс с готовой привязкой. Карту можно унести: lstopo topo.xml экспортирует топологию, lstopo -i topo.xml читает её на другой машине, а синтетическое описание node:2 2 проверяет скрипты ещё до приезда железа в стойку.
Сервер PostgreSQL с 32 ядрами и двумя узлами получает поузловую настройку: экземпляр на узле 0 запускают через numactl --cpunodebind=0 --membind=0, а cpuset закрепляет его в отдельной cgroup, чтобы systemd не разнесла потоки по машине. Прокси-слой уезжает на узел 1. На практике это ощутимая прибавка по p99 на операциях с памятью: локальный доступ укладывается в диапазон от 70 до 100 наносекунд, удалённый начинается от 130 и доходит до 200, и умножить эту разницу на миллионы промахов кэша несложно.
Порядок обкатки новой машины предсказуем:
1. Сначала lstopo и numactl --hardware снимают карту узлов, расстояний и кэшей;
2. Затем lscpu -e превращает карту в таблицу процессоров, из которой берут списки для привязок;
3. После этого taskset и numactl закрепляют пробную нагрузку по узлам, а numastat сверяет hit и miss;
4. В конце политику фиксируют в cpuset-группах и параметрах загрузки, оставляя kernel.numa_balancing выключенным для уже привязанных задач.
Числа не универсальны: SLIT даёт относительные расстояния, а абсолютные задержки зависят от поколения процессоров и прошивки. Зато подход универсален: ядро честно раскрывает всю топологию, остаётся лишь спросить, и ответ лежит в sysfs.