1만 개를 한 번 도는 것과 1개를 1만 번 부르는 것
적 1만 마리가 플레이어를 향해 걸어오는 무쌍 게임을 만들려고 합니다. 각자 위치를 갱신하고, 옆에 있는 놈을 피하고, 사거리에 들어오면 때립니다.
MonoBehaviour 로 만들면 Update 가 1만 개 돕니다. 그리고 느립니다.
그런데 계산 자체는 무겁지 않습니다. 위치에 방향 곱해서 더하는 게 전부입니다.
느린 이유는 다른 데 있습니다.
값을 가지러 가는 시간이 계산하는 시간보다 길다
CPU 가 메모리에서 값을 하나 가져올 때, 그 값 하나만 가져오지 않습니다. 캐시 라인이라는 덩어리 단위로 주변까지 통째로 끌고 옵니다. 다음에 필요한 값이 그 안에 있으면 공짜로 읽고, 없으면 다시 메모리까지 다녀와야 합니다.
MonoBehaviour 는 힙에 있는 객체입니다.
1만 개를 만들면 힙 여기저기에 흩어져 앉고, Update 를 부를 때마다 그리로 뛰어갑니다.
게다가 Update 는 가상 호출이라 어디로 가는지 매번 확인해야 합니다.
ECS 가 데이터를 두는 방식
ECS 는 세 가지로 나눕니다.
- 엔티티: 번호표입니다. 그 자체로는 아무 데이터도 없습니다
- 컴포넌트: 순수한 데이터입니다. 위치, 속도, 체력. 메서드가 없습니다
- 시스템: 로직입니다. “이런 컴포넌트를 가진 것들” 을 훑으면서 일합니다
그리고 같은 컴포넌트 구성을 가진 엔티티끼리 한 덩어리에 모아 둡니다. 그 구성을 아키타입이라 하고, 그 덩어리를 청크라 합니다.
청크 안에서 위치는 위치끼리, 속도는 속도끼리 배열로 붙어 있습니다. 그러니 “모든 적의 위치를 갱신하라” 는 그 배열을 앞에서 뒤로 훑는 일이 됩니다.
여기서 두 가지가 더 열린다
이게 이 방식의 진짜 값어치입니다. 캐시 효율은 시작일 뿐입니다.
하나. SIMD. 값이 연속으로 붙어 있으니 CPU 가 한 명령으로 여러 개를 동시에 계산할 수 있습니다. Burst 컴파일러가 이걸 자동으로 해 줍니다.
둘. 병렬. 배열의 앞부분과 뒷부분이 서로를 안 건드립니다.
그러면 앞 절반은 워커 1번, 뒤 절반은 워커 2번에 맡겨도 됩니다.
MonoBehaviour 는 이게 안 됩니다. 다른 객체를 언제 참조할지 모르기 때문입니다.
데이터를 붙여 놓은 결과로 병렬화가 가능해진 것이지, 병렬화를 위해 붙인 게 아닙니다. 순서가 이쪽입니다.
Burst 는 CoreCLR 과 다른 축이다
여기서 헷갈리기 쉬운 게 있어서 미리 정리합니다.
유니티가 Mono 를 걷어내고 CoreCLR 로 가는 중이라는 이야기와, Burst 로 잡을 컴파일한다는 이야기는 다른 층 입니다.
| 무엇 | |
|---|---|
| Mono / IL2CPP / CoreCLR | 일반 C# 코드를 실행하는 런타임입니다. GC 도 여기 붙습니다 |
| Burst | 잡과 시스템에 붙는 별도 컴파일러입니다. LLVM 으로 네이티브 코드를 뽑습니다 |
Burst 로 컴파일되는 코드는 관리 힙을 안 씁니다. 그래서 GC 대상이 아닙니다.
런타임이 무엇으로 바뀌든 이쪽은 영향을 안 받습니다.
반대로 잡 안에서 class 나 string 을 쓰면 Burst 가 컴파일을 거부합니다.
같은 C# 문법인데 규칙이 다른 구역이 하나 더 생기는 셈입니다.
원리는 재 보기 전까지 원리다
여기까지가 ECS 를 쓰는 이유입니다. 다만 전부 원리입니다. 캐시가 어떻고 청크가 어떻고 하는 말은 맞지만, 내 프로젝트에서 몇 밀리초인지는 아무것도 말해 주지 않습니다.
손익분기점이 있다는 것만 미리 적어 둡니다. 대상이 적으면 ECS 의 고정 비용이 이득보다 큽니다. 적 100마리짜리 게임에 ECS 를 얹으면 느려지지는 않아도 얻는 것도 없습니다. 그 분기점이 어디인지는 원리로는 안 나오고 재야 나옵니다.
여기에 적힌 숫자와 코드는 적 1만 마리짜리 무쌍 게임을 실제로 만들면서 잰 것입니다.