Unity/Memory

Memory, GC

SniKuz 2024. 6. 28. 21:01

목차

 

Unity Memory

유니티 메모리는 크게 3가지 영역으로 나뉘어 있습니다.

⭕ Native Memory : 유니티가 엔진을 구동하는데 쓰는 *C++ 메모리입니다. 대부분의 상황에서 유니티 사용자는 이 메모리에 엑세스할 수 없지만, 애플리케이션 성능의 특정 측면을 미세 조정하려면 이 메모리에 주의하는 것이 유용합니다. 씬 전환, 메모리 해제(Destroy) 등 관리해줍니다. 
* Unity는 엔진은 C++, 스크립팅은 C#을 사용중입니다.
* 유니티엔진이 OS로부터 메모리를 할당 받아 사용합니다. Scene, 에셋, 그래픽 API, 그래픽 드라이버 등이 할당되어 있습니다. 핵심적인 메모리 구역입니다.

Native Object(C++ 객체)는 Destroy 호출 시 메모리 해제, 즉시 삭제가 됩니다.

⭕  Managed Memory : Managed Heap과 GC를 사용하여 메모리를 자동으로 *allocate and *assign하는 계층입니다. (Wrapper Object. Native Object에 대한 pointer)
* allocate : Memory/Resource Allocation과 같이 자원을 예약하는데 주로 쓰입니다. 보다 큰 범위(리소스, 예산 등)의 분배에 대한 단어로 쓰입니다.
* assign : Variable/Task Assignment과 같이 Entity에 값 또는 작업을 제공하는데 주로 쓰입니다. 주로 특정 작업을 수행하기 위해 개인에게 책임을 부여하는데 사용됩니다.

모든 참조유형 혹은 박싱된 유형은 Managed Heap에 할당해야 합니다.

Wrapper Object(C# 클래스)는 Destory 호출 시 메모리가 즉시 해제 되지 않고, 이후 GC가 해제합니다. 하지만 이 경우 UnityEngine.Object(Wrapper Object)는 즉시 해제되지 않았기 때문에 null 비교를 할 때 문제가 있습니다. 그래서 실제로 null은 아니지만 null인 것처럼 행동하고 이를 fake null이라 합니다.

이때 null 체크를 UnityEngine.Object의 오버로딩한 연산자를 써서 Wrapper Object에서 하는 경우 비용이 매우매우 큽니다.🔗왜냐하면 Native Object 확인까지 하기 때문입니다.

GetComponent()나 transform 속성 호출이 비싸기 때문에 _cachedTransform와 같이 내장 변수를 드는 경우가 많은데, null체크를 하면 오히려 transform보다 비싸서 잘 작성해야 한다고 합니다. 그래서 null체크 말고 true, false로 묵시적 형변환으로 쓰거나 [ if(go) { } ...] 다른 방법을 쓸 수 있습니다.🔗

⭕  C# Unmanaged Memory : GC가 적용되지 않는 메모리 레이어로, Unity Collections namespace, 패키지를 통해 쓸 수 있는 공간입니다. Unity는 이 계층을 이용해 네이티브 메모리 계층에 접근하고 메모리 할당을 세밀하게 조정할 수 있다고 합니다. 만약 *Unity C# Job System 혹은 *Burst를 사용하는 경우 이 영역을 사용해야 합니다.
* Unity C# Job System : Unity엔진과 상호작용하는 멀티스레드 프로그래밍 기법.
* Burst : Low Level Virtual Machine을 활용한 최적화 컴파일러, 매 프레임마다 제한된 범위에 반복적인 코드를 돌릴 때 유용.

 

 

Unity GC

GC에 대한 지식은 이 사이트가 자세히 써져있는 것 같아 참고하기 좋을 것 같습니다. 🔗

Unity의 Managed Memory System은 VM(Mono, IL2CPP)을 기반으로하는 C# 스크립팅 시스템입니다.
Managed Heap은 *스크립팅 런타임(Mono, IL2CPP)가 자동으로 관리하는 메모리 섹션입니다.
GC와 Managed Heap을 사용해 메모리를 자동으로 해제합니다. 일반적인 GC에서의 효과를 생각하면 될 것 같습니다.

*스크립팅 런타임 : 스크립트 언어가 실행되는 환경으로 스크립트 코드 실행, 메모리 관리, 시스템 자원 제어 등 수행

유니티 GC는 Boehm-Demer-Wiser(bdwgc)라는 Mark And Sweep 기반 Stop the world 방식의 GC를 쓴다고 하는데, Unity 문서에 링크를 들어가면 이 GC가 C, C++을 타겟으로 만들어졌다고 합니다. 많은 글에서 Mono 2.8 이상부터는 SGen, Mono 2.8 이하는 Boehm 방식 GC를 쓴다고 합니다. 실제로 Unity의 mono 깃허브를 들어가보면 Boehm GC가 deprecated로 설명되어있고, SGen GC가 있었습니다. 그래서 글만보고 '아! 유니티가 SGen GC를 쓰는건가?' 했었는데 이상한 점이 많았습니다. 유니티 문서에는 SGen과 관련된 내용이 없었고 유니티 포럼에서도 .NET Core CLR GC를 도입하는데 현재 GC는 bdwgc라고 얘기하고 있었습니다. 

이상한 점을 느끼고 찾아보면서 충격적이었던 것은 bdwgc는 1988년 처음나왔다는 점입니다. 또한 Unity의 mono 깃허브 ReadMe 기록을 찾아보니 SGen이 처음 등장하는 시점은 2006년 업데이트 였습니다. MV+ * 패턴을 찾으면서도 느꼈는데 그러면 '지금 2020년도인데도 아직도 bdwgc를 쓰고 있다고...?' 라는 생각이 듭니다 .NET Core CLR GC를 도입하려고 하는 유니티 공식 포스트들과 몇몇 인터넷 글들(stackoverflow, reddit)을 보며 확신했을 때는 많이 충격적이었습니다...
*물론 bdwgc도 업데이트를 계속 하는 것 같습니다.
* WebGL의 경우 GC가 다르게 작동한다고 합니다. (Incremental GC 지원하지 않음 등) 🔗

 

 

Unity GC 모드

⭕  Incremental GC (playersetting → Use incremental GC (default))

Unity의 기본 GC로, GC 과정을 여러 프레임에 걸쳐 확산시킵니다. 기본적으로 Unity는 bdwgc를 incremental 모드로 사용합니다. 즉, GC는 CPU를 중지하여 Managed heap의 모든 개체를 처리하는 대신 여러 프레임에 걸쳐 분할해 작업합니다. 이 방법은 GC 속도를 높이지는 않지만, 여러 프레임에 걸쳐 작업을 분산하기에 GC로 인해 생기는 사용량 급상승을 줄입니다. 이 때 Unity Profiler창의 프레임 시간 그래프에 큰 스파이크가 나타나서 GC 스파이크라는 단어를 씁니다.

⭕  Disable GC

GC를 비활성화할 수 있습니다. 메모리를 잘 관리하지 않으면 애플리케이션의 메모리가 부족해질 때 까지 Managed Heap이 계속 확장되고 끝내 OS가 종료시킬 수 있습니다. GC를 완전히 비활성화할 수 있고, 혹은 GC의 자동 호출만을 비활성화하고 사용자는 원할 때 사용할 수도 있습니다. 가장 좋은 방법은 수명이 긴 할당의 경우에만 GC를 비활성화하는 것입니다. 게임이 로드되기 전에 현재 씬/레벨에 필요한 모든 메모리를 할당한 다음, GC를 비활성화하여 레벨 중 성능 오버헤드를 방지할 수 있습니다. 레벨이 종료된 후 모든 메모리가 해제되면 GC를 다시 활성화해 사용할 수 있습니다.

 

 

착한 GC를 위한 모범사례 🔗

GC는 편리하지만 성능과 사용자 경험에 큰 영향을 미칠 수 있습니다. 이에 맞게 코드를 최적화하려면 결국 GC가 먹을 것들을 줄이는 방법밖에 없습니다. 

⭕  임시할당 
Managed Heap에 자주 할당되는 양을 최대한 줄여야합니다. 만약 프레임당 1KB의 임시 메모리를 할당하는 부분이 있다면 초당 60KB, 분당 최대 3.6MB가 할당됩니다. GC가 1초에 1번 호출 되면 성능에 부정적이고, 1분에 1번만 실행되면 분산되어있는 총 3.6MB 크기의 수천 개의 개별 할당을 수집하는데 시간이 길어질 수 있습니다.
특히 곧 할당 후 잠시 사용하고 릴리스하는 객체더라도 Unity는 부족한 Managed Heap을 확장하고 넣은 후에 릴리스를 해야합니다.

⭕  재사용하는 객체 풀 (Object Pooling 등 Pool기법)
반복적인 생성/파괴를 줄이면 쓰레기가 발생하지 않도록 할 수 있습니다. 예를들어 FPS게임에서 탄알같은 경우 하나하나 생성/파괴하는 것보다 미리 탄창만큼의 탄알을 만들어 놓고 재사용할 수 있습니다.

⭕  반복되는 문자열 연결
C#의 경우 문자열은 immutable입니다. 그렇기에 문자열을 만들고 난 이후에는 문자열을 변경할 수 없고, 수정하고 싶다면 완전히 새로운 문자열을 생성해야합니다. 🔗 완전히 새로운 문자열을 생성하고 쓴다면 기존 문자열은 가비지가 될 것입니다. 이런 경우 StringBuilder 클래스를 사용하면 일부 경우 효과적일 수 있는데, StringBuilder는 내부 버퍼를 사용해 변경할 수 있는 문자열을 나타냅니다. 

유니티 내에서 프레임마다 변경되는 문자열을 없애는게 좋습니다. 만약 점수를 알려주는 UI가 있다면 매 프레임마다 업데이트하지 않고, 점수가 변경되면 업데이트 할 수 있으며, 문자열을 쓸 때 불필요한 연산을 뺄 수 있습니다.

⭕  컬렉션 재사용
배열과 같은 경우 참조 유형이기 때문에 배열에 작업을 한 이후, 다음 호출 때 재사용할 수 있습니다. 또한 컬렉션 값을 제거하지만 컬렉션에 할당된 메모리는 유지하는 Clear 메서드 등을 사용합니다.

⭕  클로저 피하기
일반적으로 C#은 가능한 한 클로저를 피해야합니다. 성능에 민감한 코드, 특히 프레임 단위로 실행되는 코드에서 익명 메서드 및 메서드 참조를 사용하는 것을 최소화해야 합니다.
C#의 메서드 참조는 참조 유형이므로 힙에 할당됩니다. 익명 메서드를 클로저로 전환하면 클로저를 메서드로 전달하는 데 필요한 메모리 양이 많이 증가합니다.

*클로저(Closure) : Lexical scope내의 Free variable을 사용하는 일급함수. 쉽게 말하면 익명/람다 함수가 그것을 정의하고 있는 메서드(outer method)의 로컬 변수(바깥 파라미터)를 사용하고 있을 때 그 익명/람다 함수를 Closure라고 부릅니다.

// Good C# script example: using an anonymous method to sort a list. 
// This sorting method doesn’t create garbage
List<float> listOfNumbers = getListOfRandomNumbers();

listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/2)) 

);
// Bad C# script example: the anonymous method has become a closure,
// and now allocates memory to store the value of desiredDivisor
// every time it is called.
List<float> listOfNumbers = getListOfRandomNumbers();

int desiredDivisor = getDesiredDivisor();

listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/desiredDivisor))

);

익명 메서드가 이제 범위를 벗어난 변수(desiredDivisor)의 상태에 엑세스해야 하므로 메서드는 클로저가 되었습니다. 올바른 값이 클로저에 전달되도록 C#은 클로저에 필요한 외부 범위 변수를 유지할 수 있는 익명 클래스를 생성합니다. 클로저를 실행하려면 생성된 클래스의 복사본을 인스턴스화해야 하며 모든 클래스는 참조 유형입니다. 이 때문에 클로저를 실행하려면 Managed Heap에 개체를 할당해야 합니다.

⭕  Boxing
값 유형 변수가 참조 유형으로 자동 변환될 때 발생합니다. 가장 흔한 경우는 기본 값 유형 변수(int, float)를 객체 유형 메서드(object)에 전달될 때 가장 자주 발생됩니다. C#은 Boxing이 의도하지 않은 메모리 할당으로 이어지더라도 경고 로그가 생기지 않습니다. 왜냐하면 C#은 작은 임시 할당을 GC 할당자와 할당 크기에 민감한 메모리풀에 의해 잘 처리된다고 가정하기 때문입니다.

⭕  Array-valued Unity APIs
배열을 반환하는 모든 Unity API는 액세스할 때마다 배열의 새 복사본을 만듭니다. 

// Bad C# script example: this loop create 4 copies of the vertices array per iteration
void Update() {
    for(int i = 0; i < mesh.vertices.Length; i++) {
        float x, y, z;

        x = mesh.vertices[i].x;
        y = mesh.vertices[i].y;
        z = mesh.vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

위 코드는 루프당 4개의 정점 배열 복사본을 불필요하게 만듭니다. .vertices에 접근할 때마다 불필요한 할당이 매번 이루어집니다.

// Best C# script example: create one copy of the vertices array
// and work with that.
List<Vector3> m_vertices = new List<Vector3>();

void Update() {
    // var vertices = mesh.vertices;
    mesh.GetVertices(m_vertices);

    for(int i = 0; i < m_vertices.Length; i++) {

        float x, y, z;

        x = m_vertices[i].x;
        y = m_vertices[i].y;
        z = m_vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

var vertices = mesh.vertices로 매 루프마다 1개의 복사본만 만들 수도 있지만, 전체 코드에서 1번만 할당하고 재사용하는 것이 가장 나은 방법입니다. 

Input.touches API가 위와 유사하게 동작해서 해당 문제는 모바일에서 일반적으로 발생한다고 합니다.

// Bad C# script example: Input.touches returns an array every time it’s accessed
for ( int i = 0; i < Input.touches.Length; i++ ) {
   Touch touch = Input.touches[i];

    // …
}
// BEST C# script example: Input.touchCount and Input.GetTouch don’t allocate at all.
int touchCount = Input.touchCount;

for ( int i = 0; i < touchCount; i++ ) {
   Touch touch = Input.GetTouch(i);

   // …
}

 

비할당 API로 대체

일부 Unity API는 메모리를 할당하지 않는 대체 버전이 있다고 합니다. 가능하면 이것들을 사용하는게 좋습니다.

Physics.RaycastAll Phystics.RaycastNonAlloc
Animator.parameters Animator.parameterCount, Animator.GetParameter
Renderer.sharedMaterials Renderer.GetSharedMaterials

 

⭕  빈 배열 재사용
배열 값 메서드가 빈 배열을 반환해야할 때 null 대신 빈 배열을 반환할 수 있습니다. 일반적으로 메서드에서 빈 배열을 반환할 때 빈 배열을 반복적으로 생성하는 것보다 정적 인스턴스로 만들어 반환하는 것이 더 효율적입니다.

⭕  적절한 시점에 미리미리 GC 작동
씬전환, UI 전환, 특정 이벤트 등 어떤 로딩이 있는경우. 혹은 상황에 따라 일정 프레임 간격으로 미리미리 요청할 수도 있습니다.

⭕  리소스 로딩 수동 관리
Resources 폴더에 Load, UnloadUnusedAsset이나 다른 방법들을 통해 리소스를 수동으로 관리할 수 있습니다.

fake null을 null 처리해주기 🔗
Unity에서 Leaked Managed Shell이란 Unity Object의 네이티브 부분이 파괴되었지만 C# 코드가 여전히 Unity Object에 대한 참조를 유지하고 있어, 그 관리 쉘(Managed Shell)이 메모리에 남아있는 상태를 뜻합니다. 이는 씬에서 게임 오브젝트, 컴포넌트가 언로도 되거나 UnityEngine.Object.Destroy()가 호출될 때 발생할 수 있습니다. 이 경우 fake null 상태입니다.

대부분의 경우 Leaked Managed Shell의 영향은 크지 않지만, Monobehaviour나 Scriptable Object에서 파생된 사용자 정의 타입이 Texture나 큰 배열, 컬렉션과 같이 많은 메모리를 소비하는 관리 타입에 대한 참조를 유지하고 있다면 큰 영향을 줄 수 있습니다. 이런 경우 null을 할당해서 GC가 회수하게 만들어 줄 수 있습니다. 이는 코드를 읽는 사람에게 해당 객체가 더 이상 사용되지 않음을 명확하게 알려줄 수 있고, 명시적으로 GC가 회수할 수 있게 나타냅니다.

Texture2D tex;
private void Start () {
    tex = new Texture2D (256, 256);
    Destroy (tex);
    tex = null;
}

 

 

정리

GC내용, 유니티 GC를 찾다보니 fake null, Unity GC 역사? 등등 다른 여러 내용이 많아진것 같습니다. Unity Profiler를 정리하려고 쓴 내용인데 너무 많은 내용이 들어가 다 써놓고 차근차근 공부를 하는게 좋은 것 같습니다.

가장 핵심적인 목표는 결국 이후 작성할 Unity Profiler와 평소 개발을 할 때 메모리 관리를 어떻게 할지, 그에 대한 사전 지식들을 총정리하는 것이기 때문에, 추후 내용을 업데이트 하면서 글을 나누거나 추가 정리를 해야할 것 같습니다.

 

* 참고자료

더보기

https://docs.unity3d.com/6000.0/Documentation/Manual/performance-memory-overview.html

 

Unity - Manual: Memory in Unity

Memory in Unity To ensure your application runs with no performance issues, it’s important to understand how Unity uses and allocates memory. This section of the documentation explains how memory works in Unity, and is intended for readers who want to un

docs.unity3d.com

 

https://unity.com/kr/blog/engine-platform/porting-unity-to-coreclr

 

Unity를 CoreCLR로 포팅하는 과정

.NET 컴파일러 및 런타임을 담당하는 유니티 엔지니어링 매니저 조시 피터슨이 CoreCLR GC가 네이티브 엔진 코드와 호환되도록 하기 위해 팀에서 최근 어떤 노력을 기울이고 있는지 소개합니다.

unity.com


https://www.mono-project.com/docs/advanced/garbage-collector/sgen/working-with-sgen/

 

Working With SGen | Mono

Working With SGen SGen is a new and powerful garbage collector that we implemented for Mono. This new engine can be used for a wide range of applications from server workloads to desktop and mobile workloads and near-real time applications. Different workl

www.mono-project.com

https://xoofx.github.io/blog/2018/04/06/porting-unity-to-coreclr/#whats-next

 

Porting the Unity Engine to .NET CoreCLR | xoofx

NOTE: This work was a prototype as we don't have yet an official roadmap and timetable in mind to bring this to Unity, more on that later this year hopefully ;) This blog post is also my own opinionated vision on the subject, but Unity may have different p

xoofx.github.io

https://github.com/Unity-Technologies/mono

 

GitHub - Unity-Technologies/mono: Mono open source ECMA CLI, C# and .NET implementation.

Mono open source ECMA CLI, C# and .NET implementation. - Unity-Technologies/mono

github.com

https://github.com/Unity-Technologies/bdwgc

 

GitHub - Unity-Technologies/bdwgc: The Boehm-Demers-Weiser conservative C/C++ Garbage Collector (libgc, bdwgc, boehmgc)

The Boehm-Demers-Weiser conservative C/C++ Garbage Collector (libgc, bdwgc, boehmgc) - GitHub - Unity-Technologies/bdwgc: The Boehm-Demers-Weiser conservative C/C++ Garbage Collector (libgc, bdwgc...

github.com

https://blog.siner.io/2021/12/26/garbage-collection/

 

가비지컬렉션(Garbage Collection)의 종류와 특징

Garbage란? Garbage는 컴퓨터의 메모리에 있지만 앞으로 사용되지 않을 데이터나 객체 또는 메모리 영역을 가리킵니다. 모든 컴퓨터 시스템은 제한적인 메모리를 가지고 있고, 대부분의 소프트웨어

blog.siner.io

https://overworks.github.io/unity/2019/07/16/null-of-unity-object.html#fn:1

 

유니티 오브젝트의 null 비교 시 유의사항

다음글: 유니티 오브젝트의 null 비교 시 유의사항 2

overworks.github.io

https://docs.unity3d.com/Packages/com.unity.memoryprofiler@1.1/manual/managed-shell-objects.html

 

Managed Shell Objects | Memory Profiler | 1.1.0

Managed Shell Objects What are Managed Shells? In Unity a lot of the objects and types used in building your application have some part of them implemented in native code and often a good chunk of their data stored in native allocations that are handled by

docs.unity3d.com