5. D3D12 프레임 최적화

JellyPower·2026년 3월 23일
post-thumbnail

OpenGL/DX11같은 구세대 API의 특징

  • OpenGL/DX11과 같은 구세대 API들의 경우 유저가 CPU와 GPU간 동기화를 신경 쓸 필요가 없었습니다.
  • 유저가 신경쓰지 않아도 그래픽스 API 내부적으로
    • GPU작업이 너무 쌓여있으면 작업 제출에 Wait를 걸거나
    • 유저 모르게 동일한 용도의 버퍼를 여러 쌍 할당하고 유저 모르게 여러 프레임의 GPU작업이 비동기로 돌게하는 리네이밍을 유저 모르게 진행하거나
  • 하였기 때문입니다. 그리고 MS나 NVidia가 자체적으로 유저에게 노출되지 않은 그래픽스 API와 드라이버 내부 정보를 활용해 최적화를 진행했기 때문에 성능도 꽤 우수했죠.
  • (그래픽 드라이버 업데이트 하면 게임 성능 N% 향상! 같은 광고를 괜히 했던게 아니죠)
  • 실제로 같은 게임을 서로 다른 그래픽스 API로 구동한 게임들의 성능을 보여주는 유튜브 영상들의 경우 구세대 API들이 차세대 API들 보다 성능이 좀 더 잘 나오는 경향성을 보여줍니다. 이러한 현상은 엔진의 종류를 가리지 않고 대부분의 게임들에 나타나는 현상이죠. (엔진개발자가 아무리 최적화를 진행해도 드라이버, API 내부 정보까지 포함하여 최적화를 진행하는 MS, NVidia를 따라가기 쉽지 않은 부분도 있으니까요.)
  • 물론 요즘은 대부분의 엔진들이 DX12에 초점이 맞춰져있고 DX11는 레거시로 취급되기 때문에 DX12가 성능을 더 앞서는 경우도 심심치않게 보이긴 합니다.
  • 그리고 오늘의 목적이 DX12로 DX11만큼 GPU사용률을 높이는 것입니다.

차세대 API의 Fence

  • DX12/Vulkan/Metal 같은 차세대 그래픽스 API들의 경우 유저가 직접 리소스의 동기화를 진행해야 합니다.
  • 그 중에서도 오늘은 CPU와 GPU동기화를 담당하는 Fence라는 기능을 살펴 보겠습니다.
  • Fence의 기능설명 자체는 매우 간단합니다.
    1. 유저가 그래픽스 작업들을 밀어넣습니다.
    2. 그리고 유저는 임의의 펜스값을 Singal해줍니다. (보통 프레임 번호로 입력해줍니다)
    3. 그러면 유저는 내가 Signal해주기 이전에 제출한 GPU작업이 끝났는지를 Fence라는 객체를 통해 확인할 수 있습니다.
  • 실제 코드 구현을 보면 다음과 같습니다.
    	// ~~~ GPU 제출 작업 완료 ~~~
    	
    	_FenceCnt++;
    	_D3DCommandQueue->Signal(_Fence, _FenceCnt); // GPU작업들에 대해 Fence를 쳐줍니다.
    	const uint64 CompletedValue = _Fence->GetCompletedValue();
    	// GPU가 어느 작업까지(_FenceCnt) 완료했는지 Fence를 통해 확인 가능합니다.
    	
    	// GPU가 내가 친 Fence까지 작업을 완료하지 못한 경우
    	if (CompletedValue < AwaitValue)
    	{
    		// Event를 활용해 Wait하는 기능도 있습니다.
    		_Fence->SetEventOnCompletion(AwaitValue, _FenceEvent);
    		WaitForSingleObject(_FenceEvent, INFINITE);
    	}

게임엔진의 렌더링 파이프라인

  • 위 그림은 일반적인 게임엔진들의 렌더링 파이프라인입니다.
  • 물리/애니메이션, 렌더링과 같은 작업들을 비동기로 돌린다고 하더라도 동기화 때문에 모든 게임엔진은 큰 틀에서 위 구조를 벗어날 수 없습니다.

  • 위 그림은 GPU 작업을 포함한, 아주 기초적인 형태의 렌더링 파이프라인 입니다.
  • GPU가 작업을 진행하는 도중 콘스탄트 버퍼와 같이 시스템에 잡힌 메모리들의 값을 업데이트 해버리면 렌더링이 크게 어그러 질 수 있기 때문에 모든 렌더링 과정에는 동기화가 필수적입니다.
  • 그래서 게임엔진들은 모두 실제 렌더링이 시작되기 전에 이전 작업이 완료되길 기다립니다.

위 파이프라인의 큰 문제점

  • 근데 위와 같이 단순한 방식으로 작성된 렌더러의 경우 아주 큰 문제가 있습니다.
  • 바로 매 프레임마다, 직전 프레임의 렌더링이 끝나는 것을 기다려야 한다는 것입니다.
  • 도표에선 보틀넥이 크지 않아보이지만 실제 게임엔진 작업량의 대부분은 렌더링에 할당되기에 전체 타임라인 대비 BOTTLE NECK이 되는 부분의 점유율이 꽤 높습니다.
  • 더불어 CPU입장에서 GPU와 작업을 주고받는건 I/O작업입니다. 동기로 I/O완료를 기다리고 다음 작업을 시작하는 것은 큰 비용 낭비입니다.

해결 방법

  • 사실 해결방법은 간단합니다. 직전프레임이 아니라 2프레임 이전 프레임의 GPU작업을 기다려주면 됩니다.
  • 위와같은 파이프라인으로 렌더링이 진행되면 GPU는 작업을 마무리 할 새 없이 새로운 작업을 계속해서 받을 수 있고 CPU는 GPU의 작업이 완료되는 것을 기다리기를 최소화 할 수 있습니다.
  • GPU작업이 너무 많아서 2프레임 이전 작업의 Fence를 기다리게 된다 하더라도 1프레임 이전의 작업들은 아직 쌓여있는 상태이기 때문에 CPU와 GPU작업을 비동기로 진행할 수 있게 됩니다.

해결방법의 문제점

  • 그런데 위 해결방법은 해결해야할 아주 심각한 문제가 있습니다.
  • 위에서 제가 “GPU가 작업을 진행하는 도중 콘스탄트 버퍼와 같이 시스템에 잡힌 메모리들의 값을 업데이트 해버리면 렌더링이 크게 어그러 질 수 있”다 라고 말했었죠?
  • GPU작업이 진행되는 중에 같은 리소스에 대고 CPU에서 계속해서 Write작업을 진행하게 되면 렌더링이 부르르르 떨린다거나 깜빡인다거나하는 해결불가능한 문제를 맞이하게 됩니다.
  • 게다가 차세대 API들의 경우 GPU에서 작업이 진행돼고 있는 중에 유저가 리소스를 할당 해제해 버리면 API가 크래시를 내뱉기 때문에 이 또한 해결하여야 합니다.

해결방법의 해결방법(?)

  • 이를 해결하는 방법도 간단합니다. 중첩해서 렌더링할 프레임의 숫자만큼 리소스를 예비해주면 됩니다.
  • 근데 사실 이게 말이 간단하지 실제 구현은 많이 복잡합니다.
  • GPU에서 접근가능한 모든 시스템 메모리를 2벌씩 만든다는 것은 GPU Upload용 버퍼, 콘스탄트 버퍼, 스키닝 포즈 버퍼 등 렌더링 시스템 전체를 리팩토링 해야하는 큰 작업이기 때문입니다.

실제 구현

  • 제 엔진같은 경우에는 다행히도 그래픽스 API디펜던트한 기능들을 별도의 모듈으로 분리해놨습니다. 그래서 실제 렌더될 객체에 대응되는 GPU용 버퍼를 중첩되는 프레임 개수만큼 생성함으로서 해당 문제를 해결했습니다.
#deifine GAL_NESTED_FRAME_CNT 2

class RIStaticMesh : public IRIMesh
{
private:
	// GALRIMetadata 가 바로 그래픽스 API 디펜던트한 렌더 객체입니다.
	GALRIMetadata* _MetaData[GAL_NESTED_FRAME_CNT] = { nullptr, };
.
.
.
public:
	// FrameMod 는 현재 프레임을 중첩해서 렌더할 프레임 숫자로 모듈로한 값입니다.
	virtual GALRIMetadata* GetGALMetadata(int32 FrameMod) const override;
	virtual void ReleaseGALMetaData() override;

public:
	// 에셋들의 경우 중첩되는 프레임 숫자만큼 만들 필요는 없습니다.
	virtual IMeshAsset* GetMeshAsset() const override;
	virtual IMaterialAsset* GetMaterialAsset(int MtlIdx) const override;
.
.
.

};
class GALRenderDeviceContext : public INoncopyable
{
protected:
	GALRenderDevice* _OwnerRenderDevice = nullptr;
	// 임시 콘스탄트 버퍼와 GPU Update용 버퍼 또한 GAL_NESTED_FRAME_CNT 만큼 생성
	GALResourceUpdater* _ResourceUpdater[GAL_NESTED_FRAME_CNT] = { nullptr, }; // TODO: 이거도 GAL_NESTED_FRAME_CNT 만큼 만들고
	SSTransientMemAllocator* _TransientCBAllocator[GAL_NESTED_FRAME_CNT] = { nullptr, }; // TODO: 이거도 GAL_NESTED_FRAME_CNT 만큼 만들어야 함
.
.
.
public:
	GALRenderDevice* GetOwnerRenderDevice() const { return _OwnerRenderDevice; }
	GALResourceUpdater* GetResourceUpdater(int FrameMod) const { return _ResourceUpdater[FrameMod]; }
	SSTransientMemAllocator* GetTransientCBAllocator(int FrameMod) const { return _TransientCBAllocator[FrameMod]; }
.
.
.
};

프로파일링 결과

  • 위 씬은 제가 테스트를 위해 렌더링 할 씬입니다.
  • 렌더할 객체는 총 4041개입니다.
  • 디퍼드용 G-Buffer작업과 섀도우맵용 작업으로 드로우콜이 2 번씩 들어가서 8082번의 드로우콜을 진행합니다.
  • (테스트용 씬이라 메테리얼 작업이 안돼있으니 양해해 주세요)

중첩렌더링 적용 O

  • 평균 85프레임정도 나옵니다.
  • 펜스를 기다리는 작업에 6.1ms 를 소요합니다.
  • GPU사용률은 거의 100% 가깝게 찍힙니다.

중첩렌더링 적용 X

  • 평균 51프레임정도 나옵니다.
  • 펜스를 기다리는 작업에 11.9ms 를 소요합니다.
  • GPU사용률은 65% 정도 찍힙니다.

결론

중첩프레임 On중첩프레임 Off성능 비교
FPS8551프레임 타임 40% 감소
GPU Usage98%65%GPU 사용률 150% 증가

추가(언리얼 렌더링 파이프라인)

  • 언리얼은 메인 스레드(콘텐츠) 작업, 렌더 스레드 작업, 그래픽스 API작업을 3단계로 병렬화 해놨습니다.
  • 그래서 제가 작성한 파이프라인과 다르다 생각할 수 있으나 결국 각 프레임의 동기화 기준으로 생각해보면 충분히 동일한 구조라 볼 수 있습니다.

0개의 댓글