Unity 검색

Improving job system performance scaling in 2022.2 – part 2: Overhead | Hero image
Improving job system performance scaling in 2022.2 – part 2: Overhead | Hero image
공유

Is this article helpful for you?

Thank you for your feedback!

2022.2 및 2021.3.14f1 릴리스에서는 Unity 잡 시스템의 스케줄링 비용과 성능 확장이 개선되었습니다. 잡 시스템의 새로운 기능에 대해 2부에 걸쳐 소개하는 이번 블로그 게시글의 1부에서는 병렬 프로그래밍에 대한 배경 정보와 잡 시스템을 사용하는 이유를 알려 드린 바 있습니다. 2부에서는 잡 시스템 오버헤드가 무엇인지 알아보고 이를 완화하기 위한 Unity의 접근 방식에 대해 자세히 살펴보겠습니다.

잡 시스템 오버헤드

오버헤드란 스케줄링하는 시점부터 완료되는 시점까지 CPU가 잡을 실행하지 않고 대기 잡의 차단을 해제하면서 소요하는 모든 시간을 의미합니다. 대체로 다음 두 영역에서 시간이 소요됩니다.

  1. C# Job API 레이어
  2. 네이티브 잡 스케줄러(스케줄링된 모든 C#, 내부적으로는 C++ 작업을 관리하고 실행)

C# Job API 오버헤드

C# Job API의 목적은 네이티브 잡 시스템에 액세스하기 위한 안전한 수단을 제공하는 것입니다. C#에서 C++로의 전환을 위한 바인딩 레이어인 동시에, 잡 내에서 NativeContainer에 액세스할 때 경쟁 상태나 교착 상태를 발생시키는 의도치 않은 C# 잡 스케줄링을 방지할 수 있는 레이어이기도 합니다.

이렇게 분리하면 보다 다양한 방법으로 잡 자체를 생성할 수도 있게 됩니다. C++ 레이어에서 잡은 일부 데이터에 대한 포인터이자 함수 포인터일 뿐입니다. 하지만 C# API를 적용하면 스케줄링하는 잡 유형을 커스터마이즈하여 사용자별 사용 사례에 맞게 잡 데이터를 분리하고 병렬화하는 방식을 더 효율적으로 제어할 수 있습니다.

잡을 스케줄링할 때 C# 잡 바인딩 레이어는 잡 구조체를 관리되지 않는 메모리 할당으로 복사합니다. 잡 종속성과 플랫폼의 전체 부하가 영향을 주기 때문에 이렇게 되면 C# 잡 구조체의 수명이 잡 시스템의 잡 수명으로부터 분리될 수 있습니다. 그런 다음 잡 시스템은 에디터 플레이모드 빌드에서 조건부로 안전 점검을 수행하여 잡이 안전하게 실행되도록 합니다.

중요한 단계지만 리소스를 소모하며 잡 시스템 오버헤드에 영향을 미치기도 합니다. 잡 크기는 물론이고 NativeContainer 수와 잡 종속성도 다를 수 있기 때문에, 잡을 복사하고 안전성을 검증하는 데 드는 비용은 고정적이지 않습니다. 그렇기 때문에 Unity에서 비용을 낮게 유지하고 선형 계산 복잡도로 제한하는 것이 중요합니다.

유니티 엔지니어링 팀은 2021.2 테크 스트림에서 개별 잡 핸들의 안전 점검 결과를 캐싱하여 잡 안전 시스템을 크게 개선했습니다. 이 개선 사항은 특히 중요합니다. 종속성 정보가 누락된 부분과 종속성을 추가해야 할 잡을 확인하려면 안전 시스템에서 모든 잡에 포함된 각각의 네이티브 메모리 참조와 잡 종속성의 전체 체인을 파악해야 하기 때문입니다. 이로 인해 스케줄링할 때 비선형적인 양의 항목이 계속 반복될 수 있습니다. 각 잡과 그 종속성의 경우, 잡이 참조하는 각 NativeContainer의 읽기/쓰기 액세스 및 NativeContainer를 참조하는 모든 잡을 확인합니다.

하지만 Unity에서는 C# 잡이 한 번에 하나만 스케줄링된다는 사실을 활용하여 이 스케줄링 과정에서 안전성을 확인할 수 있습니다. 각 스케줄링에서 모든 잡을 다시 스캔하는 대신, 잡 종속성 체인을 재검증해야 할지 빠르게 판단하여 대량의 작업을 건너뛸 수 있습니다. 소규모 잡 종속성 체인에서도 잡 안전 점검 비용이 획기적으로 줄어듭니다. 개발 중에 잡 안전 점검을 끌 이유가 없는 것이 이상적입니다(잡 안전 점검이 플레이어/출시 빌드에 있지 않음).

잡 스케줄러

실행을 위해 스케줄링될 때마다 C# 또는 C++ 잡이 잡 스케줄러를 통과합니다. 스케줄러의 역할은 다음과 같습니다.

  • 잡 핸들을 통해 잡 추적
  • 모든 종속성이 완료된 후에만 잡이 실행되도록 잡 종속성 관리
  • 잡을 실행하는 스레드인 ‘워커 스레드’ 관리
  • 잡이 최대한 빨리 실행되도록 함(종속성이 허용될 때 병렬로 실행되어야 함)

또한 C# Job API는 잡이 메인 스레드에서만 스케줄링되도록 하나, 잡 스케줄러는 잡을 스케줄링하는 여러 스레드를 동시에 지원해야 합니다. 잡을 스케줄링하며 잡 내에서 잡을 스케줄링할 수도 있는 다수의 스레드를 기본 Unity 엔진에서 사용하기 때문입니다. 이 기능은 장단점을 가지지만 정확성을 더 철저히 검토해야 하며 잡 스케줄러의 스레드 안전을 보장해야 한다는 요구 사항이 추가됩니다.

2017.3 릴리스에서 잡 스케줄러의 기본적인 모습은 다음과 같았습니다.

  • 잡 대기열
  • 잡 스택
  • 세마포어
  • 워커 스레드 배열

일반적인 사용 사례에선 잡이 스케줄링되면 전역의 잠금 없는, 다중 공급자, 다중 소비자 대기열에 추가되는 패턴을 따르며, 이는 잡이 워커 스레드에 의해 처리될 준비가 되었음을 나타냅니다. 그러면 메인 스레드에서 세마포어를 통해 신호를 보내 워커 스레드의 절전 모드를 해제합니다.

절전 모드 해제 신호를 받는 워커의 수는 스케줄링되는 잡 유형에 좌우됩니다. IJob과 같은 단일 잡의 경우 다중 워커 스레드에 작업을 분산시키지 않기 때문에 단일 워커의 절전 모드만 해제할 수 있습니다. 반면 IJobParallelFor 잡은 병렬로 실행할 수 있는 다중 작업을 나타냅니다. 한 잡이 스케줄링되는 동안, 일부 또는 전체 워커가 지원할 수 있는 작업이 동시에 여러 개 있을 수 있습니다. 이와 같이 스케줄러는 해당 개수를 잠재적으로 지원하고 절전 모드를 해제할 수 있는 워커의 수를 파악합니다.

절전 모드가 해제되면 워커 스레드는 실제 잡 작업이 발생하는 곳이 됩니다. 2017.3에서 워커 스레드는 모든 관련 잡 종속성이 완료되도록 작업 대기열에서 잡을 제거하는 일을 담당했습니다. 아직 완료되지 않은 경우, 잡과 미완료 종속성은 재시도 및 실행을 위해 대기열 앞쪽으로 이동할 수 있도록 잠금 없는 스택에 추가됩니다. 워커 스레드는 엔진이 종료 신호를 보내거나 스택 및 대기열에 잡이 없을 때까지 이 작업을 계속 수행합니다. 어떤 시점이 되면 워커 스레드는 메인 스레드 세마포어의 신호를 대기하며 절전 모드로 전환됩니다.

while(!scheduler.isQuitting)
{
    // Usually empty unless we need to prioritize a dependency
    // to unblock a job we got from the queue. Alternatively 
    // pieces of work from a IJobParallelFor job can end up here to let
    // many workers help finish IJobParallelFor work quickly
    Job* pJob = m_stack.pop();
    if(!pJob)
        Job* pJob = m_queue.dequeue();

    if(pJob) {
        // ExecuteJob if all dependencies are complete, otherwise
        // push this job and the dependencies to the stack and try again
        if(EnsureDependenciesAreCompleteOtherwiseAddToStack(pJob))
            ExecuteJob(pJob);
    }
    else
    {
        // Put the thread to sleep until more jobs are scheduled
        m_semaphore.Wait(1);
    }
}

잡 스케줄러는 CPU의 가상 코어 수만큼 워커 스레드를 생성하며 하나를 기본적으로 빼 놓습니다. 각 워커 스레드를 자체 CPU 코어에서 실행하는 동시에, 메인 스레드가 계속 실행되도록 한 CPU 코어를 독립적으로 남겨 두기 위해서입니다. 실제로는 비게임 프로세스를 위한 코어가 남아 있지 않은 플랫폼의 경우 워커 스레드의 양을 줄여, 운영 체제나 드라이버 스레드에서 수행하는 계산이 게임의 메인 스레드나 잡 워커 스레드와 경쟁하지 않도록 하는 방법이 나을 수 있습니다.

메인 스레드는 잡이 스케줄링되는 기본 장소이므로 메인 스레드의 지연을 방지하는 것이 매우 중요합니다. 잡 시스템에 유입되는 잡의 수와 한 프레임 내에서 발생할 수 있는 병렬 구조의 규모에 직접적으로 영향을 미치기 때문입니다.

메인 스레드가 이론적으로 많은 잡을 스케줄링하고 나머지 CPU 코어가 해당 잡을 실행하면, CPU에서 수행될 수 있는 병렬 작업의 수를 최대화하여 하드웨어 변경 사항에 따라 성능을 확대하거나 축소할 수 있습니다. 워커 스레드가 코어보다 많다면 운영 체제에서 메인 스레드를 컨텍스트 전환하고 워커 스레드로 전환할 수 있습니다. 추가 워커 스레드를 실행하면 잡 대기열을 더 빨리 비울 수 있으나 새 작업의 대기열 유입이 방해되어 결과적으로 성능에 매우 부정적인 영향을 주게 됩니다.

스레드 신호 전송 오버헤드

위와 같은 잡 스케줄러 접근 방식에는 잡 시스템 오버헤드를 야기할 수 있는 잠재적인 문제가 몇 가지 있습니다. 예시를 살펴보겠습니다.

메인 스레드가 종속성 없는 IJob(비병렬 잡)을 다음과 같이 스케줄링합니다.

  • 잡이 대기열에 추가되고 워커 스레드에 절전 모드 해제 신호 전송
  • 워커 스레드의 절전 모드 해제
  • 워커가 잡을 실행
  • 워커가 실행할 잡이 더 있는지 확인
  • 더 이상 잡이 없어 워커가 절전 모드로 전환

메인 스레드가 잡 스케줄러의 세마포어를 사용하여 신호를 전송하면 절전 모드의 워커 스레드(반드시 워커 0은 아님) 중 하나의 절전 모드가 해제됩니다. 절전 모드 해제와 컨텍스트 전환을 하려면 워커 코어에서 어느 정도의 시간이 걸립니다. 워커 스레드가 절전 모드인 경우, 추후 워커 스레드가 실행될 CPU 코어에서 스레드를 사용하는 머신의 다른 프로세스나 게임에서 생성된 다른 스레드를 실행하는 등 다른 작업을 수행하고 있었을 것이기 때문입니다.

스레드를 일시 중지하거나 나중에 재개하려면 스레드의 등록 상태를 저장하고, 명령 파이프라인을 플러시하고, 전환 대상 스레드의 상태를 복원해야 합니다. 절전 모드를 해제할 스레드를 알리는 것이 운영 체제에서 처리되기 때문에 스레드 신호를 전송하는 데에도 메인 스레드의 코어에서 시간이 걸립니다. 궁극적으로 우리의 잡이 아닌 워커 스레드 코어와 메인 스레드 코어에서 작업이 수행되면 오버헤드를 줄일 수 있습니다.

A job is scheduled on the Main thread and eventually runs on thread Worker 0. The job execution is delayed by overhead signaling Worker 0 to wake up on the Main thread, the context switch time on the Worker 0 thread, and the time the job system takes to find the job to run.
잡이 메인 스레드에서 스케줄링되고 결과적으로 스레드 워커 0에서 실행됩니다. 메인 스레드에서 워커 0의 절전 모드가 해제되도록 신호를 전송하는 오버헤드, 워커 0 스레드의 컨텍스트 전환 시간, 잡 시스템이 실행할 잡을 찾는 데 걸리는 시간에 의해 잡 실행이 지연됩니다.

워커에 알림이 얼마나 빨리 전달되는지와 개별 잡이 실행되는 데 얼마의 시간이 걸리는지 역시 시스템에 영향을 줄 수 있습니다. 예를 들어 위 사용 사례를 따르지만 하나가 아닌 두 개의 잡을 스케줄링하는 경우에는 다음과 같습니다.

  • 잡이 대기열에 추가되고 워커 스레드에 절전 모드 해제 신호 전송
  • 두 번째 잡이 대기열에 추가되고 워커 스레드에 절전 모드 해제 신호 전송
  • 다음 동작이 특정 순서로 두 번 수행됩니다.
    • 워커 스레드의 절전 모드 해제
    • 워커가 잡을 실행
    • 워커가 실행할 잡이 더 있는지 확인
    • 더 이상 잡이 없어 워커가 절전 모드로 전환

타이밍이 맞으면 잡에서 두 워커가 병렬로 작업할 수도 있습니다.

A parallel job is scheduled on the Main thread and eventually runs on thread Worker 0 and Worker 1 simultaneously.
병렬 잡이 메인 스레드에서 스케줄링되고 결과적으로 스레드 워커 0과 워커 1에서 동시에 실행됩니다.

하지만 한 잡이 너무 작거나 신호를 전송하여 두 워커 모두의 절전 모드를 해제하는 데 시간이 너무 오래 걸리는 경우 한 워커가 대기열의 모든 작업을 뺏을 수 있으며, 그 결과 이유 없이 워커에 신호가 전송되었습니다.

Two jobs are scheduled on the main thread but both run on Worker 0 due to Worker 1 not waking up before Worker 0 consumes all jobs in the job queue. There may be too many non-worker threads in the system occupying CPU cores, or the jobs are too small to give worker threads enough time to wake up on average.
두 개의 잡이 메인 스레드에서 스케줄링되나 워커 0이 잡 대기열의 모든 잡을 소비하기 전에 워커 1의 절전 모드가 해제되지 않아 모든 잡이 워커 0에서 실행됩니다. 시스템에 CPU 코어를 차지하고 있는 비워커 스레드가 너무 많거나, 잡이 너무 작아서 절전 모드를 해제할 만큼 평균적으로 충분한 시간을 워커 스레드에 주지 못할 수 있습니다.

이러한 유형의 잡 기아 상태 및 절전 모드 전환 주기로 인해 많은 비용이 소모될 수 있으며, 잡 시스템에서 제공되는 병렬 구조의 양이 제한될 수 있습니다.

스레드 신호 전송과 컨텍스트 전환으로 인한 오버헤드를 애초에 스레드를 다룰 때 소모되는 비즈니스 비용으로 간주하실 수도 있습니다. 틀린 말은 아닙니다. 하지만 신호를 전송하고 스레드의 절전 모드를 해제하는 데 드는 비용을 직접 제어할 수는 없더라도, 그러한 연산이 일어나는 빈도를 제어할 수는 있습니다.

이유 없이 워커의 절전 모드가 해제되지 않도록 하는 한 가지 방법은, 워커의 절전 모드 해제 비용이 정당화될 정도로 대기열에 작업 항목이 많다고 의심되는 경우에만 절전 모드를 해제하는 것입니다. 이 작업은 배칭을 통해 가능합니다. 잡을 스케줄링하자마자 워커에 신호를 보내지 말고, 잡을 목록에 추가하고 특정 시점에 해당 잡 배치를 잡 시스템으로 플러시하여 적정량의 워커에서 동시에 절전 모드를 해제하는 것입니다.

Two jobs are scheduled into a batch and then the whole batch is flushed, waking up two workers at nearly the same time. This batching approach improves the chances that both workers will find work when they wake up.
두 개의 잡이 배치로 스케줄링된 다음 전체 배치가 플러시되어 거의 동시에 두 워커의 절전 모드를 해제합니다. 이러한 배칭 접근 방식을 사용하면 절전 모드가 해제되었을 때 두 워커가 모두 작업을 찾아낼 가능성이 높아집니다.

실제 절전 모드 해제에 지나치게 긴 시간이 소요되거나, 배칭된 잡이 너무 작거나, 배치에 포함된 잡의 수가 그렇게 많지 않을 위험은 여전히 존재합니다. 보통 배치에 더 많은 잡을 포함할수록 스레드의 절전 모드를 이유 없이 해제하여 발생하는 오버헤드를 방지할 가능성은 더 커집니다. Unity에서는 JobHandle.Complete()에 대한 호출이 호출될 때마다 플러시되는 전역 배치를 유지합니다. 따라서 잡이 완료되기까지 명시적으로 대기해야 하는 경우 최대한 느리고 드물게 해야 하며, 데이터에 대한 안전한 액세스를 최적으로 제어하려면 잡 종속성이 있는 잡을 스케줄링하는 편이 좋습니다.

스레드에 신호를 전송하여 절전 모드가 전환되기까지 대기하는 것이 오버헤드라면, 작업을 찾는 동안 스레드를 절전 모드 해제 상태로 유지하면 된다고 생각하실 수도 있습니다. 대기열에 잡이 많으면 실제로 그러한 과정이 자연스럽게 발생합니다. 운영 체제에서 워커 스레드의 우선 순위가 다른 작업보다 낮다고 간주하지 않거나, 명시적으로 타임 슬라이싱되어 상당 부분의 CPU 시간을 다른 스레드에 제공하기 위해 교체되어야 하지 않는 한(플랫폼에 따라 다름) 워커 스레드는 문제없이 계속 작동합니다.

하지만 1부에서 다룬 PartialUpdateAPartialUpdateB 함수에서처럼 모든 잡이 병렬화 가능하거나 데이터 종속성으로부터 독립적이지는 않습니다. 이처럼 일반적으로 다른 잡을 실행하려면 잡의 하위 집합이 완료될 때까지 대기해야 합니다. 그 결과 실행 가능한 잡의 수가 워커 스레드보다 적어지면 잡 그래프의 병렬 구조에서 병목 현상이 나타날 수 있으며, 그로 인해 일부 워커가 더 이상 생산적인 작업을 할 수 없게 됩니다.

워커 스레드를 절전 모드로 두지 않으면 몇 가지 문제가 발생할 수 있습니다. 워커 스레드가 계속 새 잡을 확인하고 아무것도 찾지 못하면 ‘바쁜 대기(busy waiting)’ 또는 소모적이고 프로그램을 진행하지 못하는 작업으로 간주됩니다. 게임을 진행하지 않고 모든 코어를 최대의 병렬 구조로 계속 실행하면 배터리 수명이 소모됩니다. 그뿐 아니라 코어에 충분한 냉각과 대기 상태 시간이 없으면, CPU의 온도가 올라가 과열로 인한 손상을 방지하기 위해 느리게 실행되는 다운클러킹이 야기됩니다. 실제로 모바일 플랫폼에서는 과열된 경우 전체 CPU 코어가 일시적으로 비활성화되는 상황이 드물지 않게 발생합니다. 잡 시스템의 경우 코어를 효율적으로 사용하는 것이 매우 중요하며, 따라서 워커를 절전 모드로 두는 것과 계속 루핑하여 새 잡을 찾는 작업이 균형 있게 이루어집니다.

CAS(compare-and-swap) 오버헤드

위 설계에서 오버헤드를 발생시킬 수 있는 또 다른 영역은 잠금 없는 대기열과 스택입니다. 이러한 데이터 구조를 구현하는 데 있어 미묘한 부분까지 모두 살펴보지는 않겠지만, 잠금 없는 구현의 일반적인 특성 한 가지는 CAS(compare-and-swap) 루프를 사용하는 것입니다. 잠금 없는 알고리즘은 공유 상태에 안전한 액세스를 제공하기 위해 잠금 동기화 기본 형식을 사용하지 않으며, 스레드 세이프(thread-safe) 방식으로 항목을 대기열에 삽입하는 것과 같은 고순위 개별 연산을 개별 명령을 사용하여 신중하게 생성합니다. 하지만 비직관적인 측면에서 잠금 없는 알고리즘은 다른 스레드가 완료될 때까지 스레드가 진행되는 것을 계속 방해할 수 있습니다. 또한 CPU 명령과 메모리 파이프라인에 부차 효과를 미쳐서 성능 확장을 방해할 수 있습니다. ‘대기 없음(wait-free)’ 알고리즘에서는 모든 스레드가 항상 진행되도록 허용되지만 실제로 항상 전체 성능을 최고조로 제공하지는 않습니다.

다음은 CAS 루프를 사용하여 멤버 변수 m_Sum에 숫자를 더하는 인위적인 예시입니다.

int Add(int val)
{
    int newSum;
    do
    {
        // Load the current value we want to update
        var oldSum = m_Sum;

        // Compute new value we want to store
        newSum = oldSum + val;

        // Attempt to write the new value. CompareExchange returns 
        // the value seen inside m_Sum when writing newSum to m_Sum. 
        // If newSum doesn't match oldSum, we will retry the loop 
        // since it means another thread wrote to the memory before us.
        // If we wrote our value without this check, we might 
        // write an incorrect value
    }while (oldSum != Interlocked.CompareExchange(ref m_Sum, newSum, oldSum));

    return newSum ;
}

CAS 루프는 compare-and-swap 명령을 사용하며(여기서는 플랫폼 세부 사항을 추상화하는 C# Interlocked 라이브러리 사용) 이 명령은 ‘두 값이 동등한지 비교하여 동등한 경우 첫 번째 값을 교체합니다.’ Add() 함수의 사용자가 함수가 실패할 수 있다는 우려를 가지지 않도록, 다른 스레드에 의해 m_Sum이 업데이트되어 함수가 실패하는 경우 루프를 사용하여 재시도됩니다.

이 재시도 루프는 본질적으로 ‘바쁜 대기(busy-wait)’ 루프입니다. 여기에는 성능 확장에 관한 위험이 내포되어 있습니다. 다중 스레드가 CAS 루프로 동시에 유입되는 경우 한 번에 하나씩만 처리되며 각 스레드가 수행하는 연산이 직렬화됩니다. CAS 루프가 보통 의도적으로 소량의 작업을 진행한다는 점은 다행이나, 여전히 성능에 부정적인 영향을 많이 미칠 수 있습니다. 더 많은 코어가 루프를 병렬로 실행하면 스레드가 경합 상태에 있는 동안 각 스레드에서 루프를 완료하는 데 걸리는 시간이 더 길어집니다.

또한 CAS 루프는 공유 메모리에 대해 개별 읽기 및 쓰기를 사용하므로, 일반적으로 각 스레드에서 각 반복 작업마다 캐시 라인이 무효화되어야 하며 이에 따라 오버헤드가 추가로 발생합니다. 이 오버헤드는 CAS 루프 내에서 계산을 재수행하는 비용과 비교했을 때 매우 많은 비용이 소모될 수 있습니다(위 경우에는 두 숫자를 같이 더하는 작업을 재수행). 따라서 얼핏 봐서는 정확히 얼마나 많은 비용이 드는지 확인하기 어렵습니다.

2017.3 잡 스케줄러에서는 잡을 실행하고 있지 않을 때 워커 스레드가 잠금 없는 공유 스택이나 대기열에서 작업을 찾았습니다. 이러한 데이터 구조는 모두 데이터 구조에서 작업을 제거하기 위해 최소 하나의 CAS 루프를 사용했습니다. 그래서 사용 가능한 코어가 더 많아짐에 따라 데이터 구조에 경합이 있을 때 스택이나 대기열에서 작업을 제거하는 비용도 늘어났습니다. 특히 잡이 작을 때 워커 스레드는 대기열이나 스택에서 작업을 찾기 위해 비례적으로 더 많은 시간을 소비했습니다.

한 소규모 프로젝트에서 저는 일반적인 게임에서 프레임 업데이트로 인해 볼 수 있는 결정론적 잡 그래프를 생성했습니다. 아래 그래프는 단일 잡과 병렬 잡(각각 1~100개의 병렬 잡으로 병렬화)으로 구성되어 있으며 각 잡에는 0~10개의 잡 종속성이 있습니다. 메인 스레드에는 간헐적인 명시적 동기화 지점이 있고, 추가 스케줄링을 하기 전에 여기서 특정 잡이 완료될 때까지 대기해야 합니다. 잡 그래프에서 500개의 잡을 생성하고 각각에서 실행을 위해 일정한 시간(병렬 잡의 각 부분에서도 이 시간이 소요됨)이 걸리도록 하면 더 많은 코어가 사용될수록 잡 시스템의 오버헤드가 증가하는 것을 볼 수 있습니다.

Windows 11 AMD Ryzen 9 3950X
Windows 11 AMD Ryzen 9 3950X

0.5μs가 걸리는 잡의 경우 20개의 워커가 있으면 잡 시스템을 아예 사용하지 않는 것만큼 프레임이 빠르게 업데이트되며 머신의 모든 코어를 사용할 때 거의 두 배 느리게 실행됩니다. 기본적으로 Unity에서는 모든 코어가 사용되므로 1μs 잡에서 31개의 워커 스레드를 사용해도 성능은 거의 개선되지 않습니다. 잠금 없는 대기열 및 스택의 치열한 경합으로 인한 직접적인 결과입니다. 다행스럽게도 사용자 잡은 크기가 큰 편이라 이 오버헤드를 숨길 수 있습니다. 하지만 확장 및 축소 문제가 남아 있으며 작은 잡은 여전히 흔하게 존재합니다(특히 병렬 잡의 경우). 큰 잡을 사용할 때에도 잡 스케줄러의 잠금 없는 전역 스택 및 대기열의 경합 때문에 스케줄링 패턴과 워커 타이밍에서 대량의 오버헤드가 발생할 수 있습니다.

2022.2 잡 스케줄러

이제 Unity와 게임 크리에이터 양측에서 잡 시스템의 오버헤드를 줄이기 위해 다음과 같이 해결해야 할 몇 가지 영역이 있다는 사실을 알게 되셨을 겁니다.

  • 메인 스레드의 지연 방지:
    • 워커 스레드의 절전 모드를 해제하기 위해 신호를 전송하려면 비용이 많이 들기 때문에 이 작업을 최소한으로 유지해야 함
    • 워커 스레드와 공유된 메인 스레드의 상태를 수정하면 캐시 무효화와 잠재적 바쁜 대기(busy-waiting)가 발생할 수 있음
    • 메인 스레드는 잡을 자주 스케줄링해야 하고 잡이 .Complete()되기까지 명시적으로 대기하지 않아야 하며 대신 종속성이 있는 잡을 제출해야 함
  • 워커 스레드의 지연 방지:
    • 워커 스레드의 효율성은 병렬 구조에 직접적인 영향을 미치므로 가능한 한 공유 리소스에서 경합을 피해야 함
    • 워커 스레드에 바쁜 대기(busy-wait)가 발생하여 배터리 수명이 소모되며 온도 상승으로 인해 다운클러킹이 발생할 수 있음

Unity가 게임에서 사용자가 제출하는 잡의 수를 변경할 수는 없지만 Unity 엔지니어는 다른 잡 스케줄러 접근 방식을 통해 상당히 많은 문제를 해결할 수 있습니다. 2022.2 릴리스에서 잡 스케줄러는 대체로 다음과 같은 몇 가지 기본 컴포넌트로 나뉩니다.

  • 워커 스레드 배열
  • 잡의 대기열 배열
  • 세마포어 배열

전반적으로 이전의 잡 스케줄러와 매우 유사합니다. 주된 차이점은 메인 스레드와 워커 스레드 간의 공유 상태 제거입니다. 그 대신 대기열과 세마포어(또는 지원하는 플랫폼의 퓨텍스)를 각 워커 스레드의 로컬로 만들었습니다. 이제 메인 스레드가 잡을 스케줄링하면 전역 대기열이 아닌 메인 스레드의 대기열에 추가됩니다.

이와 유사하게 워커 스레드에서 잡을 스케줄링해야 하는 경우(예: 잡이 해당 Execute에서 잡을 스케줄링) 메인 스레드 대기열이 아닌 워커의 자체 대기열에서 잡이 스케줄링됩니다. 그러면 대기열에 작성할 때 워커에서 캐시 라인 무효화 빈도가 줄어들어 메모리 트래픽이 줄어듭니다. 이와 같이 워커는 여러 대기열에 동일한 빈도로 읽기/쓰기를 하지 않습니다.

워커 루프도 변경되었으며 이제 작업할 대기열이 더 많습니다.

while(!scheduler.isQuitting)
{
    // Take a job from our worker thread’s local queue
    Job* pJob = m_worker_queue[m_workerId].dequeue();
    // If our queue is empty try to steal work from someone
    // else's queue to help them out.
    if(pJob == nullptr) {
        pJob = StealFromOtherQueues()
    }

    if(pJob) {
        // If we found work, there may be more conditionally
        // wake up other workers as necessary
        WakeWorkers();
        ExecuteJob(pJob);
    }
    // Conditionally go to sleep (perhaps we were told there is a 
    // parallel job we can help with)
    else if(ShouldSleep())
    {
        // Put the thread to sleep until more jobs are scheduled
        m_semaphores[m_workerId].Wait(1);
    }
}

워커는 자체 대기열에서 작업을 살펴보고, 자체 대기열이 비어 있는 경우에만 다른 워커 대기열을 살펴봅니다. 워커는 작업을 제거하거나 추가하는 데 자체 대기열을 사용하는 것을 선호하기 때문에, 어떤 한 대기열에서 발생하는 경합의 양이 줄어듭니다.

또 다른 차이점은 스레드에 절전 모드 해제 신호가 전송되는 방식입니다. 이제 워커 스레드는 다른 워커 스레드의 절전 모드를 해제하는 일을 책임지며, 메인 스레드는 잡을 스케줄링할 때 하나 이상의 워커 스레드가 절전 모드 해제 상태를 유지하도록 하는 역할을 수행합니다.

이렇게 책임 사항을 변경하여 메인 스레드에서 과도한 오버헤드를 없앨 수 있습니다. 병렬 잡이 제출될 때 스레드의 절전 모드를 해제하는 일을 단독으로 맡을 필요가 없어지기 때문입니다. 그 대신 잡 시스템은 워커의 절전 모드를 해제할 필요가 있는지 알기 위해 추적을 수행합니다. 메인 스레드를 활용해 워커가 항상 깨어 있는 상태로 잡을 진행하도록 만들 수 있습니다. 깨어난 워커가 자체 대기열 또는 다른 대기열에서 잡을 찾으면 다른 워커에 절전 모드를 해제하라는 신호를 전송하고 필요한 경우 대기열을 비우도록 도울 수 있습니다.

Windows 11 AMD Ryzen 9 3950X
Windows 11 AMD Ryzen 9 3950X
Windows 11 AMD Ryzen 9 3950X
Windows 11 AMD Ryzen 9 3950X

워커의 대기열 분리를 통해 설정과 최적화 측면에서 어느 정도 유연함을 제공할 수 있으며, 유니티 팀은 이러한 영역을 계속 추가 및 개선하고 있습니다. 2022.2에서 사용자는 메인 스레드가 워커 스레드의 절전 모드를 해제할 때의 비용이 줄어들고, 플랫폼의 코어 수에 상관없이 워커 스레드에서 잡 처리량이 늘어남을 확인할 수 있을 것입니다. 또한 Unity에서 대기열 분리를 2021.3 LTS로 백포트하지는 않았지만, 디자인 변경 사항을 되살려 메인 스레드뿐만 아니라 워커 스레드가 상호 간에 신호를 전송하는 일을 책임지도록 만들었습니다. 전역 세마포어로 신호를 전송하여 발생하는 메인 스레드의 높은 잡 시스템 오버헤드는 2021.3.14f1부터 더 이상 문제가 되지 않을 것입니다.

질문이 있거나 자세히 알아보고 싶은 경우 C# 잡 시스템 포럼을 방문하세요. Unity Discord에서 사용자 이름 @Antifreeze#2763을 찾아 직접 문의하셔도 됩니다. Tech from the Trenches 시리즈에서 다른 Unity 개발자가 작성한 새로운 기술 블로그도 꼭 확인해 보시기 바랍니다.

2023년 3월 14일 엔진 & 플랫폼 | 20 분 소요

Is this article helpful for you?

Thank you for your feedback!

관련 게시물