유니티 최적화 - Garbage Collection
게임에서의 최적화는 정말 중요하다. 만약 유저가 재밌게 플레이하고 있을 때 게임이 잠깐 멈추는 현상이 일어나게 되면 유저에게 부정적 영향을 주게 된다.
지금부터 알아보는건 유니티엔진을 통해 게임을 제작하였을 때 최적화 기법 또는 C# 내 최적화 이다.
[ Garbage Collection ]
객체지향 프로그래밍 언어는 메모리를 동적으로 할당한다는 개념이 존재하고, 런타임에 동적으로 할당된 메모리를 해제해줄 필요가 있다. C++에서는 Modern C++이 되어서야 "스마트 포인터" 라는 기술의 등장으로 이를 비교적 나은 형태로 처리할 수 있게 되었지만 "자동"으로 해준다고 보기는 어렵다.
C#으로 작성한 코드는 .NET 위에서 실행되며 CLR을 통해 자동으로 필요없는 동적 메모리를 해제하는
GC(Garbage Collection)를 지원한다.
C#의 GC
각 어플리케이션의 Managed Heap은 그 공간이 유한하기 때문에 메모리 영역을 관리해주어야 한다.
C# 어플리케이션의 메모리는 주어진 공간을 순차적으로 채우는 특징 때문에 할당을 반복하면 힙의 마지막 주소에 도달하게 된다. 이때 GC가 필요없는 메모리를 수집하여 해당 공간의 할당을 해제하는데, 이것이 GC (Garbage Collection)이다.
GC 호출 시 다른 스레드들을 일시정지하고 GC가 실행되기 때문에, 너무 잦은 GC 호출은 프로그램의 성능을 하락시킬 수 있습니다.
Managed Heap (관리되는 힙)
힙 메모리는 프로세스 별로 고유하게 가진다. 새 프로세스가 생성될 때 인접한 공간의 메모리를 할당받게 되는데 이를 managed heap이라 부른다.
그럼 Garbage란 무엇인가?
Reference type 변수들을 살펴보면 스택 메모리에 주소 값을 담아두고 힙 메모리에 접근하는 방식으로 저장되어 있다. 여기서 함수가 종료되고 지역 변수들이 제거되면서 스택 메모리에서 힙 메모리를 가리키고 있는 일부 주소값들이 같이 pop된다. 그러면 해당 주소값에 있는 힙 메모리의 데이터에는 더 이상 접근할 수 없는 상태가 된다. 이렇게 참조할 수 없게 된 객체를 garbage라고 부른다.
아래 내용으로 살펴보자
void static Main()
{
int i = 123; // a value type
object o = i; // boxing
int j = (int)o; // unboxing
}
여기서 Main함수가 끝나게 되면 o는 stack 메모리에서 pop된다. 그럼 이것이 가리키는 boxing된 i는 힙 메모리에서 Garbage로 남아있게 되는 것이다.
그렇게 Garbage로 남은 메모리는 어떠한 시점에서 Garbage Collection이라는 알고리즘이 메모리를 해제 시켜준다.
어떠한 방식으로 메모리 할당과 해제를 하는지 마이크로 소프트 공식 홈페이지의 말을 빌려오겠다.
microsoft--
메모리 할당
사용자가 새 프로세스를 시작하면 런타임에서는 인접한 주소 공간 영역을 이 프로세스에 예약합니다. 이 예약된 주소 공간을 관리되는 힙이라고 합니다. 관리되는 힙에서는 힙에 있는 다음 개체가 할당될 주소의 포인터를 관리합니다.
관리되는 힙에서 메모리를 할당하면 관리되지 않는 힙에서 메모리를 할당하는 것보다 속도가 더 빠릅니다. 런타임에서는 포인터에 값을 더하여 개체에 메모리를 할당하기 때문에, 스택에서 메모리를 할당하는 속도만큼 빠릅니다. 또한 연속으로 할당된 새 개체는 관리되는 힙에 인접하여 저장되므로 애플리케이션에서 개체에 빠른 속도로 액세스할 수 있습니다.
메모리 해제
가비지 컬렉션은 애플리케이션의 루트를 검사하여 더 이상 사용되지 않는 개체를 결정합니다.
애플리케이션 루트에는 정적 필드, 스레드 스택의 지역 변수, CPU 레지스터, GC 핸들, finalize 큐가 포함됩니다. 각 루트는 관리되는 힙에 있는 개체를 참조하거나 Null로 설정됩니다. 가비지 컬렉션은 나머지 런타임에 이러한 루트를 요청할 수 있습니다. 가비지 컬렉션은 이 목록을 사용하여 루트에서 접근할 수 있는 모든 개체를 포함하는 그래프를 만듭니다.
그래프에 없는 개체는 애플리케이션 루트에서 연결할 수 없습니다. 가비지 컬렉션은 연결할 수 없는 개체를 가비지로 간주하고 이 개체에 할당된 메모리를 해제합니다.
-----------------
조금 더 효율적으로 관리하기 위해 GC 세대라는게 있다. (유니티에서는 GC 세대는 사용 안함)
GC 세대
GC는 효율적인 수집을 위해 객체들에 세대를 매깁니다. 최근에 생성되어 가비지 컬렉팅을 아직 겪은 적이 없는 객체를 0세대, 가비지 컬렉팅을 1번 겪었지만 해제되지 않은 객체를 1세대, 2번 겪었지만 해제되지 않은 객체를 2세대라 합니다.
세대가 높아질수록, 즉 가비지 컬렉팅을 겪고도 생존한 객체들일수록 중요한 객체로 판단하는 방식입니다. 기본적으로 가비지 컬렉팅은 0세대를 대상으로 먼저 시행되며, 그러고도 필요한 메모리를 확보할 수 없을 경우에 1세대까지 포함하여 시행, 1세대까지 포함하여도 메모리 확보가 불가할 때 2세대까지 포함하게 됩니다. 2세대까지 포함한 모든 가비지 컬렉팅 수행 시 아예 프로세스를 일시정지하고 우선적으로 수행하기 때문에, 순간적인 프레임 드랍 등이 일어나게 됩니다.
또한 GC는 객체를 그 크기에 따라 85,000바이트(85kb)보다 작으면 SOH(Small Of Heap), 크거나 같으면 LOH(Large Of Heap)로 구분합니다. SOH는 위에서 언급한대로 할당 직후 0세대부터 시작하며, LOH의 경우 처음부터 2세대로 등록됩니다. 가비지 컬렉팅을 겪고 생존한 객체를 중요하다고 판단하듯이, 크기가 큰 객체는 중요한 객체라고 판단하는 방식입니다. 또한 가비지 컬렉팅 후 SOH들은 메모리 단편화를 없애기 위해 재배치되지만, LOH의 경우 옮기는 과정에 발생하는 오버헤드가 크기 때문에 가비지 컬렉팅 후 이동되지 않습니다.
그러면 자동으로 관리 해주니까 좋은 것이 아니냐 라고 생각할 수 있다.
반은 맞고 반은 틀리다라고 말할 수 있을 것 같다.
Garbage Collection의 문제가 뭐가 있을까?
우리는 런타임에 메모리를 할당과 해제를 반복적으로 사용할 것이다. 하지만 이렇게 되면 Heap에 할당된 메모리가 해제되면서 Heap에 듬성듬성 메모리가 배치되어 메모리 단편화가 발생하고 이는 해당 듬성듬성으로 새로 할당 받을 수 있는 메모리 크기와 맞지 않아 메모리 할당이 어려워지고, 할당 속도를 느리게 하는 원인이 된다.
그렇다면 어떻게 코드를 짜야 GC를 줄일 수 있을까?
1. 불필요한 List 사용
List<T>는 동적으로 크기를 조절할 수 있는 편리한 자료구조이지만, 메모리 효율에 있어서는 좋지 않은 편입니다. 단적으로 말하자면, 일반 배열로 충분하다면 배열을 사용하는 것이 낫습니다.
2. string 변수에 무분별한 + 연산
string 변수에는 + 연산을 사용하여 간단하게 텍스트를 추가할 수 있지만, 이 기능은 사실 사용할 때마다 가비지를 생성하게 됩니다. 그래서 StringBuilder 클래스를 사용을 권장합니다.
3. 불필요한 큰 사이즈의 오브젝트 생성
85kb 이상의 객체는 LOH로 구분되어 시작부터 2세대로 등록되며, LOH 객체를 불필요하게 많이 만들면 힙 영역에 2세대 객체가 가득하여 가비지 컬렉팅 효율이 떨어지게 되며, 메모리 단편화가 발생할 수 있습니다.
4. 캐싱 사용
우리 코드가 힙 할당으로 이어지는 함수를 반복적으로 호출하고 결과를 폐기하면 불필요한 쓰레기가 생성됩니다.
대신, 우리는 이러한 객체에 대한 참조를 저장하고 재사용하는 기술을 캐싱이라고 한다.
5. 오브젝트 풀링 사용
계속 재사용될 오브젝트에는 풀에서 넣고, 가져오는 방식을 사용하면 불필요한 쓰레기가 생기지 않는다. (이미 처음에 생성시킬때 할당이 되며, 해당 오브젝트를 사용하지 않게 되었을 때만 해제 하기 때문에)
6. 함수 사용
1) GameObject.tag -> GameObject.CompareTag 사용
2) Input.GetTouch, Input.touchCount -> Input.touches 사용
3) Physics.SphereCastNonAlloc -> Physics.SphereCastAll 사용
7. 코루틴 함수 사용
1) StartCoroutine 사용 시 약간의 가비지 발생
2) yield new return 에서 new 할 때마다 가비지 발생
되기 때문에 Unitask 적극 활용!!
!. 예전엔 foreach 문에서 enumerator 부분에서 박싱해서 사용되서 가비지가 생겼지만 최신 c# 컴파일러에서는 가비지를 만들지 않는다.
이렇게 이러한 정보들을 알고 코드를 작성하는 것과 모르고 작성하는 것이 차이가 날 수 있기 때문에
C# 혹은 JAVA를 사용한다면 꼭 알고 가야하는 내용이다.!! ><