perf: transition bound descriptors on Direct3D12 without per-frame allocation - #3329
perf: transition bound descriptors on Direct3D12 without per-frame allocation#3329sasvdw wants to merge 2 commits into
Conversation
PrepareResourceGroup runs once per resource group per frame. Anything it allocates, it allocates again the next frame. No helper existed to measure that. BenchmarkDotNet is pinned but no project references it, and GC.GetAllocatedBytesForCurrentThread appears nowhere in the tree. GCMeasure reports allocated bytes and generation 0, 1 and 2 collection counts across a loop. Allocation rate and collection cost are separate axes, so it reports both. It discards a warm-up first, because pools and collection capacities grow on first touch. Those first-touch allocations otherwise read as a steady-state leak. TestResourceGroupAllocation asserts that preparing 256 resource groups per frame allocates nothing. Direct3D11 and Vulkan already pass. Direct3D12 allocates 32768 bytes per frame, which is 128 bytes per resource group. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Draft PR — automatic CI is skipped to save runner minutes.
|
4b4f998 to
ccf564d
Compare
Ethereal77
left a comment
There was a problem hiding this comment.
Looks good, although I can't verify it now. +1 also for removing allocations in a potential hot path.
I've left some comments. Note we should prefer to use appropriate terms for each specific platform, to avoid confusion.
Review feedback from @Ethereal77 on stride3d#3329. "Attachment" is a Vulkan term. Direct3D12 has a resource, a view, and a barrier that says how the resource is used. IsBoundAsAttachment becomes IsBoundAsRenderTargetOrDepth, and the comment beside it drops the same word. The type test moves from that helper to the loop that calls it. A reader of the loop now sees which resources the pass acts on, and the helper takes a Texture, so the question of what happens to a Buffer no longer arises at the call site. Buffers stay out of scope, which matches the Vulkan pass. That pass skips any descriptor that is not a sampled or storage image. A buffer barrier also replaces the whole access mask, and nothing restores the vertex, index or constant buffer access a buffer may still need, because binding those emits no barrier. Covering buffers means combining every access a buffer is currently bound for, and both backends should gain that together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…location DescriptorSet.Direct3D12 recorded which resource sits in each shader resource slot, and nothing ever read it. It was scaffolding for a transition pass that nobody wrote. Vulkan has that pass in TransitionBoundResources. Direct3D12 did not. ComputeEffectShader declares no transitions of its own, so the same engine code was correct on one backend and incorrect on the other. PrepareDraw now runs TransitionBoundResources before it flushes barriers. Both Dispatch overloads already call PrepareDraw, so draws and dispatches both get the pass. It acts on textures, and skips one that is currently bound as a render target or as the depth buffer, because the producer already set that state. The pass is idempotent, because ResourceBarrierTransition returns early when the tracked layout already matches. Sites that transition explicitly cost nothing. Buffers stay out of scope, which matches the Vulkan pass. That pass skips any descriptor that is not a sampled or storage image. A buffer barrier also replaces the whole access mask, and nothing restores the vertex, index or constant buffer access a buffer may still need, because binding those emits no barrier. Covering buffers means combining every access a buffer is currently bound for, and both backends should gain that together. The tracking now comes from the descriptor pool rather than the managed heap. A descriptor set lives for one frame, so its tracking can live in the pool that the frame already resets. Renting clears the instance. That drops the previous frame's references, and it leaves a slot holding a constant buffer empty rather than reporting the texture that slot held before. This removes 128 bytes per resource group per frame, which TestResourceGroupAllocation measured at 32768 bytes per frame for 256 groups. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
959675e to
a04a33c
Compare
|
Thanks for the detailed investigation, the compute UAV gap you found is real. However, sorry this wasn't documented much outside of commit messages: the desired direction is actually the opposite. The reasons we prefer explicit transitions:
Instead, the fix should go where the intent is known: since all compute consumers funnel through |
|
FYI I am currently doing a quick pass on the transition/barriers, please wait a bit before working on it again. |
Thanks for the heads-up @xen2. Happy to focus this PR to just the per-frame memory allocations if the intention is to remove auto transitions from Vulkan 💪 |
It's in progress #3337 and #3338 |
|
Closing this in favor of #3338. The explicit direction is the right one, and the multithreaded command list argument is the decisive part. An automatic pass cannot know the state at record time, only at sequential replay. Both things this PR aimed at are covered there:
One unconfirmed observation, in case it helps when you validate #3338. Thanks for the detailed write-up on the direction. It saved me from building the wrong thing twice. |
PR Details
Direct3D12 gains the automatic bound-descriptor transition that Vulkan already has, and the descriptor path stops allocating every frame. Both come from the same piece of abandoned scaffolding.
The scaffolding
DescriptorSet.Direct3D12recorded which resource sits in each shader resource slot, and marked whether that slot is an unordered access view. Nothing ever read it. It was built for a transition pass that nobody wrote.Meanwhile
ComputeEffectShaderissues no barriers of its own. Every compute consumer in the engine goes through it:RadiancePrefilteringGGX,LambertianPrefilteringSH,Stride.Voxels, and the compute tests.On Vulkan that works, because
TransitionBoundResources(CommandList.Vulkan.cs:318) walks the bound descriptor sets before each draw and dispatch. Direct3D12 has no such pass.PrepareDrawonly flushed pending barriers and set the viewport. A texture that a dispatch writes asUnorderedAccesstherefore never leftShaderResourceorCommon. The same engine code was correct on one backend and incorrect on the other.The transition pass
PrepareDrawnow runsTransitionBoundResourcesbefore it flushes barriers. BothDispatchoverloads already callPrepareDraw, so draws and dispatches both get it from one hook.The pass skips a resource that is currently bound as a render target or as the depth buffer. Its producer already set that state, and moving it would invalidate the draw.
The pass is additive.
ResourceBarrierTransitionreturns early when the tracked layout already matches, so the roughly ten sites that transition explicitly cost nothing extra. Explicit transitions at pass boundaries stay the norm, because only a pass knows enough to batch them. This is the floor beneath that, for generic code that cannot know what it received.The allocation
ResourceTrackingcost three heap objects per descriptor set: the object, aGraphicsResource[]and abool[].SrvCountcounts every non-Sampler entry, so a set that binds only a constant buffer paid it too.The tracking now comes from the descriptor pool rather than the managed heap. A descriptor set lives for one frame, so its tracking can live in the pool that the frame already resets. Renting clears the instance. That drops the previous frame's references, and it leaves a slot holding a constant buffer empty rather than reporting the texture that slot held before.
The pool retains one instance per descriptor set at the frame's high-water mark. A frame with 2000 draws holds about 256 KB per pool, one pool per thread context. That is a standing cost where there was none, in exchange for removing a per-frame allocation. It is bounded by real usage rather than by the 85504 descriptor heap capacity.
Measurement
GCMeasureis new, inStride.Graphics.Tests. No helper existed: BenchmarkDotNet is pinned but no project references it, andGC.GetAllocatedBytesForCurrentThreadappeared nowhere in the tree. It reports allocated bytes and generation 0, 1 and 2 collection counts, because allocation rate and collection cost are separate axes. It discards a warm-up first, since pools and collection capacities grow on first touch.TestResourceGroupAllocationmodels one frame: reset the pools, then prepare 256 resource groups.The 128 bytes per group matches the predicted three-object cost, which is what gives confidence that the whole cost is gone rather than merely smaller.
The transition pass itself allocates nothing. A bisection shows this rather than a test. A draw loop reads 420.3 bytes per draw with the pass enabled, and the same 420.3 bytes with it disabled, across three runs. Those 420 bytes belong to
DrawTextureand predate this change.Verification
Debug build, so the validation layers load.
Stride.Graphics.TestsStride.Graphics.Tests.10_0Stride.Graphics.Tests.11_0No failures. The result on Direct3D12 is identical with the pass and without it, which is the idempotence claim holding.
What this does not prove
The synchronization hole is real but latent, and this change closes it rather than fixing a reported fault:
TestHammersleyruns a compute dispatch that writes an unordered access texture, samples it, and issues no barrier between the two. On Direct3D12 with the debug layer active it passes and the layer reports nothing.ExecuteCommandListssupplies implicit synchronization between command lists.So the measured claim is the allocation. The correctness claim is that the same engine code now behaves the same way on both backends, which it did not before.
Related Issue
Types of changes
Checklist