Улучшение шкалирования производительности системы заданий в 2022.2 - часть 1: Общие сведения и API

KEVIN MACAULAY VACHERESSE / UNITY TECHNOLOGIESLead Engineer
Feb 24, 2023|13 Мин
Улучшение шкалирования производительности системы заданий в 2022.2 - часть 1: Общие сведения и API
Эта веб-страница была переведена с помощью машинного перевода для вашего удобства. Мы не можем гарантировать точность или надежность переведенного контента. Если у вас есть вопросы о точности переведенного контента, обращайтесь к официальной английской версии веб-страницы.

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

В первой части мы расскажем о параллельном программировании и API системы заданий. Если Вы уже знакомы с параллелизмом, не стесняйтесь и переходите ко второй части.

Общие сведения о параллелизме

В выпуске 2017.3 был добавлен публичный API на C# для внутренней системы заданий Unity на C++, позволяющий пользователям писать небольшие функции, называемые "заданиями", которые выполняются асинхронно. Смысл использования заданий вместо обычных функций заключается в том, чтобы предоставить API, позволяющий легко, безопасно и эффективно разрешить коду, который в противном случае выполнялся бы в главном потоке, вместо этого выполняться в рабочих потоках заданий, в идеале - параллельно. Это помогает сократить общее количество времени, которое требуется главному потоку для завершения симуляции игры. Использование системы заданий для работы процессора может обеспечить значительный прирост производительности и позволить производительности Вашей игры естественным образом масштабироваться по мере улучшения аппаратного обеспечения, на котором работает игра.

Если рассматривать вычисления как ограниченный ресурс, то одно ядро процессора может выполнить только такой объем вычислительной "работы" за определенный период времени. Например, если однопоточная игра требует, чтобы симуляция Update() занимала не более 16 мс, но в настоящее время она занимает 24 мс, значит, у процессора слишком много работы - необходимо больше времени. Чтобы достичь цели в 16 мс, есть только два варианта: заставить процессор работать быстрее (например, повысить минимальные характеристики для Вашей игры - обычно это не лучший вариант) или выполнять меньше работы.

void Update()
{
    // <lots of simulation logic...> 
}
Функция Update(), выполняющаяся в течение 24 мс в основном потоке
Функция Update(), выполняющаяся в течение 24 мс в основном потоке

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

Что, если вместо того, чтобы исключить работу, мы передадим ее другому ядру процессора? В настоящее время большинство процессоров являются многоядерными, что означает, что доступная однопоточная вычислительная мощность может быть умножена на количество ядер, которыми оснащен процессор. Если бы мы могли волшебным образом и безопасно разделить всю работу, выполняемую в настоящее время функцией Update(), между двумя ядрами процессора, то работа Update() длительностью 24 мс могла бы выполняться двумя одновременными фрагментами по 12 мс. В результате мы получим намного меньше целевого значения в 16 мс. Более того, если бы мы могли разделить работу на четыре параллельных фрагмента и запустить их на четырех ядрах, то Update() заняло бы всего 6 мс!

Такой тип разделения работы и выполнения ее на всех доступных ядрах известен как масштабирование производительности. Если Вы добавите больше ядер, то в идеале сможете выполнять больше работы параллельно, сократив время выполнения Update() без изменения кода.

void Update()
{
    // Some magic has split our logic into 4 equal parts
    // that can run in parallel. Wowee!
    PartialUpdateA();
    PartialUpdateB();
    PartialUpdateC();
    PartialUpdateD();
}
Update() был разделен на четыре частичных обновления, каждое из которых выполняется в отдельном потоке

Увы, это фантазия. Ничто не сможет разделить функцию Update() на части и запустить их на отдельных ядрах без посторонней помощи. Даже если мы перейдем на процессор со 128 ядрами, вышеописанное обновление() все равно займет 24 мс, при условии, что оба процессора имеют одинаковую тактовую частоту. Какая пустая трата потенциала! Как же писать приложения, чтобы использовать все доступные ядра процессора и увеличить параллелизм?

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

Однако многопоточное программирование сопряжено с целым рядом сложностей. В приведенном выше магическом сценарии функция Update() была равномерно разделена на четыре частичных обновления. Но в реальности Вы, скорее всего, не сможете сделать что-то настолько простое. Поскольку потоки будут работать одновременно, Вам нужно быть осторожным, когда они читают и записывают одни и те же данные в одно и то же время, чтобы не испортить вычисления друг друга.

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

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

void PartialUpdateA()
{
    // Write to m_InputBuffer with the controller state
    ReadControllerState(out m_InputBuffer);
}

void PartialUpdateB()
{
    // Read m_InputBuffer and start a player 
    // jump animation if the jump button was pressed
    if(m_InputBuffer.IsJumpPressed())
        PlayerJump();
}

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

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

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

API системы заданий

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

Система работы состоит из нескольких основных компонентов:

  • Работа
  • Ручки для работы
  • Планировщик заданий
public struct MyJob : IJob
{
    public NativeArray<int> Data;
    public void Execute()
    {
        // Do some work using our Data member
    }
}

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

var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();

После того, как экземпляр задания создан, его нужно запланировать в системе заданий. Это делается с помощью метода .Schedule(), добавленного ко всем типам заданий с помощью механизма расширения C#. Чтобы идентифицировать и отслеживать запланированное задание, предоставляется JobHandle.

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

var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();

// WritingJob writes to someNativeArray so make sure it runs
// after MyJob is done (since it uses someNativeArray as well). 
// That is, declare writingJob to have a dependency on myJob by 
// passing in the JobHandle for MyJob to writingJob.Schedule
var writingJob = new WritingJob() { Data = someNativeArray };
var writingJobHandle = writingJob.Schedule(jobHandle);

Наконец, по мере планирования заданий планировщик заданий отвечает за отслеживание запланированных заданий (сопоставляя JobHandles с запланированными экземплярами заданий) и за то, чтобы задания начали выполняться как можно быстрее. То, как это делается, очень важно, поскольку дизайн и модели использования системы заданий могут потенциально конфликтовать неочевидным образом, что приведет к накладным расходам, которые съедят выигрыш в производительности многопоточного программирования. По мере того, как пользователи начали внедрять систему заданий на C#, мы стали замечать сценарии, в которых накладные расходы на систему заданий были выше, чем нам хотелось бы, что привело к усовершенствованию внутренней реализации системы заданий Unity в 2022.2 Tech Stream.

Следите за новостями во второй части, в которой мы рассмотрим, откуда берутся накладные расходы в системе заданий C# и как они были уменьшены в Unity 2022.2.

Если у Вас есть вопросы или Вы хотите узнать больше, заходите к нам на форум C# Job System. Вы также можете связаться со мной напрямую через Unity Discord по имени пользователя @Antifreeze#2763. Обязательно следите за новыми техническими блогами от других разработчиков Unity в рамках продолжающейсясерииTech from the Trenches.