ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

D3D12资源上传与读回:堆类型、Fence同步与环形缓冲实践

D3D12资源上传与读回:堆类型、Fence同步与环形缓冲实践 最早从 D3D11 切到 DirectX 12 的时候我最大的困惑不是管线状态对象也不是描述符堆反而是最不起眼的资源上传与读回。D3D11 里一个UpdateSubresource就能把 CPU 内存里的顶点数据搬给 GPU到了 D3D12同一个操作需要你自己建堆、选堆类型、写命令列表、做资源状态转换最后还要等一个 Fence整个流程像是把驱动层以前替你干的活全部还给了你。这篇学习笔记就围绕“数据如何从 CPU 移动到 GPU再从 GPU 搬回来”这件事展开。我会把默认堆、上传堆、读回堆三个角色讲清楚给出可以照抄的最小上传/读回流程顺带把环形缓冲区、Fence 同步、调试阶段最容易踩的坑也说一遍。适合刚接触 D3D12 的图形程序新人也适合那些“能跑起来但不知道内部为什么这样设计”的开发者。读完之后你至少可以回答自己一个问题为什么 D3D12 要把一次数据拷贝搞得这么复杂。1. 为什么 D3D12 要把一次“数据传输”拆成这么多步1.1 “倒车”的观感其实是在消除隐式开销D3D11 的UpdateSubresource看起来非常美好但背后承担了大量逻辑。它会判断目标资源当前处于什么 GPU 状态、是否需要临时分配一块暂存缓冲、是否需要插入 DMA 拷贝命令、是否需要同步队列这些全部由驱动在运行时推断。问题是驱动为了“做什么都对”往往要执行很多保守检查CPU 端开销很大而且不同硬件厂商、不同驱动版本的行为还有差异。D3D12 的设计目标是把 CPU 开销压到接近零。它把“资源是否可被 CPU 或 GPU 访问”“访问时 GPU 处于什么状态”“什么时候可以安全复用内存”这些概念全部暴露给开发者。看起来是变麻烦了实际上是去掉了驱动替你做决定的权力。你可以提前把所有准备工作做掉提交命令列表时驱动真的就只是把命令丢给 GPU不再需要停下来判断一堆前置条件。1.2 三种堆类型决定了这块内存谁来碰D3D12 里新建资源时几乎都要指定堆类型最常见的就是以下三种堆类型CPU 可访问性GPU 可访问性典型用途D3D12_HEAP_TYPE_DEFAULT不可直接访问Map 返回的指针没有实际意义高性能读写顶点缓冲、索引缓冲、纹理、RenderTargetD3D12_HEAP_TYPE_UPLOAD可 Map写可读数据从 CPU 搬到 GPU 时的中转站D3D12_HEAP_TYPE_READBACK可 Map读可写GPU 向 CPU 回传结果在离散 GPU 上DEFAULT堆通常对应显存GPU 访问速度最快UPLOAD和READBACK则分配在系统内存的可映射区域CPU 可以像操作普通内存一样读写它们。UPLOAD堆上的资源 GPU 能读但一般不写READBACK堆上的资源 GPU 能写但 CPU 只读。这就是整套传输模型的地基CPU 和 GPU 无法直接共享同一块理想的“高性能内存”只能通过中转区域把数据递过去。1.3 所谓上传和读回本质是两种搬运组合理解了堆类型资源传输就没那么神秘了。上传链路是先用Map/memcpy把 CPU 内存的数据写进UPLOAD堆的一个资源再通过命令列表里的拷贝命令把这块数据复制到DEFAULT堆的目标资源之后 GPU 在渲染管线里正常访问DEFAULT资源。读回链路刚好反过来先把DEFAULT堆上的资源作为拷贝源复制到READBACK堆的资源里然后Map它CPU 再把数据拷到自己真正需要的地方。整条链路上没有玄学只有硬件访问权限的限制。你不需要让 GPU 直接去读 CPU 应用层的内存块也不必让 CPU 直接去读显存两个方向都有专门的中转区域。2. 上传链路CPU 数据如何变成 GPU 能读的默认缓冲2.1 为什么不能直接把 CPU 内存当顶点缓冲用理论上某些 UMA 平台上把一块 CPU 可访问的内存直接交给 GPU 去采样也能工作D3D12 也没有禁止你用UPLOAD堆的资源当顶点缓冲去 Draw。实际做项目时我不推荐这么做。离散 GPU 访问系统内存要走 PCIe带宽和延迟都比显存差太多顶点数据量稍微上去就会变成瓶颈。而且这类布局不利于驱动做性能优化后续要做读回、压缩、驻留管理时也会非常别扭。更关键的是从软件架构上看直接让 GPU 读 CPU 内存会让数据所有权的边界变得模糊。你无法轻易判断某块内存到底是 CPU 在用还是 GPU 在用Fence 同步写起来会非常痛苦。所以保持一个简单原则上传堆只做中转默认堆永远是 GPU 真正用的那份数据。2.2 最小可运行的上传流程实现先上代码这个流程是最常见的创建顶点缓冲 上传数据。// 1. 创建 DEFAULT 资源初始状态设为 COPY_DEST ComPtrID3D12Resource vertexBuffer; D3D12_RESOURCE_DESC vbDesc CD3DX12_RESOURCE_DESC::Buffer(sizeof(vertices)); device-CreateCommittedResource( CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT), D3D12_HEAP_FLAG_NONE, vbDesc, D3D12_RESOURCE_STATE_COPY_DEST, nullptr, IID_PPV_ARGS(vertexBuffer)); // 2. 创建 UPLOAD 资源状态固定为 GENERIC_READ ComPtrID3D12Resource uploadBuffer; device-CreateCommittedResource( CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD), D3D12_HEAP_FLAG_NONE, CD3DX12_RESOURCE_DESC::Buffer(sizeof(vertices)), D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(uploadBuffer)); // 3. 写入 CPU 数据 void* mapped nullptr; uploadBuffer-Map(0, nullptr, mapped); memcpy(mapped, vertices, sizeof(vertices)); uploadBuffer-Unmap(0, nullptr);这里有个细节值得注意UPLOAD堆的资源在创建后一直是GENERIC_READ整个生命周期都不需要再做状态转换因为它 GPU 侧永远只作为“拷贝源”被读取。而DEFAULT资源创建时是COPY_DEST拷贝完成后如果要用于渲染还必须在提交前转成对应的渲染状态。// 4. 在命令列表里从 upload 拷贝到 default commandList-CopyBufferRegion( vertexBuffer.Get(), 0, uploadBuffer.Get(), 0, sizeof(vertices)); // 5. 拷贝完成后把默认资源转换成渲染所需状态 D3D12_RESOURCE_BARRIER barrier CD3DX12_RESOURCE_BARRIER::Transition( vertexBuffer.Get(), D3D12_RESOURCE_STATE_COPY_DEST, D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER); commandList-ResourceBarrier(1, barrier);如果拷贝完成之后不转换状态Debug Layer 会在提交时给你报一堆资源状态错误但 Release 版不一定崩溃可能只是画面不显示或者出现诡异行为。这就是显式状态管理的代价错误的“时机”由你负责而不是驱动。2.3 纹理上传的行对齐是最容易翻车的细节Buffer 的拷贝很简单但纹理上传要小心字节对齐。GPU 为了让内存访问更高效要求纹理每一行的字节数对齐到 256 字节也就是D3D12_TEXTURE_DATA_PITCH_ALIGNMENT。如果一张 4 字节每像素、宽度 520 的 RGBA8 纹理按直觉520 * 4 2080字节一行但 GPU 内存布局里每行实际占用的是AlignUp(2080, 256) 2304字节。手动算这种值容易出错D3D12 提供了现成接口UINT64 totalSize 0; D3D12_PLACED_SUBRESOURCE_FOOTPRINT footprint; UINT numRows 0; UINT64 rowSizeInBytes 0; device-GetCopyableFootprints( textureDesc, 0, 1, 0, footprint, numRows, rowSizeInBytes, totalSize);footprint.Footprint.RowPitch就是 GPU 期望的行距totalSize就是整个上传缓冲区需要的大小。你创建 Upload Buffer 时按totalSize分配然后逐行从 CPU 源数据里拷贝到映射后的地址每一行都按RowPitch而不是width * bytesPerPixel作为目标地址增量。最后用CopyTextureRegion把上传缓冲区指到目标纹理上。这个步骤在 D3D11 里由驱动替你做D3D12 只能自己来忘记了就会出现典型的“花屏但没崩溃”现象。2.4 不建议每帧创建 Upload Buffer很多初学者会习惯在每一帧开始前重新创建一个 Upload Buffer写完数据、提交完命令列表就释放。这能跑但代价很大。CreateCommittedResource并不便宜它需要走驱动内部的对象创建路径和高频内存分配一样会产生明显的 CPU 开销。更麻烦的是GPU 驻留内存管理会根据资源的使用情况动态调整驻留集合频繁创建和销毁资源容易造成驻留抖动卡顿就会悄悄找上你。正确做法是在初始化时就分配一块足够大的 Upload Buffer 并长期复用。这就引出了下一节要聊的东西环形缓冲区。3. 可复用的环形 Upload Buffer别再每帧 new 了3.1 为什么需要环形缓冲GPU 和 CPU 是异步并行工作的。CPU 提交命令列表后并不会停下来等 GPU 跑完它会继续准备下一帧数据。这种并行带来的问题是上一帧已经写入上传缓冲区的数据GPU 可能还没来得及读取下一帧又想往同一块地址写就会覆盖掉 GPU 还没读完的内容。如果每帧都新建一个独立的 Upload Buffer天然避开了“同一块地址被覆盖”的问题。但这浪费了内存也浪费了CreateCommittedResource的开销。更好的方案是初始化时分配一块较大的 Upload Buffer例如 16MB 到 64MB然后把它看成一个环形空间。CPU 每帧从当前游标位置往后分配数据段写完后不立即允许覆盖等 GPU 通过 Fence 告诉我们“这段已经用完”之后游标回到这里时才能再次使用。3.2 环形缓冲的三个核心部分一个可用的环形缓冲需要维护三个信息缓冲区总大小bufferSize和当前写入游标cursor一个记录“已分配段”的队列每段包含offset、size、fenceValue一个用于推进的 Fence以及每帧提交后递增的fenceValue分配逻辑大概是这样的struct UploadSegment { UINT64 offset; UINT64 size; UINT64 fenceValue; }; std::dequeUploadSegment pendingSegments; UINT64 cursor 0; UploadSegment AllocUploadSegment(UINT64 size) { size AlignUp(size, 256); if (cursor size bufferSize) { cursor 0; // 回绕 } // 如果队头段对应的 Fence 还没执行完说明这个段的内存还在被 GPU 使用 if (!pendingSegments.empty()) { UINT64 oldestFence pendingSegments.front().fenceValue; if (fence-GetCompletedValue() oldestFence) { // 阻塞等待 GPU 完成 fence-SetEventOnCompletion(oldestFence, fenceEvent); WaitForSingleObject(fenceEvent, INFINITE); } // 完成后这一块之前的段都可以覆盖 pendingSegments.clear(); } UploadSegment seg{cursor, size, 0}; cursor size; return seg; }这个实现为了简化只保留了当前分配的段实际项目中更精细的做法是保留多个未完成段只清理那些 Fence 已经完成的段避免突然清空整个队列导致后续分配失败。核心思路不变一段内存能否被覆盖取决于它对应的提交是否已经被 GPU 消费完。3.3 三种同步策略对比策略内存占用CPU 等待情况适用场景固定多帧静态分配按帧数轮流3 倍每帧最大上传量基本不等待但浪费简单 Demo代码最直观每次同步等待后再复用同一个 Buffer1 倍上传量每帧或每批都会卡顿不推荐正式项目Fence 驱动的环形缓冲1 倍上传量 少量余量只有在覆盖到未消费段时才等待推荐第三种方案是主流游戏引擎的做法。它的收益很直接内存占用接近“每帧最大上传量”而不是“帧数 × 每帧上传量”同时不会因为单纯轮换而强制让 CPU 等待 GPU。实际项目里大多数帧的 GPU 提交都会在前几帧内很快完成GetCompletedValue()返回的值往往已经大于你要覆盖区域的 FenceValue所以连等待事件都不用触发几乎是零开销。3.4 每帧结束后要做的额外动作环形缓冲区每帧不仅要分配还要记录当前帧的 Fence 值。大致流程是CPU 准备本帧所有上传数据通过AllocUploadSegment获取写入地址并memcpy。写入命令列表提交到命令队列。commandQueue-Signal(fence, fenceValue)。把本帧所有分配过的段和fenceValue一起记录下来。下一帧开始分配前先检查这些段里的队头是否已经完成。如果漏掉第四步只记录了 offset 和 size没有记录 FenceValue你的环形缓冲就不知道某个区域“什么时候能安全复用”。最典型的后果是画面出现随机花屏和闪烁时好时坏你还会怀疑是哪里的状态转换写错了其实只是数据被自己覆盖了。4. Readback 读回把 GPU 的“作业本”拿回 CPU4.1 哪些场景真正需要读回Readback 的用途没有 Upload 那么频繁但很关键。常见场景包括GPU 物理模拟的结果例如粒子位置、水体碰撞数据需要通过 Compute Shader 算完后交还给 CPU 做逻辑处理。查询结果例如遮挡查询、时间戳统计、GPU 内存占用统计。截图、编辑器预览、离屏渲染导出。某些自定义后处理需要把 GPU 计算结果重新交给 CPU 做分析。注意一点屏幕显示本身不需要读回。画面呈现给显示器靠的是Present不是把纹理读回 CPU。只有 CPU 需要亲自拿到像素数据去做后续处理时才需要走 Readback 回读链路。4.2 完整读回流程读回链路比上传要多一步同步等待因为 CPU 不能在 GPU 还没写完时就跑去读数据。完整流程如下。// 创建 READBACK 资源 ComPtrID3D12Resource readbackBuffer; device-CreateCommittedResource( CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_READBACK), D3D12_HEAP_FLAG_NONE, CD3DX12_RESOURCE_DESC::Buffer(readbackSize), D3D12_RESOURCE_STATE_COPY_DEST, nullptr, IID_PPV_ARGS(readbackBuffer)); // 命令列表里先转换目标资源为 COPY_SOURCE再拷贝 D3D12_RESOURCE_BARRIER barrier CD3DX12_RESOURCE_BARRIER::Transition( targetBuffer.Get(), D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_COPY_SOURCE); commandList-ResourceBarrier(1, barrier); commandList-CopyBufferRegion( readbackBuffer.Get(), 0, targetBuffer.Get(), 0, readbackSize); // 提交并 Signal commandQueue-ExecuteCommandLists(1, commandList); commandQueue-Signal(fence.Get(), fenceValue); // CPU 等待确认 GPU 已经写完 if (fence-GetCompletedValue() fenceValue) { HANDLE event CreateEvent(nullptr, FALSE, FALSE, nullptr); fence-SetEventOnCompletion(fenceValue, event); WaitForSingleObject(event, INFINITE); CloseHandle(event); } // 读取数据 void* pData nullptr; D3D12_RANGE readRange { 0, readbackSize }; readbackBuffer-Map(0, readRange, pData); memcpy(cpuResult, pData, readbackSize); readbackBuffer-Unmap(0, nullptr);这里拷贝完成之后通常不需要再把目标资源转回原状态吗要的。拷贝完成后在后续帧使用该目标资源之前你需要插一条反向的Transition把状态从COPY_SOURCE转回它本该在的状态。很多读回问题就出在拷贝完忘恢复状态下一次 Pass 使用时 Debug Layer 直接报错严重时渲染结果完全错乱。4.3 缓存一致性Readback 里读到旧数据或全零的原因Map返回的指针不是普通 CPU 内存地址那么简单这里涉及缓存一致性问题。在离散 GPU 机器上Readback 资源通常位于系统内存中一段可映射区域。CPU 通过普通读写指令访问它时数据会进入 CPU 的 L1/L2 缓存而 GPU 写入这段内存时可能走的是 DMA 路径。如果 CPU 之前缓存了旧数据又没有做缓存失效读出来就是旧值。D3D12 用Map的参数来处理这件事。Map时传入的D3D12_RANGE表示“CPU 即将读取的地址范围”驱动会针对这个范围做必要的缓存失效InvalidateUnmap时传入的D3D12_RANGE表示“CPU 写过的地址范围”驱动会把它写回Flush。实践中的推荐写法是读回资源Map时pReadRange填你要读的完整范围。Unmap时pWrittenRange填nullptr因为 CPU 不修改这块内存。上传资源Map时pReadRange填空区间{0, 0}表示 CPU 不读这块内存。上传资源Unmap时pWrittenRange填你写入的完整范围确保数据对 GPU 可见。很多人图省事都传nullptr大多数时候能工作但一旦遇到缓存问题就非常难排查。4.4 读回操作的性能代价Readback 最容易被低估的是带宽开销和等待开销。我之前做截图功能天真地按“每帧读回一张 1080p RGBA8 纹理”来设计结果一开截图帧率直接掉到不可用。一是大纹理读回本身占用 PCIe 带宽二是 CPU 每帧都要同步等待 GPU 写完两个因素叠加帧时间被彻底拉长。实际项目里建议这样处理把 Readback 频率降到“用户触发”或“每 N 帧一次”。读回的大纹理不要直接同步读可以先异步提交等下一次渲染循环开始时再查 Fence。如果只是想要 GPU 统计结果尽量读回小体积的 Buffer比如几十或几百字节而不是整张纹理。Readback Buffer 初始化后长期复用不要每次临时创建。5. Fence 是传输链路里的“交接单”5.1 Fence 只解决两个问题D3D12 的 Fence 概念看起来复杂实际在资源传输链路里只回答两个问题我刚刚提交的那批命令GPU 执行完了没有我之前提交的那段上传或读回内存现在能被覆盖或者被读取吗你不需要用 Fence 去做“按帧同步 CPU 和 GPU”这种全局控制。大多数时候 CPU 跑得比 GPU 快全局同步等于让 CPU 被迫停下来等 GPU这是最浪费性能的行为。Fence 的正确用法是精细控制某块内存的生命周期。5.2 信号与等待的摆放位置要分场景上传链路里我们通常在一帧命令执行完后Signal一个递增的 FenceValue。下一帧分配上传段之前先查询相关段记录的 FenceValue 是否已经完成。这个等待不会频繁发生因为 GPU 在大多数帧里已经完成了好几帧之前的工作GetCompletedValue()直接就能通过。读回链路里我们在提交 Readback 拷贝命令后立刻Signal。CPU 真正需要读取数据时再等待。等待之前先做一次GetCompletedValue()查询未完成才去注册事件这是降低 CPU 开销的小技巧。这里有个常见错误在同一个命令队列里多次Signal同一个 Fence 值。Fence 的值必须严格递增重复值会让等待逻辑失效CPU 会一直等到超时。养成一个好习惯每次 Signal 之前都用fenceValue生成新值并在日志里记录这个值对应的操作内容。5.3 多帧延迟模型CPU 领先于 GPU 时的资源回收实际渲染循环里CPU 可能领先 GPU 两到三帧。你会发现这一帧创建的 Upload Segment 可能要到三帧之后 GPU 才真正执行完。如果采用“当前帧 3 帧后回收”的固定策略也能工作但不够精细。更好的做法是给每一帧分配一个编号或者说一个frameIndex。每帧提交后把本帧Signal得到的 FenceValue 保存下来。某个上传段属于第几帧它关联的 FenceValue 就是那一帧的 Signal 值。回收时只需要检查fence-GetCompletedValue() segmentFenceValue就说明 GPU 已经执行完该段所在的那个提交可以安全写入。用这种方式即使 CPU 领先的帧数不固定也能精确知道每一段内存何时可用。不会出现“我明明等了 3 帧结果 GPU 那次执行卡顿导致覆盖早了”的灵异问题。6. 调试资源传输问题的三个典型场景6.1 先把 Debug Layer 开起来D3D12 的 Debug Layer 是真的能救命。创建设备之前ComPtrID3D12Debug debug; D3D12GetDebugInterface(IID_PPV_ARGS(debug)); debug-EnableDebugLayer();开启后所有资源状态错误、越界访问、无效参数都会在输出窗口打印详细信息。最常见的报错是“Resource state is invalid for this operation”之类的信息它会直接告诉你哪个资源、当前状态、期望状态。Release 版可以去掉这个流程但开发期千万别关。6.2 花屏且不崩溃一半是状态问题一半是行 Pitch 问题画面花屏是最让人头疼的问题因为程序本身不崩溃你很难定位是哪儿出的问题。排查顺序我建议是确认上传纹理时是否用了GetCopyableFootprints的行 Pitch而不是自己按width * bytesPerPixel算。确认拷贝完成后的ResourceBarrier是否正确插入目标资源是否转到了渲染所需状态。确认你写入 Upload Buffer 的地址偏移和CopyBufferRegion/CopyTextureRegion的源偏移一致。这三个问题引起的现象非常相似画面错乱、显示不完整或出现重复条纹。区分方法很简单把纹理换成一个纯色的小纹理测试如果纯色显示正常大概率是行 Pitch如果纯色也花优先查 Barrier 和 Copy 参数。6.3 回读全是旧数据或全零第一步检查 Fence 有没有等对。很多人在提交命令列表后忘了Signal然后直接Map读回这时 GPU 可能还没开始执行数据就是旧的。第二步检查Map时的 ReadRange。如果填了nullptr某些硬件上不会做缓存失效CPU 缓存里保留了旧数据读出来的自然是旧内容。改成D3D12_RANGE{0, readbackSize}以后再试。第三步检查 Readback 资源状态。Readback 堆上的资源创建时需要设为COPY_DEST这个状态在整个生命周期里基本固定但目标资源转成COPY_SOURCE那一步不能省。如果目标资源本身状态不对拷贝命令可能根本没有执行或者执行了但结果不确定。6.4 画面随机闪烁但没崩溃往往是你覆盖了 GPU 还在读的内存这种情况最容易出现在环形 Upload Buffer 上。表现是画面大部分时候正常偶尔一帧出现某块区域内容错乱过一帧又恢复。原因是某个上传段的fenceValue没有正确关联或者你只按帧序而不是 Fence 完成情况来回收段导致下一帧覆盖了 GPU 还没处理完的同一块内存。解决办法就是把段回收逻辑严格改成“判断GetCompletedValue() segmentFenceValue”再允许覆盖。如果多个线程同时提交命令列表并共用一个环形 Buffer还要考虑线程安全问题。最简单的方案是每个提交线程维护自己的独立上传环避免跨线程访问同一块内存区域。6.5 用 PIX 和 DRED 做辅助定位PIX 抓帧是外面看资源内容最方便的方法能直接看到每一帧里每个资源的当前状态和数据内容。DREDDevice Removed Extended Data则能捕获 GPU 挂掉时的错误报告里面通常包含设备移除原因和涉及资源的名称。资源传输类问题十有八九和状态转换、覆盖时机有关DRED 报出来之后再去翻代码排查半径会小很多。最后再分享一个我自己的习惯在项目架构里坚持把和 D3D12 资源传输相关的操作集中到一个 ResourceManager不要散落到业务代码里。不管是 Upload Ring 还是 Readback Pool它们的生命周期管理和同步逻辑几乎都是同一个套路如果你在每个小模块里自己Map/Unmap到时候排查问题要翻十几个文件很容易漏掉一场 Barrier。把传输通道做成一个公共设施新功能接入时只需要知道“我申请一段上传空间提交后等一个 Fence”整个系统的可维护性和排错效率都会高很多。
返回列表