Сервер с двумя сокетами и терабайтом памяти выглядит в мониторинге как единое целое, а ведёт себя как два компьютера, соединённые узким мостом. Приложение, которое слепо выделяет память и запускает потоки, легко заставляет каждый второй запрос к данным пересекать межпроцессорный канал вместо остановки в локальном контроллере. Разница в латентности доступа между локальной и удалённой памятью измеряется не процентами, а разами, и именно поэтому тема NUMA регулярно всплывает в каждом проекте, где от сервера ждут предсказуемой производительности.
Как устроена NUMA и почему удалённая память медленнее
Аббревиатура расшифровывается как неоднородный доступ к памяти. В многосокетной машине каждый процессор имеет собственный контроллер памяти и подключённые к нему модули. Обращение ядра к "своей" памяти идёт напрямую, примерно за 80-100 наносекунд на современных платформах. Обращение к памяти соседнего сокета проходит через межпроцессорную шину (у Intel это линия Ultra Path Interconnect, у AMD - Infinity Fabric) и добавляет десятки наносекунд, а при перегруженном канале - и того больше. Типичный коэффициент штрафа между локальным и удалённым доступом, который измеряют бенчмарки вроде Intel Memory Latency Checker, лежит в диапазоне 1,3-1,8 для современных двухсокетных систем и уходит выше на четырёхсокетных узлах с промежуточным переходом. На практике это выглядит так: локальное обращение обходится в 70-91 наносекунду, удалённое через канал между сокетами в 148-157 наносекунд, то есть плюс 70 процентов к задержке. Пропускная способность падает ещё сильнее, с порядка 128 гигабайт в секунду на локальном контроллере до 60 гигабайт при переходе через межпроцессорный канал, минус 53 процента.
Обе матрицы снимаются одной командой:
mlc --latency_matrix
mlc --bandwidth_matrix
Step 1: Measuring idle latencies (in ns)...
All Numbers are in ns
Numa node
Numa node 0 1
0 86.5 148.7
1 147.9 85.2
Step 2: Measuring Peak Bandwidths (in MB/s)...
Numa node
Numa node 0 1
0 128400 59800
1 60100 127900
Диагональ матрицы и есть локальный доступ, всё вне диагонали - переход через канал. Пересчитать потери в эффективную задержку помогает одна формула:
T_eff = p * T_local + (1 - p) * T_remote
p = 0.9: 0.9 * 86.5 + 0.1 * 148.7 = 92.7 нс, плюс 7% к локальной
p = 0.7: 0.7 * 86.5 + 0.3 * 148.7 = 105.0 нс, плюс 21%
p = 0.5: 0.5 * 86.5 + 0.5 * 148.7 = 117.6 нс, плюс 36%
Из этих трёх строк видно главное: локальность не бинарное состояние, а непрерывная величина, и даже 10 процентов удалённых обращений уже заметны, а 50 процентов превращают двухсокетный сервер в один медленный сокет.
Операционная система видит эту топологию через таблицы прошивки: ACPI SRAT описывает принадлежность диапазонов памяти и процессоров к узлам, SLIT хранит матрицу относительных расстояний между ними. Посмотреть, во что верит Windows, можно прямо из отладчика или через API: функция GetNumaHighestNodeNumber вернёт наибольший номер узла, а GetNumaNodeProcessorMaskEx покажет, какие логические процессоры к какому узлу относятся. Ту же картину без единой строчки кода печатают две команды:
coreinfo -n
Logical to Numa Node Map:
Logical Processor 0-23: Numa Node 0
Logical Processor 24-47: Numa Node 1
Get-CimInstance Win32_NumaNode | Select-Object NodeId,NumberOfLogicalProcessors
Если карта из coreinfo расходится с ожиданиями по количеству модулей в каналах, проверку начинают именно с раскладки памяти по слотам: неполный канал на одном из сокетов даёт просадку пропускной способности, которую легко принять за межузловой штраф. На старом железе встречается курьёз, когда прошивка описывает однородную машину как NUMA или наоборот прячет разделение; в таких случаях приложения, полагавшиеся на показания системы, ползут без видимых причин, пока кто-нибудь не сверит таблицы с реальным раскладом слотов.
Как Windows по умолчанию распределяет потоки и память
Планировщик Windows с давних времён, то есть со времён Server 2003 и ранних 64-разрядных систем, придерживается двух принципов. Поток запускается на том узле, где ему с большей вероятностью достанется локальная память, и выделение памяти по умолчанию старается обслуживаться на узле процессора, который сделал запрос. Политика называется "первая касание": физическая страница привязывается к узлу процессора, который первым обратился к этой виртуальной странице. Стоит понять последствие: если инициализацию гигантского буфера выполняет один поток, весь буфер осядет на узле этого потока, и все остальные потоки будут ходить за данными через межузловой канал. Этот классический промах многопоточных программ виден на счётчиках как странная асимметрия нагрузки: один сокет пашет на память, второй ждёт.
Удобно посмотреть расклад существующего процесса инструментом resource monitor либо счётчиками производительности узлов NUMA, где система показывает объёмы доступной памяти по каждому узлу. А диагностику на уровне кода даёт простой приём: опросить процессы утилитой vmmap или встроенными структурами и сопоставить реальную принадлежность страниц тому, что ожидалось. Инженеры баз данных знают эту механику давно: серверные СУБД, SQL Server в том числе, имеют собственный слой SQLOS, который жёстко привязывает планировщики к узлам и выделяет буферный пул порциями на каждом узле отдельно, именно чтобы победить "первое касание" в лоб.
Программные приёмы привязки к узлам
Управление NUMA из собственного кода строится на трёх китах: привязка потоков, привязка памяти и инфраструктурные группы процессоров. Привязка потока к узлу делается через установку расширенной маски сходства:
GROUP_AFFINITY ga = { 0 };
// номер группы процессоров не равен номеру узла NUMA,
// его заполняет сам GetNumaNodeProcessorMaskEx
if (!GetNumaNodeProcessorMaskEx(nodeNumber, &ga)) {
return GetLastError();
}
SetThreadGroupAffinity(GetCurrentThread(), &ga, NULL);
Подставлять в поле Group номер узла вручную нельзя: на машине, где логических процессоров больше 64, нумерация узлов и нумерация процессорных групп расходятся, и такая подстановка отправит поток не туда. Расширенная версия функции заполняет и маску, и номер группы согласованно, поэтому ей и доверяют.
Память выделяют с явным указанием узла через расширенные варианты функций виртуальной памяти:
PVOID p = VirtualAllocExNuma(hProcess, NULL, size,
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE, nodeNumber);
Тонкость про машины с большим числом ядер: когда логических процессоров больше 64, Windows делит их на процессорные группы по 64 штуки, и обычные маски сходства не видят за пределами своей группы. Приложение, написанное в расчёте на одну группу, на сервере со 128 ядрами будет молча использовать только половину машины. Лечение требует переписывания работы с аффинностями на расширенные API с явным указанием группы, либо запуска нескольких процессов с распределением по группам. Серверные продукты проходили эту боль массово в годы появления машин с высокой плотностью ядер, и следы тех времён до сих пор встречаются в старом коде, где маска - это DWORD.
Отдельный блок практики - потоки пула и асинхронный ввод-вывод. Потоки системного пула мигрируют свободно, и если обработчики завершения ввода-вывода всплывают на случайных ядрах, локальность тает. Привязка портов завершения и рабочих потоков к узлам там же, где лежат обрабатываемые буферы, даёт эффект, заметный без единой строчки оптимизации алгоритмов: уменьшается трафик когерентности между сокетами, который на загруженной машине ограничивает рост ещё до того, как упрётся сам процессор.
Проверка гипотез счётчиками и профилировщиком
Прежде чем переписывать код, стоит убедиться, что узким местом действительно является межузловой трафик. Прямое измерение отношения локальных обращений к удалённым даёт аппаратный мониторинг через счётчики PMU процессора: на Intel это события семейства OFFCORE и UNC-Memory внутри контроллеров памяти, на AMD - аналогичные счётчики Data Fabric. Достать их из пользовательского кода помогает Intel PCM, набор инструментов с открытым исходным кодом: утилита pcm-memory снимает трафик по каналам и сокетам раз в заданный интервал, а результаты удобно сразу складывать в csv для сравнения прогонов:
pcm-memory 1 -csv=numa-baseline.csv
Грубое правило практиков: если доля удалённых обращений стабильно выше 30-40 процентов при равномерной нагрузке, локальность у приложения сломана. Порог легко проверить той же формулой эффективной задержки: при p = 0,65 и приведённых выше временах T_eff выходит около 108 наносекунд, то есть на четверть хуже локального доступа, и эта четверть целиком оплачена межузловым трафиком.
Порядок действий в таком разборе обычно складывается одинаковый:
- Снять базовый профиль нагрузки и зафиксировать долю удалённого трафика и пропускную способность каналов памяти;
- Найти структуры данных, к которым обращаются потоки с чужих узлов, через образцы профилировщика с точностью до адресов;
- Изменить выделение и инициализацию этих структур так, чтобы они жили на узле потребителя, включая параллельную инициализацию несколькими потоками с разных узлов;
- Привязать рабочие потоки к узлам и повторить замер на той же нагрузке;
- Сохранить дельту в протоколе изменений, чтобы следующее обновление не вернуло регресс незамеченным.
Заметный подводный камень на этом пути - избыточное увлечение привязкой. Жёстко приколоченные потоки перестают балансироваться, и при неравномерной нагрузке узел с "любимыми" ядрами оказывается перегружен, пока соседний простаивает. Опытные разработчики оставляют планировщику окно свободы: привязывают не к одному ядру, а к набору ядер узла, сохраняя локальность памяти и даром получая выравнивание.
NUMA в виртуализации и на гипервизоре
Отдельная глава - виртуальные машины. Hyper-V строит топологию гостя по принципу "виртуальный узел на физический узел" и выравнивает память ВМ по узлам хоста, поэтому гостевое приложение в принципе способно работать локально. Разваливает эту идиллию два фактора: машина, память которой пересекает границы физических узлов, и миграция, после которой вычисления уезжают на другой сокет, а память остаётся. Параметр максимального количества виртуальных процессоров на NUMA-узел в настройках ВМ и требование размещать машину внутри одного узла по памяти - те самые винтики, которые опытные администраторы крутят в первую очередь для крупных баз данных. Проверить, видит ли гость топологию хоста, можно изнутри машины той же командой coreinfo -n: если гость показывает один узел там, где хост даёт два, топология проброшена неверно и вся локальность приложения работает вхолостую. Отдельно стоит помнить, что live migration после переезда оставляет память на прежнем физическом узле, и первые минуты после миграции задержка доступа к памяти у машины заметно выше, пока страницы не перераспределятся.
Полезно знать и про режимы прошивки сервера. Опция cluster-on-die и её наследники на современных платформах дробят один сокет на два логических NUMA-домена, уменьшая штраф за удалённый доступ внутри сокета ценой усложнения топологии. Поднять или опустить этот переключатель стоит осознанно: для нагрузок с горячим общим кэшем суб-NUMA кластеризация обычно даёт выигрыш, а для приложений со слоем собственного шардирования часто выгоднее видеть узел целиком. Обе конфигурации стоит прогнать на своей нагрузке, потому что разница в несколько процентов на пустом месте превращается в ощутимые деньги при парке из десятков серверов.
Практическая схема внедрения в существующий проект
Чаще всего перед инженером стоит не чистый лист, а зрелое приложение, которое "на маленькой машине летало, а на новом сервере еле ползёт". Алгоритм действий в таком случае консервативен. Сначала измеряют, где приложение теряет время и какова доля межузлового трафика, чтобы убедиться, что проблема вообще NUMA, а не, скажем, конкуренция за блокировки. Затем находят три-четыре самые горячие структуры данных и переводят их на выделение с явным узлом и параллельную инициализацию. После этого привязывают узкую группу рабочих потоков к узлам, оставляя пул свободным, и наконец настраивают окружение: топологию ВМ, режимы прошивки, политики памяти.
Типичные ошибки, которые сводят выигрыш на нет
Опыт внедрения NUMA-aware логики в больших проектах дал вполне устойчивый список промахов, и их полезно проговорить, потому что повторяют их с завидным постоянством. Первая ошибка - вездесущий глобальный аллокатор. Пока приложение запрашивает память у одного общего менеджера кучи, весь труд по разнесению буферов по узлам впадает в единую пузырящуюся массу страниц, выделенных "где придётся". Лечится введением узловых аллокаторов: каждая рабочая группа потоков просит память только в пуле своего узла, а общий запас используется исключительно для старта.
Вторая ошибка - забытая инициализация. Многие честно выделяют буфер с указанием узла, а потом обнуляют его одним потоком ещё на этапе загрузки. С точки зрения "первого касания" страница уже привязана, но при грамотном подходе обнуление тоже раздают по узлам: пусть каждый рабочий поток первым делом трогает свой собственный срез буфера. Приём с параллельной инициализацией заслуживает отдельного фрагмента, потому что именно на нём ломаются почти все первые попытки. Смысл в том, чтобы каждый рабочий поток первым касался только своего среза буфера, и тогда страница физически осядет на узле этого потока:
DWORD WINAPI NodeInitThread(LPVOID arg)
{
PNODE_SLICE slice = (PNODE_SLICE)arg;
GROUP_AFFINITY ga = { 0 };
GetNumaNodeProcessorMaskEx(slice->Node, &ga);
SetThreadGroupAffinity(GetCurrentThread(), &ga, NULL);
PUCHAR base = (PUCHAR)slice->Buffer + slice->Offset;
for (SIZE_T i = 0; i < slice->Length; i += 4096) {
base[i] = 0; // одно касание на страницу достаточно
}
return 0;
}
Шаг в 4096 байт здесь не случаен: страница выделяется целиком при первом же обращении к любому её байту, поэтому трогать буфер подряд нет смысла. После такого прохода проверка счётчиков показывает рост локального трафика и падение удалённого без единого изменения в алгоритмах обработки.
Третья ошибка коварнее - ложное совместное использование строк кэша на границах данных разных узлов. Когда два потока с разных сокетов правят соседние поля, попадающие в одну строку кэша в 64 байта, контроллеры когерентности начинают безостановочную переброску этой строки между процессорами, и графики нагрузки показывают кипение именно на межузловом канале. Простой приём защиты - выравнивание и заполнение: горячие поля, которые пишутся разными узлами, разносят минимум на размер строки кэша.
Четвёртая ошибка организационная. Команде внедряют NUMA-оптимизации, получают локальный прирост в треть и через квартал теряют его целиком, потому что никто не поставил флажок контроля: новый код снова выделил структуру с "первым касанием" из стартового потока, и локальность распалась. Помогает элементарная дисциплина: профиль межузлового трафика добавляют в обязательный набор показателей нагрузочного теста рядом с временем отклика, и любое отклонение сверх порога становится поводом для разбора, а не сюрпризом на продуктиве в пятницу вечером. В таком режиме осознанность по отношению к узлам перестаёт быть разовым проектом и становится привычкой, которая окупается на каждом релизе.
Каждый шаг сопровождают контрольным замером, и суммарный выигрыш на реальных системах складывается впечатляющий: переход с обычной конфигурации на осмысленную NUMA-aware сборку для нагрузок с интенсивной работой с памятью регулярно приносит 20-40 процентов пропускной способности без смены железа. Это одна из немногих областей, где внимательность к топологии вознаграждается сразу и измеримо.