C# 을 쓰는데 결과는 네이티브다
Esc

    C# 을 쓰는데 결과는 네이티브다

    –UnityDOTS최적화

    잡 시스템으로 일을 워커에 나눠 줘도, 그 워커가 실행하는 것은 여전히 C# 입니다. Burst 는 그 지점을 바꿉니다. 같은 C# 코드에서 네이티브 코드를 뽑아냅니다.

    [BurstCompile]
    struct MoveJob : IJobEntity { ... }

    이 한 줄이 붙은 잡은 다른 경로로 컴파일됩니다.

    IL 을 LLVM 에 넘긴다

    C# 은 컴파일하면 IL 이라는 중간 코드가 됩니다. 그다음이 백엔드마다 갈리는데, Burst 는 여기서 IL 을 받아 LLVM 으로 넘깁니다.

    LLVM 은 C++ 컴파일러들이 쓰는 그 최적화 엔진입니다. 루프 펼치기, 죽은 코드 제거, 인라이닝, 레지스터 할당 같은 것을 전부 해 줍니다. C++ 을 컴파일할 때 받던 최적화를 C# 코드가 그대로 받습니다.

    무엇을 하나
    Mono IL 을 런타임에 JIT 으로 기계어로 바꿉니다
    IL2CPP IL 을 C++ 로 바꾼 뒤 플랫폼 컴파일러로 빌드합니다
    Burst IL 을 LLVM 으로 넘겨 최적화된 네이티브를 뽑습니다. 잡과 시스템에만 적용됩니다
    C# 소스 IL 여기서 세 갈래로 갈라집니다 Mono 런타임에 JIT 기계어 IL2CPP C++ 로 바꾼 뒤 네이티브 Burst LLVM 에 넘김 최적화된 네이티브 에디터와 일부 플랫폼 모바일 콘솔 빌드 [BurstCompile] 이 붙은 잡과 시스템만 셋 중 하나를 고르는 것이 아닙니다. 한 프로젝트 안에서 위 둘과 아래 하나가 같이 돕니다.
    Burst 는 런타임을 갈아 끼우는 것이 아니라 특정 코드만 다른 길로 보내는 것입니다.

    Burst 는 런타임을 대체하지 않습니다. 일반 C# 코드는 그대로 Mono 나 IL2CPP 로 돕니다. [BurstCompile] 이 붙은 자리만 다른 길로 갑니다. 그래서 한 프로젝트 안에 두 세계가 공존합니다.

    SIMD 를 알아서 쓴다

    Burst 가 파는 것 중 제일 큰 것이 자동 벡터화입니다.

    요즘 CPU 는 명령 하나로 값 여러 개를 한꺼번에 계산할 수 있습니다. float 넷을 더하는 데 덧셈 명령 네 번이 아니라 한 번이면 됩니다. 이걸 SIMD 라 하고, 손으로 쓰려면 플랫폼별 내장 함수를 알아야 합니다.

    스칼라 · 하나씩 c0 = a0 + b0 c1 = a1 + b1 c2 = a2 + b2 c3 = a3 + b3 덧셈 명령 4번 벡터 · 넷을 한 번에 a0 a1 a2 a3 + b0 b1 b2 b3 c0 c1 c2 c3 덧셈 명령 1번 계산하는 값의 개수는 같습니다. 명령을 내는 횟수가 다릅니다. 그래서 계산이 무거운 루프보다 단순한 루프에서 효과가 큽니다.
    Burst 가 이 변환을 자동으로 합니다. 우리가 벡터 명령을 손으로 쓰지 않습니다.

    계산하는 순서로 보면 이렇습니다. 탭을 바꾸면 코드가 통째로 바뀝니다. 위쪽이 우리가 쓴 것이고 아래쪽은 Burst 가 만들어 낸 것입니다.

    두 번째 탭에서 읽기와 쓰기도 한 번씩인 것을 보세요. a[0] 부터 a[3] 이 메모리에 붙어 있어서 한 명령으로 퍼 올 수 있었습니다. 흩어져 있었다면 네 번 읽어야 하고, 그러면 덧셈만 묶어도 남는 게 없습니다. 아래 조건 셋 중 첫 번째가 여기서 나옵니다.

    Burst 는 루프를 보고 이걸 자동으로 만들어 줍니다. 단 조건이 있습니다.

    • 데이터가 연속으로 붙어 있어야 합니다. 포인터를 따라 뛰어다니면 못 합니다
    • 반복끼리 서로 의존하지 않아야 합니다. 앞 결과를 뒤가 쓰면 못 합니다
    • 분기가 적어야 합니다. if 가 많으면 벡터로 못 묶습니다

    조건이 깨지는 것이 어떤 모양인지는 코드 두 개를 나란히 놓으면 바로 보입니다.

    ECS 가 데이터를 청크에 연속으로 두는 것이 이 조건을 만들어 줍니다. 그래서 ECS, 잡 시스템, Burst 셋이 같이 다닙니다. 따로 쓸 수도 있지만 셋이 모였을 때 효과가 곱해집니다.

    Unity.Mathematics 의 float3, math.length 같은 것들은 Burst 가 SIMD 로 바꾸기 좋게 만들어진 타입입니다. Vector3 대신 float3 을 쓰라는 이유가 이것입니다.

    쓸 수 있는 C# 이 줄어든다

    Burst 가 다루는 것은 C# 전체가 아니라 HPC# 이라 부르는 부분집합입니다. 관리 힙에 손대는 것이 전부 빠집니다.

    되는 것 안 되는 것
    int float bool double 같은 기본형 class 와 모든 참조 타입
    struct string
    float2 float3 float4 관리 배열 (int[]). NativeArray<T> 를 씁니다
    enum, 포인터 try/catch 로 잡는 예외 처리
    struct 로 제한한 제네릭 박싱이 일어나는 모든 것

    처음에는 불편한 제약처럼 보이는데, 사실은 원인과 결과가 뒤집혀 있습니다.

    참조 타입을 쓰면 데이터가 힙 여기저기로 흩어지고, 흩어지면 SIMD 도 안 되고 캐시도 안 맞습니다. 빠른 코드가 되려면 어차피 못 쓰는 것들을 컴파일러가 미리 막는 것에 가깝습니다.

    Debug.Log 도 안 됩니다. 문자열이 관리 타입이니까요. 잡 안을 들여다보려면 Run() 으로 바꿔서 Burst 를 끄고 봐야 하는데, 그러면 성능이 전혀 다른 코드가 됩니다. 디버깅이 번거로운 게 가장 큰 실무 비용입니다.

    언제 컴파일되나

    언제
    에디터 JIT. 그 잡을 처음 실행할 때 컴파일합니다
    빌드 AOT. 빌드 시점에 미리 컴파일해 둡니다

    에디터에서 첫 프레임이 유독 튀는 것이 이것 때문인 경우가 많습니다. 측정할 때 워밍업 프레임을 버려야 하는 이유가 여기에도 있습니다.

    그리고 에디터에는 Burst 를 끄는 스위치가 있습니다 (Jobs > Burst > Enable Compilation). 켜고 끄고 재 보면 Burst 가 실제로 얼마를 벌어 주는지 바로 나옵니다. 끄고 쟀는데 별 차이가 없으면 그 잡은 애초에 병목이 아니었던 것입니다.

    정말 벡터화됐는지 보는 법

    Jobs > Burst > Open Inspector 를 열면 잡마다 생성된 어셈블리가 보입니다. 여기서 확인할 것은 둘입니다.

    • SIMD 명령이 실제로 나왔는가. addps, vmulps 처럼 벡터 명령이 보이면 됐습니다
    • 경고가 있는가. 벡터화를 못 한 이유를 적어 주기도 합니다

    [BurstCompile] 을 붙였다고 벡터화가 보장되지는 않습니다. 붙였는데 안 빨라졌다면 대개 위의 세 조건 중 하나가 깨진 것입니다.

    부동소수 정밀도를 낮출 수 있다

    [BurstCompile(FloatMode = FloatMode.Fast, FloatPrecision = FloatPrecision.Low)]

    FloatMode.Fast 는 부동소수 연산의 순서를 바꿔도 된다고 허락하는 것입니다. (a + b) + c 를 a + (b + c) 로 바꾸면 결과가 미세하게 달라지는데, 그걸 허용하면 최적화 여지가 늘어납니다.

    게임 로직에서는 대개 문제가 없습니다. 단 결정론이 필요하면 쓰면 안 됩니다. 리플레이나 락스텝 네트워킹처럼 같은 입력이 같은 결과를 내야 하는 곳입니다.

    조용히 실패한다

    가장 조심할 점입니다.

    [BurstCompile] 을 붙였는데 코드에 관리 타입이 섞여 있으면 컴파일에 실패하고 원래 C# 경로로 돌아갑니다. 게임은 정상으로 돌고, 결과도 맞고, 그냥 안 빨라집니다.

    콘솔에 경고가 뜨지만 다른 로그에 묻히기 쉽습니다. 그래서 확인은 셋 중 하나로 합니다. Burst Inspector 에서 어셈블리가 나오는지 보거나, 프로파일러에서 마커가 Burst Jobs 카테고리에 잡히는지 보거나, Burst 를 껐다 켰을 때 숫자가 움직이는지 보는 것입니다.

    빌드가 통과했다는 것이 Burst 가 걸렸다는 뜻은 아닙니다.