같은 Unity 게임이라도 Framedash 대시보드, 작업 관리자, C# 힙 카운터에 표시되는 메모리 사용량은 크게 다를 수 있습니다. Windows에서 큐브 20,000개를 그리는 데모를 측정했을 때는 각각 167 MB, 739 MB, 8 MB였습니다. 이 숫자들은 서로 다른 관측에서 나왔습니다. 대시보드와 힙 값은 각각 별도 실행의 중앙값이고, 작업 관리자의 739 MB는 한 시점의 측정값입니다.
비교하기 전에 각 카운터가 무엇을 측정하는지 확인해야 합니다. Framedash의 Unity SDK가 보내는 값은 Unity 할당자가 사용 중인 메모리입니다. 관리 힙 사용량이나 프로세스가 차지하는 물리 메모리와는 측정 범위가 다릅니다. 이 차이를 같은 조건에서 살펴보기 위해, 동일한 빌드에서 매 프레임 여덟 가지 값을 기록했습니다.
메모리 사용량을 나누는 네 가지 범위
측정값은 다음 네 가지 범위로 나눌 수 있습니다. 계층으로 생각하면 용도를 구분하기 쉽지만, 서로를 완전히 포함하는 구조는 아닙니다.
- 관리 힙: C# 클래스 인스턴스와 배열 등을 저장하며 가비지 컬렉터가 관리하는 메모리
- Unity 할당 메모리: Unity 할당자의 메모리 풀에서 사용 중인 양. 텍스처 데이터 등 관리 힙 외의 엔진 메모리 할당도 포함합니다
- Unity 예약 메모리: Unity가 OS에서 확보해 보유하는 메모리 풀 전체. 재사용할 수 있는 여유 공간도 포함합니다
- 프로세스 메모리: OS가 측정하는 메모리. 실행 파일과 DLL 매핑 등 Unity 할당자가 추적하지 않는 영역도 대상입니다
OS도 여러 기준으로 메모리를 측정합니다. 작업 집합은 현재 물리 메모리에 상주하는 페이지를 세고, 프로세스 전용 커밋은 페이지 아웃된 부분까지 포함한 전용 커밋 메모리를 셉니다. 이번에는 OS 측정값이 Unity 값보다 컸지만, 범위가 다른 숫자를 빼서 정확한 내역을 구할 수는 없습니다. 그래픽스 드라이버 메모리 역시 별도 카운터이며, 다른 범위를 감싸는 다섯 번째 계층이 아닙니다.
큐브 20,000개를 두 가지 방식으로 그리기
지난 글과 같은 프로젝트에서 큐브 20,000개를 두 가지 방식으로 그렸습니다.
- Naive: 큐브마다 GameObject와 MeshRenderer를 하나씩 생성
- InstancedDirect: 큐브별 GameObject 없이 스크립트에서
Graphics.RenderMeshInstanced로 렌더링
지난 글의 NAIVE와 INSTANCED_DIRECT에 해당합니다. 환경은 Unity 6000.4.11f1, Built-in Render Pipeline, D3D12, Mono 스크립팅 백엔드, RTX 4070 Ti, 1920x1080입니다. 주 비교 표에는 텔레메트리 전송을 끈 릴리스 플레이어의 값을 사용했습니다.
각 모드는 15초 워밍업 후 120초 동안 측정했습니다. 이 실험에서는 SDK의 Collect() 호출 직후 같은 프레임 안에서 여덟 가지 값을 읽었습니다. 표의 각 값은 해당 카운터가 측정 기간의 모든 프레임에서 기록한 값의 중앙값(p50)입니다. 따라서 표 전체가 특정 프레임 하나의 상태를 나타내지는 않습니다.
이번에 테스트한 Mono 플레이어에서는 System.Diagnostics.Process.WorkingSet64와 PrivateMemorySize64가 모두 0을 반환했습니다. 대신 P/Invoke로 psapi의 GetProcessMemoryInfo를 호출하고, 플레이어 외부에서 실행한 Get-Process와 대조했습니다. 대조 측정의 중앙값은 848.5 MB와 848.7 MB로, 차이는 0.2 MB였습니다.
여덟 가지 측정값의 120초 중앙값
| 읽기 | 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 | - |
음영 처리한 행이 Framedash Unity SDK가 보내는 카운터입니다. 프레임 타임 p50은 Naive 9.25 ms, InstancedDirect 1.44 ms로 지난 글과 같은 경향을 보였습니다. 지난 글의 169.0 MB와 이번의 167.2 MB는 동일한 빌드 구성과 실행 인자로 각각 별도 실행한 결과이며, 둘 다 할당 메모리를 측정합니다.
GC.GetTotalMemory(false)는 이 자릿수에서는 GetMonoUsedSizeLong과 같은 값이 되고, GetAllocatedMemoryForGraphicsDriver는 릴리스 플레이어에서 0이기 때문입니다. 진한 막대가 Naive, 연한 막대가 InstancedDirect이며, 음영 처리된 행이 Framedash의 Unity SDK가 보내는 계층입니다.Naive에서 InstancedDirect로 전환하자 Unity 할당 메모리는 66%, 관리 힙 사용량은 16%, 관리 힙 용량은 23% 줄었습니다. 모두 같은 변경의 결과입니다. “메모리가 66% 줄었다”고 보고하려면 어떤 카운터인지도 밝혀야 합니다.
각 카운터로 알 수 있는 것
관리 힙의 사용량과 용량
GetMonoUsedSizeLong은 가비지 컬렉션을 기다리는 객체까지 포함한 관리 힙 사용량을 반환합니다. GetMonoHeapSizeLong은 힙 용량을 반환합니다. Naive의 중앙값은 각각 8.2 MB와 10.3 MB로, 차이는 2.1 MB였습니다. 힙에는 객체가 차지하지 않은 공간도 있지만, 이 중앙값의 차이가 여유 공간을 별도로 측정한 값은 아닙니다.
GC.GetTotalMemory(false)는 이번 Mono 백엔드에서 표시된 자릿수까지 힙 사용량과 일치했습니다. 다른 백엔드에서도 일치한다고 보장할 수는 없습니다. 힙 사용량만으로는 할당 속도나 가비지 컬렉션 시간을 알 수 없습니다. GC와 관련된 프레임 타임 문제를 조사할 때는 Profiler에서 프레임별 할당량과 가비지 컬렉션 동작도 함께 확인해야 합니다.
Unity 메모리 풀의 사용량과 예약량
GetTotalAllocatedMemoryLong은 Unity 할당자의 메모리 풀에서 사용 중인 양을 반환합니다. 관리 힙의 8.2 MB는 할당 메모리 167.2 MB의 약 5%에 해당하는 크기입니다. 관리 힙의 작은 감소만으로는 할당 메모리가 110.6 MB 줄어든 결과를 설명할 수 없습니다. 이번 모드 전환에서 주로 네이티브 엔진 메모리가 줄었다고 해석할 수 있지만, 이 테스트에서는 객체별 사용량을 분리하지 않았습니다.
GetTotalReservedMemoryLong에는 풀 안의 여유 공간도 포함됩니다. 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의 작업 집합 중앙값 852.4 MB는 할당 메모리 중앙값 167.2 MB보다 685.2 MB 큽니다. 이는 정의가 다른 카운터의 차이이며, 특정 종류의 메모리를 측정한 결과가 아닙니다. 실행 파일과 DLL 매핑, 그래픽스 드라이버와 플러그인의 메모리 할당은 Unity 할당자의 추적 범위 밖에 있을 수 있습니다. 이번 실험에서는 각각이 얼마를 차지하는지 확인하지 않았습니다.
그래픽스 드라이버 메모리와 읽을 수 없는 값
GetAllocatedMemoryForGraphicsDriver는 Editor와 Development 플레이어에서 측정값을 제공하며, 네이티브 측정은 ENABLE_PROFILER로 제어됩니다. 이번 릴리스 플레이어에서는 0을, 두 Development 모드에서는 모두 99.0 MB를 반환했습니다. 표시된 정밀도에서는 모드 간 차이가 없었습니다.
릴리스의 0은 드라이버가 메모리를 쓰지 않았다는 뜻이 아니라 값을 읽을 수 없었다는 뜻입니다. Framedash는 이 API가 양수를 반환하지 않으면 mem.vram 키를 보내지 않습니다.
작업 관리자가 다른 숫자를 표시하는 이유
작업 관리자의 프로세스 탭에서 이 프로그램의 메모리 열은 738.7 MB였습니다. 같은 시각에 외부에서 조회한 작업 집합은 795.5 MB, 전용 커밋은 1,091.3 MB였습니다. 표시된 열은 물리 메모리에 상주하는 프로세스 전용 페이지만 세는 개인 작업 집합입니다. 공유 상주 페이지와 페이지 아웃된 전용 메모리는 제외하므로, 표에 있는 두 OS 카운터와 모두 다릅니다.
이 값들은 한 시점의 관측이므로 표의 120초 중앙값인 852.4 MB 및 1,168.3 MB와도 일치할 필요가 없습니다. 비교할 때는 카운터와 측정 구간을 모두 맞춰야 합니다.
변경을 비교할 때는 빌드 종류를 맞추기
두 모드를 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보다 모두 높았습니다. Development 빌드에는 프로파일링 계측이 포함되지만, 이 비교만으로 증가분 중 계측의 몫을 분리할 수는 없습니다.
작업 집합은 서로 다른 방향으로 움직였습니다. Naive는 792.1 MB로 릴리스보다 낮았고, InstancedDirect는 469.6 MB로 더 높았습니다. 할당자의 사용량이 늘었다고 해서 상주 페이지도 같은 만큼 늘어나는 것은 아닙니다. 최적화 효과를 판단하려면 릴리스끼리 또는 Development끼리 비교해야 합니다. 빌드 종류까지 바꾸면 또 다른 변수가 생깁니다.
Framedash의 메모리 필드에 담기는 값
전송 스키마의 메모리 필드는 int64인 memory_used_bytes 하나지만, 값을 얻는 출처는 엔진마다 다릅니다. 대시보드는 바이트 수를 1048576으로 나누어 MB로 표시합니다. 엄밀히 말하면 이 나눗셈의 단위는 MiB입니다.
- Unity:
Profiler.GetTotalAllocatedMemoryLong(). 이 글에서 167.2 MB와 56.6 MB를 기록한 카운터이며, SDK가 10초마다 보내는perf_heartbeat에 포함됩니다 - UE5:
FPlatformMemory::GetStats().UsedPhysical. Unity 할당자 카운터와 달리, 플랫폼이 제공하는 프로세스 물리 메모리 통계입니다 - Godot(C#):
Performance.Monitor.MemoryStatic이 양수를 반환하면 그 값을 사용하고, 그렇지 않으면System.GC.GetTotalMemory(false)로 폴백합니다. 내보낸 릴리스 빌드에서는 이 관리 힙 폴백을 사용하는 경우가 많습니다
따라서 Godot에서는 빌드 구성에 따라 같은 필드의 의미가 바뀔 수 있습니다. 값의 출처가 다른 빌드끼리는 직접 비교하지 않아야 합니다. Unity의 관리 힙 8.2 MB와 할당 메모리 167.2 MB는 이 차이를 설명하는 예시일 뿐, Godot에 적용할 환산 비율은 아닙니다.
Unity SDK는 GetMonoUsedSizeLong을 mem.heap으로, GetAllocatedMemoryForGraphicsDriver를 mem.vram으로도 전송합니다. 해당 API가 양수를 반환할 때만 키를 붙이므로, 키가 없다는 것을 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 로그의 몫을 나누지 않았고, 실행 기록으로는 로그 설정이 같았는지도 확인할 수 없습니다.
Framedash의 빌드 비교 화면은 p50과 p95를 함께 보여 줍니다. p95는 중앙값이 비슷하게 유지될 때도 높은 쪽 측정값의 변화를 보여 줄 수 있지만, 최댓값은 아닙니다. CI에서 framedash perf-diff를 실행해 릴리스 전에 빌드를 비교할 수 있습니다. 프레임별 벤치마크와 비교하기 전에는 백분위수를 어떤 표본으로 계산했는지 확인해야 합니다.
결과를 적용할 수 있는 범위
이번 측정은 Windows, D3D12, Mono, 데스크톱 GPU, 1920x1080 구성에서 이루어졌습니다. IL2CPP나 다른 해상도는 테스트하지 않았습니다. 워크로드는 매 프레임 큐브 20,000개 전체의 위치를 다시 씁니다. 다른 플랫폼, 백엔드, 해상도, 워크로드에서는 따로 측정해야 합니다. 여기서 나온 대소 관계나 감소율을 Unity 전체에 적용할 수는 없습니다.
목적에 맞는 카운터를 고르고 비교 조건을 유지하기
“메모리가 852 MB”라고만 하면 측정 범위가 빠집니다. 같은 실행에서도 8.2 MB와 1,168.3 MB가 함께 나왔습니다. 알고 싶은 내용에 따라 측정값을 골라야 합니다.
- Unity 할당자 사용량의 변화를 보려면 할당 메모리를 비교합니다. 풀 용량도 확인하려면 예약 메모리를 함께 봅니다
- 기기의 메모리 요구량을 평가하려면 대상 플랫폼에서 프로세스 작업 집합과 커밋을 피크까지 확인합니다. 120초 중앙값만으로 게임이 기기 메모리에 들어간다고 판단할 수는 없습니다
- 가비지 컬렉션 문제를 조사하려면 관리 힙 사용량에 더해 할당 속도와 컬렉션 동작을 확인합니다
변경 전후를 비교할 때는 카운터, 플랫폼, 스크립팅 백엔드, 빌드 종류, 해상도, 워크로드, 텔레메트리 설정을 맞춥니다. 워밍업, 샘플링 간격, 집계 구간도 같아야 합니다. 작업 관리자의 순간 값, 프레임별 p50, 텔레메트리 이벤트의 백분위수는 모두 MB로 표시되어도 서로 다른 질문에 답합니다.
- 데이터 모델: SDK가 전송하는 메트릭의 정의
- Unity SDK 가이드: 설정과 수집 방법