유니티 DOTS 연구1 : Burst Compiler (버스트 컴파일러)

 

사실 Burst는 1.0을 지원한 지 꽤 된 기술이지만,

구체적으로 필요성을 못 느껴서 계속 공부할 생각을 안 하고 있다가 면접 질문으로 나와서 정리하고자 한다.

 

DOTS(데이터 지향 프로그래밍)

유니티의 고성능 멀티스레드를 위해 새롭게 제시된 패러다임. 

 

ECS(Entity Component System)
C# JobSystem
Burst Compiler

 

DOTS를 위해 새롭게 생긴 위 세 가지 기능이 세트처럼 같이 언급되는데,

위의 것들을 동시에 사용하면 시너지가 난다는 의미지 세 가지 중 한 두개만 따로 써도 전혀 문제가 없다고 한다.

 

Entity는 아직 Preview상태지만 JobSystem이랑 Burst Compiler는 정식 기능이 되었기 때문에 충분히 활용 가능하다.

유니티 2023.1 문서에서 entities는 아직 pre 상태인 것을 확인할 수 있다.

 

즉, 현재 시점 (유니티 2023.1) 기준으로는 잡 시스템버스트 컴파일러를 활용하는 것을 추천한다. 

 

 

그래서 DOTS를 왜 쓰는데?

한 문장으로 요약하면 성능을 위해 사용한다.

 

객체지향 프로그래밍은 객체끼리 상호작용해야 돼서 비교적 성능이 느리다는 점인데,

데이터 지향 코드로 이를 보완할 수 있다.

(기존 방법은 저장된 메모리를 불러와서 연산한다음 다시 돌려주고 하는 부분에서 오버헤드가 발생,

DOTS에서는 한번에 처리해서 캐시 적중률을 높인다는 듯)

 

특히 시뮬레이션 코드에 DOTS를 사용하면 수백만 유닛의 전투도 구현이 가능하다.

 

유니티는 단일 쓰레드만 지원한다. (메인 스레드만 유니티 메인 로직에 접근가능하다)

C# JobSystem을 사용한다면 유니티에서도 멀티스레드를 안전하게 사용할 수 있다.

 

 

버스트 컴파일러

유니티 메뉴얼에는 버스트 컴파일러에 대해 다음과 같이 설명하고 있다.

Burst는 LLVM을 사용하여 IL/.NET 바이트코드를 
고도로 최적화된 네이티브 코드로 변환하는 컴파일러 기술입니다.

 

버스트는 범용 컴파일러가 아니라 유니티만을 위해 만들어진 특화 컴파일러이다. 

버스트는 멀티 플랫폼을 지원한다.

 

 

버스트 컴파일러는 어떤 일을 하는데?

유니티에서 C#코드를 작성하게되면 .Net assembly가 이를 IL 코드로 변환한다.

MONO(JIT) C# 컴파일러는 이를 그대로 플랫폼에서 MONOVM을통해 Just In Time으로 실행한다.

IL2CPP(AOT)는 IL코드를 다시 한번 C++로 변환한다. (Ahead of time), 최종적으로는 Assembly -> Machine Code로 변환한다.

 

기존 변환하는 과정에서 컴파일러가 코드 최적화도 실행하는데, Burst 컴파일러는 이를 좀 더 강화하였다.

LLVM(컴파일 컨셉 버츄얼 머신)기반의 컴파일러로 중간언어인 LLVM IR(InterMedia Representation)로 바꾼다.

때문에 IL2CPP를 대체할 수 있는 컴파일러라고 보면 된다.

 

의문점 : 왜 그냥 LLVM을 사용하지 않는가?

LLVM은 C#을 지원하지않는다.

의문점 : 이미 IL2CPP가 있는데 왜 필요한가?

Burst와 IL2CPP를 비교한 실험 아티클을 읽어보는 것을 추천.

https://www.jacksondunstan.com/articles/5282

요약 : IL2CPP는 Burst와 때때로 동일한 어셈블리 코드를 생성하지만 대부분의 경우 IL2CPP는 추가 분기를 삽입하는 경우가 많았고, Burst는 더 작고 빠른 코드를 생성했다.

 

버스트는 데이터지향이며, IL2CPP는 객체지향을 보다 효율적으로 사용하기 위해 존재함

 

 

버스트 컴파일러가 내 코드를 왜 빨라지게 하는데?

SIMD : Single Instruction Multiple Data

 

하나의 연산으로 데이터를 여러 개 처리하는 아키텍처. (Vector4 연산 등)

버스트 컴파일러는 SIMD에 최적화된 코드를 만들어 준다.

때문에 데이터 지향적인 잡시스템이나 엔터티와 같이 사용하게 되면 시너지가 나는 것.

 

또한 버스트는 타겟 CPU에 특화된 코드를 작성한다. 

 

버스트 컴파일러의 단점은?

단. 버스트는 완전히 자동화된 게 아니라 버스트에 맞는 코드를 작성해야 한다.

- List<T>나 Array 대신 NativeArray를 사용하고 Dispose()도 직접 해줘야 한다. (메모리 직접관리)

- 레퍼런스 타입. 즉 class를 지원하지 않아서 대신 struct를 사용해야 한다.

- string도 레퍼런스 타입이기 때문에 지원하지 않는다!

지원 bool
byte / sbyte
float / double
int / uint
long / ulong
short / ushort
미지원 char
decimal
string
유니티 메뉴얼 : Burst C# Support
https://docs.unity3d.com/Packages/com.unity.burst@1.8/manual/csharp-type-support.html

 

 

즉, 성능을 위해 C#의 자동메모리 관리등의 편의성을 희생하는 것.

이런 문제가 있어 [BurstCompile]애트리뷰트를 통해 버스트 컴파일러는 필요한 부분에만 사용할 수 있다.

(예를 들어, 유닛이 수백만 마리가 나오는 게임에서는 유닛의 이동로직부분만 버스트 컴파일러를 사용하면 된다!)

 

특정 Struct 전체에 대해 Burst 를 적용한 예 

[BurstCompile]
public struct MyJob : IJob
{
	public void Execute(){ };
}

특정 메서드만 Burst를 제외한 예

[BurstCompile]
public struct MyJob : IJob
{
	[BurstDiscard] // 제외
	public void Execute(){ };
}

 

 

버스트 컴파일러 + 잡시스템(단일) 테스트

Unity 2022.1.24f1 환경에서 테스트함.

Burst 컴파일러는 유니티 패키지매니저에서 임포트가 가능하다.

 

임포트 하고 나면 상단 메뉴에 Jobs라는 메뉴가 생긴다.

Jobs - Open Inspector를 통해 버스트 컴파일러로 컴파일된 결과도 확인 가능하다.

(IL이나 Assembly로 컴파일된 결과물을 확인가능하다)

 

구현하는 법

JobSystem은 IJob을 상속받은 struct에서 Execute()메서드를 구현하면 된다.

(병렬처리가 가능한 버전의 인터페이스도 별도로 존재한다.)

 

테스트를 해보니 Execute()안에 버스트컴파일러로 변환이 안 되는 UnityEngine 코드가 들어간 경우

별다른 오류 없이 버스트 컴파일러로 변환하지 못하고 일반 컴파일 하는 것으로 보인다.

UnityEngine.Debug.Log 한 줄 넣는다고 일반 실행보다 느려진다.

 

 

BurstTestController.cs

더보기
public class BurstTestController : MonoBehaviour
{
    [SerializeField] TextMeshProUGUI text;

    private void Start()
    {
        var array = Enumerable.Repeat(1f, 50000000).ToArray();


        // 버스트
        Stopwatch burstWatch = new Stopwatch();
        burstWatch.Start();
        // tester.Execute(); 이렇게 호출하면 안된다.
        BurstTest burst = new BurstTest(array);
        JobHandle jobHandle = burst.Schedule();
        jobHandle.Complete();
        burstWatch.Stop();

        // 일반
        Stopwatch normalWatch = new Stopwatch();
        normalWatch.Start();
        NormalTest normal = new NormalTest(array);
        normal.Execute();
        normalWatch.Stop();

        text.text = $"일반 {normalWatch.ElapsedMilliseconds}ms / 버스트 {burstWatch.ElapsedMilliseconds}ms";
        burst.Dispose();
    }
}

 

BurstTest.cs

더보기
[BurstCompile]
public struct BurstTest : IJob // 클래스가 아니라 구조체!
{
    [ReadOnly] public NativeArray<float> input;
    public NativeArray<float> output;

    public BurstTest(float[] array)
    {
        input = new NativeArray<float>(array, Allocator.TempJob);
        output = new NativeArray<float>(input.Length, Allocator.TempJob);
    }

    public void Execute()
    {
        for (int i = 0; i < input.Length; i++)
            output[i] = output[i] = Mathf.Pow(input[i], 2) * Mathf.Abs(input[i]) - Mathf.Ceil(input[i]);
    }

    public void Dispose()
    {
        input.Dispose();
        output.Dispose();
    }
}

 

NormalTest.cs

더보기
public class NormalTest
{
    public float[] input;
    public float[] output;

    public NormalTest(float[] array)
    {
        input = array.Clone() as float[];
        output = new float[input.Length];
    }

    public void Execute()
    {
        for (int i = 0; i < input.Length; i++)
            output[i] = Mathf.Pow(input[i], 2) * Mathf.Abs(input[i]) - Mathf.Ceil(input[i]);
    }
}

 

실행결과(에디터)

 -> 위의 코드에서 계산용 클래스 생성 부분이 포함되어 있는데,

순수 계산 부분만 측정하기 위해 생성 부분도 분리해서 한번 더 측정했다. 

NormalTest, BurstTest 생성 부분을 제외하고 측정한경우
클래스 생성부분까지 통채로 넣은경우

 

실행결과(Mono, Window64 빌드)

NormalTest, BurstTest 생성 부분을 제외하고 측정한경우
클래스 생성부분까지 통채로 넣은경우

 

실행시간이 약 8~40배 정도 차이 나는 것을 확인할 수 있었다. 

 

 

 

 

참고자료

Unite Now 2020 : Using Burst Compiler to optimize for Android
https://www.youtube.com/watch?v=WnJV6J-taIM
유니티 블로그 : 버스트 컴파일러로 모바일 성능 강화
https://blog.unity.com/kr/technology/enhancing-mobile-performance-with-the-burst-compiler
유니티 코리아 : Burst 컴파일러 완전정복! 개념 & 기초 활용
https://www.youtube.com/watch?v=ZuzBOXUuEeM

 

댓글

Designed by JB FACTORY