Skip to content

mtl_pool_del reads the open flag before g_fence - #2328

Closed
dean0396 wants to merge 1 commit into
seedds:mainfrom
dean0396:pr-pooldel
Closed

dean0396 wants to merge 1 commit into
seedds:mainfrom
dean0396:pr-pooldel

Conversation

@dean0396

Copy link
Copy Markdown
Contributor

What and why

mtl_pool_del (from 1d37720, #1392) gets a retired pool buffer's fence from a single expression:

uint64_t fence = g_fence + (atomic_load(&g_sub_on) ? 1 : 0);

C doesn't sequence the two operands of + against each other, so nothing guarantees the open flag is read before g_fence, and clang reads g_fence first: in main's dmhost (-O2) the two loads are an ldar of g_fence, then an ldar of g_sub_on. mtl_sub_commit clears g_sub_on only after its ++g_fence and its last encoding. Suppose a deleter reads g_fence before a commit's ++g_fence and 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_kick would 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_fence to 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_fence after it. Both are 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, 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 beside g_sub_on, which the host never sets. It lets t_pool_retire act out a commit landing there. The compiler drops the hook from dmhost: 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

  • Tests (M3 Max, macOS 26.7.2, 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_fence and 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.
    • With the reads put back 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 still open.
    • The whole graphics test run (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.
    • The t_feedback check (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.
    • The Mac host (make -C dm/host GAME=0400486024DEC000) builds with the same 58 warning lines as main. Its mtl_pool_del now loads g_sub_on, then g_fence (read from the disassembly; I didn't run the host).
    • dm/tools/tests/test_docs.py passes.
  • Games run: none, and regress.py check wasn'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.
  • Not run: lint_c.py (I don't have clang-tidy installed), iOS.
  • Overlapping PRs: none of the open PRs changes this fence expression. git merge-tree gives every open PR that touches these three files the same conflicts with this branch as with main.

Docs

  • The finding is in its docs/reference/memory.md entry (Fix and Check of "Retired pool buffers pile up while a game submits nothing"), and python3 dm/tools/docs_symptoms.py was run (no change to the index, since the Symptom line is unchanged)
  • No game files, keys or data extracted from a game in the change or the attachments

…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.
@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation labels Nov 11, 2026
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>
@seedds

seedds commented Nov 14, 2026

Copy link
Copy Markdown
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:

  • Since this PR, the expression moved into pool_gone_release, which mtl_pool_del and mtl_pool_replace (sparse pool runs) both call, so the fix is there.
  • Reading a 0/1 flag first is correct but can still be one fence late: if the flag reads 1 and the commit lands before g_fence is read, the buffer waits for g_fence + 1 after that commit, which a game that stops submitting never reaches. So the flag (now g_sub_open) holds the open submission's own fence (g_fence + 1 at mtl_sub_begin, 0 after the commit's clear), and pool_gone_release uses it when nonzero and reads g_fence only on a 0. That fence is exact in every order.
  • Your hook (renamed g_pool_gone_between) and test case are kept, with the check tightened to exactly the submission's fence and the buffer gone with no submission after it. With g_fence read first, the case fails 2 checks (freed while open); with the 0/1 flag read first, 2 checks (one fence late), in 3 of 3 runs each.

The reference entry in docs/reference/memory.md links this PR. Closing it, as the change is on main.

@seedds seedds closed this Nov 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants