Unity 검색

Placeholder image Unity 2
Placeholder image Unity 2
다루는 주제
공유

Is this article helpful for you?

Thank you for your feedback!

지난 번에(Last time) 우리는 가상 함수 호출이 직접 호출보다 더 느리다는 사실을 배웠고 IL2CPP로 해당 가상 함수 호출을 더 빠른 직접 함수 호출로 변환(가상화 취소)하는 방법을 살펴봤다. 하지만 가상 함수 호출이 반드시 필요한 경우는 어떻게 해야 할까? 최대한 빠르게 실행되도록 만드는 방법을 배워보자.

가상 함수 호출의 실행 시간

가상 함수 호출은 runtime에서 반드시 완료해야 하는 호출이다. 컴파일러는 코드를 컴파일할 때 어떤 함수가 호출될지 알지 못한다. 그래서 컴파일러는 각 계층에 맞게 (가상 테이블(vtable)이라는) 일련의 함수를 생성한다. 누군가가 이 함수 중 하나를 호출하면 runtime이 vtable에서 적절한 함수를 찾아서 호출한다. 그러나 문제가 생겼는데 vtable에 호출할 수 있는 가상 함수가 하나도 없는 경우에는 어떻게 될까?

가상 함수가 잘못된 경우

runtime에서 생성된 형식이 우리가 사용하는 객체에 포함되어 있는 다음과 같은 극단적인 예를 한번 살펴보자.

class BaseClass {
   public virtual string SayHello() {
       return "Hello from base!";
   }
}

class GenericDerivedClass<T> : BaseClass {
   public override string SayHello() {
       return "Hello from derived!";
   }
}

이와 같은 형식이 정해진 경우 Unity 편집기에서 이 코드를 실행해볼 수 있다(참고로 필자는 5.3.5 버전을 사용한다).

public class VirtualInvokeExample : MonoBehaviour {
   void Start () {
       Debug.Log(MakeRuntimeBaseClass().SayHello());
   }

   private BaseClass MakeRuntimeBaseClass() {
       var derivedType = typeof(GenericDerivedClass<>).MakeGenericType(typeof(int));
       return (BaseClass)FormatterServices.GetUninitializedObject(derivedType);
   }
}

MakeRuntimeBaseClass의 세부적인 내용은 그리 중요하지 않다. 정말 중요한 사실은 MakeRuntimeBaseClass가 생성한 객체에 runtime에서 생성된 형식(GenericDerivedClass<int>)이 포함되어 있다는 것이다.

runtime에서 컴파일 작업이 이뤄지는JIT(Just-in-time) 컴파일러라면 다소 이상해 보이는 이 코드도 전혀 문제가 되지 않는다. 그런데 Unity 편집기에서 이것을 실행할 경우 그 결과는 다음과 같다.

Hello from derived!

UnityEngine.Debug:Log(Object)
VirtualInvokeExample:Start() (at Assets/VirtualInvokeExample.cs:7)

하지만 AOT(Ahead-of-time) 컴파일러를 사용할 경우 상황은 사뭇 달라진다. 동일한 iOS용 코드를 IL2CPP로 실행하면 다음과 같은 예외가 나타난다.

ExecutionEngineException: Attempting to call method 'GenericDerivedClass`1[[System.Int32, mscorlib, Version=2.0.5.0,
     Culture=, PublicKeyToken=7cec85d7bea7798e]]::SayHello' for which no ahead of time (AOT) code was generated.
  at VirtualInvokeExample.Start () [0x00000] in <filename unknown>:0

runtime에서 생성된 형식(GenericDerivedType<int>)이 SayHello라는 가상 함수 호출에 문제를 일으키고 있다. IL2CPP는 AOT 컴파일러에 해당되고 GenericDerivedType<int> 형식에 맞는 소스 코드가 하나도 없기 때문에 IL2CPP는 SayHello 함수를 실행하지 못한다.

존재하지 않는 함수를 호출한 경우

Xcode에서 예외 브레이크 포인트를 생성하여 어떤 일이 일어나는지 확인해볼 수 있다. 예외 브레이크 포인트는 il2cpp::vm::Runtime::GetVirtualInvokeData 함수 안에서 발동되는데 여기서 libil2cpp runtime은 가상 함수 호출을 완료하려고 시도한다. 이 함수의 양상은 다음과 같다.

static inline void GetVirtualInvokeData(Il2CppMethodSlot slot, void* obj, VirtualInvokeData* invokeData) {
   *invokeData = ((Il2CppObject*)obj)->klass->vtable[slot];
   if (!invokeData->methodPtr)
       RaiseExecutionEngineException(invokeData->method);
}

첫 번째 행에서는 위에 언급한 vtable에서 호출할 함수를 찾는다. 두 번째 행에서는 가상 함수가 실제로 존재하는지 확인하고 함수가 존재하지 않을 경우 좀 전에 그림으로 제시했던 관리형 예외를 적용한다.

코드 속도 개선

위의 예에서는 세 줄의 코드만 사용되었는데 그렇다면 이 코드의 속도를 개선할 수 있는 방법이 있을까? 확인된 바에 의하면 가능하다! vtable은 참조해야 하므로 그대로 둬야 한다. 하지만 if check 함수는 어떤가? 대부분의 경우 조건은 '거짓'이 된다(결국 runtime에서 형식을 생성하고 조건을 '참'으로 만드는 데 사용해야 했던 잘못된 코드를 살펴봐야 한다). 거의 (또는 아예) 사용하지 않을 코드의 분기 때문에 성능을 낭비할 필요가 과연 있을까?

함수를 호출하도록 만드는 것이 차라리 더 현명하다! AOT 컴파일러가 그 함수를 생성하지 않은 경우 관리형 예외를 적용하는 함수로 대체할 수 있다. Unity 5.5(현재 클로즈알파 버전)에서는 GetVirtualInvokeData가 다음과 같은 양상을 띤다.

static inline void GetVirtualInvokeData(Il2CppMethodSlot slot,
                    void* obj, VirtualInvokeData* invokeData) {
   *invokeData = ((Il2CppObject*)obj)->klass->vtable[slot];
}

이제 IL2CPP는 프로젝트에서 가상 함수에 의해 사용되는 모든 함수 시그니처에 대응하는 가짜(stub) 함수를 생성한다. vtable 슬롯에 진짜 함수가 존재하지 않을 경우 그 함수 시그니처와 일치하는 적절한 가짜 함수를 취하게 된다. 이와 같은 경우 아래의 가상 함수가 호출된다.

static  Il2CppObject * UnresolvedVirtualCall_2 (Il2CppObject * __this, const MethodInfo* method) {
    il2cpp_codegen_raise_execution_engine_exception(method);
    il2cpp_codegen_no_return();
}

코드는 동일한 방식으로 반응하고, AOT 컴파일러가 가상 함수 호출에 상응하는 코드를 생성하지 못했을 때 적절한 관리형 예외를 적용한다. 무엇보다 중요한 사실은 이와 같은 반응에 일반적으로 비용이 전혀 낭비되지 않는다는 점이다.

얼마나 빨라질 수 있을까?

이쯤 해서 요점을 요약하자면, "이 마이크로 최적화가 정말 중요한가?"라는 질문에 대한 답변은 "그렇다."이다. 우리가 실험한 바에 따르면 전체적인 실행 시간이 3~4% 향상된다. 개선 정도는 가상 함수 호출 횟수와 프로세스 아키텍처에 따라 달라진다. 프로세서의 분기 예측 성능이 우수할수록 if check 함수 실행 비용은 감소한다. 따라서 분기 예측 성능을 배제하면 효과가 감소할 수밖에 없다. 다만, 분기 예측을 처리하지 않는 프로세서는 성능 면에서 더 큰 효과를 발휘한다.

실제로 이와 같은 최적화 기법은 가상 시스템에 널리 사용되고 있다. 따라서 IL2CPP에도 이를 활용할 수 있게 된 것은 반가운 일이다. 이 최적화 기법은 "코드를 하나도 실행하지 않는 것이 일부 코드를 실행하는 것보다 낫다"는 오래된 성능 관련 명언을 추구한다.

다음 번에는 중요하지 않는 것으로 증명된 경우 IL2CPP가 실행 코드를 아예 배제할 수 있는 또 다른 마이크로 최적화 방법을 살펴보겠다.

2016년 8월 4일 엔진 & 플랫폼 | 5 분 소요

Is this article helpful for you?

Thank you for your feedback!

다루는 주제
관련 게시물