В машине с двумя и более процессорами память принадлежит не системе в целом, а конкретному сокету. Контроллер памяти встроен в кристалл процессора, планки в слотах рядом с сокетом обслуживаются его каналами напрямую, а до планок соседнего сокета запрос идёт через межпроцессорную шину. Логически адресное пространство единое, и приложение не обязано знать, где физически лежит его страница. Физически же обращение к своей и чужой памяти стоит разного времени, и на этом различии строится вся практика настройки многосокетных машин.

Откуда взялась асимметрия

Раньше контроллер памяти жил в чипсете, и все процессоры ходили к нему по общей шине. Схема была однородной: любой сокет добирался до любой планки за одинаковое время. Платой за однородность было общее узкое место — один контроллер и одна шина на всех, из-за чего добавление процессоров переставало давать прирост.

Перенос контроллера в кристалл снял это ограничение. Каждый сокет получил собственные каналы памяти, и суммарная пропускная способность стала расти вместе с числом процессоров. Взамен появилась неоднородность: часть памяти оказалась ближе, часть дальше. Такая топология и называется NUMA, а группа «процессор плюс его локальные каналы» — узлом. Современные кристаллы дробятся ещё мельче: внутренняя шина делится на сегменты, и один физический процессор может представляться системе несколькими узлами.

Цена обращения к чужой памяти

Удалённый доступ дороже по трём причинам сразу. Первая — лишние переходы: запрос уходит на соседний сокет, там обслуживается его контроллером, и результат возвращается обратно, что заметно увеличивает задержку. Вторая — пропускная способность: межпроцессорная шина уже, чем суммарная ширина локальных каналов памяти, поэтому если весь трафик пойдёт через неё, потолок скорости определит именно она, а не память. Третья — очереди: удалённые запросы конкурируют с когерентным трафиком и с обращениями устройств, и при насыщении задержка растёт нелинейно.

Для приложения это выглядит не как честное замедление, а как нестабильность. Один и тот же участок кода отрабатывает то быстро, то медленно, разброс времени ответа увеличивается, а профиль нагрузки при этом не меняется. Причина обычно в том, что поток переехал на другое ядро, а его данные остались в памяти прежнего узла.

Когерентность кэшей

Пока данные лежат в кэшах разных процессоров, система обязана поддерживать единую картину памяти. За это отвечает протокол когерентности: у каждой строки есть домашний узел, который знает её состояние и владельцев. Промах кэша превращается в запрос к домашнему узлу, а тот при необходимости запрашивает копию у чужого кэша.

Отсюда следует неприятный эффект: переменная, которую активно пишут потоки на разных сокетах, начинает курсировать между кэшами. К этому добавляется ложное разделение, когда независимые переменные попали в одну строку кэша — тогда обмен идёт из-за соседства данных, а не из-за логики программы. В однопроцессорной машине это стоит дорого, в многосокетной — дороже на величину похода через межпроцессорную шину.

Как раскладываются память и устройства

Ядро операционной системы обычно выделяет физическую страницу в том узле, где выполнялся поток, впервые к ней обратившийся. Правило простое, но оно означает, что раскладку определяет фаза инициализации: если один поток при старте заполнил массив, весь массив окажется локальным для одного узла, сколько бы потоков потом его ни читало. Инструменты привязки процессов к узлам и режим чередования страниц между узлами существуют именно для того, чтобы вручную поправить такие случаи.

Устройства подчиняются той же географии. Линии PCIe выходят из конкретного сокета, поэтому дисковый или сетевой контроллер физически подключён к одному процессору. Его прямой доступ к памяти и его прерывания дешевле обслуживать локальными ядрами, и при высокой нагрузке распределение прерываний по узлам заметно влияет на результат. Соображения по выбору сокетов и их обвязки собраны в разделе про серверные процессоры.

Типичные ошибки сборки

Самая частая ошибка — несимметричное заполнение слотов. Если все модули поставлены к одному сокету, второй процессор остаётся без локальной памяти и работает исключительно через межпроцессорную шину. Вторая ошибка — заполнение не всех каналов: половина каналов пустует, и половина пропускной способности теряется ещё до того, как начнутся разговоры про удалённый доступ. Требования к модулям и правила их размещения по каналам разобраны в разделе про память с коррекцией ошибок.

Диагностика начинается с топологии: сколько узлов видит система, сколько памяти в каждом, как распределены ядра. Дальше проверяется, совпадает ли расположение потоков с расположением их данных. Обычно достаточно симметрично заполнить слоты и привязать нагрузку к узлам, чтобы разброс времени ответа вернулся к норме.