管理 Direct3D12 资源的生命周期
本文介绍了一种用于管理 Direct3D12 中资源生命周期的低开销策略。
免责声明:本文是转载内容,最初发布在 此页面 上,来自 Diligent Engine 网站。
引言
在典型的 Direct3D 应用程序中,CPU 和 GPU 并行运行,GPU 落后 CPU 几个帧。这意味着当 CPU 发出渲染命令时,该命令不会立即执行,而是被添加到 GPU 命令缓冲区中,GPU 会稍后运行该命令。此时,与该命令相关的所有资源必须仍然有效且处于正确状态。在 D3D11 中,这是由系统保证的。例如,一个缓冲区可以在绑定为着色器资源后立即释放,D3D11 会确保与该缓冲区对象关联的实际资源仅在 draw
命令完成后才会被释放。然而,在 D3D12 中,这是应用程序的责任。D3D12 应用程序必须正确管理以下资源的生命周期:
- 资源(纹理和缓冲区)
- 管线状态
- 描述符堆以及描述符堆内的分配
- 不仅描述符堆本身在 GPU 执行命令时必须存在,而且绘制命令引用的所有描述符也必须有效。
- 命令列表和分配器
- 动态上传堆
基本策略
D3D12 系统提供的用于同步 CPU 和 GPU 执行的主要工具是 Fence(围栏)。Fence 可以被视为 GPU 执行中的一个里程碑。CPU 可以命令 GPU 触发 Fence(参见 ID3D12CommandQueue::Signal()),该命令将与其他命令一起添加到命令缓冲区。当 GPU 到达该点时,它会设置指定的值。CPU 可以查询当前值(参见 ID3D12Fence::GetCompletedValue())以了解 GPU 当前执行到的位置。Diligent Engine 会跟踪 GPU 执行的命令列表数量。每次将下一个命令列表提交到命令队列时,引擎都会触发下一个值。引擎维护一个名为 m_NextCmdList
的变量,该变量包含即将提交的下一个命令列表的从零开始的序号。引擎还维护一个 Fence,其值为已完成命令列表的总数(而不是最后一个提交的命令列表的序号)。以下代码片段显示了当下一个命令列表提交到队列时引擎执行的操作:
std::lock_guard<std::mutex> LockGuard(m_CmdQueueMutex);
// Submit the command list
ID3D12CommandList *const ppCmdLists[] = {pCmdList};
m_pd3d12CmdQueue->ExecuteCommandLists(1, ppCmdLists);
// Increment the counter before signaling the fence.
SubmittedCmdListNum = m_NextCmdList;
Atomics::AtomicIncrement(m_NextCmdList);
// Signal the fence value.
m_pd3d12CmdQueue->Signal(m_pCompletedCmdListFence, m_NextCmdList);
请注意,所有操作都必须是原子的,因此受到互斥锁的保护。另请注意,下一个命令列表编号首先增加,然后用于触发 Fence。考虑提交第一个命令列表(序号为 0)。这是第一个命令列表,因此当执行了一个命令列表时,它就被认为是完成的,所以我们应该在队列中触发 1。下图说明了 CPU 和 GPU 的时间线以及相关值:
注意到已完成命令列表的数量比最后一个已完成的命令列表多一,并且等于GPU 当前正在执行的命令列表的序号。还请注意,虽然命令列表可以从多个线程提交到命令队列(这不常见),但引擎会串行化对命令队列的访问,因此只有一个线程可以提交命令列表并原子地更新下一个命令列表编号。
引擎使用相同的策略来回收所有类型的资源。当应用程序释放资源时,资源不会立即销毁,而是与下一个命令列表编号一起添加到释放队列中。对于不同类型的资源有不同的队列。队列在帧中被清除一次或几次,只有属于已完成命令列表的对象才会被实际销毁。此策略有效,前提是满足以下基本要求:
资源在即时上下文上引用它的最后一个绘制命令被调用之前永远不会被释放。
对于即时上下文,这是一个自然的要求。资源可以在调用绘制命令后立即释放,引擎会将其保持活动状态,直到可以安全释放为止。然而,对于延迟上下文,这意味着所有资源必须保持活动状态,直到此延迟上下文的命令列表被即时上下文执行。延迟上下文不会保留资源的引用,因此这是应用程序的责任。
上述基本要求确保了与释放队列中的资源配对的命令列表编号至少是资源最后被引用的命令列表编号(但可能更大)。考虑以下示例,其中一个缓冲区在引用它的 draw
命令之后立即被释放:
请注意,在 GPU 完成 cmd list N 后,Direct3D12 缓冲区资源不会立即释放,而是在相应的队列下次被清除时释放。另请注意,缓冲区对象可以由另一个线程释放。只要满足最后一个绘制命令在资源释放之前被执行的基本要求,资源就可以被另一个线程安全地销毁。
请注意,在上面的示例中,缓冲区引用与释放队列中的 N+1 配对,因此缓冲区将在比其可能的最早时间稍晚的时间被释放。在多线程环境中,缓冲区可能与任意编号配对,但基本要求确保在调用释放过程时,下一个命令列表编号至少为 N,并且 N 只会增长。结果是,缓冲区引用将至少与 N 配对,因此,缓冲区只能在 cmd list N 完成后销毁。它可能会稍后销毁,但绝不会更早,这提供了正确性保证。
资源生命周期管理细节
本节简要讨论管理不同类型资源生命周期的具体细节。
缓冲区、纹理和管线状态
渲染设备维护一个用于 D3D12 缓冲区、纹理和管线状态的单一队列。
typedef std::pair<Uint64, CComPtr<ID3D12Object> > ReleaseQueueElemType;
std::deque<ReleaseQueueElemType> m_D3D12ObjReleaseQueue;
当一个对象被销毁时,其析构函数会调用渲染设备的 SafeReleaseD3D12Object()
方法,将内部 D3D12 资源添加到释放队列。
BufferD3D12Impl :: ~BufferD3D12Impl()
{
// D3D12 object can only be destroyed when it is no longer used by the GPU
GetDevice()->SafeReleaseD3D12Object(m_pd3d12Resource);
}
用于将初始数据上传到纹理和缓冲区的临时资源以类似的方式进行回收。渲染设备将资源连同下一个命令列表编号一起添加到队列中。
void RenderDeviceD3D12Impl::SafeReleaseD3D12Object(ID3D12Object* pObj)
{
std::lock_guard<std::mutex> LockGuard(m_ReleasedObjectsMutex);
m_D3D12ObjReleaseQueue.emplace_back( GetNextCmdListNumber(), CComPtr<ID3D12Object>(pObj) );
}
请注意,在多线程环境中,GetNextCmdListNumber()
返回的值可能是任意的,但如果满足基本要求,它永远不会小于最后引用该资源的命令列表编号(请参见上图)。
队列在每个帧结束时被清除一次。
void RenderDeviceD3D12Impl::ProcessReleaseQueue(bool ForceRelease)
{
std::lock_guard<std::mutex> LockGuard(m_ReleasedObjectsMutex);
auto NumCompletedCmdLists = GetNumCompletedCmdLists();
// Release all objects whose cmd list number value < number of completed cmd lists
while (!m_D3D12ObjReleaseQueue.empty())
{
auto &FirstObj = m_D3D12ObjReleaseQueue.front();
// GPU must have been idled when ForceRelease == true
if (FirstObj.first < NumCompletedCmdLists || ForceRelease)
m_D3D12ObjReleaseQueue.pop_front();
else
break;
}
}
描述符堆
Diligent Engine 包含四个 CPU 描述符堆对象和两个 GPU 描述符堆对象,如本文所述。每个描述符堆对象包含一个或多个描述符堆分配管理器。每个描述符堆分配管理器又依赖于可变大小 GPU 分配管理器在分配的空间内执行子分配。每个分配管理器维护自己的释放队列,其中包含命令列表编号以及分配属性。队列的工作方式与资源释放队列类似,只要满足基本要求,描述符堆的反分配就是安全且正确的。
队列在每个帧结束时由渲染设备清除一次。
动态描述符
动态描述符由 DynamicSuballocationsManager
类分配(参见 管理描述符堆)。该类根据需要从主 GPU 描述符堆请求描述符块。当此上下文的命令列表关闭并执行后,所有动态分配都会被丢弃。
pCtx->DiscardDynamicDescriptors(SubmittedCmdListNum);
此时,所有动态块都被添加到相应分配管理器的释放队列中,这些队列在每个帧结束时进行回收。
注意:由于所有动态分配都将在每个帧中回收,因此所有动态描述符(包括在延迟上下文中分配的)都不能跨越多帧使用,并且必须在当前帧结束前释放。 实际上,这意味着延迟上下文不能记录跨越多帧的命令,并且必须在其启动的同一帧内关闭并执行。
上传堆
引擎使用动态上传堆来分配更新资源内容或为动态缓冲区提供存储所需的临时空间。上传堆在本文中进行了描述,并使用环形缓冲区。在每个帧结束时,当前的环形缓冲区尾部会连同下一个命令列表编号一起添加到释放队列。此外,所有属于上一帧的陈旧尾部都会被释放。
注意:由于所有空间都在每个帧中回收,因此所有动态空间(包括在延迟上下文中分配的)都不能跨越多帧使用,并且必须在当前帧结束前释放。 实际上,这意味着所有动态缓冲区必须在同一帧内取消映射,并且所有资源更新必须在单帧的边界内完成。
命令列表
命令列表和分配器永远不会被销毁,而是被重用。当命令列表提交执行时,其关联的 D3D12 命令列表对象以及命令列表分配器会连同下一个命令列表编号一起添加到队列的末尾。当请求新的命令列表时,会首先检查队列开头的分配器和命令列表。如果它们引用已完成的命令列表,则会被重用。否则,将创建一个新的命令列表对象。
将命令列表提交到命令队列
以下列表展示了每次将命令列表提交到队列时运行的函数。该函数执行以下步骤:
- 增加下一个命令列表编号 (
m_NextCmdList
) 并从 GPU 触发增加后的值。 - 丢弃用于创建 D3D12 命令列表的分配器。
- 丢弃此上下文使用的所有动态描述符。
- 将上下文返回到可用上下文池。
void RenderDeviceD3D12Impl::CloseAndExecuteCommandContext(CommandContext *pCtx)
{
CComPtr<ID3D12CommandAllocator> pAllocator;
auto *pCmdList = pCtx->Close(&pAllocator);
Uint64 SubmittedCmdListNum = 0;
{
std::lock_guard<std::mutex> LockGuard(m_CmdQueueMutex);
// Kickoff the command list
ID3D12CommandList *const ppCmdLists[] = {pCmdList};
m_pd3d12CmdQueue->ExecuteCommandLists(1, ppCmdLists);
// Increment the counter before signaling the fence.
SubmittedCmdListNum = m_NextCmdList;
Atomics::AtomicIncrement(m_NextCmdList);
// Signal the fence value. Note that for cmd list N that has just been submitted,
// we are signaling value N+1, that has a meaning of the TOTAL NUMBER OF COMPLETED
// cmd lists rather than the index number of the LAST completed cmd list.
m_pd3d12CmdQueue->Signal(m_pCompletedCmdListFence, m_NextCmdList);
}
m_CmdListManager.DiscardAllocator(SubmittedCmdListNum, pAllocator);
pCtx->DiscardDynamicDescriptors(SubmittedCmdListNum);
{
std::lock_guard<std::mutex> LockGuard(m_ContextAllocationMutex);
m_AvailableContexts.push_back(pCtx);
}
}
完成帧
在每个帧结束时执行另一组操作,如下列表所示:
- 丢弃所有上传堆的分配。
- 释放所有描述符堆中的所有陈旧分配。
- 销毁所有陈旧的 D3D12 资源。
void RenderDeviceD3D12Impl::FinishFrame()
{
auto NumCompletedCmdLists = GetNumCompletedCmdLists();
{
std::lock_guard<std::mutex> LockGuard(m_UploadHeapMutex);
for (auto &UploadHeap : m_UploadHeaps)
UploadHeap->FinishFrame(GetNextCmdListNumber(), NumCompletedCmdLists);
}
for(Uint32 CPUHeap=0; CPUHeap < _countof(m_CPUDescriptorHeaps); ++CPUHeap)
m_CPUDescriptorHeaps[CPUHeap].ReleaseStaleAllocations(NumCompletedCmdLists);
for(Uint32 GPUHeap=0; GPUHeap < _countof(m_GPUDescriptorHeaps); ++GPUHeap)
m_GPUDescriptorHeaps[GPUHeap].ReleaseStaleAllocations(NumCompletedCmdLists);
ProcessReleaseQueue();
Atomics::AtomicIncrement(m_CurrentFrameNumber);
}
结论
Diligent Engine 中的资源生命周期管理系统要求应用程序遵循几个规则:
- 在即时上下文中引用它的最后一个绘制命令被调用之前,资源永远不会被释放。
- 对于延迟上下文,这意味着在发送该延迟上下文的命令列表进行执行之前,不能释放任何资源。
- 所有动态描述符(包括在延迟上下文中分配的)都将在当前帧结束前释放。
- 延迟上下文不能记录跨越多帧的命令,并且必须在其启动的同一帧内关闭并执行。
- 所有动态空间(包括在延迟上下文中分配的)都将在当前帧结束前释放。
- 所有动态缓冲区必须在同一帧内取消映射,并且所有资源更新必须在单帧的边界内完成。
遵循这些规则可以实现低开销的资源生命周期管理系统,确保在 GPU 执行引用它们的命令时,所有资源都有效且处于正确状态。