본문 바로가기

Unity/Articles

Unity: 코드 블록 성능 측정 방법

효율적인 로직을 구현하기 위해 어떤 코드가 더 성능상 유리한지 시간을 재어 보고 싶을 때가 있습니다. 유니티에서는 GameObject.Find나 GetComponent와 같이 매 프레임 반복적으로 호출하지 말 것을 권장하는 코드들이 있죠. 하지만 글이나 얘기로만 들었지 실제로 어느정도로 성능이 좋지 않은지 제대로 측정해보지는 않습니다. 게다가 비슷한 시도를 하더라도 제대로 측정하는 경우는 거의 없습니다.

 

최근 유니티 한국어 개발자 톡방에서 TryGetComponent와 GetComponent의 성능에 대한 얘기가 나왔는데, 아무도 잘못된 부분을 짚어주지 않아 겸사겸사 코드 블럭 성능 테스트를 위한 코드를 작성하는 부분에 대해 다뤄보겠습니다.

 

더보기

# 유니티 개발자 톡방 캡쳐 보기

 

 

유니티 엔진으로 개발을 하면 우리는 일반적으로 C#을 이용해 코드를 작성하게 됩니다. 여기서 C#을 '스크립팅 언어' 정도로 표현할 수 있는데요, 엔진의 코어 부분은 C++로 개발되었기 때문입니다. 네이티브 부분은 C++이지만 스크립팅은 C#으로 진행되는데, 엔진은 어떻게 이 컴파일 된 C# 코드를 통해 C++로 정의된 코어가 돌아가게 만들까요?

자세한 내용은 다루지 않겠지만, 유니티는 자체적으로 네이티브 기능을 사용하기 위한 C#으로 쓰여진 래핑 클래스들을 제공하고 있습니다. 이 미리 정의된 API들에 따라서 스크립팅만 진행하게 되는 셈입니다. (구조상 C#에서 C++ Native API를 호출하는 것 처럼 보이지만, 일반적으로 C#에서 C++로 작성된 Unmanaged DLL을 import 해서 extern call을 수행하는 것과는 조금 다릅니다.)

 

여기에서의 중간과정은 유니티 엔진이 스스로 처리해주는데요, 이 백엔드 과정에는 2가지 방식이 있습니다. 하나는 Mono, 나머지 하나는 IL2CPP 방식입니다. 이 두 방법에는 큰 차이가 있는데, 코드를 기계어로 바꾸어주는 방식입니다. 보통 컴파일 방식은 인터프리터 방식, JIT 방식, AOT 방식 등이 있는데요, Mono는 JIT(Just-In-Time) 방식, IL2CPP는 AOT 방식을 택하고 있습니다.

 

각자 장단점이 존재하는데 간략하게만 설명하자면, 인터프리터 방식은 파이썬과 같은 언어가 동작하는 방식입니다. 하나의 행씩 분석하여 기계어로 바꾸는데, 빌드과정이 따로 없고 실행속도가 다소 느립니다. JIT 방식은 빌드 시간에 런타임에 사용할 중간 언어(IL)로 바꿔둡니다. 런타임에서 중간언어를 기계어로 바꾸는데, 기계어를 생성할 때 함수를 캐싱해둡니다. 하나의 함수는 한 번 생성되고 나면 다음번 사용해서는 캐시된 기계어를 불러오게 됩니다. AOT 방식은 빌드타임에 코드를 분석해 기계어를 생성합니다. 런타임에서는 기계어를 읽기만 하므로, 실행속도가 빠릅니다. (유니티 IL2CPP에서는 내부적으로 처음 불리는 함수를 Bake하는 보일러플레이트 코드가 존재합니다.)

 

다시 성능 테스트로 돌아가서, JIT 방식 환경에서 함수를 미리 기계어로 구워두는 과정 없이 함수를 1000회 실행 시키게 되면 그 테스트 수치에 함수를 굽는 시간까지 포함되게 됩니다. 이는 저희가 원하는 퍼포먼스 벤치마크 결과가 아니게 되죠. 테스트 횟수가 늘어나면 미미해질거라 기대할 수 있으나, 어쨌든 보다 정확한 계산을 위해서는 고려하는게 좋습니다.

 

JIT 방식인 경우 테스트할 함수를 미리 한 번 실행하여 기계어를 생성해두어야 함

 

유니티 에디터 환경은 항상 JIT 방식으로 동작하므로 고려하여 테스트 케이스를 작성하여야 합니다. 더 중요한 점은, IL2CPP를 이용하더라도 il2cpp code initialize 과정이 있다는 점입니다. 생성된 코드를 확인하면, 최초 호출시 메서드를 베이크하는 과정이 포함되어있습니다. 이 때 추가적인 시간이 소요될 가능성이 있습니다. 코드는 맨 아래에서 확인하겠습니다.

 

 

이를 고려하여 테스트 코드를 작성하고 씬의 모든 요소를 비활성화한 뒤 테스트를 시행했습니다.

 

더보기

# 코드 보기

 

# 코드 보기

 

using System;
using System.Collections;
using System.Diagnostics;
using System.IO;
using UnityEngine;
using Debug = UnityEngine.Debug;

public class GetComponentTest : MonoBehaviour
{
	private Data data;
	private Action<decimal, string, Action<decimal>> action = default;
	
	private void Awake()
	{
		string dataPath = "C:/Github/Windows-Bullder/Build/Windows64/data.json";
		if (!File.Exists(dataPath))
		{
			Debug.Log("New File!");
			File.WriteAllText(dataPath, JsonUtility.ToJson(new Data()));
		}

		data = JsonUtility.FromJson<Data>(File.ReadAllText(dataPath));
		
		Application.targetFrameRate = data.targetFrameRate;

		action = (count, identifier, work) =>
		{
			Stopwatch sw = new Stopwatch();
			
			sw.Start();

			work(count);

			sw.Stop();
			
			Debug.Log($"[{identifier}] :: {sw.ElapsedMilliseconds}ms");
		};
		
		action(1, "GetComponent-Test", WorkGetComponent);
		action(1, "TryGetComponent-Test", WorkTryGetComponent);
		action(1, "TryGetComponent2-Test", WorkTryGetComponent2);
	}
	
	private IEnumerator Start()
	{
		yield return null;
		
		for (int i = 0; i < data.yieldCount; i++)
		{
			yield return null;
		}
		
		yield return null;

		action(data.caseCount, "GetComponent", WorkGetComponent);
		
		yield return null;
		
		for (int i = 0; i < data.yieldCount; i++)
		{
			yield return null;
		}
		
		yield return null;
		
		action(data.caseCount, "TryGetComponent", WorkTryGetComponent);
		
		yield return null;
		
		for (int i = 0; i < data.yieldCount; i++)
		{
			yield return null;
		}
		
		yield return null;
		
		action(data.caseCount, "TryGetComponent2", WorkTryGetComponent2);
	}

	private void WorkGetComponent(decimal count)
	{
		for (int i = 0; i < count; i++)
		{
			GetComponent<MyComponent>();
		}
	}

	private void WorkTryGetComponent(decimal count)
	{
		for (int i = 0; i < count; i++)
		{
			TryGetComponent<MyComponent>(out var component);
		}
	}
	
	private void WorkTryGetComponent2(decimal count)
	{
		MyComponent component = null;
		for (int i = 0; i < count; i++)
		{
			TryGetComponent<MyComponent>(out component);
		}
	}
}

[Serializable]
public class Data
{
	public long caseCount = 1000000;
	public int targetFrameRate = 60;
	public int yieldCount = 20;
}

 

씬이 로드되고 Start가 처음 호출될 때 테스트 하는 것 역시 외부 영향을 받을 수 있습니다. 씬이 아직 다 로딩되지 않은 상태이기때문에, 최소한 10여 프레임 후에 테스트가 시작되어야 합니다. 업데이트 등에서 버튼이나 키를 입력받아 처리하는것이 바람직하나, Headless 모드로 테스트 할 것이기 때문에 코루틴을 활용했습니다. 

 

Awake와 OnEnable은 씬의 로딩 과정에서 호출될 수 있고, 완전히 로딩이 끝나기 전에 Update는 2회 호출될 수 있습니다. 이를 고려하여 충분히 초반에 인터벌을 두어야 씬 로딩으로부터 생기는 오버헤드의 영향을 줄일 수 있습니다.

 

 

10만회 반복 테스트 결과입니다.

 

 

이게 무슨 일일까요?

위의 3개는 함수 베이크를 위한 결과이고, 제대로 테스트를 한 것 같은데 TryGetComponent가 훨씬 빨라 보입니다. 거의 스무배 가깝게요. 

 

 

프로파일러를 살펴봐도 3개의 기둥이 보이고 Raw Hierarchy를 살펴봐도 StopWatch와 Debug.Log 외에는 특이사항이 없습니다. 테스트 코드가 잘못된 것일까요? 

 

아닙니다.

 

유니티 에디터 환경은 Development 환경이고, 추가로 Debugger가 붙을 수 있도록 구성되어있으며 작업 도중에 생길 수 있는 여러 문제들을 해결한 비교적 안전하고 안정적인 장치들이 추가되어있는 코어를 사용합니다. 빌드 후에 호출되는 GetComponent에디터에서 호출되는 GetComponent다른 함수라는 것입니다. 에디터에서는 여러 이유로 수행하야만 했던 일들을 빌드 후 바이너리에는 Strip되어있는 함수로 링크되므로 필요없는 작업들을 수행되지 않게 됩니다. 에디터에서는 GetComponent가 느리게 느껴질 수 있지만, 성능 확인은 꼭 타겟 플랫폼에서 빌드 후 수행해야합니다.

 

우선, 4가지 버전으로 빌드했습니다.

Mono/IL2CPP, Development/Release 입니다.

 

 

차례로 100만회 결과입니다.

 

IL2CPP Development
IL2CPP Release
Mono - Development
Mono - Release

 

전체 표입니다.

 

같은 케이스를 수 차례 반복하여 평균을 내면 더욱 일반적인 결과를 도출할 수 있습니다. 그러나 이번에는 단일 테스트로 처리했습니다. 여러차례 반복수행했지만 캐시미스를 유발하는 로직이 아니어서 크게 차이나지 않았습니다.

  IL2CPP MONO
Shipment Development Release Development Release
GetComponent 183ms 155ms 128ms 113ms
TryGetComponent 206ms 183ms 157ms 140ms
TryGetComponent2 209ms 205ms 155ms 141ms

 

에디터에서 결과와는 전혀다릅니다. 이로써 에디터에서의 벤치마크 결과는 믿을 수 없다는 점을 꼭 짚어두고 싶네요. 프로파일러를 확인할 때에도, 빌드를 진행한 다음 프로파일러만 붙여 확인하는 것을 권장합니다.

 

여기에서 의문점이 2가지 생깁니다.

1. 왜 에디터에서 훨씬 느릴까?

2. IL2CPP가 AOT 방식이니 더 빨라야하지 않나?

 

1. 왜 에디터에서 훨씬 느릴까?

위에서 간략하게 설명하였습니다. 호출되지 않아도 될 부분들이 사라진 프로덕션 릴리즈용 GetComponent가 사용되었기 때문입니다. 호출이 생략된 일부 메소드들이 유니티 에디터에서 꽤 두드러진 오버헤드를 일으킵니다. 버그나 잘못디자인 된 것은 아니며, 에디터에서의 부가적인 기능을 위해 구현되어 있는 부분입니다.

 

2. IL2CPP가 AOT 방식이니 더 빨라야하지 않나?

대체로 그렇기는 합니다. 하지만 모든 메서드가 그렇게 동작해야만 하는 것은 아닙니다. 더 자세한 비교는 해봐야 알 수 있겠지만, 생성된 CPP 코드를 보면 다음과 같습니다.

 

goto를 이용해 루프를 처리하고 있고, 제네릭 GetComponent<T> 대신에 각 타입별로 생성된 코드를 통해 네이티브 코드 호출을 시도합니다. 생성된함수가 가리키고 있는 제네릭 함수는 아래와 같습니다.

 

끝에서는 유니티 코어 라이브러리로부터 함수를 호출하게됩니다. GetComponent의 성능 개선이 있기 전과 다르게 GetComponentFastPath를 통해 Component를 리턴받습니다. 사실 GetComponent와 TryGetComponent의 퍼포먼스 비교를 위해선 여기 생성된 코드들은 큰 의미가 없고 유니티 네이티브 코어 안에서 어떤 동작을 하는지에 따라 성능이 결정될것입니다. 그러나 Mono와 비교하여서 차이가 나는 이유를 찾자면 코어 라이브러리를 호출하는 과정에서 Mono 방식의 GetComponent 루프 과정이 IL2CPP의 과정보다 적은 시간을 소모하기때문에 그렇다라고 추측해볼 수 있겠습니다. 이에 대해서는 좀 더 자세한 분석을 해 보고 따로 기록을 남기도록 하겠습니다.

 

결론

1. 최소한의 환경을 갖춰 퍼포먼스 테스트를 진행할 것.

2. GetComponent vs TryGetComponent 중 GetComponent가 더 빠르다.