queue: stop pinning every resource a command buffer referenced - #291
Conversation
CommandBuffer carries usedBuffers, usedTextures and usedBindGroups, populated during encoding so validateCommandBufferForSubmit can reject a submit that references a released buffer, a mapped buffer, a destroyed texture or a released bind group. postSubmit clears trackedRefs and returns the HAL encoder to the pool, but leaves those three maps in place. They are hard Go references to every resource the frame touched, so nothing the command buffer saw can be collected while the command buffer is reachable -- even after Release() has already freed the native side. An application that builds bind groups per frame accumulates them without bound: a GPU terminal emulator drawing at ~2fps accumulated 167,484 live BindGroup objects in five minutes, while its descriptor-set count and destroy queue both stayed flat, which is what made this hard to see from the outside. Validation has already run by the time postSubmit is reached, so the sets have no further purpose. Dropping them takes the same measurement to -128 objects over four minutes.
SetBindGroup fanned every bound buffer and texture of a bind group into the encoder's usedBuffers/usedTextures maps, once per draw. The fan-out was pure duplication: CreateBindGroup already records boundBuffers and boundTextures on the group, and the group itself is tracked in usedBindGroups, so those slices were already reachable. validateCommandBufferForSubmit now walks them from the bind group instead, and the passes track only the group. The buffer and texture checks move into validateSubmitBuffer/validateSubmitTexture so both the direct and the transitive path share them. One behavioural nuance: a resource reachable only through a bind group that is itself released now reports the released bind group rather than the released or mapped resource. Both fail the submit; only the message differs. The previous commit cleared the three sets in postSubmit, which left two paths holding them. postSubmit returns early when the device has no DestroyQueue, skipping the clear and cb.submitted alike, so the loop moves above that lookup — the HAL submit has already succeeded by then and the buffers are spent regardless. CommandBuffer.Release() never cleared them at all, so a Finish() followed by Release() — the documented path after hal.Submit fails — pinned the whole frame. Both now go through cb.dropUsedSets(). Clearing in validateCommandBufferForSubmit instead was tempting, since it is the only consumer, but a batch where an early buffer validates and a later one fails returns without marking anything submitted; the caller is expected to retry, and the retry would revalidate the early buffer against nil sets and skip its checks silently.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Codecov flagged the patch at 74% on queue_native.go. Three of the four uncovered blocks were worth acting on rather than testing around. validateSubmitBuffer and validateSubmitTexture each opened with a nil guard that nothing can trigger: trackBuffer and trackTexture skip nil before inserting, and collectBindGroupResources only records non-nil entries. Both guards are gone, with the reason noted where they were. The texture half of the bind group walk had no test — only the buffer half did. TestSubmitWithReleasedTextureInBindGroup covers it, mirroring the buffer case: a texture reachable only through a bind group is still rejected at submit. TestPostSubmitDropsSetsWithoutDestroyQueue covers the early return when the device has no DestroyQueue, asserting the buffer is still marked submitted and its validation sets still dropped. That is the exact path the previous commit moved the bookkeeping above, so it was worth pinning down rather than leaving as the one uncovered block.
kolkov
left a comment
There was a problem hiding this comment.
Nice catch — the per-draw fan-out into usedBuffers/usedTextures was load-bearing for memory. We didn't see it because descriptor-set and destroy-queue counts stayed flat while BindGroup live count grew silently.
The fix aligns with how both Rust wgpu and Dawn handle this:
- Rust wgpu:
StatelessTracker<BindGroup>tracks the group itself;queue_submitvalidation walksbind_group.used(theBindGroupStates) to reach buffers/textures transitively (queue.rs:1810-1818). No per-draw fan-out. - Dawn:
CommandBufferResourceUsagestores raw pointers for validation only (not ownership). Bind group entries are checked at submit, not copied per draw.
Your transitive walk in validateCommandBufferForSubmit matches this pattern exactly.
A few questions for discussion:
-
Discovery context — was this visible as memory growth in Taubyte, or did you find it via profiling? Curious how it manifested in practice — we have users building bind groups per frame in gg and hadn't caught the accumulation.
-
dropUsedSetstiming — you chose to clear inpostSubmit(after HAL submit succeeds) rather than invalidateCommandBufferForSubmit(after validation passes). The PR body explains why — a batch where an early buffer validates but a later one fails would lose the validation sets on retry. Good reasoning. Did you consider clearing only on successful Submit return instead? Or is thepostSubmitplacement more robust because it also covers theRelease()path? -
Behavioral nuance — a resource reachable only through a released bind group now reports the bind group error rather than the specific buffer/texture error. For debugging, the buffer-level error is more actionable ("buffer X is released" vs "bind group is released"). Would it be worth checking
bg.releasedlast (after walking its resources) so the more specific error wins? Or is the current ordering intentional — fail fast on the bind group before walking its contents? -
Interaction with ADR-056 — we just landed unified resource lifecycle (#288-#290) where
BindGroup.Release()goes throughref.Drop()→onZero→dq.Defer. Your PR is orthogonal (validation maps vs lifecycle), but worth noting: after your change, the validation maps no longer pin*BindGroupGo objects, which meansonZerocan fire earlier (GC collects the BindGroup sooner). This is correct behavior — just confirming you're aware of the interaction.
Before merge — one question that needs a clear answer:
Point 3 (error message specificity): do you consider the current ordering a deliberate trade-off that's fine to ship, or should we fix it before merge? If a user gets "released bind group" when the actual problem is a released buffer inside it, they'll have a harder time debugging. If you think the current behavior is acceptable — say so explicitly and we'll merge as-is. If not — a small reorder (walk resources before checking bg.released) would fix it without changing the rest of the PR.
Approve — enterprise quality, well-tested, matches reference implementations.
Walking every bind group's boundBuffers and boundTextures in its own pass, ahead of a second pass over bg.released, restores the error precedence this branch had changed. Before the per-draw fan-out was removed, a buffer or texture bound by a bind group also sat in the flat usedBuffers/usedTextures sets, which are checked in steps 2 and 3 ahead of any bind group, so a released resource inside a released bind group reported the resource. Checking the group first inverted that and surfaced the less actionable of the two errors. The two passes matter. Folding the release check into the resource loop fixes the single-bind-group case but leaves the outcome dependent on map iteration order once several groups are at fault -- some released with sound resources, one live but binding a released buffer. Whichever the range reached first would decide the error, so the same program reports different errors on different runs. Splitting the passes makes every resource error beat every bind group error, which is what the flat sets did. The ordering also matches Rust wgpu, which checks buffers and textures before bind groups (queue.rs:1780-1818). Release() never mutates boundBuffers or boundTextures, so the slices stay walkable after the group is released, and a bind group with no bound resources still reports ErrSubmitBindGroupDestroyed as before. TestSubmitResourceErrorWinsAcrossBindGroups uses eight released groups against one live group binding a released buffer, rebound through a single slot to stay under maxBindGroups. Two groups would leave the merged-pass bug passing about 88% of attempts; eight makes it fail within the first couple of iterations.
|
Thanks for the review, and for digging up the Rust/Dawn precedent — good to know 3. Error specificity — fixed, and it was more than a preference. You're right, and the reason is stronger than debuggability: the current ordering Before the fan-out was removed, Fixed, but not with the single reorder you suggested — that turned out to be Walking the resources before So it's two passes now: one over every group's Safe to walk a released group's slices — Two tests, since nothing exercised both conditions at once — which is why the 1. Discovery context. Not Taubyte — an application built on gogpu. It was leaking, and profiling is Given you have users building bind groups per frame in gg, they'd hit exactly 2.
But it doesn't cover
One more thing in that area: 4. ADR-056 interaction — aware, but your framing of it is more generous to this During encoding nothing changes: Where I'd push back is " The one case where your description is exactly right is the finalizer fallback: So: correct that the interaction exists, but it's GC-level rather than Rebased onto current main (through #290) before pushing, so the lifecycle changes |
gocritic's deferInLoop fired on the bind group release in TestSubmitResourceErrorWinsAcrossBindGroups: a deferred Release inside a sixteen-iteration loop holds every bind group until the test function returns. Released explicitly at the end of each iteration instead, along with the command buffer, which the submit contract requires anyway once validation has rejected it -- without that the loop keeps sixteen HAL encoders out of the pool.
kolkov
left a comment
There was a problem hiding this comment.
All questions addressed. Error precedence fix is better than what I suggested — two-pass approach eliminates map iteration order dependency entirely. Tests are thorough (especially the 8-group multi-iteration case). ADR-056 interaction clarification accepted. LGTM.
Problem
CommandBuffercarries three encode-time validation sets —usedBuffers,usedTextures,usedBindGroups— populated during encoding sovalidateCommandBufferForSubmitcan reject a submit that references a released buffer, a mapped buffer, a destroyed texture or a released bind group.They are hard Go references to every resource the frame touched. An application using gogpu that builds bind groups per frame accumulates them without bound, and the live
BindGroupcount grows for as long as the command buffers stay reachable — while descriptor-set and destroy-queue counts both stay flat, which is what makes this hard to see from the outside.Changes
Drop the per-draw fan-out.
SetBindGroupcopied every bound buffer and texture of a bind group into the encoder's maps, once per draw. That was duplication:CreateBindGroupalready recordsboundBuffers/boundTextureson the group, and the group itself is tracked inusedBindGroups, so those slices were already reachable.validateCommandBufferForSubmitnow walks them from the bind group and the passes track only the group. The buffer and texture checks move intovalidateSubmitBuffer/validateSubmitTexture, shared by the direct and transitive paths.One behavioural nuance: a resource reachable only through a bind group that is itself released now reports the released bind group rather than the released or mapped resource. Both fail the submit; only the message differs.
Release the sets on every spent path.
postSubmitreturned early when the device had noDestroyQueue, skipping both the clear andcb.submitted, so the loop moves above that lookup — the HAL submit has already succeeded by then and the buffers are spent regardless.CommandBuffer.Release()never cleared them at all, so aFinish()followed byRelease()— the documented path afterhal.Submitfails — pinned the whole frame. Both now go throughcb.dropUsedSets().Clearing inside
validateCommandBufferForSubmitinstead was tempting, since it is the only consumer, but a batch where an early buffer validates and a later one fails returns without marking anything submitted; the caller is expected to retry, and the retry would revalidate the early buffer against nil sets and skip its checks silently.What was considered and rejected
Replacing the maps with slices. The maps exist for O(1) dedup and
SetVertexBuffer/SetIndexBufferare per-draw; renderers interleave buffers (vb-A, vb-B, vb-A, …), so a "same as last entry" guard never fires and a slice would grow per draw call rather than per distinct buffer. The maps are load-bearing.Verification
Measured in an application using gogpu that builds bind groups per frame: live
BindGroupcount goes from unbounded growth to flat.TestSubmitWithReleasedBufferInBindGroup— a buffer reachable only through a bind group is still caught at submit, covering the deleted fan-out.TestCommandBufferReleaseDropsUsedSets—Release()drops the sets.Full suite passes, including the
lifecycle_test.goadditions from #288.