이번에 프로젝트에서 최적화가 필요해 Unity에서 C# Job System과 Burst Compiler를 사용하게 된 일이 있었습니다. 그 내용에 대해 제가 공부한 바를 정리해 놓은 글을 작성하려 합니다. 오류가 있다면 자유롭게 지적 부탁드립니다!
1. Job System
Job이란?
유니티에서 멀티스레드 프로그램을 손쉽게 작성할 수 있는 방법.
일반적인 멀티스레드와 차이점이 있다면, 유니티 자체 시스템을 활용하기 때문에 별도로 스레드를 생성하지 않고 유니티에서 지원하는 워커 스레드를 가져와 사용한다.
다만 워커 스레드는 중간에 중단할 수 없기 때문에, System.Thread.Sleep에 대응되는 기능이 없다는 단점이 있다.
IJob, IJobParallel을 상속받는 잡을 만들어서, 이를 이용해서 유니티 내에서 안전하게 멀티스레드를 굴릴 수 있다.
IJob: 단일 작업을 수행하는 클래스
IJobParallelFor: 복수의 작업을 처리하는 클래스. 보통 배열의 요소들을 병렬적으로 처리할 때 사용한다.
[BurstCompiler]
public struct SquaresJob : IJob
{
public NativeArray<int> Nums;
public void Execute()
{
for (int i = 0; i < Nums.Length; i++)
{
Nums[i] *= Num[i];
}
}
}
이때 Job은 Execute(void) 나(IJob), Execute(int index) 를 사용하여(IJobParallel) 실행한다.
이때 보통 [BurstCompiler] 어트리뷰트를 같이 붙인다. 그 이유는, 이를 붙이면 버스트 컴파일러를 통해 C++ 단에서 컴파일하여 더 효율적으로 처리하기 때문이다. 다만 문제가 있는데 그건 다음 글(Burst compiler)에서 정리해 놓겠다.
Job을 실행할 땐 Job의 Schedule 함수를 호출해야 한다.
// Job 생성
void job = new SquaresJob { Nums = myArray };
// Job 스케줄링
JobHandle handle = job.Schedule();
// Job 시뮬레이션
handle.Complete();
- Job을 생성한다.
- Job을 스케줄링한다. 스케줄링하게 되면 Global Job Queue에 Job을 삽입한다. 그러면 작업을 수행할 만한 빈 worker thread를 큐에서 반환한다.
- Job.Complete를 호출한다. 모든 Job은 Complete를 호출해줘야 한다. Job이 종료될 때까지 기다려야 한다. 이때 케이스는 세 가지이다.
잡을 완료하고 나면 메인스레드는 반드시 Job Handle에서 반환하는 핸들러를 통해 Complete를 호출해야 한다.
- 이미 Complete을 호출하기 이전에 Job을 완료했다면, Complete는 Job이 끝날 때까지 기다린다. 이때 Complete는 Job이 실행을 마칠 때까지 끝나지 않는다. 그래서 보통 Main 스레드에서 Complete를 통해 흐름을 조절할 수 있다.
- Complete는 job 큐에서 모든 레코드를 제거한다. 그래서 complete는 resource leak을 피하도록 주의해야 한다.
- 주의점: 오직 메인 스레드만이 Job을 Schedule하고 Complete할 수 있다.
Job과 종속성
- 메인 스레드는 Job에서 처리 중인 데이터에 접근할 수 없다.
- 비슷하게 둘 이상의 Job이 동일한 데이터에 접근하려고 한다면 예외가 발생한다. Race Condition이 발생할 수 있기 때문.
var job = new SquaresJob { Nums = myArray };
var otherJob = new SquaresJob { Nums = myArray };
JobHandle handle = job.Schedule();
// safety check exception!
JobHandle otherHandle = otherJob.Schedule();
- 이 경우, 아래처럼 handle.Complete()를 먼저 호출한 후 두 번째 job을 호출하면 해결된다. 그러나 문제는 Complete로 인해 메인 스레드는 그동안 스레드 시간을 낭비하게 된다.
var job = new SquaresJob { Nums = myArray };
var otherJob = new SquaresJob { Nums = myArray };
JobHandle handle = job.Schedule();
handle.Complete();
// OK
JobHandle otherHandle = otherJob.Schedule();
otherHandle.Complete();
- 더 나은 방법으로 아예 종속성(Dependency)을 설정할 수 있다.
JobHandle handle = job.Schedule();
// 종속성으로 인해 OK
JobHandle otherHandle = otherJob.Schedule(handle);
handle.Complete();
otherHandle.Complete();
- Job Dependencies는 작업 순서를 지정할 수 있다.
- 일반적으로는 위와 같이 하나의 dependency만 지정한다.
- 다만, 다중으로 dependency를 지정할 수도 있다. 여러 개의 Job이 끝날 때까지 기다린 후 작업을 시작하는 것이다. 기본적으로 파라미터를 하나만 받기 때문에, Combine 함수를 써야 한다.
JobHandle handleB = jobB.Schedule();
JobHandle handleC = jobC.Schedule();
JobHandle handleD = jobD.Schedule();
JobHandle combined = JobHandle.CombineDependencies(handleB, handleC, handleD);
JobHandle handleA = jobA.Schedule(combined);
위의 경우 A는 B, C, D가 모두 끝날 때까지 실행할 수 없지만, B, C, D는 서로의 Job을 기다리지 않고 실행할 수 있다.
아니면 아예 위처럼 상당히 복잡한 종속성 그래프를 그릴 수도 있다.
- 알아두면 좋을 점은, 모든 Job에 대해 Complete를 호출할 필요는 없다는 점이다. 위 그래프에서는 마지막 Job에 해당하는 E, F, I에 대해서만 Complete를 호출해주면 된다.
- 물론 이미 완료된 Job에 대해서는 Complete를 호출해도 아무런 일도 하지 않으니 걱정할 필요는 없다(중복해서 여러 번 호출해도 별로 문제는 없다).
- 위와 같이 Job이 서로가 서로를 참조하는 구조가 나오지 않을까 걱정할 수 있는데, 그렇지는 않다.
- 이 경우 Deadlock이 발생해서 끝나지 않게 된다.
- 그러나 그럴 일은 없다. 다음 두 규칙 덕분이다.
배열을 처리하는 IJobParallelFor
- 배열이나 리스트를 처리하는 Job은 병렬적으로 처리할 수도 있다.
- Squares 예시에서는 모든 연산이 병렬적으로 처리가 가능한데, 단순히 하나의 처리를 여러 개의 Job으로 나누어도 되지만 IJobParallelFor로 한번에 처리하는 방법도 있다.
[BurstCompiler]
public struct SquaresJob : IJobParallelFor
{
public NativeArray<int> Nums;
public void Execute(int index)
{
Nums[index] *= Num[index];
}
}
- IJobParallelFor는 배열의 모든 인덱스마다 Execute를 호출한다. 이때 위와 같이 int 형태의 인덱스를 전달받는다.
- 다만 배열에서 다른 index를 참고하려 하면 safety check exception이 발동하니 주의하자.
// Job 초기화
var job = new SquaresJob { Nums = myArray };
// Job 스케줄링 및 대기
JobHandle handle = job.Schedule(arr.Length, 100);
handle.Complete();
- Schedule을 할 때 배열의 길이와 batch size를 전달한다. 이렇게 전달하면, 전달한 batch size에 맞게 index를 쪼개어 작업을 나누게 된다.
- 이때 각 배치는 독립된 스레드에서 병렬적으로 Job을 처리하게 된다.
- batch size는 특별한 값이 있는 것이 아니기 때문에 적절한 값을 선택해야 한다. 환경에 따라 얼마가 괜찮을지 알 수 없기 때문에 실험적으로 너무 작지도, 너무 크지 않은 값을 찾는 작업이 필요하다.
예를 들어 1,000만 개의 정수를 처리하는 데에 batch size = 100으로 설정했을 때 처리 시간은 다음과 같다.
job 각각을 실행하는 데에는 3ms의 시간이 걸렸지만 8개 코어로 나누어서 처리하였으므로 총 CPU 타임은 총 24ms가 걸렸다.
분명 이렇게 보면 멀티스레드의 승리처럼 보인다.
하지만 과연 그럴까? Batch Size를 배열의 길이랑 똑같이 했을 때, 이것보다 느릴지는 직접 확인해 봐야 한다.
// 배치 크기를 배열 길이랑 똑같게 해보자.
JobHandle handle = job.Schedule(arr.Length, arr.Length);
그런데 batch size를 배열 길이와 동일하게 설정해도 결과는 똑같게 나온다.
이렇게 되면 싱글 코어에서 처리하는 것이지만, 결과는 크게 다르지 않았다.
- 해당 연산에서 진짜 병목점이 되는 부분은 바로 ‘메모리 접근 시간’이다.
- 반면, 메모리 접근 시간보다 연산에 필요한 시간이 더 긴 경우에는 병렬로 처리하는 것이 더 유리할 수 있다.
이 둘을 선택하는 것은 프로그래머의 판단을 필요로 하는 것 같다.
참고 자료
동영상 The Unity Job System - Brian Will(Unity-Technologies/EntityComponentSystemSamples 내 설명 자료)
C# Job System - 캐니, 네이버 블로그
'Unity3D > API & Library' 카테고리의 다른 글
Unity DOTS 기술 정리: 2. Burst Compiler (0) | 2025.09.06 |
---|---|
deltaTime은 언제 계산될까? (2) | 2023.07.15 |
ScriptableObject를 저장하는 법: EditorUtility.SetDirty(Object target) (4) | 2021.03.17 |
댓글