본문 바로가기
Unity3D/API & Library

Unity DOTS 기술 정리: 2. Burst Compiler

by 니키티스 2025. 9. 6.

2. Burst Compiler

  • Burst 컴파일러는 유니티의 성능을 끌어올리기 위한 컴파일러 도구이다. C# 코드와 호환되는 최적화된 네이티브 CPU 코드를 작성할 때 필요하다.
  • Burst는 Unity에서 고성능 연산 처리를 위해 도입된 컴파일러로, Unity에서 사용 가능한 C# 코드 컴파일러이다. Burst는 LLVM을 통해 .NET IL( Intermediate Language, 중간 언어)를 대상 CPU 아키텍처에 최적화된 코드로 변환한다.
  • Unity의 Job System을 위해 디자인되었다. 
  • 사용 방법은 단순히 클래스에 어트리뷰트로 [BurstCompiler]를 붙이면 된다. 다만 주의사항이 있다면, Burst Compiler를 사용하면 런타임 오류가 발생했을 때 상세 정보를 확인하기 어렵다는 문제가 있다.
보통은 앞에서 보았던 것처럼, [BurstCompiler] 어트리뷰트만 붙이면 사용할 수 있기 때문에 사용 방법은 어렵지 않다. 게다가 성능도 2~3배씩 향상시킬 수 있다는 장점이 있다.
[BurstCompiler]
public struct SquaresJob : IJob
{
  public NativeArray<int> Nums;

  public void Execute()
  {
    for (int i = 0; i < Nums.Length; i++)
    {
      Nums[i] *= Num[i];
    }
  }
}
 
그러나 문제는 .NET IL이 아니라 바로 CPU Native Code로 최적화하여 컴파일하기 때문에 더 이상 C#이 메모리를 관리해주는 방식은 사용할 수가 없다. 즉, BurstCompiler가 사용하는 부분에 대해서는 C#의 Managed memory 기능을 사용할 수 없다.
 
Burst Compiler는 C++과 유사하게, Unmanaged memory 기반의 자료구조만 사용할 수 있다. string이나 커스텀 클래스와 같은 C# Managed 객체는 사용할 수 없고, Burst Compiler 전용 타입을 사용해야 한다. 또한 배열이나 딕셔너리 같은 자료구조를 사용할 경우에도 C++처럼 프로그래머가 직접 자료구조의 메모리를 해제해 주어야 한다.
 
ex) Debug.Log와 같은 유니티의 함수를 쓸 수 없고, Time 구조체에도 접근할 수 없어 Time.deltaTime 같은 값을 별도 변수로 전달해 주어야 한다.
좀 더 정리하자면 이렇다.
  • 타입: int, float 같은 기본 타입은 사용할 수 있지만 string은 사용할 수 없다. 또, 조금 복잡한 타입은  Unity.Mathematics에서 지원하는 타입을 사용해야 한다.
    • ex) Vector3 대신 float3를 사용하는 식
  • System.Collections에서 지원하는 리스트 List나 Dictionary는 사용할 수 없다. 대신, Unity.Collections에 있는 Native Container를 사용해야 한다.
    • ex) NativeArray나 NativeHashMap 같은 것을 통해 데이터를 관리한다(병렬 처리 여부에 따라 또 변수가 달라짐).
직접 공식 사이트에서 제공하는 자료를 보며 좀 더 알아보자.
// 출처: https://docs.unity3d.com/Packages/com.unity.burst@1.8/manual/getting-started.html
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

public class MyBurst2Behavior : MonoBehaviour
{
    void Start()
    {
        var input = new NativeArray<float>(10, Allocator.Persistent);
        var output = new NativeArray<float>(1, Allocator.Persistent);
        for (int i = 0; i < input.Length; i++)
            input[i] = 1.0f * i;

        var job = new MyJob
        {
            Input = input,
            Output = output
        };
        job.Schedule().Complete();

        Debug.Log("The result of the sum is: " + output[0]);
        input.Dispose();
        output.Dispose();
    }

    // Using BurstCompile to compile a Job with Burst

    [BurstCompile]
    private struct MyJob : IJob
    {
        [ReadOnly]
        public NativeArray<float> Input;

        [WriteOnly]
        public NativeArray<float> Output;

        public void Execute()
        {
            float result = 0.0f;
            for (int i = 0; i < Input.Length; i++)
            {
                result += Input[i];
            }
            Output[0] = result;
        }
    }
}

MyBurst2Behavior 클래스는 MyJob이라는 Job을 호출하는 클래스이다.

MyJob은 구조체이기 때문에 여기에서 처리할 입력, 출력 데이터를 전달해 주어야 한다. 그 역할을 NativeArray<float> Input, Output이 수행하게 된다. 이때 두 개의 네임스페이스를 포함하는 것을 볼 수 있다.

  • using Unity.Burst: Burst Compiler를 사용하기 위해 필요한 네임스페이스
  • using Unity.Collections: NativeArray와 같은 Native Container를 사용하려면 필요한 네임스페이스

이때 NativeArray는 메모리를 C#에서 자동으로 관리해 주지 않기 때문에 직접 할당과 해제를 프로그래머가 제어해야 한다. 먼저 Native Container를 할당하는 부분은 이렇게 생겼다.

var input = new NativeArray<float>(10, Allocator.Persistent);
var output = new NativeArray<float>(1, Allocator.Persistent);

input은 길이 10의 배열이고, output은 길이 1의 배열이다.

이때 Native Container의 두 번째 인자는 Allocator의 타입을 의미한다. 이 부분은 조금 있다가 더 자세히 살펴보자.

다음으로, Job에 해당 데이터를 전달하고 나서, 끝나고 나서는 Native Container의 해제가 필요하다.

input.Dispose();
output.Dispose();

Native Container는 위와 같이 Dispose()를 호출하면 해제할 수 있다. C++에서의 free와 동일하다고 볼 수 있겠다.

그런데 이때 위를 보면 한 프레임만에 할당하고 해제하는 것을 볼 수 있는데, 꼭 그렇지 않고 오브젝트가 파괴될 때까지 Native Container를 유지하고 싶을 수도 있다.

그러고 싶을 땐 OnDestroy()나 프로그램이 종료될 때 Dispose해도 되긴 하는데 이때 고려해야 하는 것이 바로 Native Container의 Allocator이다. 이 Allocator에 따라 Native Container의 수명이 달라지기 때문이다. Unity Docs의 내용을 참고하자면 다음과 같다.

  • Allocator.Temp: 한 프레임 이하의 수명을 가진 할당.  할당 속도가 가장 빠르지만, Temp를 쓰면 NativeContainer 할당을 잡에 전달할 수 없다(Job이 완료되기도 전에 자동으로 해제되기 때문). 자동으로 해제되지만, 가능하면 직접 Dispose를 호출하는 것을 권장한다.
  • Allocator.TempJob: 4프레임 내의 스레드 세이프 할당에 사용한다. Temp 보다 할당 속도가 더 느리지만 Persistent 보다는 속도가 더 빠르다. 중요한 점은, 이러한 타입의 할당은 반드시 4프레임 내에서 Dispose 메서드를 호출해야 한다(그러지 않을 경우 경고가 발생하니 참고할 것). 대부분의 소규모 잡은 이러한 NativeContainer 할당 타입을 사용한다.
  • Allocator.Persistent: 애플리케이션의 주기에 걸쳐 필요한 만큼 오래 지속된다. 대신 할당이 가장 느리다. C언어의 malloc을 직접 호출하는 래퍼 역할을 한다. 오래 걸리는 잡은 이 NativeContainer 할당 타입을 사용할 수 있긴 하지만, 성능이 중요한 상황에서는 Persistent를 사용하면 안 된다니 주의할 것.

즉, 보통 Job에 필요한 Native Container는 Allocator.TempJob(4프레임)이나 Allocator.Persistent(영구적)를 사용할 수 있는데, 성능적으로는 TempJob이 더 유리하다.

참고 자료

Burst compiler - Unity Manual

NativeContainer - Unity Docs

유니티 엔진의 버스트란? - 원소랑, 네이버 블로그

 

댓글