C# 을 쓰는데 결과는 네이티브다
잡 시스템으로 일을 워커에 나눠 줘도, 그 워커가 실행하는 것은 여전히 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 으로 넘겨 최적화된 네이티브를 뽑습니다. 잡과 시스템에만 적용됩니다 |
Burst 는 런타임을 대체하지 않습니다. 일반 C# 코드는 그대로 Mono 나 IL2CPP 로 돕니다.
[BurstCompile] 이 붙은 자리만 다른 길로 갑니다.
그래서 한 프로젝트 안에 두 세계가 공존합니다.
SIMD 를 알아서 쓴다
Burst 가 파는 것 중 제일 큰 것이 자동 벡터화입니다.
요즘 CPU 는 명령 하나로 값 여러 개를 한꺼번에 계산할 수 있습니다.
float 넷을 더하는 데 덧셈 명령 네 번이 아니라 한 번이면 됩니다.
이걸 SIMD 라 하고, 손으로 쓰려면 플랫폼별 내장 함수를 알아야 합니다.
계산하는 순서로 보면 이렇습니다. 탭을 바꾸면 코드가 통째로 바뀝니다. 위쪽이 우리가 쓴 것이고 아래쪽은 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 가 걸렸다는 뜻은 아닙니다.