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

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

Как маска привязки удерживает поток на выбранных ядрах

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

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

#define _GNU_SOURCE
#include <sched.h>

cpu_set_t set;
CPU_ZERO(&set);          /* очищаем маску */
CPU_SET(3, &set);        /* разрешаем только ядро номер 3 */

if (sched_setaffinity(0, sizeof(set), &set) == -1) {
    perror("sched_setaffinity");   /* 0 означает текущий поток */
}

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

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

Почему одной привязки процессора недостаточно для близкой памяти

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

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

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

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

Какой выигрыш даёт согласование процессора и памяти на реальных задачах

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

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

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

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

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

Где у привязки острые грани и как их обойти

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

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

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

Стоит упомянуть и более крупный рычаг, чем привязка отдельных потоков. Ядро позволяет на этапе загрузки изъять часть ядер из ведения обычного планировщика, чтобы он вовсе не ставил на них фоновые задачи. Тогда изъятые ядра остаются чистым полем, и привязанный к ним критичный поток не делит их вообще ни с кем из обычной нагрузки. Этот приём сочетают с привязкой прерываний на оставшиеся ядра, добиваясь почти полной изоляции горячего пути от любой посторонней работы. Для систем, где доли процента джиттера решают всё, такая выделенность ядер под критичную задачу даёт результат, недостижимый одной лишь маской привязки поверх общей нагрузки.

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

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