
이 장에서는 주로 C# 코드의 성능 튜닝 방법에 대해 소개한다. C#의 기초적인 표기법에 대해서는 다루지 않고, 성능이 요구되는 게임 개발에서 유의해야 할 설계와 구현에 대해 설명한다.
[2.5.2 가비지 컬렉션]에서 소개했지만, 본 절에서는 구체적으로 어떤 처리를 할 때 GC.Alloc을 하는지에 대해 먼저 알아보자.
우선 가장 이해하기 쉽게 GC.Alloc이 발생하는 경우이다.
<예시> : 매 프레임마다 GC.Alloc하는 코드
private void Update()
{
const int listCapacity = 100;
// List<int>의 new에서 GC.
var list = new List<int>(listCapacity);
for (var index = 0; index < listCapacity; index++)
{
// 특별한 의미는 없지만 index를 List에 담는다.
list.Add(index);
}
// list에서 무작위로 값을 가져오는
var random = UnityEngine.Random.Range(0, listCapacity);
var randomValue = list[random];
// ... 임의의 값에서 무언가 하기 ...
}
이 코드의 가장 큰 문제는 매 프레임마다 실행되는 Update 메소드에서 List<int>를 new하고 있다는 점이다.
이를 수정하려면 List<int>를 미리 생성하여 사용함으로써 매 프레임마다 GC.Alloc을 회피할 수 있다.
<예시> : 매 프레임의 GC.Alloc을 제거한 코드
private static readonly int listCapacity = 100;
// 미리 List를 생성해 두기
private readonly List<int> _list = new List<int>(listCapacity);
private void Update()
{
_list.Clear();
for (var index = 0; index < listCapacity; index++)
{
// 특별한 의미는 없지만 index를 List에 담는다.
list.Add(index);
}
// list에서 무작위로 값을 가져오는
var random = UnityEngine.Random.Range(0, listCapacity);
var randomValue = list[random];
// ... 임의의 값에서 무언가 하기 ...
}
여기 샘플 코드와 같은 무의미한 코드를 작성할 일은 없을 것 같지만 비슷한 예제는 생각보다 많이 찾아볼 수 있다.
<GC.Alloc을 없애면>
눈치채신 분도 있겠지만, 위 예시의 샘플 코드는 아래 예시만으로도 충분하다.
var randomValue = UnityEngine.Random.Range(0, listCapacity);
// ... 임의의 값으로 무언가 하기 ...성능 튜닝에서 GC.Alloc을 없애는 것을 고려하는 것도 중요하지만 무의미한 계산을 생략하는 것을 항상 고려하는 것이 속도 향상의 한 단계가 될 수 있다.
람다식도 유용한 기능이지만, 이 역시 사용법에 따라 GC.Alloc이 발생하기 때문에 게임에서는 사용할 수 있는 곳이 한정되어 있다. 여기서는 다음과 같은 코드가 정의되어 있다고 가정한다.
<예시> : 람다식 샘플의 전제 코드
// 멤버 변수
private int _memberCount = 0;
// static 변수
private static int _staticCount = 0;
// 멤버 메소드
private void IncreamentMemeberCount()
{
_memberCount++;
}
// static 메소드
private static void IncreamentStaticCount()
{
_staticCount++;
}
// 받은 Action을 Invoke만 하는 멤버 메소드.
private void InvokeActionMethod(System.Action action)
{
action.Invoke();
}
이때 다음과 같이 람다식 내에서 변수를 참조하면 GC.Alloc이 발생한다.
<예시> : 람다식 내에서 변수를 참조하여 GC.Alloc하는 경우
// 멤버 변수를 참조한 경우 Delegate Allocation 발생
InvokeActionMethod(() => { _memberCount++;});
// 로컬 변수를 참조한 경우 Closure Allocation이 발생한다.
int count = 0;
// 위와 동일한 Delegate Allocation이 발생.
InvokeActionMethod(() => { count++;});
단, 아래와 같이 static 변수를 참조하면 이러한 GC.Alloc을 피할 수 있다.
<예시> : 람다 수식 내에서 static 변수를 참조하여 GC.Alloc을 하지 않는 경우
// static 변수를 참조한 경우 GC Alloc이 발생하지 않는다.
InvokeActionMethod(() => { _staticCount++; });
람다식 내 메소드 참조도 작성 방법에 따라 GC.Alloc이 되는 방식이 다르다.
<예시> : 람다 수식 내에서 메소드를 참조하여 GC.Alloc 하는 경우
// 멤버 메소드를 참조한 경우 Delegate Allocation 발생
InvokeActionMethod(() => { IncreamentMemberCount(); });
// 멤버 메소드를 직접 지정한 경우 Delegate Allocation 발생
InvokeActionMethod(InCreamentMemberCount);
// static 메소드를 직접 지정한 경우 Delegate Allocation이 발생
InvokeActionMethod(InCreamentStaticCount);
이를 피하기 위해서는 아래와 같이 statement 형식으로 static 메소드를 참조해야 한다.
<예시> : 람다식 내에서 메소드를 참조하여 GC.Alloc을 하지 않는 경우
// 람다식 내에서 static 메소드를 참조한 경우, Non Alloc이 된다.
InvokeActionMethod(() => { IncreamentStaticCount(); });
이렇게 하면 처음에만 Action이 new되지만, 내부적으로 캐싱되어 두 번째부터는 GC.Alloc을 피할 수 있다.
하지만 모든 변수나 메소드를 정적으로 만드는 것은 코드의 안전성이나 가독성 측면에서 그다지 채택할 만한 방법은 아니다. 고속화가 필요한 코드에서는 static을 많이 사용하여 GC.Alloc을 없애는 것보다 매 프레임 또는 불규칙한 타이밍에 발동하는 이벤트 등은 람다식을 사용하지 않고 설계하는 것이 더 안전하다고 할 수 있다.
제네릭을 사용했을 때 다음과 같은 경우, 어떤 이유로 박스화될 가능성이 있을까?
<예시> : 제네릭을 사용하여 박스화할 수 있는 예시
public readonly struct GenericStruct<T> : IEquatable<T>
{
private readonly T _value;
public GenericSTruct(T value)
{
_value = value;
}
public bool Equals(T other)
{
var result = _value.Equals(other);
return result;
}
}
이 사례에서 프로그래머는 GenericStruct에 IEquatable<T> 인터페이스를 구현했지만 T에 제한을 두는 것을 잊어버렸다. 그 결과 IEquatable<T> 인터페이스가 구현되지 않은 타입을 T에 지정할 수 있게 되어 Object 타입으로 암묵적으로 캐스트되어 아래의 Equals가 사용되는 경우가 존재하게 된다.
<예시> : Object.cs
public virtual vool Equals(object obj);
예를 들어 IEquatable<T> 인터페이스가 구현되지 않은 struct를 T에 지정하면 Equals의 인수로 object로 캐스트되기 때문에 박스화가 발생한다. 이런 일이 발생하지 않도록 사전에 방지하려면 다음과 같이 변경하면 된다.
<예시> : 박스화되지 않도록 제한한 예시
public readonly struct GenericOnlyStruct<T> : IEquatable<T>
where T : IEquatable<T>
{
private readonly T _value;
public GenericOnlyStruct(T _value)
{
_value = value;
}
public bool Equals(T other)
{
var result = _value.Equals(other);
return result;
}
}
where 구문 (generic type constraint)을 사용하여 T가 받아들을 수 있는 타입을 IEquatable<T>를 구현하는 타입으로 제한해 주면 이러한 예기치 못한 박스화를 방지할 수 있다.
<본연의 목적을 잃지 않기>
[2.5.2 가비지 컬렉션]에서 소개한 것처럼 게임에서는 런타임 중 GC.Alloc을 피하려는 의도가 있기 때문에 구조체가 선택되는 경우도 많이 존재한다. 그러나 GC.Alloc을 줄이고 싶다고 해서 무작정 모든 것을 구조체로 만들어도 속도가 빨라지는 것은 아니다.
Alloc을 피하기 위해 구조체를 도입한 결과, 기대했던 대로 GC관련 비용은 줄었지만 데이터 크기가 커서 값형 복사 비용이 발생한다. 그래서 결과적으로 비효율적인 처리가 되는 경우를 흔히 볼 수 있다.
또한, 이를 더 피하기 위해 메소드의 인자를 참조 전달을 이용하여 복사 비용을 줄이는 방법도 있다. 결과적으로 속도를 높일 수 있지만, 이 경우 처음부터 클래스를 선택하고 인스턴스를 미리 생성하여 사용하는 구현을 고려해야한다. GC.Alloc을 없애는 것이 목적이 아니라, 프레임당 처리 시간을 단축하는 것이 최종 목적임을 잊지말자.
[2.6 알고리즘과 계산량]에서 소개한 것처럼 루프는 데이터 개수에 따라 시간이 오래 걸리게 된다. 또한, 겉으로 보기에 비슷한 처리로 보이는 루프도 코드 작성 방식에 따라 효율성이 달라질 수 있다.
여기서는 SharpLab을 이용하여 foreach/for을 사용한 List와 배열의 내용을 하나씩 가져오는 코드를 IL에서 C#으로 디컴파일한 결과를 살펴본다.
먼저 foreach에서 루프를 돌린 경우를 살펴본다. List에 값을 추가하는 부분은 생략했다.
<예시> : List를 foreach로 돌리는 예시
var list = new List<int>(128);
foreach (var val in list)
{
}
<예시> : List를 foreach로 돌리는 예제의 디컴파일 결과이다.
List<int>.Enumerator enumerator = new List<int>(128).GetEnumerator();
try
{
while (enumerator.MoveNext())
{
int current = enumerator.Current;
}
}
finally
{
((IDisposable)enumerator).Dispose();
}
foreach에서 돌린 경우, MoveNext()에서 열거자를 가져와서 Current에서 값을 참조하는 구현으로 되어 있음을 알 수 있다. 또한 list.cs의 MoveNext()의 구현을 보면 크기 확인 등 각종 속성 접근 횟수가 많아 인덱서에 의한 직접 접근보다 처리 횟수가 많은 것을 알 수 있다.
다음으로 for로 돌렸을 때를 살펴보자.
<예시> : List를 for로 돌리는 예시
var list = new List<int>(128);
for (var i = 0; i< list.Count; i++)
{
var val = list[i];
}
<예시> : List를 for로 돌렸을 때의 디컴파일 결과
List<int> list = new List<int>(128);
int num = 0;
while (num < list.Count)
{
int num2 = list[num];
num++;
}
C#에서 for문장은 while문장의 당의 구문이며, 인덱서 (public T this[int index]에 의한 참조로 가져온 것을 알 수 있다. 또한, 이 while문을 자세히 살펴보면 조건문에 list.Count가 들어 있다. 즉, Count 속성에 대한 접근이 루프를 반복할 때마다 이루어지게 된다. Count의 개수가 많아질수록 Count속성에 대한 접근 횟수가 비례적으로 증가하여 개수에 따라서는 무시할 수 없는 부하가 발생하게 된다. 만약 루프 내에서 Count가 변하지 않는다면, 루프 앞에 캐싱을 해두면 프로퍼티 액세스의 부하를 줄일 수 있다.
<예시> : List를 for로 돌리는 예시 >> 개선판
var count = list.Count;
for (var i = 0; i < count; i++)
{
var val = list[i];
}
<얘시> : List를 for로 돌리는 예시 >> 개선판의 디컴파일 결과
List<int> list = new List<int>(128);
int count = list.Count;
int num = 0;
while (num < count)
{
int num2 = list[num];
num++;
}
Count를 캐싱하여 속성 접근 횟수가 줄어들어 속도가 빨라졌다. 이번 루프 중 비교는 둘 다 GC.Alloc에 의한 부하가 아니라 구현 내용의 차이로 인한 차이이다.
또한, 배열의 경우 foreach도 최적화되어 for에서 설명한 것과 거의 변화가 없는 것을 확인할 수 있다.
<예시> : 배열을 foreach로 돌리는 예제
var array = new int[128];
foreach (var val in array)
{
}
<예시> : 배열을 foreach로 돌리는 예제의 디컴파일 결과
int[] array = new int[128];
int num = 0;
while (num < array.Length)
{
int num2 = array[num];
num++;
}
검증을 위해 데이터 10,000,000개의 데이터를 임의로 미리 할당하고, List<int>에 할당된 데이터의 합을 계산하는 것으로 실험했다. 검증 환경은 Pixel 3a, Unity 2021.3.1f1에서 진행했다.
| 종류 | Time(ms) |
|---|---|
| List:foreach | 66.43 |
| List:for | 62.49 |
| List:for(카운트 캐시) | 55.11 |
| 배열:for | 30.53 |
| 배열:foreach | 23.75 |
List<int>의 경우, 세부적으로 조건을 맞춰 비교해보면 foreach보다 for, Count의 최적화를 적용한 for가 보다 더 빠르다는 것을 알 수 있다. List의 foreach는 Count의 최적화를 적용한 for로 다시 작성하면 foreach의 처리에서 MoveNext()와 Current속성의 오버헤드를 줄여 속도를 높일 수 있다.
또한, List와 배열을 각각 가장 빠른 것으로 비교하면 List보다 배열이 약 2.3배 이상 빨라졌다. foreach와 for에서 IL이같은 결과가 나오도록 작성해도 foreach가 더 빠른 결과를 보여 배열의 foreach가 충분히 최적화되어 있다고 할 수 있다.
위의 결과를 통해 데이터 수가 많고 처리 속도를 빠르게 해야 하는 상황이라면 List<T>보다는 배열을 고려해야 할 것이다. 하지만 필드에 정의한 List를 로컬 캐시하지 않고 그대로 참조하는 등 재작성이 미흡한 경우 속도를 높일 수 없으므로 foreach에서 for으로 변경할 때는 반드시 계측을 하면서 적절히 재작성해야한다.
여러 차례 언급했지만, 게임 개발에서 오브젝트를 동적으로 생성하지 않고 미리 생성해서 사용하는 것이 중요하다. 이를 오브젝트 풀링이라고 한다. 예를 들어, 게임 단계에서 사용할 오브젝트를 로드 단계에서 일괄적으로 생성하여 풀링해두고, 사용할 때는 풀링된 오브젝트에 대입과 참조만 하면서 처리하면 게임 단계에서 GC.Alloc을 피할 수 있다.
또한, 오브젝트 풀링은 할당량 감소 외에도 화면을 구성하는 오브젝트를 매번 다시 만들지 않고 화면 전환을 가능하게 함으로써 로딩 시간 단축을 실현하거나, 계산 비용이 매우 높은 처리 결과를 보관하여 무거운 계산을 여러 번 실행하는 것을 피하는 등 다양한 용도로 사용된다.
여기서는 넓은 의미로 오브젝트라고 표현했지만, 이는 최소 단위의 데이터에 국한되지 않으며, Coroutine이나 Action등에도 해당된다. 예를 들어, Coroutine을 미리 예상 실행 시간 이상 생성해두고, 필요한 타이밍에 사용해서 소진하는 것도 고려해 볼 수 있다. 2분짜리 게임으로 최대 20회 정도 실행될 것 같으면 IEnumerator를 미리 생성해두고, 사용할 때는 StartCoroutine만 사용하면 생성 비용을 절감할 수 있다.
string 객체는 문자열을 나타내는 System.Char 객체의 순차석 컬렉션이다. string Alloc은 한 번의 사용으로 GC.Alloc이 쉽게 일어난다. 예를 들어, 문자열 연결 연산자 + 를 이용하여 두 문자열을 연결하면 새로운 string 객체를 생성하게 된다. string의 값은 생성 후 변경할 수 없으므로, 값이 변경된 것처럼 보이는 조작은 새로운 string 객체를 생성하여 반환한다.
<예시> : 문자열 결합으로 string을 만드는 경우
private string CreatePath()
{
var path = "root";
path += "/";
path += "Hoge";
path += "/";
path += "Fuga";
return path;
}
위 예제의 경우, 각 문자열 결합에서 string이 생성되어 총 164Byte의 할당량이 발생한다.
문자열이 자주 변경되는 경우, 값 변경이 가능한 StringBuilder를 이용하면 string 오브젝트의 대량 생성을 방지할 수 있다. 문자 연결, 삭제 등의 작업을 StringBuilder 객체에서 수행한 후 최종적으로 값을 가져와 string 객체에 ToString() 함으로써 획득 시에 메모리 할당을 줄일 수 있다. 또한, StringBuilder를 사용할 때는 Capacity를 반드시 설정해야 한다. 미지정 시에는 초기값이 16으로 설정되어 Append등에서 문자 수가 증가하여 버퍼가 확장될 때 메모리 확보와 값의 복사가 진행되므로 부주의한 확장이 발생하지 않는 적절한 Capacity를 설정하도록 하자.
<예시> : StringBuilder로 string을 만드는 경우
private readonly StringBuilder _stringBuilder = new StringBuilder(16);
private string CreatePathFromStringBuilder()
{
_stringBuilder.Clear();
_stringBuilder.Append("root");
_stringBuilder.Append("/");
_stringBuilder.Append("Hoge");
_stringBuilder.Append("/");
_stringBuilder.Append("Fuga");
return _stringBuilder.ToString();
}
StringBuilder를 사용한 예에서는 미리 StringBuilder를 생성 해두면, 이후부터는 생성한 문자열을 가져오는 ToString() 시 소요되는 50Byte의 할당으로 충분하다.
단,StringBuilder 역시 값의 조작 중 할당이 잘 일어나지 않을 뿐, 앞서 언급했듯이 ToString() 실행 시 string 객체를 생성하게 되므로 GC.Alloc을 피하고 싶을 때 사용하는 것은 권장하지 않는다. 또한 $""구문은 string.Format으로 변환되고,string.Format의 내부 구현은 StringBuilder가 사용되기 때문에 결국 ToString()의 비용은 피할 수 없다. 이전항의 오브젝트 활용법을 여기서도 적용해서 미리 사용할 가능성이 있는 문자열은 string 오브젝트를 미리 생성해서 사용하도록 하자.
하지만 게임 중에 문자열 조작과 string 객체를 생성해야 하는 경우도 있다. 이럴때는 문자열을 위한 버퍼를 미리 만들어 놓고, 그 버퍼를 그대로 사용할 수 있도록 확장해야 한다. unsafe같은 코드를 직접 구현하거나, ZString같은 유니티용 확장 기능을 갖춘 라이브러리를 도입하는 것도 고려해보자.
이 절에서는 LINQ를 사용하여 GC.Alloc을 완화하는 방법과 지연 평가의 포인트에 대해 설명한다.
<예시> : GC.Alloc이 발생하는 예시
var oneToTen = Enumerable.Range(1, 11).ToArray();
var query = oneToTen.Where(i => i % 2 == 0).Select(i => i * i);
위 예시에서 GC.Alloc이 발생하는 이유는 LINQ의 내부 구현에 기인한다. 또한 LINQ의 일부 메소드는 호출자의 타입에 따라 최적화를 하기 때문에 호출자의 타입에 따라 GC.Alloc의 크기가 달라진다.
<예시> : 타입별 실행 속도 검증
private int[] array;
private List<int> list;
private IEnumerable<int> ienumerable;
public void GlobalSetup()
{
array = Enumerable.Range(0, 1000).ToArray();
list = Enumerable.Range(0, 1000).ToList();
ienumerable = Enumerable.Range(0, 1000);
}
public void RunAsArray()
{
var query = array.Where(i => i % 2 == 0);
foreach (var i in query){}
}
public void RunAsList()
{
var query = list.Where(i => i % 2 ==0);
foreach(var i in query){}
}
public void RunAsIEnumerable()
{
var query = ienumerable.Where(i => i % 2 == 0);
foreach (var i in query){}
}
위 예시에 정의한 각 메소드의 벤치마크를 측정하면 아래 그럼과 같은 결과를 얻을 수 있다. 이 결과에서 T[] -> List<T> -> IEnumerable<T>순으로 힙 할당 크기가 커지는 것을 알 수 있다.
이처럼 LINQ를 사용하는 경우, 실행 시 타입을 의식하여 GC.Alloc의 크기를 줄일 수 있다.

<LINQ의 GC.Alloc의 원인>
LINQ 사용으로 인한 GC.Alloc의 원인 중 하나는 LINQ의 내부 구현이다. LINQ의 메소드들은 IEnumerable<T>를 받아 IEnumerable<T>를 반환하는 경우가 많은데, 이러한 API 설계로 인해 메소드 체인을 이용한 직관적인 설명이 가능하다. 이때 메소드가 반환하는 IEnumerable의 실체는 각 기능에 맞는 클래스의 인스턴스이다. LINQ는 내부적으로 IEnumerable<T>를 구현한 클래스를 인스턴스화하고, 더 나아가 루프 처리를 구현하기 위해 GetEnumerator()의 호출 등이 이루어지기 때문에 내부적으로 GC.Alloc이 발생하게 된다.
LINQ의 where, Select등의 메소드는 실제로 결과가 필요할 때까지 평가를 지연시키는 지연 평가이다. 반면 ToArray와 같이 즉시 평가가 되는 메소드도 정의되어 있다. 아래 예시의 경우를 생각해보자.
<예시> : 즉시 평가를 끼워 넣은 메소드
private static void LazyExpression()
{
var array = Enumerable.Range(0, 5).ToArray();
var sw = Stopwatch.StartNew();
var query = array.Where(i => i % 2 == 0).Select(HeavyProcess).ToArray();
Console.WriteLine($"Query: {sw.ElapsedMilliseconds}");
foreach (var i in query)
{
Console.WriteLine($"diff: {sw.ElapseMilliseconds}");
}
}
private static int HeavyProcess(int x)
{
Thread.Sleep(1000);
return x;
}
\\실행결과
Query: 3013
diff: 3032
diff: 3032
diff: 3032
위 예시를 보자. 즉시 평가가 되는 ToArray를 끝에 추가함으로써 query에 대입할 때 Where나 Select의 메소드를 실행하여 값을 평가한 결과를 반환한다. 따라서 HeavyProcess도 호출되므로 query를 생성하는 타이밍에 처리 시간이 걸리는 것을 알 수 있다.
이처럼 LINQ의 즉시 평가 메소드를 의도치 않게 호출하면 그 부분이 병목현상이 발생할 수 있다. ToArrayOrderBy, Count등 시퀀스 전체를 한 번 봐야 하는 메소드는 즉시 평가가 되므로 호출 시 비용을 의식하고 사용해야 한다.
지금까지 LINQ 사용 시 GC.Alloc의 원인과 완화 방법, 지연 평가의 포인트에 대해 설명했다. 이번 절에서는 LINQ를 사용하는 기준에 대해 설명한다. 전제적으로 LINQ는 편리한 언어 기능이지만, 사용하면 힙 할당과 실행 속도는 사용하지 않을 때보다 더 나빠진다. 실제로 마이크로 소프트의 Unity 성능 관련 권장사항에는 'Avoid use of LINQ'라고 명시되어 있다. LINQ를 사용한 경우와 사용하지 않은 경우로 동일한 로직을 구현한 코드를 비교해보자.
<예시> : LINQ 사용 유무에 따른 성능 비교
private int[] array;
public void GlobalSetup()
{
array = Enumerable.Range(0, 100_000_000).ToArray();
}
public void Pure()
{
foreach (var i in array)
{
if (i % 2 == 0)
{
var = i * i;
}
}
}
public void UseLinq()
{
var query = array.Where(i => i % 2 == 0).Select(i => i * i);
foreach (var i in query)
{
}
}
결과는 아래 그림이다. 실행 시간을 비교해보면 LINQ를 사용하지 않은 경우 대비 LINQ를 사용한 처리는 19배 정도나 더 많은 시간이 소요되는 것을 알 수 있다.

위의 결과에서 LINQ의 사용으로 인한 성능 저하는 분명하지만, LINQ를 사용함으로써 코딩의 의도가 잘 전달되는 경우도 있다. 이러한 동작을 파악한 후 LINQ를 사용할지, 사용할 경우의 규칙 등은 프로젝트 내에서 논의의 여지가 있을 것 같다.
async/await은 C# 5.0에서 추가된 언어 기능으로, 비동기 처리를 콜백을 사용하지 않고 한 줄의 동기적 처리처럼 작성할 수 있는 기능이다.
async가 정의된 메소드는 컴파일러에 의해 비동기 처리를 구현하기 위한 코드가 생성된다. 그리고 async 키워드가 있으면 컴파일러에 의한 코드 생성은 반드시 이루어진다. 따라서 아래 예시와 같이 동기적으로 완료될 가능성이 있는 메소드도 실제로는 컴파일러에 의한 코드 생성이 이루어지고 있다.
<예시> : 동기적으로 완료될 가능성이 있는 비동기 처리
using System;
using Sysem.Threading.Tasks;
namespace A {
public class B {
public async Task HogeAsync(int i) {
if (i == 0) {
Console.Writeline("i is 0");
return;
}
await Task.Delay(TimeSpan.FromSeconds(1));
}
public void Main() {
int i = int.Parse(Console.ReadLine());
Task.Run(() => HogeAsync(i));
}
}
}
위 예시와 같은 경우 동기적으로 완료될 가능성이 있는 HogeAsync를 분할하여 아래 예시처럼 구현함으로써 동기적으로 완료될 경우 불필요한 IAsyncStateMachine 구현의 상태 머신 구조체를 생성하는 비용을 생략할 수 있다.
<예시> : 동기 처리와 비동기 처리를 분할하여 구현하는 방법
using System;
using Sysem.Threading.Tasks;
namespace A {
public class B {
public async Task HogeAsync(int i) {
await Task.Delay(TimeSpan.FromSeconds(1));
}
public void Main() {
int i = int.Parse(Console.ReadLine());
if (i == 0) {
Console.Writeline("i is 0");
return;
} else {
Task.Run(() => HogeAsync(i));
}
}
}
}
<async/await의 구조>
async/await 구문은 컴파일 시 컴파일러에 의한 코드 생성을 통해 구현된다. async 키워드가 붙은 메소드는 컴파일 시점에 IAsyncStateMachine을 구현한 구조체를 생성하는 처리가 추가되고, wait 대상의 처리가 완료되면 상태를 진행하는 스테이트 머신을 관리하는 것으로 async/await의 기능을 구현하고 있다. 또한, 이 IAsyncStateMachine은 System.Runtime.CompilerServices 네임스페이스에 정의된 인터페이스이며, 컴파일러만 사용할 수 있는 것으로 되어 있다.
다른 스레드로 피신시킨 비동기 처리에서 호출자 스레드로 복귀하는 구조가 동기 컨텍스트이며, await을 사용하여 직전 컨텍스트를 캡처할 수 있다. 이 동기 컨텍스트 캡처는 await이 매번 수행되며 await마다 오버헤드가 발생한다. 따라서 Unity 개발에 널리 사용되는 UniTask(https://github.com/Cysharp/UniTask)에서는 동기 컨텍스트 캡처로 인한 오버헤드를 피하기 위해 Exe cutionContext 와 SynchronizationContext를 사용하지 않는 구현을 하고 있다. Unity의 경우 이러한 라이브러리를 도입하면 성능 향상을 기대할 수 있다.
배열의 확보는 보통 힙 영역에 확보되기 때문에 로컬 변수로 배열을 확보하면 매번 GC.Alloc이 발생하여 스파이크가 발생하게 된다. 또한, 힙 영역에 대한 읽기/쓰기는 스택 영역에 비해 조금은 효율이 떨어진다.
따라서 C#에서는, unsafe코드에 한정하여 스택에 배열을 확보하기 위한 구문이 마련되어 있다.
아래 예시와 같이 new 키워드를 사용하는 대신 stackalloc 키워드를 사용하여 배열을 확보하면 스택에 배열이 확보된다.
<예시> : stackalloc을 이용한 스택에 배열 확보
// stackalloc은 unsafe 한정
unsafe
{
// 스택에 int 배열 확보
byte* buffer = stackalloc byte[BufferSize];
}
C# 7.2버전 부터 Span<T>구조체를 사용함으로 아래 예시와 같이 unsafe 없이 stackalloc를 사용할 수 있게 되었다.
<예시> : Span구조체 병용을 통한 스택 상에 배열 확보
Span<byte> buffer = stackalloc byte[BufferSize];
Unity의 경우 2021.2버전 부터 표준으로 사용할 수 있다. 그 이전 버전의 경우 Span<T>가 존재하지 않기 때문에 System.Memory.dll을 도입해야 한다.
stackalloc에서 확보한 배열은 스택 전용이므로 클래스나 구조체의 필드에 가질 수 없다. 반드시 로컬 변수로 사용해야한다.
스택에 확보한다고는 하지만, 요소 수가 많은 배열을 확보하는 데는 상당한 처리 시간이 소요된다. 만약 Update 루프 내 등 힙 할당을 피하고 싶은 곳에서 요소 수가 많은 배열을 사용하려면 초기화 시 미리 확보하거나, 객체 풀과 같은 데이터 구조를 준비하여 사용 시 대여하는 방식으로 구현하는 것이 좋다.
또한 stackalloc에서 확보한 스택 영역은 함수를 빠져나갈 때까지 해제되지 않는다는 점을 주의해야한다. 예를 들어 아래 예시 코드는 루프 내에서 확보한 배열은 모두 유지되고 Hoge 메소드를 빠져나갈 때 해제되기 때문에 루프를 돌리는 동안 Stack Overflow가 발생할 수 있다.
<예시> : stackalloc을 이용한 스택에 배열 확보
unsafe void Hoge()
{
for (int i = 0; i< 10000; i++)
{
// 루프 횟수만큼 배열이 쌓인다.
byte* buffer = stackalloc byte[10000];
}
}
Unity에서 IL2CPP를 백엔드로 빌드하면 클래스의 virtual한 메소드 호출을 구현하기 위해 C++의 vtable과 같은 메커니즘을 사용하여 메소드 호출을 수행한다.
구체적으로는 클래스의 메소드 호출을 정의할 때마다 아래 예시와 같은 코드가 자동으로 생성된다.
<예시> : IL2CPP가 생성하는 메소드 호출 관련 C++코드
struct VirActionInvoker()
{
typedef void (*Action)(void*, const RuntimeMethod*);
static inline void Invoke (
Il2CppMethodSlot slot, RuntimeObject* obj)
{
const VirtualInvokeData& invokeData =
il2cpp_codegen_get_virtual_invoke_data(slot, obj);
((Action)invokeData.methodPtr)(obj, invokeData.method);
}
};
이는 virtual 메소드뿐만 아니라 컴파일 시 상속을 하지 않은, virtual이 아닌 메소드도 유사한 C++코드를 생성한다. 이러한 자동 생성 동작으로 인해 코드 크기가 커지거나 메소드 호출의 처리 시간이 길어지게 된다.
이 문제는 클래스 정의에 sealed 수정자를 붙임으로써 해결할 수 있다.
아래 예시와 같은 클래스를 정의하고 메소드를 호출할 경우, IL2CPP로 생성된 C++코드에서는 두번째 아래 예시와 같은 호출이 이루어진다.
<예시> : sealed를 사용하지 않는 클래스 정의와 메소드 호출 방법
public abstract class Animal
{
public abstract string Speak();
}
public class Cow : Animal
{
public override string Speak() {
return "Moo";
}
}
var cow = new Cow();
// Speak 메소드 호출하기
Debug.LogFormat("The cow says '{0}'", cow.Speak());
<예시> : 위 예시 메소드 호출에 대응하는 C++ 코드
// var cow = new Cow();
Cow_t1312235562 * L_14 =
(Cow_t1312235562 *)il2cpp_codegen_object_new(
Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /* hiddden argument*/Null);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;
// cow.Speak()
String_t* L_17 = VirtuncInvoker0< String_t* >::Invoke(
4 /* System.String AssemblyCSharp.Cow::Speak() */, L_16);
위 예시에서 볼 수 있듯이 가상 메소드 호출이 아님에도 불구하고 VirtFuncInvoker0< String_t* >::Invoke를 호출하고 있어 가상 메소드처럼 메소드 호출이 이루어지고 있음을 확인할 수 있다.
한편, 위로 두번 째 예시의 Cow클래스를 바로 아래 예시와 같이 sealed 수정자를 사용하여 정의하면 아래로 두번째와 같은 C++코드가 생성된다.
<예시> : sealed를 이용한 클래스 정의와 메소드 호출하기
public sealed class Cow : Animal
{
public override string Speak() {
return "Moo";
}
}
var cow = new Cow();
// Speak 메소드 호출하기
Debug.LogFormat("The cow says '{0}'", cow.Speak());
<예시> : 위 예시의 메소드 호출에 대응하는 C++ 코드
// var cow = new Cow();
Cow_t1312235562 * L_14 =
(Cow_t1312235562 *)il2cpp_codegen_object_new(
Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /* hiddden argument*/Null);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;
// cow.Speak()
String_t* L_17 = Cow_Speak_m1607867742(L_16, /* hidden argument*/NULL);
이처럼 메소드 호출이 Cow_Speak_m1607867742를 호출하고 있으며, 직접 메소드를 호출하고 있는 것을 확인할 수 있다.
다만, 비교적 최근의 Unity에서는 이러한 최적화가 일부 자동으로 이루어지고 있음을 Unity 공식에서 명시하고 있다.
즉, sealed를 명시적으로 지정하지 않더라도 이러한 최적화가 자동으로 이루어지고 있을 가능성이 있다.
하지만 [il2cpp] Is sealed Not Worked As Said Anymore In Unity 2018.3? 이라는 포럼에서 언급된 바와 같이 2019년 4월 기준으로 구현이 완벽하지 않다.
따라서 IL2CPP가 생성하는 코드를 확인하면서 프로젝트별로 sealed modifier 설정을 결정하는 것이 좋을 것 같다.
보다 확실한 직접 메소드 호출을 위해, 그리고 향후 IL2CPP의 최적화를 기대하며, 최적화 가능한 표시로 sealed 수정자를 설정하는 것도 좋은 방법이다.
메소드 호출에는 약간의 비용이 발생한다. 따라서 C#에 국한되지 않고 일반적인 최적화로 비교적 작은 메소드 호출은 컴파일러 등에 의해 인라인화라는 최적화가 이루어진다.
구체적으로 아래 예시 같은 코드에 대해 인라인화를 통해 아래로 두번째 같은 코드가 생성된다.
<예시> : 인라인화 전 코드
int F(int a, int b, int c)
{
var d = Add(a, b);
var e = Add(b, c);
var f = Add(d, e);
return f;
}
int Add(int a, int b) => a + b;
<예시> : 위 예시에 대한 인라인화를 수행한 코드
int F(int a, int b, int c)
{
var d = a + b;
var e = b + c;
var f = d + e;
return f;
}
인라인화는 바로 위 예시와 같이 위로 두번 째 예시의 F메소드 내의 Add 메소드 호출을 메소드 내의 내용을 복사하여 전개하는 방식으로 이루어진다.
IL2CPP에서는 코드 생성 시에는 특별히 인라인화를 통한 최적화가 이루어지지 않는다.
하지만 Unity 2020.2부터 메소드에 MethodImpl 속성을 지정하고 그 파라미터에 MethodOptions.AggressiveInlining을 지정하면 생성되는 C++ 코드의 해당 함수에 inline 지정자가 부여된다. 즉, C++의 코드 레벨에서 인라인화를 할 수 있게 된 것이다.
인라인화의 장점은 메소드 호출 비용이 절감될 뿐만 아니라, 메소드 호출 시 지정한 인수의 복사본을 생략할 수 있다는 점이다.
예를 들어, 산술계 메소드는 Vector3나 Matrix와 같이 비교적 큰 크기의 구조체를 여러 개의 인자로 받는다. 구조체를 그대로 인수로 전달하면 모두 값 전달로 복사되어 메소드에 전달되기 때문에, 인수 개수나 전달되는 구조체의 크기가 크면 메소드 호출과 인수 복사로 인해 상당한 처리 비용이 발생할 수 있다. 또한 물리 연산이나 애니메이션 구현등 주기적인 처리 등으로 활용되는 경우가 많기 때문에 메소드 호출이 처리 부하로 작용하는 경우가 있다.
이런 경우 인라인화를 통한 최적화가 효과적이다. 실제로 유니티의 새로운 산술계 라이브러리인 Unity.Mathmatics에서는 모든 메소드 호출에 MethodOptions.AggressiveInlining이 지정되어 있다.
한편, 인라인화는 메소드 내 처리를 전개하는 처리이기 때문에 전개할수록 코드 크기가 커진다는 단점이 있다. 따라서 특히 1 프레임에서 자주 호출되어 핫패스가 되는 메소드에 대해서는 인라인화를 고려하는 것이 좋다. 또한, 속성을 지정한다고 해서 반드시 인라인화가 되는 것은 아니라는 점도 주의해야한다.
인라인화되는 메소드는 그 내용이 작은 것으로 제한되므로, 인라인화를 하고자 하는 메소드는 처리량을 작게 유지해야한다.
또한, Unity 2020.2버전 이전에는 속성 지정에 inline 지정자가 붙지 않으며, C++의 inline 지정자를 지정해도 인라인화가 확실하게 이루어지지는 않는다.
따라서 확실하게 인라인화를 하고 싶다면 가독성은 떨어지지만 핫패스가 되는 메소드는 수동으로 인라인화 하는 것도 고려해 볼 수 있다.