1만 개를 한 번 도는 것과 1개를 1만 번 부르는 것
Esc

    1만 개를 한 번 도는 것과 1개를 1만 번 부르는 것

    –UnityDOTS최적화메모리

    적 1만 마리가 플레이어를 향해 걸어오는 무쌍 게임을 만들려고 합니다. 각자 위치를 갱신하고, 옆에 있는 놈을 피하고, 사거리에 들어오면 때립니다.

    MonoBehaviour 로 만들면 Update 가 1만 개 돕니다. 그리고 느립니다. 그런데 계산 자체는 무겁지 않습니다. 위치에 방향 곱해서 더하는 게 전부입니다.

    느린 이유는 다른 데 있습니다.

    값을 가지러 가는 시간이 계산하는 시간보다 길다

    CPU 가 메모리에서 값을 하나 가져올 때, 그 값 하나만 가져오지 않습니다. 캐시 라인이라는 덩어리 단위로 주변까지 통째로 끌고 옵니다. 다음에 필요한 값이 그 안에 있으면 공짜로 읽고, 없으면 다시 메모리까지 다녀와야 합니다.

    GameObject 1만 개 읽을 때마다 딴 데로 뜁니다. 끌고 온 주변 값은 대부분 못 씁니다. ECS 청크 앞에서 뒤로 한 방향입니다. 한 번 끌고 오면 여러 개가 같이 옵니다. 같은 계산인데 속도가 갈리는 이유는 계산이 아니라 값을 가지러 가는 시간입니다. 그리고 연속된 배열이라는 사실이 두 가지를 더 열어 줍니다. SIMD 로 한 번에 여러 개를 계산할 수 있고 앞뒤가 독립적이라 워커 스레드에 쪼갤 수 있습니다
    ECS 가 파는 것은 빠른 계산이 아니라 예측 가능한 메모리 접근입니다.

    MonoBehaviour 는 힙에 있는 객체입니다. 1만 개를 만들면 힙 여기저기에 흩어져 앉고, Update 를 부를 때마다 그리로 뛰어갑니다. 게다가 Update 는 가상 호출이라 어디로 가는지 매번 확인해야 합니다.

    ECS 가 데이터를 두는 방식

    ECS 는 세 가지로 나눕니다.

    • 엔티티: 번호표입니다. 그 자체로는 아무 데이터도 없습니다
    • 컴포넌트: 순수한 데이터입니다. 위치, 속도, 체력. 메서드가 없습니다
    • 시스템: 로직입니다. “이런 컴포넌트를 가진 것들” 을 훑으면서 일합니다

    그리고 같은 컴포넌트 구성을 가진 엔티티끼리 한 덩어리에 모아 둡니다. 그 구성을 아키타입이라 하고, 그 덩어리를 청크라 합니다.

    구성이 제각각인 엔티티 위치 이동 체력 위치 체력 위치 이동 체력 사망 위치 이동 체력 위치 체력 위치 이동 체력 같은 구성끼리 아키타입 = 구성이 같은 것들의 묶음 A · 위치 이동 체력 B · 위치 이동 체력 사망 C · 위치 체력 묶음 하나가 메모리 블록 하나입니다. 이 블록을 청크라 부릅니다. "이동할 것 전부" 는 A 와 B 블록을 통째로 훑으면 됩니다. C 는 이동 컴포넌트가 없으니 열어 보지도 않습니다. 구성이 바뀌면 다른 묶음으로 옮겨 갑니다.
    엔티티를 종류로 나눈 것이 아니라 데이터의 모양으로 나눈 것입니다.

    청크 안에서 위치는 위치끼리, 속도는 속도끼리 배열로 붙어 있습니다. 그러니 “모든 적의 위치를 갱신하라” 는 그 배열을 앞에서 뒤로 훑는 일이 됩니다.

    여기서 두 가지가 더 열린다

    이게 이 방식의 진짜 값어치입니다. 캐시 효율은 시작일 뿐입니다.

    하나. 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만 마리짜리 무쌍 게임을 실제로 만들면서 잰 것입니다.