Version: 2019.1
에셋 검사
문자열과 텍스트

관리되는 힙에 대한 이해

많은 Unity 개발자들이 겪는 또 다른 일반적인 문제는 예기치 않은 관리되는 힙(managed heap)의 확장입니다. Unity에서 관리되는 힙은 축소되는 것보다 더 쉽게 확장됩니다. 이와 더불어, Unity의 가비지 컬렉션 전략은 메모리를 프래그먼트하는 경향이 있는데, 이는 크기가 큰 힙이 줄어드는 것을 방해할 수 있습니다.

관리되는 힙의 작업 방식 및 확장 이유

“관리되는 힙”은 프로젝트의 스크립팅 런타임(Mono 또는 IL2CPP) 메모리 관리자가 자동으로 관리하는 메모리 부분입니다. 관리 코드에 생성된 모든 오브젝트는 관리되는 힙에 할당되어야 합니다. (2) (참고: 엄격히 말하면 모든 null 참조를 하지 않는 타입의 오브젝트와 상자 값 타입의 오브젝트가 관리되는 힙에 할당되어야 합니다)

위 다이어그램에서 흰 상자는 관리되는 힙에 할당된 메모리의 용량을 나타내며, 그 안의 색칠된 상자는 관리되는 힙의 메모리 공간에 저장된 데이터 값을 나타냅니다. 만일 추가 값들이 필요한 경우에는, 관리되는 힙에서 더 많은 공간이 할당됩니다.

가비지 컬렉터는 일정 시간마다 실행됩니다. (3) (참고: 정확한 실행 간격은 플랫폼마다 서로 다릅니다) 이는 힙의 모든 오브젝트에 대해 정리하고, 더 이상 레퍼런스하지 않는 모든 오브젝트를 삭제하도록 표시합니다. 이후 레퍼런스하지 않는 오브젝트는 삭제되어 메모리를 확보합니다.

여기서 중요한 사실은 Unity 가비지 컬렉션은 Boehm GC 알고리즘을 사용하며, 이는 비세대 기반이고 비압축화 되어있다는 것 입니다. 여기서 “비세대 기반”은 콜렉션 패스를 진행하는 동안 가비지 컬렉터(GC)는 힙 전체를 정리해야 한다는 것을 의미하며, 따라서 GC의 성능은 힙이 확장되는 경우 감소합니다. “비압축화”는 오브젝트간 간극을 줄일 수 있도록 메모리의 오브젝트를 재배치하는 과정이 일어나지 않는다는 의미입니다.

위의 다이어그램은 메모리 단편화의 예제입니다. 오브젝트가 해제되면 오브젝트가 점유하던 메모리는 다시 반환됩니다. 하지만, 반환된 공간은 즉시 “사용 가능한 메모리”라는 단일 풀에 속하지는 않습니다. 해제된 오브젝트의 양쪽에 위치한 오브젝트가 사용 중일 수 있기 때문입니다. 이로 인하여, 반환된 공간은 사용 중인 메모리 세그멘트 간 “간극”이 됩니다(이 간극은 다이어그램 상으로는 빨간색 원으로 나타납니다). 따라서 반환된 공간은 해제된 오브젝트의 용량과 같거나 적은 데이터를 저장하는 데에만 사용할 수 있습니다.

오브젝트를 할당할 때, 오브젝트는 항상 연속된 메모리 공간을 차지해야 한다는 것을 기억하십시오.

이는 메모리 단편화의 핵심 문제를 유발합니다. 힙에 존재하는 사용 가능한 메모리 양 자체는 상당할 수 있지만, 정작 사용 가능한 공간은 할당된 오브젝트 사이의 “간극”으로 구성되어 있을 수도 있습니다. 이 경우 어떤 오브젝트를 할당할 공간은 전체적으로 충분하지만, 관리되는 힙에서 그 오브젝트를 저장할 수 있는 연속된 공간을 찾지 못할 수 있습니다.

하지만, 만일 용량이 큰 오브젝트가 할당되었으나 이를 수용할 만한 충분한 크기의 연속된 공간이 없는 경우 위에서 보이는 것과 같이, Unity 메모리 관리자는 두 개의 작업을 실행합니다.

우선, 가비지 컬렉터가 아직 실행되지 않은 경우 이를 실행시킵니다. 이는 할당 요청을 수용할 수 있는 충분한 공간을 만들 수 있도록 시도합니다.

가비지 컬렉터가 실행된 이후에도 요청된 메모리 공간을 수용할 수 있는 연속된 공간이 없는 경우에는 힙을 확장시킵니다. 힙이 확장되는 정도는 플랫폼에 따라 다르지만, 대부분의 Unity 플랫폼의 경우 관리되는 힙의 크기를 두 배로 늘립니다.

힙의 주요 문제점

관리되는 힙의 핵심 이슈는 다음과 같이 크게 두 개가 있습니다.

  • Unity는 관리되는 힙이 확장되는 경우, 그에 할당된 메모리 페이지는 주로 해제하지 않습니다. 관리되는 힙의 상당 부분이 빈 경우에도 확장된 힙 부분을 그대로 유지합니다. 이는 좀더 큰 할당이 발생해도 힙을 다시 확장해야 할 필요가 없도록 하기 위한 것입니다.

  • 대부분의 플랫폼에서 Unity는 결국 관리되는 힙의 빈 부분이 차지하는 페이지를 해제하여 다시 운영체제에 릴리스하지만, 해제가 되는 간격은 정확하지 않으므로 이에 종속해서는 안됩니다.

  • 관리되는 힙이 사용하는 주소 공간은 절대로 운영체제에 반환되지 않습니다.

  • 32비트 프로그램에서 관리되는 힙이 반복해서 확장하고 수축하면, 주소 공간이 부족해지는 경우가 발생할 수 있습니다. 만일 프로그램이 사용 가능한 메모리 주소 공간이 부족해지는 경우 운영체제는 해당 프로그램을 종료합니다.

  • 반면 64비트 프로그램의 경우 주소 공간은 충분히 확보되어 있으므로, 사용자가 평생 프로그램을 실행시킨다 하더라도 위의 상황이 발생할 가능성은 거의 없습니다.

임시 할당

많은 Unity 프로젝트에서 매 프레임마다 수십 또는 수백 KB의 임시 데이터를 관리되는 힙에 할당하며 동작하는 것이 발견됩니다. 이는 프로젝트 성능에 상당한 악영향을 줄 수 있습니다. 아래의 계산을 참조하십시오.

만일 프로그램이 각 프레임마다 1KB 만큼의 임시 메모리를 할당하고, 초당 프레임 수가 60인 경우, 프로그램은 1초당 60KB 만큼의 임시 메모리를 할당해야 합니다. 1분이 지나면 메모리에 가비지가 3.5MB까지 추가됩니다. 가비지 컬렉터를 매초마다 호출하는 것은 성능에 악영향을 줄 수 있습니다만 매 분당 3.6MB를 할당하는 것은 메모리가 부족한 디바이스에서 실행하려고 할 때 문제가 됩니다.

또한, 로딩 작업을 고려하십시오. 만일 용량이 큰 에셋을 로드하는 작업 동안 다수의 임시 오브젝트가 생성되고, 오브젝트는 작업이 완료되기 전까지 레퍼런스된다고 가정하면, 가비지 컬렉터는 이들 임시 오브젝트를 릴리스할 수 없습니다. 그리고 비록 힙에 있는 많은 오브젝트가 잠시 후에 해제되더라도 관리되는 힙은 확장되어야 합니다.

관리 메모리 할당을 트래킹하는 것은 비교적 간단합니다. Unity의 CPU 프로파일러의 개요 창에는 “GC Alloc” 열이 있습니다. 이 열은 특정 프레임에서 관리되는 힙에 할당된 용량을 바이트 단위로 나타냅니다. (4) (참고: 이는 주어진 프레임 동안 일시적으로 할당된 용량과 동일하지는 않습니다. 할당된 메모리의 전체 또는 일부가 이후 프레임에서 재사용된다고 하더라도 이 프로파일은 특정 프레임에 할당된 바이트 수를 표시합니다) “Deep Profiling” 옵션이 활성화된 경우, 이러한 할당이 발생하는 메서드를 트래킹할 수 있습니다.

메인 스레드에서 발생하는 경우에만 Unity 프로파일러는 이러한 할당을 트래킹합니다. 따라서 “GC Alloc” 열은 사용자가 생성한 스레드에서 발생하는 관리되는 할당을 측정하는 데 사용할 수 없습니다. 디버깅 용도일 경우 코드 실행을 별도의 스레드에서 메일 스레드로 전환하십시오. 타임라인 프로파일러에서 샘플을 표시하려면 BeginThreadProfiling API를 사용합니다.

대상 기기에서 항상 개발 빌드 옵션을 설정하고 관리되는 메모리 할당을 프로파일링합니다.

일부 스크립트 메서드는 에디터에서 실행될 때 할당을 하지만, 프로젝트가 빌드된 이후에는 할당을 하지 않는다는 점을 주목하십시오. GetComponent 메서드가 가장 일반적인 예입니다. 메서드는 에디터에서 실행될 때 항상 힙을 할당하지만, 빌드된 프로에서는 할당하지 않습니다.

일반적으로, 프로젝트가 상호작용하는 상태인 경우에는 관리되는 힙 할당을 최소화하는 것을 강력히 추천합니다. 씬 로딩과 같이 상호작용이 없는 작업 중 할당하는 것은 문제의 소지가 비교적 적습니다.

Visual Studio용 Jetbrains Resharper Plugin을 이용하면 코드에서 할당 위치를 검색할 수 있습니다.

Unity의 세부 프로파일링(Deep Profile) 모드를 사용하여 관리되는 할당의 특정 원인을 검색할 수 있습니다. Deep Profile 모드에서는 모든 메서드 호출이 개별적으로 기록되어 메서드 호출 트리 내에서 관리되는 할당이 발생하는 위치를 보다 명확하게 확인할 수 있습니다. Deep Profile 모드는 에디터뿐 아니라 커맨드 라인 인자 -deepprofiling을 사용할 경우 Android 및 데스크톱에서도 안정적으로 작동합니다. Deep Profiler 버튼은 프로파일링이 진행되는 동안 회색으로 표시됩니다.

메모리 보존의 기본

관리되는 힙 할당을 줄이기 위해 사용할 수 있는 비교적 단순한 기법들이 있습니다.

컬렉션과 배열 재사용

C# 컬렉션 클래스나 배열을 사용하는 경우, 가능하면 할당된 컬렉션이나 배열을 재사용하거나 풀링하는 것을 고려해 보십시오. 컬렉션 클래스는 컬렉션의 값을 제거하지만, 컬렉션에 할당된 메모리는 해제하지 않는 Clear 메서드를 가지고 있습니다.


void Update() {

    List<float> nearestNeighbors = new List<float>();

    findDistancesToNearestNeighbors(nearestNeighbors);

    nearestNeighbors.Sort();

    // … use the sorted list somehow …

}

이는 복잡한 연산에 임시 “helper” 컬렉션 메서드를 할당할 때 특히 유용합니다. 다음은 이에 관련된 간단한 예제입니다.

이 예제에서는, nearestNeighbors 리스트가 프레임당 한 번 할당되어 데이터 포인트 집합을 수집합니다. 이 리스트를 메서드 밖으로 빼서 이것을 가지고 있는 클래스로 옮기는 것은 아주 단순하며, 매 프레임마다 새로운 리스트를 할당하는 것을 방지합니다.


List<float> m_NearestNeighbors = new List<float>();

void Update() {

    m_NearestNeighbors.Clear();

    findDistancesToNearestNeighbors(NearestNeighbors);

    m_NearestNeighbors.Sort();

    // … use the sorted list somehow …

}

이 예제에서는 리스트의 메모리가 계속 유지되고 여러 프레임 동안 재사용되는 것을 볼 수 있습니다. 새로운 메모리 공간은 오직 리스트가 확장되어야 하는 경우에만 할당됩니다.

클로저 및 익명 메서드

클로저와 익명 메서드를 사용하는 경우, 크게 두 가지를 고려해야 합니다.

첫째, C#의 메서드 레퍼런스는 전부 레퍼런스 타입이므로, 이는 힙에 할당됩니다. 메서드 참조를 인수로 전달하는 것으로 쉽게 임시 할당이 생성될 수 있습니다. 이런 할당은 전달되는 메서드가 익명 메서드인지 미리 정의된 것인지에 관계없이 발생합니다.

둘째로, 익명 메서드를 클로저로 전환하는 것은 이를 수용하는 메서드에 클로저를 전달하는 데 필요한 메모리 용량을 상당히 증가시킵니다.

다음의 코드를 살펴보겠습니다


List<float> listOfNumbers = createListOfRandomNumbers();

listOfNumbers.Sort( (x, y) =>

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

);

이 코드 조각은 첫 줄에서 생성된 숫자 리스트의 정렬 순서를 관리하는 단순한 익명 메서드를 사용하고 있습니다. 하지만, 만약 프로그래머가 이 코드 조각을 다시 사용할 수 있도록 하려면, 다음과 같이 상수 2를 로컬 범위의 변수로 대체하는 것이 좋아 보입니다.


List<float> listOfNumbers = createListOfRandomNumbers();

int desiredDivisor = getDesiredDivisor();

listOfNumbers.Sort( (x, y) =>

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

);

이제 이 익명 메서드는 메서드 범위 밖의 변수 상태에 접근할 수 있어야 하므로 클로저가 되었습니다. desiredDivisor 변수를 이 클로저에서 사용할 수 있으려면 해당 변수를 어떻게든 클로저에게 전달해야 합니다.

이를 위해서 C#은 클로저에 필요한 외부 범위 변수를 유지할 수 있는 익명 클래스를 생성합니다. 이 클래스의 사본은 클로저가 Sort 메서드에 전달될 때 인스턴스화되며, desiredDivisor 정수의 값으로 초기화됩니다.

이 클로저를 실행하려면 생성된 클래스 사본의 인스턴스화가 필요하고, 모든 클래스가 C# 참조 형식이므로, 클로저를 실행하기 위해서는 관리되는 힙에 오브젝트를 할당해야 합니다.

일반적으로 가능하면 C#에서는 클로저를 피하는 것이 가장 좋습니다. 익명 메서드와 메서드 참조는 성능에 민감한 코드, 특히 프레임 단위로 실행되는 코드에서 최소화 돼야합니다.

IL2CPP 하의 익명 메서드

현재 IL2CPP가 생성한 코드를 살펴보면, System.Function 변수의 단순 선언과 할당이 새로운 오브젝트를 할당하는 것을 알 수 있습니다. 이는 변수가 명시적(메서드나 클래스에서 선언된 경우)이든 암시적(다른 메서드에 인수로서 선언된 경우)이든 무관합니다.

따라서 IL2CPP 스크립팅 백엔드 하에서 익명 메서드를 사용하는 경우, 관리되는 메모리를 할당합니다. Mono 스크립팅 백엔드의 경우에는 그렇지 않습니다.

또한, IL2CPP는 메서드 인수가 선언된 방식에 따라 관리되는 메모리 할당 수준이 아주 다르게 나타납니다. 예상대로 클로저는 호출당 최대 메모리를 할당합니다.

비직관적이지만, 미리 정의된 메서드는 IL2CPP 스크립팅 백엔드 하에서 인수로 패스될 때 클로저만큼 메모리 를 할당합니다. 익명 메서드는 힙에 일시적인 가비지를 한 자릿수 이상으로 최소한의 양만큼 생성합니다.

따라서 만일 프로젝트가 IL2CPP 스크립팅 백엔드로 출시되는 경우, 아래와 같이 세 가지 사항을 추천합니다.

  • 메서드를 인수로서 전달할 필요가 없는 코딩 스타일을 선호합니다.

  • 반드시 써야한다면 미리 정의된 메서드보다는 익명 메서드를 선택합니다.

  • 스크립팅 백엔드의 타입과 무관하게, 클로저를 최대한 피하십시오.

박싱

박싱은 Unity 프로젝트에서 의도하지 않은 임시 메모리 할당이 발생하는 가장 주된 원인입니다. 이는 값 타입의 값이 레퍼런스 타입으로 활용될 때마다 발생하며, 특히 기본적인 값 형식 변수(intfloat 등)를 오브젝트 형식의 메서드로 전달할 때 주로 발생합니다.

아래의 아주 간단한 예제에서 x 의 정수가 object.Equals 메서드에 전달될 수 있도록 박싱이 일어나는데, objectEquals 메서드는 object가 전달되어야 하기 때문입니다.


int x = 1;

object y = new object();

y.Equals(x);

보통 C# IDE와 컴파일러는 의도하지 않은 메모리 할당으로 이어지는 경우에도, 박싱에 대한 경고를 표시하지 않습니다. 이는 C# 언어가 소규모 임시 메모리 할당은 효율적으로 세대 기반 가비지 컬렉터와 할당 크기에 민감한 메모리 풀을 활용하여 해결할 수 있을 것이라는 전제 아래 개발되었기 때문입니다.

Unity 할당자는 크고 작은 메모리 할당을 해결하기 위해 서로 다른 메모리 풀을 활용하기는 하지만, Unity 가비지 컬렉터는 세대 기반이 아니며 따라서 박싱이 생성하는 소규모의 빈번한 임시 할당을 효율적으로 제거할 수 없습니다.

Unity 런타임용 C# 코드를 작성할 경우, 박싱(boxing)을 최대한 피해야 합니다.

박싱의 식별

박싱은 CPU 트레이스에서, 사용하는 스크립팅 백엔드에 따라 여러 개의 메서드 중 하나를 호출되는 형식으로 나타납니다. 이는 일반적으로 다음과 같은 형식을 가집니다. 여기서 <some class>는 다른 클래스나 구조체의 이름을 의미하며, 는 인수를 의미합니다.

  • <some class>::Box(…)

  • Box(…)

  • <some class>_Box(…)

또한 ReSharper나 dotPeek 디컴파일러에 내장된 IL 뷰어 툴과 같은 디컴파일러나 IL 뷰어의 결과물을 검색하여 박싱을 식별할 수도 있습니다. IL 명령어는 “box”입니다.

딕셔너리(Dictionaries) 및 열거형(enums)

박싱이 발생하는 흔한 원인 중 하나는 enum 타입을 Dictionary의 키로 사용하는 것입니다. enum을 선언하는 것은 씬 내부에서 정수로 취급되는 새로운 값 타입을 생성하나, 컴파일 시간 동안에는 형식 안전성 규칙을 집행합니다.

기본적으로 Dictionary.add(key, value)를 호출하면 Object.getHashCode(Object)가 호출됩니다. 이 메서드는 Dictionary의 키에 적절한 해시 코드를 얻기 위해서 사용되며 Dictionary.tryGetValue, Dictionary.remove 등과 같이 키를 허용하는 모든 메서드에서 사용됩니다.

Object.getHashCode 메서드는 참조 형식이지만, enum 값은 항상 값 형식입니다. 따라서 열거형 키를 가지는 Dictionary의 경우, 모든 메서드 호출은 최소한 한 번씩은 키를 박싱합니다.

아래의 코드 조각은 이러한 박싱 문제를 보여주는 간단한 예제입니다.


enum MyEnum { a, b, c };

var myDictionary = new Dictionary<MyEnum, object>();

myDictionary.Add(MyEnum.a, new object());

이 문제를 해결하려면 IEqualityComparer 인터페이스를 구현하는 사용자 정의 클래스를 작성하고, 그 클래스의 인스턴스를 Dictionary의 비교자로 할당하면 됩니다(참고: 이 오브젝트는 보통 무소속이므로, 메모리를 절약하기 위해 다른 Dictionary 인스턴스에 대해서도 사용할 수 있습니다).

다음은 위의 코드 조각에 사용할 수 있는 IEqualityComparer의 간단한 예제입니다.


public class MyEnumComparer : IEqualityComparer<MyEnum> {

    public bool Equals(MyEnum x, MyEnum y) {

        return x == y;

    }

    public int GetHashCode(MyEnum x) {

        return (int)x;

    }

}

위 클래스의 인스턴스는 Dictionary의 생성자로 전달될 수도 있습니다.

Foreach 루프

Mono C# 컴파일러의 Unity 버전에서는, foreach 루프를 사용하는 경우 각각의 루프가 종료되는 시점 마다 Unity가 값을 강제로 박싱하게 합니다(참고: 루프 전체의 실행이 완료된 이후에 해당 값이 한 번씩 박싱됩니다. 루프가 반복될 때마다 박싱을 하지는 않기에, 루프가 2번 반복하든, 200번 반복하든 메모리 사용량은 같습니다). 이는 Unity C# 컴파일러가 생성한 IL이 값 수집을 반복할 수 있도록 일반적인 값 형식의 열거자(Enumerator)를 생성하기 때문입니다.

이 열거자는 루프가 종료되는 시점에 호출되어야 하는 IDisposable 인터페이스를 구현합니다. 하지만, 값 형식의 오브젝트(구조체나 열거자 등)에서 인터페이스 메서드를 호출하려면, 오브젝트를 박싱해야 합니다.

다음의 아주 단순한 예제 코드를 생각해 보십시오.


int accum = 0;

foreach(int x in myList) {

    accum += x;

}

위의 코드가 Unity의 C# 컴파일러를 통해 실행된 경우에는 다음의 중간 언어로 작성된 코드가 생성됩니다.


   .method private hidebysig instance void 

       ILForeach() cil managed 

     {

       .maxstack 8

       .locals init (

         [0] int32 num,

         [1] int32 current,

         [2] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> V_2

       )

       // [67 5 - 67 16]

       IL_0000: ldc.i4.0     

       IL_0001: stloc.0      // num

       // [68 5 - 68 74]

       IL_0002: ldarg.0      // this

       IL_0003: ldfld        class [mscorlib]System.Collections.Generic.List`1<int32> test::myList

       IL_0008: callvirt     instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0/*int32*/> class [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()

       IL_000d: stloc.2      // V_2

       .try

       {

         IL_000e: br           IL_001f

       // [72 9 - 72 41]

         IL_0013: ldloca.s     V_2

         IL_0015: call         instance !0/*int32*/ valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()

         IL_001a: stloc.1      // current

       // [73 9 - 73 23]

         IL_001b: ldloc.0      // num

         IL_001c: ldloc.1      // current

         IL_001d: add          

         IL_001e: stloc.0      // num

       // [70 7 - 70 36]

         IL_001f: ldloca.s     V_2

         IL_0021: call         instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()

         IL_0026: brtrue       IL_0013

         IL_002b: leave        IL_003c

       } // end of .try

       finally

       {

         IL_0030: ldloc.2      // V_2

         IL_0031: box          valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>

         IL_0036: callvirt     instance void [mscorlib]System.IDisposable::Dispose()

         IL_003b: endfinally   

       } // end of finally

       IL_003c: ret          

     } // end of method test::ILForeach

   } // end of class test

여기서 가장 연관있는 코드는 후반부에 있는 __finally { … }__ 블록입니다. callvirt 명령은 메서드를 호출하기 이전 메모리에 있는 IDisposable.Dispose 메서드의 위치를 파악하며, 이는 열거자(Enumerator)가 박싱되어야 실행할 수 있습니다.

일반적으로 Unity에서는 foreach 루프를 피하는 것이 좋습니다. 이 루프는 박싱을 할 뿐만 아니라, 열거자를 통해 콜렉션을 반복하는 메서드 호출 비용은 forwhile 루프를 사용하는 수동 반복보다 일반적으로 훨씬 느립니다.

Unity 버전 5.5에서는 C# 컴파일러가 업그레이드되어, IL을 생성하는 기능이 크게 개선되었습니다. 특히, foreach 루프에서 발생하던 박싱 작업이 제거되었으며, 이는 foreach 루프와 관련된 메모리 오버헤드를 제거합니다. 하지만 메서드 호출 오버헤드로 인하여, 동일한 배열(Array) 기반 코드와 비교했을 때 아직 CPU 성능은 차이가 있습니다.

배열 기반 Unity API

배열을 리턴하는 Unity API를 반복해서 액세스하는 것은 불필요한 배열 할당을 유발하는 원인 중의 하나로 치명적이지만 눈에 잘 띄지는 않습니다. 배열을 리턴하는 모든 Unity API는 액세스할 때마다 배열의 새로운 사본을 생성합니다. 따라서 배열 기반의 Unity API를 필요 이상으로 액세스하는 것은 대단히 비효율적입니다.

아래 예제에서는 루프 반복마다 네 개의 vertices 배열 사본을 불필요하게 생성하고 있음을 볼 수 있습니다. 이러한 할당 작업은 .vertices 프로퍼티에 액세스할 때마다 매번 실행됩니다.


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);   

}

이는 아래와 같이 루프에 들어가기 전에, .vertices 배열을 캡처함으로써 루프 반복 횟수와 무관하게 단일 배열 할당으로 간단하게 바꿀 수 있습니다.


var vertices = mesh.vertices;

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

{

    float x, y, z;

    x = vertices[i].x;

    y = vertices[i].y;

    z = vertices[i].z;

    // ...

    DoSomething(x, y, z);   

}

프로퍼티를 한 번 액세스하는 CPU 비용은 그렇게 크지는 않지만, 루프에 계속해서 액세스하면 CPU 성능을 크게 차지합니다. 반복적인 액세스는 관리되는 힙을 불필요하게 확장합니다.

이 문제는 모바일 디바이스에서 자주 나타나는데, 이는 Input.touches API가 위의 예제와 비슷하게 실행되기 때문입니다. .touches 프로퍼티가 액세스될 때마다 할당이 발생하는 것과 같이, 프로젝트에 다음과 같은 코드가 있는 일은 대단히 흔합니다.


for ( int i = 0; i < Input.touches.Length; i++ )

{

   Touch touch = Input.touches[i];

    // …

}

물론, 이는 다음과 같이 배열 할당을 루프 조건 밖으로 빼는 것으로 간단하게 개선할 수 있습니다.


Touch[] touches = Input.touches;

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

{

   Touch touch = touches[i];

   // …

}

하지만, 메모리 할당을 유발하지 않는 많은 종류의 Unity API가 있습니다. 가능하면 이들을 주로 사용합니다.


int touchCount = Input.touchCount;

for ( int i = 0; i < touchCount; i++ )

{

   Touch touch = Input.GetTouch(i);

   // …

}

위의 예제를 할당이 적은 Touch API로 전환하는 것은 간단합니다.

프로퍼티의 get 메서드를 호출하는 CPU 비용을 절약하기 위해서, 프로퍼티 접근(Input.touchCount)은 루프 조건 외부에 위치하고 있습니다.

빈 배열 재사용

몇몇 개발팀은 배열 값 기반의 메서드가 빈 세트을 반환해야 할 때 null 값 대신에 빈 배열을 반환하는 것을 선호합니다. 이러한 코딩 방식은 C#이나 Java와 같은 다수의 관리되는 언어에서는 흔한 일입니다.

일반적으로 메서드에서 길이가 0인 배열을 반환할 때, 매번 새로운 배열을 생성하는 것보다 미리 할당된 싱글톤 인스턴스로 반환하는 것이 더 효율적입니다. (5) (참고: 물론, 배열이 반환된 후 크기 조절되는 경우에는 예외가 발생되어야 합니다)

각주

  • (1) 이는 대부분의 플랫폼의 경우, GPU 메모리로부터 리드백(되읽기)하는 것이 대단히 느리기 때문입니다. GPU 메모리에서 텍스처를 CPU 코드(Texture.GetPixel 등)가 사용하기 위해 임시 버퍼로 읽어들이는 것은 대단히 비효율적입니다.

  • (2) 엄격히 말하면 모든 null이 아닌 참조 형식의 오브젝트와 모든 박싱된 값 형식의 오브젝트를 관리되는 힙에 할당되어야 합니다

  • (3) 정확한 실행 간격은 플랫폼마다 다릅니다.

  • (4) 이는 주어진 프레임 동안 일시적으로 할당된 용량과 동일하지는 않습니다. 할당된 메모리의 전체 또는 일부가 이후 프레임에서 재사용된다고 하더라도 이 프로파일은 특정 프레임에 할당된 용량을 전부 표시합니다.

  • (5) 물론, 배열이 반환된 후 크기 조절되는 경우에는 예외가 발생되어야 합니다.


  • 2018–03–05 일부 편집 리뷰를 거쳐 페이지 수정됨
에셋 검사
문자열과 텍스트