· 7 min read

CoreCLR은 왜 오래 돌면 빠른가

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 28: default 7.0 ms (off axis)Default 369 μs, 0.82× of 7 IL2CPP
Time per round: CoreCLR default vs. the rest (every 5th Round value)
RoundCoreCLR defaultTieredCompilation=0TieredPGO=07 IL2CPP
01,5631,3311,052565
51,190483610450
101,136481605447
151,126477601454
201,092480598437
251,096479596440
301,109478615448
351,070479618453
401,089480602444
451,069478617447
501,064480605440
1001,068478596458
1501,100480597451
2001,082484540450
2501,041481488446
300536482459440
350389482464457
400390477454449
450387481459453
500392477461448
550371476459436
600377475457439
650369480451449
700371477453454
750372479448445
800369480448438
850371481448440
900377475445442
950369483445444
1,000368479445443
1,050367480446450
1,100368479445446
1,150370481446440
1,200366478446448
1,250371477449440
1,300369478448442
1,350368482444444
1,400368480449445
1,450367477453445
1,490368487445447
라운드마다 걸린 시간
vec3 제외, μs라운드 0라운드 1~20정상 상태
CoreCLR 기본1,5631,128369
CoreCLR, PGO 끔1,052602446
CoreCLR, tiered compilation 끔1,331479479
7 IL2CPP565449450

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)
ItemCoreCLR defaultCoreCLR, PGO off7 IL2CPP
Interface, 90% one typePGO 2.6× faster9.223.633.7
Interface, 4 types evenPGO 2.0× faster42.082.693.0
DelegatePGO 2.8× faster5.615.811.2
Branchy int loopIL2CPP 2.7× faster48.450.618.0
PGO가 넣는 타입 확인과 커널별 정상 상태
  • PGO를 끄면 정상 상태가 17% 느려진다.
  • 대가는 워밍업이다. 계측 단계가 하나 더 생겨 정상 도달이 0.24초에서 0.44초로 늘어난다. 루프가 있는 메서드는 첫 JIT부터 계측 코드가 들어가 Tier0 구간도 느리다.
  • 분기가 많은 정수 루프는 IL2CPP(Clang)가 빠르다. Vector3는 PGO와 상관없이 JIT가 SIMD 명령을 낸다.

스위치를 하나씩 끄면

Turning one switch off at a time

All seven kernels, median of 7 processes. Startup = process launch to first script

ConfigurationStartup, msFirst round, μsTime to steady, msSteady round, μs
Default3362,685439454
PGO off3152,132243531
Tiering off4051,520from start564
OSR off3272,425567465
ReadyToRun off3984,452157456

Tiering off compiles everything fully optimized up front, so there is no warm-up to reach.

스위치를 하나씩 끈 구성별 비교
  • 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 카운터를 기록하도록 이슈를 열어 두었다.