Unity 검색

Improving Job System performance scaling in 2022.2 – part 1: Background and API | Hero image
Improving Job System performance scaling in 2022.2 – part 1: Background and API | Hero image
공유

Is this article helpful for you?

Thank you for your feedback!

2022.2 및 2021.3.14f1에서는 Unity 잡 시스템의 스케줄링 비용과 성능 확장을 개선했습니다. 2부로 구성된 이 블로그 게시글에서는 병렬 프로그래밍과 잡 시스템을 간략히 살펴보고, 잡 시스템 오버헤드에 관해 이야기하며 이를 완화하기 위한 유니티의 접근 방식을 공유하고자 합니다.

1부에서는 병렬 프로그래밍과 잡 시스템 API에 대한 배경 정보를 알아봅니다. 이미 병렬 구조에 익숙하다면 이 글은 가볍게 훑어 보고, 2부로 넘어가셔도 됩니다.

병렬 구조의 배경 설명

2017.3 릴리스에서는 내부 C++ Unity 잡 시스템을 위한 공개 C# API를 추가하여 사용자가 비동기적으로 실행되는 '잡(job)'이라는 작은 함수를 작성할 수 있게 되었습니다. 기존의 일반 함수 대신 잡을 사용하는 목적은 메인 스레드에서 실행될 코드를 쉽고 안전하고 효율적으로 잡 워커 스레드에서, 이상적으로는 병렬로 실행할 수 있는 API를 제공하는 것입니다. 이를 통해 메인 스레드가 게임의 시뮬레이션을 완료하는 데 필요한 총 실제 시간(wall time)을 줄일 수 있습니다. CPU 작업에 잡 시스템을 사용하면 성능을 상당히 개선할 수 있으며, 게임이 실행되는 하드웨어가 개선됨에 따라 게임 성능도 자연스럽게 높아집니다.

계산이 유한한 리소스라고 생각하면, 하나의 CPU 코어는 주어진 시간에 정해진 계산 '작업'만 수행할 수 있습니다. 예를 들어 단일 스레드 게임에서 시뮬레이션 Update()가 16ms를 넘지 않아야 하는데 현재 24ms가 걸린다면, CPU가 해야 하는 일이 너무 많아서 더 많은 시간이 필요한 것입니다. 목표인 16ms를 달성하려면 게임의 최소 사양을 높이는 등의 방식으로 CPU의 처리 속도를 높이거나, 작업의 양을 줄이는 방법밖에 없습니다. 그러나 여기서 CPU 처리 속도를 높이는 방법은 일반적으로 권장되는 방법이 아닙니다.

void Update()
{
    // <lots of simulation logic...> 
}
An Update() function executing for 24ms on the Main Thread
메인 스레드에서 24ms 동안 실행되는 Update() 함수

결과적으로 8ms 분량의 계산 작업을 제거해야 합니다. 일반적으로는 알고리즘 개선, 여러 프레임에 하위 시스템 작업 분산, 개발 중에 누적될 수 있는 중복 작업 제거 등으로 계산 작업 제거가 가능합니다. 그래도 성능 목표를 달성하지 못하면, 한 번에 생성되는 적의 수를 줄이는 등 콘텐츠와 게임플레이를 줄여 게임 시뮬레이션의 복잡도를 낮춰야 할 수도 있습니다. 하지만 이러한 방식은 적합하지 않습니다.

만약 작업을 없애는 대신 다른 CPU 코어에 작업을 할당해서 실행하면 어떨까요? 요즘에는 대부분의 CPU가 멀티코어이기 때문에 사용할 수 있는 단일 스레드 계산 성능을 CPU의 코어 수로 곱하면 됩니다. 현재 Update() 함수에 있는 모든 작업을 안전하게 두 개의 CPU 코어로 나눌 수 있다면, 24ms가 걸리는 Update() 작업을 12ms가 걸리는 두 개의 청크로 동시에 실행할 수 있습니다. 그러면 작업이 목표치인 16ms보다 훨씬 빠르게 실행됩니다. 또한 작업을 4개의 병렬 청크로 분할하여 4개의 코어에서 실행할 수 있다면, Update()를 실행하는 데 6ms밖에 걸리지 않습니다.

이러한 유형의 작업 분할과 사용할 수 있는 모든 코어에서 작업을 실행하는 것을 성능 확장(performance scaling)이라고 합니다. 코어를 추가하면 코드를 변경하지 않아도 더 많은 작업을 병렬로 실행하여 Update()의 실제 시간을 줄일 수 있습니다.

void Update()
{
    // Some magic has split our logic into 4 equal parts
    // that can run in parallel. Wowee!
    PartialUpdateA();
    PartialUpdateB();
    PartialUpdateC();
    PartialUpdateD();
}
Update() has been divided into four partial updates each running on their own thread
각각 자체 스레드에서 실행되는 네 개의 부분 업데이트로 나뉜 Update()

하지만 이러한 방법은 환상에 지나지 않습니다. 어떠한 도움도 없이 Update() 함수를 여러 부분으로 나누어서 별도의 코어에서 실행하는 방법은 없습니다. 128코어가 있는 CPU를 사용한다 해도 두 CPU의 클럭 속도가 같다면 위에서 24ms가 걸리던 Update()를 계산하는 데 똑같이 24ms가 걸립니다. 엄청난 잠재력이 낭비되는 셈이죠. 그렇다면 어떻게 해야 사용 가능한 모든 CPU 코어를 활용하고 병렬 구조를 늘리도록 애플리케이션을 작성할 수 있을까요?

한 가지 방법은 멀티스레딩을 사용하는 것입니다. 이 경우 프로그램은 운영체제에서 실행하도록 예약된 함수를 실행하기 위해 스레드를 생성합니다. CPU에 여러 개의 코어가 있다면, 여러 스레드를 각각 코어에서 동시에 실행할 수 있습니다. 사용할 수 있는 코어보다 스레드가 더 많은 경우, 운영체제는 컨텍스트 전환이라는 프로세스를 거쳐 다른 스레드로 전환하기 전에 코어에서 어떤 스레드를 얼마나 오래 실행해야 하는지 결정해야 합니다.

그러나 멀티스레드 프로그래밍에는 여러 가지 복잡한 문제가 수반됩니다. 위의 완벽한 시나리오에서는 Update() 함수를 네 개의 부분 업데이트로 균등하게 나누었습니다. 하지만 현실은 그렇게 쉽지 않습니다. 스레드는 동시에 실행되기 때문에 동일한 데이터를 동시에 읽고 쓸 때 서로의 계산이 손상되지 않도록 주의해야 합니다.

여기에는 일반적으로 뮤텍스나 세마포어 등의 잠금 동기화 프리미티브를 사용하여 스레드 간 공유 상태 액세스를 제어하는 것이 포함됩니다. 이러한 프리미티브는 일반적으로 다른 스레드를 '잠금' 처리하여 락 홀더(lock holder)가 완료될 때까지 해당 섹션의 병렬 처리를 실행하지 못하도록 하고, 대기 중인 스레드의 섹션을 '잠금 해제'하여 코드의 특정 섹션이 가지는 병렬 처리의 양을 제한합니다. 일반적으로는 병렬 처리를 제한하지 않습니다. 그러면 항상 병렬로 코드를 실행하지 않기 때문에 여러 스레드를 사용하여 얻을 수 있는 성능은 줄어들지만, 프로그램이 올바르게 유지됩니다.

또한 데이터 종속성으로 인해 Update() 함수의 일부를 병렬로 실행하는 것은 그리 합리적이지 않습니다. 예를 들어 거의 모든 게임은 컨트롤러에서 입력을 읽고 입력 버퍼에 입력을 저장한 다음, 입력 버퍼를 읽고 해당 값에 따라 반응해야 합니다.

void PartialUpdateA()
{
    // Write to m_InputBuffer with the controller state
    ReadControllerState(out m_InputBuffer);
}

void PartialUpdateB()
{
    // Read m_InputBuffer and start a player 
    // jump animation if the jump button was pressed
    if(m_InputBuffer.IsJumpPressed())
        PlayerJump();
}

입력 버퍼를 읽는 코드가 캐릭터의 점프 여부를 결정하는 동시에 해당 프레임의 업데이트를 위해 입력 버퍼에 코드를 작성하는 것은 말이 되지 않습니다. 뮤텍스를 사용해서 m_InputBuffer에 대한 읽기와 쓰기가 안전하게 이루어지도록 했더라도, 항상 m_InputBuffer에 먼저 쓰고 m_InputBuffer 읽기 코드를 그 다음으로 실행하여 이전 프레임이 아닌 현재 프레임에서 점프 버튼이 눌렸는지 여부를 알 수 있어야 합니다. 이러한 데이터 종속성은 일반적이고 정상적인 현상이지만, 사용할 수 있는 병렬 구조의 양을 줄입니다.

멀티스레드 프로그램을 작성하는 방법은 다양합니다. 플랫폼별 API를 사용하여 스레드를 직접 생성하고 관리하거나, 추상화를 제공하는 다양한 API를 사용하여 멀티스레드 프로그래밍의 복잡한 문제를 어느 정도 관리할 수 있습니다.

잡 시스템도 이런 추상화의 하나입니다. 잡 시스템은 단일 스레드 코드의 일부를 논리적 블록으로 나누고, 해당 코드에 필요한 데이터를 분리하고, 동시에 해당 데이터에 액세스하는 사용자를 제어하고, 최대한 많은 코드를 병렬로 실행하여 CPU에서 활용 가능한 모든 계산 성능을 필요에 따라 활용할 수 있는 방법을 제공합니다.

잡 시스템 API

현재로서는 임의의 함수를 자동으로 분할할 수 없기 때문에 Unity는 사용자가 함수를 작은 논리적 블록으로 변환할 수 있는 잡 API를 제공합니다. 잡 시스템은 변환된 논리적 블록을 병렬로 실행하도록 처리합니다.

잡 시스템은 다음과 같은 핵심 구성 요소로 이루어져 있습니다.

  • 잡 핸들
  • 잡 스케줄러
public struct MyJob : IJob
{
    public NativeArray<int> Data;
    public void Execute()
    {
        // Do some work using our Data member
    }
}

앞서 언급했듯이 잡은 함수와 일부 데이터에 불과하지만, 이렇게 캡슐화된 데이터는 잡에서 읽거나 쓸 특정 데이터의 범위를 줄여 주므로 유용합니다.

var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();

잡 인스턴스가 생성되면 잡 시스템에 인스턴스를 예약해야 합니다. 잡은 C# 확장 메커니즘을 통해 모든 잡 유형에 추가된 .Schedule() 메서드를 통해 예약됩니다. 또한 예약된 잡을 식별하고 추적하기 위해 JobHandle이 제공됩니다.

잡 핸들은 예약된 잡을 식별하기 때문에, 잡 종속성을 설정하는 데 사용할 수 있습니다. 잡 종속성은 잡의 종속성이 처리될 때까지 예약된 잡을 실행하지 않도록 합니다. 또한 유향 비순환 잡 그래프를 생성하여 서로 다른 작업을 언제 병렬로 실행할 수 있는지 알려 줍니다.

var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();

// WritingJob writes to someNativeArray so make sure it runs
// after MyJob is done (since it uses someNativeArray as well). 
// That is, declare writingJob to have a dependency on myJob by 
// passing in the JobHandle for MyJob to writingJob.Schedule
var writingJob = new WritingJob() { Data = someNativeArray };
var writingJobHandle = writingJob.Schedule(jobHandle);

마지막으로, 잡이 예약되면 잡 스케줄러는 JobHandle을 예약된 잡 인스턴스에 매핑하여 예약된 잡을 추적하고 잡이 최대한 빨리 실행되도록 합니다. 이 과정을 어떻게 수행하는지가 중요합니다. 잡 시스템의 설계 및 사용 패턴이 잠재적으로 명확하지 않은 방식으로 충돌하여 멀티스레드 프로그래밍의 성능 이점을 잠식하는 오버헤드 비용을 발생시킬 수 있기 때문입니다. Unity는 사용자가 C# 잡 시스템을 채택하기 시작하면서 잡 시스템 오버헤드가 예상보다 높게 나타나는 사례를 확인했으며, 이에 따라 2022.2 테크 스트림에서 Unity의 내부 잡 시스템 구현을 개선했습니다.

2부도 많이 기대해 주세요. C# 잡 시스템의 오버헤드가 어디에서 발생하고 Unity 2022.2에서는 어떻게 줄어들었는지 살펴봅니다.

궁금한 점이 있거나, 잡 시스템에 대해 더 자세히 알아보려면 C# 잡 시스템 포럼을 방문하세요. Unity Discord에서 사용자 이름 @Antifreeze#2763을 찾아 직접 문의해 주셔도 됩니다. 연재 중인 Tech from the Trenches 시리즈에서 다른 Unity 개발자들의 새로운 기술 블로그도 확인해 보세요.

2023년 2월 24일 엔진 & 플랫폼 | 13 분 소요

Is this article helpful for you?

Thank you for your feedback!

관련 게시물