Сходство с процессором: не ешьте суп палочками для еды

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

Настройка соответствия ЦП - это проверка понимания. Понимаю ли я процессы, выполняемые в моей системе? Я понимаю, как выполняются процессы? Понимаю ли я шаблоны выполнения моих процессов? Как эти процессы могут взаимодействовать друг с другом? Как долго мои процессы работают? Распространяют ли процессы общую память? Сколько памяти разделяют мои процессы? Насколько велики мои кеши? Сколько физической памяти существует в моей системе? Сбрасывает ли операционная система TLB при переключении контекста? Разработано ли оборудование, чтобы операционная система могла сохранять состояние TLB при переключении контекста? Самое главное, может ли моя программа быть быстрее?

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

Как вы могли заметить, эти, казалось бы, рациональные мысли, к сожалению, приводят к частичному конфликту. Вы не можете одновременно ограничить выполнение процессов на одном ядре и ограничить их выполнение разными ядрами. Это невозможно. Следовательно, максимальное повышение эффективности двух связанных процессов полностью зависит от состояния среды выполнения, а также будущих схем выполнения вышеупомянутых процессов. Что, если процессы привязаны к вводу-выводу, а именно отправляют данные по сети и, таким образом, ожидают времени сетевого драйвера? Если несколько процессов отправляют данные и ждут информации от сетевого драйвера, вполне возможно, что установка соответствия ЦП малоэффективна. Сетевой драйвер может ограничивать работу системы, что превращает CPU Affinity в отвлекающий маневр.

Проще говоря, использование CPU Affinity без учета всех аспектов будущих шаблонов выполнения системы похоже на использование палок для измельчения без знания того, что вы едите. Если у вас есть тарелка супа, на то, чтобы закончить суп палочками для еды, потребуется очень много времени. Точно так же привязка процессов к процессору - это не то, что вам следует делать без очень тщательного изучения эффектов по вашему выбору.

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

Какова же тогда местность ссылки? Когда у вас есть процесс, существует набор адресов физической памяти, которые потребуются процессу в разумные сроки; Этот набор называется рабочим набором процесса. Учитывая конкретный момент времени, ядро, которое либо ранее обслуживало, либо в настоящее время обслуживает процесс, скорее всего, будет иметь определенное подмножество этих адресов физической памяти, все еще находящихся в памяти. Затем Locality измеряет размер пересечения между рабочим набором и адресами памяти, которые в настоящее время находятся в физической памяти. Если перекресток содержит множество адресов, необходимых для процесса, то показатель местоположения высок. Если на перекрестке есть только несколько необходимых адресов, показатель местоположения невелик.

Предполагая централизованную память для аппаратной системы, показатель локальности физической памяти может не зависеть от ядра; все ядра будут поддерживать эти отношения независимо от конкретного ядра. Однако расположение кеша отличается. В то время как современные системы кэширования имеют многоуровневые кеши, кэш уровня 1 всегда зависит от ядра. Таким образом, локальность кеша становится свойством конкретного процесса, совокупности ядер. Расположение кэша часто более важно, чем расположение памяти. Локальность кэша - это мера пересечения между адресами памяти в кэше в данный момент времени и рабочим набором процесса.

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

Если меры локальности можно найти в памяти, кеш-памяти и TLB, как может закрепление процесса на одном ядре помочь поддерживать локальность?

Большинство современных операционных систем построены на концепции разбивки на страницы по запросу. Запрошенная страница будет загружена в память / кеш во время запроса. Пейджинг по запросу позволяет использовать более простые алгоритмы прогнозирования страниц, которые логически очень сложно реализовать. Однако оборотной стороной является то, что запрос страницы из памяти в кеш требует много времени, а от диска к памяти - тем более. Из-за этого ЦП сделает все возможное, чтобы после первого запроса запрошенная страница уже была в памяти.

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

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

Кеши не имеют этой концепции распределения. В кэше страница либо находится в кэше, либо нет. Если пространство кеша заканчивается, кеш должен заменить кешированную страницу новой запрошенной страницей.

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

Однако современные кеши не позволяют заменять любую страницу в кэше в алгоритме. Когда адрес запрашивается кешем, кеш сначала определяет, какой набор физических адресов следует проверить. Для этого он берет средний раздел битов, называемый индексом, и определяет из них правильный набор. Только в этом наборе выполняются алгоритмы замены. Если кэш является 4-сторонним ассоциативным, это означает, что каждый набор содержит 4 адреса для сравнения. Используя алгоритм аппроксимации наименее недавно использованного, описанный ранее, можно заменить любой из трех других адресов, но не последний использованный в этом наборе.

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

Даже если локальность нашего отключенного процесса уменьшится, это все равно предпочтительнее, чем отсутствие локальности на другом ядре. Если операционная система решит переместить наш процесс на другое ядро, кеш и TLB будут «холодными», поскольку оба они зависят от ядра больше, чем от памяти. «Холодный» просто относится к кеш-памяти, не имеющей рабочего набора целевого процесса. Исправление холодного кеша происходит естественным образом из-за разбиения на страницы по запросу, но требует много времени.

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

Если ограничение привязки процесса к процессору может улучшить локальность, то почему операционная система не связывает процессы с ядрами? В Linux процесс будет иметь сходство с ЦП по умолчанию, что позволит ему работать на любом ядре, выбранном оператором, верно? Почему может быть хорошей идеей разрешить запуск процесса на другом процессоре?

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

Начиная с Linux 2.6, Linux реализует алгоритм, называемый CFS или полностью справедливое планирование для систем с симметричной многопроцессорной обработкой. У каждого ядра будет своя собственная очередь выполнения, которую оно будет поддерживать и переупорядочивать во время переключения контекста. современные очереди выполнения Linux часто построены с использованием красно-черных деревьев, тангенциально отсортированных по приоритету. Linux поддерживает 140 уровней приоритета. Значения 0–99 поддерживаются для процессов в реальном времени. От 100 до 139 - это очереди стандартного уровня. Процессы, поддерживающие приложения реального времени, имеют приоритет над процессами стандартного уровня. Для стандартных процессов, чем ближе значение приоритета к 100, тем быстрее процесс может быть выполнен.

Linux выполняет алгоритм, который определяет как вес процесса, так и загрузку процесса для данного процессора. Вес процесса - это значение, используемое в вычислениях, которое также может быть отображено обратно в значения приоритета процесса. Значения приоритета отображаются в массиве prio_to_weight, что помогает генерировать хорошее значение. Хорошие значения - это прокси для значений приоритета. Чем «приятнее» процесс, тем ниже приоритет. Более высокие приоритеты не так хороши, они отнимают процессорное время и, таким образом, «вредны» для других процессов. Эта строка преобразований необходима, потому что значения веса являются входными данными для вывода вычислений, называемого vruntime. Виртуальная среда выполнения включает время выполнения и вес процесса. Когда время выполнения процесса вычисляется, оно вычисляется заново при каждом переключении контекста и преобразуется в красно-черное дерево. Самый левый узел в дереве выполняется следующим в очереди выполнения.

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

Нагрузка процессора в данный момент - это сумма нагрузок процесса. Эта метрика используется для определения в операционной системе Linux 2.6+, как должна выполняться балансировка нагрузки между ядрами. По этой же причине процессы не привязаны к единственному ядру.

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

Часто вы действительно заботитесь о других процессах, запущенных на сервере, даже если вы этого не знаете. Многие процессы - это демоны операционной системы, работающие в фоновом режиме, чтобы помочь оптимизировать систему. Кроме того, в Linux есть понятие «родственный процессор». Linux использует значение родственного процессора, чтобы установить мягкую привязку ЦП к ЦП. Это позволяет процессу перемещаться, если необходимо, но будет пытаться сохранить процесс на ЦП, если это возможно. Закрепив процесс, вы можете вызвать перемещение других полезных процессов, что может нанести вред системе.

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

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

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

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

Когда количество ядер становится большим, например count ›= 8, эффекты становятся еще мрачнее. Существует множество конструкций аппаратного обеспечения для высокопроизводительных вычислений, которые нацелены на целый ряд проблем, от доступа к памяти до согласованности кеш-памяти и пропускной способности шины. В наших целях мы сосредоточимся на памяти, а для этого я расскажу о конструкции памяти NUMA.

Традиционная неоднородная архитектура памяти берет физическую память и разделяет ее между ядрами. Допустим, у нас есть восемь ядер с номерами 0–7 и 800 кадров памяти. Каждому ядру будет предоставлено 100 кадров физической памяти. Это позволяет процессам, которые используют менее 100 кадров памяти, получать доступ к памяти намного быстрее, чем если бы все ядра должны были перемещаться по одной системной шине для запроса памяти. Если процессам необходим доступ к памяти, к которой не имеет прямого доступа рассматриваемое ядро, запросы могут выполняться по специальным шинам и запрашивать кадры из памяти, подключенной к другим ядрам.

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

Каким же тогда образом могут влиять домены NUMA на соответствие ЦП? Можно создать интересную золотую середину между многопроцессорностью и общей памятью. Если процессы привязаны к отдельным ядрам в одном домене NUMA, это может повысить производительность. В то же время, почему бы просто не установить привязку ЦП процессов к одному домену NUMA? Таким образом, операционная система может попытаться сбалансировать нагрузку процесса в домене NUMA.

Хотя обсуждения CPU Affinity до сих пор были поучительными, давайте рассмотрим более конкретный пример, чтобы исследовать еще несколько вопросов, связанных с CPU Affinity.

Допустим, у вас есть кодовая база, и этот код запускает веб-сайт. Этот веб-сайт построен на JavaScript с серверной частью node express. Используя технологии развертывания, мы можем запустить 9 процессов, завернутых в демон Systemd. 3 из этих процессов являются производителями. Эти производители являются серверами узловой экспресс-доставки, с балансировкой нагрузки на отдельном сервере с использованием Nginx, и они прослушивают входящие запросы через порт 8080. Остальные 6 процессов являются потребителями, сосредоточенными на вычислениях, требующих более интенсивного использования ЦП, но также обменивающихся данными по сети для связи с Кластер Redis и база данных. Предположим, что в настоящее время существует 4 ядра, поддерживающие среду Rhel 7 для этого экземпляра веб-сайта. Какова ценность использования каждого процесса, начиная с ядра 1 и циклического перебора, назначающего их ядру?

Как упоминалось ранее, мы хотим, чтобы процесс оставался на одном ядре, потому что он содержит теплый кеш и, возможно, теплый TLB. Поскольку это веб-сайт, мы знаем, что этот набор процессов будет непрерывно выполняться неделями, если не месяцами. Даже если предположить, что все 9 системных демонов запускаются на ядре 0, многие потоки выполнения будут создавать гораздо более высокую нагрузку для ядра 0 по сравнению с другими. Rhel 7 работает на ядре 3.10, которое включает алгоритм планирования CFS. Это означает, что потоки выполнения, генерирующие числа нагрузки, со временем выровняются по ядрам.

Допустим, в какой-то момент в будущем процессы будут распределены по ядрам 2–3 на каждое ядро. Если нет маски соответствия ЦП, то что мешает процессу покинуть свое текущее ядро ​​и перейти на другое ядро ​​при возможном возврате из переключения контекста. Одна вещь, которую я должен подчеркнуть, это то, что CPU Affinity определяет только то, на каком ядре процессу разрешено выполняться; он ничего не говорит о том, на каком ядре он ДОЛЖЕН работать. Как упоминалось ранее, все ядра Linux 2.6+ предпочитают оставлять процесс на родственном процессоре. Мы знаем, что более теплые кеши обеспечивают лучшую производительность. Разработчики Linux тоже знают это и перемещают процесс только в случае непропорциональной нагрузки ядра с течением времени. При исправлении перегруженного ядра, вероятно, перемещенный процесс восполнит потерю локальности в долгосрочной перспективе за счет более частых блоков выполнения и более поддерживаемой локальности при возврате переключения контекста.

Что произойдет, если какой-то другой процесс начнет выполняться на родственном процессоре наших процессов? Что, если ранее упомянутая база данных также работает на том же сервере. Время от времени требуется резервное копирование этой базы данных, и мы можем сделать это с помощью задания Cron. Допустим, Cron начинает выполняться на ядрах 2 и 3. Этих процессов может быть достаточно, чтобы изменить баланс нагрузки ядер, так что хотя бы один процесс на ядрах 2 и 3 должен перейти на 0 или 1 для идеальной эффективности. Если функция CPU Affinity закрепила процесс за ядром, такого сдвига не могло бы произойти.

Еще одна интересная проблема возникает из-за различий между видами процессов. Вполне вероятно, что процессы, разработанные как производители, используют один и тот же код. Это могло бы позволить более эффективное использование памяти, если бы подобные процессы были размещены в одном ядре. Сходство ЦП лучше всего в ситуациях с сильным совместным использованием памяти, потому что ядро ​​не так хорошо обнаруживает более сильное использование разделяемой памяти. Наш пример все еще может быть не лучшим примером такой ситуации; Лучшим примером может быть многопоточный процесс, который складывает части массива вместе, чтобы в конце вывести сумму. Все, что требует синхронизации или совместного использования большего объема памяти.

Еще одна мысль - в каком процессе находится каждый? На самом деле, по крайней мере, 3 потока-производителя являются тяжелыми для операций ввода-вывода или ЦП, и, вероятно, даже 6 потоков-потребителей являются более тяжелыми для операций ввода-вывода, чем ожидалось. Экспресс-сервер узла прослушивает порт, просто ожидая запроса. Когда запрос действительно поступает, наша примерная система обрабатывает запрос, а затем отправляет задачу в очередь Redis, таким образом передавая ее потребителям, когда потребители будут готовы. Даже эти потребители будут ждать появления нового значения в очереди Redis. Вероятно, что наш пример будет больше зависеть от пропускной способности сетевого драйвера, чем от эффективного использования CPU Affinity.

Что будет, если мы начнем менять параметры? Нравится, если количество производителей / потребителей изменится? Что будет, если количество ядер изменится? Последний особенно интересен из-за доменов NUMA. На самом деле может быть лучше всего в многоядерных системах ограничить все эти экземпляры производителей и потребителей одним доменом NUMA, поскольку они будут иметь приличный объем общего кода выполнения. В сочетании со знанием того, что мы поддерживаем процессы, связанные с операциями ввода-вывода, блокировка этих процессов в одном домене NUMA и предоставление возможности ЦП балансировать нагрузку других процессов по другим доменам NUMA может быть хорошей идеей. Конечно, с доменами NUMA циклический перебор, назначающий процессы произвольного типа разным ядрам, является неэффективной идеей.

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

Нельзя сказать, что использование CPU Affinity - пустая трата времени. Если серверов мало, и вы сделали все возможное, чтобы оптимизировать другие узкие места в своих системах, то подходящее направление для изучения - CPU Affinity. Тем не менее, исследуйте свою систему. Для изучения выполняемых процессов Linux nmon - отличный инструмент, который предоставляет обширную информацию о процессах и памяти ЦП во время выполнения. У MacOs есть несколько опций, но htop может предоставить достойный эквивалент nmon.

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

Я прошу только не есть суп палочками.