[Unity] 제네릭의 컴파일

KYO
KYO
∙∙조회 14

들어가며

이전 글을 보고오기.

제네릭을 쓰면 빌드가 오래걸린다거나, 코드가 복사된다거나 하는 이야기를 들어본적이 있다.

이 글에서는 그 점을 다룬다.

제네릭의 컴파일

일단 Unity의 컴파일은 두 번 나눠서 이루어진다는 점을 떠올려보자.

  • Roslyn에 의한 C# --> IL
  • JIT이나 AOT로부터 IL --> 기계어

그리고 제네릭은 T가 구조체냐 참조냐에 따라서 아래와 같이 다르게 컴파일된다.

  • Roslyn에 의해 IL이 될 땐 코드가 복사되지 않는다. 여전히 T로 남아있다.
  • JIT, AOT에 의해 기계어가 될 때, 구조체는 타입마다 기계어가 새로 생성된다. 참조는 아니다.

위 차이는 T의 크기에 있다. List<T>.Add를 떠올려보면, T를 추가할 때 몇 바이트를 복사해서 offset은 얼마나 줘야하는지 궁금해진다. 기계어가 되는 시점에서 T의 크기가 정해져있어야 그 기계어가 고정된 명령으로써 존재할 수 있다. 만약 안 정해지면 런타임에 또 물어봐야됨.

값타입은 크기가 제각각이다. 그래서 기계어로 컴파일되면 각 타입마다 기계어가 생성된다. (List<int>, List<Vector3>, ...)

참조 타입의 주소는 (64비트 환경에서) 8바이트 고정이다. 그래서 타입별로 코드를 따로 둘 필요가 없다. 그래서 참조 타입 공유 코드를 쓴다.

이로부터 알 수 있는 실무적 팁이 있다.

T에 참조타입이 들어간 제네릭은 그 참조타입 종류의 수와 IL2CPP 빌드의 실행파일의 크기가 상관이 없다. 대신 값타입이 들어간 제네릭이 그 크기를 늘린다.

IL2CPP 괴롭히기

우리가 제네릭을 아래와 같이 만들면,

  • Struct<Struct<Struct<Struct<...>>>>

IL2CPP가 닫힌 타입을 찾지 못한다고 한다. 이외에도 다음과 같은 경우가 있다.

MakeGenericType, MakeGenericMethod로 실행 중에 닫힌 타입을 만드는 경우. 리플렉션 기반 직렬화 라이브러리가 내부에서 이 방식을 쓴다.

컴파일 시점에 대상을 추론할 수 없는 제네릭 가상 메서드 호출 일부

그래서 과거엔 ExecutionEngineException 라는 오류가 발생했다는데, 요즘은 이 오류를 뱉는 대신 Full Generic Sharing을 쓴다고 한다. (Unity 2022.1부터. Unity6 포함)

Full Generic Sharing

IL2CPP 빌드 시점에 T가 무엇인지 알 수 없었던 것들을 위한 완전 공유 코드이다. 이러한 T들은 하나의 공용 코드를 쓰는데, 런타임에 타입을 조회하여야하므로 그 비용이 발생한다.

제약으로 where T : class를 붙이면 어차피 8바이트로 취급하면 돼서 Full Generic Sharing 코드를 쓸 필요가 없다. 그래서 참조 타입 공유 코드를 그대로 쓰므로 런타임에서 크기 조회와 값/참조 여부 조회를 건너뛸 수 있다. 반면 struct 제약은 일부 최적화가 이뤄진다고는 하나, 어차피 구조체 크기가 제각각이라 크기 조회는 해야해서 여전히 타입 전용 코드보단 느리다.

끗.