보헴 GC 는 객체를 옮기지 않는다
지금 대부분의 유니티 프로젝트는 Boehm 이라는 GC 위에서 돕니다. Mono 백엔드도 그렇고, 모바일에서 쓰는 IL2CPP 도 그렇습니다.
이름은 만든 사람 이름입니다. 원래 C 와 C++ 처럼 GC 가 없는 언어에 GC 를 붙이려고 만든 것입니다. 그 출신이 성질을 거의 다 설명합니다.
Boehm 은 세 가지를 안 합니다. 정확히 알지 않고, 옮기지 않고, 나이를 따지지 않습니다.
셋 다 “덜 똑똑한” 것인데, 대신 아무 언어에나 붙일 수 있습니다. 그 맞바꿈의 결과가 우리에게 옵니다.
문서와 자료에서는 이 셋을 각각 한 단어로 부릅니다. 먼저 이름을 붙여 두고 시작합니다.
| 안 하는 것 | 부르는 이름 | 우리에게 오는 결과 |
|---|---|---|
| 무엇이 주소인지 정확히 알기 | 보수적 (conservative) | 안 쓰는 객체가 살아남는 일이 생깁니다 |
| 살아남은 것을 앞으로 밀기 | 비압축 (non-compacting) | 빈틈이 남아서, 빈 메모리가 있어도 힙이 커집니다 |
| 나이로 나눠서 훑기 | 비세대 (non-generational) | 매번 힙 전체를 훑어서, 힙이 커질수록 한 번이 비싸집니다 |
방식으로 부르면 표시하고 쓸기(mark-sweep)입니다. 살아 있는 것에 표시하고, 표시 안 된 자리를 비웁니다. 그 뒤에 밀어 붙이는 단계가 없습니다. 그 없는 단계의 이름이 압축(compaction)이고, 그래서 비압축입니다.
하나. 보수적: 정확히 알지 않는다
GC 는 뿌리에서 출발해 참조를 따라간다고 했습니다. 그러려면 어느 값이 주소인지 알아야 합니다.
정밀한 GC 는 이걸 압니다. 컴파일러가 “이 자리는 참조다” 라는 표를 같이 만들어 주기 때문입니다.
Boehm 은 그 표가 없습니다. 그래서 스택과 레지스터를 통째로 훑으면서 주소처럼 생긴 값을 전부 주소로 칩니다.
이 방식을 보수적 스캔(conservative scanning)이라고 부릅니다. 반대쪽은 정밀(precise 또는 exact)이라고 합니다.
정수 하나가 우연히 어떤 객체의 주소와 같은 값이면, 그 객체는 아무도 안 쓰는데도 살아남습니다. 이걸 잘못된 보존이라고 부릅니다.
이건 버그가 아니라 설계입니다. 확신이 없을 때 살려 두는 쪽을 고른 것입니다. 반대로 골랐다면 살아 있는 객체를 지워서 프로그램이 죽었을 겁니다.
실무에서 이게 문제가 되는 경우는 드뭅니다. 다만 알아 둘 값어치는 있습니다. “분명히 다 놓았는데 메모리가 안 줄어드는” 일이 가끔 있고, 그중 일부가 이것입니다. 64비트에서는 주소 공간이 넓어서 우연히 겹칠 확률이 훨씬 낮아졌습니다.
둘. 비압축: 옮기지 않는다
이게 실무에서 제일 크게 옵니다.
정밀한 GC 는 치운 뒤에 살아남은 객체를 앞으로 밀어 붙입니다. 빈틈을 없애는 것입니다. 그러려면 그 객체를 가리키던 주소를 전부 찾아 고쳐 써야 하는데, 정확히 알고 있으니 할 수 있습니다.
이 미는 단계가 압축(compaction)입니다.
Boehm 은 못 합니다. 방금 본 것처럼 주소인지 아닌지도 확신을 못 하는데 그 값을 고쳐 쓸 수는 없습니다. 그냥 정수였다면 남의 계산을 망가뜨리게 됩니다. 보수적이라서 비압축인 것이고, 두 성질은 따로 고른 것이 아니라 하나가 다른 하나를 막은 것입니다.
그래서 유니티 문서에도 이렇게 적혀 있습니다. 유니티는 객체들 사이의 빈틈을 메우려고 재배치하지 않는다고요.
결과는 조각입니다.
정밀한 GC 였다면 여기서 힙이 안 커집니다. A 와 C 와 E 를 앞으로 밀어 붙이면 구멍 둘이 하나로 합쳐지고, 128바이트가 그 자리에 들어갑니다. 밀어 붙이지 못하니 빈 공간이 있어도 못 씁니다.
빈 메모리의 총합은 충분한데 힙이 커지는 것입니다. 그리고 앞에서 봤듯이 한 번 커진 힙은 안 줄어듭니다.
크기가 제각각인 객체를 계속 만들고 버리면 이 구멍이 쌓입니다. 문자열을 반복문에서 잇거나, 매 프레임 배열을 새로 만드는 코드가 딱 그런 모양입니다.
셋. 비세대: 나이를 따지지 않는다
정밀한 GC 들은 대개 세대를 씁니다. 대부분의 객체가 금방 죽는다는 관찰을 이용하는 것입니다. 방금 만든 것만 모아 자주 훑고, 오래 산 것은 드물게 훑습니다. 그러면 대부분의 수집이 아주 싸집니다.
이걸 세대(generational) GC 라고 합니다. Boehm 에는 그게 없어서 비세대입니다. 한 번 돌 때마다 힙 전체를 훑습니다.
그래서 힙이 커질수록 한 번 도는 시간이 같이 길어집니다. 게임이 진행되면서 힙이 100MB, 200MB 로 커지면 GC 한 번이 점점 비싸집니다. 초반에는 멀쩡하다가 후반부에 프레임이 튀기 시작하는 게임에 이 이유가 섞여 있습니다.
그리고 여기서 작은 할당도 비싸다는 결론이 나옵니다. 16바이트짜리 하나를 할당했을 뿐인데 그게 임계치를 넘기면 200MB 짜리 힙 전체를 훑습니다. 할당의 크기가 아니라 횟수가 중요하다는 말이 유니티에서 특히 강하게 성립하는 이유입니다.
정리
- 지금 대부분의 유니티 프로젝트는 Boehm 위에서 돕니다. IL2CPP 도 그렇습니다
- 보수적(conservative)입니다. 주소처럼 생긴 값을 다 주소로 쳐서, 안 쓰는 객체가 살아남기도 합니다
- 비압축(non-compacting)입니다. 빈틈이 그대로 남아서, 빈 메모리가 충분해도 이어진 자리가 없으면 힙이 커집니다
- 비세대(non-generational)입니다. 매번 힙 전체를 훑어서, 힙이 커질수록 한 번이 비싸집니다
- 셋은 따로 놓인 것이 아닙니다. 정확히 모르는 것이 첫 단추입니다. 그래서 못 옮기고, 세대로 나눠서 얻는 이득도 대부분 옮기는 것에서 나옵니다
- 셋 다 “아무 언어에나 붙일 수 있게” 하려고 포기한 것들입니다