在图形编程中两个处理:CPU和GPU并行工作,有时需要同步。为了获得最佳性能,要尽可能长时间地保持忙碌,并最小化同步。同步是不可取的,因为这意味着一个处理单元是空闲的,需要等待另一个完成一些工作,破坏了并行性。

4.2.1命令队列和命令列表

Unity优化的一种思路-drawcall

GPU有一个命令队列,CPU通过使用命令列表的Direct3D API向队列提交命令。命令被提交到命令队列不会立即被GPU执行,而是一直排在队列中,直到GPU准备好再进行处理。

CPU submits commands 
GPU gets and processes 
next command

命令队列为空的话GPU无事可做,命令队列太满的话,GPU无法及时处理,则CPU将在某个时间点不得不空闲。相当于浪费了硬件资源。目标是要让CPU和GPU都保持忙碌的状态。

在Direct3D 12中,命令队列由ID3D12CommandQueue接口表示。 它是通过填充描述队列的D3D12_COMMAND_QUEUE_DESC结构,然后调用ID3D12Device :: CreateCommandQueue创建的。

创建命令队列的方式如下所示:

Microsoft::WRL::ComPtr mCommandQueue;
//定义ID3D12CommandQueue的地址

D3D12_COMMAND_QUEUE_DESC queueDesc = {};
//定义CommandQueue的描述符及其一些参数

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助手宏被定义为:#define IID_PPV_ARGS(ppType) __uuidof(**(ppType)), IID_PPV_ARGS_Helper(ppType)

其中__uuof(**(ppType))计算为(**(ppType))的COM接口ID,在上面就是COMJ接口:ID3D12CommandQueue。 IID_PPV_ARGS_Helper函数基本上将ppType转换为void **。

主要方法之一是ExecuteCommandLists方法,它将命令列表中的命令添加到队列中:

void ID3D12CommandQueue::ExecuteCommandLists(
// 数组中列出的命令数
UINT Count,
//指向命令列表数组中第一个元素的指针

ID3D12CommandList *const *ppCommandLists);

命令列表按照从第一个数组元素开始的顺序执行。

正如上面的方法声明所提示的,图形的命令列表由从ID3D12CommandList接口继承的ID3D12GraphicsCommandList接口表示。 ID3D12GraphicsCommandList接口提供了多种向命令列表添加命令的方法。 例如,下面的代码添加了设置窗口,清除渲染目标视图和发出绘制调用的命令:

// mCommandList pointer to ID3D12CommandList
mCommandList->RSSetViewports(1, &mScreenViewport);
mCommandList->ClearRenderTargetView(mBackBufferView,Colors::LightSteelBlue, 0, nullptr);

mCommandList->DrawIndexedInstanced(36, 1, 0, 0, 0);

从命令的名字来看是立即执行的命令,但其实不是。上面的这些代码只是把命令添加到了

命令列表。ExecuteCommandLists方法将命令添加到命令队列,之后GPU处理命令。当完成向命令列表添加命令时,必须通过调用ID3D12GraphicsCommandList::Close方法来表明已经完成了对命令的记录:

mCommandList->Close();//记录命令

在传递给ID3D12CommandQueue :: ExecuteCommandLists之前,命令列表必须关闭。

与命令列表关联的是称为ID3D12CommandAllocator的内存支持类。 当命令被记录到命令列表中时,它们实际上将被存储在关联的命令分配器中。 当通过ID3D12CommandQueue :: ExecuteCommandLists执行命令列表时,命令队列将引用分配器中的命令。 命令分配器是从ID3D12Device创建的:

HRESULT ID3D12Device::CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE type,REFIID riid,void **ppCommandAllocator);

  1. type:可以与此分配器关联的命令列表的类型。
    两种常见类型是:
    1. D3D12_COMMAND_LIST_TYPE_DIRECT:存储GPU直接执行的命令列表(上文到现在描述的命令列表类型)。
    2. D3D12_COMMAND_LIST_TYPE_BUNDLE:指定命令列表表示一个包。在构建命令列表中有一些CPU开销,所以Direct3D 12提供了一种优化,能够将一系列命令记录到捆绑包中。在记录包之后,驱动程序将预处理这些命令以在渲染过程中优化它们的执行。因此,应该在初始化时记录捆绑包。如果分析显示构建特定命令列表需要花费大量时间,则应该将捆绑的使用视为优化。 Direct3D 12绘图API已经非常高效,所以不需要经常使用bundle。
  2. riid:我们想要创建的ID3D12CommandAllocator接口的COM ID
  3. ppCommandAllocator:输出一个指向创建的命令分配器的指针。

命令列表也是从ID3D12Device创建的:

HRESULT ID3D12Device::CreateCommandList(
UINT nodeMask,
D3D12_COMMAND_LIST_TYPE type,
ID3D12CommandAllocator *pCommandAllocator,
ID3D12PipelineState *pInitialState,
REFIID riid,
void **ppCommandList);

  1. nodeMask:为单GPU系统设置为0。 否则,节点掩码将标识与该命令列表关联的物理GPU。假设单GPU系统。
  2. 键入:命令列表的类型:_COMMAND_LIST_TYPE_DIRECT或D3D12_COMMAND_LIST_TYPE_BUNDLE。
  3. pCommandAllocator:与创建的命令列表关联的分配器。 命令分配器类型必须与命令列表类型匹配。
  4. pInitialState:指定命令列表的初始管道状态。 这对bundle来说可以为null,在特殊情况下,为了初始化而执行命令列表并且不包含任何绘图命令。
  5. riid:要创建的ID3D12CommandList接口的COM ID。
  6. ppCommandList:输出指向创建的命令列表的指针。

可以创建与同一分配器关联的多个命令列表,但不能同时进行记录。

试图用同一个分配器在一行中创建两个命令列表,会错误:

D3D12 ERROR: ID3D12CommandList::

{Create,Reset}CommandList: The command allocator is currently in-use by another command list.

用ID3D12CommandQueue :: ExecuteCommandList(C)之后,通过调用ID3D12CommandList :: Reset方法重用C的内部存储器以记录一组新的命令是安全的。 此方法的参数与ID3D12Device :: CreateCommandList中的匹配参数相同。

HRESULT ID3D12CommandList::Reset(ID3D12CommandAllocator *pAllocator,ID3D12PipelineState *pInitialState);

这个方法将命令列表放在与刚刚创建的状态相同的状态,但允许重新使用内部存储器,避免释放旧命令列表并分配新命令列表。 请注意,重置命令列表不会影响命令队列中的命令,因为关联的命令分配器仍然具有命令队列引用的内存中的命令。

在向GPU提交完整帧的渲染命令后,需要在命令分配器中重用下一帧的内存,ID3D12CommandAllocator :: Reset方法可用于此:

HRESULT ID3D12CommandAllocator::Reset(void);

类似于调用std :: vector :: clear,它将vector重新调整为零,但保持当前容量不变。 但是,由于命令队列可能引用了分配器中的数据,因此只有在确定GPU已完成执行分配器中的所有命令后,才能重置命令分配器。

4.2.2 CPU / GPU同步

由于有两个处理器并行运行,所以有许多同步问题。

假设,我们有储存了一些我们要绘制的集合体的位置的资源R,CPU更新R的数据去存储位置p1然后将参照r的绘图命令c添加到命令队列中,以便在位置p1处绘制几何图形。向命令队列添加命令不会阻塞CPU,所以cpu可以继续。在gpu执行draw命令c之前,cpu继续运行并覆盖r的数据以存储新的位置p2,会造成错误。

c 
CPU Timeline 
Add C 
Store PI 
Store P2 
GPU gets and processes 
next command

如图会造成错误,因为C要用p2绘制图形,而R在更新中。

这种情况的一个解决方案是强制CPU等待,直到GPU完成队列中所有命令的处理,直到达到指定的隔断点,将其成为刷新命令队列。可以使用fence来实现。ID3D12Fence接口用于实现CPU和GPU同步:

1
2
3
4
5
6
7
8
9
10
11
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)));

fence对象维护一个UINT64值,该值只是一个整数,用于及时标识一个fence点。从值0开始,每次需要标记一个新的栅栏点,增加整数。下面的代码展示了如何使用fence刷新命令队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
UINT64 mCurrentFence = 0;
void D3DApp::FlushCommandQueue()
{
// 提高围栏值以标记直至此围栏点的命令。
mCurrentFence++;
//向命令队列添加一条指令来设置一个新的围栏点。
//因为我们在GPU时间轴上,所以新的栅栏点不会
//直到GPU完成处理此Signal()前的所有命令为止。
ThrowIfFailed(mCommandQueue->Signal(mFence.Get(),mCurrentFence));
//等到GPU完成到这个围栏点的命令。
if(mFence->GetCompletedValue() < mCurrentFence)
{
HANDLE eventHandle = CreateEventEx(nullptr, false,
false, EVENT_ALL_ACCESS);
//当GPU击中当前栅栏时触发事件。
ThrowIfFailed(mFence->SetEventOnCompletion(mCurrentFence, eventHandle));
//等待GPU击中当前的围栏事件。
WaitForSingleObject(eventHandle, INFINITE);
CloseHandle(eventHandle);
}
}

如图:

Fence n 
GPU Timeline 
x 
gpu 
Command 
Queue 
CPU Timeline 
Fence n+l 
Signal (fence, 
xcpu 
1)

如图,GPU已经处理了xgpu的命令,CPU刚刚调用了ID3D12CommandQueue::Signal(fence, n+1)方法。

这实际上是在队列末尾添加一条指令,将fence值更改为n + 1。

然而,mFence->GetCompletedValue()将继续返回n,直到GPU处理队列中在信号(fence, n+1)指令之前添加的所有命令。

所以在前面的例子中,在CPU发出绘图命令C之后,它将在覆盖R的数据以存储新的位置p2之前刷新命令队列。 这个解决方案并不理想,因为这意味着CPU在等待GPU完成时处于空闲状态。可以在几乎任何点刷新命令队列(不一定每帧只刷新一次);如果有一些初始化GPU命令,可以在进入主渲染循环之前刷新命令队列来执行初始化。

4.2.3资源转换

为了实现常见的呈现效果,gpu通常在一个步骤中写入资源r,然后在后面的步骤中读取资源r。但是,如果gpu没有完成对资源r的写入或根本没有开始写入,则从资源r读取将是一种资源危害。要解决此问题,Direct3D将状态与资源关联资源在创建时处于默认状态,由应用程序告诉Direct3D任何状态转换。这使得gpu能够做任何它需要做的工作来进行转换和防止资源危害。例如,如果我们正在写入资源(例如纹理),则将纹理状态设置为渲染目标状态;当需要读取纹理时,将其状态更改为着色器资源状态。通过将转换通知direct3d,gpu可以采取步骤避免危险,例如,在从资源读取之前等待所有写操作完成。由于性能原因,资源转换的负担落在程序员身上。

资源转换是通过在命令列表上设置转换资源屏障的数组来指定的;它是一个数组,以防使用一个api调用转换多个资源。在代码中,资源屏障由d3d12_resource_barrier_desc结构表示。下面的helper函数(在d3dx12.h中定义)返回给定资源的转换资源屏障描述,并指定before和after状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
 
};

CD3DX12_RESOURCE_BARRIER扩展了D3D12_RESOURCE_BARRIER_DESC并添加了便利方法。

示例应用程序中该函数的一个示例如下:

1
2
mCommandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition( CurrentBackBuffer(), D3D12_RESOURCE_STATE_PRESENT,
D3D12_RESOURCE_STATE_RENDER_TARGET));

此代码将表示在屏幕上显示的图像的纹理从呈现状态转换为呈现目标状态。资源屏障已添加到命令列表中,可以将资源屏障转换视为命令本身,指示GPU正在转换资源的状态,以便可以在执行后续命令时采取必要步骤防止资源危险。

除了过渡类型外,还有其他类型的资源壁垒。

4.2.4多线程命令

direct3d 12是为高效多线程而设计的,命令列表设计是direct3d利用多线程的一种方式。对于包含大量对象的大型场景,构建命令列表以绘制整个场景可能需要CPU时间。因此,并行地构建命令列表是一种可行的办法;例如,您可以生成四个线程,每个线程负责构建一个命令列表来绘制25%的场景对象。

关于命令列表多线程处理,需要注意以下几点:

  1. 命令列表不是自由线程的;也就是说,多个线程不能共享同一个命令列表并同时调用其方法。所以一般来说,每个线程都会得到自己的命令列表。
  2. 命令分配器不是自由线程;也就是说,多个线程不能共享同一个命令分配器并同时调用其方法。所以一般来说,每个线程都有自己的命令分配器。
  3. 命令队列是自由线程的,因此多个线程可以同时访问命令队列和调用其方法。特别是,每个线程都可以同时将生成的命令列表提交给线程队列。
  4. 出于性能原因,应用程序必须在初始化时间指定它们同时记录的命令列表的最大数量。