Skip to content

perf: transition bound descriptors on Direct3D12 without per-frame allocation - #3329

Closed
sasvdw wants to merge 2 commits into
stride3d:masterfrom
LazyWorksZA:perf/d3d12-descriptor-transitions
Closed

perf: transition bound descriptors on Direct3D12 without per-frame allocation#3329
sasvdw wants to merge 2 commits into
stride3d:masterfrom
LazyWorksZA:perf/d3d12-descriptor-transitions

Conversation

@sasvdw

@sasvdw sasvdw commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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.Direct3D12 recorded 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 ComputeEffectShader issues 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. PrepareDraw only flushed pending barriers and set the viewport. A texture that a dispatch writes as UnorderedAccess therefore never left ShaderResource or Common. The same engine code was correct on one backend and incorrect on the other.

The transition pass

PrepareDraw now runs TransitionBoundResources before it flushes barriers. Both Dispatch overloads already call PrepareDraw, 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. ResourceBarrierTransition returns 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

ResourceTracking cost three heap objects per descriptor set: the object, a GraphicsResource[] and a bool[]. SrvCount counts 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

GCMeasure is new, in Stride.Graphics.Tests. No helper existed: BenchmarkDotNet is pinned but no project references it, and GC.GetAllocatedBytesForCurrentThread appeared 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.

TestResourceGroupAllocation models one frame: reset the pools, then prepare 256 resource groups.

Backend Before After
Direct3D12 32768 bytes per frame, 128 per group 0
Vulkan 0 0
Direct3D11 0 0

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 DrawTexture and predate this change.

Verification

Debug build, so the validation layers load.

Suite Direct3D11 Direct3D12 Vulkan
Stride.Graphics.Tests 87 passed, 6 skipped 87 passed, 6 skipped 88 passed, 4 skipped
Stride.Graphics.Tests.10_0 44 passed, 4 skipped 44 passed, 4 skipped 44 passed, 4 skipped
Stride.Graphics.Tests.11_0 4 passed, 1 skipped 4 passed, 1 skipped 1 passed, 4 skipped

No 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:

  • Direct3D12 reports nothing for a missing barrier. Its debug layer catches an illegal transition, not an absent one. TestHammersley runs 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.
  • A missing barrier usually still works. The dispatch normally finishes before its consumer reaches it, and ExecuteCommandLists supplies 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

  • Docs change / refactoring / dependency upgrade
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My change requires a change to the documentation.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • I have built and run the editor to try this change out.

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>
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🤖 Draft PR — automatic CI is skipped to save runner minutes.

  • Mark the PR ready for review to run the full automatic CI — or add a ci-run-on-draft label to run it now without leaving draft.
  • Or arm a specific opt-in suite: ci-enduser, ci-editor, ci-ios, ci-android.

@sasvdw
sasvdw force-pushed the perf/d3d12-descriptor-transitions branch from 4b4f998 to ccf564d Compare August 6, 2026 15:03

@Ethereal77 Ethereal77 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread sources/engine/Stride.Graphics/Direct3D12/CommandList.Direct3D12.cs Outdated
Comment thread sources/engine/Stride.Graphics/Direct3D12/CommandList.Direct3D12.cs Outdated
Comment thread sources/engine/Stride.Graphics/Direct3D12/CommandList.Direct3D12.cs Outdated
sasvdw added a commit to LazyWorksZA/stride that referenced this pull request Aug 7, 2026
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>
@sasvdw
sasvdw force-pushed the perf/d3d12-descriptor-transitions branch from 959675e to a04a33c Compare August 7, 2026 07:34
@sasvdw
sasvdw marked this pull request as ready for review August 7, 2026 08:40
@xen2

xen2 commented Aug 7, 2026

Copy link
Copy Markdown
Member

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.
We want to stop doing automatic transitions. The Vulkan TransitionBoundResources pass was a safety net added during the enhanced-barriers rework; it still covers a few remaining cases (compute dispatches, render target layouts at draw time), but the plan is to close those with explicit transitions and then remove it, not to replicate it on D3D12. The D3D12 ResourceTracking is unused scaffolding from before that decision and can just be deleted (which also removes the allocation).

The reasons we prefer explicit transitions:

  • Only high-level code knows the intent (a renderer knows the texture's lifecycle for the whole frame, so it can place one transition at the right spot and batch barriers at pass boundaries).
  • An automatic pass has to guess, so special cases keep growing (read-only depth sampled as SRV, swapchain images, your render-target skip), and it adds per-draw tracking cost and races with multithreaded command list recording. I have spent lot of time dealing with multithread command list issues, there is simply no simple/proper way to do it automatically as we don't have all state when we record them, only when replayed sequentially later.

Instead, the fix should go where the intent is known: since all compute consumers funnel through ComputeEffectShader, transition there (walk EffectInstance.Effect.Bytecode.Reflection.ResourceBindings, UAV textures to UnorderedAccess, SRV textures to ShaderResource), similar to what ImageEffect.PreDrawCore already does for its inputs.

@xen2

xen2 commented Aug 7, 2026

Copy link
Copy Markdown
Member

FYI I am currently doing a quick pass on the transition/barriers, please wait a bit before working on it again.

@sasvdw

sasvdw commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

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 💪

@xen2

xen2 commented Aug 8, 2026

Copy link
Copy Markdown
Member

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
I will let you know when it's merged, then let's see about the rest (hopefully memory alloc should be gone with the automatic system).

@sasvdw

sasvdw commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

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:

  • The compute unordered access gap is fixed at the level that knows the intent, in ComputeEffectShader.TransitionBoundTextures.
  • The per-frame allocation goes away with ResourceTracking, so nothing is left to optimize.

One unconfirmed observation, in case it helps when you validate #3338. TestHammersley has now failed 3 times on Direct3D12, each time on the first Direct3D12 run after another backend has run. It passes on every re-run, including 3 consecutive runs just now. The signature is all 1024 sample points missing. That is what a compute dispatch looks like when its unordered access write is not visible to the consumer. I have not established a cause and the trigger may well be the shader cache rather than a barrier, so please treat this as an observation and not as evidence.

Thanks for the detailed write-up on the direction. It saved me from building the wrong thing twice.

@sasvdw sasvdw closed this Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants