Unity 검색

다루는 주제
공유

Is this article helpful for you?

Thank you for your feedback!

이번 IL2CPP 마이크로 최적화 미니시리즈의 마지막 에피소드에서는 "박싱"이라는 고비용 유발 요인을 살펴보고 IL2CPP에서 어떻게 하면 불가피한 경우를 제외하면서 박싱을 피할 수 있는지 살펴본다.

힙(Heap) 할당이 느려진다

많은 프로그래밍 언어가 그러하듯 C#에서는 스택(stack)(작고 "빠르며" 범위가 정해진 메모리 블록)이나 힙(heap)(크고 "느린" 광역 메모리 블록)에 객체용 메모리를 할당할 수 있다. 일반적으로 힙에 객체용 공간을 할당하는 것이 스택에 공간을 할당하는 것보다 훨씬 더 많은 비용이 소요된다. 또한 힙에 객체용 공간을 할당한 경우 할당된 메모리를 가비지 컬렉터(garbage collector)에서 추적하는 작업이 수반되기 때문에 추가 비용도 발생한다. 따라서 가급적이면 힙 할당을 피하는 것이 바람직하다.

C#을 이용하면 형식을 (스택에 할당할 수 있는) 값 형식과 (반드시 힙에 할당해야 하는) 참조 형식으로 나눌 수 있기 때문에 힙 할당을 피할 수 있다. int와 float 같은 형식은 값 형식에 해당되며 string가 object는 참조 형식에 속한다. 사용자 정의형 값 형식에는 struct라는 키워드가 사용되고 사용자 정의형 참조 형식에는 class라는 키워드가 사용된다. 참고로, 값 형식에는 null 값이 절대로 존재할 수 없다. C#에서 null 값은 참조 형식에만 할당할 수 있다. 진행하는 동안 이런 차이점을 계속 유념해야 한다.

만족할만한 성능을 얻으려면 불가피한 경우를 제외하고 박싱을 피하는 것이 바람직하다. 그러나 스택에 할당된 값 형식을 힙에 할당된 참조 형식으로 변환해야 하는 경우도 종종 있다. 이 과정을 박싱(boxing)이라 한다. 박싱 순서는 다음과 같다.

  1. 힙에 공간을 할당한다.
  2. 가비지 컬렉터에게 새 객체에 대해 알린다.
  3. 값 형식 객체의 데이터를 새 참조 형식 객체에 복사한다.

방식은 가급적 기피해야 한다는 사실 잊지 말자!

성가신 컴파일러 (compiler)

불필요한 힙 할당과 방식을 피한 채 즐거운 마음으로 코드를 작성하고 있다고 가정해보자. 당연히 트리도 몇 개 생길 테고 트리의 크기도 지정된 기간에 따라 확장되기 마련이다.


interface HasSize {
   int CalculateSize();
}

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

   public int CalculateSize() {
       return years*3;
   }
}

코드의 다른 곳에서는 다음과 같이 편리한 방법으로 (Tree 객체를 포함한) 여러 가지 객체의 크기의 누계를 낼 수 있다.


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(Intermediate Language) 코드도 조금만 살펴보자.


// 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

// Set up the arguments for the method and it call
IL_001a: ldloc.0
IL_001b: ldarg.0
IL_001c: ldloc.1
IL_001d: ldelema !!T
IL_0022: constrained. !!T
IL_0028: callvirt instance int32 Unity.IL2CPP.IntegrationTests.Tests.ValueTypeTests.ValueTypeTests/
                                   IHasSize::CalculateSize()

IL_002f: // Do the next loop iteration...

C# 컴파일러가 박싱을 통해 if(things[i] != null) 검사 구문을 실행했다! 형식 T가 이미 참조 형식인 경우 opcode 구문에 그리 큰 비용이 들지 않는다. 기존의 포인터를 배열 요소에 반환하기 때문이다. 그러나 T 형식이 (Tree 형식처럼) 값 형식인 경우 opcode 구문에 대단히 많은 비용이 소요된다. 물론, 값 형식에는 절대 null 값이 존재할 수 없다. 그렇다면 검사 구문을 먼저 실행해야 하는 이유는 뭘까? 그리고 100개 혹은 심지어 1000개나 되는 Tree 객체의 크기를 계산해야 하는 경우라면 어떻게 될까? 이럴 때는 불필요해 보이던 박싱이 갑자기 아주 요긴한 수단으로 돌변한다.

실행할 필요가 없는 코드가 가장 빠른 코드

C# 컴파일러는 T 형식이 필요로 하는 일반적 실행 방법을 지원해야 한다. 그러나 IL2CPP같은 컴파일러는 좀 더 "공격적"이어서 실행해야 할 코드는 생성하고 실행할 필요가 없는 코드는 생성하지 않는다!

IL2CPP는 T 형식이 Tree 형식인 경우에 특히 The TotalSize<T> 방법을 실행한다. 위의 IL 코드는 생성된 C++ 코드에서 아래와 같은 양상을 보인다.


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...

값 형식 객체에 절대 null 값이 존재할 수 없다는 사실을 앞서 설명했는데 그런 이유로 IL2CPP는 opcode 구문이 값 형식에 불필요하다고 판단했다. 몇 개의 명령어가 포함된 루프에서 이와 같이 불필요한 할당과 데이터 복사 작업을 배제하면 성능에 상당히 긍정적인 영향을 미칠 수 있다.

요약

이번 시리즈에서 논의했던 다른 마이크로 최적화 방법과 마찬가지로 이번 최적화 방법도 .NET 코드 생성기에서 흔히 사용할 수 있다. 유니티가 현재 사용 중인 모든 Scripting Backend는 이와 같은 최적화를 실시하므로 사용자는 코드 작업에만 전념할 수 있다

모쪼록 마이크로 최적화에 관한 이번 미니시리즈가 도움이 됐기를 바란다. 유니티는 앞으로도 계속 현재 사용 중인 코드 생성기와 런타임을 개선하는 한편, 암암리에 진행되는 마이크로 최적화에 관한 더욱 구체적인 정보를 제공하는 데 힘쓸 것이다.

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

Is this article helpful for you?

Thank you for your feedback!

다루는 주제
관련 게시물