Исследуем NUMA

Исследуем NUMA


В системах Symmetric Multiprocessor (SMP) вся физическая память выглядит как единый пул. Все CPU делят между собой аппаратные ресурсы и одно адресное пространство, потому что ядро в системе тоже одно.

Когда физическая память и устройства ввода-вывода находятся на одинаковом расстоянии по задержкам от набора независимых физических CPU (сокетов), такую систему называют UMA (Uniform Memory Access).

В UMA-конфигурации все физические процессоры обращаются к памяти через один и тот же контроллер и работают на одном общем bus’е. Из-за физических ограничений общей шины и растущих конфликтов при обращениях, собрать большой SMP-сервер довольно сложно — чем больше CPU, тем все хуже масштабируется.

NUMA (Non Uniform Memory Access) даёт возможность строить системы покрупнее, но ценой различающихся задержек при доступе к памяти.

Один CPU-сокет (который может содержать несколько логических ядер, каждое с двумя гиперпотоками) — это UMA. Но как только в системе появляется два и более сокетов, она становится NUMA. Такой дизайн — компромисс: мы получаем большую систему, но вместе с ней и более высокие задержки доступа к памяти.

Главный плюс NUMA-серверов — заметно более высокая производительность по всем направлениям (compute, memory, network, storage). Иногда такого уровня просто не добиться на UMA-системах.

Linux-ядро понимает NUMA и учитывает привязку процессов к CPU и локальность данных, чтобы по возможности держать задержки на низком уровне. Библиотека libnuma и утилита numactl позволяют приложениям подсказывать ядру, как им хотелось бы управлять памятью.

NUMA-узлы

Обычно NUMA-системы состоят из нескольких узлов. У каждого узла есть свои CPU, своя память и свои устройства ввода-вывода. Узлы соединены между собой высокоскоростной шиной и через неё получают доступ к памяти и устройствам других узлов.

Каждый NUMA-узел работает как UMA-SMP-система: быстрый доступ к локальной памяти и локальным устройствам, но заметно более медленный — к памяти удалённых узлов.

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

Ядро Linux понимает топологию системы

ACPI (Advanced Configuration and Power Interface) в BIOS формирует таблицу распределения системных ресурсов (SRAT), в которой каждому CPU и каждому блоку памяти сопоставляется «домен близости» — то есть NUMA-узел.

SRAT описывает архитектуру памяти для CPU: какие процессоры и какие диапазоны памяти принадлежат конкретному NUMA-узлу. Это позволяет связать память каждого узла в единый последовательный блок адресного пространства.

Ядро Linux использует SRAT, чтобы понять, какой банк памяти является локальным для конкретного физического CPU, и старается выделять этому CPU именно локальную память. Таблица System Locality Information Table (SLIT), которую тоже формирует ACPI, содержит матрицу взаимных расстояний (то есть задержек памяти) между такими доменами.

Раньше большее число узлов означало и большие задержки из-за расстояния между ними. Но современные point-to-point архитектуры ушли от кольцевой схемы к полной mesh-топологии, и теперь количество “прыжков” остаётся фиксированным, независимо от того, насколько крупная NUMA-конфигурация.

Кэш-когерентная NUMA (ccNUMA)

Поскольку каждый CPU core (а физический CPU состоит из нескольких логических ядер) имеет собственный набор кэшей, это может приводить к проблемам когерентности — когда существует несколько копий одних и тех же данных.

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

Если когерентности нет, данные, которые один CPU изменил в своём кэше, могут не быть видны другим CPU, и это легко приводит к повреждению данных. Для SMP систем требуется аппаратная поддержка когерентности кэшей. Это реализовано через специальный протокол, называемый cache snooping.

Snooping гарантирует, что все процессоры видят одинаковое текущее состояние данных или блокировки в физической памяти. Механизм отслеживает кэши CPU на предмет изменённых (modified) данных.

Кэши CPU разбиты на одинаковые по размеру ячейки — cache lines. Данные подтягиваются из физической памяти кусками по 64 байта и кладутся в эти cache lines для быстрого доступа.

Когда один CPU модифицирует cache line, все остальные кэши проверяются на наличие той же линии. Если она там есть, её инвалидируют или просто выбрасывают.

Когда CPU обращается к данным, которых нет в его собственном кэше, snooping сначала проверяет кэш-линии других CPU. Если нужные данные находятся там, CPU получает их от другого кэша, а не тянет из памяти. В итоге механизм write-invalidate перед записью в локальный кэш удаляет все остальные копии этой cache line в кэшах других CPU. Для других процессоров это означает cache miss, но данные при этом берутся из кэша того CPU, который содержит самую свежую версию.

Иерархия памяти в сервере

В процессоре есть целая иерархия кэшей. У каждого ядра — свои приватные L1 и L2, а L3 общий для всех ядер сокета. Всё это нужно, чтобы уменьшить задержки при обращении к физической памяти. Доступ к памяти локального NUMA-узла быстрее, чем к памяти удалённого узла. Задержки кэша измеряются в тактах, а задержки физической памяти — в наносекундах.

Если преобразование виртуального адреса в физический (v→p) не найдено в TLB (Translation Lookaside Buffer), нужно 1–2 дополнительных обращения к таблице страниц, чтобы найти нужную запись. Это означает ещё 1 или 2 доступа к DRAM, каждый с задержкой 60–120 нс, прежде чем можно будет прочитать сами данные из физической памяти.

Реальная задержка DRAM должна включать и задержку cache miss. Когда поток приложения ловит cache miss, его запрос на доступ к DRAM попадает в очередь глубиной до 16 уровней. Поэтому итоговая задержка зависит от того, сколько других запросов уже стоят в очереди. В худшем случае задержка может достигать (3 + 16) * DRAM-латентности.

Чтобы уменьшить задержки при доступе к памяти, стоит:

  • Минимизировать TLB-промахи — использовать большие страницы (2 MB или 1 GB) или следить за тем, чтобы доступы шли к соседним адресам.
  • Использовать временные переменные, которые могут поместиться в регистры или быть оптимизированы компилятором прямо в регистры.
  • Согласовывать работу потоков и процессов, размещая их ближе друг к другу (в пределах одного ядра или сокета). Это позволяет им делить кэши и снижать стоимость доступа к данным.

Задержки файлового кэша

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

  1. Освободить память файлового кэша в локальном узле, ведь кэш считается «свободной» памятью.
  2. Выделить память приложения на удалённом узле.

На решение Linux влияет параметр vm.zone_reclaim_node (по умолчанию: 0). Стандартное поведение — не трогать страницы файлового кэша в локальном узле и удовлетворять запросы приложения за счёт памяти удалённого узла, если локальная память закончилась. Это сохраняет низкую задержку для файлового кэша (чтение/запись), но приложение начинает сталкиваться с более высокой задержкой из-за удалённого доступа к памяти.

Если установить vm.zone_reclaim_node=1, ядро включает более агрессивную очистку локальной памяти, выталкивая страницы файлового кэша ради размещения страниц приложения. Это может снизить задержки памяти для приложения, но приведёт к росту задержек работы файлового кэша.

NUMA-политики в Linux

Привязка процессов к процессорам и размещение данных играют важную роль в производительности приложений.

Привязка к процессору — это сопоставление потока/процесса или задачи конкретному CPU.

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

Задача считается «cache hot», если она просыпается менее чем через полмиллисекунды (это регулируется параметром sched_migration_cost_ns). Если дольше — ядро может мигрировать её на другие ядра в том же сокете. Если все локальные ядра заняты, задача может уйти на ядро в другом сокете. Это приводит к задержкам из-за удалённого доступа к памяти.

Linux предоставляет разные инструменты (taskset, numactl, cgroup) и библиотеку libnuma, которые позволяют переопределить стандартное поведение планировщика и политику выделения памяти: закрепить задачу за конкретными ядрами и привязать память к нужному NUMA-узлу.

numactl

Linux-утилита numactl умеет работать с NUMA и позволяет выбирать, на каком узле запускать задачу и из какого узла выделять память. С её помощью администратор может поменять стандартную политику выделения памяти и задать ту, что лучше подходит для конкретного приложения:

  • Bind — память выделяется только из указанных NUMA-узлов.
  • Preferred — задаётся список узлов по приоритету. Если первый узел заполнен, используется следующий.
  • Interleave — память распределяется по очереди между несколькими узлами. Это полезно, если объём выделяемой памяти не помещается в один узел и приложение многопоточное/многопроцессное. Такой режим позволяет использовать несколько узлов и выравнивать задержки между потоками.

Контроллеры ресурсов (cgroups)

Контроллеры ресурсов — это по сути драйверы/модули ядра, которые дают тонкий контроль над системными ресурсами и позволяют логически разделять ресурсы большого сервера.

Например, контроллер cpuset позволяет разделять NUMA-узлы между нагрузками и выделять отдельным приложениям свои CPU и свою память в определённом NUMA-узле.
# Запуск приложения с маской CPU affinity. Поток будет закреплён за ядрами из маски.
taskset -p <cpu mask> <application>

# Можно указать конкретный CPU. Это закрепит приложение за ядром №8.
taskset -c 8 <application>

# Изменение маски афинности у уже запущенного процесса.
taskset -p <cpu mask> <pid>
# numactl

# Запустить myapp на ядрах 2,4,6,8 и выделять память только из локального узла,
# соответствующего месту выполнения.
numactl -l --physcpubind=2,4,6,8 myapp

# Запустить многопоточное myapp с распределением памяти по всем CPU для выровненной латентности.
numactl --interleave=all myapp

# Запуск на CPU, относящихся к узлу 0, с выделением памяти из узлов 0 и 1.
numactl --cpunodebind=0 --membind=0,1 myapp

Иногда ручная установка affinity снижает производительность из-за того, что:

  • планировщик ограничен и не может отправить ожидающий поток на простое или недогруженное ядро;
  • увеличивается время доступа к памяти, если новая память больше не помещается в локальном узле;
  • старая память приложения не будет автоматически мигрировать между узлами, если только не включён параметр numa_balancing.

numa_balancing пытается автоматически перемещать страницы приложения, чтобы держать их ближе к ядру выполнения. Linux также предоставляет утилиту migratepages и API для ручного перемещения страниц между узлами (пример).

Особенности проектирования приложений

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

Даже Docker-контейнеры обычно запускают на больших NUMA-серверах — такие машины позволяют размещать сотни и даже тысячи контейнеров на одном хосте.

У крупных серверов также лучше характеристики RAS (надёжность, доступность, ремонтопригодность), и их проще настраивать под высокую доступность. Ниже — несколько рекомендаций с учётом NUMA:

  • Уменьшайте количество TLB-промахов, используя HugePages (2 MB или 1 GB вместо обычных 4 KB). Oracle, MySQL и другие системы умеют работать с HugePages.
  • Базы данных (Oracle, MySQL) и JVM поддерживают NUMA-режим, и его стоит включать при работе на NUMA-системах — это помогает добиться ровной производительности.
  • Если HugePages не используются, старайтесь, чтобы доступ к памяти шёл к соседним адресам — это активирует аппаратный префетч Intel CPU.
  • Используйте временные переменные, которые могут быть размещены в регистрах CPU или оптимизированы в них компилятором.
  • Координируйте работу потоков и процессов, располагая их ближе друг к другу (в пределах одного ядра или сокета). Так они будут делить кэши.
  • Если приложение использует модель master/slave, и slave-потоки работают с независимыми наборами данных, следите за тем, чтобы память выделялась именно тем потоком, который затем будет к ней обращаться, а не кодом инициализации. Ядро размещает память рядом с потоком, который её выделяет — значит, она окажется местной для worker-потоков.
  • Если приложение многопоточное/многопроцессное и делит один большой пул данных (например, MySQL buffer pool или Oracle SGA), который не помещается в память одного узла, стоит использовать режим --interleave=all (через numactl или libnuma). Так память распределяется между узлами, и задержки будут более ровными для всех потоков или процессов.
  • Если приложение помещается в память одного узла (размер узла можно посмотреть через numactl --hardware), и несколько потоков/процессов читают одинаковые данные, лучшая производительность достигается при их совместном размещении на одном узле. Это делается с помощью политики bind: numactl --cpunodebind=0 --membind=0.
  • Некоторые долго живущие, интенсивно работающие с памятью приложения создают и уничтожают потоки в пуле в зависимости от нагрузки. Новые потоки могут оказаться нелокальными и получать более высокие задержки при доступе к общим данным. В таких случаях стоит периодически отслеживать задержки доступа к памяти и, при необходимости, мигрировать страницы ближе к потокам. В целом миграция страниц между узлами — операция дорогая, но может окупаться в память-интенсивных задачах.
Для ручной миграции страниц стоит использовать migratepages или NUMA API. Автоматическое распределение загрузки (numa_balancing) тоже поддерживается, но может приводить к росту накладных расходов ядра (в зависимости от нагрузки). Обычно лучше вручную двигать страницы через migratepages или NUMA API, как показано в примере.
  • Если приложению нужно навязывать политику памяти (bind, preferred, interleave) уже в момент выделения памяти, стоит использовать опцию numactl --touch. По умолчанию политика применяется при первом доступе к странице, то есть на page fault.

Мониторинг NUMA

numactl и numastat можно использовать, чтобы узнать:

  • количество NUMA-узлов и расстояния между ними;
  • какие CPU и какие области памяти относятся к каждому узлу;
  • какую политику NUMA использует процесс (default, bind, preferred, interleave);
  • статистику по узлам: numa_hit, numa_miss, numa_foreign, interleave_hit, local_node, other_node и т.д.;
  • статистику по распределению памяти для конкретного процесса — аналогично тому, что видно в /proc/<pid>/numa_map.

NUMA API

  • getcpu — определяет, на каком CPU и NUMA-узле работает текущий поток.
  • get_mempolicy — возвращает NUMA-политику процесса и говорит, на каком узле находится указанный адрес.
  • set_mempolicy — устанавливает для процесса NUMA-политику (default, preferred, interleave).
  • move_pages — перемещает отдельные страницы процесса на другой узел.
  • mbind — задаёт политику (bind) для конкретного диапазона памяти или узла.

Intel PMU (Performance Monitoring Unit)

Каждое ядро Intel имеет свой PMU — модуль, который предоставляет массу статистики о задержках и пропускной способности кэша и памяти.

Утилита Linux perf может собирать события вроде: cpu-clock, cycles, task-clock (wall-clock время).

Метрики PMU, такие как CPI (clocks per instruction) или IPC (instructions per cycle), можно использовать для оценки задержек выборки данных из физической памяти.

Библиотека Intel PCM и утилиты (pcm.x, pcm-numa.x, pcm.mem.x) дают дополнительную информацию по uncore-компонентам: статистику L3-кэша, нагрузку на каналы памяти и QPI, данные по NUMA-латентности и пропускной способности памяти. У uncore есть собственный PMU, который позволяет собирать такую статистику.

Аппаратные возможности

Межбанковое (memory) interleaving

Interleaving — это аппаратная функция, которая увеличивает пропускную способность между CPU и памятью, позволяя параллельно обращаться к нескольким банкам памяти.

Проще говоря, interleaving определяет, как физическая память «размазывается» по физическим DIMM-модулям.

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

Interleaving работает, разделяя память системы на два или четыре блока — это 2-way или 4-way interleaving. Каждый блок обслуживается своим набором управляющих линий, которые объединяются на шине памяти. Поэтому операции чтения/записи разных блоков могут перекрываться. Последовательные физические адреса распределяются по разным блокам, чтобы задействовать параллелизм.

Лучше всего interleaving работает в сбалансированной системе. Сервер считается сбалансированным, когда у всех каналов памяти на сокете одинаковый объём. Цель — равномерно заполнять память на всех сокетах, чтобы повысить пропускную способность и снизить задержки.

Dual- и Quad-Rank модули памяти

Термин rank означает 64-битный фрагмент данных. Количество ранков — это число независимых наборов DRAM на одном DIMM, к которым можно обращаться для выборки 64 бит (ширина модуля). DIMM с одним таким фрагментом называют single-rank. Сегодня чаще встречаются quad-rank модули, потому что большее число ранков повышает плотность DIMM’ов.

Увеличение частоты памяти также улучшает пропускную способность. Например:

  • 1066 МГц → вместо 800 МГц даёт примерно +28%;
  • 1333 МГц → вместо 1066 МГц даёт около +9%.
Спецификация DDR4 поддерживает более высокие частоты, чем DDR3. DDR4 обеспечивает 2133–4266 MT/s (миллионов транзакций в секунду), тогда как DDR3 ограничена диапазоном 800–2133 MT/s. DDR4 также потребляет меньше энергии.


Report Page