Unity, ECS, Actors: как поднять FPS в своей игре в десять раз, когда оптимизировать уже нечего [с правками]

Что такое ECS
Что такое 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.

Позиции тестирования и код апдейта


  1. 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
        }
    

  2. 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
    		}
    	}
    

  3. 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;
    }
    
  4. 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, чтобы показать что там визуально происходит)

  1. MonoBehaviour sequential:

  2. Actors sequential:

  3. Actors + Jobs + Burst:

  4. Actors + Parallel.For:


5000 объектов




  1. MonoBehaviour sequential:

  2. Actors sequential:

  3. Actors + Jobs + Burst:

  4. Actors + Parallel.For:



50000 объектов



  1. MonoBehaviour sequential:

  2. Actors sequential:

  3. Actors + Jobs + Burst:

  4. 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]
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 12

    +3
    Следующий коммент написан для людей, которые только планируют открыть себе славный мир ECS.

    Из-за таких статей создается ложное впечатление, что основной плюс ECS — производительность, но это совершенно не так. Выигрыш в производительности лишь приятное последствие этого архитектурного паттерна. ИМХО, основной его плюс — удобство работы с ним в рамках архитектуры проекта(ессесно, только когда начнешь им думать, а не пробовать), он решает те же проблемы, что и SOLID, но иным образом.
      +1
      Абсолютно согласен — производительность не есть основная причина для использования ECS. И если причина выбора этого подхода — архитектурное удобство — стоит смотреть в сторону сторонних фреймворков, ибо Юнитевский пока сыроват, и за счет работы со структурами — не шибко удобен.
        +1
        Плюсую. Юнитеховский фреймворк пока далек от удобной разработки. Без ссылочных типов в компонентах очень больно работать, особенно в нынешнем рантайме Unity. А в будущем это добавит геморрою при использовании кастомных C#-либ.
        Мне больше всего в работе зашел фреймворк от Leopotam, но тут на вкус и цвет. Лучше попробовать все, чем позже решить, что выбранный чем-то не устраивает.
          +1
          Тоже его использую в домашних проектах, привет Leopotam ;)
      +2
      IJobParallelForTransform используется не совсем как надо. Он работает по принципу Root per thread соответственно если перемещаемые объекты не раскиданы по нескольким рут трансформам они будут обновляться на одном worker thread, а значит никакого параллелизма тут не будет. Раскидай их на группы (например 50к разбей на 5 групп по 10к — просто вложи их в пустой GO вместо того чтобы они были в корне сцены) что увеличит текущие цифры в ~xКОЛИЧЕСТВО_РУТОВ раз.
        0
        А сами проверки производились в редакторе? Какая версия редактора была? (UPD: пропустил, оказывается есть в статье) Была ли включена настройка Editor Attaching? Были ли отключены проверки безопасности для нативных коллекций и вообще?
        Я считаю что самый правильный способ сравнивать эти цифры, это делать сборку на целевую платформу, выключив все проверки и development build option, тогда вариант 3 станет эффективнее на порядки раз, судя по замерам моей команды. Хотя мы не с Actors, а с Entities тестируем.
        Забавно, но мы столкнулись с другой проблемой, хоть вариант 3 самый эффективный, в редакторе на большом проекте работать все равно нормально невозможно так как всё тормозит. А галочка Editor Attaching работает только с перезапуском редактора, что неудобно, дебагер нужно подключать всегда не вовремя.
          +1
          В профайлере можно увидеть как по воркер тредам делится если не в корне сцены и как запускается на одном треде если все трансформы в руте сцены. (скрины с редактора с оверхедом Safety\Collections Checks, просто чтобы показать порядок разницы)

          50k 5 рутов
          image

          50k 1 рут
          image
            0
            Спасибо за комментарии. Делал для себя и был сильно удивлен и разочарован производительностью Jobs.
            Соберу дополнительную информацию и вероятно будет перетест отдельно третьего пункта с учетом замечаний.
              +1
              Здесь проблема не самих джобов и бёрста скорее, а сама синхронизация MonoBehaviour трансформов + оверхед самих трансформов как таковых. В pure DOTS нет такой проблемы и вся работа с позицией\вращением на несколько порядков быстрее. Чтобы это работало производительно то и Data Layout должен быть производительным (==линейным, с минимумом cache misses) :) Тогда и раскрывается вся мощность Job System и Burst.
                0
                я уже отправил PR с исправлением кода использующий джобы и теперь там всё как надо
              +1

              Статья очень понравилась.(написал в комменте, так как не могу плюсовать)
              А какая разница в производительности на моб. устройствах?

                –1
                можно скачать с репозитория и собрать билд на моб. устройство.

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

              Самое читаемое