Дневник разработчиков. Часть 1

Старт серии дневников разработчиков

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

Дисклеймер

Сразу уточню, что данный дневник разработчиков содержит лишь частичное сравнение производительности EdenSpark и языка Daslang с Unity и языка C#.

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

Описание бенчмарка

В качестве бенчмарка был взят следующий стресс-тест для проверки эффективности дерева иерархии:

  • Каждый кадр каждая нода дерева может создать еще одну дочернюю ноду, пока глубина не превысит 7 или общее число нод не превысит 16000
  • Каждый объект использует свой уникальный цвет в зависимости от глубины в иерархии (черный у корня, желтый у листьев)

Данный стресс-тест проверяет следующий функционал игрового движка:

  • Создание новых нод
  • Работа с особенно ветвистым деревом иерархии
  • Рендер большого числа однотипных объектов, но с разными per instance data.
Визуализация стресс-теста в EdenSpark.
Визуализация стресс-теста в EdenSpark.

Параметры железа и софта

И Unity и EdenSpark будет использовать DX12 в качестве графического драйвера.

Используется Unity 6.2.

CPU: AMD Ryzen 7 6800H with Radeon Graphics

Base speed: 3,20 GHz Sockets: 1 Cores: 8 Logical processors: 16 Virtualization: Enabled L1 cache: 512 KB L2 cache: 4,0 MB L3 cache: 16,0 MB

Utilization: 7% Speed: 2,81 GHz Up time: 2:03:13:04 Processes: 299 Threads: 5500 Handles: 141949

GPU:  NVIDIA GeForce RTX 3060 Laptop GPU

Driver version: 31.0.15.4692 Driver date: 30.04.2024 DirectX version: 12 (FL 12.2) Physical location: PCI bus 1, device 0, function 0

Utilization: 2% Dedicated GPU memory: 2,1/6,0 GB Shared GPU memory: 0,3/15,6 GB GPU Memory: 2,4/21,6 GB

 Замеры производительности EdenSpark

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

  • Обновление пользовательских компонент и кода в отдельном треде занимает 19.66 мс
  • Обновление всех объектов (act) и диспатч команд отрисовки 2.88 мс
  • Непосредственно отрисовка на GPU в сумме 8.94 мс

Возьмем эти данные за точку отсчета и посмотрим что будет в Unity Engine!

Если вам интересно поизучать профайл самостоятельно, то вы можете скачать daProfiler здесь: https://github.com/GaijinEntertainment/daProfiler.

И непосредственно сохраненный профайл по кнопке ниже

download scene_perf_test.dap

Unity Engine

Поскольку я не имею большого опыта работы с Unity Engine я попросил Copilot переписать код нашего стресс-теста с языка Daslang на Unity C#.

Очень важно отметить, что я взял шаблон с BuiltIn рендер пайплайном, это может значительно повлиять на результаты измерений! Получилось перенести код без ошибок компиляции и с минимум дополнительных действий. Пришлось только выключить Input System Package и сделать парочку нод для сферы, пола и текста.

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

Vector3 Approach(Vector3 current, Vector3 target, float deltaTime, float speed) { return Vector3.MoveTowards(current, target, speed * deltaTime); }

 Я заменил его на следующий и Unity стала выдавать тот же самый результат (визуально), что и EdenSpark 

Vector3 Approach(Vector3 current, Vector3 target, float deltaTime, float speed) { return Vector3.Lerp(current, target, 1.0f - Mathf.Exp(-Mathf.Log(2) * deltaTime / 0.5f)); }
Визуализация стресс-теста в Unity
Визуализация стресс-теста в Unity

Профилируем Unity 

Unity показывает следующий перф:

  • Обновление пользовательского кода 52.66 мс2.68 раз медленнее Eden'а)
  • CPU часть отрисовки 126-127 мс (в Main/Render thread) (В 44 раза медленнее Eden'a)
  • GPU - 147 мс16,4 раз медленнее Eden'a)

Оптимизация Unity

Достаточно быстро стало понятно, что Unity не смог задействовать instancing объектов. Мое внимание привлек этот кусок кода, прошу Copilot его исправить.

Renderer renderer = node.GetComponent<Renderer>(); if (renderer != null) { Material mat = new Material(renderer.material); mat.color = color; renderer.material = mat; }

Мне был предложен следующий подход на основе MaterialPropertyBlock. Мне он понравился и я заменил необходимые куски кода, а также поменял галку в материале "Enable GPU Instancing". 

public class TreePerformanceTest : MonoBehaviour { [Header("Optimization")] public Material sharedMaterial; // One material for sharing private MaterialPropertyBlock materialPropertyBlock; private static readonly int ColorPropertyID = Shader.PropertyToID("_Color"); void Awake() { // Create one MaterialPropertyBlock for reusing materialPropertyBlock = new MaterialPropertyBlock(); InitializeInputActions(); } GameObject AddObject(float scale, Vector3 position, Color color, Transform parent = null) { GameObject node = Instantiate(spherePrefab); node.transform.position = position; node.transform.localScale = Vector3.one * scale; if (parent != null) { node.transform.SetParent(parent); } Renderer renderer = node.GetComponent<Renderer>(); if (renderer != null && sharedMaterial != null) { // Use shared material renderer.material = sharedMaterial; // Set actual color in MaterialPropertyBlock materialPropertyBlock.SetColor(ColorPropertyID, color); renderer.SetPropertyBlock(materialPropertyBlock); } return node; } }

Профилируем Unity с включенным инстансингом

Обновленные замеры:

  • Обновление пользовательского кода 44.38 мс, а было только что 52.66 мс. Возможно ошибка замеров, или просто при низком фпс замеры производительности не стабильны.
  • CPU часть отрисовки 50 мс (Main thread) - 74 мс (Render thread) против 127 мс ранее
  • GPU - 67.8 мс против 147 мс ранее.

Видим, что отрисовка стала в два раза быстрее, но общий фпс остается все еще очень низким. Однако профилирование GPU, согласно Unity, значительно нагружает CPU и делает CPU профайл невалидным! Давайте посмотрим, что будет если выключить сбор GPU данных.

Новый профайл уже показывает значительно лучшие результаты:

Замер производительности с выключенным GPU profile
Замер производительности с выключенным GPU profile

Обновленные замеры:

  • Обновление пользовательского кода 45.94 мс, в прошлом профайле 44.38 мс. Видим что этот код выполняется достаточно стабильно 
  • CPU часть отрисовки 21.12 мс (Main thread) - 21.84 мс (Render thread) против 50 мс и 74 мс с GPU профилированием
  • GPU - неизвестно

Кажется, что теперь мы профилируем что-то адекватное.

Я бы очень хотел сделать вывод, что CPU часть рендера в EdenSpark в 7.3 раз быстрее, чем в Unity (21.12 мс / 2.88 мс), но лучше сделать сравнение нашего рендера с Universal Render Pipeline. Так что оставлю сравнение рендера для следующих частей дневника. А сейчас сконцентрируюсь на сравнении пользовательского кода.

Unity Release Build

Известно, что Unity редактор добавляет значительный оверхед на выполнение пользовательского кода. Поэтому для чистоты эксперимента соберем релизную версию нашего стресс-теста. Оставим Dev Build галку включенной, чтобы подключаться профайлером.

После успешного билда я сделал замеры и убрал все не относящееся к пользовательскому коду, чтобы не засорять экран:

Профайл CPU релизной сборки
Профайл CPU релизной сборки

Получаем 38.8 мс в Release Unity против 19.66 мс в EdenSpark.

Если присмотреться, то каждый фрейм делится на 2 части:

  • обновление дерева - рекурсивный обход большого дерева с созданием большого числа нод
  • обновление позиции каждого объекта через его on_update/OnUpdate коллбек

В Eden (кадр 19.66 мс):

  • обновление дерева - 16.8 мс (85% от кадра)
  • обновление позиций нод - 1.9 мс (9.6% от кадра)
  • отправка актуальных трансформов объектов в render тред 0.8 мс. Но мы не будем сравнивать эту рендер related часть cpu работы.

В Unity (кадр 38.8 мс в 1.97 раза медленнее Eden'а)

  • обновление дерева - 30.7 мс (79% от кадра). В 1.82 раза медленнее Eden'а
  • обновление позиций нод - 8 мс (20.6% от кадра). В 4.21 раза медленнее Eden'а

Unity AoT(IL2CPP) Release Build

Но давайте дадим еще больше форы Unity и воспользуемся IL2CPP, чтобы сделать aot (ahead of time) компиляцию C# в С++.

EdenSpark выполняет Daslang код в интерпретации (JiT PC-only, AoT компиляция доступна в standalone сборке EdenSpark), так что мы даем значительную фору Unity!

Настройки AoT компиляции
Настройки AoT компиляции
Определенно стало быстрее, но ускорение всего пара миллисекунд 38.8 ms -> 36.6 ms
Определенно стало быстрее, но ускорение всего пара миллисекунд 38.8 ms -> 36.6 ms

Обычный Release в Unity 38.8 мс1.97 раза медленнее Eden'а)

IL2CPP Release в Unity 36.6 мс1.86 раза медленнее Eden'а)

Итог

Отрисовка большого числа однотипных объектов (16к сфер) дается Unity с трудом. Скорость работы BuiltIn render pipeline на стороне CPU в 7.3 раза медленнее по сравнению с той же работой у EdenSpark.

Выполнение таких вещей как инстанциирование объектов, работа связанная с итерацией по иерархии сцены, вызов OnUpdate и изменение трансформов нод в Unity получается в 1.86 раза медленнее .

В EdenSpark используется язык Daslang в режиме интерпретации и он оказывается практически в 2 раза быстрее C# в AoT исполнении.