9.11 NUMA-осведомлённость и будущее планировщика
Планировщик, описанный в предыдущих разделах, опирается на предположение, которое нигде явно не сформулировано: каждый M обращается к памяти с одинаковой скоростью, а стоимость перемещения G между любыми двумя P одинакова. На ноутбуке или однопроцессорном сервере это предположение почти верно. Но стоит запустить программу на крупном многопроцессорном сервере — оно начинает трещать, и чем больше ядер, тем шире трещина. Этот раздел посвящён именно ей: откуда она берётся, почему планировщик Go так долго её игнорировал, какой NUMA-осведомлённый дизайн был тщательно проработан, но так и не выпущен, и как пользователи обходят проблему сегодня.
9.11.1 NUMA: память перестала быть плоской равниной
На ранних SMP-машинах (symmetric multiprocessing) каждый CPU обращался к одному общему блоку памяти через единую шину, и задержка доступа не зависела от того, какое именно ядро делает запрос. Такая «плоская» модель памяти проста, но разваливается по мере роста числа ядер: общая шина становится узким местом, и каждое новое ядро лишь увеличивает конкуренцию за неё. NUMA (non-uniform memory access, неравномерный доступ к памяти) — это ответ, на котором остановилась индустрия. Машина делится на несколько узлов (node): каждый узел — это один процессорный сокет плюс фрагмент локальной памяти, непосредственно к нему подключённой; узлы соединяются между собой межузловым интерконнектом (Intel UPI, AMD Infinity Fabric). CPU, обращающийся к памяти своего узла, идёт кратчайшим путём; обращение к памяти другого узла требует пересечения интерконнекта — одного лишнего перехода или даже нескольких.
flowchart LR
subgraph N0["NUMA-узел 0"]
direction TB
C0["CPU 0 (многоядерный + LLC)"]
M0[("Локальная память 0")]
C0 ---|"локальный: быстро"| M0
end
subgraph N1["NUMA-узел 1"]
direction TB
C1["CPU 1 (многоядерный + LLC)"]
M1[("Локальная память 1")]
C1 ---|"локальный: быстро"| M1
end
C0 -. "удалённый: через интерконнект, медленнее" .-> M1
C1 -. "удалённый: через интерконнект, медленнее" .-> M0
Эта цена вполне реальна. Задержка локального и удалённого доступа типично различается в полтора–два
раза, а пропускная способность между узлами ниже, чем внутри узла; точные цифры зависят от платформы,
и на Linux топологию узлов и матрицу относительных расстояний между ними можно считать командой
numactl --hardware. Ещё тоньше, чем задержка, — стоимость когерентности кэша: когда ядро хочет
записать кэш-линию, кешированную на другом узле, протокол когерентности (семейство MESI) обязан сначала
инвалидировать эту удалённую копию, а «сообщение об инвалидации» само совершает круговой путь через
интерконнект. Поэтому false sharing на NUMA (12.2) обходится
дороже, чем на SMP: за одну и ту же кэш-линию конкурируют ядра двух разных узлов, и каждая запись
оплачивает межузловой round-trip когерентности.
Записав это в виде грубой модели стоимости, можно яснее увидеть значение для планирования. Пусть G выполняет обращений к памяти за своё время жизни, из которых доля приходится на удалённый узел; задержки одного локального и удалённого доступа — и соответственно (типично ). Тогда суммарная стоимость доступов грубо составляет:
Идеальная NUMA-локальность — это , когда стоимость стремится к ; худший случай — данные и выполняющее ядро находятся на противоположных концах, , и стоимость вырастает вдвое до . Каждая межузловая миграция, которую совершает планировщик, — это в точности акт перевода некоторой G от значений, близких к нулю, к бо́льшим. Для рабочих нагрузок с интенсивным использованием памяти (большое ) счёт значителен; для I/O-интенсивных нагрузок, где изначально мало, он практически незаметен. Это простое выражение уже предвосхищает вывод приведённого ниже NUMA-дизайна: его окупаемость критически зависит от характера рабочей нагрузки.
Одним словом, NUMA-машина больше похожа на маленькую распределённую систему, несущую расстояние внутри себя. Для рантайма, работающего поверх неё, «на каком узле находятся данные, на каком узле работает ядро» — это уже не несущественная деталь, а строка, напрямую вписанная в счёт задержек.
9.11.2 Локальность: в рантайме Go она есть, но досталась случайно
Читатель вправе спросить: если NUMA настолько важна, как рантайм Go до сих пор справляется? Ответ в том, что определённая локальность в нём всё же есть, только она достаётся «бесплатно» — как побочный эффект других частей дизайна, а не как целенаправленная оптимизация для NUMA.
Важнейший источник локальности — per-P mcache (9.3, 12.2). Небольшие объекты, аллоцируемые G на некотором P, берутся из локального кэша этого P; пока G продолжает работать на том же P (с высокой вероятностью — на том же ядре) и обращается к только что аллоцированным объектам, эти обращения скорее всего попадают на локальный узел. Кража задач (9.2) с её политикой «сначала выполнять локальную очередь, идти красть только при её опустошении» неявно также поощряет выполнение G на одном и том же P с течением времени. Добавьте к этому аффинность CPU, которую планировщик Linux CFS поддерживает по умолчанию и которая не переносит поток с ядра на ядро без причины, — и цепочка «G остаётся на своём P, P остаётся на своём ядре, ядро обращается к локальной памяти» удерживается большую часть времени.
Но «скорее всего» — это не «гарантировано». Эта локальность нарушается в трёх местах:
- Кража задач не учитывает топологию. При краже G планировщик перебирает все P в псевдослучайном порядке и ворует у первого подходящего, не принимая во внимание, на каком узле находится P-жертва. Как только G украдена с ядра на узле, где живут её данные, на ядро другого узла, G платит за удалённый доступ при каждом последующем обращении к старым данным, а новые объекты аллоцируются уже на новом узле — локальность разрушается.
- Аллокация памяти не привязана к узлу. Когда mcache исчерпан и пополняется из mcentral и mheap (12.2), получаемые страницы приходят из глобальной кучи без каких-либо гарантий принадлежности к узлу текущего P. Арена кучи также не разбита по узлам.
- M, P и узел ничем не связаны. P — лишь логический процессор; какой именно поток ОС является M, на каком ядре он выполняется, какому узлу принадлежит — рантайм ничего из этого не отслеживает.
Сложив три факта, приходим к прямому выводу: планировщик Go не осведомлён о NUMA. Нигде в исходном коде рантайма нет понятий о узле, сокете или расстоянии через интерконнект, а выбор цели при краже задач — топологически слепое псевдослучайное перечисление:
|
|
Это осознанный компромисс, а не недосмотр. Следующий раздел показывает, что кто-то всё же детально спроектировал «правильный» путь.
9.11.3 Проект, завершённый на бумаге, но так и не реализованный
В 2014 году Дмитрий Вьюков представил дизайн NUMA-осведомлённого планировщика (предшественник предложения golang/go#14406). Он не разрушал скелет MPG (9.3), дошедший до наших дней, а надстраивал поверх него топологию узлов:
- Группировать P по NUMA-узлам. Рантайм при запуске зондирует топологию и назначает P узлам, делая «множество P одного узла» явным понятием.
- Красть и пробуждать сначала вблизи. Когда простаивающий P ищет работу, он сначала ворует у P-соседей на том же узле; лишь когда на локальном узле действительно нечего красть, переходит к межузловой краже. При пробуждении спящего M/P аналогично предпочитает выбирать находящийся на том же узле, чтобы связанные G оставались сгруппированными на одном узле.
- Локализовать кучу по узлам. Аллокатор памяти стремится выдавать страницы из локальной памяти узла, на котором находится текущий P, выравнивая арену по узлу — чтобы «аллоцировано на узле, доступ на узле» стало правилом, а не случайностью.
Идея не сложна; по-настоящему трудно её воплотить. Она вводит узел как новое измерение на быстром пути планирования, затрагивая организацию очередей выполнения, политики кражи и пробуждения и даже логику выдачи страниц в аллокаторе памяти (12). Рост глобальной сложности значителен и постоянен: с того момента каждое изменение, касающееся планирования или аллокации, должно рассуждать ещё об одном слое — топологии. А окупаемость критически зависит от рабочей нагрузки. Программы с интенсивным использованием памяти и выраженной узловой аффинностью данных выигрывают очевидно, однако очень многие Go-сервисы I/O-интенсивны, с короткоживущими G и данными, и без того перетекающими между узлами; для них этот слой механизмов добавляет стоимость почти без отдачи. Взвесив оба аргумента, официальный вердикт звучал так: вложение того не стоит; дизайн так и не был включён в план и никогда не был слит.
Это стоит сопоставить с ROC (Request Oriented Collector, 13) из области сборки мусора. Оба подхода теоретически превосходят текущее состояние, оба имеют достаточно зрелые проработки, — и оба в итоге получили решение «не делать». Вместе они обрисовывают устойчивую инженерную позицию команды Go: превосходящий дизайн, требующий значительного и постоянного роста глобальной сложности при окупаемости, которую не разделяет широкий круг пользователей, делает «пока не принимать» самостоятельно ответственным решением. Этот принцип проходит красной нитью через всю книгу; это не консерватизм, а отношение к простоте как к активу, которым нужно управлять и который нужно беречь на долгосрочную перспективу.
9.11.4 На что Go опирается сегодня, и что делают пользователи
Поскольку рантайм не занимается NUMA, проблема делегируется двум окружающим его уровням: операционной системе и пользователю.
На практике рантайм опирается на несколько механизмов, о которых ему «не нужно беспокоиться самому».
Первый — аффинность CPU в планировщике ОС: Linux CFS по умолчанию не мигрирует потоки
произвольно, поэтому M, как правило, остаётся вблизи своего исходного ядра, и неявная локальность,
описанная выше, тем самым сохраняется. Второй — прозрачные огромные страницы (THP): на Linux
рантайм вызывает madvise(MADV_HUGEPAGE) для памяти кучи (runtime/mem_linux.go), позволяя
ядру подкладывать под кучу огромные страницы везде, где возможно, для сокращения промахов TLB; это
не нацелено на NUMA, но попутно снижает фиксированные накладные расходы на обращения к памяти. Третий —
неявная локальность per-P структур, обсуждавшаяся в предыдущем разделе. Три механизма вместе
позволяют Go выдавать приемлемую производительность на многопроцессорных машинах без единой строчки
NUMA-кода.
Пользователи, которым нужна более строгая NUMA-локальность, решают задачу вне процесса, не дожидаясь рантайма:
- Закрепить весь процесс. Использовать
numactl --cpunodebind=0 --membind=0 ./server, чтобы намертво привязать весь процесс к одному узлу: ни CPU, ни память за пределы узла не выходят. Плата — используется лишь часть машины, что подходит сценариям, когда один процесс всё равно не насыщает всю машину. - Один процесс на узел плюс шардирование. Запустить по одному процессу на каждом NUMA-узле,
каждый привязан к своему узлу, установить
GOMAXPROCSравным числу ядер этого узла, а данные и запросы распределить по узлам на уровне приложения. По сути это ручная реализация «группировки P по узлам» на уровне процессов; такая архитектура распространена для насыщения крупных машин при сохранении локальности. - Привязка на уровне потоков. Такие инструменты, как
taskset, позволяют ограничить процесс конкретным набором ядер. Важно учитывать: Go не предоставляет стабильного интерфейса для «привязки горутины к конкретному потоку ОС»;runtime.LockOSThreadсвязывает G с M, но не M с ядром, поэтому тонкозернистая привязка на уровне отдельных горутин в Go неудобна, и на практике возвращаются к грубозернистому «один процесс на узел».
Иными словами, ответственность за NUMA Go целиком передаёт тому, кто развёртывает приложение: рантайм
предоставляет машину, «притворяющуюся однородной», а numactl и шардирование перерисовывают границы
узлов снаружи.
9.11.5 Открытые вопросы
После того как планировщик эволюционировал от GM к GMP (9.3), скелет долгое время оставался
стабильным — красноречивое свидетельство прочности исходного дизайна. Но число ядер продолжает расти:
сотни ядер и несколько узлов на одной машине уже не редкость, и несколько болевых точек при
сильнопараллельных нагрузках остаются актуальными: конкуренция за глобальные структуры (глобальную
очередь, sched.lock), масштабируемость кражи задач и NUMA-локальность этого раздела.
Вернётся ли NUMA-осведомлённость в ядро? Устоявшегося ответа нет. Аргументы за её возвращение: число
ядер и узлов продолжает расти, а доля рабочих нагрузок, которую покрывает неявная локальность,
сокращается. Аргументы за продолжение отсрочки: облачное развёртывание «один контейнер на узел» уже
решает проблему на уровне оркестрации, а контейнерно-осведомлённый GOMAXPROCS (Go 1.25) делает
«выполнение в рамках cgroup-квоты» более плавным — что, в свою очередь, снижает срочность управления
топологией со стороны рантайма. Более вероятная эволюция — по-прежнему серия инкрементальных
улучшений, не затрагивающих скелет, как это делали runnext (1.5), асинхронное вытеснение (1.14) и
контейнерно-осведомлённый GOMAXPROCS (1.25): непрерывное совершенствование в рамках MPG, а не
очередной «качественный скачок». Если NUMA-осведомлённость и вернётся, то скорее всего в сдержанной
форме — «необязательно, сначала ближнее, без нарушения быстрого пути» — а не в виде того масштабного
и всестороннего дизайна, разработанного много лет назад.
Дополнительная литература
- Dmitry Vyukov. NUMA-aware scheduler for Go. Go design proposal, 2014. https://github.com/golang/go/issues/14406 (полный дизайн группировки P по узлам, кражи «сначала ближнее» и локализации кучи)
- Dmitry Vyukov. Scalable Go Scheduler Design Doc. 2012. https://golang.org/s/go11sched (исходный дизайн MPG и кражи задач — отправная точка для обсуждения локальности в этом разделе)
- Ulrich Drepper. What Every Programmer Should Know About Memory. Red Hat, 2007. https://www.akkadia.org/drepper/cpumemory.pdf (авторитетное развёрнутое изложение NUMA, когерентности кэша и задержек доступа; раздел 5 посвящён NUMA)
- Christoph Lameter. NUMA (Non-Uniform Memory Access): An Overview. ACM Queue, 2013. https://queue.acm.org/detail.cfm?id=2513149 (топология узлов, матрица расстояний и Linux NUMA-интерфейсы)
- The Go Authors. runtime/proc.go (
stealWork,randomOrder), runtime/mem_linux.go (MADV_HUGEPAGE). https://github.com/golang/go/tree/master/src/runtime (текущее состояние реализации: топологически слепая кража задач и прозрачные огромные страницы) - Linux man-pages project. numactl(8), numa(7), set_mempolicy(2). https://man7.org/linux/man-pages/man8/numactl.8.html (инструменты и системные вызовы для NUMA-привязки вне процесса)
- Эта книга: 9.2 Планирование с кражей задач, 9.3 Модель MPG, 12.2 Компоненты аллокатора, 13 Сборка мусора (компромисс ROC).