CPU와 GPU는 병렬로 작동하지만, 종종 동기화가 필요하다. 동기화는 한 처리 장치가 작업을 마칠 때 까지 다른 처리 장치가 놀고 있어야 함을 의미하며, 성능에 바람직 하지 않다. 동기화는 병렬성을 망친다.
4.2.1 명령 대기열과 명령 목록
GPU에는 명령 대기열 (command queue)가 있다. CPU는 그리기 명령들이 담긴 명령 목록(command list)을 Direct3D API를 통해서 그 대기열을 제출한다. 하지만 그 명령들을 즉시 GPU가 실행하는 것은 아니다. 처리할 준비가 되어야 비로소 실행되기 시작한다. 즉, 명령들은 대기열에 남아 있는다. 명령 대기열이 비면 GPU는 할 일이 없어 놀게 된다. 반대로 대기열이 꽉 차면, 대기열에 자리가 생길 때까지 CPU가 논다. 고성능 응용 프로그램의 목표는 둘 다 쉬지 않고 돌아가게 만드는 것이다.
Direct3D 12에서 명령 대기열을 대표하는 인터페이스는 ID3D12CommandQueue이다. 이 인터페이스를 생성하려면 대기열을 서술하는 D3D12_COMMAND_QUEUE_DESC 구조체를 채워서 ID3D12Device::createCommandQueue를 호출해야 한다.
Microsoft::WRL::ComPtr<ID3D12CommandQueue> mCommandQueue;
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
queueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;
ThrowIfFailed(md3dDevice->CreateCommandQueue(
&queueDesc, IID_PPV_ARGS(&mCommandQueue)));
위의 보조 매크로 IID_PPV_ARGS는 COM ID를 받아서 void**로 캐스팅해준다.
ID3D12CommandQueue의 주요 메서드 중 하나는 명령 목록에 있는 명령들을 대기열에 추가하는 ExecuteCommandLists 메서드이다.
void ID3D12CommandQueue::ExecuteCommandLists(
// Number of commands lists in the array
UINT Count,
// Pointer to the first element in an array of command lists
ID3D12CommandList *const *ppCommandLists);
위 선언에서 보듯이, 명령 목록을 대표하는 인터페이스는 ID3D12CommandList이다. 실제 그래픽 작업은 이걸 상속하는 ID3D12GraphicsCommandLists라는 인터페이스로 대표된다. 명령들을 명령 목록에 추가하는 여러 메서드가 있다.
// mCommandList pointer to ID3D12CommandList
mCommandList->RSSetViewports(1, &mScreenViewport);
mCommandList->ClearRenderTargetView(mBackBufferView,
Colors::LightSteelBlue, 0, nullptr);
mCommandList->DrawIndexedInstanced(36, 1, 0, 0, 0);
이 코드는 명령들을 추가하기만 하지 즉시 실행은 하지 않는다. 나중에 ExecuteCommandLists를 호출해야 비로소 명령들이 대기열에 추가된다. 명령들을 명령 목록에 다 추가했다면, ID3D12GraphicsCommandList::Close 메서드를 호출해서 명령들의 기록이 끝났음을 Direct3D에 알려주어야 한다.
명령 목록에는 ID3D12CommandAllocator 형식의 메모리 할당자가 하나 연관된다. 명령 목록에 추가된 명령들은 이 할당자의 메모리에 저장된다. ExecuteCommandList로 명령 목록을 실행하면, 대기열은 그 할당자에 담긴 명령들을 참조한다. 즉, 대기열에서 실제로 사용하는 것은 CommandAllocator라는 의미이다.
HRESULT ID3D12Device::CreateCommandAllocator(
D3D12_COMMAND_LIST_TYPE type,
REFIID riid,
void **ppCommandAllocator);
- type: 이 할당자와 연관시킬 수 있는 명령 목록의 종류
- riid: 생성하고자 하는 ID3D12CommandAllocator 인터페이스의 COM ID
- ppCommandAllocator: 출력 매개변수
명령 목록 역시 ID3D12Device로 생성된다.
HRESULT ID3D12Device::CreateCommandList(
UINT nodeMask,
D3D12_COMMAND_LIST_TYPE type,
ID3D12CommandAllocator *pCommandAllocator,
ID3D12PipelineState *pInitialState,
REFIID riid,
void **ppCommandList);
- nodeMask: GPU가 하나인 시스템은 0으로 설정한다. 여러 개일 때는, 이 명령 목록과 연관시킬 물리적 GPU 어댑터 노드들을 지정하는 비트마스크 값을 설정한다.
- type: 명령 목록의 종류
- pCommandAllocator: 생성된 명령 목록에 연관시킬 할당자
- pInitialState: 명령 목록의 초기 파이프라인 상태를 지정한다.
- riid: 생성하고자 하는 명령 목록에 해당하는 ID3D12CommandList 인터페이스의 COM ID
- ppCommandList: 출력 매개변수
한 할당자를 여러 명령 목록에 연관시켜도 되지만, 명령들을 여러 명령 목록에 동시에 기록할 수는 없다. 다른 말로 하면, 현재 명령들을 추가하는 명령 목록을 제외한 모든 명령 목록은 닫혀 있어야 한다. 이러게 해야 한 명령 목록의 모든 명령이 할당자 안에 인접해서 저장된다. 명령 목록을 생성하거나 재설정하면 명령 목록은 '열린' 상태가 됨을 주의하기 바란다.
ExecuteCommandList를 호출한 후, ID3D12CommandList::Reset을 호출하면 명령 목록을 재사용할 수 있게 된다.
HRESULT ID3D12CommandList::Reset(
ID3D12CommandAllocator *pAllocator,
ID3D12PipelineState *pInitialState);
새로이 명령 목록을 할당할 필요가 없어진다. 명령 목록을 이처럼 재설정해도 명령 대기열에 있는 명령들에는 영향이 미치지 않는다. 명령 대기열이 참조하는 명령들은 연관된 명령 할당자의 메모리에 여전히 남아 있기 때문이다.
하나의 프레임을 완성하는데 필요한 렌더링 명령들을 모두 GPU에 제출한 후에는, 명령 할당자의 메모리를 다음 프레임을 위해 재사용해야 할 것이다.
HRESULT ID3D12CommandAllocator::Reset(void);
GPU가 명령 할당자에 담긴 모든 명령을 실행했음이 확실해지기 전까지는 명령 할당자를 재설정하지 말아야 한다.
4.2.2 CPU/GPU 동기화
한 시스템에서 두 개의 처리 장치가 병렬로 실행되다 보니 여러 가지 동기화 문제가 발생한다. 그리고자 하는 어떤 기하구조의 위치를 R이라는 자원에 담는다고 하자. 그 기하구조를 위치 p1에 그리려는 목적으로 CPU는 위치 p1을 R에 추가하고, R을 참조하는 그리기 명령 C를 명령 대기열에 추가한다. 명령 대기열에 명령을 추가하는 연산은 CPU의 실행을 차단하지 않으므로, CPU는 계속해서 다음 단계로 넘어간다. 만일 GPU가 그리기 명령 C를 실행하기 전에 CPU가 새 위치 p2를 R에 추가해서 기존 p1자료를 덮어쓰면, 기하구조는 의도했던 위치에 그려지지 않게 된다.
이런 문제의 해결책 하는 GPU가 명령 대기열의 명령들 중 특정 지점까지의 모든 명령을 다 처리할 때까지 CPU를 기다리게 하는 것이다. 대기열의 모든 특정지점까지의 명령을 처리하는 것을 명령 대기열을 비운다 또는 방출한다라고 말한다. 이 때 필요한 것이 울타리(fence)라고 부르는 객체이다. 울타리는 ID3D12Fence 인터페이스로 대표되며, GPU와 CPU의 동기화를 위한 수단으로 쓰인다.
HRESULT ID3D12Device::CreateFence(
UINT64 InitialValue,
D3D12_FENCE_FLAGS Flags,
REFIID riid,
void **ppFence);
// Example
ThrowIfFailed(md3dDevice->CreateFence(
0,
D3D12_FENCE_FLAG_NONE,
IID_PPV_ARGS(&mFence)));
울타리 객체는 시간상의 시점을 식별하기위해 UINT64 값 하나를 관리한다. 다음은 울타리르 이용해서 명령 대기열을 비우는 코드이다.
UINT64 mCurrentFence = 0;
void D3DApp::FlushCommandQueue()
{
// Advance the fence value to mark commands up to this fence point.
mCurrentFence++;
// Add an instruction to the command queue to set a new fence point.
// Because we are on the GPU timeline, the new fence point won’t be
// set until the GPU finishes processing all the commands prior to
// this Signal().
ThrowIfFailed(mCommandQueue->Signal(mFence.Get(), mCurrentFence));
// Wait until the GPU has completed commands up to this fence point.
if(mFence->GetCompletedValue() < mCurrentFence)
{
HANDLE eventHandle = CreateEventEx(nullptr, false, false, EVENT_
ALL_ACCESS);
// Fire event when GPU hits current fence.
ThrowIfFailed(mFence->SetEventOnCompletion(mCurrentFence,
eventHandle));
// Wait until the GPU hits current fence event is fired.
WaitForSingleObject(eventHandle, INFINITE);
CloseHandle(eventHandle);
}
}
앞의 예에 이를 적용한다면, CPU가 그리기 명령 C를 제출한 후 새 위치 p2로 R의 자료를 덮어쓰기 전에 먼저 명령 대기열을 비우면 된다. 그러나 이것이 이상적인 해결책은 아니다. GPU 작업이 끝날 때까지 CPU가 기다려야 하기 때문이다.
모든 GPU 명령이 실행되었음이 확실해진 이후에 명령 할당자를 재설정하려면, 명령 대기열을 비운 후에 명령 할당자를 재설정하면 된다.
4.2.3 자원 상태 전이
GPU가 자원에 자료를 다 기록하지 않았거나 기록을 아예 시작하지도 않은 상태에서 자원의 자료를 읽으려 하면 문제가 생긴다. 이를 자원 위험 상황(resource hazard)라고 부르기로 한다. 이 문제를 해결하기 위해, Direct3D는 자원들에 상태를 부여한다. 새로 생성된 자원은 기본 상태로 시작한다. 임의의 상태 전이를 Direct3D에게 보고하는 것은 전적으로 응용 프로그램의 몫이다. 이 덕분에, GPU는 상태 전이하고 자원 위험 상황을 방지하는 데 필요한 일들을 자유로이 진행할 수 있다. 응용 프로그램이 상태 전이를 Direct3D에 보고함으로써, GPU는 자원 위험을 피하는 데 필요한 조치를 할 수 있다.
자원 상태 전이는 전이 자원 장벽(transitiono resource barrier)들의 배열을 설정해서 지정한다. 배열을 사용하기에, 한 번의 API 호출로 여러 개의 자원을 전이할 수 있다. 자원 장벽은 D3D12_RESOURCE_BARRIER_DESC 구조체로 서술된다. 다음은 주어진 자원과 이전, 이후 상태에 해당하는 전이 자원 장벽 서술 구조체를 돌려준다.
struct CD3DX12_RESOURCE_BARRIER : public D3D12_RESOURCE_BARRIER
{
// [...] convenience methods
static inline CD3DX12_RESOURCE_BARRIER Transition(
_In_ ID3D12Resource* pResource,
D3D12_RESOURCE_STATES stateBefore,
D3D12_RESOURCE_STATES stateAfter,
UINT subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES,
D3D12_RESOURCE_BARRIER_FLAGS flags = D3D12_RESOURCE_BARRIER_FLAG_
NONE)
{
CD3DX12_RESOURCE_BARRIER result;
ZeroMemory(&result, sizeof(result));
D3D12_RESOURCE_BARRIER &barrier = result;
result.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
result.Flags = flags;
barrier.Transition.pResource = pResource;
barrier.Transition.StateBefore = stateBefore;
barrier.Transition.StateAfter = stateAfter;
barrier.Transition.Subresource = subresource;
return result;
}
// [...] more convenience methods
};
한 예시를 보자.
mCommandList->ResourceBarrier(1,
&CD3DX12_RESOURCE_BARRIER::Transition(
CurrentBackBuffer(),
D3D12_RESOURCE_STATE_PRESENT,
D3D12_RESOURCE_STATE_RENDER_TARGET));
이 코드는 화면에 표시할 이미지를 나타내는 텍스처 자원을 제시 상태(presentation state)에서 렌더 대상 상태로 전이한다. 명령 목록에 자원 장벽이 추가되며, 전이 자원 장벽이라는 것을, GPU에게 자원의 상태가 전이됨을 알려주는 하나의 명령이라고 생각한다.
4.2.4 명령 목록을 이용한 다중 스레드 활용
Direct3D 12는 다중 스레드를 효율적으로 활용할 수 있도록 설계되었다. 명령 목록의 설계는 Direct3D가 다중 스레드 적용의 장점을 취하는 한 방법이다. 여러 개의 명령 목록을 병렬로 구축하여 CPU 시간을 줄인다.
다음은 주의해야 할 점 몇 가지 이다.
- 명령 목록은 자유 스레드(free-threaded) 모형을 따르지 않는다. 즉, 보통의 경우 여러 스레드가 같은 명령 목록을 공유하지 않으며, 그 메서드들은 동시에 호출하지 않는다. 일반적으로 각 스레드는 각자 자신만의 명령 목록을 가진다.
- 명령 할당자도 자유 스레드가 아니다. 즉, 보통의 경우 여러 스레드가 같은 명령 할당자를 공유하지 않으며, 그 메서드들을 동시에 할당하지 않는다. 따라서, 일반적으로 각 스레드는 각자 자신만의 명령 할당자를 가지게 된다.
- 명령 대기열은 자유 스레드 모형을 따른다. 즉, 여러 스레드가 같은 명령 대기열에 접근해서 그 메서드들을 동시에 호출할 수 있다. 특히, 스레드들이 각자 자신의 생성한 명령 목록을 동시에 명령 대기열에 제출할 수 있다.
- 성능상의 이유로, 응용 프로그램은 동시에 기록할 수 있는 명령 목록들의 최대 개수를 반드시 초기화 시점에서 설정해야 한다.
'그래픽스 > DirectX 12' 카테고리의 다른 글
[DirectX 12 3D게임 입문] Chapter 5.5: 렌더링 파이프라인 - 입력 조립기 단계 (1) | 2023.11.11 |
---|---|
[DirectX 12 3D게임 입문] Chapter 4.4: Direct3D의 초기화 - 시간 측정과 애니메이션 (0) | 2023.11.08 |
[DirectX 12 3D게임 입문] Chapter 4.3: Direct3D의 초기화 - Direct3D의 초기화 (0) | 2023.11.07 |
[DirectX 12 3D게임 입문] Chapter 4.1: Direct3D의 초기화 - 기본지식 (0) | 2023.11.06 |
[DirectX 12 3D게임 입문] Chapter 3: 변환 (1) | 2023.11.03 |