[태그:] GC

  • 가비지 컬렉션의 영향을 최소하하기 위한 코드의 구조화

    가비지 컬렉션의 영향을 최소하하기 위한 코드의 구조화 (Structuring our code to minimize the impact of garbage collection)

    코드를 구조화하는 방식은 가비지 컬렉션에 영향을 줄 수 있습니다. 작성한 코드가 힙 할당을 발생시키지 않더라도, 가비지 컬렉터의 작업에 부하를 줄 수 있습니다.

    불필요하게 가비지 컬렉터의 작업에 부하를 줄 수 있는 방법 중 하나는, 검사할 필요가 없는 항목을 검사하도록 요청하는 것입니다.
    구조체는 값 타입 변수입니다. 하지만, 참조 타입 변수를 갖는 구조체의 경우, 가비지 컬렉터는 구조체 전체를 검사해야 합니다.
    이때 이런 구조체의 배열이 많은 경우, 가비지 컬렉터가 점검해야하는 작업이 많이 발생할 수 있습니다.

    아래 예제에서, 구조체는 문자열을 포함하며, 문자열은 참조 타입입니다. 이제 가비지 컬렉터가 실행되면, 이 구조체 배열 전체를 검사해야합니다.

    public struct ItemData
    {
        public string name;
        public int cost;
        public Vector3 position;
    }
     
    private ItemData[] itemData;

    아래 예제에서는, 별도의 배열에 데이터를 저장합니다.
    가비지 컬렉터가 실행되면, 문자열 배열에 대해서만 검사를 진행하고, 나머지 배열은 무시할 수 있습니다. 이는 가비지 컬렉터가 해야할 일을 줄입니다.

    private string[] itemNames;
     
    private int[] itemCosts;
     
    private Vector3[] itemPositions;

    가비지 컬렉터의 작업에 불필요한 부하를 줄 수 있는 또 다른 방법은, 불필요한 오브젝트 참조 값을 갖는 것입니다.
    가비지 컬렉터가 힙 상의 오브젝트에 대한 참조를 검색하려면, 코드에서 사용 중인 오브젝트를 모두 검사해야합니다.
    코드에서 오브젝트의 참조가 적다는 것은, 힙 상의 전체 오브젝트의 수를 줄이지 않더라도, 처리할 작업량이 적다는 것을 의미합니다.

    아래 예제에는, 대화 상자를 띄우는 클래스가 있습니다. 사용자가 대화 상자를 다보면, 다음 대화상자가 열립니다.
    이 코드에는 다음에 표시되어야하는 DialogData의 인스턴스에 대한 참조가 포함되어 있습니다. 이는 가비지 컬렉터가 작업의 일부로, 이 참조를 검사해야 한다는 것을 의미합니다:

    public class DialogData
    {
        private DialogData nextDialog;
     
        public DialogData GetNextDialog()
        {
            return nextDialog;
        }
    }

    아래 예제에서는, 다음에 표시되어야하는 DialogData의 인스턴스 값 대신, 그 인스턴스를 찾을 수 있는 식별자(identifier)를 반환하도록 코드를 재구성 했습니다. 이 값은 오브젝트 참조가 아니기 때문에, 가비지 컬렉터가 실행되는데 소모되는 시간을 추가하지 않습니다.

    public class DialogData
    {
        private int nextDialogID;
     
        public int GetNextDialogID()
        {
            return nextDialogID;
        }
    }

    이 예제 자체는 간단한 내용을 다룬 예제에 불과합니다.
    하지만, 만드는 게임에서 다수의 오브젝트를 참조하는 오브젝트가 많은 경우, 코드를 이런 방식으로 재구성해서 힙의 복잡도를 현저하게 줄일 수 있습니다.

    Timing garbage collection

    수동으로 가비지 컬렉션 실행하기 (Manually forcing garbage collection)

    마지막으로, 수동으로 가비지 컬렉션을 실행시킬 수 있습니다. 힙 메모리가 할당되었지만, 더이상 사용되지않고(예: 코드에서 애셋을 로드할때 가비지를 생성한 경우), 가비지 컬렉터로 인한 화면 멈춤이 플레이어에게 영향을 미치지 않는다는 것을 알고 있는 경우(예: 로딩 화면이 보여지는 동안에), 아래 코드를 이용해서 가비지 컬렉션을 요청할 수 있습니다:

    System.GC.Collect();

    이 코드는 가비지 컬렉터가 실행되도록 강제하며, 이를 통해서 원하는 시점에 사용하지않는 메모리를 회수할 수 있습니다.

    결론 (Conclusion)

    유니티에서 가비지 컬렉션이 동작하는 방식에 대해서 살펴봤습니다.
    또한 성능 문제를 일으킬 수 있는 원인과 가비지 컬렉션이 게임에 미치는 영향을 최소화 하는 방법에 대해서도 살펴봤습니다.
    여기에서 배운 지식과 프로파일링 도구를 이용해서, 가비지 컬렉션과 관련된 성능 문제를 해결하고, 메모리를 효율적으로 관리할 수 있도록 게임을 구성할 수 있습니다.

    아래 링크는 이 글에서 다룬 주제들에 대한 자세한 정보를 제공합니다.

    Further reading
    Memory management and garbage collection in Unity

    Unity Manual: Understanding Automatic Memory Management

    Gamasutra: C# Memory Management for Unity Developers by Wendelin Reich

    Gamasutra: C# memory and performance tips for Unity by Robert Zubek

    Gamasutra: Reducing memory allocations to avoid Garbage Collection on Unity by Grhyll JDD

    Gamasutra: Unity Garbage Collection Tips and Tricks by Megan Hughes

    Boxing

    MSDN: Boxing and Unboxing (C# Programming Guide)

    Object pooling

    Unity Learn: Object Pooling Tutorial

    Wikipedia: Object Pool Pattern

    블로그 강좌

    Strings

    Best Practices for Using Strings in the .NET Framework

  • 불필요한 힙 할당을 발생시키는 주요 원인 2

    코루틴 (Coroutine)

    유니티가 코루틴을 관리하기 위해서 인스턴스를 생성해야만하는 클래스 때문에, StartCoroutine() 함수를 호출하면 소량의 가비지(Garbage)가 생성됩니다.
    게임에서 퍼포먼스가 중요한 시점에는, StartCoroutine()함수의 호출을 제한하는 것이 좋습니다.
    이런 방식으로 생성된 가비지를 줄이기 위해서는, 성능이 중요한 시점에 호출해야하는 코루틴을 모두 미리 시작시켜야 합니다.
    그리고 StartCoroutine()의 지연 호출(Delayed calls)을 포함하는 중첩된 코루틴을 사용할때는 특히 주의해야 합니다.

    코루틴 내의 yield 구문은 그 자체로는 힙 할당을 발생시키지 않습니다; 그러나, yield 구문에 전달하는 값이 불필요한 힙 할당을 발생시키는 경우가 있습니다.
    예를 들어, 다음 코드는 가비지를 발생시킵니다:

    yield return 0;

    0의 값을 갖는 int 변수가 박싱 처리되기 때문에, 이 코드는 가비지를 생성합니다.
    이 경우, 불필요한 힙 할당없이 단순히 한 프레임을 대기하려고 할때, 아래 코드를 사용하는 것이 좋습니다:

    yield return null;

    코루틴을 사용할때 자주 범하는 다른 공통된 실수는 동일한 값을 두번 이상 사용해서 yield 시킬때 new를 사용하는 것입니다.
    예를 들어, 다음 코드는 루프가 반복될때마다, WaitForSeconds 오브젝트를 생성하고 삭제합니다:

    while (!isComplete)
    {
        yield return new WaitForSeconds(1f);
    }

    WaitForSeconds 오브젝트를 캐싱하고, 재사용하면 가비지가 훨씬 적게 생성됩니다. 다음 코드는 이 방법을 예제로 보여줍니다:

    WaitForSeconds delay = new WaitForSeconds(1f);
     
    while (!isComplete)
    {
        yield return delay;
    }

    코루틴으로 인해서 많은 가비지가 생성되는 코드가 작성된 경우, 코루틴 대신 다른 기능을 사용하도록, 코드를 리팩토링하는 것이 좋습니다.
    코드를 리팩토링하는 것은 매주 복잡한 주제이고, 모든 프로젝트는 각각 고유하지만, 일반적으로 코루틴 대신 사용할 수 있는 2가지 대안이 있습니다.

    예를 들어, 코루틴을 사용해서 주로 시간을 관리하는 경우, Update() 함수에서 단순히 시간을 추적(기록)하는 방법을 사용할 수 있습니다.
    코루틴을 사용해서, 게임에서 실행될 명령의 순서를 관리하는 경우, 오브젝트 간에 서로 통신할 수 있는 일종의 메시지 시스템을 만들어서 이를 대신 사용할 수 있습니다.

    모든 상황에 대처할 수 있는 방법은 없지만, 코드에서 동일한 목적을 당성하는데는 여러 가지 방법을 사용할 수 있다는 점을 명심하세요.

    foreach 루프 (foreach loops)

    유니티 5.5 이전 버전에서는, 배열이 아닌 다른 항목에 대해서 foreach 루프를 반복시킬때, 각 루프가 종료될때마다 가비지가 생성됩니다.
    이는 내부에서 발생하는 박싱(Boxing) 때문입니다. 루프가 시작될때 System.Object가 힙에 할당되고 루프가 종료될때 해제됩니다.
    이 문제는 유니티 5.5 버전에서 수정되었습니다.

    예를 들어, 유니티 5.5 이전 버전에서, 다음 코드의 루프는 가비지를 생성합니다:

    void ExampleFunction(List listOfInts)
    {
        foreach (int currentInt in listOfInts)
        {
            DoSomething(currentInt);
        }
    }

    유니티 버전을 업그레이드할 수 없는 상황이라면, 이 문제를 위한 간단한 해결책이 있습니다.
    for 루프와 while 루프는 박싱을 발생시키지 않기때문에, 가비지를 생성하지 않습니다.
    배열이 아닌 컬렉션(Collections)에 대해서 루프를 사용할때는 for 또는 while 루프를 사용하는 것이 좋습니다.

    다음 코드는 가비지를 생성하지 않습니다:

    void ExampleFunction(List listOfInts)
    {
        for (int i = 0; i < listOfInts.Count; i ++)
        {
            int currentInt = listOfInts[i];
            DoSomething(currentInt);
        }
    }

    함수 참조 (Function references)

    익명 메소드(anonymous method)또는 명명 메소드(named method)에 관계없이, 함수에 대한 참조는 유니티의 참조 타입(Reference Type) 변수입니다.
    따라서 힙 할당이 발생합니다.
    익명 메소드를 클로저(closure)로 변환하면(익명 메소드를 작성한 시점에 스코프 내에 있는 변수에 접근한 경우), 상당한 양의 메모리와 힙 할당이 증가합니다.

    함수 참조 및 클로저가 메모리를 할당하는, 정확하고 자세한 수치는 플랫폼과 컴파일러 설정에 따라 다릅니다.
    하지만, 가비지 컬렉션이 걱정되는 수준이라면, 게임 플레이 중에 함수 참조 및 클로저의 사용을 최소화하는 것이 좋습니다.

    LINQ 와 정규식 (LINQ ad Regular Expressions)

    LINQ 와 정규식은 모두 내부에서 박싱(Boxing)을 발생시키기 때문에, 가비지를 생성합니다. 성능이 중요한 시점에서 이 두가지 모두의 사용을 피하는 것이 좋습니다.

  • 불필요한 힙 할당을 발생시키는 주요 원인 1

    값 타입의 로컬 변수는 스택에 할당되고 이외의 모든 것은 힙에 할당된다는 것을 살펴봤습니다.
    그러나, 힙 할당이 우리를 놀라게할만한 상황이 많이 있습니다.
    불필요하게 힙 할당이 발생하는 몇가지 주요 원인을 살펴보고, 이를 줄이는 가장 좋은 방법에 대해서 살펴보겠습니다.

    문자열 (Strings)

    C#에서 문자열은 참조 타입입니다. 문자열의 “값”을 저장하고 있는 것처럼 보이지만, 값 타입(Value Type)이 아닌 참조 타입(Reference type)입니다.
    즉, 문자열을 생성하고 삭제하면 가비지가 생성됩니다. 문자열은 일반적으로 많은 코드에서 사용되기 때문에, 이로 인해서 가비지가 많이 쌓일 수 있습니다.

    C#의 문자열은 변경이 불가능하기 때문에, 처음 생성한 후에는 값을 변경할 수 없습니다.
    따라서 문자열을 변경할때마다(예: +연산자를 이용해서 두 개의 문자열을 더하는 경우), 유니티는 새로운 문자열을 생성하고 업데이트된 값을 저장한다음, 기존의 문자열을 삭제합니다. 이 과정에서 가비지가 생성됩니다.

    몇가지 간단한 규칙을 따르면, 문자열로 인해서 생성되는 가비지의 양을 최소화할 수 있습니다. 이 규칙들을 고려해서, 적용하는 방법에 대해서 살펴보겠습니다.

    • 불필요한 문자열 생성을 줄여야 합니다. 동일한 문자열 값을 두번 이상 사용하는 경우, 해당 문자열을 한번만 생성하고 캐시에 저장해서 사용해야합니다.
    • 불필요한 문자열 변경을 줄여야 합니다. 예를 들어, 자주 업데이트되는 텍스트 컴포넌트와 연결된 문자열이 있는 경우, 두 개의 텍스트 컴포넌트로 분리하는 것을 고려해보는 것이 좋습니다.
    • 런타임에 문자열을 생성해야하는 경우, StringBuilder 클래스를 사용할 수 있습니다. StringBuilder 클래스는 할당을 발생시키지 않고 문자열을 생성하도록 설계되었기 때문에, 복잡한 문자열을 연결할때 생성되는 가비지의 양을 줄일 수 있습니다.
    • 디버깅을 위한 목적으로 더 이상 필요하지 않게되면, Debug.Log()의 호출을 제거해야 합니다. Debug.Log()의 호출은 화면에 아무것도 출력하지 않더라도, 게임의 모든 빌드에서 실행됩니다. Debug.Log()는 최소 하나의 문자열을 생성하고 이를 처리하기 때문에, 게임에서 Debug.Log()를 많이 호출하면, 이로 인해서 가비지가 쌓일 수 있습니다.

    비효율적인 문자열의 사용으로 인해서 불필요한 가비지가 생성되는 코드의 예제를 살펴보겠습니다.
    다음의 코드에서는, Update()함수에서 “TIME :” 문자열과 float 타이머 값을 합쳐서 점수를 표시하기위한 문자열을 생성합니다. 이 코드는 불필요한 가비지를 생성합니다.

    public Text timerText;
    private float timer;
     
    void Update()
    {
        timer += Time.deltaTime;
        timerText.text = "TIME:" + timer.ToString();
    }

    다음 예제는 상당히 개선된 예입니다.
    별도의 텍스트 컴포넌트를 추가하고, Start()함수에서 “TIME:”이라는 단어를 설정했습니다. 즉, Update()함수에서 더이상 문자열을 결합하는 작업이 필요하지 않게 되었습니다.
    이렇게하면, 생성되는 가바지의 양이 상당히 줄어듭니다.

    public Text timerHeaderText;
    public Text timerValueText;
    private float timer;
     
    void Start()
    {
        timerHeaderText.text = "TIME:";
    }
     
    void Update()
    {
        timerValueText.text = timer.toString();
    }

    유니티 함수 호출 (Unity function calls)

    유니티 자체 코드 또는 플러그인 여부에 관계없이, 직접 작성하지 않은 코드를 호출할때마다 가비지가 생성될 수 있다는 것을 인식하는 것이 중요합니다.
    일부 유니티 함수 호출은 힙 할당을 발생시키기 때문에 불필요한 가비지가 생성되지 않도록 주의해서 사용해야합니다.

    호출을 피해야하는 함수에 대한 목록은 없습니다. 모든 함수는 일부 상황에서는 유용한 동시에 일부 다른 상황에서는 유용하지 않을 수 있습니다.
    이에 관계없이, 게임을 프로파일링해서 가비지를 생성시키는 원인을 파악하고, 어떻게 처리할 지를 고민하는 것이 가장 좋은 방법입니다.
    경우에 따라, 함수의 결과를 캐시에 저장하는 것이 좋은 방법이 될 수 있습니다; 이와 다른 경우로, 다른 함수를 사용하도록 코드를 리팩토링하는 것이 좋은 방법이 될 수 있습니다. 그렇긴 해도, 힙 할당을 발생시키는 유니티 함수의 주요 예제를 몇개 살펴보고, 이를 처리하는 최선의 방법에 대해서 살펴보겠습니다.

    배열을 반환하는 유니티 함수에 접근할때마다, 새로운 배열이 생성되고 이 배열이 반환 값으로 전달됩니다.
    이 동작은 특히, 해당 함수가 accessor 일때(예: Mesh.normals), 항상 정확하거나 예상할 수 있는 것은 아닙니다.

    다음 코드에서는 루프를 반복할때마다 새 배열이 생성됩니다.

    void ExampleFunction()
    {
        for (int i = 0; i < myMesh.normals.Length; i++)
        {
            Vector3 normal = myMesh.normals[i];
        }
    }

    이런 경우에는 할당을 줄이는 것이 쉽습니다: 간단히 배열의 참조 값을 캐시에 저장할 수 있습니다. 이렇게하면, 한개의 배열만 생성되고, 이에 따라서 생성되는 가바지의 양이 줄어듭니다.

    다음 코드는 이 방법을 보여줍니다. 예제의 경우, 루프를 실행하기 전에 Mesh.normals를 호출해서, 이 참조 값을 캐시에 저장해서 하나의 배열만 생성되도록 했습니다.

    void ExampleFunction()
    {
        Vector3[] meshNormals = myMesh.normals;
     
        for (int i = 0; i < meshNormals.Length; i++)
        {
            Vector3 normal = meshNormals[i];
        }
    }

    예상치 못한 힙 할당을 발생시키는 함수의 다른 예로, GameObject.name 과 GameObject.tag 가 있습니다.
    이 둘은 모두 새로운 문자열을 반환하는 접근자(accessor)입니다. 즉, 이러한 함수를 호출하면 가비지가 생성됩니다.
    값을 캐싱하는 것이 유용할 수 있지만, 이 경우에는 대신 사용할 수 있는 유니티 함수가 존재합니다.
    가비지를 생성하지 않고 게임 오브젝트의 태그(tag)를 값과 비교할때는, GameObject.CompareTag()를 사용할 수 있습니다.

    다음 예제 코드에서는, GameObject.tag를 호출해서 가비지를 생성합니다.

    private string playerTag = "Player"; 
    void OnTriggerEnter(Collider other) 
    { 
        bool isPlayer = other.gameObject.tag == playerTag; 
    }

    GameObject.CompareTag()를 사용하면, 이 함수는 더이상 가비지를 생성시키지 않습니다:

    private string playerTag = "Player"; 
     
    void OnTriggerEnter(Collider other) 
    { 
        bool isPlayer = other.gameObject.CompareTag(playerTag); 
    } 

    GameObject.CompareTag()는 교유한 함수가 아닙니다; 많은 유니티 함수 호출에는 힙 할당을 발생시키지 않는 대체 버전이 존재합니다.
    예를 들어, Input.GetTouch()와 Input.touchCount를 Input.touches 대신 사용할 수 있습니다.
    또는 Physics.SphereCastAll() 대신 Physics.SphereCastNonAlloc()을 사용할 수 있습니다.

    박싱 (Boxing)

    박싱(Boxing)이란 값 타입(Value Type)의 변수가 참조 타입(Reference Type)의 변수로 사용될때 발생하는 상황을 나타내는 용어입니다.
    박싱은 주로 int나 float와 같은 값 타입 변수를, Object.Equals()와 같이 object 파라미터를 요구하는 함수에 전달할때 발생합니다.

    예를 들어, String.Format()함수는 string과 object 파라미터를 전달받습니다. 이 함수에 string과 int를 전달하면, 전달된 int변수에서 박싱이 발생합니다.
    아래 코드는 박싱의 예를 보여줍니다:

    void ExampleFunction()
    {
        int cost = 5;
        string displayString = String.Format("Price: {0} gold", cost);
    }

    박싱이 처리되는동안 내부에서 일어나는 과정 때문에, 박싱은 가비지를 생성합니다.
    값 타입의 변수가 박싱되면, 유니티는 임시로 힙에 System.Object 변수를 생성해서, 해당 값 타입의 변수를 래핑(Wrapping, 감쌉니다)합니다.
    System.Object는 참조 타입의 변수이기 때문에, 이 임시 오브젝트가 처리될때 가비지가 생성됩니다.

    박싱은 불필요한 힙 할당의 매우 전형적인 사례입니다.
    코드에서 직접 변수를 박싱하지 않더라도, 박싱을 발생시키는 플러그인을 사용하는 경우가 있을 수 있고, 다른 기능의 내부 처리과정 중에서 박싱이 발생할 수도 있습니다. 가능하면 박싱을 피하고, 박싱을 발생시키는 함수 호출을 제거하는 것이 가장 좋은 방법입니다.