4. D3D12 리소스 최적화 방법

JellyPower·2025년 11월 26일
post-thumbnail

안녕하세요오늘은

  • 현재 제가 제작하고 있는 자체제작 엔진의 메모리 최적화 기법을 소개해드리려 합니다.
  • 엔진 기반 시스템과 밀접한 최적화들의 경우 미리 준비해놓지 않으면 이미 만들어놓은 상당수의 코드들을 갈아엎어야 할 수 있기 때문에 엔진 제작 초창기에 미리 처리해 놓는 것이 좋다고 생각합니다.
  • 오늘은 그 중에서도 메모리 할당/해제 속도 향상과 메모리 절약 관련된 주제로 이야기를 해보고자 합니다.

ID3D12Device::CreateCommittedResource

  • D3D12에 익숙한 사람들은 위 함수도 꽤 익숙할 겁니다.
  • 위 함수는 D3D12에서 CPU와 GPU사이의 인터페이스 역할을 해줄 수 있는 메모리 공간을 할당하는 함수입니다.
  • GPU에 메모리를 할당하거나, GPU에서 접근 가능한 CPU공간의 메모리를 할당하거나 등등… GPU작업과 CPU작업간 공유 가능한 메모리 영역을 만들어 내기 위한 함수로 대부분의 D3D12예제에서 사용되는 함수입니다.
  • 그런데, 해당 함수는 MS의 D3D예제대로만 사용하기엔 큰 단점을 가지고 있습니다. 바로 메모리를 엄청 먹고 할당 속도도 느리다는 거죠. 왜그럴까요?

CreateCommittedResource 함수의 숨겨진 작동방식

  • 해당 함수의 msdn 문서를 보면 “힙이 전체 리소스를 포함할 만큼 크고 리소스가 힙에 매핑되도록 리소스와 암시적 힙을 모두 만듭니다.” 라고 나와있습니다.
  • 쉽게 표현하면 해당 함수는 두 가지 역할을 합니다.
    1. 암시적 힙을 만든다. (ID3D12Device::CreateHeap)
    2. 리소스를 만든다. (ID3D12Device::CreatePlacedResource)
  • 여기서 힙을 만든다 라는 동작이 아주 큰 문제가 됩니다.
  • GPU에서 접근가능한 메모리 공간을 할당한다는 것은 우리가 기존에 사용하던 시스템 메모리와는 다른 공간에 할당해야 합니다.
  • (메모리 공간을 나눠놓지 않으면 운영체제 상의 보안 문제가 생기며 GPU와 메모리 공간을 공유하려면 메모리의 페이지 자체가 Write Combine가능하거나 MMIO를 통해 연결돼있거나 하는 등의 필요성이 있기 때문입니다.)
  • 그렇기 때문에 CreateCommittedResource 함수 자체는 별도의 메모리 공간이 필요하며, 이는 즉 시스템콜이 필수적으로 들어간다는 뜻입니다.
  • 게다가 만들어지는 힙들의 경우 4KB 얼라인이 지켜지기 때문에 캐릭터 1개를 위한 64 Byte 공간만 요청한다 하더라도 CreateCommittedResource를 호출하면 4KB나 되는 메모리가 낭비되는 셈입니다.
  • 위 캡처는 PIX의 Framing Capture 기능을 통해 확인한 Vritual Alloc을 통해 할당된 메모리들입니다.
  • 저는 코드상으로 32 Byte만 할당했는데도 실제론 4KB의 페이지가 할당됐다고 나옵니다.
  • 이런 방식으로 코드를 작성하게 되면 프랍이나 캐릭터 1개를 스폰하기 위해 시스템콜과 4KB나 되는 메모리가 필수적으로 들어가야 하는 상황이 돼버립니다.

그러면 어떻게 해야할까?

  • 그런데, 우리는 CreateCommittedResource 함수를 사용하지 않을 수 없습니다.
  • 결국 CPU와 GPU가 메모리를 공유하게 하려면 D3D12용 Heap을 만들고 Heap을 활용 가능하게 하는 D3D Resource를 만들어야 하니까요.
  • 그러면 이를 해결할 방법은 결국 CreateCommittedResource 자체의 호출을 최소화 하는 겁니다!!

1. 메모리풀

  • 메모리 풀은 쉽게 말해 커다란 공간을 하나 할당하고 해당 공간을 쪼개 쓰는 방법입니다.
  • 이를 D3D12에 적용한다면 CreateCommittedResource 를 통해 커다란 메모리 공간을 생성하고 이를 쪼개 쓸 수 있게 시스템을 만들어주면 됩니다.
  • 해당 방법은 메모리를 쪼개는 “사이즈가 고정”돼있어 할당, 해제 과정이 매우 빠르다는 것이 장점입니다.
  • 메모리 풀을 사용하는 것은 꽤 간단하고 성능도 훌륭한 최적화 방식입니다.

2. 커스텀 힙

  • 커스텀 힙은 생소하신 분들도 있을텐데 간단하게 설명하자면 malloc같은 메모리 할당 함수의 일부를 운영체제 밖의 영역에서 유저가 직접 구현하는 방식입니다.
  • 해당 방식은 제가 실제로 구현한 방식으로 현재 사용 가능한 메모리 공간과 사용하고 있는 메모리 공간을 기록하여 실제로 사용 가능한 메모리 공간을 “가변적인 사이즈로” 잘라서 메모리 공간이 필요한 객체에게 이를 던져주는 방식입니다.
  • 공간의 사용 여부를 기록하고 대여한 메모리 공간 반환시 반환된 공간과 기존에 남아있던 가용 가능 공간들을 병합하는 과정이 들어가야 하기 때문에 할당, 해제가 메모리 풀에 비해 느리긴 하지만 다양한 사이즈의 메모리 청크 요청에 대해 대응이 가능하다는 장점이 있습니다.

3. 실제 구현

  • 아래 코드는 실제로 제가 엔진 최적화를 위해 사용하는 코드입니다.
  • D3D12에서 할당해 주는 메모리 공간을 나눠쓰기 위하여 어느 공간을 쓰고있고 어느 공간을 쓸 수 있는지를 별도로 기록해 주는 코드입니다.
	.
	.
	.
	
	int64 DefaultPageCnt = _DefaultPages.GetSize();
	for (int32 i = 0; i < DefaultPageCnt; i++)
	{
		SS::PooledLinkedList<AvailableMemspace>::iterator iter = _DefaultPages[i].AvailableChunks.begin();
		for (;iter != _DefaultPages[i].AvailableChunks.end(); ++iter)
		{
			AvailableMemspace& UsableSpaceItem = *iter;
			if (UsableSpaceItem.Size >= NeededSize)
			{
				int32 OriginalChunkOffset = UsableSpaceItem.Offset;

				UsableSpaceItem.Offset += NeededSize;
				UsableSpaceItem.Size -= NeededSize;

				if (UsableSpaceItem.Size == 0) // 사용하고 나서 해당 공간의 사이즈가 0이되면 그냥 없애버린다.
				{
					_DefaultPages[i].AvailableChunks.Erase(iter);
				}

				AllocatedChunkHeader NewChunk;
				NewChunk.ChunkName = ChunkName;
				NewChunk.PageContent = _DefaultPages[i].PageContent;
				NewChunk.DefaultPageIdx = i;
				NewChunk.ChunkOffset = OriginalChunkOffset;
				NewChunk.Size = NeededSize;
				_AllocatedDefaultPageChunks.PushBack(NewChunk);

				SS::PooledLinkedList<AllocatedChunkHeader>::iterator IterLastItem = _AllocatedDefaultPageChunks.GetLastValidIter();
				AllocatedChunkHeader& LastItem = *IterLastItem;
				LastItem.IdxInLinkedList = IterLastItem.GetNodeIdx();
				return LastItem;
			}
		}
	}

	// 가용 가능한 메모리 공간이 없는 경우에는 새로 페이지를 할당한다.
	ReserveDefaultPage(DefaultPageCnt + 1); // !! AllocPage !!

	SS::PooledLinkedList<AvailableMemspace>::iterator iter = _DefaultPages[DefaultPageCnt].AvailableChunks.begin();
	AvailableMemspace& FirstUsableSpace = *iter;
	FirstUsableSpace.Offset += NeededSize;
	FirstUsableSpace.Size -= NeededSize;

	AllocatedChunkHeader NewChunk;
	NewChunk.ChunkName = ChunkName;
	NewChunk.PageContent = _DefaultPages[DefaultPageCnt].PageContent;
	NewChunk.DefaultPageIdx = DefaultPageCnt;
	NewChunk.ChunkOffset = 0;
	NewChunk.Size = NeededSize;
	_AllocatedDefaultPageChunks.PushBack(NewChunk);

	SS::PooledLinkedList<AllocatedChunkHeader>::iterator IterLastItem = _AllocatedDefaultPageChunks.GetLastValidIter();
	AllocatedChunkHeader& LastItem = *IterLastItem;
	LastItem.IdxInLinkedList = IterLastItem.GetNodeIdx();
	return LastItem;
  • 만약 가용가능한 메모리 공간이 없는 경우에만 AllocPage 함수를 통해 CreateCommittedResource 함수를 호출합니다.
void* DX12ConstantBufferResourceCustomAllocator::AllocPage(int32 PageSize)
{
	ID3D12Device5* DX12Device = _RenderDevice->GetD3DDevice();
	ID3D12Resource* newResource = nullptr;
	CD3DX12_HEAP_PROPERTIES heapProperties = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);
	CD3DX12_RESOURCE_DESC resourceDesc = CD3DX12_RESOURCE_DESC::Buffer(GetEachPageSize());

	HRESULT hr = DX12Device->CreateCommittedResource(
		&heapProperties,
		D3D12_HEAP_FLAG_NONE,
		&resourceDesc,
		D3D12_RESOURCE_STATE_GENERIC_READ,
		nullptr,
		IID_PPV_ARGS(&newResource)); // 페이지 단위로 할당하고 이걸 쪼개쓴다.
	if (FAILED(hr))
	{
		DEBUG_BREAK();
		return nullptr;
	}
	newResource->SetName(GetAllocatorName().C_Str());

	byte* newResourceSysmem = nullptr;
	CD3DX12_RANGE writeRange(0, 0);
	hr = newResource->Map(0, &writeRange, reinterpret_cast<void**>(&newResourceSysmem));
	if (FAILED(hr))
	{
		DEBUG_BREAK();
		return nullptr;
	}

	DX12ConstantBufferResourcePage* NewResourcePage = DBG_NEW DX12ConstantBufferResourcePage;
	NewResourcePage->D3D12Resource = newResource;
	NewResourcePage->ResourceSysMem = newResourceSysmem;

	return NewResourcePage;
}

4. 성능 차이

  • 커스텀 힙의 페이지 사이즈가 1MB일 때 성능 비교표는 위와 같습니다.
  • 128 Byte 메모리를 1000번 할당하고 해제하는 작동방식을 비교해봤을 때 성능차이가 최대 100배까지 나는 것을 볼 수 있습니다.
  • 메모리 사이즈를 64 KB씩 할당하도록하여 D3D12Resource의 최소 페이지 사이즈보다 훨씬 큰 사이즈의 메모리를 할당하고 해제하는 경우에도 적어도 10배 이상의 성능차이가 나는 것을 볼 수 있습니다.
  • 이처럼 빠른 응답성을 필요로하는 게임엔진에서는 커스텀 힙을 만드는 것이 성능에 큰 도움을 주는 것을 볼 수 있습니다.

5. GPU 리소스 업데이트와 응용

  • 위와 같은 원리를 적용하면 해당 기법을 다양한 곳에서 응용 가능합니다.
  • 특히 이와 비슷한 방식으로 GPU에 데이터를 업데이트 하는 곳을 최적화 해줄 수 있습니다.
  • 대부분의 DX12 예제에선 텍스쳐나 버텍스 버퍼같은 GPU전용 메모리에 값을 업데이트 해 줄 때마다 CreateCommittedResource함수를 통해 중간 버퍼를 별도로 생성하고 해당 중간 버퍼에 값을 복사 한 이후 GPU전용 버퍼CopyBufferRegion 함수를 통해 복사해줍니다. 그리고 임시로 할당한 중간버퍼는 바로 삭제하죠.
  • 왜냐하면 GPU에서 접근가능하도록 할당해놓은 CPU측 메모리 공간만이 CPU에서 GPU로의 복사가 가능하기 때문입니다.
  • 그렇기 때문에 해당 동작에서도 불필요한 메모리 할당/해제로 인한 오버헤드가 존재합니다.
inline UINT64 UpdateSubresources(
    _In_ ID3D12GraphicsCommandList* pCmdList,
    _In_ ID3D12Resource* pDestinationResource,
    _In_ ID3D12Resource* pIntermediate,
    UINT64 IntermediateOffset,
    _In_range_(0,D3D12_REQ_SUBRESOURCES) UINT FirstSubresource,
    _In_range_(0,D3D12_REQ_SUBRESOURCES-FirstSubresource) UINT NumSubresources,
    _In_reads_(NumSubresources) const D3D12_SUBRESOURCE_DATA* pSrcData) noexcept
{
  • 위의 코드는 MS공식 DX12 예제에서 제공하는 텍스쳐 업로드 함수입니다.
  • 매개변수에 보면 _In_ ID3D12Resource* pIntermediate 라는 인자가 있는 것을 볼 수 있습니다.
  • DX12에서 텍스쳐 업로드용 함수들을 제공해줄 때도 CPU에서 Write가능하고 GPU에서 Read가능한 별도의 ID3D12Resource를 넘겨주도록 코드를 작성한 것을 볼 수 있습니다.
  • 저는 이를 해결하기 위해 버퍼 복사를 위한 별도의 메모리 공간을 예비해놓는 메모리 풀을 직접 만들어서 사용하고 있습니다.
HRESULT DX12GALResourceUpdater::UpdateTexture(
	ID3D12GraphicsCommandList* CommandList,
	ID3D12Resource* Dest,
	const D3D12_SUBRESOURCE_DATA* pSrcData,
	int32 NumSubResource,
	int32 UploadBufferSize,
	D3D12_RESOURCE_STATES FromState,
	D3D12_RESOURCE_STATES ToState)
{

	HRESULT hr = S_OK;
	byte* pUploadBufferData = nullptr;
	CD3DX12_RANGE writeRange(0, 0);

	int32 RentBufferStartOffset = 0;
	ID3D12Resource* UpdateResourceBuffer = RentUpdateBuffer(RentBufferStartOffset, UploadBufferSize);
	// 미리 만들어놓은 메모리 풀을 빌려서 GPU메로리를 업데이트 해줍니다.			

	CD3DX12_RESOURCE_BARRIER ResourceBarrierCommonToCopyDest = CD3DX12_RESOURCE_BARRIER::Transition(Dest, FromState, D3D12_RESOURCE_STATE_COPY_DEST);
	CD3DX12_RESOURCE_BARRIER ResourceBarrierCopyDestToVertexBuffer = CD3DX12_RESOURCE_BARRIER::Transition(Dest, D3D12_RESOURCE_STATE_COPY_DEST, ToState);

	CommandList->ResourceBarrier(1, &ResourceBarrierCommonToCopyDest);
	uint64 UpdateSize = UpdateSubresources(CommandList, Dest, UpdateResourceBuffer, RentBufferStartOffset, 0, NumSubResource, pSrcData);
	CommandList->ResourceBarrier(1, &ResourceBarrierCopyDestToVertexBuffer);

	if (UpdateSize <= 0)
	{
		SS_ASSERT(false);
		return S_FALSE;
	}

	return hr;
}
  • 위는 제 엔진 코드의 일부인데 RentUpdateBuffer 라는 함수를 통해 이미 마련된 메모리 풀을 빌려와서 사용하고 GPU리소스 업데이트가 끝나면 메모리를 해제하지 않고 메모리 풀에 반환해주는 방식으로 오버헤드를 완화해줄 수 있습니다.

코드

0개의 댓글