· 10 min read

Boehm과 CoreCLR, 두 GC는 어떻게 다른가

할당 잠금과 스레드 확장성 비교

Unity 6.7과 Unity 7 비교에서 병이나 저금통 네 개를 동시에 깨뜨리면 CoreCLR이 6.7 IL2CPP의 0.64~0.78배 시간에 끝났다. 본편에는 이유를 한 줄로 적었다. IL2CPP의 Boehm GC는 할당마다 전역 잠금을 잡는다는 것이다. 이 글은 그 한 줄을 소스와 측정으로 확인한 기록이다.

소스는 Unity Editor에 든 IL2CPP 런타임과 Boehm GC(bdwgc), 그리고 Unity 7 Player의 CoreCLR과 같은 버전인 .NET 10.0.10이다.

객체 하나를 만들 때

IL2CPP는 모든 스레드가 잠금 하나 앞에 줄을 선다. CoreCLR은 스레드마다 자기 버퍼에서 포인터만 민다.

Allocation path for a small object

Concept diagram. Unity IL2CPP with Boehm (6.7 and 7) vs. Unity 7 CoreCLR

IL2CPP (Boehm)

Thread 1

One GC lock

taken on every allocation

Others wait: spin, then yield, then sleep

Free list by size

shared by all threads

Collection runs inside this lock

Thread 2

Thread 3

Thread 4

  • Thread 1 → One GC lock
  • Thread 2 → One GC lock
  • Thread 3 → One GC lock
  • Thread 4 → One GC lock
  • One GC lock → Free list by size

CoreCLR

Thread 1

Own buffer, bump pointer

Heap lock

only to refill a buffer

Gen0 region

Thread 2

Own buffer, bump pointer

Thread 3

Own buffer, bump pointer

Thread 4

Own buffer, bump pointer

  • Thread 1 → Own buffer, bump pointer
  • Thread 2 → Own buffer, bump pointer
  • Thread 3 → Own buffer, bump pointer
  • Thread 4 → Own buffer, bump pointer
  • Own buffer, bump pointer → Heap lock
  • Own buffer, bump pointer → Heap lock
  • Own buffer, bump pointer → Heap lock
  • Own buffer, bump pointer → Heap lock
  • Heap lock → Gen0 region

No lock in the fast path. A refill hands out 1 to 8 KB

작은 객체 하나를 할당하는 길

IL2CPP: 매번 잠금

Boehm은 THREAD_LOCAL_ALLOC을 켜고 빌드하면 스레드별 할당 버퍼를 쓴다. Unity는 켜지 않는다. 6.7과 7이 같다. 그래서 new는 결국 이 함수로 온다.

LOCK();                                  // 프로세스 전체에 하나
op = GC_gcjobjfreelist[size_class];     // 크기별 공유 free list
if (op == 0) op = GENERAL_MALLOC_INNER(...);  // 비었으면 채우거나 수집
else GC_gcjobjfreelist[size_class] = next(op);
UNLOCK();
  • 잠금을 못 잡으면 macOS와 Linux에서 spin, sched_yield 12번, 그 뒤 nanosleep(한 번에 최대 약 16 ms)으로 기다린다. Windows는 CRITICAL_SECTION이다.
  • 6.7 Editor의 Mono(libmonobdwgc-2.0)도 같다.

CoreCLR: 스레드마다 버퍼

  • 스레드마다 alloc_ptr와 alloc_limit 두 값을 둔다. JIT helper RhpNewFast는 크기를 더하고 끝과 비교할 뿐이다. 잠금도 원자 연산도 없다.
  • 버퍼가 넘칠 때만 힙 잠금(more_space_lock_soh)을 잡고 1~8 KB를 새로 받는다. 32바이트 객체라면 수백 번에 한 번이다.

수집할 때

Boehm은 네이티브 코드를 도는 스레드까지 모든 스레드를 OS로 세운다. CoreCLR은 관리 코드를 도는 스레드만 safe point나 함수 반환에서 세우고, 네이티브 스레드는 계속 돈다.

What each thread does during one collection

Concept diagram. Thread 1 triggers the collection; time runs left to right

IL2CPP (Boehm)

ItemBeforeCollectionAfter
Thread 1 (C#)150.00Mark + sweep, holds the GC lock120.00
Thread 2 (C#)140.00Suspended by the OS110.00
Thread 3 (native)140.00Suspended by the OS110.00

Roots: every stack scanned word by word, conservatively. Objects never move.

CoreCLR

ItemBeforeCollectionAfter
Thread 1 (C#)150.00Gen0: mark, promote, compact120.00
Thread 2 (C#)156.00Stopped at a safe point or on return120.00
Thread 3 (native)Keeps running. Waits only if it re-enters C#

Roots: exact, from JIT GC info. Objects can move.

수집 한 번 동안 스레드가 하는 일
  • Boehm은 스택을 통째로 훑는다. 힙을 가리킬 수 있는 값은 모두 포인터로 본다(보수적 수집). 진짜 포인터인지 모르니 객체를 옮길 수 없다.
  • Unity는 Boehm을 incremental로 쓴다. 대부분 partial 수집이고 20번째마다 full이다. 3 ms 안에 mark를 끝내려 하고, 넘으면 나눠서 한다. 나눈 조각도 할당하는 스레드가 잠금을 쥔 채 돌고, mark는 한 스레드만 한다.
  • CoreCLR은 참조 칸을 정확히 안다. JIT가 남긴 GC 정보 덕분이다. 그래서 gen0 수집에서 살아남은 객체를 옮겨 압축할 수 있다.
  • CoreCLR도 gen0·gen1은 모두 멈춘다. Unity 7은 .NET 기본값(Workstation, concurrent)을 쓰는데, background로 도는 것은 gen2 수집뿐이다.

측정

어떻게 쟀나: 빈 프로젝트를 6.7 IL2CPP, 7 IL2CPP, 7 CoreCLR(알파 9) Release Player로 빌드해 Mac(M4 Max)에서 batchmode로 돌렸다. Incremental GC는 켰다. 스레드 1, 2, 4, 8개가 각자 같은 양을 하고, 값은 변형마다 프로세스 7개의 중앙값이다. 작업은 넷이다.

  • 할당만: 32바이트 객체를 스레드당 400만 번. 살아 있는 객체는 스레드당 1,024개뿐이다.
  • 32번에 한 번 할당: 계산 루프에서 가끔만 할당한다.
  • GC 끄고 할당: IL2CPP는 GCMode = Disabled, CoreCLR은 GC.TryStartNoGCRegion.
  • 할당 없음: 같은 계산만. 대조군이다.

IL2CPP는 할당이 들어가면 스레드를 늘릴수록 오히려 줄어든다.

Throughput by thread count, relative to 1 thread

Same work per thread, so the ideal is the dotted line. Log axes. Mac M4 Max, Release players, median of 7 processes

Allocate only

Allocate only
ThreadsIdeal6.7 IL2CPP7 IL2CPP7 CoreCLR
11.00×1.00×1.00×1.00×
2–0.96×0.93×1.51×
4–0.90×0.88×2.01×
88.00×0.84×0.84×1.94×

1 alloc per 32 steps

1 alloc per 32 steps
ThreadsIdeal6.7 IL2CPP7 IL2CPP7 CoreCLR
11.00×1.00×1.00×1.00×
2–0.55×0.56×1.92×
4–0.49×0.48×3.41×
88.00×0.42×0.42×5.78×

Allocate, GC off

Allocate, GC off
ThreadsIdeal6.7 IL2CPP7 IL2CPP7 CoreCLR
11.00×1.00×1.00×1.00×
2–0.85×0.81×1.66×
4–0.50×0.47×2.58×
88.00×0.29×0.30×4.12×

No allocation

No allocation
ThreadsIdeal6.7 IL2CPP7 IL2CPP7 CoreCLR
11.00×1.00×1.00×1.00×
2–1.96×1.96×1.98×
4–3.85×3.86×3.87×
88.00×7.48×7.58×7.59×
스레드 수별 처리량, 1스레드 대비
8스레드 / 1스레드6.7 IL2CPP7 IL2CPP7 CoreCLR
할당만0.84배0.84배1.93배
32번에 한 번 할당0.42배0.42배5.78배
GC 끄고 할당0.29배0.30배4.12배
할당 없음7.48배7.58배7.59배
  • 스레드 탓이 아니다. 할당 없는 계산은 셋 다 7.5배로 늘었다.
  • GC를 꺼도 0.3배다. 수집이 한 번도 없으니 잠금을 다투는 비용만으로 잃는 몫이다.
  • 가끔 할당해도 아프다. 1스레드에서는 IL2CPP도 빠르다. 8스레드에서는 잠금을 기다리는 동안 계산까지 멈춰 1스레드보다 느렸다.
  • CoreCLR도 할당만 하면 2배에서 멈춘다. 4스레드가 정점이다. gen0 수집이 모든 스레드를 세우기 때문이다. 수집이 wall 시간에서 차지하는 몫이 1스레드 12%에서 8스레드 48%로 커졌다.
  • 1스레드 할당 처리량은 CoreCLR이 초당 197M개, IL2CPP가 43~46M개다.

IL2CPP는 수집이 50배 잦다(1스레드에서 811번과 788번 대 gen0 15번). 한 번은 짧지만(65~67 µs 대 166 µs), 8스레드에서 할당 100만 개당 정지는 12.5~13.4 ms 대 1.8 ms로 7배 길다.

Collections and pauses, allocate-only work

4M allocations per thread. Per-collection pause: IL2CPP via a probe thread, CoreCLR via the runtime

ItemValue
Collections, 1 thread
6.7 IL2CPP811
7 IL2CPP788
7 CoreCLR15
Pause per collection, 1 thread (µs)
6.7 IL2CPP67
7 IL2CPP65
7 CoreCLR166
Pause per 1M allocations, 8 threads, probe thread (ms)
6.7 IL2CPP13.4
7 IL2CPP12.5
7 CoreCLR1.8
수집 횟수와 정지 시간

IL2CPP는 살아 있는 객체가 적으면 힙을 작게 유지하고, 그래서 자주 수집한다. 정지 시간 API가 6.7에는 없고 7에서는 0을 돌려준다. 그래서 할당하지 않는 스레드 하나를 더 띄워 Stopwatch가 20 µs 넘게 끊긴 구간을 셌다.

차이가 드러나지 않는 곳

일부러 할당만 하게 만든 측정이다. 실제 게임에서 이만큼 벌어지는 경우는 드물다.

  • 한 스레드에서만 할당하는 코드. 다툴 상대가 없다. 할당이 프레임에서 차지하는 몫만큼만 보인다.
  • Burst job. Burst 컴파일러는 관리 객체 생성을 거부한다. 할당이 없으니 잠금도 없다.
  • 할당 없는 계산. 세 런타임이 같은 모양이다.
  • Spill 테이프 재생 0.57배. GC가 아니라 managed job 하나였다. 6.7의 수집을 72번에서 7번으로 줄여도 프레임 중앙값은 그대로였다(Unity 7 IL2CPP에서 밟은 함정들 5번).

병 깨짐도 전부가 잠금 몫은 아니다.

  • 향수병 하나는 CoreCLR이 0.87배로 프로세스 범위가 겹친다. 넷을 동시에 깨면 0.64배로 벌어진다. 이 벌어진 몫을 잠금과 수집이 한 줄로 서는 몫으로 본다.
  • 저금통 하나는 오히려 CoreCLR이 1.8배 느렸다. 수집 탓은 아니었다.
  • 커터 스레드가 IL2CPP 4개, CoreCLR 8개다. 각자의 기본값 비교라 잠금 몫만 떼어 낸 숫자는 아니다.

그래서

  • IL2CPP로 낸다면 여러 스레드에서 도는 코드의 할당을 줄이는 것이 가장 크다. worker 스레드, Parallel.For, Task 안의 new, LINQ, 박싱, 클로저가 모두 같은 잠금 앞에 선다. 풀을 쓰거나 Burst job으로 옮긴다.
  • 메인 스레드 하나만 할당한다면 걱정할 일은 크지 않다.
  • CoreCLR도 많이 할당하면 gen0 수집이 모든 스레드를 세운다. 할당을 줄이는 습관은 어느 쪽이든 손해가 없다.

한계

  • Mac 한 대, batchmode, 빈 장면이다. Windows는 잠금이 CRITICAL_SECTION이라 기다리는 방식이 다르다. 재지 않았다.
  • 다른 작업을 멈춘 Mac에서 쟀다. 부하가 있을 때도 모양은 같았다.
  • 살아 있는 객체가 아주 적은 극단적인 작업이다. 힙이 큰 게임에서는 Boehm의 수집이 덜 잦은 대신 한 번이 길다.
  • CoreCLR 소스는 Microsoft의 .NET 10.0.10이다. Unity 빌드(10.0.10-dev)에서 GC를 고쳤는지는 확인하지 못했다. Unity 7은 알파 9다.

출처

Unity 쪽 경로는 Unity.app/Contents/Resources/Scripting/il2cpp/ 아래다. 6000.7.0b2와 7000.0.0a9가 같다.

  • 빌드 define: build/deploy/Unity.IL2CPP.Bee.BuildLogic.dll의 CreateBoehmNativeProgram. THREAD_LOCAL_ALLOC, PARALLEL_MARK 없음
  • 할당 경로: libil2cpp/vm/Object.cpp, external/bdwgc/gcj_mlc.c(GC_gcj_malloc), malloc.c(GC_malloc_kind_global)
  • 잠금: external/bdwgc/include/private/gc_locks.h, pthread_support.c(GC_lock)
  • 수집: external/bdwgc/alloc.c(GC_maybe_gc, GC_collect_a_little_inner), mark.c, darwin_stop_world.c, win32_threads.c
  • CoreCLR: AllocFast.S, gcinterface.h, gc.cpp 할당, gc.cpp background GC 조건, threadsuspend.cpp