Unity 검색

Unity에는 Messaging system이라고 불리우는 시스템을 가지고 있습니다. 이 시스템은 게임이 동작하는 동안 특정시점에 유저가 정의한 함수가 동작될 수 있게 해주죠. 이런일이 가능한 건 바로 매직함수들(Update, Start...) 때문입니다. 이건 굉장히 이해하기 쉬운 컨셉입니다. 특히 새로운 유저에겐 더욱 그렇지요. 아래처럼 Update 함수를 만들면, 이 함수는 알아서 매 프레임마다 호출 될 것입니다.

void Update() {
    transform.Translate(0, 0, Time.deltaTime);
}

하지만 숙련된 개발자는 위의 코드에서 몇몇 의문이 들 수 있습니다.

  1. 이 함수는 도대체 언제 호출되지?
  2. 만약, 여러개의 스크립트가 있고 그 스크립트들 모두에 Update()를 정의 해놨으면,  어떤 순서로 스크립트들이 호출되지?
  3. 이런 코드 스타일은 인텔리센스하게 동작되지 않아.

Update 문은 어떻게 호출되나?

No! 유니티는 유저의 매직 함수를 한번 호출하기 위해서, 매번 System.Reflection을 이용해 그 함수를 찾지 않습니다.

대신, 주어진 MonoBehaviour 스크립트가 처음으로 이용될 때, 스크립트 내 매직함수들이 있다면 이 스크립트는 각 매직함수와 연관된 List에 저장됩니다. 예를 들어 어떤 스크립트에 Update 함수가 정의되어 있다면 이 스크립트는 Update와 관련된 List에 저장되고, 이건 매프레임마다 Update가 호출되어야 할 시점에 이용되지요.

게임이 실행되는 동안 유니티는 이러한 리스트를 순회하면서 실행시킵니다. — 매우 쉽죠? 이러한 이유때문에 Update 함수가 public이든 private이든 상관이 없습니다.

어떤 순서로 Update 함수는 호출될까?

호출 순서는 Script Execution Order Settings (menu: Edit > Project Settings > Script Execution Order) 을 이용해 결정할 수 있습니다. 하지만 만개의 스크립트가 있다면, 그걸 일일이 정의하는 건 쉬운일은 아닐것입니다. 어떤 스크립트가 다른 모든 스크립트들보다 먼저 실행되야 하는 경우에는 이 방식이 유용합니다. 물론 미래에는 C#의 속성 기능을 이용한다던가 하는 식의 좀 더 손쉬운 방법이 생기길 원합니다.

이건 인텔리센스하지 않아.

우리 보통 C# 스크립트를 작성하기 위해 어떤 종류든 IDE를 이용합니다. 하지만 IDE들은 이런식의 매직함수를 좋아하지 않죠. 이 매직함수는 언제 호출되는지도 알 수 없고, 네이게이션하기도 힘드니깐요.

몇몇 개발자들은 이를 위해 MonoBehaviour를 확장한 추상화된 클래스를 만듭니다.  BaseMonoBehaviour 라든가 또는 이와 유사한 형태로 기존적인 뼈대만 가지는 클래스를 만들지요. 그리고 게임에서 이용되는 스크립트들이 이 클래스를 상속받게 합니다. BaseMonoBehaviour 클래스는 보통 아래처럼 여러개의 virtual함수를 가지고 있습니다.

public abstract class BaseMonobehaviour : MonoBehaviour {
    protected virtual void Awake() {}
    protected virtual void Start() {}
    protected virtual void OnEnable() {}
    protected virtual void OnDisable() {}
    protected virtual void Update() {}
    protected virtual void LateUpdate() {}
    protected virtual void FixedUpdate() {}
}

이러한 구조는 좀더 논리적으로 MonoBehaviours를 이용하게 하지만 약간의 흠이 있습니다.

유저가 만든 스크립트들은 유니티 내부적으로 이용하는 Update 리스트에 존재하게 되는데, 위와 같이 구현하게 되면, 정작 유저 스크립트내의 Update문에서는 아무것도 하는게 없지만, 유니티 내부에서는 매 프레임마다 이 함수를 호출하게 됩니다.

어떤 사람들은 '함수내에서 아무것도 안하는게 문제가 될 수 있어?' 할 수 있습니다. 하지만 Update문이 호출되는 것은 Native C++에서 managed C#이 호출되는 경우이기 때문에 이건 꽤 비용이 발생할 수 있습니다. 자 그럼 어떤 비용이 발생하는지 확인해 보죠.

10000번의 Update 호출

저는 이 포스트를 위해 Github에 한개의 작은 프로젝를 만들었습니다. 프로젝트에는 2개의 씬이 있습니다. 씬전환은 간단하게 디바이스를 탭하거나 에디터에서 아무키나 누르면 됩니다.

(1) 첫번째 씬에서는 아래의 코드를 가지는 10000개의 MonoBehaviour가 생성됩니다.

private void Update() {
    i++;
}

(2) 두번째 씬에서는 또다른 10000개의 MonoBehaviour들이 생성됩니다. 하지만 이번에는 스크립트가 Update문을 가지는게 아니라 UpdateMe라는 함수를 가집니다. 그리고 이 함수는 아래처럼 매니징 스크립트의 Update함수에서 호출됩니다.

private void Update() {
    var count = list.Count;
    for (var i = 0; i < count; i++) list[i].UpdateMe();
}

이 테스트 프로젝트는 2개의 iOS 장비에서 실행되었고, 릴리즈, 그리고 Non-Development 옵션으로 Mono와 IL2CPP 모두로 빌드되었습니다. 시간은 아래와 같은 방법으로 측정되었습니다.

  1. 첫번째 Update 콜에서 스탑워치는 시작됩니다.(이건Execution Order로 조절했습니다.)
  2. 스탑워치는 LateUpdate문에서 정지됩니다.
  3. 몇 분동안 테스트를 하고 평균을 냈습니다.

Unity version: 5.2.2f1
iOS version: 9.0

Mono

와우!엄청 걸리네요! 테스트에 뭔가 잘못된것 같습니다!

Fast but no Exceptions 옵션을 켜는것을 잊었네요.  하지만 우린 이제 이옵션이 어떤 영향을 주는지 알게 됐네요. (사실 IL2CPP에서는 아무도 이걸 신경쓰지 않지만...)

Mono (fast but no exceptions)

OK, 훨씬 낫네요. 이제 IL2CPP를 한번 볼까요.

IL2CPP

여기서 두가지를 알 수 있는데요:

  1. 이 옵션은 여전히 IL2CPP에 영향을 미칩니다.
  2. IL2CPP는 여전히 개선할 부분이 있네요. 사실 제가 이 글을 쓰고 있는 동안에도 우리 IL2CPP팀은 성능을 개선하기 위해 열심히 일하고 있습니다. (내부에 있는 최신 소스코드를 이용하면 성능이 35%나 좋아져요.^^)

유니티에서 무슨일을 하고 있는 앞으로 몇달동안 설명드리겠습니다. 하지만 지금은 왜 우리의 매니징 스크립트가 5배나 빠른지 알아보시죠.

Interface calls, virtual calls and array access

만약 this great series of posts about IL2CPP internals글을 읽지 않으셨다면, 이 글을 읽은 다음에 바로 그 글을 읽으셔만 해요.

아래의 결과를 보면, 매 프레임마다 10000개의 요소에 접근하려 하면 리스트보다 배열이 더 낫다는걸 볼 수 있습니다. 왜냐하면 배열을 이용한 경우가 C#에서 생성된 C++ 코드들이 좀 더 심플하기 때문입니다.

다음 테스트에서 나는 List<ManagedUpdateBehavior>를 ManagedUpdateBehavior[]로 바꿨습니다.

음 훨씬 낫네요!

Update: 나는 Mono빌드에서 배열로 테스트했을 때 0.23ms결과를 얻었습니다.

Instruments to the rescue!

우리는 C++에서 C#함수를 호출하는게 빠르지 않다는 걸 봤습니다. 하지만 실제로 유니티 내부에서 이를 위해 무엇을 하고 있는지 알아야 할 필요가 있었습니다. 이를 알기에 가장 쉬운 방법은 Apple Instruments의 Time Profiler를 이용하는 것이었습니다.

Note: 이건 Mono vs. IL2CPP 테스트가 아닙니다. — 밑에 나오는 모든 결과는 Mono(for iOS)에서 좀 더 빠르게 측정됩니다.

저는 아이폰6에 Time Profiler를 이용해 테스트했습니다. 몇분간 데이터을 기록했고 1분간 값을 수집했습니다. 그리고 모든 것은 아래 라인부터 시작되는 것을 알게 됐습니다.
void BaseBehaviourManager::CommonUpdate<BehaviourManager>()

Instruments를 보는 방법을 설명드리면, 오른쪽엔 함수가 실행 순서에 따라 정렬되어 있고 가장 왼쪽에는 각 함수별 CPU 점유율(ms, %)을 보실수 있습니다. 그 옆에는 해당 함수 자체만의 실행 시간이 써져있습니다. CPU 점유율이 유니티를 위해 전부 사용되지 않는것을 알 수 있지만, 이번 실험의 목적은 이것이 아니기 때문에 일단은 각 함수들이 걸린 시간을 집중적으로 분석해 보겠습니다.

나는 이해를 위해 어설프지만 포토샵을 이용해서 색칠을 해보았습니다.

UpdateBehavior.Update()

가운데를 보면 우리의 Update문을 볼 수 있습니다. IL2CPP에서는 이것이UpdateBehavior_Update_m18 으로 표현됩니다. 하지만 이 함수를 호출하기에 앞서 유니티는 무엇인가 더 많은 작업들을 하고 있습니다.

모든 Behaviours의 순회

유니티는 Update를 호출하기 위해 모든 Behaviours 순회합니다. 유니티는 Update관련 순회를 하면서 Update관련 리스트의 값이 안정적이기 원합니다. 그리고 그것을 보장하기 위해 SafeIterator를 이용합니다. 모든 Behaviour를 순회하는데 걸리는 시간이 1517ms가 걸렸습니다. (총 시간 9978ms 중)

안정성 검사

다음으로 유니티는 Update가 호출될 스크립트가 유효한지 검사합니다. Update가 호출되기 전에 이미 초기화는 잘 되었는지, 또 이전에 Start는 호출되었었는지, 그리고 이미 destory된 스크립트는 아닌지 등을 검사합니다. 이러한 검사를 하는데 2188ms가 소요됩니다.(총 시간 9978ms 중)

함수를 invoke하기 위한 준비

유니티는 ScriptingArguments와 함께 ScriptingInvocationNoArgs의 인스턴스를 생성합니다. (Native 쪽에서 managed 쪽 함수를 호출하기 위해 필요합니다.) 그리고 이것을 IL2CPP에서 invoke하라고 하죠.(scripting_method_invoke function) 이 과정은 총 2061ms가 소요됩니다.(총 시간 9978ms 중)

함수 호출

scripting_method_invoke function 함수는 전달된 매개변수가 유효한지 확인(900ms)하고 IL2CPP virtual machine의Runtime::Invoke를 호출(1520ms)합니다.Runtime::Invoke함수가  처음으로 하는일은 매개변수로 전달 받은 함수가 존재하는지 확인(1018ms)하고, 그리고 생성된method signature 를 위한 RuntimeInvoker를 실행(283ms)합니다. 단 42ms Update구문을 실행시키기 위해 우리는 이러한 작업들을 하게 됩니다.

그리고 아래는 이걸 정리했습니다.

Managed Updates?

자 이번엔 매니징 스크립트를 이용한 것을 한번 측정해 볼까요? 아래 이미지에서 볼 수 있듯이 이번 테스트도 호출순서는 위의 테스트와 거의 유사합니다. 다만 몇몇함수들은 걸린 시간이 1ms도 안되서 정확한 시간을 측정하진 못했죠. 대부분의 실행시간이UpdateMe 함수에 집중되어 있는것을 볼 수 있습니다. (IL2CPP에서는 이게ManagedUpdateBehavior_UpdateMe_m14함수이고 IL2CPP는 추가적으로 NullCheck를 또합니다.)

다음 이미지는 위와 동일한 방식으로 색칠을 해본것입니다.

자, 이제 여러분은 어떤 생각이 드시나요?  작은 함수 하나를 호출하는게 신경쓰이시나요?

이 테스트를 관한 몇 말씀..

솔직히 말씀드리면, 이 테스트가 매우 공정하다곤 생각하진 않습니다. 유니티는 당신의 Update문을 호출하기 위해 여러가지 작업들을 합니다. 이 GameObject는 Active되어 있는지, 이번 Update 루프가 진행되는 동안 오브젝트가 삭제되지는 않았는지, Update구문 동안에 생성된 오브젝트들은 어떻게 처리해야 하는지... 하지만 저의 매니져 스크립트에서는 이러한 것들을 하지 않죠.

실제로 사용될 매니져 스크립트라면 당연히 이것보다 훨씬 복잡한 동작을 처리하것입니다. 그리고 아마도 저의 테스트보다 훨씬 결과가 더 느리게 나오겠죠. 하지만 우리는 개발자입니다.  우리는 우리의 매니져 스크립트가 무엇을 해야할 지 알고, 또 뭐가 실행되면 않되는지 알고 있습니다. 하지만 유니티는 이런것을 알 수 없어요.

자 그럼 이제 어떻해야 할까요?

물론 이건 당신의 프로젝트에 달렸어요. 하지만 이제는 매 프레임마다 호출되고, 또 한 씬에 매우 많은 게임오브젝트가 있는것도 매우 흔한일이죠. 사실 제가 알려드린 작업은 단순 코드만 변경되는 작업이고 눈에 띄는 작업도 아니죠. 하지만 당신이 엄청나게 많은 게임오브젝트를 이용하고 있다면, 한번쯤은 인식해 보는게 좋다고 생각합니다. 이미 지금은 개발이 너무 많이 진행되어서 이러한 수정을 하기엔 너무 늦었을 수 도 있습니다.

다만, 이제 당신은 데이터를 가졌고, 당신의 다음 프로젝트를 시작할 때는 이것을 한번 생각해 보세요.

2015년 12월 23일 테크놀로지 | 9 분 소요