본문 바로가기

C#/Unity

[IL2CPP] Avoid Boxing

Avoid Boxing

IL2CPP OPTIMIZATION : Avoid Boxing 

Boxing은 코스트가 높은 연산이다. 이 글에서는 기존 C# 컴파일러는 특정한 상황에서 불필요한 Boxing이 수행되고, IL2CPP가 이를 어떻게 회피하는지를 보여준다.

기존 C# 컴파일러의 방식

interface HasSize {
   int CalculateSize();
}

struct Tree : HasSize {
   private int years;
   public Tree(int age) {
       years = age;
   }

   public int CalculateSize() {
       return years*3;
   }
}
public static int TotalSize<T>(params T[] things) where T : HasSize
{
   var total = 0;
   for (var i = 0; i < things.Length; ++i)
       if (things[i] != null)
           total += things[i].CalculateSize();
   return total;
}

위와 같은 C# 코드를 컴파일하면 아래와 같은 IL 코드가 생성된다. (Tree가 class가 아닌 struct라는 점에 주의)

// This is the start of the for loop

// Load the array
IL_0009: ldarg.0
// Load the current index
IL_000a: ldloc.1
// Load element at the current index
IL_000b: ldelem.any !!T
// What is this box call doing in here?!?
// (Hint: see the null check in the C# code)
IL_0010: box !!T
IL_0015: brfalse IL_002f

IL_0010 부분에서 box !!T명령어를 볼 수 있는데, null과 비교하려면 일단 레퍼런스 타입이어야 하기 때문에 값을 강제로 레퍼런스 타입으로 캐스팅하는작업이다.
T가 이미 레퍼런스 타입이라면 박싱은 아주 빠르게 끝나겠지만, T가 밸류 타입이라면 박싱 작업은 아래와 같은 단계를 거치게 된다.

  • 힙에 할당한다.
  • 가비지 컬렉터에 새 오브젝트가 생겼음을 알린다.
  • 밸류 타입 데이터를 할당된 공간에 옮긴다
  • 새 공간을 가리키는 레퍼런스 타입을 가져온다.

만약 TotalSize 메소드에 10000개짜리 배열을 넘겨주게 되면 위와 같은 작업이 10000번 일어난다는 뜻이다.
가장 어이없는건, 밸류 타입은 처음부터 null이 될수 없기 때문에 박싱의 코스트를 논하기 이전에 if (things[i] != null) 이 문장 자체가 아무런 의미가 없는 문장이 되어버린다. 결국 항상 true만을 리턴하는 조건식을 위해서 불필요한 박싱이 계속 일어나게 된다는 점이다. 

이는 C# 컴파일러가 C++와 같은 템플릿 방식이 아니라 제너릭방식을 채용했기 때문이다. TotalSize 메소드는 일단HasSize를 구현하기만 했으면 밸류 타입이던, 레퍼런스 타입이던 모두 넘어올 수 있다. 컴파일러는 어쩔수 없이 둘 다 대응하는 코드를 작성해야 한다. 

IL2CPP 에서는

원본 글에서는 이렇게 언급하고 있다. IL2CPP will create an implementation of The TotalSize<T> method specifically for the case where T is a Tree.
C#의 하나의 제너릭한 메소드만을 생성하는 방법 대신, 최적화를 위해서 예전의 C++ 시절 방법으로 회귀한다는 것이다. 주어진 T가 Tree처럼 밸류 타입이라면 해당 타입을 위한 메소드를 한벌 더 준비하게 된다. 

IL_0009:

// Load the array
TreeU5BU5D_t4162282477* L_0 = ___things0;
// Load the current index
int32_t L_1 = V_1;
NullCheck(L_0);
IL2CPP_ARRAY_BOUNDS_CHECK(L_0, L_1);
int32_t L_2 = L_1;
// Load the element at the current index
Tree_t1533456772  L_3 = (L_0)->GetAt(static_cast<il2cpp_array_size_t>(L_2));

// Look Ma, no box and no branch!

// Set up the arguments for the method and it call
int32_t L_4 = V_0;
TreeU5BU5D_t4162282477* L_5 = ___things0;
int32_t L_6 = V_1;
NullCheck(L_5);
IL2CPP_ARRAY_BOUNDS_CHECK(L_5, L_6);
int32_t L_7 = Tree_CalculateSize_m1657788316((Tree_t1533456772 *)(
                (L_5)->GetAddressAt(static_cast<il2cpp_array_size_t>(L_6))), /*hidden argument*/NULL);

// Do the next loop iteration...

실제로 원글의 최적화된 IL2CPP 코드 부분을 보면 박싱도 없고 null 체크도 완전히 생략된것을 볼 수 있다.