PostgreSQL и NUMA, часть 1 из 4
Автор: Chris Travers PostgreSQL and NUMA, part 1 of 4
Этот цикл посвящён особенностям работы PostgreSQL на крупных системах с большим числом процессоров. По моему опыту, столкнувшись с этой задачей, люди нередко тратят месяцы на освоение азов. Цель цикла — снять эти трудности, дав ясную базовую картину по ключевым темам. Хочется верить, что будущим инженерам и администраторам баз данных не придётся месяцами методом проб и ошибок разбираться в том, что можно понять быстрее.
Эти статьи сосредоточены на низкоуровневых «как» и «почему» в контексте неоднородного доступа к памяти (Non‑Uniform Memory Access), чтобы затем было проще понимать решения и рекомендации — с упором на концептуальную сторону. К сожалению, во многих местах без технических деталей не обойтись: голые концепции без деталей в лучшем случае сбивают с толку.
Дальнейшие части будут опираться на материал этой статьи. Рекомендуем начать с него, а затем по мере необходимости возвращаться.
Что такое NUMA и зачем она нужна?
Историческая справка
До начала 1990‑х многопроцессорные системы, включая мейнфреймы, использовали архитектуру памяти Uniform Memory Access, где все процессоры обращаются к оперативной памяти через единый контроллер. Доступ можно распараллеливать с помощью блокировок и прочих приёмов. По мере того как процессоры и контроллеры памяти становились мощнее, потребность в распараллеливании доступа к памяти за пределами мейнфреймов стремительно росла.
Управление памятью таит множество вызовов. Большинство обращений должно быть сериализовано: нельзя разрешать чтение, пока идёт запись; к тому же существуют аппаратные и электрические ограничения на одновременное чтение. Значит, для каждого блока памяти нужно гарантировать последовательный доступ и при чтении, и при записи, одновременно обеспечивая, чтобы множество разных чтений и записей могли выполняться параллельно в разных частях подсистемы памяти.
Мейнфреймы решали это через сложный слой управления памятью, включая высокоскоростную область памяти для блокировок; вне мира мейнфреймов такой подход сочли непрактичным, и родилась NUMA.
NUMA основана на идее, что можно отказаться от гарантии одинаковой задержки при доступе ко всей памяти целиком и реализовать архитектуру, в которой у каждой группы процессоров есть область памяти, доступ к которой быстрее, чем к остальной. Это определение — практический компромисс и само по себе не объясняет, как NUMA работает, зачем она нужна и как с ней обращаться. Смысл компромисса в том, что он позволяет разделить память между контроллерами памяти; те, в свою очередь, могут гарантировать последовательный доступ к «своей» памяти. Этот ключевой момент необходимо понимать, чтобы разбираться в NUMA на аппаратном и, следовательно, программном уровнях.
Как устроена NUMA
Традиционный сервер UMA (Uniform Memory Access), симметричный многопроцессорный (2 ядра на сокет, 2 сокета), выглядит так: каждое ядро обращается к внешнему контроллеру памяти.

В этой схеме каждая пара ядер делит общий кеш L3, а запросы в RAM идут через единый контроллер памяти, у которого есть доступ ко всей памяти системы.
При росте числа ядер такая архитектура перестаёт масштабироваться. Мейнфреймы решали проблему, блокируя области памяти и используя высокоскоростной общий кеш блокировок. Но если принять разделение памяти на локальную и удалённую, то параллелизм обеспечивать проще.
Поэтому мы просто делим системную память на регионы и закрепляем их за контроллерами памяти. Двухсокетная система с четырьмя ядрами на сокет и одним контроллером памяти на сокет может выглядеть так.

Теперь каждое ядро может обращаться ко всей памяти системы, но лишь часть её доступна напрямую. Если память не локальна, один контроллер памяти запрашивает доступ у другого, и данные идут по высокоскоростной шине Interconnect между контроллерами разных NUMA‑узлов. Если процессы мигрируют между процессорами, их код и рабочие наборы тоже должны быть перемещены через эту шину либо посредством миграции страниц (см. ниже), либо за счёт косвенного доступа.
Разумеется, каждый сокет может иметь несколько контроллеров памяти, как видно на следующей материнской плате.

Материнская плата Selectel с двумя сокетами и двумя банками памяти на сокет, фото: Chris Travers
Здесь у каждого сокета два комплекта банков памяти. Сам CPU может дополнительно делить банк на регионы.
Linux и NUMA
Политики распределения
Планировщик ядра Linux может размещать память и запускать процессы в соответствии с несколькими политиками NUMA. Важное ограничение: политики влияют и на выделение памяти, и на запуск потоков, что иногда приводит к неидеальным сочетаниям. Например, если поток или процесс стартует под политикой interleave, то и его распределения будут рассредоточены по узлам, то есть как минимум часть памяти окажется на других NUMA‑узлах. С другой стороны, если всё настроено на «local», то и память, и потоки будут стартовать лишь в одном узле NUMA, что приведёт к вытеснению страниц, выгрузке на диск и возможной загрузке в «дальний» NUMA‑узел.
Основные политики:
- Local (по умолчанию): по возможности предпочитать текущий узел. Если нельзя — распределять на других узлах.
- Membind: распределять только на текущем узле. Если места недостаточно, распределение завершается неудачей.
- Interleave: распределять по всем указанным узлам по кругу. Это означает, что когда процесс запрашивает память, она будет распределена по узлам; в результате любой процесс с такой политикой имеет как минимум часть памяти, доступ к которой идёт через интерконнект. Со временем ситуация может меняться за счёт миграции процессов и страниц.
Миграция страниц и процессов
Политики выделения задают лишь стартовую точку. Планировщик, по мере необходимости, может перемещать процессы и страницы к ядрам CPU, привязанным к другим контроллерам памяти. Со временем он стремится разместить вместе процессы и их память, исходя из реального доступа.
Ядро Linux анализирует паттерны доступа к памяти и периодически решает мигрировать страницы данных от набора CPU (и NUMA‑узла) к другому. Процесс непрост: страницы нужно закрепить (pin), а затем переместить в другую область памяти.
Возможна и вторая разновидность миграции: планировщик может перевести процесс на узел, более близкий к нужной памяти. В этом случае процесс вместе с некоторой (или всей) локальной памятью будет перенесён на другой NUMA-узел по той же шине интерконнекта.
Выводы
NUMA приносит немало вызовов в современных серверах с большим количеством ядер и объёмов памяти. Базовых средств NUMA в ядре Linux недостаточно, чтобы «всё само» работало с максимально возможной производительностью, если приложения не управляют своими политиками NUMA напрямую. Тем не менее понимание этих основ даёт фундамент для грамотного управления ПО на крупных системах.
Понимание механики необходимо, чтобы осознавать компромиссы. Это особенно важно для последующих частей цикла.
Следующая статья серии будет посвящена запуску Postgres 17 и ниже на системах NUMA под Linux. Часть рекомендаций применима и к 18‑й версии, даже с поддержкой libnuma.
Trackbacks
The author does not allow comments to this entry
Comments
Display comments as Linear | Threaded