-
개요
이 글에서는 Direct3D 12로 구현된 샘플을 통해 PSO Cache에 대해서 알아보도록 하겠습니다.
PSO 란?
우선 PSO가 무엇인지 알아보겠습니다. PSO는 Pipeline State Object의 약자입니다. 컴퓨터 그래픽스에서 3차원 공간을 2D 스크린에 그리기 위해 Direct3D와 같은 그래픽스 API는 다음과 같은 일련의 정형화된 단계들을 거치게 됩니다.
이러한 단계를 Rendering Pipeline이라고 하며 Pipeline State Object의 Pipeline 이 바로 그것입니다. Rendering Pipeline은 여러 단계로 구성되어 있으며 물체를 그릴 때 삼각형의 앞면을 어떻게 판단할지, 깊이 검사를 할지, 어떤 색상 채널을 텍스쳐에 그릴지 등의 여러 상태(State)를 변경할 수 있습니다. 그리고 이와 같은 Pipeline의 상태를 정의하여 GPU에 설정하기 위한 객체가 Pipeline State Object입니다.
PSO Cache란?
초창기 Rendering Pipeline은 고정된 몇 가지 스위치를 변경하는 정도였으나 GPU가 점차 발전하고 고정된 스위치만으로는 다양한 요구사항을 충족할 수 없게 됨에 따라 단순한 스위치 변경을 넘어 프로그래밍할 수 있는 셰이더가 도입되게 됩니다. 그리고 프로그래밍할 수 있는 셰이더는 여타 프로그래밍 언어와 마찬가지로 GPU를 위해 컴파일되어야 합니다.
문제는 GPU가 CPU의 x86, Arm과 같은 공통적인 명령어 집합(Instruction Set Architecture)을 가지고 있지 않다는 점입니다. 각 GPU 제조사마다 자신들의 GPU 구조에 맞는 별도의 셰이더 컴파일 방법을 가지고 있습니다. 심지어 이러한 GPU 구조는 같은 제조사라 할지라도 GPU 세대 별로 달라질 수 있고 셰이더의 컴파일 방법은 드라이버의 버전에 따라서도 달라질 수 있습니다. 따라서 이런 모든 상황을 만족할 수 있도록 셰이더를 컴파일하는 것은 불가능합니다. 그래서 애플리케이션이 실행되는 도중에 새로운 셰이더 혹은 그래픽 상태를 GPU 드라이버가 컴파일하게 되고 이러한 과정에서 잠깐 애플리케이션이 멈추는 스터터링(혹은 히치)이 발생하게 됩니다.
PSO Cache는 이러한 문제점을 해결하기 위해 사용됩니다. PSO Cache에는 하드웨어, 드라이버와 같은 컴퓨터에 관련된 데이터가 포함되어 있고 이 데이터를 사용한 컴파일은 사용하지 않은 컴파일보다 빠릅니다.
이제부터 Direct3D 12를 기준으로 하여 PSO Cache 사용하는 두 가지 방법을 살펴보겠습니다. 먼저 Direct3D 12에서 제공하는 Pipeline Library를 이용한 방법부터 보겠습니다.
Pipeline Library
Direct3D 12 기능 검사
우선 현재 사용 중인 그래픽 드라이버가 Pipeline Library를 지원하는지 ID3D12Device의 기능 검사 함수인 CheckFeatureSupport 함수를 호출하여 검사할 필요가 있습니다. Pipeline Library의 지원 여부를 확인하기 위한 검사에는 D3D12_FEATURE_SHADER_CACHE 열거형이 필요하며 결과는 D3D12_SHADER_CACHE_SUPPORT_FLAGS 형의 플래그 변수인 SupportFlags 멤버 변수에 담깁니다. 해당 변수가 D3D12_SHADER_CACHE_SUPPORT_LIBRARY 열거형을 포함하고 있는지 확인하면 지원 여부를 알 수 있습니다.
D3D12_FEATURE_DATA_SHADER_CACHE shaderCacheFeature = {}; hr = m_device->CheckFeatureSupport( D3D12_FEATURE_SHADER_CACHE, &shaderCacheFeature, sizeof( shaderCacheFeature ) ); if ( FAILED( hr ) ) { return false; } m_psoLibraryCacheAvailable = shaderCacheFeature.SupportFlags & D3D12_SHADER_CACHE_SUPPORT_LIBRARY;
Pipeline Library 객체 생성
기능 검사를 통해 사용 가능 여부가 판단되면 ID3D12Device의 CreatePipelineLibrary 함수를 통해 Pipeline Library 객체를 생성하면 됩니다. Pipeline Library 객체를 생성하는 전체 코드는 다음과 같습니다.
void D3D12ResourceManager::SetPSOCache( const BinaryChunk& psoCache ) { if ( GetInterface<IAgl>()->IsSupportsPSOLibraryCache() == false ) { return; } /* HRESULT CreatePipelineLibrary( const void *pLibraryBlob, // 이전에 직렬화해 놓은 Pipeline Library의 시작 메모리 위치 SIZE_T BlobLength, // 직렬화해 놓은 Pipeline Library의 크기 REFIID riid, void **ppPipelineLibrary ); */ HRESULT hr = D3D12Device().CreatePipelineLibrary( psoCache.Data(), psoCache.Size(), IID_PPV_ARGS( m_d3d12PipelineLibrary.GetAddressOf() ) ); if ( hr == D3D12_ERROR_DRIVER_VERSION_MISMATCH ) { hr = D3D12Device().CreatePipelineLibrary( nullptr, 0, IID_PPV_ARGS( m_d3d12PipelineLibrary.GetAddressOf() ) ); } }
CreatePipelineLibrary의 인자로는 이전에 직렬화해 놓은 Pipeline Library를 전달할 필요가 있습니다. 만약 이전에 직렬화한 Pipeline Library가 없이 처음 생성하는 경우에는 각각 nullptr과 0을 전달하면 됩니다. 드라이버의 버전에 따라 이전에 기록한 Pipeline Library가 무효가 되는 경우도 있기 때문에 Pipeline Library 생성 시에는 항상 반환 값을 확인하여 드라이버 버전이 달라지지 않았는지 확인해야 할 필요가 있습니다. 위 코드에서는 CreatePipelineLibrary의 결과가 D3D12_ERROR_DRIVER_VERSION_MISMATCH 인지 확인하여 PSO 기록을 위한 빈 Pipeline Library 객체를 생성하고 있습니다.
Pipeline State Object 생성
Pipeline Library 객체를 통한 PSO를 생성에는 LoadPipeline, Load(Graphics/Compute)Pipeline 함수를 사용하면 됩니다. 먼저 Graphics pipeline과 Mesh pipeline에 대한 PSO를 생성하는 코드를 보겠습니다.
/* HRESULT LoadPipeline( [in] LPCWSTR pName, // PSO의 이름, 저장할 때 사용한 이름이여야 합니다. [in] const D3D12_PIPELINE_STATE_STREAM_DESC *pDesc, // PSO를 생성할 때 사용하였던 Desc REFIID riid, [out] void **ppPipelineState ); */ if ( m_d3d12PipelineLibrary.Get() != nullptr ) { auto hashString = std::to_wstring( psoHash ); m_d3d12PipelineLibrary->LoadPipeline( hashString.c_str(), &pipelineStateStreamDesc, IID_PPV_ARGS( newPipelineState.GetAddressOf() ) ); }
LoadPipeline의 결과로 알맞은 PSO에 대한 Cache가 존재한다면 CreatePipelineState를 호출하지 않아도 PSO를 얻을 수 있습니다. Compute pipeline도 LoadPipeline 함수를 통해 얻을 수 있지만 여기서 보여드릴 샘플에서는 D3D12_COMPUTE_PIPELINE_STATE_DESC를 사용하여 Compute pipeline을 생성하고 있기 때문에 LoadComputePipeline를 사용하여 Compute PSO를 얻어 옵니다.
/* HRESULT LoadComputePipeline( [in] LPCWSTR pName, [in] const D3D12_COMPUTE_PIPELINE_STATE_DESC *pDesc, REFIID riid, [out] void **ppPipelineState ); */ if ( m_d3d12PipelineLibrary.Get() != nullptr ) { auto hashString = std::to_wstring( psoHash ); m_d3d12PipelineLibrary->LoadComputePipeline( hashString.c_str(), &desc, IID_PPV_ARGS( newPipelineState.GetAddressOf() ) ); }
D3D12_COMPUTE_PIPELINE_STATE_DESC 형을 사용한다는 점 빼고는 LoadPipeline와 다른 부분은 없습니다.
Pipeline State Object 기록
이제 Pipeline Library 객체에 PSO를 기록하는 법을 살펴보겠습니다. StorePipeline 함수를 통해 PSO를 Pipeline Library에 기록할 수 있습니다. 코드는 다음과 같습니다.
/* HRESULT StorePipeline( [in, optional] LPCWSTR pName, // PSO의 이름 [in] ID3D12PipelineState *pPipeline // PSO ); */ if ( m_d3d12PipelineLibrary.Get() != nullptr ) { auto hashString = std::to_wstring( hash ); m_d3d12PipelineLibrary->StorePipeline( hashString.c_str(), pipelineState ); }
PSO 기록 시 사용하는 이름은 유일해야 합니다. 만약 겹치는 이름이 있으면 다음과 같은 경고가 발생합니다.
A pipeline with name "TEST" already exists in the library.
직렬화
PSO가 기록된 Pipeline Library는 직렬화를 통해 연속된 메모리 덩어리로 만들어 파일로 기록해 놓고 다음 애플리케이션 실행 시 PSO 생성을 가속하는 데 사용할 수 있습니다. 다음은 Pipeline Library를 직렬화하는 예시 코드입니다.
BinaryChunk D3D12ResourceManager::SerializePSOLibraryCache() { if ( m_d3d12PipelineLibrary.Get() == nullptr ) { return BinaryChunk(); } BinaryChunk serializedCache( static_cast<uint32>( m_d3d12PipelineLibrary->GetSerializedSize() ) ); /* HRESULT Serialize( [out] void *pData, SIZE_T DataSizeInBytes ); */ m_d3d12PipelineLibrary->Serialize( serializedCache.Data(), serializedCache.Size() ); return serializedCache; }
먼저 GetSerializedSize 함수를 호출하여 직렬화되는 메모리 덩어리의 크기를 알아야 합니다. 이렇게 얻은 메모리 크기만큼 연속된 메모리를 준비해서 Serialize 함수를 호출하면 Pipeline Library의 내용이 기록되고 해당 내용을 파일에 기록하면 됩니다.
추가적인 이슈
Pipeline Library를 사용해 보면서 겪은 이슈가 있는데 PIX 라이브러리와 함께 Pipeline Library를 사용하는 경우 Serialize 함수로 얻은 직렬화된 Pipeline Library의 내용이 유효하지 않은 경우가 있었습니다. 이 경우 Pipeline Library에 얼마나 많은 PSO를 기록하던 다음과 같이 메모리의 대부분이 0으로 채워져 있었습니다.
따라서 Pipeline Library 사용시 PIX 라이브러리를 동시에 사용하지 않아야겠습니다.
직접 만드는 PSO Cache
만약 Pipeline Library를 사용하지 못하는 경우에는 어떻게 해야 할까요? Pipeline Library는 PSO Cache를 편하게 구성할 수 있게 해주는 도구일 뿐이므로 약간의 수고를 들인다면 Pipeline Library가 없어도 얼마든 PSO Cache를 구성할 수 있습니다.
Cached Blob 얻기
Direct3D 12의 Pipeline State 객체 ID3D12PipelineState는 캐시된 Pipeline State를 나타내는 연속된 메모리를 반환하는 GetCachedBlob 함수를 제공합니다.
/* HRESULT GetCachedBlob( [out] ID3DBlob **ppBlob ); */ Microsoft::WRL::ComPtr<ID3DBlob> cachedBlob; [[maybe_unused]] HRESULT hr = pipelineState->GetCachedBlob( cachedBlob.GetAddressOf() ); assert( SUCCEEDED( hr ) ); BinaryChunk cachedPSO( static_cast<uint32>( cachedBlob->GetBufferSize() ) ); std::memcpy( cachedPSO.Data(), cachedBlob->GetBufferPointer(), cachedPSO.Size() );
이렇게 얻은 Cached Blob을 Pipeline State 생성 시 사용하여 PSO생성 속도를 올릴 수 있습니다.
PSO 생성
PSO를 생성하기 위한 정보를 담고 있는 Pipeline State Desc 구조체들은 공통으로 Cached Blob을 PSO 생성에 전달할 수 있는 멤버 변수를 가지고 있습니다.
DEFINE_ENUM_FLAG_OPERATORS( D3D12_PIPELINE_STATE_FLAGS ); typedef struct D3D12_GRAPHICS_PIPELINE_STATE_DESC { ID3D12RootSignature *pRootSignature; D3D12_SHADER_BYTECODE VS; D3D12_SHADER_BYTECODE PS; D3D12_SHADER_BYTECODE DS; D3D12_SHADER_BYTECODE HS; D3D12_SHADER_BYTECODE GS; D3D12_STREAM_OUTPUT_DESC StreamOutput; D3D12_BLEND_DESC BlendState; UINT SampleMask; D3D12_RASTERIZER_DESC RasterizerState; D3D12_DEPTH_STENCIL_DESC DepthStencilState; D3D12_INPUT_LAYOUT_DESC InputLayout; D3D12_INDEX_BUFFER_STRIP_CUT_VALUE IBStripCutValue; D3D12_PRIMITIVE_TOPOLOGY_TYPE PrimitiveTopologyType; UINT NumRenderTargets; DXGI_FORMAT RTVFormats[ 8 ]; DXGI_FORMAT DSVFormat; DXGI_SAMPLE_DESC SampleDesc; UINT NodeMask; D3D12_CACHED_PIPELINE_STATE CachedPSO; /* 여기! */ D3D12_PIPELINE_STATE_FLAGS Flags; } D3D12_GRAPHICS_PIPELINE_STATE_DESC;
D3D12_CACHED_PIPELINE_STATE는 Cached Blob의 시작 주소와 크기를 저장하는 멤버 변수를 갖고 있기 때문에 여기에 Cached Blob을 이용하여 적절하게 초기화를 해주면 됩니다.
D3D12_CACHED_PIPELINE_STATE cachedPSO = {}; cachedPSO.pCachedBlob = cashedPSO->second.Data(); cachedPSO.CachedBlobSizeInBytes = cashedPSO->second.Size(); D3D12_GRAPHICS_PIPELINE_STATE_DESC graphicsPipelineStateDesc = { /* 나머지 초기화는 생략 */ .CachedPSO = cachedPSO, };
이후 PSO를 생성하는 과정은 동일합니다. 다만 드라이버 버전 변경 등의 이유로 PSO 생성이 실패할 수 있으니 이에 따른 처리를 해줘야 합니다.
HRESULT hr = D3D12Device().CreatePipelineState( &pipelineStateStreamDesc, IID_PPV_ARGS( newPipelineState.GetAddressOf() ) ); if ( FAILED( hr ) ) { subobjectStream.CachedPSO = {}; /* Cached Blob을 비워 PSO Cache 없이 재시도 */ hr = D3D12Device().CreatePipelineState( &pipelineStateStreamDesc, IID_PPV_ARGS( newPipelineState.GetAddressOf() ) ); assert( SUCCEEDED( hr ) ); }
PSO Cache 구성
지금까지 PSO 생성을 가속하기 위한 Cached Blob과 이를 사용한 PSO 생성을 살펴보았습니다. 이제 직접 만드는 PSO Cache의 마지막 연결 고리를 채우도록 하겠습니다.
앞에서 본 것과 같이 PSO와 Cached Blob은 1대1 대응 관계입니다. PSO 생성에는 알맞은 짝의 Cached Blob을 전달해야 합니다. 따라서 직접 만드는 PSO Cache는 1대1 대응 관계를 위해 연관 자료구조를 이용해 구성됩니다. 값은 Cached Blob일 것이 명백합니다. 키의 경우에는 여러 가지가 있을 수 있는데 저는 유일한 Pipeline State를 나타낼 수 있는 해시값을 구성하여 키로 사용하였습니다. 따라서 다음과 같이 map에 Cached Blob을 저장하게 됩니다.
std::map<uint64, BinaryChunk> m_psoCache;
그리고 Pipeline State의 해시값은 Pipeline State를 이루는 각 상태들에 대한 해시값의 조합입니다. 다음은 Graphics Pipeline State의 해시값을 구하는 코드입니다.
size_t GraphicsPipelineStateInitializer::GetHash() const { size_t typeHash = typeid( GraphicsPipelineStateInitializer ).hash_code(); size_t hash = typeHash; if ( m_vertexShader ) { HashCombine( hash, m_vertexShader->GetHash() ); } if ( m_geometryShader ) { HashCombine( hash, m_geometryShader->GetHash() ); } if ( m_piexlShader ) { HashCombine( hash, m_piexlShader->GetHash() ); } if ( m_meshShader ) { HashCombine( hash, m_meshShader->GetHash() ); } if ( m_amplificationShader ) { HashCombine( hash, m_amplificationShader->GetHash() ); } if ( m_blendState ) { HashCombine( hash, m_blendState->GetHash() ); } if ( m_rasterizerState ) { HashCombine( hash, m_rasterizerState->GetHash() ); } if ( m_depthStencilState ) { HashCombine( hash, m_depthStencilState->GetHash() ); } if ( m_vertexLayout ) { HashCombine( hash, m_vertexLayout->GetHash() ); } HashCombine( hash, m_primitiveType ); return hash; }
이렇게 구성한 해시와 Cached Blob으로 map을 채운 다음 파일로 출력하면 직접 만든 PSO Cache가 완성됩니다.
void PipelineStateCache::SaveToFile() { auto agl = GetInterface<agl::IAgl>(); if ( agl->IsSupportsPSOCache() == false ) { return; } if ( agl->IsSupportsPSOLibraryCache() == false ) { m_pipelineStateCache->m_psoCacheType = PipelineStateCacheType::HandmadeCache; } if ( m_pipelineStateCache != nullptr ) { m_pipelineStateCache->m_psoLibraryCache = GetInterface<agl::IResourceManager>()->SerializePSOLibraryCache(); // Unused types of PSO cache data are deleted to save space. if ( m_pipelineStateCache->m_psoCacheType == PipelineStateCacheType::HandmadeCache ) { m_pipelineStateCache->m_psoLibraryCache = BinaryChunk(); } else { m_pipelineStateCache->m_psoCache.clear(); } Archive ar; m_pipelineStateCache->Serialize( ar ); const fs::path cachePath = agl->GetPSOCacheFilePath(); ar.WriteToFile( cachePath ); } }
마치며
지금까지 PSO Cache를 알아보았습니다. 게임과 같이 복잡하고 무거운 셰이더를 사용하는 애플리케이션에서 PSO Cache 없이 PSO를 생성하는 것은 간헐적 끊김과 같은 좋지 않은 경험을 사용자에게 주기 때문에 이를 방지 하기 위하여 반드시 PSO Cache를 준비해 두어야 합니다. 문제는 이러한 PSO Cache를 준비하기 위해서는 반드시 PSO가 필요하다는 것이고 복잡한 조합의 PSO를 미리 생성하는 것은 어려운 일입니다. 이를 위해서 언리얼은 PSO 프리캐싱과 같은 기능을 도입하고 있으나 이 또한 이런 문제를 완전하게 해결하지는 못합니다.
여담으로 PS5, XBOX와 같은 콘솔에서는 PSO Cache가 필요하지 않습니다. 왜냐하면 콘솔은 고정된 하나의 GPU 모델을 가지기 때문에 패키징 과정에서 셰이더를 바로 최종 실행 코드까지 컴파일할 수 있습니다. 따라서 콘솔의 경우 PSO 조합마다 셰이더를 다시 컴파일할 필요가 없어 다양한 GPU 구조를 가질 수 있는 PC와 모바일 환경과는 다르다고 할 수 있습니다.
준비한 내용은 여기까지입니다. 마지막으로 예제 프로그램의 Github 링크를 첨부합니다. 세부 코드를 확인하고 싶으신 분은 참고 바랍니다
연관된 파일과 함수는 다음과 같습니다.
- PipelineStateCache.cpp : 파일로 기록되는 PSO Cache
- 파일로부터 읽기 : LoadFromFile()
- 파일로 쓰기 : SaveToFile()
- D3D12ResourceManager.cpp : D3D12 그래픽 리소스 생성
- PSO 생성 : FindOrCreate()
- PSO Cache 업데이트 : UpdatePSOCache()
Reference
'개인 프로젝트' 카테고리의 다른 글
Mesh Shader (2) 2025.02.25 HLSL 2021 (2) 2024.12.15 GPU Query (0) 2024.09.19 오브젝트 피킹 (Object Picking) (0) 2024.07.13 Specular IBL (0) 2024.05.24 - PipelineStateCache.cpp : 파일로 기록되는 PSO Cache