> Что такое Actors
Не раз слышал, как хорош шаблон ECS, и что Jobs и Burst из библиотеки Unity — решение всех проблем с быстродействием. Чтобы не добавлять каждый раз слово «наверное» и «может», рассуждая о быстродействии кода, решил проверить всё лично.
Моей целью было непредвзято разобраться, насколько это быстрый инструмент разработки, и стоит ли использовать распараллеливание для вычислений. И если стоит, то лучше использовать Unity.Jobs или System.Threading? Заодно выяснил, какова польза от ECS в реальных задачах.
Условия тестов (приближены к реальным игровым задачам):
- Процессор i5 2500 (4 ядра без гипертрейдинга) и Unity2019.3.0f1
- Каждый GameObject каждый кадр…
А) перемещается по квадратичной кривой Безье в течение 10 минут от начальной точки до конечной.
B) рассчитывает свой квадратный коллайдер (box 10fх10f), где используется math.sincos, math.asin, math.sqrt (одинаковые, достаточно сложные расчеты для всех тестов).
- Объекты до замеров FPS выставляются в случайных позициях в рамках зоны 720fх1280f и двигаются к случайной точке в этой зоне.
- Всё тестируется в релизе в IL2CPP на PC
- Тесты записываются спустя несколько секунд после запуска, чтобы все стартовые предварительные расчеты и включение систем Unity не влияли на FPS. По этим же причинам показан только код апдейта каждого кадра.
- Объекты не имеют визуального отображения в релизе, чтобы работа рендера не влияла на FPS.
Позиции тестирования и код апдейта
- MonoBehaviour sequential (условная маркировка).
На объект «повешен» скрипт MonoBehaviour, в апдейте которого происходит расчет позиции, коллайдера и перемещение самого себя.
Код апдейтаvoid Update() { // расчет новой точки var velocityToOneFrame = velocityToOneSecond * Time.deltaTime; observedDistance += velocityToOneFrame; var t = observedDistance / distanceFull; if (t > 1f) t = 1f; var newPos = t.CalculateBesierPos(posToMove.c0, posToMove.c2,posToMove.c1); // Обновление коллайдера obj.properties.c0 = newPos; var posAndSize = new float2x2 { c0 = newPos, c1 = obj.collBox.posAndSize.c1 }; obj.collBox = obj.entity.NewCollBox(posAndSize, new float2(10f, 10f), obj.rotation.ToEulerAnglesZ()); // перемещение на новую позицию tr.position = new Vector3(newPos.x, newPos.y); #if UNITY_EDITOR DebugDrowBox(obj.collBox, Color.blue, Time.deltaTime); #endif }
- Actors sequential на компонент-классах без распараллеливания.
Код апдейтаpublic void Tick(float delta) { foreach (ent entity in groupMoveBezier) { var cMoveBezier = entity.ComponentMoveBezier_noJob(); var cObject = entity.ComponentObject(); ref var obj = ref cObject.obj; // расчет новой точки var velocityToOneFrame = cMoveBezier.velocityToOneSecond * delta; cMoveBezier.observedDistance += velocityToOneFrame; var t = cMoveBezier.observedDistance / cMoveBezier.distanceFull; if (t > 1f) t = 1f; var newPos = t.CalculateBesierPos(cMoveBezier.posToMove.c0, cMoveBezier.posToMove.c2,cMoveBezier.posToMove.c1); // Обновление коллайдера obj.properties.c0 = newPos; var posAndSize = new float2x2 { c0 = newPos, c1 = obj.collBox.posAndSize.c1 }; obj.collBox = obj.entity.NewCollBox(posAndSize, new float2(10f, 10f), obj.rotation.ToEulerAnglesZ()); // перемещение на новую позицию cObject.tr.position = new Vector3(newPos.x, newPos.y, 0); #if UNITY_EDITOR DebugDrowBox(obj.collBox, Color.blue, Time.deltaTime); #endif } }
- Actors + Jobs + Burst
Расчет и перемещение в Jobs из библиотек Unity.Jobs 0.1.1, Unity.Burst 1.1.2.
Safety Checks — off
Editor Attaching — off
JobsDebbuger — off
Для нормальной работы IJobParallelForTransform все перемещаемые объекты имеют «объекта-родителя» (до 255 штук объектов в каждом «родителе» по рекомендации для максимальной производительности).
Код апдейтаpublic void Tick(float delta) { if (index <= 0) return; handlePositionUpdate.Complete(); #if UNITY_EDITOR for (var i = 0; i < index; i++) { var obj = nObj[i]; DebugDrowBox(obj.collBox, Color.blue, Time.deltaTime); } #endif jobPositionUpdate.nSetMove = nSetMove; jobPositionUpdate.nObj = nObj; jobPositionUpdate.deltaTime = delta; handlePositionUpdate = jobPositionUpdate.Schedule(transformsAccessArray); } } [BurstCompile] struct JobPositionUpdate : IJobParallelForTransform { public NativeArray<SetMove> nSetMove; public NativeArray<Obj> nObj; [Unity.Collections.ReadOnly] public float deltaTime; public void Execute(int index, TransformAccess transform) { var setMove = nSetMove[index]; var velocityToOneFrame = nSetMove[index].velocityToOneSecond * deltaTime; // расчет новой точки setMove.observedDistance += velocityToOneFrame; var t = setMove.observedDistance / setMove.distanceFull; if (t > 1f) t = 1f; var newPos = t.CalculateBesierPos(setMove.posToMove.c0, setMove.posToMove.c2,setMove.posToMove.c1); nSetMove[index] = setMove; // Обновление коллайдера var obj = nObj[index]; obj.properties.c0 = newPos; var posAndSize = new float2x2 { c0 = newPos, c1 = obj.collBox.posAndSize.c1 }; obj.collBox = obj.entity.NewCollBox(posAndSize, new float2(10f, 10f), obj.rotation.ToEulerAnglesZ()); nObj[index] = obj; // перемещение на новую позицию transform.position = (Vector2) newPos; } } public struct SetMove { public float2x3 posToMove; public float distanceFull; public float velocityToOneSecond; public float observedDistance; }
- Actors + Parallel.For
Вместо обычного цикла For по группе перемещающихся сущностей, используется Parallel.For из библиотеки System.Threading.Tasks. Он производит расчеты новой позиции и коллайдера в параллельных потоках. Перемещение объекта осуществляется в соседней группе.
Код апдейтаpublic void Tick(float delta) { Parallel.For(0, groupMoveBezier.length, i => { ref var entity = ref groupMoveBezier[i]; var cMoveBezier = entity.ComponentMoveBezier_actorsParallel(); ref var obj = ref entity.ComponentObject().obj; // расчет новой точки var velocityToOneFrame = cMoveBezier.velocityToOneSecond * delta; cMoveBezier.observedDistance += velocityToOneFrame; var t = cMoveBezier.observedDistance / cMoveBezier.distanceFull; if (t > 1f) t = 1f; var newPos = t.CalculateBesierPos(cMoveBezier.posToMove.c0, cMoveBezier.posToMove.c2,cMoveBezier.posToMove.c1); // обновление коллайдера obj.properties.c0 = newPos; var posAndSize = new float2x2 { c0 = newPos, c1 = obj.collBox1.posAndSize.c1 }; obj.collBox1 = obj.entity.NewCollBox(posAndSize, new float2(10f, 10f), obj.rotation.ToEulerAnglesZ()); }); // перемещение на новую позицию foreach (ent entity1 in groupMoveBezier) { var cObject = entity1.ComponentObject(); cObject.tr.position = new Vector3(cObject.obj.properties.c0.x, cObject.obj.properties.c0.y, 0); #if UNITY_EDITOR DebugDrowBox(cObject.obj.collBox1, Color.blue, Time.deltaTime); #endif } }
Тестирование с перемещением[1]:
500 объектов
(картинка из редактора около текста с FPS, чтобы показать что там визуально происходит)
- MonoBehaviour sequential:
- Actors sequential:
- Actors + Jobs + Burst:
- Actors + Parallel.For:
5000 объектов
- MonoBehaviour sequential:
- Actors sequential:
- Actors + Jobs + Burst:
- Actors + Parallel.For:
50000 объектов
- MonoBehaviour sequential:
- Actors sequential:
- Actors + Jobs + Burst:
- Actors + Parallel.For:
Actors + Threaded (встроенное в Actors распараллеливание на System.Threading)
В Actors есть возможность держать все компоненты игры в структурах вместо классов. Это геморройнее с точки зрения написания кода, зато при таких условиях программа в большей степени работает со стеком, а не с управляемой кучей, что заметно сказывается на скорости ее работы.
public void Tick(float delta)
{
groupMoveBezier.Execute(delta);
for (int i = 0; i < groupMoveBezier.length; i++)
{
ref var cObject = ref groupMoveBezier.entities[i].ComponentObject();
cObject.tr.position = new Vector3(cObject.obj.properties.c0.x, cObject.obj.properties.c0.y, 0);
#if UNITY_EDITOR
DebugDrowBox(cObject.obj.collBox, Color.blue, Time.deltaTime);
#endif
}
}
static void HandleCalculation(SegmentGroup segment)
{
for (int i = segment.indexFrom; i < segment.indexTo; i++)
{
ref var entity = ref segment.source.entities[i];
ref var cMoveBezier = ref entity.ComponentMoveBezier();
ref var cObject = ref entity.ComponentObject();
ref var obj = ref cObject.obj;
// расчет новой точки
var velocityToOneFrame = cMoveBezier.velocityToOneSecond * segment.delta;
cMoveBezier.observedDistance += velocityToOneFrame;
var t = cMoveBezier.observedDistance / cMoveBezier.distanceFull;
if (t > 1f) t = 1f;
var newPos = t.CalculateBesierPos(cMoveBezier.posToMove.c0, cMoveBezier.posToMove.c2, cMoveBezier.posToMove.c1);
// обновление коллайдера
obj.properties.c0 = newPos;
var posAndSize = new float2x2
{
c0 = newPos,
c1 = obj.collBox.posAndSize.c1
};
obj.collBox = obj.entity.NewCollBox(posAndSize, new float2(10f, 10f), obj.rotation.ToEulerAnglesZ());
}
}
на компонентах-классах
на компонентах-структурах
В данном случае мы получаем +10% к FPS, но в примере всего два компонента-структуры, а не десятки, как это должно быть в конечном продукте. Тут возможен нелинейный рост FPS по мере замены компонентов программы reference types на value types.
Заключение
- Во всех случаях FPS в Actors без Parallel.For увеличивается примерно в два раза, а с ним — в три раза по сравнению с MonoBehaviour sequential. С увеличением математических расчетов эти пропорции сохраняются.
- Для меня дополнительное преимущество ECS Actors перед MonoBehaviour sequential в том, что дающее прибавку к скорости распараллеливание вычислений добавляется элементарно.
- Использование Actors + Jobs + Burst повышает FPS примерно в десять раз, по сравнению с MonoBehaviour sequential
- Надо признать, что такой прирост в FPS в большей степени заслуга Burst. Само собой, для его нормальной работы нужно использовать типы данных из Unity.Mathematics (к примеру, Vector3 заменяем на float3)
И очень важно: чтобы на моем процессоре с 50000 объектами на экране поднять FPS с до !
Нужно соблюдать следующие пункты:
1)Если в расчетах можно обойтись без библиотеки, то лучше ее не использовать(красный маркер — плохо, зеленый — хорошо)
2)Нельзя использовать библиотеку Mathf — только math, иначе burst не сможет векторизировать и обработать данные.
- Судя по нескольким сторонним тестам MonoBehaviour sequential с 50000 объектами показывает везде одинаковые ~50fps. А вот работа на Actors + Jobs или Threaded сильно отличается.
Также, чем более современный процессор, тем полезнее работу разбивать на несколько «вложенных в очередь» Jobs: расчет позиции, коллайдера, перемещение на позицию.
Можно скачать тестовую программу и сравнить работу Actors+Jobs+Burst [один Job] с Actors+Jobs+Burst [четыре Job]. (На моем процессоре с четырьмя ядрами без гипертрейдинга первый тест быстрее на -0.2ms при 50000 объектов) - Эффективность ECS зависит от количества дополнительных элементов (рендер, физика Unity и т. д.).
[1]Каково быстродействие в других фреймворках на ECS, в системах ECS-Unity/DOTS, мне не известно.
Спасибо Oleg Morozov(BenjaminMoore) за правку по джобам, добавление SceneSelector и новый fps счетчик.
Спасибо iurii zakipnyi за наставления, правки и дополнительный тест Actors+Jobs+Burst [четыре Job]
Комментарии (12)
klimenkosergey
05.12.2019 12:17+1IJobParallelForTransform используется не совсем как надо. Он работает по принципу Root per thread соответственно если перемещаемые объекты не раскиданы по нескольким рут трансформам они будут обновляться на одном worker thread, а значит никакого параллелизма тут не будет. Раскидай их на группы (например 50к разбей на 5 групп по 10к — просто вложи их в пустой GO вместо того чтобы они были в корне сцены) что увеличит текущие цифры в ~xКОЛИЧЕСТВО_РУТОВ раз.
HexGrimm
05.12.2019 12:22А сами проверки производились в редакторе? Какая версия редактора была? (UPD: пропустил, оказывается есть в статье) Была ли включена настройка Editor Attaching? Были ли отключены проверки безопасности для нативных коллекций и вообще?
Я считаю что самый правильный способ сравнивать эти цифры, это делать сборку на целевую платформу, выключив все проверки и development build option, тогда вариант 3 станет эффективнее на порядки раз, судя по замерам моей команды. Хотя мы не с Actors, а с Entities тестируем.
Забавно, но мы столкнулись с другой проблемой, хоть вариант 3 самый эффективный, в редакторе на большом проекте работать все равно нормально невозможно так как всё тормозит. А галочка Editor Attaching работает только с перезапуском редактора, что неудобно, дебагер нужно подключать всегда не вовремя.
klimenkosergey
05.12.2019 12:52+1В профайлере можно увидеть как по воркер тредам делится если не в корне сцены и как запускается на одном треде если все трансформы в руте сцены. (скрины с редактора с оверхедом Safety\Collections Checks, просто чтобы показать порядок разницы)
50k 5 рутов
50k 1 рут
GeometricBrain Автор
05.12.2019 14:48Спасибо за комментарии. Делал для себя и был сильно удивлен и разочарован производительностью Jobs.
Соберу дополнительную информацию и вероятно будет перетест отдельно третьего пункта с учетом замечаний.klimenkosergey
05.12.2019 14:57Здесь проблема не самих джобов и бёрста скорее, а сама синхронизация MonoBehaviour трансформов + оверхед самих трансформов как таковых. В pure DOTS нет такой проблемы и вся работа с позицией\вращением на несколько порядков быстрее. Чтобы это работало производительно то и Data Layout должен быть производительным (==линейным, с минимумом cache misses) :) Тогда и раскрывается вся мощность Job System и Burst.
BenjaminMoore
06.12.2019 16:52я уже отправил PR с исправлением кода использующий джобы и теперь там всё как надо
DiSur
05.12.2019 20:26Статья очень понравилась.(написал в комменте, так как не могу плюсовать)
А какая разница в производительности на моб. устройствах?GeometricBrain Автор
06.12.2019 23:12-1можно скачать с репозитория и собрать билд на моб. устройство.
SH42913
Следующий коммент написан для людей, которые только планируют открыть себе славный мир ECS.
Из-за таких статей создается ложное впечатление, что основной плюс ECS — производительность, но это совершенно не так. Выигрыш в производительности лишь приятное последствие этого архитектурного паттерна. ИМХО, основной его плюс — удобство работы с ним в рамках архитектуры проекта(ессесно, только когда начнешь им думать, а не пробовать), он решает те же проблемы, что и SOLID, но иным образом.
Tutanhomon
Абсолютно согласен — производительность не есть основная причина для использования ECS. И если причина выбора этого подхода — архитектурное удобство — стоит смотреть в сторону сторонних фреймворков, ибо Юнитевский пока сыроват, и за счет работы со структурами — не шибко удобен.
SH42913
Плюсую. Юнитеховский фреймворк пока далек от удобной разработки. Без ссылочных типов в компонентах очень больно работать, особенно в нынешнем рантайме Unity. А в будущем это добавит геморрою при использовании кастомных C#-либ.
Мне больше всего в работе зашел фреймворк от Leopotam, но тут на вкус и цвет. Лучше попробовать все, чем позже решить, что выбранный чем-то не устраивает.
Tutanhomon
Тоже его использую в домашних проектах, привет Leopotam ;)