· 9 min read
Unity 7 IL2CPP에서 밟은 함정들 Unity 7 IL2CPP에서 밟은 함정들
Vector3부터 종료 crash까지, 함정 다섯 가지 Vector3부터 종료 crash까지, 함정 다섯 가지
Unity 6.7과 Unity 7 비교를 하며 숫자보다 먼저 부딪힌 다섯 가지다. 앞의 넷은 버그처럼 보였고 원인은 런타임이나 엔진에 있었다. 마지막은 숫자를 잘못 읽을 뻔한 경우다. Unity 7 알파 9(7000.0.0a9)와 6.7(6000.7.0b2) 기준이고, 측정 방법은 비교 글에 있다.
1. Unity 7 IL2CPP의 Vector3가 느리다
Unity 7 IL2CPP Player에서만 수박과 지오드가 잘리지 않았다. 계산이 느려 벤치의 대기 시간을 넘긴 것이다.
같은 계산을 2천만 번 돌렸을 때 알파 9도 6.7의 4.7배다(556.6 ms 대 117.7 ms). float로 풀어 쓴 코드는 세 IL2CPP 모두 약 16 ms로 버전 차이가 없다. 실제 코드에서는 수박 깍둑썰기가 142 ms에서 387 ms가 됐다(알파 7, Mac).
- 원인: 7 IL2CPP는 .NET 10 BCL을 쓰고, 여기서
Vector3는Vector128<float>위에 있다. CoreCLR JIT는 이를 SIMD로 바꾸지만 IL2CPP는 인라인되지 않는 호출과 원소 루프로 만든다. 알파 9의 인라이닝 확대도 이 경로는 바꾸지 않았다. - 피할 설정이 없다: API 호환성을
.NET Standard로 지정해도.NET으로 돌아온다. - 대응: 자주 도는 계산은
System.Numerics연산을 float 성분 연산으로 풀어 둔다.
2. 6.7 Editor에서 구운 것이 Player에서 다르다
MatterKit Core 시나리오 85개 중 25개가 6.7 Editor(Mono)에서만 기준값과 달랐다. Unity 7 Editor(CoreCLR)는 85개 모두 같았다.
Same float expression, two answers
Math.Floor(-0.17f / 0.005f). Mono in the 6.7 Editor keeps float math in double precision
Runtime
-0.17f / 0.005f
Math.Floor
Watermelon grid
IL2CPP, CoreCLR
float math
-34 exactly
-34
68 cells
Unity 6.7 Editor
double math (Mono)
-34.0000011...
-35
70 cells
- IL2CPP, CoreCLR → -34 exactly
- -34 exactly → -34
- -34 → 68 cells
- Unity 6.7 Editor → -34.0000011...
- -34.0000011... → -35
- -35 → 70 cells
Math.Floor(-0.17f / 0.005f)가 IL2CPP와 CoreCLR에서는 -34, 6.7 Editor의 Mono에서는 -35다. 수박 격자가 68칸에서 70칸이 되어, 한 칸 차이로 같은 장면이 Editor와 Player에서 다른 결과를 낸다.
- 원인: Editor의 Mono는 float 연산을 double로 하고, 저장하거나
(float)로 바꿀 때만 반올림한다. 끌 수 없다(MONO_ENV_OPTIONS도 안 된다). Mono의-Vector3는 0 성분을 -0이 아니라 +0으로 만들어서, 부호 비트까지 해시하면 식별자도 갈린다. - 대응: 25개는 버전 비교에서 뺐다. Editor에서 굽고 Player에서 쓰는 값은 float가 double로 넘어가는 곳에
(float)를 붙이고, 두 float 모드에서 모두 테스트한다. 6.7과 7은 1스텝부터 물리 결과가 갈려서, Spill은 6.7에서 구운 테이프와 데이터를 7에서 다시 굽는다.
3. 알파 9 링커가 빌드에서 뺀 어셈블리를 링크한다
Spill을 Unity 7로 처음 빌드했을 때 7 IL2CPP만 링커에서 실패했다.
| Player 빌드 | 결과 |
|---|---|
| 6.7 IL2CPP | 성공 |
| 7 CoreCLR | 성공 |
| 7 IL2CPP | IL1009(Spill.DevTools를 찾을 수 없음), IL1040 134건 |
- 원인: 알파 9의 UnityLinker는
[AlwaysLinkAssembly]가 붙은 어셈블리를IFilterBuildAssemblies가 빌드에서 뺐어도 링크한다. 6.7은 그러지 않는다. - 대응: ECS 시스템이 있는
Spill.Workbench의 집계 코드를 에디터 어셈블리로 옮겼다. 어셈블리를 걸러 내는 구조라면 7 IL2CPP 빌드를 한 번 따로 돌려 본다. Unity에는 아직 보고하지 않았다.
4. 스레드 풀 작업 중에 끝내면 IL2CPP가 죽는다
Spill 6.7 IL2CPP Player가 시작 직후 종료에서 접근 위반(0xC0000005)으로 끝났다. Windows에서 20번 중 6번이다. 7 IL2CPP도 같다. 빈 프로젝트로 좁히면 조건은 둘이다. 끝날 때 작업이 돌고 있고, 그 사이 관리 메모리 할당이 있어야 한다.
| 빈 프로젝트, headless, Mac (crash / 실행) | 6.7 IL2CPP | 7 IL2CPP |
|---|---|---|
Parallel.For 64개 300 ms, 일하는 중 종료, 할당 | 28 / 80 | 29 / 50 |
| 할당 없음 | 0 / 60 | 0 / 30 |
| 64개가 다 끝난 뒤 종료 | 0 / 60 | 0 / 30 |
Quit while thread-pool work runs: the race
Unity 6.7 IL2CPP, Runtime::Shutdown. Time flows down. From libil2cpp source and the crash registers
Shutdown cleanup
Worker A (busy)
Worker B (busy)
GC finalizer
Copy busy list
then release the lock
Still running
cleanup waits for A
Job done, exits
removed from live list
an allocation triggers GC
Finalize B's Thread
internal data = null
Stop(B) from copy
reads null, crash
- Shutdown cleanup → Still running
- Finalize B's Thread → Worker B (busy)
- Worker B (busy) → Stop(B) from copy
정리 코드가 들고 있는 복사본에 이미 풀린 스레드가 남는다. crash 덤프의 레지스터가 이 순서와 맞는다.
- 원인: libil2cpp 소스(Editor에 들어 있다)에서 찾았다. 6.7은 스레드 풀 정리 코드에서, 알파 9는 모든 스레드를 멈추는 단계에서 같은 모양으로 죽는다. 관리 Thread 객체를 놓고 기다리는 사이 스스로 끝난 스레드가 풀린다.
- 대응: Spill에서 남는 작업은 로딩 때 시작하는 변형 빌드다.
Application.quitting에서 이것을 최대 10초 기다린다(중앙값 0.75초). 창을 띄운 Mac에서 6.7은 30번 중 21번이 0번, 알파 9는 20번 중 7번이 0번이 됐다. 이후 Windows 밤 측정에서도 종료 crash는 없었다. - 버린 것: 전용 스레드로 옮기는 방법은 빈 프로젝트에서 알파 9가 30번 중 1번 죽었다.
Environment.Exit는 더 나쁘다(알파 9는 15번 모두 crash).
5. 7 IL2CPP가 43% 빠르다? managed job 하나였다
Spill에서 테이프로 한 판을 재생하는 장면은 7 IL2CPP의 프레임 중앙값이 6.7의 0.57배였다(Windows 창 모드). MatterKit 장면은 0.94배였다. 600프레임에 GC가 6.7은 35번, 7은 6번이라 GC부터 의심했다.
그림은 차이가 더 큰 Mac headless 값이다. managed job이면 7이 6.7의 0.15배(2.00 ms 대 13.2 ms)인데, Burst로 바꾸면 1.51 ms 대 1.74 ms로 비가 0.87이 되어 MatterKit 장면과 비슷해진다. 0.57배는 엔진이 빨라진 몫이 아니라 job 하나의 몫이다.
- 원인: MatterKit 소리 시스템이 접촉을 샘플링하는 job이다. Burst가 아닌 managed
IJobParallelFor로 worker에서 돌고, main thread가JobHandle.Complete()로 기다린다. 측정한 버전은 몸체가 4개 이상이면 이 경로를 골랐고, Spill 장면은 몸체 230개를 따라간다. Profiler로 보면 이 job의 실행 CPU가 6.7 113 ms, 7 7.3 ms로 15배 차이다. 나머지 PlayerLoop는 1.08 ms와 0.93 ms로 비슷하다. 15배의 이유는 job 안에 마커가 없어 더 나누지 못했다. - GC는 아니다: incremental GC를 끈 빌드에서 6.7의 수집이 72번에서 7번으로 줄어도 프레임 중앙값은 13.2 ms에서 13.1 ms로 그대로다.
- 대응: MatterKit은 접촉 샘플링을 Burst 기본으로 바꿨고, Spill main도 그 버전을 쓴다.
버전 비교에서 한 장면만 크게 움직이면, 평균을 믿기 전에 Profiler로 어느 마커가 움직였는지 먼저 본다.