유니티 DOTS 연구2 : Job System(잡 시스템)

이전 글

2023.02.23 - [Unity 연구/잡다한연구] - 유니티 DOTS 연구1 : Burst Compiler (버스트 컴파일러)

 

 

Job System

앞서 살펴본 대로 잡 시스템은 유니티 환경에서 멀티 쓰레드를 안전하게 사용하기 위해 만들어진 기능이다.

버스트 컴파일러와 같이 사용하면 성능 개선을 기대할 수 있다.

유니티 콘솔을 사용해 디버깅도 가능하다는 것이 장점.

NativeContainer라는 공유 메모리 타입을 사용하기 때문에 Race Condition 문제를 방지한다.

 

단일 Job 기본 사용법

1. IJob을 구현하는 struct를 만든다. Job을 사용하면 실행 중인 다른 잡과 병렬로 실행되는 단일 잡을 예약할 수 있다.

    - (버스트 컴파일러 사용 시) BurstCompile 애트리뷰트를 명시한다

2. Execute 함수 내부에 처리할 로직을 작성한다.

    - (버스트 컴파일러 사용 시) 버스트로 컴파일 할 수 없는 자료형이나 로직은 넣지 않도록 하자.

[BurstCompile]
public struct MyJob : IJob
{
    public void Execute(){ };
}

참고: 잡을 디자인할 때는 데이터 복사본에서 동작한다는 점을 기억해야한다.

(NativeContainer의 경우는 예외인데, 제어 스레드에서 잡의 데이터에 액세스하는 유일한 방법은 NativeContainer에 작성하는 것이다.)

참고 : 잡에서 관리되는 메모리를 할당하면 속도가 매우 느려지고, 잡이 Unity Burst 컴파일러를 충분히 활용하여 성능을 향상할 수 없다.

 

 

 

NativeContainer

잡의 결과가 격리되는 문제점을 해결하기 위해 만들어진 공유 메모리 타입이라는 컨테이너이다.

이를 이용하면 메인 스레드와 공유되는 데이터에 액세스할 수 있다.

 

잡 내부에는 레퍼런스 타입인 System.Collection.Generic.List<T> 등을 넣은 경우 오류가 발생한다.

다행히 기존 컨테이너들과의 연산자는 잘 오버로딩 되어있다.

NativeArray<T>는  Unity.Collections네임 스페이스에 위치하며,

추가적으로 아래의 네 개의 컨테이너가 존재한다.

 

NativeList<T>

NativeHashMap<T>

NativeMultiHashMap<T>

NativeQueue<T>

 

ReadOnly 읽기전용 애트리뷰트 설정하기

컨테이너는 읽기-쓰기를 모두 수행할 수 있는데, 읽기전용으로 제한 할 경우 약간의 성능 상승 효과가 있다고 한다.

[ReadOnly]
public NativeArray<int> input;

 

레퍼런스 반환 부재

레퍼런스 반환의 부재로 인해 NativeContainer의 콘텐츠를 직접 변경할 수 없다.

nativeArray[0]++;는 nativeArray에서 값을 업데이트하지 않는 var temp = nativeArray[0]; temp++;를 작성하는 것과 동일하다. 대신에 인덱스의 데이터를 로컬 임시 복사본으로 복사하고 해당 복사본을 수정한 후 다음과 같이 다시 저장해야 한다.

MyStruct temp = myNativeArray[i];
temp.memberVariable = 0;
myNativeArray[i] = temp;

 

NativeContainer 할당자

컨네이터를 만들 때에는 메모리 할당 타입도 커스텀 할 수 있다.

최고의 성능을 위해서는 알맞는 할당자를 선택해야 한다.

 

대부분의 경우 TempJob 을 선택하면 된다.

// Temp : 할당 속도 빠름, 한 프레임 이하 수명, NativeContainer 할당을 전달하면 안된다.
new NativeArray<float>(array, Allocator.Temp);
new NativeArray<float>(array.Length, Allocator.TempJob);

// Temp : 할당 속도 보통, 4 프레임 이하 수명
// 반드시 4프레임 내에서 Dispose 해야함, 대부분의 경우에 사용하면 됨.
new NativeArray<float>(array, Allocator.TempJob);

// Temp : 할당 속도 가장느림, 애플리케이션 전체에 걸쳐 필요한 경우
new NativeArray<float>(array, Allocator.Temp);

 

다음은 작성한 코드의 예시.

[BurstCompile]
public struct BurstTest : IJob // 클래스가 아니라 구조체!
{
    [ReadOnly] public NativeArray<float> input;
    public NativeArray<float> output;

    public BurstTest(float[] array)
    {
        // 첫번째 인자로 기본형 array가 올 수도있다.
        input = new NativeArray<float>(array, Allocator.TempJob);
        // 첫번째 인자로 array의 길이가 올 수도 있다.
        output = new NativeArray<float>(input.Length, Allocator.TempJob);
    }

    public void Execute()
    {
        for (int i = 0; i < input.Length; i++)
            output[i] = output[i] = Mathf.Pow(input[i], 2) * Mathf.Abs(input[i]) - Mathf.Ceil(input[i]);
    }

    public void Dispose()
    {
        input.Dispose();
        output.Dispose();
    }
}
유니티 메뉴얼 : NativeContainer
https://docs.unity3d.com/kr/2022.1/Manual/JobSystemNativeContainer.html

 

 

잡 예약하기

만든 잡을 수행하려면 직접 Execute를 호출하는 것이 아니라 Schedule 메서드를 사용해야한다.

Schedule을 호출하면 잡 대기열에 들어가며, 수행가능할때 실행된다. 예약된 잡은 인터럽트할 수 없다.

// 공유 컨테이너는 같은 메모리를 사용하기 때문에 밖에서 만들어서 넘겨도된다.
NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

// 잡 만들기
MyJob jobData = new MyJob();
jobData.a = 10;
jobData.b = 10;
jobData.result = result;

// 잡 예약하기
JobHandle handle = jobData.Schedule();

// 메인 스레드가 대기함
handle.Complete();

// 대기가 끝났으면 데이터를 바로 사용해도된다.
float aPlusB = result[0];

// 물론 별도로 Dispose는 필요하다.
result.Dispose();

 

JobHandle 및 종속성

특정 잡이 완료되었을 때 실행되게 제어도 가능하다.

스케줄을 호출할 때 JobHandle만 넘겨주면 된다.

 

다음의 예에서는 firstJob이 끝난뒤에, secondJob이 실행된다.

JobHandle handle = firstJob.Schedule();
secondJob.Schedule(handle);

단일 말고 여러개로 넘길 수도있다.

NativeArray<JobHandle> handles = new NativeArray<JobHandle>(numJobs, Allocator.TempJob);
handles.Add(//잡을 넣자);
JobHandle jh = JobHandle.CombineDependencies(handles);

 

 

ParallelFor 잡

수 많은 오브젝트에 대해 동일한 작업을 하는 경우에 사용할 수 있는 잡 타입

ParallelFor 잡은 여러 개의 코어에서 동작하며, 코어당 하나의 잡을 수행한다.

단일 Execute가 아니라 데이터 소스의 항목당 하나의 Execute 메서드를 호출한다. Execute 메서드에는 정수 파라미터가 있다. 이 인덱스는 잡 구현 내에서 데이터 소스의 단일 요소에 액세스하여 동작한다.

 

이전 글에서 작성했던 같은 테스트를 병렬로 작성했다.

[BurstCompile]
public struct BurstParallelTest : IJobParallelFor
{
    [ReadOnly] public NativeArray<float> input;
    public NativeArray<float> output;

    public BurstParallelTest(float[] array)
    {
        input = new NativeArray<float>(array, Allocator.TempJob);
        output = new NativeArray<float>(input.Length, Allocator.TempJob);
    }

    public void Execute(int i)
    {
        output[i] = output[i] = Mathf.Pow(input[i], 2) * Mathf.Abs(input[i]) - Mathf.Ceil(input[i]);
    }

    public void Dispose()
    {
        input.Dispose();
        output.Dispose();
    }
}

 

 

ParallelFor 잡 예약

ParallelFor 잡을 예약할 때는 분할 하고싶은 데이터 소스의 길이를 지정해야한다. (배치 수)

하나의 잡이 먼저 완료되면 다른 네이티브 잡의 남은 배치를 절반 가져오는 식으로 균형을 맞춘다.

어느 정도가 좋은지는 알 수 없기 때문에 테스트를 거칠 수 밖에 없다.

 

var array = Enumerable.Repeat(2f, 50000000).ToArray();

var parallel = new BurstParallelTest(array);
JobHandle parallelJobHandle = parallel.Schedule(array.Length, 100); // index크기, 배치갯수
parallelJobHandle.Complete();

 

병렬은 단일 버스트보다 처리시간이 줄어든 것을 확인할 수 있다.

(좌) 버스트 병렬처리 / (우) 단일 버스트

 

트랜스폼 계산을 위한 IJobParallelForTransform도 별도로 존재한다.

아마 이건 Entities랑 같이 사용할 듯 하다.

 

유니티 메뉴얼 : Job System
https://docs.unity3d.com/kr/2022.1/Manual/JobSystemParallelForJobs.html

 

 

댓글

Designed by JB FACTORY