UniTask 의 에디터 할당은 코드 최적화가 만든다
async UniTask OneYield()
{
await UniTask.Yield();
}
UniTask 로 옮겼는데 에디터 프로파일러에는 이 메서드를 부를 때마다 48 B 가 찍힙니다. 같은 코드를 빌드해서 재면 0.1 B 입니다.
에디터에서 보이는 할당은 대부분 UniTask 가 아니라 꺼진 코드 최적화가 만든 것입니다. 최적화를 켜고 재면 0 이고, 그 뒤에도 남는 할당은 두 가지입니다.
아래 숫자는 모두 1회당 할당입니다. N=1,000 과 2,000 의 차이를 1,000 으로 나눴습니다.
에디터의 할당은 코드 최적화가 꺼져서 생긴다
원인
C# 컴파일러는 async 메서드를 상태 머신으로 바꿉니다. 코드 최적화가 켜져 있으면 구조체로, 꺼져 있으면 클래스로 만듭니다. 클래스면 부를 때마다 new 가 되고, 이것은 컴파일러가 만든 코드라 UniTask 의 풀로 막을 수 없습니다.
유니티 에디터는 스크립트를 컴파일할 때 코드 최적화 설정을 따릅니다. 기본값이 Debug 라 최적화가 꺼져 있습니다.
| 환경 | 코드 최적화 | OneYield() 1회 |
|---|---|---|
| 에디터, 코드 최적화 Debug (기본) | 꺼짐 | 48.1 B |
| 에디터, 코드 최적화 Release | 켜짐 | 0.1 B |
| Development 빌드 | 켜짐 | 0.1~0.2 B |
| Development 빌드 + Script Debugging | 꺼짐 | 48~56 B |
| 출하 빌드 | 켜짐 | 0 B |
기준은 Development 빌드인지나 DEBUG 심볼이 아닙니다. Development 빌드는 DEBUG 가 정의돼 있어도 0 이고, Script Debugging 을 켜면 최적화가 꺼져 다시 할당합니다.
async 메서드를 거치지 않으면 최적화가 꺼져 있어도 0 입니다. 48 B 가 UniTask 의 것이 아니라는 증거입니다.
| 작업 | 에디터 Debug | 에디터 Release |
|---|---|---|
async UniTask<int>, await 없음 |
56.0 B | 0.0 B |
async UniTask + await UniTask.Yield() |
48.1 B | 0.1 B |
UniTask.Delay(1) 을 직접 부름 |
0.0 B | 0.0 B |
async Task<int>, await 없음 |
128.0 B | 80.0 B |
async Task + await Task.Yield() |
368.0 B | 376.0 B |
Task 는 최적화를 켜도 80 B, 376 B 가 남습니다.
이렇게 쓴다
에디터 기본 설정에서 찍힌 할당은 출하 빌드에 없습니다. 그 숫자로 UniTask 코드를 고치지 않습니다. 할당을 잴 때는 최적화가 켜진 곳에서 잽니다.
| 재는 곳 | 설정 |
|---|---|
| 에디터 | 코드 최적화를 Release 로 바꿈 |
| 플레이어 | Development Build 켬, Script Debugging 끔 |
에디터의 코드 최적화는 에디터를 -releaseCodeOptimization 인자로 띄우면 Release 로 시작합니다. Release 에서는 디버거를 붙여 중단점을 걸 수 없으니, 잴 때만 바꿉니다.
최적화를 켜도 남는 할당 1: 처음 많이 돌릴 때 한 번
원인
첫 실행은 풀이 비어 러너를 새로 만듭니다. 풀을 미리 채워도 첫 대량 실행에는 값이 남습니다. OneYield() 를 한 번에 1,000개 돌린 첫 측정은 1회당 16.6 B 였습니다.
이것은 PlayerLoop 의 continuation 큐가 처음 커지는 값입니다. 큐는 배열 두 개를 번갈아 쓰고, 16칸에서 두 배씩 자랍니다. 미리 돌린 한 바퀴가 한쪽만 키웠고, 본 측정에서 다른 쪽이 커졌습니다.
| 계산 | 값 |
|---|---|
| 16칸에서 1,024칸까지 새로 만든 배열 합 × 8 B | 16,128 B |
| 실측 (N=1,000) | 16,648 B |
두 배열이 다 커진 뒤에는 0.3 B 였습니다. 한 번 내고 끝나는 값입니다.
이렇게 쓴다
- 잴 때는 같은 개수로 미리 돌려 풀과 큐를 키운 뒤에 잽니다. 첫 측정에 섞인 값을 매번 드는 값으로 읽지 않습니다.
- 게임에서는 대부분 무시해도 됩니다. 전투 시작처럼 한 프레임에 수백 개가 몰리는 자리가 있고 그 프레임의 GC 가 문제라면, 로딩 중에 같은 개수를 미리 돌려 둡니다.
최적화를 켜도 남는 할당 2: 정상 경로를 벗어날 때
원인
UniTask 가 0 인 것은 대기 객체를 풀에서 빌려 정상으로 끝낼 때입니다. 아래는 그 밖의 경로라 부를 때마다 할당합니다.
| 대상 | Mono | IL2CPP |
|---|---|---|
WhenAll(3개) |
110.8 B | 143.5 B |
Delay + 취소 |
352 B | 448 B |
WaitUntil(() => flag && local >= 0) |
148.2 B | 152.2 B |
WaitUntil(state, s => s.flag) |
0.2 B | 0.2 B |
WhenAll은 결과 배열과 조합기 객체를 만듭니다.- 취소는 예외 객체를 만들어 던집니다.
- 바깥 변수를 캡처한 람다는 부를 때마다 클로저와 델리게이트가 생깁니다. 이것은 UniTask 가 아니라 호출하는 코드의 할당입니다.
이렇게 쓴다
매 호출 도는 람다는 바깥 변수를 캡처하지 않고, state 를 받는 오버로드로 넘깁니다.
// 148~152 B. this 를 캡처해 부를 때마다 클로저와 델리게이트가 생긴다
await UniTask.WaitUntil(() => this.hp <= 0);
// 0.2 B. 필요한 것을 state 로 넘기고 람다는 캡처하지 않는다
await UniTask.WaitUntil(this, self => self.hp <= 0);
WhenAll 과 취소의 할당은 없앨 수 없습니다. 로딩이나 화면 전환처럼 가끔 도는 곳에서는 그대로 쓰고, 매 프레임 도는 경로에는 두지 않습니다.
끝나지 않는 태스크는 UniTask Tracker 로 찾는다
할당보다 흔한 문제는 끝나지 않는 태스크입니다. 끝나지 않으면 대기 객체와 러너가 풀로 돌아가지 않습니다. 에디터 메뉴 Window → UniTask Tracker 가 이것을 봅니다.
Enable Tracking 을 켜야 목록에 오릅니다. 기본은 꺼져 있고, 켜기 전에 시작된 태스크는 안 잡힙니다. 추적 호출은 [Conditional("UNITY_EDITOR")] 라 플레이어 빌드에는 없습니다.
UniTask.Delay(1) 하나를 만드는 비용입니다(에디터 PlayMode).
| 설정 | 1개당 할당 | 1,000개 만드는 시간 (Release / Debug) |
|---|---|---|
| 전부 끔 | 0.2 B | 0.16 / 0.29 ms |
| Enable Tracking | 158 B | 6.3 / 12.2 ms |
| Tracking + StackTrace | 약 78 KB | 349 / 576 ms |
찾는 법은 이렇습니다.
- Tracking 을 켜고 플레이를 다시 시작합니다.
- 씬 전환이나 창 열고 닫기처럼 의심 가는 동작을 되풀이합니다.
- 되풀이할 때마다 같은 TaskType 이 늘면 끝나지 않는 태스크입니다.
- 그때만 StackTrace 를 켜고 재현해 만든 자리를 봅니다.
async UniTaskVoid 의 러너는 추적용으로 늘 Pending 을 돌려줍니다. Pending 이라고 새는 것이 아니고, 목록에서 사라지는지를 봅니다.
StackTrace 는 찾은 뒤에 바로 끕니다. 설정이 EditorPrefs 에 저장돼 다음 날에도 켜져 있고, 태스크 하나마다 78 KB 와 0.35~0.58 ms 가 들어 에디터 플레이가 느려집니다.
정리
에디터에서 보이는 UniTask 의 할당은 대부분 꺼진 코드 최적화가 만든 클래스 상태 머신입니다.
- 에디터 기본(Debug)과 Script Debugging 을 켠 빌드는
async호출마다 48~64 B 를 만들고, 최적화가 켜진 곳에서는 0 입니다. - 최적화를 켜도 처음 많이 돌릴 때는 continuation 큐 배열이 커지느라 한 번 할당합니다.
WhenAll, 취소, 캡처 람다는 부를 때마다 할당합니다.
그래서 할당은 최적화가 켜진 곳에서 풀을 채운 뒤에 재고, 매 호출 도는 람다는 state 오버로드로 바꿉니다. 끝나지 않는 태스크는 Tracker 로 찾습니다.