Go under the hood
Go: Under the Hood

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 выполняет nn обращений к памяти за своё время жизни, из которых доля r∈[0,1]r \in [0,1] приходится на удалённый узел; задержки одного локального и удалённого доступа — tlt_l и trt_r соответственно (типично tr≈1.5 tl∼2 tlt_r \approx 1.5\,t_l \sim 2\,t_l). Тогда суммарная стоимость доступов грубо составляет:

T(r)=n[(1−r) tl+r tr]=n tl[1+r(trtl−1)]. T(r) = n\big[(1-r)\,t_l + r\,t_r\big] = n\,t_l\Big[1 + r\big(\tfrac{t_r}{t_l} - 1\big)\Big].

Идеальная NUMA-локальность — это r→0r \to 0, когда стоимость стремится к n tln\,t_l; худший случай — данные и выполняющее ядро находятся на противоположных концах, r→1r \to 1, и стоимость вырастает вдвое до n trn\,t_r. Каждая межузловая миграция, которую совершает планировщик, — это в точности акт перевода rr некоторой G от значений, близких к нулю, к бо́льшим. Для рабочих нагрузок с интенсивным использованием памяти (большое nn) счёт значителен; для I/O-интенсивных нагрузок, где nn изначально мало, он практически незаметен. Это простое выражение уже предвосхищает вывод приведённого ниже 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. Нигде в исходном коде рантайма нет понятий о узле, сокете или расстоянии через интерконнект, а выбор цели при краже задач — топологически слепое псевдослучайное перечисление:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Порядок выбора целей при краже задач: полностью независим от NUMA-топологии (runtime/proc.go, эскиз)
//
// randomOrder использует "шаг, взаимно простой с GOMAXPROCS", для неповторяющегося псевдослучайного
// перебора всех P: если X взаимно просто с count, то (i + X) % count обходит 0..count-1 ровно по одному разу.
func stealWork(now int64) (gp *g, ...) {
	pp := getg().m.p.ptr()
	const stealTries = 4
	for i := 0; i < stealTries; i++ {
		// Начиная со случайной позиции, перебираем все P с взаимно простым шагом. Два соседних
		// P в allp могут принадлежать разным NUMA-узлам; здесь мы этого не знаем и нас это не волнует.
		for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() {
			p2 := allp[enum.position()]
			if pp == p2 || idlepMask.read(enum.position()) {
				continue
			}
			if gp := runqsteal(pp, p2, /*stealRunNextG=*/...); gp != nil {
				return gp // берём и уходим — независимо от того, на ближнем или дальнем узле p2
			}
		}
	}
	// ...
}

Это осознанный компромисс, а не недосмотр. Следующий раздел показывает, что кто-то всё же детально спроектировал «правильный» путь.

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-осведомлённость и вернётся, то скорее всего в сдержанной форме — «необязательно, сначала ближнее, без нарушения быстрого пути» — а не в виде того масштабного и всестороннего дизайна, разработанного много лет назад.

Дополнительная литература

  1. Dmitry Vyukov. NUMA-aware scheduler for Go. Go design proposal, 2014. https://github.com/golang/go/issues/14406 (полный дизайн группировки P по узлам, кражи «сначала ближнее» и локализации кучи)
  2. Dmitry Vyukov. Scalable Go Scheduler Design Doc. 2012. https://golang.org/s/go11sched (исходный дизайн MPG и кражи задач — отправная точка для обсуждения локальности в этом разделе)
  3. Ulrich Drepper. What Every Programmer Should Know About Memory. Red Hat, 2007. https://www.akkadia.org/drepper/cpumemory.pdf (авторитетное развёрнутое изложение NUMA, когерентности кэша и задержек доступа; раздел 5 посвящён NUMA)
  4. Christoph Lameter. NUMA (Non-Uniform Memory Access): An Overview. ACM Queue, 2013. https://queue.acm.org/detail.cfm?id=2513149 (топология узлов, матрица расстояний и Linux NUMA-интерфейсы)
  5. The Go Authors. runtime/proc.go (stealWork, randomOrder), runtime/mem_linux.go (MADV_HUGEPAGE). https://github.com/golang/go/tree/master/src/runtime (текущее состояние реализации: топологически слепая кража задач и прозрачные огромные страницы)
  6. Linux man-pages project. numactl(8), numa(7), set_mempolicy(2). https://man7.org/linux/man-pages/man8/numactl.8.html (инструменты и системные вызовы для NUMA-привязки вне процесса)
  7. Эта книга: 9.2 Планирование с кражей задач, 9.3 Модель MPG, 12.2 Компоненты аллокатора, 13 Сборка мусора (компромисс ROC).