[번역] 렌더 그래프 (Render Graphs)

2022. 5. 11. 23:38개인 공부 및 연구

- 2022/05/04 (최초 작성)

- 2022/05/11 (마지막 수정)

발 번역 주의보, Figures나 예제 코드는 빠져있을 수 있습니다.

개인 공부 및 연습용으로 번역된 글입니다. 의역/오역/오타가 많을 수 있으며. 원저작권자의 별도의 허가 없이 작성되었으므로, 언제든지 삭제될 수 있습니다.

원본 아티클


렌더 그래프 (Render Graphs)

by Riccardo Loggini

 

목차

  • 왜 렌더 그래프에 대해 이야기하나요?
  • 특성
    • 리소스 배리어의 최소화 및 배치(Batch)
    • 리소스 메모리의 관리 및 최적화
    • 병렬 커맨드 리스트 기록
  • 구성 및 워크플로우
    • 준비 단계
    • 컴파일 단계
    • 실행 단계
  • 사용 가능한 Direct3D 도구들
    • 배치된 리소스 (Placed Resources)
    • 앨리어스 된 리소스 (Aliased Resources)
    • 조언
  • 참고 자료

왜 렌더 그래프에 대해 이야기하나요?

2017년 프로스트바이트를 개발하던 Yuriy O'Donnell은 GDC에서 Frame Graph에 대해 강연하였습니다. 이는 트리플 A급 게임에서 랜더 그래프가 실제로 응용된 첫 사례로 꼽힙니다.

프레임 그래프는 장면을 렌더링 하기 위한 각 그래픽스 연산들의 고수준 표현 방법을 목표로 하였습니다. 이에 덛붙혀서, 시스템이 렌더 리소스의 큰 부분을 사용과 수명을 완전히 컨트롤합니다.

그렇게 함으로써, 모든 렌더링 코드에 대한 정의된 구조를 가지지만, 자동으로 리소스 전환과 펜스 동기화 그리고 메모리 할당이 이루어지게 하는 것과 같은 일련의 장점들을 얻을 수 있었습니다.

2021년에 이르러서는 트리플 A급 게임 엔진 개발을 하는 데 있어서 렌더 그래프의 사용은 사실상 표준이 되었습니다: 가장 일반적인 예로 언리얼 엔진의 RDG(Render Dependency Graph) 그리고 유비소프트가 개발한 Anvil Engine의 Frame Graph와 같은 구현들이 있죠.

렌더 그래프는 크고 가끔은 복잡한 시스템입니다. 그리고 대부분의 이점은 다양한 기법들을 구현하는 아주 큰 엔진을 만들 때 얻을 수 있죠. 이를 취미나 인디 프로젝트에서 사용하는 건 렌더 그래프의 완전한 잠재력을 끌어오지 못할 수 있습니다, 왜냐면 그 시점에선 많은 최적화가 직접 이루어질 수 있기 때문입니다. 하지만 언제나 트리플 A 엔진이 어떻게 동작하는지 이해하는 건 아주 유용합니다.

특성

렌더 그래프에 의해 지정되는 엔진 렌더 코드를 가지면 다양한 특성들이 생깁니다.

첫 번째로, 그래픽스 작업을 실행하는 데 있어 더 명확한(깔끔한) 순서를 가지게 됩니다:

각 렌더 작업은 렌더 코드를 생성하기 위한 유일한 방법으로 추상화됩니다.

이 구조의 명확함은 더 많은 디버그 능력과 보여주기 위한 더 많은 디스플레이 도구들을 구축하기 위한 잠재력이 생기게 합니다. 예를 들어, 리소스의 수명이나 렌더 패스의 종속성 같은 것들 말이죠.

이는 직접적으로 적은 개발 시간으로 치환됩니다.

또한 사용자에게 리소스 상태 전환 및 메모리 할당과 같은 저수준의 작업도 숨겨서 독립적이고 자동적인 방식으로 이런 활동을 최적화할 수 있는 여지를 남겨둡니다.

이 모든 특성들은 엔진에서 플랫폼 추상화 코드의 수준을 높여 줍니다. 그렇게 함으로써 많은 세부 사항과 최적화가 플랫폼-종속적인 코드에 숨겨질 수 있죠.

리소스 배리어의 최소화 및 배치(Batch)

Direct3D12나 Vulkan 같은 새로운 세대의 그래픽스 API를 사용하면, 리소스 상태를 관리하고 우리가 하고자 하는 작업에 따라 그와 관련된 상태로의 전환을 해주어야 합니다.

렌더 그래프를 사용함으로써, 이러한 대부분의 작업들이 프로그래머부터로 의 수동적인 입력이 없더라도 자동으로 처리되게 됩니다. 이를 통해 에러를 줄이거나, 코드를 더 깔끔하게 만들어주고 실제 그래픽스 기능을 개발하는데 집중할 수 있게 도와줍니다. 각 렌더 패스에서 프로그래머는 쉐이더의 입력으로 어떤 리소스들이 사용되어야 하는지, 렌더 타깃이나 깊이-스텐실은 어떤 게 사용되는지 정해주게 됩니다 (더 자세한 내용은 구성과 워크플로우 섹션에서 말씀드리겠습니다). 이는 각 패스에서 요구하는 리소스 상태 전환을 추론하는데 큰 도움을 줍니다.

위의 이미지는 리소스 사용의 예시를 보여줍니다: 초록색 선은 읽기 작업을 그리고 빨간색 선은 각 렌더러를 위한 다른 리소스에서의 쓰기 연산을 의미합니다.

때문에 렌더 그래프는 이런 상호작용들에 대한 사전 정보를 가지고 있게 되고, 리소스 전환을 위해 배치될 배리어들을 알아낼 수 있습니다.

단일 커맨드 큐에서 최적의 배리어 구성을 찾는 것은 꽤 쉬운 작업입니다: 만약에 리소스 A가 1번 패스에서는 쉐이더 리소스로 사용되었는데 2번 패스에서는 렌더 타깃으로 사용되었다면, 두 패스 사이에서 렌더 타깃으로의 리소스 전환이 필요로 하게 됩니다. 만약 두 패스 모두 리소스 A를 쉐이더 리소스로 사용한다면, 아무런 전환도 하지 않아도 되겠지요.

일반적으로, 여기서 더 나아가서 단일 콜(호출) 내에서 리소스 전환들을 묶어서, SDK를 더 적게 호출함으로써 프로그램이 더 빠르게 수행될 수 있도록 할 수 있습니다.

한번 쉐도우 맵핑 알고리즘을 예시로 들어 보죠: 깊이 버퍼를 계산하는 그래픽스 패스와, 여기서 계산된 깊이 정보를 쉐도우 버퍼에 출력 하기 위한 픽셀 당 컴퓨트 패스, 그리고 마지막으로 깊이 버퍼와 쉐도우 버퍼를 입력으로 사용하는 또 다른 그래픽스 패스가 있다고 생각해봅시다.

이 경우에, 깊이 버퍼 그리고 쉐도우 버퍼 양 쪽에 대한 전환은 마지막 패스의 쉐이더 리소스로 사용될 때 같이 묶일 수 있습니다.

 

 리소스들이 서로 다른 커맨드 큐에서 사용된다고 하더라도 리소스 배리어를 배치(Batch)할 수 있습니다.

트리플 A급 엔진의 경우를 예시로 들자면, 최소 3개의 커맨드 큐를 가지는 게 일반적입니다: 그래픽스, 컴퓨트 그리고 복사 커맨드 큐.

그래픽스 커맨드 리스트는 "main" 큐가 되어야 하고, 일반적으로 모든 큐들은 서로 통신하기 위해선 동기화 메커니즘 (특히 D3D12에서의 GPU 펜스처럼)이 필요하게 됩니다.

 

서로 다른 커맨드 큐 사이에서 일어나는 리소스 전환의 최적화는 패스 사이에서의 상호작용과 얼마나 많은 서로 다른 큐들이 사용하고 있는지에 따라 복잡해질 수 있습니다.

 

Pavlo Muratov는 그의 블로그 포스트에서 이러한 연산들을 완전히 자동화하는 것에 대한 어려움에 대해 설명하고 정해지지 않은 수만큼의 큐들을 상대하기 위해 어떻게 접근을 해야 할지 나열해 주었습니다. 일반적으로 리소스 전환을 모아서 단일 큐에서 실행시키는 것이 제일 나은 선택으로 보입니다.

리소스 메모리 관리 및 최적화

각 패스는 필요한 리소스들을 선언하기 때문에, 리소스의 수명을 제어하는 것도 가능합니다: 할당 또한 그래프에 의해 처리됩니다.

위의 이미지를, 예로 들자면, 리소스 A는 3번째 패스에 까지만 사용됩니다. 또 다른 리소스 C는 4번째 패스에서 사용되기 시작하죠, 그리고 리소스 C의 수명은 리소스 A와 겹치지 않습니다, 즉 두 리소스에 대해 같은 메모리를 사용할 수 있다는 것을 의미하죠.

같은 콘셉트가 리소스 A와 D에 대해서도 성립되고, 일반적으로 여러 개의 겹칠 수 있는 메모리 할당을 가지게 될 수 있기 때문에, 최상의 할당 전략을 감지하기 위한 기발한 방법이 필요하게 됩니다.

 

프레임 단위로 사용되는 리소스가 있다고 해보죠: 이들은 보통 그래프 또는 일시적 리소스(transient resources)라고 부릅니다 그리고 이들의 수명은 완전히 렌더 그래프에 의해 처리될 수 있죠.

그 예로, 지연 라이팅 패스에서 프레임 단위로 계산될 카메라 깊이나 GBuffer 같은 것들이 있습니다.

또 다른 형태의 리소스들도 있습니다. 윈도우 스왑 체인 후면 버퍼 같이 수명이 그래프 밖의 시스템에 종속적이어서, 그래프가 그저 이들의 상태만 관리하도록 스스로 제한해야 합니다. 이들은 외부 리소스(external resources)라고 알려져 있습니다.

일시적 리소스 시스템

렌더 그래프 안에서 생성되는 모든 리소스는 단일 프레임 안에서 특정 시간 동안 지속되도록 의도되기 때문에, 높은 메모리 재사용 잠재력을 보입니다.

이런 리소스들은, 렌더 그래프에 의해 소유되고 최대 한 프레임 동안만 지속됩니다, 이들을 일시적 리소스(transient resources)라 부르기도 합니다.

리소스의 수명은 이후에 배치된(placed) 리소스리소스 앨리어싱(resource aliasing)을 적용하는 데 사용됩니다(D3D12에서의 용어를 사용하자면).

배치된 리소스와 리소스 앨리어싱에 대해서는 제가 이전에 작성한 D3D12에서의 리소스 처리에 대한 아티클에서 찾아볼 실 수 있습니다.

배치된 리소스들은 이미 GPU 메모리 힙에 만들어져 있기 때문에 예약된 리소스들과 비교했을 때 할당 그리고 삭제가 빠릅니다.

배치된 리소스와 리소스 앨리어싱에 대해 더 자세한 내용은 D3D 도구 챕터에서 다루도록 하겠습니다.

 

객체당 데이터 (이미지가 아닌)를 포함하는 버퍼에 대해서는, 프레임당 사용되는 크기가 작기 때문에 이런 시스템이 필요하지 않습니다. 그러므로 이 경우엔 선형 할당자를 사용합니다.

텍스처의 경우 이들이 GPU 메모리 사용의 대부분을 차지하므로 상황이 좀 달라집니다, 그러므로 텍스처에 대해서는 리소스 앨리어싱 전략을 사용할 수 있습니다.

 

만약 성능을 최적화하는데 관심이 있으시다면, Pavlo Muratov의 블로그 포스트에 렌더 그래프에서의 앨리어스 된 리소스를 위한 최적 스케쥴링 알고리즘을 찾아보실 수 있습니다.

병렬 커맨드 리스트 기록

앞에서 말씀드렸듯이, 렌더 그래프를 사용하는 것은 동시에 실행되는 서로 다른 커맨드 큐 사이의 패스 연결을 처리하는데도 도움을 줍니다.

대부분의 일반적인 경우엔 그래픽스(Graphics) + 비동기 컴퓨트(Async Compute) 커맨드 큐로 구성합니다.

컴퓨트 패스를 비동기적으로 실행하는 명령은 패스 정의 이전에 그냥 다음과 같이 호출하는 것입니다.

MyGraphBuilder::ExecuteNextAsync();

그럼 그래프는 이 정보를 가지고 그래픽스 컴퓨트 큐를 사용하는 대신에 병렬 컴퓨트 큐를 사용해서 다음 패스를 시작합니다.

더 똑똑한 그래프의 경우, 이 옵션은 흐름 구조를 검사해서 선택될 수도 있습니다.

그래프가 자동으로 비동기 컴퓨트 작업을 처리하도록 할 수 있지만 이는 최적이 아닙니다 그리고 수동 설정이 선호됩니다.

비동기적으로 실행한 모든 패스를 위해서, 리소스 수명이 패스의 출력이 메인 렌더 스레드에서 다시 사용될 때까지 유지되도록 보장해주어야 합니다.

 

이런 모든 메커니즘은 기초적으로 서로 다른 스레드 실행을 동기화하기 위한 GPU 펜스를 사용해서 처리됩니다. 이때 까다로운 부분은 이들이 타이밍 코스트를 가지고 있기 때문에 이런 동기화 지점의 수를 최소화 하기 위한 최선의 접근법을 찾는 것입니다.

만약 D3D12 커맨드 큐의 동기화에 대한 더 많은 정보를 아시고 싶으시다면 공식 문서를 참고해주세요.

구성 및 워크플로우

이전에 UE4의 쉐이더에 대한 블로그 포스트에서 UE4에서 사용하는 렌더 그래프에 이게 어떻게 작동하는지에 대해 간단히 말한 적이 있었습니다.

렌더 그래프는 프레임을 렌더링 하기 위한 정보를 모두 모은 다음에, 워크플로우를 최적화하고 마지막으로 결과를 렌더링 하는 방식의 Retained Mode Model을 사용합니다. 이는 요소들을 받는 즉시 렌더링 하는 "Immediate Mode"와 반대되지요. 그럼에도 즉시 모드는 구현하기엔 가장 직관적이고, 리테인 모드는 더 많은 최적화를 할 수 있도록 해줍니다.

렌더 그래프를 사용할 땐 이를 3가지 단계로 나눠볼 수 있습니다:

  • 준비 단계 (Setup phase): 이 단계에선 어떤 렌더 패스들이 존재하고 어떤 리소스들이 존재하는 렌더 패스들에 의해서 접근되는지 정합니다.
  • 컴파일 단계 (Compile phase): 리소스들의 수명을 알아내서 이를 기반으로 리소스 할당을 진행합니다.
  • 실행 단계 (Execute phase): 모든 그래프의 노드들을 실행시킵니다.

프로스트바이트의 프레임 그래프는 모든 프레임마다 처음부터 다시 만들어지고 코드 주도 아키텍처를 가지고 있습니다. 이는 필요에 따라 패스들을 삭제하거나 추가하는 등의 더 많은 유연성을 위해 선택되었습니다.

하지만 apoorvaj.io에서 논의되었듯이 전체 그래프를 매 프레임마다 새로 만들 필요는 없고, 렌더 그래프가 변경되었을 때만 새로 만들어 주면 됩니다. 이를 위한 전략은 먼저 정해진 패스들의 조합에 대한 해시를 준비 단계 이후에 계산해두고, 새로운 해시를 계산했을 때 이전 프레임에서 계산한 해시와 같다면, 이미 계산된 그래프를 다시 사용합니다, 그렇지 않으면 새로운 그래프를 만들고요.

참고: 시스템이 거의 항상 모든 정해진 패스들을 필요로 한다면 시스템에서 그래프 준비 단계를 딱 한 번만 실행하고, 명시적으로 요청된 경우에만 재 빌드하는 식으로 그래프를 정적으로 만들 수 있습니다.

그래프를 만드는데 GraphBuilder 타입의 객체를 사용할 수 있고, 여기에서 우리가 모든 렌더 패스들을 밀어 넣는데(push) 사용되는 인스턴스를 정적으로 얻어올 수 있습니다.

GraphBuilder::Get().AddPass(
“MyPassName”,
Flags,
MyPassParameters,
[MyPassParameters](){
    /* 이 람다 함수의 몸체는 PassParameters (또는 사용가능한 다른 정보)를 사용해서 디스패치나 드로우 커맨드를 전송하기 위해 사용됩니다 */
} );

각 패스는 다음으로 요소들로부터 만들어지게 됩니다:

  • 이름
  • 플래그 (예시. 패스가 그래픽스 패스 일지 컴퓨트 패스일지 정해야 할 때)
  • 패스 파라미터: 필요한 리소스 전환, 수명 그리고 앨리어싱들을 알아내기 위해 사용됩니다.
  • 실행 람다: 정해둔 파라미터들을 캡처해서 드로우나 디스패치 커맨드에서 사용하도록 만들기 위한 부분입니다.

패스 파라미터는 적절한 전환을 감지하기 위해 최소한 쉐이더 리소스 그리고 렌더 타깃 중 하나로 구분되어야 합니다.

struct MyPassParameterStruct{
	std::vector<ResourceRef> ShaderResources;
	std::vector<ResourceRef> RenderTargets;
}

렌더 패스들은 더 나아가 렌더 피처(Render Feature)들로 조직될 수 있습니다. 이는 패스들의 그룹이고 렌더 파이프라인에 적용될 특별한 효과를 정의합니다.

렌더 피처의 예시로 베이스 패스, 환경 반사, 지연 라이팅(디퍼드 라이팅) 또는 쉐도우 맵등이 있습니다.

피처 리소스가 다른 피처 리소스에 종속적 인일 수 있습니다, 예를 들어 디퍼드 라이팅은 베이스 패스에 종속적인 경우, 또는 쉐도우 맵이 쉐도우 깊이에 종속적인 경우가 있습니다.

한 가지 해결 방법은 각 피쳐의 출력 데이터 레퍼런스를 저장하기 위한 맵 컨테이너를 사용하는 것입니다: 종속적인 패스는 첫 번째 패스의 출력이 정의된 헤더를 포함합니다, 그리고 헤더의 리소스들을 필요한 경우 종속성으로써 추가해줍니다.

// 피쳐의 마지막에 이 구조체의 인스턴스는 채워져서 출력 맵에 삽입됩니다.
struct MyFirstFeatureOutput {
	ResourceRef MyFirstOutput;
}

// …
MyFirstFeatureOutput featureOutput { outputResource };

GraphBuilder::AddFeatureOutput<MyFirstFeatureOutput>(featureOutput);

// 이후에 종속적인 피처의 패스 코드에서는

#include “MyFirstFeature.h”

MyFirstFeatureOutput& firstFeatOutput = GraphBuilder::GetFeatureOutput<MyFirstFeatureOutput>();

MyPassParameterStruct.ShaderResources.add(firstFeatOutput.MyFirstOutput);

UE4에 적용된 또 다른 해결 방법은 그래프로부터 해싱된 문자열을 통해 리소스를 얻어오는 것이 있습니다.

ResourceRef sceneColor = GraphBuilder::GetOrCreateResource("SceneColor");

MyPassParameterStruct.ShaderResources.add(sceneColor);

물론 이 방법은 덜 안전합니다, 왜냐하면 다른 패스에서 어떤 리소스가 어떤 문자열을 사용하는지 알아야 하기 때문입니다.

다른 한편으로는, 이 접근법은 특정 패스의 출력이 무엇인지 예상하지 않는 대신에 리소스에 이름을 지어주고 이전에 사용된 적이 없다면 그 자리에 새로 만들어 줌으로써 더욱 유연해질 수 있습니다.

준비 단계

준비 단계에서 시스템은 모든 정의된 렌더 패스를 따라서 모든 선언된 리소스들을 검사합니다. 각 선언된 리소스들은 사용 타입과 연관된 플래그를 가집니다, 예를 들어. Read, Write, ComputeRW, Render Target, Depthstencil 그리고 새로운 그래프 리소스를 할당해야 할 때는 그래프와 통신하기도 합니다, 예를 들어

TextureRef MyGraphBuilder::CreateTexture(const TextureDesc& Desc, const char* Name, TextureFlags Flags);

레퍼런스 된 (또는 만들어진) 리소스는 Clear와 같은 텍스처 플래그를 명시하여, 쓰레기 데이터를 읽게 되는 경우를 방지하기 위해서, 패스를 사용하기 전에 텍셀들을 특정 값으로 채워 놓도록 할 수도 있습니다.

이런 리소스들의 CPU 메모리는 패스가 실행될 때부터 끝날 때까지 보장됩니다 (CPU 그리고 GPU에서 작업 둘 다) 메모리는 다음 프레임에서 재사용될지 고려됩니다.

렌더 타깃 리소스에 관해서는, 언제든 그중 하나에 쓰기를 실행할 때마다, 현재 리소스 이후에 실행된 패스를 피하고 리소스의 이전 상태를 사용을 고려하기 위해 그래프에서 모든 이전 참조들을 무효화해야 합니다.

이는 리소스에 다른 내부 업데이트 인덱스를 부여함으로써 이루어질 수 있고, 그럼으로써 런타임에 유효성을 검사할 수 있습니다.

 

수명이 그래프의 실행에 묶여있는 리소스를 제쳐두고, 외부 시스템으로부터 만들어진 외부 리소스들을 레퍼런스 할 수도 있습니다 (왜냐하면 다른 방식으로 사용되거나 엔진의 다른 부분에서 필요로 할 수도 있기 때문에).

BufferRef MyGraphBuilder::RegisterExternalBuffer(const PooledBuffer& ExternalPooledBuffer, BufferFlags Flags);

이런 케이스의 리소스들은 그래프에 의해 추적되고, 필요하다면 상태 전환을 시행합니다.

하나의 예시로 외부 리소스가 윈도우의 API의 윈도우 스왑 체인 후면 버퍼인 경우가 있습니다.

 

보통 우리는 단일 커맨드 큐 상에서 신뢰할만한 타임라인을 가지기 위해 어떤 패스가 그래프에 등록된 순서에 의존하곤 합니다. 다시 말씀드리자면, 싱글 커맨드 큐에 보내진 명령어들은 우리가 그 명령어들은 보낸 순서대로 실행되는 걸 보장받습니다.

추가 최적화

그래프의 리소스 들은 지연 초기화(lazy initialzation)를 통해 만들어질 수도 있습니다, 즉 리소스를 그래프 준비 단계에서 만든다 해도 할당은 오직 리소스를 필요로 하는 첫 패스가 실행되기 전에 이루어집니다.

시스템은 사용된 리소스들의 상태를 연역(deduce) 할 수도 있습니다, 예시. 텍스처가 픽셀 쉐이더를 위한 리소스로 바인드 되었을 다면 별도로 지정해주지 않더라도 읽기 상태(read state)로 연역될 수 있습니다(만약 텍스처를 UAV와 함께 사용하고 싶은 경우).

또한 패스의 입력으로부터 리소스의 생성을 연역해내는 것도 가능합니다, 예를 들어 입력 버퍼 이미지로부터 밉맵이 생성된 텍스처를 만드는 경우, 크기와 특성(attributes)들을 연역해낼 수 있습니다.

컴파일 단계

컴파일 단계는 완벽하게 자주적이고 "프로그래밍 불가능한" 단계입니다, 이는 렌더 패스 프로그래머가 컴파일 단계에 관여할 수 없다는 의미입니다.

이 단계에서는 그래프는 모든 가능한 흐름 최적화들을 찾기 위해 들여다봐지는데, 그러기 위해 다음의 일들이 일어납니다:

  1. 정의되어 있는 리소스와 패스들이지만 참조되지 않는 것들을 제외시킨다:만약 우리가 장면의 두 번째 디버그 뷰를 그리고 싶다면, 이를 그리기 위해 필요한 패스들만 필요로 할 것입니다.
  2. 사용된 리소스의 수명을 계산하고 관리합니다
  3. 리소스를 할당합니다
  4. 최적화된 리소스 상태 전환 그래프를 구축합니다

최적화된 리소스 상태 전환을 위해서, D3D12에선 우리는 구체적인 경우의 D3D12_RESOURCE_TRANSITION_BARRIER를 위해서 ID3D12GraphicsCommandList::ResourceBarrier 메서드를 사용할 수 있습니다.

이 콘셉트를 기반으로 우리를 위해서 리소스의 전환을 대신해줄 내부 서브시스템을 만들 수 있습니다. 가장 기본적인 예시를 (렌더 그래프를 사용하지 않고) 3DGEP.com의 리소스 상태 추적 - DirectX 12 학습 파트 3 섹션에서 찾아볼 수 있습니다, 그러므로 여기서는 별도로 다루진 않겠습니다.

하나 이상의 커맨드 큐를 실행시키는 경우엔, 상황은 더 복잡해집니다.

큐 사이의(Cross-Queues) 동기화 구축하기

여러분도 아시다시피, 같은 커맨드 큐 내에서의 렌더 작업은 순차적으로 실행됩니다, 하지만 우리가 여러 개의 커맨드 큐를 쓴다면, 작업들은 병렬적으로 실행되게 되므로, 공유된 리소스에서의 레이스 컨디션을 막기 위해 동기화 메커니즘이 필요로 하게 됩니다.

더 적은 펜스를 쓸수록, 더 빠른 렌더 작업이 가능하게 됩니다, 그리고 컴파일 단계는 두 개 이상의 큐 사이의 기다림을 최소화하기 위한 알고리즘을 넣기에 가장 적합한 장소입니다.

참고: 2017년에 발표된 렌더 그래프를 따르는 언리얼 엔진과, 프로스트 바이트의 경우, 펜스의 최소 양을 계산하지 않습니다, 대신 그들이 워크플로우를 최적화하고 싶은 곳이나 특별한 케이스에서 필요로 할 때 수동으로 펜스를 두었습니다, 왜냐하면 실제 AAA급 엔진 시나리오에서는, 하나의 알고리즘이 관리하기에 비현실적으로 많은 숨겨진 극단적인 경우가 많기 때문입니다.

Pavlo Muratov는 그의 블로그 아티클에서 어떻게 하면 자동으로 병렬 커맨드 큐 사이에서 최적의 펜스를 계산해낼 수 있을지에 대한 아이디어에 대해 설명했습니다.

메인 콘셉트 중 하나가 제안한 것은 사실 D3D12같은 API 안에서는, 모든 종류의 가능한 리소스 상태를 전환할 수 있는 그래픽스 커맨드 큐가 오직 하나이고, 이를 "가장 능숙한(competent)" 큐라고 부를 수 있다는 것입니다. 그러므로, 적절한 동기화 후에, 모든 리소스 상태 전환을 단일 (그래픽스) 큐에서 실행하는 것이 더 낫다는 것이 입증되었다는 것입니다.

그의 아티클에서, Pavlo는 렌더 패스들의 비순환 그래프로부터 어떻게 의존 트리(dependency tree)를 만들어내는지에 대해 설명합니다. 이는 종속 큐로부터 모든 패스를 놓아준 이후에 할 수 있습니다.

종속 트리의 각 레벨은, 의존 레벨(dependency level)이라고 부르고, 패스들의 리소스 사용 관점에서 서로 독립적인 패스들을 포함하고 있습니다.

그럼으로써 우리는 같은 종속성 레벨에 있는 모든 패스들이 잠재적으로 비동기적으로 실행될 수 있음을 확신할 수 있습니다. 아직 다수의 패스들이 같은 종속성 레벨에 있는 같은 큐에 속해있는 경우들이 있긴 하지만, 이 점은 우리가 신경쓰이 않아도 됩니다.

결과적으로, 모든 종속성 레벨의 마지막에 GPU 펜스와 함께 동기화 지점을 설정할 수 있게 됩니다: 이는 단일 그래픽스 커맨드 리스트에 있는 모든 큐를 위해 필요한 리소스 상태 전환을 실행합니다.

이런 접근법은 펜스를 사용하는 것과 와 다른 커맨드 큐의 동기화시키는 게 시간적 비용을 가지고 있는 한  당연히 무료로 이루어지진 않습니다.

추가적으로, 이 방법이 언제나 최적이고 최소한의 동기화 횟수를 만들어내는 것은 아닙니다, 하지만 납득할만한 퍼포먼스를 만들어낼 수 있고 모든 가능한 극단적인 상황을 커버할 수 있게 됩니다.

참고: 실제로 사용할 때는, 달성할 수 있는 병렬화의 양은 사용 중인 하드웨어에 달려 있습니다 그리고 각 패스당 얼마나 많은 부하량을 가지느냐에 따라서도 달라질 수 있습니다.

실행 단계

실행 단계는 컴파일 단계의 컬링에서 살아남은 패스들을 쭉 따라가면서 리스트에서 드로우 그리고 디스패치 명령어를 실행하기만 하면 되는 만큼 간단합니다.

실행 단계에 이르기까지 모든 리소스들은 불투명하고 추상적인 레퍼런스로써 다루어졌습니다, 하지만 실행 단계에서는 실제 GPU AI 리소스에 접근하고 이들을 파이프라인에 설정해줍니다.

CPU 측면에서의 커맨드 리스트의 준비는, 잠재적으로 꽤 많이 병렬화될 수 있습니다: 대부분의 경우, 각 패스 커맨드 리스트 준비(setup)는 서로 독립적입니다. 

이를 제쳐놓고, 단일 커맨드 큐에서의 커맨드 리스트 제출은 스레드-세이프하지 않습니다, 그리고 어쨌든 만약 병렬화를 추가한다면 이것이 눈에 띌만한 이점을 가져오는지 정해야 합니다.

사용 가능한 Direct3D 도구들

커맨드 리스트 제출

ID3D12CommandQueue::ExecuteCommandLists 메서드를 사용해서 여러 개의 커맨드 리스트를 동시에 제출할 수 있습니다.

예를 들어, 만약 각 레이어가 독립적인 패스들을 포함하고 있는 패스 종속성 트리를 먼저 만든다고 한다면.

다른 커맨드 리스트에 있는 모든 패스들을 같은 "종속성 레이어(dependency layer)"에 둘 수 있습니다. 그리고 같은 ExecuteCommandLists 호출을 통해 다 같이 실행시킬 수 있습니다.

여기서 비결은 절대로 얼마나 많은 이러한 작업들이 실제로 GPU상에서 병렬화 될지 확신할 수 없다는 것입니다. 문서(ExecuteCommandLists의) 스스로도 두 번째 리스트가 "아마도" 첫 번째 배치의 마지막 리스트가 실행되기 전에 실행될지도 모른다고 말하고 있습니다, 하지만 이들이 하드웨어-종속적인 부분인 이상 우리는 확실하게 알 수 없습니다.

우리가 정확하게 알고 있는 건 이런 경우(순서가 보장되어야 하는 경우) 한 번에 여러 개의 커맨드 리스트를 제출해야 하고, 개별적으로 제출하기 위한 CPU 워크로드를 할애해야 한다는 것입니다.

배치(Placed)되어있는 리소스

커밋된 리소스를 만드는 한, 그리고 각각에 대해 독립적인 리소스 힙을 가지게 되면, 메모리를 엄청나게 많이 사용하게 됩니다, 이를 위한 해결법은 바로 배치되어있는 리소스를 사용하는 것입니다.

이들은 이미 존재하는 이상적으로(ideally) 영구적인 GPU 메모리 힙 안에서 선형적으로 할당되기 때문에  "배치되어있다"라고 불립니다. Direct3D11에선 존재하지 않았던 이 특성은 이들을 다루기 위한 아주 빠르고 매우 유연한 타입 또는 리소스로 만들어 주었습니다.

우리는 배치되어있는 리소스를 이미 존재하는 힙과 오프셋, 그리고 우리가 할당하고 싶은 리소스에 대한 정보를 필요로 하는 ID3D12Device::CreatePlacedResource 메서드를 호출해서 생성할 수 있습니다.

배치되어있는 리소스를 다룰 때는, 이들로 다음 부분에서 설명할  메모리 앨리어싱을 적용할 수 있습니다.

앨리어스 된 리소스

앨리어스 된 리소스의 기본 컨셉은 만약, 렌더 타임라인 도중에, 두개의 리소스가 사용되고 이들의 사용이(그리고 수명이) 서로 겹치지 않는다면, 이들은 같은 메모리 공간을 사용 할 수 있다는 겁니다.

참고: 앨리어스된 리소스는 특히 렌더 그래프를 사용할 때 50% 이상의 사용되는 리소스 할당 공간을 아낄 수 있습니다. 이들은 장면에 추가적인 리소스 관리 복잡도를 추가하지만, 만약 메모리를 아끼고 싶다면, 이만한 게 없습니다.

또한 예약된 리소스도 앨리어스 할 수 있는데, 이는 타일화 된 리소스라고도 알려져 있습니다, 하지만 이걸 다루는 건 이 아티클의 범주를 넘어서기 때문에 다루지 않겠습니다.

앨리어스 된 리소스의 간단한 모델의 사용에선 공유 메모리가 현재 그런 리소스가 사용을 위해 할당되어있을 때, 앨리어스 된 리소스가 활성화돼있다고 말합니다. 반대로, 앞서 말한 리소스와 메모리를 공유하는 다른 리소스들은, 비활성화되어 있다고 하고, 이런 리소스는 사용해서는 안됩니다.

이는 비활성화된 리소스와 비활성화된 상태로 만들어진 배치되어있는 리소스에 GPU가 쓰거나 읽을 수 없도록 해야 한다는 것입니다.

 

두 개의 중첩된 앨리어스 된 리소스의 사용 사이에는 언제나 앨리어싱 배리어가 필요합니다.

이들은 D3D12_RESOURCE_ALIASING_BARRIER 구조체로 정의됩니다.

이들의 사용은 앨리어스 된 리소스를 위해 만들어졌다는 것을 제외하면 리소스 전환 배리어와 유사합니다.

우리가 앨리어싱 리소스를 대할 때, 일반적인 리소스 배리어는 이들이 여러 리소스에 의해 공유되는 메모리 공간을 참조하는 이상 더 이상 유용하지 않습니다.

D3D12_RESOURCE_ALIASING_BARRIER myAliasingBarrier{
prevResource.Get(), resourceToActivate.Get()};
myCmdList->ResourceBarrier(1, &myAliasingBarrier);

앨리어싱 배리어는 인자로 앨리어싱 이전에 존재했던 리소스를 가져갑니다. 그리고 그 리소스는 배리어 이후에 활성화된 리소스가 됩니다.

이전 리소스는 NULL로 남겨 둘 수 있습니다, 그리고 이 경우 이후 리소스와 메모리를 공유한 모든 리소스들은, 비활성화 되게 됩니다.

이는 앨리어싱 배리어를 다른 전환 배리어와 묶을 수 있다(batch)는 것이고. 최고의 성능을 위해선 그렇게 해야 합니다.

Adam Sawiki의 블로그 포스트 설명에 따르면, D3D12_RESOURCE_FLAGS 열거자의 D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET 또는 D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL을 통해 만들어진 리소스에 관한 중요한 사항이 있다고 합니다.

 

렌더 타깃과 깊이 스텐실은 텍스처 같은 쉐이더 리소스와 비교했을 때 GPU에서 다르게 다루어진다고 합니다. 그리고 이들은 리소스 앨리어싱에 이용할 때 추가적인 주의가 필요로 합니다.

특히, 이들이 활성화된 앨리어스 된 리소스가 될 때마다, 그리고 그래서 이후에 앨리어싱 배리어가 이들을 이후(after) 리소스로써 보게 되었을 때, 이들은 아래의 연산중 하나를 사용해서 초기화되어 있어야 합니다:

  • Clear 연산: 현재 리소스를 1과 0 또는 정의된 색상과 같이 특정한 값으로 채우는 ClearRenderTargetViewClearDepthStencilView와 같습니다. Clear는 다음 렌더 연산들이 현재 리소스 데이터의 일부분만 변경하고, 유효한 정보만을 포함하도록 남겨야 할 때 유용해집니다. 큐브를 그리기 전에 렌더 타깃을 파란색으로 채우는 것이 예시중 하나입니다.
  • DiscardResource 연산: 이는 현재 콘텐츠가 보존할 필요가 없는 부분을 알림으로써 리소스의 메타데이터만 빠르게 업데이트하는 연산입니다. DiscardResource는 다음 연산이 리소스의 모든 텍셀에 대해 계산을 할 때 선호되는 선택지입니다.
  • Copy 연산: CopyBufferRegion, CopyTexture, 또는 CopyResource처럼 다른 리소스로부터 현재의 리소스로 콘텐츠를 복사해옵니다.
참고: 렌더 타깃이나 깊이=스텐실이 아닌 경우엔 초기화 조건은 필수가 아닙니다, 그러니 그들로부터 쓰레기 데이터를 자유롭게 읽을 수 있습니다!

또한 특정 상황에서 최고의 퍼포먼스를 얻기 위해 앨리어스 된 리소스를 심화된 사용 방법으로 쓸 수도 있는데, 이는 공식 문서에 설명되어 있습니다.

심화된 사용법을 사용하면 리소스의 활성화 비활성화 상태에 대해서 잊어버려도 됩니다, 그리고 중첩된 리소스의 서브리전을 다음의 규칙을 만족한다면 아무 때나 접근할 수 있습니다:

  • 여전히 같은 메모리 서브리전에 있는 두 개의 다른 GPU 리소스 접근하기 위해선 앨리어싱 배리어가 필요합니다.
  • 간단한 모델에서 그랬던 것처럼 렌더 타깃들과 깊이-스텐실에 대해서는 서브리전 초기화가 필요합니다.

조언

펜스는 비쌉니다

렌더 그래프에서의 펜스는 특히나 두 개의 커맨드 큐 사이에서 공유되는 리소스에 대한 접근을 동기화하는데 주로 사용됩니다 (예. 그래픽스 + 컴퓨트).

각 리소스에 대해 펜스를 파인 그레이드(finely grained; 잘게 쪼개서) 쓰는 것은 좋지 않습니다: 가능한 한 최대한 많은 리소스 사용을 동기화하기 위해 펜스에 신호를 줘야 합니다.

이전 섹션들에서 말했듯이, 이는 배리어들이 리스트에 추가되어 필요할 때까지 리스트의 플러싱을 기다리도록 하는 지연 배리어 시스템을 만듦으로써 가능합니다. 단일 커맨드 큐를 사용하면 이 시스템은 꽤 직관적입니다 하지만 다중 큐는 이를 복잡하게 만듭니다. 이는 이미 컴파일 단계 섹션에서 설명하였습니다.

리소스 쉐이더 가시성을 사용하세요

쉐이더 가시성은 루트 시그니처의 입력으로 써 사용될 D3D12_ROOT_PARAMETER 구조체의 파라미터입니다.

리소스 뷰와 샘플러들에 올바른 쉐이더 가시성을 선언해줌으로써, 그래픽스 드라이버가 이들의 사용을 최적화할 수 있습니다.

이는 렌더 그래프의 기능성과 함께 제공되어야 합니다:그래프가 어떤 특정한 리소스가 사용되고 어떤 방식으로 사용되는지 알고 있는 패스들의 전체 리스트를 가지고 있으므로, 이로 부터 적절한 쉐이더 가시성을 선택하는 것도 가능합니다.

SHARED_VISIBILITY_ALL을 사용하지 마세요 그러면 최적화할 수 있는 여지가 적어집니다.

루트 시그니처와 PSO를 수정하는 건 비쌉니다

가능한 한 루트 시그니처와 PSO들을 캐싱해두세요, 그리고 이런 객체에 대한 변경을 최소화하세요.

PSO를 캐싱해두는 건 재질에 대한 가능한 연산의 범위를 제한합니다: 깊이 편향, 뷰포트, 시저 직사각형, 래스터라이저 또는 PSO의 다른 부분들을 변경하는 건 캐스를 무표화 시킬 수 있습니다 그러므로 더 이상 이런 연산들이 프레임 당 일어나도록 할 수 없습니다.

깊이 편향의 경우를 예로 들자면, 편향을 PSO 파라미터 대신 픽셀 쉐이더 안에서 사용되는 파라미터로 만든다면 이 문제를 해결할 수 있습니다.

PSO 캐시는 메모리를 가져갈 수 있고, 우린 각 재질 조합마다 잠재적으로 많은 수의 다른 PSO를 가질 수 있습니다, 하지만 이는 런타임 성능을 위해 받아들일만한 비용입니다.

 

디스크립터 테이블은 빈번하게 변하지 않는 쉐이더 파라미터 그룹으로부터 만들 수 있습니다. 그 예로 재질 쉐이더 파라미터 그룹을 위한 정적 디스크립터들이 있습니다: 먼저 이들을 참조하는 루트 테이블 항목을 만들고, 그런 재질을 사용할 때마다 같은 루트 테이블 항목을 참조하도록 할 수 있습니다.

참고 자료