본문 바로가기

유니티/최적화

[유니티 최적화] 자동 메모리 관리 이해

728x90
반응형

오브젝트나 문자열, 배열을 생성한 이후 저장하려면 메모리 공간이 필요합니다. 

필요한 공간은 heap이라고 하는 중심 풀에서 할당됩니다. 

메모리 공간을 할당받은 항목이 더 이상 사용되지 않게 되면 차지하던 메모리를 회수하여 다른 항목을 저장하는 데 사용할 수 있습니다. 

이전에는 프로그래머가 적절한 함수 호출을 통해 명시적으로 힙 메모리 블록을 할당하고 회수해야 했습니다. 

하지만 최근에는 Unity Mono 엔진과 같은 런타임 시스템이 자동으로 메모리 관리를 수행합니다. 

자동으로 메모리를 관리하면 명시적으로 할당하고 회수하는 것보다 코딩이 덜 필요하며 메모리 누수 현상이 발생할 가능성을 낮춥니다(메모리 누수 현상이란, 메모리가 할당된 이후 회수되지 않는 경우를 의미합니다).

1. 값 타입과 레퍼런스 타입

함수가 호출되면 하위 파라미터의 값이 해당 함수 호출을 위해 지정된 메모리 구역에 복사됩니다. 

몇 바이트만 차지하는 데이터 타입은 빠르고 쉽게 복사할 수 있지만, 보통 오브젝트, 문자열, 배열은 이보다 더 큰 경우가 많습니다. 

따라서 이를 주기적으로 복사하는 것은 대단히 비효율적이지만, 다행히도 꼭 그렇게 할 필요는 없습니다. 

큰 항목의 실제 스토리지 공간은 힙에서 할당되며, 저장되는 위치를 기억하기 위해 작은 “포인터” 값이 사용됩니다. 

그 이후부터는 파라미터를 패스할 때 포인터만 복사하면 됩니다. 

런타임 시스템이 포인터가 식별하는 항목을 찾을 수 있는 경우 데이터를 한 번만 복사하더라도 여러 번 사용할 수 있습니다.

값 타입은 파라미터가 전달되는 동안 사본이 직접 저장되는 타입입니다. 

이 타입으로는 정수, 부동소수점, 부울, Unity의 구조체 타입(예를 들어, Color 및 Vector3)이 포함됩니다. 

힙에 할당하고 그 후 포인터를 통해 액세스하는 타입을 레퍼런스 타입이라고 하며 변수에 저장되는 값은 어디까지나 실제 데이터를 “참조”합니다. 

참조 타입의 예로는 오브젝트, 문자열, 배열 등이 있습니다.

2. 메모리 할당 및 가비지 컬렉션

메모리 관리자는 힙에서 사용되지 않는 영역을 트래킹합니다. 

오브젝트가 인스턴스화되는 것과 같이 새로운 메모리 블록이 요청되는 경우, 관리자는 블록을 할당하기 위해 미사용 영역을 선택한 후 할당된 메모리를 제거합니다. 

이 과정은 필요한 블록 크기를 할당할 수 없는 빈 공간이 없을 때까지 반복됩니다. 

이 시점에서는 힙에서 할당된 모든 메모리가 사용 중일 가능성이 매우 낮습니다. 

힙에 있는 참조 항목을 접근하려면 해당 항목을 찾을 수 있도록하는 참조 변수가 필요합니다. 

참조 변수가 재할당되거나 로컬 변수로 변하는 경우와 같이 메모리 블록에 대한 모든 참조가 사라진 경우, 해당 메모리 블록을 안전하게 재할당할 수 있게 됩니다.

어떤 힙 블록이 더 이상 사용되지 않고 있는지를 확인하기 위해, 메모리 관리자는 현재 모든 액티브 참조 변수를 검색하고 이 변수가 참조하는 블록을 “살아있음(live)”이라고 표시합니다. 

검색이 끝나면 메모리 관리자는 살아 있는 블록 사이의 모든 공간을 비어 있다고 간주하며 다음 할당 요청 시 사용할 수 있다고 간주합니다.

이러한 이유로, 미사용 메모리를 파악하고 해제하는 프로세스를 가비지 컬렉션(garbage collection, GC)이라 합니다.

Unity는 stop-the-world 방식의 가비지 컬렉터인 Boehm–Demers–Weiser 가비지 컬렉터를 사용합니다. 

Unity는 가비지 컬렉션을 수행할 때마다 프로그램 코드 실행을 중지하고, 가비지 컬렉터가 모든 작업을 마친 후에만 일반적인 실행을 재개합니다. 

이러한 중단으로 인해 게임 내 실행에 지연이 발생할 수 있습니다.

지연 시간은 1밀리초 이하에서 수백 밀리초에 이르기까지 가비지 컬렉터가 처리하는 데 필요한 메모리 양과 게임이 실행 중인 플랫폼에 따라 다릅니다.

게임 같은 실시간 애플리케이션의 경우에는 가비지 컬렉터가 게임 실행을 중단하면 부드러운 애니메이션을 구현하는 데 필요한 일관된 프레임 속도를 지속할 수 없습니다.

이러한 중단 문제는 프로파일러 프레임 시간 그래프에서 높게 치솟은 모양을 보인다고 해서 GC 스파이크로도 불립니다.

다음 섹션에서는 게임 실행 중에 불필요한 가비지 컬렉션 메모리 할당을 방지하고 가비지 컬렉터의 작업량을 줄이는 코드를 작성하는 방법을 배울 수 있습니다.

3. 최적화

가비지 컬렉션(GC)은 자동으로 일어나며 프로그래머에게 비가시적으로 일어납니다. 

그러나 컬렉션 프로세스는 실제로는 내부적으로 상당한 CPU 시간을 요구합니다. 

제대로 사용하면 자동 메모리 관리는 일반적으로 전반적인 성능에 있어 수동 할당과 비슷하거나 훨씬 더 나은 결과를 나타냅니다. 

그러나 프로그래머 입장에서는 가비지 컬렉터가 필요 이상으로 자주 실행되어 게임 실행 중에 멈추는 현상을 유발하는 실수를 막는 것이 중요합니다.

처음에는 아무 문제 없어 보일지라도 이후 GC의 악몽을 불러올 수 있는 악명 높은 알고리즘이 일부 존재합니다. 

가장 잘 알려진 사례로 문자열 접합 반복이 있습니다.

 

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void ConcatExample(int[] intArray) {
        string line = intArray[0].ToString();
        
        for (i = 1; i < intArray.Length; i++) {
            line += ", " + intArray[i].ToString();
        }
        
        return line;
    }
}

 

여기서 주목해야 하는 사실은 새로운 조각이 문자열이 한 개씩 추가되지는 않는다는 점입니다. 

실제로는 루프가 실행될 때마다 라인 변수의 이전 내용이 삭제되며, 기존 조각 끝에 새로운 부분이 더해진 형태의 새로운 문자열이 할당됩니다. 

i의 값이 커질수록 문자열은 길어지므로, 사용되는 힙 공간 역시 증가합니다. 

따라서 이 함수는 매번 호출될 때마다 순식간에 빈 힙 공간을 수백 바이트 사용합니다. 

문자열을 동시에 여러 개 연결해야 하는 경우, Mono 라이브러리의 System.Text.StringBuilder 클래스를 사용하는 것이 좋습니다.

하지만 문자열 연결이 반복되어도 지나치게 자주 호출되지 않은 이상 큰 문제는 일어나지 않습니다. 

예를 들어, 아래와 같이 Unity에서 프레임 업데이트마다 문자열 연결을 반복하지만 않으면 됩니다.

 

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public int score;
    
    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}

 

…위 코드는 Update가 호출될 때마다 새 문자열을 할당하며 지속적으로 새 가비지 메모리를 조금씩 생성합니다. 

대부분의 메모리 누수는 score가 변경될 때에만 텍스트를 업데이트하여 줄일 수 있습니다.

 

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public string scoreText;
    public int score;
    public int oldScore;
    
    void Update() {
        if (score != oldScore) {
            scoreText = "Score: " + score.ToString();
            scoreBoard.text = scoreText;
            oldScore = score;
        }
    }
}

 

발생할 수 있는 또 다른 문제로는 어떤 함수가 배열 값을 반환하는 경우가 있습니다.

 

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    float[] RandomList(int numElements) {
        var result = new float[numElements];
        
        for (int i = 0; i < numElements; i++) {
            result[i] = Random.value;
        }
        
        return result;
    }
}

 

이 함수 타입은 값으로 채워진 새로운 배열을 생성하는 데 사용하면 매우 편리합니다. 

하지만 이 타입을 자주 호출하면 매번 새로운 메모리가 할당됩니다. 

보통 배열을 크기가 상당히 크므로 빈 힙 공간이 빠르게 소모되어 자주 가비지 컬렉션을 해야합니다. 

배열이 참조 타입이라는 점을 활용하면 이 문제를 피할 수 있습니다. 

함수에 파라미터로 전달된 배열은 해당 함수에서 수정할 수 있으며 수정 결과는 함수를 리턴해도 유지됩니다. 

이 점을 활용하면 위의 함수를 아래와 같이 수정할 수 있습니다.

 

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void RandomList(float[] arrayToFill) {
        for (int i = 0; i < arrayToFill.Length; i++) {
            arrayToFill[i] = Random.value;
        }
    }
}

 

위 함수는 배열의 기존 내용을 새로운 값으로 간단하게 교체합니다. 

코드를 호출하는 과정에서 배열을 초기에 할당해야 하지만(따라서 덜 깔끔하지만), 이 함수는 호출되더라도 새로운 가비지를 생성하지 않습니다.

3.1 가비지 컬렉션 비활성화

 

Mono 또는 IL2CPP 스크립팅 백엔드를 사용하는 경우 런타임 시 가비지 컬렉션을 비활성화하여 가비지 컬렉션 동안 CPU 사용량 급증을 막을 수 있습니다.

가비지 컬렉션을 비활성화하면 가비지 컬렉터가 레퍼런스가 없는 오브젝트를 더 이상 수집하지 않기 때문에 메모리 사용량이 절대로 감소하지 않습니다.

실제로, 가비지 컬렉션을 비활성화하면 메모리 사용량이 계속 증가합니다.

메모리 사용량이 지속적으로 증가하지 않도록 만들려면 메모리를 잘 관리해야 합니다.

이상적으로는, 가비지 컬렉터를 비활성화하기 전에 모든 메모리를 할당하고, 비활성화된 시간 동안에는 추가 할당을 피하는 것이 좋습니다.

런타임 시 가비지 컬렉션을 활성화하거나 비활성화하는 방법은 GarbageCollector 스크립팅 API 페이지를 참조하십시오.

점진적 가비지 컬렉션 옵션을 사용할 수도 있습니다.

3.2 컬렉션 요청


위에서 언급한 것처럼 할당을 최대한 피하는 것이 최선입니다. 

하지만 할당 과정을 완전히 제거할 수는 없습니다. 

따라서 아래에 있는 게임플레이에 대한 영향을 최소화하는 두 가지 전략을 사용하는 것이 좋습니다.

3.2.1 작은 힙과 빠르고 빈번한 가비지 컬렉션

 

이 방법은 오래 플레이되는 게임에서 부드러운 프레임률을 유지하는 데 가장 적합합니다. 

이러한 게임은 작은 블록을 자주 할당하게 되지만, 이들 블록은 짧은 기간 동안만 사용됩니다. 

이 방법을 iOS에서 사용할 때 할당할 일반적인 힙 크기는 200KB이며, 가비지 콜렉션은 이 경우 iPhone 3G에서 대략 5ms 정도 걸리게 됩니다.

힙 크기가 1MB로 증가하면 가비지 컬렉션은 7ms 정도 걸리게 됩니다.

따라서 가비지 콜렉션을 일정 프레임 간격마다 주기적으로 요청하는 것이 좋습니다.

이렇게 하면 가비지 컬렉션이 실제로 필요한 만큼 이상으로 발생하게 되지만 더 빠르게 수행되어 게임플레이에 최소한의 지장을 주게 됩니다. 아래를 참조하십시오.

 

if (Time.frameCount % 30 == 0)
{
   System.GC.Collect();
}

 

그러나 이 기법은 주의해서 사용해야 하며 게임에서 실제로 가비지 컬렉션 시간을 감소시키는지 프로파일러 통계를 확인해야 합니다.

3.2.2 큰 힙과 느리지만 덜 빈번한 가비지 컬렉션

 

이 전략은 메모리 할당과 가비지 콜렉션이 비교적 자주 발생하지 않아 게임플레이 중간에 처리할 수 있는 게임에 적합합니다. 

힙 용량은 최대한 큰 것이 좋습니다. 

다만 너무 커서 운영체제가 시스템 메모리를 확보하려고 앱을 강제종료하는 경우는 피해야 합니다. 

Mono 런타임은 자동으로 힙을 확장하는 것을 최대한 피합니다. 

따라서 수동으로 힙을 확장해야 하는데, 시작할 때 일부 플레이스홀더를 미리 할당하면 됩니다. 

예를 들어, 메모리 관리자에서 메모리를 할당받기 위해 “무의미한” 오브젝트를 인스턴스 처리하면 됩니다.

 

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void Start() {
        var tmp = new System.Object[1024];
        
        // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
        for (int i = 0; i < 1024; i++)
            tmp[i] = new byte[1024];
        
        // release reference
        tmp = null;
    }
}

 

충분히 큰 힙을 확보하면 게임플레이 중 일시정지가 발생할 때까지 완전히 힙이 차버려서 가비지 컬렉션이 일어나는 일은 발생하지 않습니다. 

일시정지가 발생하면 명시적으로 가비지 컬렉션을 요청할 수 있습니다.-

 

System.GC.Collect();

 

재차 강조하지만, 이 전략을 사용할 때에는 원하는 효과가 알아서 구현될 것이라 단정하지 말고 프로파일러 통계를 계속해서 참조하십시오.

3.3 재사용 가능 오브젝트 풀

 

새로 생성되고 제거되는 오브젝트의 수를 줄이는 것으로 간단하게 가비지 생성을 줄일 수 있습니다. 

게임에서는 투사체와 같이 여러 번 반복되지만 한 번에는 몇 개만 사용되는 오브젝트 유형이 있습니다. 

이런 경우 오브젝트를 새로 생성하여 기존 오브젝트를 대체하는 것보다 오브젝트를 재사용하는 편이 좋습니다.

 

4. 점진적 가비지 컬렉션

참고: 이 기능은 프리뷰 기능이며 변경될 수 있습니다. 

이 기능을 사용하는 프로젝트는 향후 릴리스에서 업데이트해야 할 수 있습니다. 

공식 출시되기 전까지 정식 프로덕션에서 이 기능에 의존하지 마십시오.

점진적 가비지 컬렉션 은 가비지 컬렉션을 수행하는 데 필요한 작업을 여러 프레임에 걸쳐 분산합니다.

점진적 가비지 컬렉션의 경우 Unity는 여전히 Boehm–Demers–Weiser 가비지 컬렉터를 사용하지만, 점진적 모드에서 실행한다는 점이 다릅니다. 

실행할 때마다 전체 가비지 컬렉션을 수행하는 대신, Unity는 가비지 컬렉션 작업량을 여러 프레임에 걸쳐 분할합니다.

따라서 가비지 컬렉터의 작업을 위해 한 번에 긴 시간 동안 프로그램 실행을 중단하지 않고 여러 번에 걸쳐 훨씬 짧은 시간 동안만 프로그램을 중단합니다.

이렇게 하면 가비지 컬렉션 속도를 전반적으로 크게 향상하지는 않지만, 작업량을 여러 프레임에 걸쳐 분산함으로써 게임의 원활한 실행을 방해하는 가비지 컬렉션 “스파이크” 문제를 크게 줄일 수 있습니다.

점진적 가비지 컬렉션을 사용할 때와 사용하지 않을 때를 캡처한 다음의 Unity 프로파일러 스크린샷을 통해 점진적 가비지 컬렉션이 프레임 속도의 끊김 현상을 줄이는 방식을 알 수 있습니다. 

이러한 프로파일 트레이스에서 프레임의 하늘색 부분은 스크립트 동작에서 사용되는 시간, 노란색 부분은 Vsync(다음 프레임이 시작될 때까지 대기)까지 프레임에 남은 시간, 그리고 암녹색 부분은 가비지 컬렉션이 소비한 시간을 각각 나타냅니다.

 

그림. 비점진적 가비지 컬렉션 프로파일 

 

점진적 GC를 사용하지 않는 경우(상기 참조) 원활한 60fps 프레임 속도를 방해하는 스파이크를 발견할 수 있습니다.

이 스파이크는 가비지 컬렉션이 일어나는 프레임이 60FPS를 유지하는 데 필요한 16밀리초 제한을 훨씬 초과하도록 만듭니다.

실제로 이 예에서는 가비지 컬렉션 때문에 1프레임 넘게 하락했습니다.

 

그림. 점진적 가비지 컬렉션 프로파일

 

점진적 가비지 컬렉션을 활성화하면(상기 참조) 가비지 컬렉션 동작이 여러 프레임에 걸쳐 분할되어 각 프레임의 작은 시간 조각만을 사용하기 때문에 동일한 프로젝트에서 일관된 60fps 프레임 속도가 유지됩니다(암녹색 테두리가 노란색 Vsync 트레이스 위에 있음).

 

그림. 프레임의 남은 시간을 사용하는 점진적 가비지 컬렉션

 

이 스크린샷은 점진적 가비지 컬렉션이 활성화된 동일한 프로젝트를 보여주지만, 이번에는 프레임당 스크립팅 동작이 더 적습니다.

이번에도 가비지 컬렉션 동작이 여러 프레임에 걸쳐 분할됩니다.

유일한 차이는 가비지 컬렉션이 프레임당 더 많은 시간을 사용하며, 완료하는 데 필요한 총 프레임 수가 더 적다는 것입니다.

이는 Vsync 또는 Application.targetFrameRate가 사용되는 경우 남은 이용 가능 프레임 시간에 기반하여 가비지 컬렉션에 할당된 시간을 조정하기 때문에 가능합니다.

이런 식으로 대기 시간 없이 가비지 컬렉션을 실행할 수 있으므로, 가비지 컬렉션을 ‘성능 소모 없이’ 이용하는 효과를 누릴 수 있습니다.

4.1 점진적 가비지 컬렉션 활성화

 

점진적 가비지 컬렉션은 현재 다음 플랫폼에서 지원됩니다.

  • Mac 스탠드얼론 플레이어
  • Windows 스탠드얼론 플레이어
  • Linux 스탠드얼론 플레이어
  • iOS
  • Android
  • Windows UWP 플레이어
  • PS4
  • Xbox One
  • Nintendo Switch
  • Unity 에디터

점진적 가비지 컬렉션은 현재 WebGL에서 지원되지 않습니다.

점진적 가비지 컬렉션을 사용하려면 .NET 4.x Equivalent 스크립팅 런타임 버전이 필요합니다.

지원되는 설정의 경우 Unity는 점진적 가비지 컬렉션을 플레이어 설정 창의 ‘Other settings’ 영역에서 옵션으로 제공합니다. 

여기서 Use incremental GC 체크박스를 활성화하여 사용할 수 있습니다.

 

그림. 점진적 가비지 컬렉션을 활성화하기 위한 플레이어 설정 

 

또한 프로젝트 품질 설정에서 VSync Count를 Don’t Sync 이외의 옵션으로 선택하거나 Application.VSync 프로퍼티 또는 Application.targetFrameRate 프로퍼티를 설정하면, Unity는 특정 프레임이 끝날 때 남은 대기 상태 시간을 점진적 가비지 컬렉션에 자동으로 사용합니다.

Scripting.GarbageCollector 클래스를 사용하여 점진적 가비지 컬렉션 동작을 더욱 정밀하게 제어할 수 있습니다. 

예를 들어 VSync 또는 타겟 프레임 속도를 사용하고 싶지 않으면 프레임이 끝나기 전에 직접 이용 가능한 시간을 계산한 후 해당 값을 가비지 컬렉터에 제공하여 사용하도록 만들 수 있습니다.

4.2 점진적 가비지 컬렉션에서 발생할 수 있는 문제

 

대개의 경우 점진적 가비지 컬렉션은 가비지 컬렉션 스파이크 문제를 완화할 수 있습니다. 

하지만 일부 경우 점진적 가비지 컬렉션이 실질적 이점을 제공하지 않을 수도 있습니다.

점진적 가비지 컬렉션은 작업량을 나눌 때 마킹 단계를 분할합니다. 

이 단계에서는 모든 관리되는 오브젝트를 스캔하여 사용 중인 오브젝트와 정리할 수 있는 오브젝트를 판단합니다. 

마킹 단계 분할은 오브젝트 사이에 있는 레퍼런스 대다수가 작업 조각 사이에서 변하지 않을 때 특히 유용합니다. 

오브젝트 레퍼런스가 변경되면 해당 오브젝트는 다음 반복 작업 때 다시 스캔해야 합니다. 

따라서 변경 사항이 너무 많으면 점진적 가비지 컬렉터에 과부하가 걸릴 수 있습니다. 

따라서 처리해야 할 작업이 너무 많아서 마킹 패스가 완료되지 않습니다.

이 경우 가비지 컬렉션은 완전한 비점진적 컬렉션으로 폴백합니다.

또한 점진적 가비지 컬렉션을 사용할 경우 Unity는 추가 코드(’쓰기 배리어’라고 불림)를 생성하여 레퍼런스가 변경될 때마다 가비지 컬렉션에 알려야 합니다. 

그래야만 가비지 컬렉션이 오브젝트를 다시 스캔해야 할지 판단할 수 있습니다. 

레퍼런스를 변경할 때는 일부 관리되는 코드에 측정 가능한 성능 영향을 줄 수 있으므로 오버헤드가 추가로 발생합니다.

하지만 ‘일반적인’ Unity 프로젝트가 있다는 가정하에 대부분의 일반적인 Unity 프로젝트는 점진적 가비지 컬렉션을 사용하면 도움이 됩니다. 

특히 가비지 컬렉션 스파이크 문제를 해결할 수 있습니다.

항상 프로파일러를 사용하여 게임이나 프로그램이 의도한 대로 동작하는지 확인하십시오.

728x90
반응형