Repository navigation
Conversation
…ired while a submission commits waits for that submission Found by reading the code, not seen in a game: mtl_pool_del (nvn_mtl.m, from 1d37720) took a retired pool buffer's fence from one expression, g_fence + (atomic_load(&g_sub_on) ? 1 : 0). C does not sequence the two operands of + against each other, so nothing makes the read of the open flag come before the read of g_fence, and clang emits the g_fence load first (main's dmhost at -O2). mtl_sub_commit clears the flag only after its ++g_fence and its last encoding. A deleter that read g_fence before a commit's ++g_fence and the flag after that commit's clear got the fence before that submission's own, which may already have passed: defer_kick would then take the buffer out of the residency set and drop it at once, while the submission, which looked the pool up before the generation bump, could still be queued or running on the GPU. The window is between those two loads, so the deleting thread has to be preempted there while a commit runs from its ++g_fence to its clear. The flag is now read first, in its own statement, and g_fence after it (both seq_cst atomic loads, after the existing full fence). A 0 read written by a commit's clear means the g_fence read after it already counts that submission; a 1 read gives g_fence + 1, at most one fence later than needed. A test hook between the two reads (g_pool_del_between, a static function pointer declared beside g_sub_on, which the host never sets) lets t_pool_retire stand in for a commit landing there. The compiler drops the hook from dmhost: there is no symbol, and the two loads stay adjacent. The reference entry (Retired pool buffers pile up while a game submits nothing) says so in its Fix and Check. Checked on main 8fb5ffe (M3 Max, macOS 26.7.2): t_pool_retire has a new case, where the hook does a commit's ++g_fence and clears the open flag between the reads: the buffer waits for at least that submission's fence, stays while it is open, and goes behind it. 18 checks, 0 failed, in 10 runs. Built with the reads in the old order (g_fence, the hook, then the flag), the new case fails 2 checks in 5 of 5 runs: the fence is the one before the submission's, and the buffer is gone while the submission is open. The whole graphics test run (make -k run in dm/gfx/tests) passes but for one t_feedback check ("with nothing running: a commit"), which fails in some runs and passes in others, on unmodified main as here (it failed 8 of 12 runs on main, 7 of 12 here). The Mac host builds with the same 58 warning lines as main, and its mtl_pool_del now loads g_sub_on, then g_fence (disassembly; the host was not run). test_docs passes. Not run: any game, regress.py, lint_c.py (no clang-tidy here), iOS.
seedds
added a commit
that referenced
this pull request
Nov 14, 2026
…e (PR #2328) pool_gone_release took a retired pool buffer's fence from one expression, g_fence + (atomic_load(&g_sub_on) ? 1 : 0). C leaves the order of the two reads open, and clang reads g_fence first. mtl_sub_commit clears the flag only after its ++g_fence, so g_fence read before a commit's ++g_fence and the flag after its clear gave the fence before that submission's, which may already be done: defer_kick could take the buffer out of the residency set and free it while the submission, which looked the pool up before the generation bump, still ran. Found by reading the code (PR #2328), not seen in a game; the window is two adjacent loads. The flag (now g_sub_open) holds the open submission's own fence, g_fence + 1 from mtl_sub_begin (only the submit thread moves g_fence, and segment cuts commit under the submission's fence), and 0 once its commit has cleared it. pool_gone_release reads it first and reads g_fence only on a 0, which the clear after ++g_fence makes count that submission. The fence is exact in every order: the PR's fix, a 0/1 flag read first, left a buffer one fence late when a commit landed between the reads, a fence a game that stops submitting never reaches. The other readers of the flag test it for nonzero. A test hook between the two reads (g_pool_gone_between, from the PR; the host never sets it) lets t_pool_retire land a commit there. Checked: t_pool_retire 18 checks, 0 failed in 5 of 5 runs; with g_fence read first the new case fails 2 checks (the buffer gone while its submission is open), and with the PR's 0/1 flag read first 2 checks (the buffer one fence late, not freed with no submission after it), 3 of 3 runs each. The whole graphics test run (make -k run in dm/gfx/tests), lint_c tidy (nothing above baseline) and test_docs pass. Not run: any game or regress.py (one helper and the flag's two stores, covered by the unit test), the iPhone. Co-authored-by: dean0396 <4335213+dean0396@users.noreply.github.com>
Owner
|
Thank you, this is a real race, and your test made it easy to pin down. It landed on main as 259f4a5, with you as co-author, in a slightly different form:
The reference entry in |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
mtl_pool_del(from 1d37720, #1392) gets a retired pool buffer's fence from a single expression:C doesn't sequence the two operands of
+against each other, so nothing guarantees the open flag is read beforeg_fence, and clang readsg_fencefirst: in main'sdmhost(-O2) the two loads are anldarofg_fence, then anldarofg_sub_on.mtl_sub_commitclearsg_sub_ononly after its++g_fenceand its last encoding. Suppose a deleter readsg_fencebefore a commit's++g_fenceand then reads the flag after that commit clears it. It gets the fence before that submission's own fence, and that one may already have passed.defer_kickwould then take the buffer out of the residency set and drop it right away. The submission looked the pool up before the generation bump, so it could still be queued or running on the GPU at that point.The window is between those two loads, so the deleting thread has to be preempted there while a commit runs from its
++g_fenceto its clear. I found this by reading the code. I haven't seen it happen in a game.The fix reads the flag first, in its own statement, and reads
g_fenceafter it. Both are seq_cst atomic loads, after the existing full fence:g_fenceread after it already counts that submission.g_fence + 1, which is at most one fence later than needed.A test hook sits between the two reads:
g_pool_del_between, a static function pointer declared besideg_sub_on, which the host never sets. It letst_pool_retireact out a commit landing there. The compiler drops the hook fromdmhost: there is no symbol, and the two loads stay adjacent. The Fix and Check lines of the reference entry "Retired pool buffers pile up while a game submits nothing" now describe this.Checked
GAME=0400486024DEC000, on main 8fb5ffe):make -C dm/gfx/tests check_t_pool_retire: the new case uses the hook to do a commit's++g_fenceand clear the open flag between the two reads. The buffer waits for at least that submission's fence, stays while the submission is open, and goes once it and one more submission complete (the 1 read gives a fence one later than needed). 18 checks, 0 failed, in 10 runs.g_fence, the hook, then the flag), the new case fails 2 checks in 5 of 5 runs: the fence is the one before the submission's, and the buffer is gone while the submission is still open.make -k -C dm/gfx/tests run) passes except for one t_feedback check, the same as on unmodified main. That includes t_pool_chunks, t_pool_notes, t_pool_standin, t_pool_seen_replace, t_defer_chain, t_gmem_words, t_readback, t_rt_recycle, t_alias_takeover, t_reorder_chain, t_direct_upload and t_blend_zero.with nothing running: a commit) fails in some runs and passes in others, on unmodified main as on this branch, and it is always the same single check. This change doesn't touch the segment cut it checks.make -C dm/host GAME=0400486024DEC000) builds with the same 58 warning lines as main. Itsmtl_pool_delnow loadsg_sub_on, theng_fence(read from the disassembly; I didn't run the host).dm/tools/tests/test_docs.pypasses.regress.py checkwasn't run. The change only swaps the order of two loads and adds a hook the host never sets. Behaviour differs only when a commit lands between the two reads.lint_c.py(I don't have clang-tidy installed), iOS.git merge-treegives every open PR that touches these three files the same conflicts with this branch as with main.rel_pool_goneto the top ofmtl_pool_del. It already conflicts with main, and this change adds no conflict to it.mtl_pool_wrappedright abovemtl_pool_del. The hook is declared besideg_sub_oninstead, so the two merge cleanly.t_pool_retire.m, which already collides with main's test of the same name. This PR adds a case to main's file.Docs
docs/reference/memory.mdentry (Fix and Check of "Retired pool buffers pile up while a game submits nothing"), andpython3 dm/tools/docs_symptoms.pywas run (no change to the index, since the Symptom line is unchanged)