Unity

[Unity] DOTS 시스템과 Unity JobSystem, Burst, ECS 개념

usingsystem 2024. 8. 30. 14:43
728x90

DOTS (Data Oriented Technology Stack)란?

DOTS는 Unity에서 제공하는 데이터 지향 기술 스택으로, 기존의 객체 지향 프로그래밍(OOP) 방식 대신 데이터 지향 디자인(DOD)을 채택하여 메모리 사용성을 향상시키고, 성능을 극대화하는 것을 목표로 합니다. DOTS는 엔티티와 네이티브 컬렉션을 사용하여 런타임이나 가비지 컬렉터에 의해 관리되지 않는 메모리를 직접 다루며, CPU와 메모리 간의 접근 효율성을 높입니다.

 

DOTS의 주요 목표:

  1. 메모리 최적화:
    • 일관된 메모리 배치: OOP 방식에서는 객체와 데이터가 메모리에 산발적으로 배치되어 있어 캐시 적중률이 낮아집니다. 반면, DOTS는 데이터와 로직을 분리하고, 컴포넌트 데이터를 연속된 메모리 블록에 저장합니다. 이를 통해 CPU의 캐시(L1, L2, L3) 적중률을 극대화하여, 메모리 접근 속도가 향상됩니다. 특히, 캐시 적중 시 데이터를 매우 빠르게 읽을 수 있어 전체 시스템의 성능을 높이는 데 기여합니다.
    • 캐시 효율성 향상: 데이터가 일관되게 메모리에 배치되면 CPU가 데이터를 더 자주 캐시에서 찾을 수 있어, 메모리 접근 시간이 크게 줄어듭니다. 이로 인해 고속의 메모리 접근이 가능해지고, 연산 효율성이 높아집니다.
  2. 멀티스레드 최적화:
    • 멀티스레드 처리: Unity의 기존 MonoBehaviour 방식에서는 단일 스레드에서 모든 오브젝트 업데이트가 이루어졌습니다. 이는 복잡한 연산이나 다수의 오브젝트를 처리하는 데 한계가 있었습니다. DOTS는 Job System을 통해 연산을 멀티스레드로 분산 처리하며, 모든 CPU 코어를 효과적으로 활용합니다. 이를 통해 각 스레드가 독립적으로 작업을 수행하고, 동시에 여러 연산을 처리하여 성능 병목을 줄입니다.
    • 병렬 처리의 효율성: 멀티스레드 환경에서 연산이 병렬로 처리되면, 동일한 시간 내에 더 많은 작업을 수행할 수 있어 전반적인 성능 향상 효과를 볼 수 있습니다. 예를 들어, 대규모 물리 연산이나 복잡한 AI 계산을 병렬로 처리하여 게임의 실시간 성능을 높입니다.
    • 메인 스레드 업데이트: 멀티스레드에서 분산 처리된 연산 결과는 최종적으로 Unity의 메인 스레드에서 한 번에 업데이트됩니다. 이 접근 방식은 메인 스레드가 병목 지점이 되는 것을 방지하고, 복잡한 로직도 효율적으로 관리할 수 있도록 합니다.
  3. 성능 최적화:
    • Burst Compiler: DOTS는 Burst Compiler를 통해 고성능의 네이티브 코드를 생성합니다. 이는 컴파일 단계에서 코드의 최적화를 극대화하여, 실행 속도가 매우 빠른 코드를 생성합니다. Burst Compiler는 특히 벡터 연산이나 수치 계산에 강점을 가지고 있어, 게임 내 복잡한 수학적 연산을 최적화하는 데 유리합니다.
    • 저수준 최적화 기술: SIMD(Single Instruction, Multiple Data)와 같은 저수준 최적화 기법을 사용하여, 동일한 연산을 여러 데이터에 동시에 적용할 수 있습니다. 이 기법은 그래픽 연산이나 물리 계산 등에서 성능을 크게 향상시킵니다. 또한, 메모리 접근을 최소화하고 연산을 단순화하여, 반복적인 계산을 더 빠르게 수행할 수 있습니다.

DOTS의 주요 이점:

  • 데이터 지향적 설계 철학: DOTS는 성능 최적화를 넘어서, 데이터를 중심으로 설계하여 코드의 가독성과 유지보수성을 크게 개선합니다. 이는 데이터와 로직이 명확하게 분리되어 있어, 코드 수정 시 영향 범위를 쉽게 파악할 수 있으며, 유지보수가 쉬워집니다.
  • 확장성과 유지보수성: ECS 구조는 시스템을 확장 가능하고 모듈화된 방식으로 구성할 수 있게 하여, 대규모 프로젝트에서도 코드 관리가 용이합니다. 이로 인해 새로운 기능을 추가하거나 기존 기능을 변경할 때도 최소한의 수정만으로 대응할 수 있습니다.
  • 예측 가능한 성능: DOTS는 데이터 레이아웃과 처리 흐름을 명확하게 정의하여, 성능을 예측 가능하게 하고 최적화할 수 있는 환경을 제공합니다. 이를 통해 개발자는 성능 병목을 사전에 파악하고, 필요한 부분을 적절히 개선할 수 있습니다.

DOTS 등장 배경과 필요성:

DOTS는 Unity에서 기존 객체 지향 프로그래밍(OOP)과 MonoBehaviour 방식의 복잡성과 성능 문제를 해결하기 위해 도입되었습니다. OOP 방식은 대규모 프로젝트에서 상속 구조의 복잡성, 상태 관리의 어려움, 객체 간의 의존성 문제 등으로 인해 유지보수가 어렵고 성능 저하를 초래합니다. 특히, MonoBehaviour 방식의 비결정적 실행 순서와 메모리 접근의 비효율성은 성능에 큰 영향을 미칩니다.

  • OOP와 MonoBehaviour의 한계:
    • OOP의 한계: OOP의 상속 남용과 상태 관리의 어려움은 코드 유지보수를 어렵게 하고, 디버깅이 복잡해지며, 객체 간 상호작용의 불명확성으로 인해 버그 발생 가능성을 높입니다.
    • MonoBehaviour의 한계: 비결정적 실행 순서는 의도한 로직의 예측 불가능성을 초래하고, 객체 지향 특성으로 인한 데이터 접근의 비효율성은 캐시 미스 증가와 성능 저하로 이어집니다. 또한, 단일 스레드 실행 모델로 인해 멀티코어 CPU의 성능을 충분히 활용하지 못해 성능 병목이 발생합니다.

성능 최적화의 필요성:

게임 개발에서는 성능 최적화가 필수적입니다. 특히, 고사양 PC에서 원활하게 실행되는 게임도 저사양 모바일 기기에서는 성능 문제가 발생할 수 있으며, 프레임 지연, 긴 로딩 시간 등이 포함됩니다. 이러한 성능 저하의 주요 원인으로는 렌더링 이슈, 비효율적인 물리 시뮬레이션, 그리고 CPU 코드의 비효율성 등이 있으며, DOTS는 이러한 문제를 효과적으로 해결할 수 있습니다.

  • Unity에서의 구체적인 성능 문제:
    • 가비지 컬렉션 오버헤드: Unity의 자동 메모리 관리로 인한 가비지 컬렉션은 성능 저하와 끊김 현상을 유발할 수 있습니다. DOTS는 이러한 메모리 관리 오버헤드를 줄이기 위해 엔티티와 컴포넌트를 명시적으로 관리합니다.
    • 비최적화된 컴파일러 코드: Unity의 Mono 컴파일러와 IL2CPP의 최적화 수준 차이는 성능에 직접적인 영향을 미칩니다. DOTS의 Burst Compiler는 이러한 문제를 해결하여, 최적화된 네이티브 코드를 제공합니다.
    • 멀티코어 비효율성: 많은 Unity API와 이벤트 함수가 메인 스레드에서 실행되어 멀티코어 활용이 제한적입니다. DOTS는 멀티스레드 지원을 기본으로 하여, 병렬 처리를 통해 CPU 자원을 최대한 활용할 수 있습니다.
    • 비캐시 친화적 데이터와 코드: 데이터가 메모리에 비효율적으로 분산되어 있어 캐시 미스가 잦고, 코드의 과도한 추상화로 인해 최적화가 어렵습니다. DOTS는 이를 해결하기 위해 데이터 중심의 설계를 도입하여, 캐시 효율성을 극대화합니다.

DOTS의 구성하는 기능

이 기술들은 독립적이기 때문에 반드시 함께 사용할 필요는 없다. 예를 들어 Jobs, Burst를 사용할 때 ECS와 반드시 사용할 필요는 없다. 하지만 이 세 가지 기술을 함께 사용하면 DOD관점에서 궁합이 좋다. 

  1. Jobs System: 멀티스레딩을 쉽게 사용할 수 있게 도와주는 시스템입니다. CPU의 여러 코어를 활용하여 작업을 병렬 처리함으로써, 성능을 극대화합니다.
  2. Burst Compiler: C# 코드를 네이티브 기계어로 컴파일하여 실행 성능을 크게 향상하는 컴파일러입니다. Burst Compiler는 ECS와 Jobs System에서 작성된 코드를 최적화하는 데 중요한 역할을 합니다.
  3. ECS (Entity Component System): DOTS의 핵심 요소로, 데이터와 로직을 분리하여 성능 최적화를 이루는 구조입니다. 엔티티, 컴포넌트, 시스템으로 구성되어 있으며, 게임 객체의 관리와 동작을 효율적으로 처리합니다.

C# 잡 시스템 ( Job System )

// 두 배열의 요소를 곱하는
// 간단한 예시 잡.
// IJob을 구현하면 구조체가 잡 타입이 됩니다.
struct MyJob : IJob
{
// NativeArray는 ‘관리되지 않으므로’
// 가비지를 만들지 않습니다.
public NativeArray<float> Input;
public NativeArray<float> Output;
// Execute 메서드는
// 잡 시스템이 이 잡을 실행할 때 호출됩니다.
public void Execute()
{
// Output 배열의 모든 값에
// Input 배열의 대응하는 값을 곱합니다.
for (int i = 0; i < Input.Length; i++)
{
Output[i] *= Input[i];
}
}
}

MonoBehaviour 업데이트는 메인 스레드에서만 실행되므로 모든 게임 로직을 하나의 CPU코어에서만 실행되게 된다. 직접 스레드를 추가해서 사용한다면 안전성과 효율적으로 수행시키기 상당히 어려움이 있다. Job System에서는 모든 cpu코어를 활용할 수 있는 멀티스레드 코드를 간단하고 효율적으로 작성하는 데 필요한 수단을 제공한다.

 

잡시스템은 추가 코어마다 하나씩 늘어나는 워커 스레드 풀을 사용한다. 예를 들어 pc에 8코어가 존재한다면 unity는 1개의 메인 스레드와 7개의 워커 스레드를 생성하게 된다. 워커 스레드는 잡이라는 단위의 작업을 실행하며 잡큐와 같은 방식으로 잡 대기열에 채워지며 메인 스레드에서만 잡 대기열에 추가할 수 있다.(다른 잡에서 스케줄링 불가) 또 한 오직 메인 스레드에서만 Coplete() 메서드를 사용하여 호출 가능하며 잡 실행이 완료될 때까지 대기한다. 완료가 되면 Complete() 메서드가 반환되어 다시 메인스레드에서 안전하게 액세스 하고 다음 예약된 잡을 안전하게 수행한다.

 

잡은 다른 잡이나 메인 스레드와 데이터를 공유할 수 있다. 공유가 가능하기 때문에 이는 데이터의 우위를 갖는 경쟁 상태를 방지해야 하므로 데이터를 공유하는 잡이 동시에 실행되면 안 된다.(다른 잡과 충돌할 가능성이 있다면 안전성 검사에서 오류가 발생)  이런 문제를 해결하기 스케줄링된 잡간에 종속성을 부여할 수 있다.

 

해당 잡은 메모리에서 데이터를 처리하기 위한 용도로 디자인되었기 때문에 파일 읽기/쓰기 네트워크상의 데이터 전송/수신작업을 수행하는 용도가 아니다.

 

잡시스템 NativeContainer 설명

https://usingsystem.tistory.com/544

 

[Unity] JobSystem(2) NativeContainer VS TransformAccessArray

Job System과 Native Collections 특징Native collections는 성능 최적화와 메모리 관리 효율성을 위해 제공하는 데이터 구조입니다. 이들은 기본적으로 관리되지 않는 메모리(heap)에 저장되어 있으며, 높은

usingsystem.tistory.com

 

버스트 컴파일러 ( Burst Compiler )

// BurstCompile 속성이 이 잡을 버스트 컴파일하도록 지정합니다.
[BurstCompile]
struct MyJob : IJob
{
public NativeArray<float> Input;
public NativeArray<float> Output;
public void Execute()
{
for (int i = 0; i < Input.Length; i++)
{
Output[i] *= Input[i];
}
}
}

버스트 컴파일러는 잡시스템과 함께 사용하여 애플리케이션의 성능을 향상시키고 개선하는 코드를 생성하는 컴파일러이다.

 

Unity에서 C#코드는 기본적으로 Mono를 통해 컴파일이 되거나 런타임 성능에서 일부 타깃 플랫폼에서 더 잘 지원되는 IL2CPP가 사용된다. IL2CPP는 빌드시간이 상당히 많이 소요된다. 이 2가지 컴파일보다 코드를 더욱 최적화할 수 있는 방법으로 버스트 컴파일러가 있다. 버스트 컴파일러는 많은 계산이 필요한 상황에서 성능과 확장성을 크게 증가시킬 수 있으며 LLVM 컴파일을 사용하는 최적화된 네이티브 CPU 코드로 개발코드를 변환한다.

 

하지만 버스트 컴파일러는 부분적인 C# 코드에서만 적용되어 컴파일될 수 있다. 즉 모든 클래스 인스턴스를 비롯하여 관리되는 오브젝트에 액섹스할 수 없다는 한계가 존재한다.

ECS ( Entity Component System )

먼저 ECS에는 Sparse Set과 Archetype 2가지 접근 방식이 존재한다. Sparse Set은 컴포넌트 접근과 메모리 사용이 단순하고 빠른 방식인 반면, Archetype은 메모리 레이아웃을 최적화하여 캐시 효율성을 높이고 성능을 극대화하는 접근 방식입니다. Unity ECS에서는 Archetype을 사한다.

Sparse Set

  • 구조: 각 컴포넌트는 별도의 배열에 저장되며, 엔티티 ID를 인덱스로 사용하여 컴포넌트를 빠르게 조회합니다. Structure 별로 묶는다.
  • 장점: 간단한 구조로 인해 특정 컴포넌트에 대한 접근 속도가 매우 빠르며, 개별 컴포넌트의 추가 및 제거가 쉬움.
  • 단점: 컴포넌트가 분산되어 있어 메모리 접근이 비효율적일 수 있으며, 캐시 미스가 발생할 가능성이 높음.

Archetype

  • 구조: 동일한 구성의 엔티티들이 하나의 메모리 블록에 함께 저장됩니다. 이는 엔티티를 컴포넌트 조합별로 그룹화하는 방식입니다. (같은 조합 별로 묶는다.)
  • 장점: 메모리 레이아웃이 최적화되어 캐시 효율이 높아지고, 특정 엔티티 그룹에 대한 작업 시 매우 빠른 접근이 가능함.
  • 단점: 특정 컴포넌트를 추가하거나 제거할 때 메모리 재배치가 필요할 수 있어 관리가 복잡할 수 있음. (오브젝트가 변화하면 다른 Achetype으로 복사가 되어야 한다.)

 

Unity ECS는 ArchetType 방식을 사용하며 Entity, 데이터를 나타내는 Component, 코드를 나타내는 System으로 구성된 DO(data oriented) 프로그래밍 디자인이다. 엔티티는 컴포넌트로 구성되며 각 컴포넌트는 c#의 구조체로 이루어진다. 기존의 게임 오브젝트와 마찬가지로 엔티티는 수명이 지속되는 동안 컴포넌트가 추가되거나 제거될 수 있다. 하지만 게임 오브젝트와 다르게 엔티티의 컴포넌트는 자체 메서드를 가지지 않고 대신 ECS의 각 시스템에는 대체로 프레임마다 한 번 호출되는 업데이트 메서드가 존재하여 컴포넌트를 읽고 수정한다. 예를 들어 Moster로 구성된 게임에 MonsterMoveSystem의 업데이트 메서드가 각 모든 Monster 엔티티의 Transform 컴포넌트를 수정한다.(하나의 업데이트 매니저가 있는 느낌.)

 

ECS 예제

// 간단한 예시 시스템.
public partial struct MonsterMoveSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// LocalTransform, Velocity 및 Monster 컴포넌트로 구성된
// 모든 엔티티를 순회하는 쿼리
foreach (var (transform, velocity) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<Velocity>>()
.WithAll<Monster>())
{
// deltaTime을 감안한 속도로
// 트랜스폼 위치 업데이트
transform.ValueRW.Position +=
velocity.ValueRO.Value * SystemAPI.Time.deltaTime;
}
}
}

잡 시스템 연동

엔티티 액세스를 위한 두 개의 특별한 잡 타입으로 IJobChunk와 IJobEntity가 있습니다.

// IJobEntity를 스케줄링하는 간단한 예시 시스템.
public partial struct MonsterMoveSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// 잡 생성 및 스케줄링 .
var job = new MonsterMoveJob {
DeltaTime = SystemAPI.Time.DeltaTime
};
job.ScheduleParallel();
}
}
// LocalTransform, Velocity 및 Monster 컴포넌트로 구성된
// 모든 엔티티를 처리하는 버스트 컴파일된 잡
[WithAll(typeof(Monster))]
[BurstCompile]
public partial struct MonsterMoveJob : IJobEntity
{
public float DeltaTime;
// LocalTransform을 수정하기 위해 ‘ref’ 사용.
// Velocity만 읽고 싶으므로 ‘in’ 사용.
public void Execute(ref LocalTransform, in Velocity)
{
transform.Position += velocity.Value * DeltaTime;
}
}

 

언제 DOTS가 필요할까? 꼭 써야만 할까?

만약 코드에서 CPU 병목 현상이 발생하는 경우 버스트 컴파일된 잡으로 다시 구현해 보자. 버스트 컴파일된 코드는 Mono또는 IL2CPP로 컴파일된 코드보다 몇 배 더 빠르게 실행되며 잡을 통해 CPU의 모든 코어를 사용할 수 있으며 기존 프로젝트를 비교적 쉽게 변경할 수 있다.

 

DOTS는 CPU의 효율만 향상하므로 GPU에서 병목 현상을 개선해주지는 않는다.

 

 

참고

https://unity.com/kr/dots

 

DOTS - Unity의 데이터 지향 기술 스택

Unity의 데이터 지향 기술 스택(DOTS)은 Unity에서 게임을 구축하기 위한 데이터 지향 디자인 접근 방식을 제공하는 기술과 패키지의 조합입니다.

unity.com

https://unity.com/kr/resources/introduction-to-dots-ebook?utm_source=eloqua&utm_medium=eDM&utm_campaign=kr_newsletter_2408

 

고급 Unity 개발자를 위한 데이터 지향 기술 스택 | Unity

이 가이드의 내용을 바탕으로 DOTS가 현재 진행 중인 프로젝트에 어울릴지 팀원들과 함께 고민해 보세요.

unity.com

https://www.youtube.com/watch?v=H7zAORa3Ux0

 

https://www.youtube.com/watch?v=7UphiG8UtTg

 

https://www.youtube.com/watch?v=anoA9d2vn9A

 

728x90