· 9 min read
Unity 7 Editor 메모리 누수를 쫓아서 Unity 7 Editor 메모리 누수를 쫓아서
reload마다 남던 이전 코드, 원인과 잡는 법 reload마다 남던 이전 코드, 원인과 잡는 법
Unity 6.7과 Unity 7 비교에서 Editor 메모리는 짧게만 다뤘다. 이 글은 그 뒤다. reload마다 줄지 않던 메모리를 따라 Unity 코드 두 곳과 패키지 세 개까지 내려갔다.
30번 reload에 8 GB
Unity 7 알파 7(7000.0.0a7) Editor는 스크립트 reload 한 번에 MatterKit 샘플에서 230 MB씩 커졌다. 30번 만에 8.2 GB다. Unity 6.7(Mono)은 1 MB 정도다. Windows도 같았다. 작은 예제 프로젝트도 reload당 122 MB였고, 110번까지 돌려도 꺾이지 않았다(584 MB → 13.9 GB). 아래는 예제 프로젝트 30번이다. 알파 7만 직선으로 늘고, 6.7, 알파 9, 캐시를 비운 알파 7은 바닥에 머문다.
Editor memory over repeated reloads
30 script edits, each followed by a reload. App Resident memory, Mac, sample project. Unity's own leak, fixed in alpha 9
Alpha 7 4.2 GB+122 MB per reload
| Reloads | Unity 7 alpha 7 (CoreCLR) | Unity 7 alpha 9 (25 reloads) | Unity 7 alpha 7 + two caches cleared (15 reloads) | Unity 6.7 (Mono) |
|---|---|---|---|---|
| 0 | 0.58 GB | 0.59 GB | 0.47 GB | 0.40 GB |
| 1 | 0.70 GB | 0.61 GB | 0.54 GB | 0.42 GB |
| 2 | 0.81 GB | 0.62 GB | 0.55 GB | 0.44 GB |
| 3 | 0.93 GB | 0.62 GB | 0.55 GB | 0.44 GB |
| 4 | 1.05 GB | 0.62 GB | 0.56 GB | 0.44 GB |
| 5 | 1.16 GB | 0.62 GB | 0.56 GB | 0.44 GB |
| 6 | 1.28 GB | 0.62 GB | 0.56 GB | 0.44 GB |
| 7 | 1.40 GB | 0.62 GB | 0.56 GB | 0.44 GB |
| 8 | 1.54 GB | 0.63 GB | 0.56 GB | 0.44 GB |
| 9 | 1.67 GB | 0.63 GB | 0.56 GB | 0.44 GB |
| 10 | 1.80 GB | 0.63 GB | 0.56 GB | 0.44 GB |
| 11 | 1.90 GB | 0.63 GB | 0.56 GB | 0.44 GB |
| 12 | 2.03 GB | 0.63 GB | 0.56 GB | 0.44 GB |
| 13 | 2.14 GB | 0.63 GB | 0.56 GB | 0.44 GB |
| 14 | 2.25 GB | 0.63 GB | 0.57 GB | 0.44 GB |
| 15 | 2.37 GB | 0.64 GB | 0.57 GB | 0.44 GB |
| 16 | 2.49 GB | 0.64 GB | – | 0.44 GB |
| 17 | 2.62 GB | 0.64 GB | – | 0.44 GB |
| 18 | 2.72 GB | 0.64 GB | – | 0.44 GB |
| 19 | 2.85 GB | 0.64 GB | – | 0.44 GB |
| 20 | 2.95 GB | 0.64 GB | – | 0.44 GB |
| 21 | 3.09 GB | 0.66 GB | – | 0.44 GB |
| 22 | 3.18 GB | 0.57 GB | – | 0.45 GB |
| 23 | 3.32 GB | 0.57 GB | – | 0.45 GB |
| 24 | 3.44 GB | 0.57 GB | – | 0.45 GB |
| 25 | 3.55 GB | 0.57 GB | – | 0.45 GB |
| 26 | 3.67 GB | – | – | 0.45 GB |
| 27 | 3.80 GB | – | – | 0.45 GB |
| 28 | 3.92 GB | – | – | 0.45 GB |
| 29 | 4.04 GB | – | – | 0.45 GB |
| 30 | 4.16 GB | – | – | 0.45 GB |
GC 여유분이 아니었다
처음에는 CoreCLR GC가 여유 메모리를 넉넉히 쓰는 줄 알았다. 아니었다. GC 설정을 바꿔도 기울기는 그대로였고, 늘어나는 것은 GC 힙이 아니라 코드였다.
- 이전 코드에 weak GCHandle을 걸고 강제 GC를 다섯 번 해도 옛 컨텍스트 110개가 전부 살아 있었다.
- 흔한 지표로는 안 보인다.
AppDomain.GetAssemblies()와AssemblyLoadContext.All은 Unload가 호출된 컨텍스트를 살아 있어도 뺀다. macOS RSS는 압축된 페이지가 빠져서, 110번째에 RSS 1.4 GB, 실제 footprint 13.6 GB였다.
무엇이 붙잡고 있었나
Unity 7은 사용자 코드를 collectible AssemblyLoadContext에 올리고 reload 때 내린다. 그 안의 타입을 누가 쥐고 있으면 컨텍스트가 통째로 남는다. UnityEditor.CoreModule까지 들어 있어 하나에 100 MB가 넘는다.
숨은 인자 -enable-alc-leak-detection 1을 켜면 Editor가 새는 컨텍스트와 루트 경로를 JSON으로 남긴다. 경로는 Unity.Scripting.dll의 두 곳, ProfilerMarker 캐시와 정리 콜백 목록이었다. 기본 컨텍스트에 있는 이 static들이 옛 컨텍스트를 쥔다.
Who keeps the old context alive (alpha 7)
Both roots live in Unity.Scripting.dll lifecycle code, in the default context
Unity.Scripting.dll
statics, never unloaded
ProfilerMarker cache
keyed by attribute Type, never cleared
Fixed in alpha 9: ClearMarkerCache()
Old collectible ALC
user code + CoreModule
Static cleanup list
filter sees only the wrapper's type
Fixed in alpha 9: filter by IsOwnedBy
- Unity.Scripting.dll → ProfilerMarker cache
- Unity.Scripting.dll → Static cleanup list
- ProfilerMarker cache → Old collectible ALC
- Static cleanup list → Old collectible ALC
Clear one and the other still holds it. Clear both and growth drops to 1.1 MB per reload.
리포트는 컨텍스트마다 가장 짧은 경로 하나만 보여 준다. 그래서 한쪽만 비우면 다른 쪽이 나온다.
최소 재현과 알파 9
빈 프로젝트에 빈 MonoBehaviour와 Editor 드라이버만 넣고 reload를 25번 돌렸다. 무엇을 빼도 reload당 122~127 MB로 새는 양은 같았다. 새는 것은 사용자 코드가 아니라 컨텍스트 하나다.
- domain reload를 켠 Play Mode도 같은 양이 새고, 끄면 0이다.
- 알파 9는 릴리스 노트에 없이 이것을 고쳤다. 두 버전의
Unity.Scripting.dll을 디컴파일해 보면 그림의 두 곳이 그대로 바뀌어 있다. 같은 날 대조로 돌린 알파 7은 122.9 MB였다. - Windows MatterKit 샘플도 알파 9에서 reload당 -5 MB로 늘지 않는다. 알파 7~8을 쓰고 있다면 알파 9로 올리면 된다.
알파 9에 남은 것: Play Mode 누수
Unity 자체 누수가 고쳐진 뒤에도 MatterKit 샘플은 domain reload를 켠 Play 진입마다 커졌다. 6.7은 늘지 않는다.
| 알파 9, Play 진입 한 번 | 프로세스 메모리 | managed 힙 | 옛 컨텍스트 |
|---|---|---|---|
| Windows | +283 MB | +102 MB | |
| Mac, 12번 | +365 MB | +102 MB | 12/12 살아 있음 |
What still holds the old context in alpha 9
Two package paths found with Editor leak detection, MatterKit sample
Entities [OnCodeUnloading]
starts UnloadUnusedAssets()
Play entry reload only, domain reload on
AsyncOperation wrapper
native strong handle stays
Old collectible ALC
about 100 MB in MatterKit
pipeline HTTP server
not stopped before reload
macOS only. Windows HttpListener uses http.sys
Pending socket accept
static in default context
- Entities [OnCodeUnloading] → AsyncOperation wrapper
- pipeline HTTP server → Pending socket accept
- AsyncOperation wrapper → Old collectible ALC
- Pending socket accept → Old collectible ALC
- Entities 쪽은 빈 프로젝트로 좁혔다.
[OnCodeUnloading]에서Resources.UnloadUnusedAssets()를 부르는 메서드 하나로 Play 진입마다 122.5 MB가 샌다. 스크립트 reload나beforeAssemblyReload에서 부르거나 6.7에서 돌리면 새지 않는다. - MatterKit에서 한 번에 100 MB나 되는 것은 Play 진입 때 도는 카탈로그 굽기가 레시피를 그 컨텍스트의 static에 채우기 때문이다. 굽기를 끄면 +11 MB로 줄지만 누수는 그대로다.
- pipeline 쪽은 reload마다 포트도 하나씩 늘어난다. Windows에서 스크립트 reload가 새지 않은 이유가 이 경로가 없어서다.
reload 직전에 두 곳을 정리하면 증가가 사라진다. Mac 8사이클 동안 GC 힙 기울기가 -0.3 MB였다.
AssemblyReloadEvents.beforeAssemblyReload += () =>
{
// Entities: clean up now, so [OnCodeUnloading] does not start UnloadUnusedAssets during the unload
Unity.Entities.Content.RuntimeContentManager.Cleanup(out _);
// com.unity.pipeline: stop the HTTP listener (internal API, so reflection)
Type.GetType("Unity.Pipeline.Editor.PipelineServerStartup, Unity.Pipeline.Editor")?
.GetMethod("StopServer", BindingFlags.Public | BindingFlags.Static)?.Invoke(null, null);
};Play에 들어갈 때 domain reload를 끄는 것만으로도 피할 수 있다.
Spill에서는 reload마다 110 MB
실제 게임 Spill도 같은 알파 9 Editor로 Windows에서 쟀다.
| Spill, Windows | 프로세스 메모리 | GC 힙 | MatterKit 샘플 |
|---|---|---|---|
| Play 진입 한 번 (domain reload 켬) | +269 MB | +21 MB | +283 MB |
| 스크립트 reload 한 번 | +110 MB | +6.9 MB | -5 MB |
Play 쪽은 Entities 경로와 같은 모양이다. 새로운 것은 스크립트 reload다. 20번에 1.72 GB → 3.83 GB가 됐고 후반에도 멈추지 않았다.
Mac에서 누수 탐지를 켜고 Spill의 reload를 돌리자 옛 컨텍스트를 쥔 곳이 둘 나왔다. 둘 다 Spill 코드가 아니라 패키지다.
com.unity.pipelineHTTP 서버: 위의 그 경로다.com.unity.ide.visualstudio2.0.27: 컨텍스트마다 UDP 소켓을 열고 정리를AppDomain.CurrentDomain.DomainUnload에 건다. Mono는 reload가 곧 AppDomain을 버리는 일이라 이 이벤트가 불렸다. CoreCLR은 부르지 않는다.
Mac에서 하나만 막으면 reload당 +259 MB 그대로이고, 둘 다 막아야 +9.7 MB로 떨어진다. 우회는 Play 쪽과 같다. beforeAssemblyReload에서 두 패키지의 정리를 직접 부른다. Unity 7에서 VS Code로 프로젝트를 연다면 pipeline 패키지가 없어도 reload마다 컨텍스트 하나씩을 잃고 있을 수 있다.
Windows의 110 MB는 아직 설명하지 못했다. 러너에서는 pipeline 서버가 포트를 못 잡아 뜨지 않았고, 편집기가 Rider라 VS 연동도 꺼져 있었다. 남은 후보는 Rider 연동 패키지가 Rider 설치본에서 읽어 들이는 Editor 플러그인이다. 확인하지 않은 추정이다.
unity-bench는 이제 무엇을 보나
어셈블리 수가 누수를 숨겨서, unity-bench v0.7.0부터 누수 지표를 바꿨다.
- reload·Play 반복 워크로드는 “내린 컨텍스트 중 아직 살아 있는 수”를 센다. 정상이면 0(마지막 reload 직후는 1)이고, 새면 reload 횟수만큼 쌓인다.
- 방법은 이 조사와 같다. 자기 코드의 타입에 weak GCHandle을 걸고, 아직 살아 있으면 강제 수집을 최대 다섯 번 더 한다.
- 프로세스 메모리는 macOS에서도 RSS가 아니라 App Resident(phys_footprint)로 읽는다.
- 왜 남았는지는
alc_leak_detection = true로 Editor 누수 탐지를 켜서, 첫 루트 경로를 결과에 함께 남긴다.