· 7 min read
CoreCLR은 왜 오래 돌면 빠른가 CoreCLR은 왜 오래 돌면 빠른가
tiered compilation, PGO, OSR이 각각 얼마나 기여하나 tiered compilation, PGO, OSR이 각각 얼마나 기여하나
Unity 6.7과 Unity 7 비교에서 CoreCLR Player는 같은 모양을 반복했다. MatterKit에서 첫 생성과 첫 자르기는 7 IL2CPP의 1.37~1.70배 걸렸고, 오래 도는 작업은 0.82~1.02배였다. 처음 도는 코드는 느리고 오래 도는 코드는 빠르다. 그 안에서 무엇이 얼마나 기여하는지 따로 쟀다.
Unity 7은 .NET 기본값을 그대로 쓴다
호스트가 런타임에 넘기는 속성은 어셈블리 경로 셋뿐이다. tiered compilation, PGO, GC 설정도, DOTNET_ 환경 변수도, runtimeconfig.json도 없다. 그래서 .NET의 tiered compilation이 그대로 돈다. JIT 이벤트와 JitDisasm으로 순서를 직접 확인했다.
티어는 Tier0, 계측 Tier0, Tier1 순서로 올라간다. 인터페이스 호출 커널은 라운드 시작 기준 5 ms에 Tier0, 665 ms에 계측 단계, 906 ms에 Tier1이 됐다(JIT 이벤트 리스너를 단 한 번의 실행 기준이라 리스너 없는 측정보다 조금 늦다).
Tiered compilation in Unity 7 CoreCLR (.NET defaults)
Stages, then when the interface kernel reached each one. JIT events, one run with a listener
Tier0
first call, few optimizations
Instrumented Tier0
records call targets
Tier1 + Dynamic PGO
optimized, hot targets inlined
OSR
long loop swaps mid-run
- Tier0 → Instrumented Tier0 (30 calls)
- Instrumented Tier0 → Tier1 + Dynamic PGO (profile)
- Tier0 → OSR (loop runs long)
- Tier0, 5 ms
- Unity startup code first, 3,544 methods queued, 169 ms
- Instrumented, 665 ms
- Tier1, 906 ms
Background JIT drains the queue at about 20-60 methods per 11 ms. The kernels wait behind it.
- 30번 불렸다고 바로 Tier1이 되지 않는다. Unity 자체 시작 코드가 큐 앞에 먼저 서 있다.
- 설정은
DOTNET_*환경 변수나 Unity 스위치-coreclr-config-property-tiered-compilation 0|1로 바꾼다. Player는boot.config에 같은 이름의 줄 하나로 준다.
1,500라운드 동안 라운드 시간
어떻게 쟀나: 빈 프로젝트에 작은 커널 일곱 개(인터페이스 호출, delegate, Vector3, 분기 루프, 긴 루프, BCL)를 넣고 1,500라운드를 쉬지 않고 돌렸다. M4 Max Mac, Unity 7 알파 9 Player, 구성마다 프로세스 7개의 중앙값이다. 7 IL2CPP는 Vector3 문제 때문에 이 커널 하나가 나머지를 가려서, IL2CPP와 견줄 때는 뺐다.
Time per round: CoreCLR default vs. the rest
Empty project, same work for 1,500 rounds. Vector3 kernel excluded, μs, log axis. Mac arm64, Unity 7 alpha 9, 7 processes
| Round | CoreCLR default | TieredCompilation=0 | TieredPGO=0 | 7 IL2CPP |
|---|---|---|---|---|
| 0 | 1,563 | 1,331 | 1,052 | 565 |
| 5 | 1,190 | 483 | 610 | 450 |
| 10 | 1,136 | 481 | 605 | 447 |
| 15 | 1,126 | 477 | 601 | 454 |
| 20 | 1,092 | 480 | 598 | 437 |
| 25 | 1,096 | 479 | 596 | 440 |
| 30 | 1,109 | 478 | 615 | 448 |
| 35 | 1,070 | 479 | 618 | 453 |
| 40 | 1,089 | 480 | 602 | 444 |
| 45 | 1,069 | 478 | 617 | 447 |
| 50 | 1,064 | 480 | 605 | 440 |
| 100 | 1,068 | 478 | 596 | 458 |
| 150 | 1,100 | 480 | 597 | 451 |
| 200 | 1,082 | 484 | 540 | 450 |
| 250 | 1,041 | 481 | 488 | 446 |
| 300 | 536 | 482 | 459 | 440 |
| 350 | 389 | 482 | 464 | 457 |
| 400 | 390 | 477 | 454 | 449 |
| 450 | 387 | 481 | 459 | 453 |
| 500 | 392 | 477 | 461 | 448 |
| 550 | 371 | 476 | 459 | 436 |
| 600 | 377 | 475 | 457 | 439 |
| 650 | 369 | 480 | 451 | 449 |
| 700 | 371 | 477 | 453 | 454 |
| 750 | 372 | 479 | 448 | 445 |
| 800 | 369 | 480 | 448 | 438 |
| 850 | 371 | 481 | 448 | 440 |
| 900 | 377 | 475 | 445 | 442 |
| 950 | 369 | 483 | 445 | 444 |
| 1,000 | 368 | 479 | 445 | 443 |
| 1,050 | 367 | 480 | 446 | 450 |
| 1,100 | 368 | 479 | 445 | 446 |
| 1,150 | 370 | 481 | 446 | 440 |
| 1,200 | 366 | 478 | 446 | 448 |
| 1,250 | 371 | 477 | 449 | 440 |
| 1,300 | 369 | 478 | 448 | 442 |
| 1,350 | 368 | 482 | 444 | 444 |
| 1,400 | 368 | 480 | 449 | 445 |
| 1,450 | 367 | 477 | 453 | 445 |
| 1,490 | 368 | 487 | 445 | 447 |
vec3 제외, μs | 라운드 0 | 라운드 1~20 | 정상 상태 |
|---|---|---|---|
| CoreCLR 기본 | 1,563 | 1,128 | 369 |
| CoreCLR, PGO 끔 | 1,052 | 602 | 446 |
| CoreCLR, tiered compilation 끔 | 1,331 | 479 | 479 |
| 7 IL2CPP | 565 | 449 | 450 |
CoreCLR 기본값은 라운드 308(약 0.44초)에서 정상 상태에 닿는다. 7 IL2CPP보다 첫 라운드가 2.8배, Tier0 구간이 2.5배 느리고, 정상 상태에서는 0.82배 시간에 끝난다. 정상 상태는 마지막 500라운드의 중앙값이다.
PGO가 하는 일
정상 상태 속도의 상당 부분은 Dynamic PGO 몫이다. Tier1 코드에 guarded devirtualization이 들어간다.
What Dynamic PGO does at an interface call
Top: Tier1 code with PGO (from JitDisasm). Bottom: steady-state time per round, μs, median of 7 processes
Method table == Circle?
the type seen most often
Inlined 3.14159 * R * R
Without PGO, Tier1 has only the indirect call.
Delegates get the same check before the loop.
Normal interface call
- Method table == Circle? → Inlined 3.14159 * R * R (yes)
- Method table == Circle? → Normal interface call (no)
- PGO를 끄면 정상 상태가 17% 느려진다.
- 대가는 워밍업이다. 계측 단계가 하나 더 생겨 정상 도달이 0.24초에서 0.44초로 늘어난다. 루프가 있는 메서드는 첫 JIT부터 계측 코드가 들어가 Tier0 구간도 느리다.
- 분기가 많은 정수 루프는 IL2CPP(Clang)가 빠르다.
Vector3는 PGO와 상관없이 JIT가 SIMD 명령을 낸다.
스위치를 하나씩 끄면
- Tier0: 시작과 첫 라운드를 싸게 하는 대신 처음 0.2~0.6초 동안 코드가 느리다. 끄면 첫 라운드가 1.8배 빨라지고 시작은 69 ms 늦어진다.
- Dynamic PGO: 정상 상태를 15% 당기고 워밍업을 0.2초 늘린다.
- OSR: 정상 상태에는 영향이 없다. 한 번 불려 오래 도는 루프의 첫 실행을 구한다. 끄면
longloop의 Tier0 구간이 277 μs에서 441 μs가 된다. - ReadyToRun: Unity Player에서 ReadyToRun 어셈블리는
System.Private.CoreLib하나다. Microsoft .NET 10 배포본은 BCL 172개 중 92개다. LINQ, 컬렉션, UnityEngine 모듈, 게임 코드는 처음 불릴 때 모두 JIT를 거친다. CoreLib의 ReadyToRun만 꺼도 시작까지 JIT하는 메서드가 약 2,500개에서 5,100개로 는다. 정상 도달은 오히려 빨라진다. ReadyToRun 코드의 재컴파일이 무거워 큐를 오래 붙잡기 때문이다.
기본값은 언제 이기나
When the default pulls ahead in total time
Cumulative time of the default config. Beyond 1,500 rounds the steady-state gap is extrapolated
- measured: 1,500 rounds: 0 s to 1 s
- 0.44 s: steady state, 0.44 s
- 1.2 s, ~1,990 rounds, passes PGO off, 1.2 s
- 1.6 s, ~2,890 rounds, passes tiering off, 1.6 s
Before 1.2 s the default trails both in total time. After 1.6 s it beats both.
1,500라운드(약 1초)에서는 기본값이 PGO 끔, tiered compilation 끔보다 합계가 길다. 정상 상태 차이로 외삽하면 1.2~1.6초보다 오래 도는 작업부터 기본값이 이긴다.
- 게임 루프처럼 계속 도는 코드에는 기본값이 맞다.
- 로딩 직후 한 번 크게 도는 코드라면 설정을 바꾸기보다 로딩 화면에서 주요 경로를 한 번씩 미리 돌려 두는 편이 범위가 좁고 안전하다.
한계
- Mac 한 대, batchmode, 빈 장면이다. 시작 시간은 MatterKit Player와 직접 비교하지 않는다.
- 측정 커널은 모두 루프가 있다. 루프 없는 메서드가 많은 실제 코드에서는 차이가 다를 수 있다. 코드가 많은 게임은 JIT 큐가 길어 정상 도달이 더 늦을 수 있다.
- Windows, development 빌드, MatterKit Player는 재지 않았다. unity-bench가 CoreCLR 프로세스마다 이 설정과 JIT 카운터를 기록하도록 이슈를 열어 두었다.