Unity로 만든 게임의 “메모리 사용량”은 어디서 보느냐에 따라 숫자가 달라집니다. 이번에 큐브 20,000개를 그리는 데모를 Windows에서 돌려 측정했더니, Framedash 대시보드에서는 167 MB, Windows 작업 관리자에서는 739 MB, Unity의 API로 C# 힙을 읽으면 8 MB였습니다. 셋은 같은 순간의 값도, 같은 실행의 값도 아닙니다. 대시보드와 API의 값은 각각 다른 실행의 중앙값이고, 작업 관리자의 739 MB는 어느 한 시점의 값입니다(읽는 법은 뒤에서 다룹니다). 숫자가 어긋나는 것은 각각이 서로 다른 것을 세고 있기 때문입니다. 이 글에서는 같은 빌드에서 여덟 가지 방법으로 메모리 값을 동시에 기록해, 각각이 무엇을 세고 있는지와 Framedash가 보내는 것은 어느 것인지를 정리합니다.
”메모리”는 안쪽에서 바깥쪽까지 네 개의 계층으로 나뉜다
게임의 메모리 사용량은 어디까지를 세느냐에 따라 네 개의 계층으로 나뉩니다.
가장 안쪽은 C# 코드가 만든 객체가 올라가는 영역입니다. new한 클래스나 배열이 여기에 쌓이고, 가비지 컬렉션이 치우는 것도 여기입니다.
그 바깥은 Unity 엔진이 실제로 쓰고 있는 양입니다. C# 객체 말고도 텍스처처럼 엔진이 내부에서 안고 있는 것이 들어갑니다.
더 바깥은 Unity가 OS에서 한꺼번에 빌려 와 손에 쥐고 있는 양입니다. 다 쓴 몫도 곧바로 돌려주지 않고 다음 확보에 대비해 계속 들고 있습니다. 그만큼 실제로 쓰고 있는 양보다 커집니다.
가장 바깥은 OS에서 본 프로세스 전체의 양입니다. 실행 파일이나 DLL, 그래픽스 드라이버처럼 Unity의 할당자가 추적하지 않는 것까지 들어갑니다.
이번 측정에서는 바깥쪽 계층일수록 큰 숫자가 나왔습니다. 다만 넷이 깔끔한 포함 관계는 아닙니다. 작업 집합이 세는 것은 물리 메모리에 올라와 있는 페이지이고, 개인(커밋)이 세는 것은 페이지 아웃된 몫까지 포함해 프로세스가 확보해 둔 페이지입니다. 메모리 표시는 대개 이 넷 가운데 하나를 잘라 낸 것입니다. 예외는 뒤에 나오는 그래픽스 드라이버 값으로, 넷을 감싸는 다섯 번째 계층이 아니라 따로 서 있는 카운터입니다.
큐브 20,000개를 두 가지 방식으로 그려서 측정한다
사용한 것은 지난 글과 같은, 큐브 20,000개를 그리는 프로젝트입니다. 그리는 방식만 다른 두 가지 모드를 준비했습니다.
- Naive: 큐브 한 개마다 게임 오브젝트와 MeshRenderer를 하나씩 둔다
- InstancedDirect: 큐브별 게임 오브젝트를 만들지 않고
Graphics.RenderMeshInstanced로 그린다
지난 글의 NAIVE와 INSTANCED_DIRECT에 해당합니다. 환경은 Unity 6000.4.11f1, Built-in Render Pipeline, 그래픽스 API는 D3D12, GPU는 RTX 4070 Ti, 해상도는 1920x1080입니다. Development 빌드가 아니라 릴리스 플레이어에서 측정했습니다.
15초 워밍업 뒤 120초를 측정했습니다. 여덟 가지 읽기는 SDK의 Collect()를 호출한 직후의 같은 프레임 안에서 함께 취합니다. 다만 표에 싣는 값은 120초분의 프레임을 읽기별로 중앙값(p50)을 낸 것이라, 어느 한 프레임을 그대로 옮긴 숫자가 아닙니다.
Unity 플레이어 안에서 OS 쪽 값을 가져올 때 System.Diagnostics.Process.WorkingSet64와 PrivateMemorySize64는 쓸 수 없습니다. Mono 런타임에서는 둘 다 0을 돌려줍니다. 이번에는 psapi의 GetProcessMemoryInfo를 P/Invoke로 호출했고, 밖에서 Get-Process로 동시에 취한 값과 맞춰 봤습니다. 중앙값은 848.5 MB와 848.7 MB로, 어긋나지 않았습니다.
같은 빌드에서 읽은 여덟 개의 값
| 읽기 | Naive | InstancedDirect | 차이 |
|---|---|---|---|
GetMonoUsedSizeLong |
8.2 MB | 6.9 MB | -16% |
GC.GetTotalMemory(false) |
8.2 MB | 6.9 MB | -16% |
GetMonoHeapSizeLong |
10.3 MB | 7.9 MB | -23% |
GetTotalAllocatedMemoryLong |
167.2 MB | 56.6 MB | -66% |
GetTotalReservedMemoryLong |
263.4 MB | 155.2 MB | -41% |
| OS 작업 집합 | 852.4 MB | 412.0 MB | -52% |
| OS 개인(커밋) | 1,168.3 MB | 681.9 MB | -42% |
GetAllocatedMemoryForGraphicsDriver |
0 | 0 | - |
여덟 가지의 내역은 가장 안쪽 계층을 보는 것이 셋, 엔진이 쓰고 있는 양과 빌려 온 양이 하나씩, OS 쪽이 둘, 그래픽스 드라이버가 하나입니다. 음영 처리된 행이 Framedash의 Unity SDK가 보내고 있는 계층입니다. 프레임 타임 p50은 9.25 ms와 1.44 ms로, 지난번과 같은 경향이었습니다. 지난 글에 실은 169.0 MB와 이번의 167.2 MB는 같은 인자로 같은 빌드 구성을 따로따로 돌린 결과입니다. 어느 쪽의 memory_used_bytes도 이 할당된 메모리 계층을 가리킵니다.
GC.GetTotalMemory(false)는 이 자릿수에서는 GetMonoUsedSizeLong과 같은 값이 되고, GetAllocatedMemoryForGraphicsDriver는 릴리스 플레이어에서 0이기 때문입니다. 진한 막대가 Naive, 연한 막대가 InstancedDirect이며, 음영 처리된 행이 Framedash의 Unity SDK가 보내는 계층입니다.게임 오브젝트를 그만둔 효과는 어느 계층에서 보느냐에 따라 크기가 달라집니다. 할당된 메모리에서는 -66%, C# 힙 사용량에서는 -16%, 힙으로 확보해 둔 양에서는 -23%입니다. “메모리를 66% 줄였다”와 “16%밖에 줄지 않았다”는 같은 하나의 변경을 다른 계층에서 본 보고이고, 둘 다 맞는 숫자입니다.
계층마다 무엇을 세고 있는가
GetMonoUsedSizeLong과 GetMonoHeapSizeLong이 보고 있는 것은 가장 안쪽 계층입니다. C# 객체가 올라가는 이 영역을 관리 힙이라고 부릅니다. 사용량이 8.2 MB, 확보량이 10.3 MB로, 차이인 2.1 MB는 힙으로 확보해 둔 빈 영역입니다. GC.GetTotalMemory(false)가 표시된 자릿수까지 일치한 것은, 이것도 같은 관리 힙의 사용량을 돌려주기 때문입니다. GC 압력을 쫓는다면 볼 것은 이 계층입니다.
GetTotalAllocatedMemoryLong은 Unity의 할당자가 실제로 쓰고 있는 양의 합계입니다. 167.2 MB 가운데 관리 힙은 8.2 MB뿐, 전체의 5%입니다. 나머지는 네이티브 쪽이고, 게임 오브젝트 20,000개를 그만둬서 110.6 MB 줄어든 것도 거의 이 몫이었습니다.
GetTotalReservedMemoryLong은 Unity가 OS에서 가져와 손에 쥐고 있는 풀의 크기입니다. 할당된 메모리는 그 풀 가운데 실제로 쓰이고 있는 몫에 해당합니다. Naive에서는 263.4 MB를 예약하고 167.2 MB를 쓰고 있었으니, 96.2 MB가 쓰이지 않은 채 남아 있었습니다. InstancedDirect에서도 남은 양은 98.6 MB로 거의 같습니다. 비율로 보면 예약된 메모리의 -41%와 할당된 메모리의 -66%는 떨어져 있지만, 줄어든 양은 108.2 MB와 110.6 MB로 거의 같습니다.
OS 작업 집합은 프로세스가 지금 물리 메모리 위에 갖고 있는 페이지의 양입니다. 작업 관리자의 “작업 집합”(Working set)과 같은 것을 가리킵니다. 개인(커밋)은 프로세스가 자기 전용으로 확보한 페이지로, 물리 메모리에서 밀려난 몫도 포함합니다. 작업 관리자의 “커밋 크기”(Commit size)가 이것입니다.
Naive에서는 할당된 167.2 MB에 대해 작업 집합이 852.4 MB였습니다. 차이는 685.2 MB입니다. 다만 이것은 재는 대상이 다른 두 카운터의 차입니다. 작업 집합은 물리 메모리에 올라와 있는 페이지를, 할당된 메모리는 Unity의 할당자가 쓰고 있는 양을 세기 때문에, 이 685.2 MB 자체가 무엇인가를 재다 값은 아닙니다. 실행 파일과 DLL의 매핑, 그래픽스 드라이버, 플러그인의 할당은 모두 프로세스 안에 있고 Unity의 할당자 밖에 있지만, 내역까지는 이번 측정이 나누지 않았습니다.
남은 GetAllocatedMemoryForGraphicsDriver는 네 계층과는 별도로, 그래픽스 드라이버가 확보한 양을 보는 API입니다. 릴리스 빌드에서는 0을 돌려줬습니다. 이 API는 ENABLE_PROFILER를 필요로 하고, Development 빌드에서 측정하면 두 모드 모두 99.0 MB입니다. 두 모드에서 같은 값이었으니, 게임 오브젝트를 그만둬도 드라이버 쪽 확보량은 움직이지 않았습니다. 여기서의 0은 “0바이트 쓰고 있다”가 아니라 “값을 가져오지 못했다”를 뜻합니다. Framedash의 Unity SDK도 0일 때는 mem.vram 키째로 보내지 않습니다.
작업 관리자의 “메모리” 열은 표의 어느 것과도 다르다
작업 관리자의 [프로세스] 탭에서 이 실행 파일의 “메모리” 열은 738.7 MB였습니다. 같은 시각에 밖에서 취한 작업 집합은 795.5 MB, 개인은 1,091.3 MB입니다. 셋 다 다릅니다. [프로세스] 탭의 이 열이 가리키는 것은 “개인 작업 집합”(Private working set)으로, 위 표에 실은 두 개의 OS 쪽 값 가운데 어느 것도 아닙니다.
이 셋은 표의 852.4 MB나 1,168.3 MB와도 맞지 않습니다. 작업 관리자에서 보이는 것은 한 시점의 값이고, 표는 120초 동안의 중앙값이기 때문입니다.
Development 빌드의 숫자와 릴리스의 숫자를 섞지 않는다
같은 두 모드를 Development 빌드에서도 측정했습니다. 할당된 메모리는 183.6 MB와 84.6 MB, 예약된 메모리는 337.7 MB와 221.5 MB입니다. 릴리스의 할당된 메모리 167.2 MB / 56.6 MB, 예약된 메모리 263.4 MB / 155.2 MB와 비교하면 넷 다 늘었습니다. 프로파일러의 계측이 그만큼 메모리를 쓰기 때문입니다.
작업 집합 쪽은 같은 방향으로 움직이지 않았습니다. 792.1 MB와 469.6 MB로, Naive는 릴리스보다 낮고 InstancedDirect는 높습니다. 할당된 메모리와 예약된 메모리에서 늘었더라도, 작업 집합이 같은 방향으로 움직인다는 보장은 없습니다. Development 빌드의 숫자와 릴리스의 숫자를 나란히 놓고 증감을 말할 수 없는 것은 이 때문입니다.
Framedash가 보내는 것은 엔진마다 다른 계층
Framedash의 전송 스키마에 있는 메모리 칸은 int64인 memory_used_bytes 하나입니다. 대시보드는 이것을 1048576으로 나눠 MB로 표시합니다. 칸이 하나여도, 거기에 들어가는 값이 어느 계층인지는 엔진마다 다릅니다.
- Unity:
Profiler.GetTotalAllocatedMemoryLong(). 이 글의 167.2 MB와 56.6 MB 계층이고, 10초마다의perf_heartbeat에 실립니다 - UE5:
FPlatformMemory::GetStats().UsedPhysical. 물리 메모리 위의 상주량이라, Unity보다 OS 쪽 숫자에 가까운 계층입니다 - Godot(C#):
Performance.Monitor.MemoryStatic. 익스포트한 릴리스 빌드에서는 이 모니터를 쓸 수 없어, SDK는System.GC.GetTotalMemory로 폴백합니다
Godot(C#)에서는 같은 열의 의미가 빌드 종류에 따라 달라집니다. 관리 힙만 센 값이 얼마나 낮게 나오는지는 위 표로 짐작할 수 있습니다. Unity에서는 8.2 MB와 167.2 MB였습니다.
Unity SDK에서는 관리 힙과 드라이버 쪽을 별도의 키로 추가할 수 있습니다. mem.heap이 GetMonoUsedSizeLong, mem.vram이 GetAllocatedMemoryForGraphicsDriver입니다. 둘 다 0일 때는 보내지 않습니다.
SDK에서 텔레메트리를 보내면서 측정한 다른 실행에서는 GetMonoUsedSizeLong의 p50이 올라갔습니다. Naive에서 8.2 MB에서 9.8 MB로, InstancedDirect에서 6.9 MB에서 8.8 MB로입니다. 같은 실행의 할당된 메모리는 167.0 MB와 56.6 MB, 작업 집합은 857.3 MB와 415.3 MB로, 전송하지 않은 실행과의 차이는 모두 5 MB 이내였습니다. 늘어난 1.6 MB와 1.9 MB는, 이 구성에서 SDK가 관리 힙에 올린 몫입니다. 이벤트 버퍼와 직렬화와 SDK 자체의 로그 중 어느 것이 얼마인지는 이 실행에서 나누지 않았습니다.
빌드 간 비교에서는 p50과 p95가 나란히 나옵니다. 중앙값이 움직이지 않은 채 꼬리만 늘어난 변화도 거기에 나타납니다. CI에서 framedash perf-diff를 실행하면, 릴리스 전에 빌드 간의 차이를 이 입도로 확인할 수 있습니다.
이번 이야기는 어디까지인가
여기까지의 값은 Windows, D3D12, Mono 백엔드, 데스크톱 GPU라는 하나의 구성에서 측정한 것입니다. 할당된 메모리와 작업 집합 사이가 얼마나 벌어지는지는 플랫폼과 스크립팅 백엔드에 따라 달라집니다. IL2CPP 빌드에서는 측정하지 않았습니다.
계층끼리의 대응도 이 구성에서의 결과입니다. GC.GetTotalMemory(false)가 GetMonoUsedSizeLong과 일치한 것은 이번 Mono 백엔드에서의 이야기이고, 어느 백엔드에서나 그렇다고는 할 수 없습니다.
해상도를 바꾸면 작업 집합도 움직입니다. 1920x1080 이외의 값은 갖고 있지 않습니다. 20,000개 전부의 위치를 매 프레임 다시 쓰는 워크로드라는 점도, 자기 프로젝트에 적용하기 전에 확인해 주세요.
숫자를 내놓을 때는 계층의 이름도 함께 말한다
“메모리가 852 MB”라고만 하면 무슨 이야기인지 정해지지 않습니다. 같은 측정에서 8.2 MB라고도, 1,168.3 MB라고도 말할 수 있기 때문입니다.
무엇을 알고 싶은지에 따라 볼 계층이 정해집니다.
- 내 변경으로 엔진이 안고 있는 양이 줄었는지: 할당된 메모리
- 기기의 메모리에 들어가는지: 작업 집합과 커밋 크기
- GC 압력: 관리 힙
비교할 때는 계층과 빌드 종류와 해상도를 맞춥니다. 그리고 Framedash를 쓰고 있다면, 자기 엔진이 어느 계층을 보내고 있는지를 먼저 확인해 둡니다.
- 전송되는 메트릭의 정의는 데이터 모델에 정리되어 있습니다.
- SDK 도입 절차는 Unity SDK 가이드에 있습니다.