Unity 검색

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

유니티의 스크립팅 가상 머신 팀은 코드 실행 속도를 더 높일 수 있는 방법을 늘 모색한다. 이번 게시물은 IL2CPP AOT 컴파일러가 수행하는 마이크로 최적화의 정의와 활용 방법에 관한 연재물 3부 중 1부이다. 지금부터 소개할 방법이 코드 실행 속도를 두 세 배로 끌어올리지는 않겠지만 약간의 최적화가 게임의 중요한 부분에 도움이 될 수 있으며, 여러분이 코드 실행 과정을 이해하는 기회가 될 수 있다.

최신 컴파일러는 런타임 코드 성능을 개선하기 위한 각종 최적화 수행에 탁월하다. 개발자로서 우리는 코드에 관한 정보를 컴파일러에 명시하여 컴파일러 성능을 향상시킬 수 있다. 오늘은 IL2CPP의 마이크로 최적화 중 한 방법을 비교적 자세히 다루고 기존 코드의 성능 개선에 어떤 도움이 될지 살펴보겠다.

Devirtualization

가상 메서드 호출은 무조건 직접 메서드 호출보다 비싸다는 표현 외에 다른 말은 떠오르지 않는다. libil2cpp 런타임 라이브러리의 성능을 개선하여 가상 메서드 호출의 오버헤드를 줄이기 위해 노력했지만(자세한 내용은 다음 게시물에서 다룸), 아직은 런타임 조회가 필요하다. 컴파일러는 런타임에 어떤 메서드가 호출될지 알 수 없다. 과연 그럴까?

역가상화는 가상 메서드 호출을 직접 메서드 호출로 변경하는 일반적인 컴파일러 최적화 전술이다. 컴파일러가 컴파일 시간에 정확히 어떤 실제 메서드가 호출될지 입증할 수 있어야 이 전술을 적용할 수 있다. 안타깝게도 컴파일러가 전체 코드베이스를 항상 볼 수는 없기 때문에 이 점은 쉽게 입증할 수 없다. 그러나 이것이 가능하다면 가상 메서드 호출이 훨씬 빨라질 수 있다.

전형적인 예시

젊은 개발자에 속하는 나는 다소 부자연스러운 동물 예제로 가상 메서드에 대해 배웠다. 여러분도 아마 이 코드에 익숙할 것이다.


public abstract class Animal {
  public abstract string Speak();
}

public class Cow : Animal {
   public override string Speak() {
       return "Moo";
   }
}

public class Pig : Animal {
    public override string Speak() {
        return "Oink";
   }
}

유니티(버전 5.3.5)에서 우리는 이 클래스를 이용해 작은 농장을 만들 수 있다.


public class Farm: MonoBehaviour {
   void Start () {
       Animal[] animals = new Animal[] {new Cow(), new Pig()};
       foreach (var animal in animals)
           Debug.LogFormat("Some animal says '{0}'", animal.Speak());

       var cow = new Cow();
       Debug.LogFormat("The cow says '{0}'", cow.Speak());
   }
}

여기에서 Speak 호출은 모두 가상 메서드 호출이다. IL2CPP에 이 메서드 호출 중 무엇이든 역가상화하여 성능을 개선하도록 명령할 수 있는지 알아보자.

C++ 코드생성도 나쁘지 않다

내가 좋아하는 IL2CPP 기능 중 하나는 어셈블리 코드 대신 C++ 코드를 생성하는 기능이다. 물론 이 코드는 여러분이 직접 작성한 C++ 코드처럼 보이지는 않지만 어셈블리보다 훨씬 이해하기 쉽다. ForEach 루프의 본문에 대해 생성된 코드를 살펴보자.


// Set up a local variable to point to the animal array
AnimalU5BU5D_t2837741914* L_5 = V_2;
int32_t L_6 = V_3;
int32_t L_7 = L_6;

// Get the current animal from the array
V_1 = ((L_5)->GetAt(static_cast<il2cpp_array_size_t>(L_7)));
Animal_t3277885659 * L_9 = V_1;

// Call the Speak method
String_t* L_10 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Animal::Speak() */, L_9);

간단하게 설명하기 위해 생성된 코드 중 일부를 지웠다. 거슬리는 Invoke 호출이 보이는지? Vtable에서 적절한 가상 메서드를 조회한 후 호출할 것이다. Vtable 조회는 직접 함수 호출보다 느리겠지만 이해할 만하다. 동물은 소나 돼지 또는 다른 파생된 유형일 수 있다.

이제 직접 메서드 호출에 더 가까운 두 번째 Debug.LogFormat 호출에 대해 생성된 코드를 살펴보자.


// Create a new cow
Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;

// Call the Speak method
String_t* L_17 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Cow::Speak() */, L_16);

이 경우에도 가상 메서드 호출은 여전히 가능한다! IL2CPP는 최적화에 아주 보수적이어서 대부분의 경우 정확성 확인을 선택한다. 직접 호출일 가능성을 확인하기 위한 전체 프로그램 분석을 충분히 하지 않기 때문에 더 안전한 (그리고 더 느린) 가상 메서드 호출을 선택한다.

우리 농장에는 다른 종류의 소가 없어서 다른 종류의 소가 파생되지 않을 것임을 알고 있다고 가정해 보자. 이 사실을 컴파일러에 명시하면 더 나은 결과를 얻을 수 있다. 정의할 클래스를 이렇게 변경해 보자.


public sealed class Cow : Animal {
   public override string Speak() {
       return "Moo";
   }
}

봉인된 (sealed) 키워드가 컴파일러에게 소에서 아무 것도 파생되지 않음을 알린다(봉인됨 역시 Speak 메서드에 직접 사용할 수 있음). 이제 IL2CPP가 직접 메서드 호출에 대한 확신을 갖게 된다.


// Create a new cow
Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;

// Look ma, no virtual call!
String_t* L_17 = Cow_Speak_m1607867742(L_16, /*hidden argument*/NULL);

컴파일러에 정보를 명시했고 확실히 최적화할 수 있도록 했기 때문에, 여기에서 Speak 호출은 크게 느리지 않을 것이다.

이런 종류의 최적화가 게임 속도를 눈에 띄게 높이지는 않겠지만, 향후 이 코드를 읽을 사람과 컴파일러 모두에게 코드 안의 코드에 관한 여러분의 가정을 명시하는 좋은 방법이 될 수 있다. IL2CPP로 컴파일링할 때는 생성된 C++ 코드를 프로젝트에서 숙독하여 다른 새로운 사실을 발견하길 바란다!

다음에는 왜 가상 메서드 호출이 비싼지, 속도를 높이기 위한 방법은 무엇인지 알아보겠다.

2016년 7월 26일 테크놀로지 | 5 분 소요
다루는 주제