IL2CPP 내부: 일반 공유 구현

이 글은 IL2CPP 내부 시리즈의 다섯 번째 포스팅입니다.
지난 포스트에서는 IL2CPP 스크립팅 백엔드용으로 생성된 C++ 코드에서 메서드가 호출되는 방식을 살펴봤습니다. 이 게시물에서는 이러한 기능이 어떻게 구현되는지 살펴봅니다. 특히 IL2CPP로 생성된 코드의 가장 중요한 기능 중 하나인 일반 공유에 대해 더 잘 이해하려고 노력할 것입니다. 제네릭 공유를 사용하면 여러 제네릭 메서드가 하나의 공통 구현을 공유할 수 있습니다. 이로 인해 IL2CPP 스크립팅 백엔드의 실행 파일 크기가 크게 감소합니다.
일반 공유는 새로운 개념이 아니며, Mono와 .Net 런타임 모두 일반 공유를 사용합니다. 처음에 IL2CPP는 일반 공유를 수행하지 않았습니다. 최근의 개선으로 더욱 강력하고 유익한 기능을 갖추게 되었습니다. il2cpp.exe는 C++ 코드를 생성하므로 메서드 구현이 공유되는 위치를 확인할 수 있습니다.
참조 유형과 값 유형에 대해 일반 메서드 구현이 어떻게 공유되는지(또는 공유되지 않는지) 살펴보겠습니다. 또한 일반 매개변수 제약 조건이 일반 공유에 어떤 영향을 미치는지 살펴볼 것입니다.
이 시리즈에서 설명하는 모든 내용은 구현에 대한 세부 사항이라는 점을 기억하세요. 여기서 논의되는 주제와 코드는 향후 변경될 수 있습니다. 하지만 저희는 가능하면 이런 세부 사항을 공개하고 토론하는 것을 좋아합니다!
일반 공유란 무엇인가요?
C#으로 List<T> 클래스에 대한 구현을 작성한다고 가정해 보겠습니다. 그 구현은 T 유형에 따라 달라지나요? 리스트<스트링>과 리스트<객체>에 대해 동일한 추가 메서드 구현을 사용할 수 있나요? List<DateTime>은 어떨까요?
사실 제네릭의 힘은 이러한 C# 구현을 공유할 수 있고 제네릭 클래스 List<T>가 모든 T에서 작동한다는 것입니다. 하지만 List가 C#에서 어셈블리 코드(Mono처럼) 또는 C++ 코드(IL2CPP처럼)처럼 실행 가능한 것으로 변환되면 어떻게 될까요? 추가 메서드의 구현을 공유할 수 있나요?
예, 대부분의 경우 공유할 수 있습니다. 이 글에서 살펴보겠지만, 일반 메서드의 구현을 공유하는 기능은 거의 전적으로 해당 타입 T의 크기에 따라 달라집니다. T가 문자열이나 객체와 같은 참조 타입이면 항상 포인터의 크기입니다. T가 값 유형(예: int 또는 DateTime)인 경우 크기가 달라질 수 있으며 상황이 조금 더 복잡해집니다. 공유할 수 있는 메서드 구현이 많을수록 결과 실행 코드의 크기가 작아집니다.
일반 공유를 구현한 개발자 마크 프롭스트는 Mono가 일반 공유를 수행하는 방법에 대한 훌륭한 시리즈 포스팅을 보유하고 있습니다. 여기서는 일반적인 공유에 대해서는 자세히 다루지 않겠습니다. 대신 IL2CPP가 일반 공유를 수행하는 방법과 시기를 살펴보겠습니다. 이 정보가 프로젝트의 실행 가능한 크기를 더 잘 분석하고 이해하는 데 도움이 되길 바랍니다.
IL2CPP는 무엇을 공유하나요?
현재 IL2CPP는 T가 일부 제네릭 타입인 경우 일부 제네릭 타입 <T>에 대한 제네릭 메서드 구현을 공유합니다:
- 모든 참조 유형(예: 문자열, 객체 또는 사용자 정의 클래스)
- 정수 또는 열거형 유형
IL2CPP는 T가 값 유형인 경우 각 값 유형의 크기가 필드 크기에 따라 다르기 때문에 일반 메서드 구현을 공유하지 않습니다.
실제로, 이는 T가 참조 유형인 일부 일반 유형<T>의 새로운 사용법을 추가하면 실행 파일 크기에 미치는 영향이 최소화된다는 의미입니다. 그러나 T가 값 유형인 경우 실행 파일 크기가 영향을 받습니다. 이 동작은 Mono 및 IL2CPP 스크립팅 백엔드 모두에서 동일합니다. 더 자세한 내용을 알고 싶으시다면 지금부터 몇 가지 구현 세부 사항을 살펴보겠습니다!
설정
Windows에서 Unity 5.0.2p1을 사용하고 WebGL 플랫폼용으로 빌드할 것입니다. 빌드 설정에서 "개발 플레이어" 옵션을 활성화했고 "예외 활성화" 옵션은 "없음" 값으로 설정했습니다. 이 게시물의 스크립트 코드는 우리가 조사할 일반 유형의 인스턴스를 생성하는 드라이버 메서드로 시작합니다:
public void DemonstrateGenericSharing() {
var usesAString = new GenericType<string>();
var usesAClass = new GenericType<AnyClass>();
var usesAValueType = new GenericType<DateTime>();
var interfaceConstrainedType = new InterfaceConstrainedGenericType<ExperimentWithInterface>();
}다음으로 이 메서드에 사용되는 유형을 정의합니다:
class GenericType<T> {
public T UsesGenericParameter(T value) {
return value;
}
public void DoesNotUseGenericParameter() {}
public U UsesDifferentGenericParameter<U>(U value) {
return value;
}
}
class AnyClass {}
interface AnswerFinderInterface {
int ComputeAnswer();
}
class ExperimentWithInterface : AnswerFinderInterface {
public int ComputeAnswer() {
return 42;
}
}
class InterfaceConstrainedGenericType<T> where T : AnswerFinderInterface {
public int FindTheAnswer(T experiment) {
return experiment.ComputeAnswer();
}
}그리고 모든 코드는 모노비헤이비어에서 파생된 HelloWorld라는 클래스에 중첩되어 있습니다.
이 시리즈의 첫 번째 글에서 설명한 대로 il2cpp.exe의 명령줄을 보면 --enable-generic-sharing 옵션이 포함되어 있지 않다는 것을 알 수 있습니다. 그러나 일반 공유는 여전히 발생하고 있습니다. 이제 더 이상 선택 사항이 아니며 모든 경우에 적용됩니다.
참조 유형에 대한 일반 공유
가장 자주 발생하는 일반적인 공유 사례인 참조 유형부터 살펴보겠습니다. 관리 코드의 모든 참조 유형은 System.Object에서 파생되므로 생성된 C++ 코드의 모든 참조 유형은 Object_t 유형에서 파생됩니다. 그런 다음 모든 참조 유형은 Object_t* 유형을 자리 표시자로 사용하여 C++ 코드에서 표현할 수 있습니다. 이것이 왜 중요한지 잠시 후에 살펴보겠습니다.
생성된 DemonstrateGenericSharing 메서드 버전을 검색해 보겠습니다. 제 프로젝트의 이름은 HelloWorld_DemonstrateGenericSharing_m4입니다. GenericType 클래스에서 네 가지 메서드에 대한 메서드 정의를 찾고 있습니다. Ctags를 사용하면 GenericType<string> 생성자, GenericType_1__ctor_m8의 메서드 선언으로 이동할 수 있습니다. 이 메서드 선언은 실제로는 #define 문으로, 메서드를 다른 메서드인 GenericType_1__ctor_m10447_gshared에 매핑한다는 점에 유의하세요.
뒤로 돌아가서 GenericType<AnyClass> 유형에 대한 메서드 선언을 찾아보겠습니다. 생성자 GenericType_1__ctor_m9의 선언으로 이동하면 동일한 함수인 GenericType_1__ctor_m10447_gshared에 매핑된 #define 문이라는 것을 알 수 있습니다!
GenericType_1__ctor_m10447_gshared의 정의로 이동하면 메서드 정의의 코드 주석에서 이 메서드가 관리 메서드 이름 HelloWorld/GenericType`1<System.Object>::.ctor()에 해당한다는 것을 알 수 있습니다. 제네릭타입<객체> 타입의 생성자입니다. 이 유형을 완전 공유 유형이라고 하는데, 이는 GenericType<T> 유형이 주어지면 참조 유형인 모든 T에 대해 모든 메서드의 구현이 이 버전을 사용하며, 여기서 T는 객체입니다.
생성된 코드에서 생성자 바로 아래를 보면 UsesGenericParameter 메서드의 C++ 코드가 보일 것입니다:
extern "C" Object_t * GenericType_1_UsesGenericParameter_m10449_gshared (GenericType_1_t2159 * __this, Object_t * ___value, MethodInfo* method)
{
{
Object_t * L_0 = ___value;
return L_0;
}
}일반 매개변수 T가 사용되는 두 곳(반환 유형과 단일 관리 인수의 유형)에서 생성된 코드는 Object_t* 유형을 사용합니다. 생성된 코드에서 모든 참조 유형은 Object_t*로 표현할 수 있으므로 참조 유형인 모든 T에 대해 이 단일 메서드 구현을 호출할 수 있습니다.
이 시리즈의 두 번째 블로그 게시물 (생성된 코드에 대해)에서 모든 메서드 정의는 C++의 자유 함수라고 언급했습니다. il2cpp.exe 유틸리티는 C++ 상속을 사용하여 C#에서 재정의된 메서드를 생성하지 않습니다. 그러나 il2cpp.exe는 유형에 C++ 상속을 사용합니다. 생성된 코드에서 문자열 "AnyClass_t"를 검색하면 C# 유형 AnyClass의 C++ 표현을 찾을 수 있습니다:
struct AnyClass_t1 : public Object_t
{
};AnyClass_t1은 Object_t에서 파생되므로 문제 없이 GenericType_1_UsesGenericParameter_m10449_gshared 함수에 대한 인수로 AnyClass_t1에 대한 포인터를 전달할 수 있습니다.
하지만 반환 값은 어떨까요? 파생 클래스에 대한 포인터가 예상되는 경우 기본 클래스에 대한 포인터를 반환할 수 없겠죠? GenericType<AnyClass>::UsesGenericParameter 메서드의 선언을 살펴보세요:
#define GenericType_1_UsesGenericParameter_m10452(__this, ___value, method) (( AnyClass_t1 * (*) (GenericType_1_t6 *, AnyClass_t1 *, MethodInfo*))GenericType_1_UsesGenericParameter_m10449_gshared)(__this, ___value, method)생성된 코드는 실제로 반환 값(Object_t* 유형)을 파생된 유형 AnyClass_t1*로 캐스팅하고 있습니다. 따라서 여기서 IL2CPP는 C++ 타입 시스템을 피하기 위해 C++ 컴파일러에 거짓말을 하고 있습니다. C# 컴파일러는 이미 UsesGenericParameter의 코드가 T 유형으로 불합리한 작업을 수행하지 않도록 강제했기 때문에 IL2CPP는 여기서 C++ 컴파일러에게 거짓말을 해도 안전합니다.
제약 조건이 있는 일반 공유
T 타입의 객체에서 일부 메서드를 호출할 수 있도록 허용하고 싶다고 가정해 보겠습니다. System.Object에 메서드가 많지 않으므로 Object_t*를 사용하면 이를 방지할 수 있지 않을까요? 예, 맞습니다. 하지만 먼저 일반 제약 조건을 사용하여 이 아이디어를 C# 컴파일러에 표현해야 합니다.
이 게시물의 스크립트 코드에서 인터페이스 컨스트레인트 제네릭 타입이라는 유형을 다시 살펴보세요. 이 일반 유형은 where 절을 사용하여 주어진 인터페이스인 AnswerFinderInterface에서 파생된 유형 T를 요구합니다. 이렇게 하면 ComputeAnswer 메서드를 호출할 수 있습니다. 인터페이스 메서드 호출에 대한 이전 블로그 게시물에서 인터페이스 메서드 호출에는 vtable 구조에서 조회가 필요하다는 점을 기억하세요. FindTheAnswer 메서드는 유형 T의 제약된 인스턴스에서 직접 함수를 호출하므로 C++ 코드에서는 여전히 완전히 공유된 메서드 구현을 사용할 수 있으며, 유형 T는 Object_t*로 표현됩니다.
HelloWorld_DemonstrateGenericSharing_m4 함수의 구현에서 시작하여 InterfaceConstrainedGenericType_1__ctor_m11 함수의 정의로 이동하면, 이 메서드가 다시 #define이며 InterfaceConstrainedGenericType_1__ctor_m10456_gshared 함수에 매핑되는 것을 볼 수 있습니다. 해당 함수 바로 아래에서 InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared 함수의 구현을 살펴보면, 실제로 이 함수가 Object_t* 인수를 받는 완전히 공유된 버전임을 알 수 있습니다. 이 함수는 InterfaceFuncInvoker0::Invoke 함수를 호출하여 관리되는 ComputeAnswer 메서드를 실제로 호출합니다.
extern "C" int32_t InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared (InterfaceConstrainedGenericType_1_t2160 * __this, Object_t * ___experiment, MethodInfo* method)
{
static bool s_Il2CppMethodIntialized;
if (!s_Il2CppMethodIntialized)
{
AnswerFinderInterface_t11_il2cpp_TypeInfo_var = il2cpp_codegen_class_from_type(&AnswerFinderInterface_t11_0_0_0);
s_Il2CppMethodIntialized = true;
}
{
int32_t L_0 = (int32_t)InterfaceFuncInvoker0<int32_t>::Invoke(0 /* System.Int32 HelloWorld/AnswerFinderInterface::ComputeAnswer() */, AnswerFinderInterface_t11_il2cpp_TypeInfo_var, (Object_t *)(*(&amp;___experiment)));
return L_0;
}
}IL2CPP는 모든 관리 인터페이스를 System.Object처럼 취급하기 때문에 생성된 C++ 코드 코드에서 이 모든 것이 함께 중단됩니다. 이는 다른 경우에도 il2cpp.exe가 생성한 코드를 이해하는 데 도움이 되는 유용한 경험 법칙입니다.
베이스 클래스가 있는 제약 조건
C#에서는 인터페이스 제약 조건 외에도 제약 조건이 기본 클래스가 될 수 있습니다. IL2CPP는 모든 베이스 클래스를 System.Object처럼 취급하지 않으므로 베이스 클래스 제약 조건에 대한 일반 공유는 어떻게 작동할까요?
베이스 클래스는 항상 참조 유형이므로 IL2CPP는 이러한 유형에 대해 완전히 공유된 버전의 제네릭 메서드를 사용합니다. 제약된 타입의 필드를 사용하거나 메서드를 호출해야 하는 모든 코드는 C++에서 적절한 타입으로 형변환을 수행합니다. 다시 말하지만, 여기서는 C# 컴파일러에 의존하여 제네릭 제약 조건을 올바르게 적용하고 C++ 컴파일러에 유형에 대해 거짓말을 합니다.
가치 유형을 사용한 일반 공유
이제 HelloWorld_DemonstrateGenericSharing_m4 함수로 돌아가서 GenericType<DateTime에 대한 구현을 살펴 보겠습니다. DateTime 유형은 값 유형이므로 GenericType<DateTime>은 공유되지 않습니다. 이 유형에 대한 생성자 선언인 GenericType_1__ctor_m10으로 이동합니다. 여기에는 다른 경우와 마찬가지로 #define이 있지만, #define은 GenericType_1__ctor_m10_gshared 함수에 매핑되며, 이 함수는 다른 클래스에서는 사용되지 않고 GenericType<DateTime> 클래스에만 해당합니다.
일반 공유에 대한 개념적 생각
일반 공유의 구현은 이해하고 따르기 어려울 수 있습니다. 문제 공간 자체에는 병적인 사례(예: 기이하게 반복되는 템플릿 패턴)가 가득합니다. 몇 가지 개념을 생각해 보면 도움이 될 수 있습니다:
- 제네릭 타입의 모든 메서드 구현은 공유됩니다.
- 일부 제네릭 타입은 메서드 구현을 자신과만 공유합니다(예: 값 타입 제네릭 파라미터가 있는 제네릭 타입, 위의 GenericType).
- 참조 유형 제네릭 매개 변수가 있는 제네릭 유형은 완전히 공유되며, 모든 유형 매개 변수에 대해 항상 System.Object로 구현을 사용합니다.
- 유형 매개변수가 두 개 이상인 일반 유형은 해당 유형 매개변수 중 하나 이상이 참조 유형인 경우 부분적으로 공유할 수 있습니다.
il2cpp.exe 유틸리티는 항상 모든 일반 유형에 대해 완전히 공유된 메서드 구현을 생성합니다. 다른 메소드 구현이 사용될 때만 생성합니다.
일반 메소드 공유
제네릭 타입에 대한 메서드 구현을 공유할 수 있는 것처럼 제네릭 메서드에 대한 메서드 구현도 공유할 수 있습니다. 원본 스크립트 코드에서 UsesDifferentGenericParameter 메서드가 GenericType 클래스와 다른 유형 매개 변수를 사용하는 것을 확인할 수 있습니다. GenericType 클래스에 대한 공유 메서드 구현을 살펴봤을 때 UsesDifferentGenericParameter 메서드가 보이지 않았습니다. 생성된 코드에서 "UsesDifferentGenericParameter"를 검색하면 이 메서드의 구현이 GenericMethods0.cpp 파일에 있는 것을 확인할 수 있습니다:
extern "C" Object_t * GenericType_1_UsesDifferentGenericParameter_TisObject_t_m15243_gshared (GenericType_1_t2159 * __this, Object_t * ___value, MethodInfo* method)
{
{
Object_t * L_0 = ___value;
return L_0;
}
}이것은 메서드 구현의 완전히 공유된 버전으로, Object_t* 유형을 허용합니다. 이 메서드는 제네릭 유형에 속하지만, 제네릭이 아닌 유형의 제네릭 메서드에서도 동작은 동일합니다. 사실상 il2cpp.exe는 일반 매개변수를 포함하는 메서드 구현에 대해 항상 가능한 최소한의 코드를 생성하려고 시도합니다.
맺는말
일반 공유는 IL2CPP 스크립팅 백엔드가 처음 출시된 이후 가장 중요한 개선 사항 중 하나였습니다. 생성된 C++ 코드를 최대한 작게 만들어 동작이 다르지 않은 메서드 구현을 공유할 수 있습니다. 바이너리 크기를 계속 줄여나가면서 메서드 구현을 공유할 수 있는 더 많은 기회를 활용하기 위해 노력할 것입니다.
다음 포스트에서는 p/invoke 래퍼가 생성되는 방법과 관리형 코드에서 네이티브 코드로 유형을 마샬링하는 방법에 대해 살펴보겠습니다. 다양한 유형의 마샬링 비용을 확인하고 마샬링 코드의 문제를 디버그할 수 있습니다.
