Перенос Unity на CoreCLR

Мы продолжаем упорно работать над тем, чтобы пользователи Unity могли использовать новейшие технологии .NET. Как один из членов команды, возглавляющей эту работу, я рад поделиться с Вами дальнейшим прогрессом. Часть работы заключается в том, чтобы заставить существующий код Unity работать со средой выполнения .NET CoreCLR JIT, включая высокопроизводительный, более совершенный и эффективный сборщик мусора (GC).
В этом блоге мы расскажем о недавних изменениях, которые мы внесли, чтобы позволить CoreCLR GC работать рука об руку с нативным кодом движка Unity. Мы начнем с самого главного, а затем перейдем к более техническим деталям.
Распределением памяти в языке C# управляет сборщик мусора. В любой момент, когда требуется выделение памяти, код, выделяющий эту память, может игнорировать ее, когда она больше не используется. Позже GC поможет Вам переработать эту память для использования другим кодом.
В настоящее время Unity использует GC Boehm, который является консервативным, неподвижным GC. Он просканирует все стеки потоков (включая управляемый и родной код) в поисках управляемых объектов для сбора, и как только он выделит управляемый объект, местоположение этого объекта никогда не переместится в памяти.
В .NET используется CoreCLR GC, который представляет собой точный, подвижный GC. Он отслеживает выделенные объекты только в управляемом коде и будет перемещать их в памяти для повышения производительности. Это позволяет CoreCLR GC работать с гораздо меньшими накладными расходами и обеспечивать Вашей игре лучшие характеристики производительности.
Оба GC отлично справляются со своей работой, но они предъявляют разные требования к использующему их коду. Движок Unity и код редактора были разработаны на основе требований Boehm GC, поэтому для использования CoreCLR GC нам необходимо внести ряд изменений в код Unity, в том числе в пользовательские инструменты маршалинга, написанные Unity, - генератор привязок и генератор прокси.
Вы можете думать об управляемом коде как о доме в городе, где за углом есть кофейня, а на соседней улице - продуктовый магазин. Давайте назовем его "Управляемый код Landia". Для разработчиков это отличное место для жизни. Но иногда нам хочется уехать в "Дикие земли родного кода", где код C++ можно найти в его естественной среде обитания.

Путешествуя между ними, Вы можете взять с собой немного памяти, так как на маршевой железной дороге разрешено перевозить чемоданы на руках. В Диких землях Вы, возможно, захотите купить сувенир и привезти его домой.
Удобно, что GC будет послушно следить и перерабатывать любую память, которую Вы, возможно, больше не используете, независимо от того, где она находится. Но GC предстоит еще много работы. Все эти нити и стеки вызовов быстро накапливаются. Прошло уже много времени после посещения Native Code Wildlands, а GC все еще тратит большую часть своего времени, преследуя Вас.
Большая часть работы по переносу движка Unity на CoreCLR заключается в том, чтобы заставить код движка работать с GC, рука об руку.

GC и железная дорога заключили соглашение о том, чтобы не допускать пересечения управляемой памяти с Дикими землями Родового кода. В этом случае у GC будет гораздо меньше работы, что приведет к повышению эффективности. CoreCLR GC работает в этом режиме, точно зная, какие объекты существуют, и имея дело только с управляемым кодом. Это также позволяет ему перемещать объекты в памяти для большей эффективности.
Веселые диаграммы и эмодзи - это мило, но нам нужно действительно внедрить их в производственную кодовую базу, которая развивалась более десяти лет, с тысячами переходов от управляемого кода к нативному и обратно.
Размышляя об этом с точки зрения системного дизайна, нам нужно найти границы. У Unity есть две важные внутренние границы:
Вызовы из управляемого кода в нативный (аналогично p/invoke), с помощью инструмента под названием Генератор привязок
Вызовы из родного кода в управляемый код (аналогично вызову во время выполнения Mono) с помощью инструмента под названием Proxy Generator
Оба этих инструмента генерируют код на C++ и IL, чтобы действовать как железная дорога, перемещающая память между двумя мирами. В течение последнего года разработчики Unity модифицировали эти два генератора кода, чтобы не допустить утечки объектов, выделенных GC, через границу, и обеспечить полезную диагностику, если это все-таки произошло. Мы также находили код, который пытался самостоятельно преодолеть границу управляемый/нативный, и вместо этого мы перемещали его в один из этих генераторов кода.
Конечно, все это происходит в то время, как сотни других разработчиков Unity активно изменяют код движка, предоставляя пользователям новые возможности и исправляя ошибки. Мы хотим модифицировать ракету, пока она находится в полете. Чтобы лучше понять, как нам удалось осуществить этот постепенный переход, давайте углубимся в один из аспектов этой управляемой/нативной границы: System.Object.
Любая память, выделенная GC в .NET, должна быть привязана к объекту типа System.Object. Это базовый класс для всех типов .NET, поэтому он часто является центром памяти, пересекающейся с родным кодом. В коде Unity Engine C++ используется абстракция ScriptingObjectPtr для представления System.Object:
typedef MonoObject* ScriptingBackendNativeObjectPtr;
class ScriptingObjectPtr {
public:
ScriptingObjectPtr(ScriptingBackendNativeObjectPtr target) : m_Target(target) {}
protected:
ScriptingBackendNativeObjectPtr m_Target;
};
Именно так управляемая память оказывается в родном коде: ScriptingBackendNativeObjectPtr - это указатель на память, выделенную GC. Текущий GC Unity обходит все стеки вызовов в родном коде, консервативно ища память, которая может быть ScriptingObjectPtr. Если мы сможем изменить эти экземпляры так, чтобы они больше не были указателями на память, выделенную GC, то мы сможем снизить нагрузку на GC и в конечном итоге перейти на более быстрый CoreCLR GC.
Вместо того, чтобы иметь только одно представление для ScriptingObjectPtr, нам нужно, чтобы он имел одно из трех возможных представлений:
Указатель, выделенный GC (текущее представление)
Ссылка на управляемый стек
System.Runtime.InteropServices.GCHandle
Указатель, выделенный GC, - это временный шаг к удалению всех небезопасных для GC использований. Это позволяет ScriptingObjectPtr продолжать работать так, как он работает сейчас. Мы планируем убрать этот вариант использования, как только весь код Unity станет безопасным для CoreCLR GC.
Ссылка на управляемый стек - это эффективный способ представить перенаправление на управляемый объект в случае, когда значение передается из управляемого в родной. В родной код передается адрес переменной-указателя, выделенной GC (а не сам указатель, выделенный GC). Это безопасно для GC, поскольку сам локальный адрес не перемещается GC, а управляемый объект остается живым в стеке вызовов в управляемом коде. Этот подход вдохновлен аналогичной техникой, используемой в среде выполнения CoreCLR.
GCHandle служит сильным указателем на управляемый объект, гарантируя, что объект не будет собран GC. Если во время отпуска в Диких землях Вы случайно оставили в Управляемом коде Ландии какую-то память, GC знает, что Вы хотите сохранить ее до своего возвращения. Это похоже на эталонный случай управляемого стека, но требует явного управления временем жизни. Возникают дополнительные накладные расходы, связанные с созданием и разрушением GCHandle. Это означает, что мы хотим использовать это представление только там, где оно абсолютно необходимо.
Для этого используется новый тип, ScriptingReferenceWrapper, который заменяет ScriptingBackendNativeObjectPtr.
struct ScriptingReferenceWrapper
{
// Various constructors elided for brevity
void* GetGCUnsafePtr() const;
static ScriptingReferenceWrapper FromRawPtr(void* ptr);
private:
// Assumption: all pointers are 8 byte aligned.
// This leaves 2 bits for tracking.
// One bit is already in use by GCHandle
// Bits
// 0 - reserved for GC Handles.
// 1 - 0 - object reference
// - 1 - gc handle
// 2 - 0 - this is a managed object pointer
// - 1 - this is a GCHandle or object reference
// 0b00 - object pointer
// 0b01 - object reference
// 0b1_ - gc handle; lowest bit is implementation specific
bool IsPointer() const {
return (((uintptr_t)value) & 0b11) == 0b00; }
bool IsRef() const {
return (((uintptr_t)value) & 0b11) == 0b01; }
bool IsHandle() const {
return (((uintptr_t)value) & 0b10) == 0b10; }
uintptr_t value;
};
Я удалил здесь множество конструкторов или операторов присваивания - они используются для обеспечения надлежащего управления временем жизни внутреннего ресурса.
Обратите внимание на размер этого типа - он состоит только из одного значения uintptr_t, которое имеет тот же размер, что и указатель, то есть ScriptingReferenceWrapper имеет тот же размер, что и ScriptingBackendNativeObjectPtr. Затем мы можем произвести замену 1:1 без кода, при этом ScriptingObjectPtr будет знать разницу.
Ключевым здесь является требование выравнивания по 4 байтам, упомянутое в комментарии к коду.Выделение памяти, выполняемое в языке C#, управляется сборщиком мусора. С учетом этого мы можем повторно использовать два бита этого значения, чтобы указать, какое из трех представлений используется. Методы GetGCUnsafePtr и FromRawPtr обеспечивают временную совместимость для представления указателей, выделенных GC, пока мы переводим код Unity.
В идеальном мире абстракция ScriptingObjectPtr была бы ненужной - управляемая память никогда не появлялась бы в родном коде. Но есть места, где такая возможность полезна, поэтому мы рассчитываем завершить работу над безопасностью GC в движке, сохранив случаи управляемых ссылок на стек и GCHandle и полностью удалив случаи указателей, выделенных GC.
Именно здесь вступает в силу соглашение между GC и генераторами кода. Теперь, когда все три подсистемы могут понимать возможные представления ScriptingObjectPtr, наша команда постепенно заменяет их использование в коде движка. Мы можем удалить ScriptingObjectPtr там, где он не нужен, и использовать наиболее эффективное представление там, где он нужен. Пока каждое использование меняется от конца к концу, различные представления могут жить бок о бок, и ракета продолжает летать.
Благодаря полностью безопасному механизму GC мы можем включить GC CoreCLR и гарантировать, что ему нужно будет искать память для повторного использования только в Managed Code Landia, а это значит, что он будет выполнять гораздо меньше работы и оставлять больше времени для выполнения Вашего кода в каждом кадре.
Чтобы узнать больше о переходе Unity на CoreCLR, посетите наши форумы или посмотрите Unite 2023, где мы расскажем о дорожной карте продуктов Unity. Вы также можете связаться со мной напрямую в X по адресу @petersonjm1. Обязательно следите за новыми техническими блогами от других разработчиков Unity в рамках продолжающейся серииTech from the Trenches.