Repository navigation
Standalone Metal probes: heap aliasing, render-target sizes, mesh index ranges, mesh pipeline archive, mesh/task sweep, task-shader hang - #1583
Open
nafields wants to merge 1 commit into
Conversation
This was referenced Nov 6, 2026
nafields
pushed a commit
to nafields/nxnative
that referenced
this pull request
Nov 7, 2026
…carina of Time's Kokiri load no longer piles up 3.4 GB of render targets Symptom: at Ocarina of Time's Kokiri Forest load on a 16 GB M2 Pro, the residency set grew from 1.6 to 6.6 GB in about 30 s, 3.2 GB of it render targets (72 → 1,068 textures, 31 → 313 descriptors); swap followed wired (wired 3.0 → 9.8 GB while swap rose 1.1 → 3.2 GB and available memory fell to 15%), and the runs reached the +4 GB swap limit. Cause: the game keeps its render targets as texture objects over one 305 MB virtual pool, reused by overwrite: at f1,200, 781 live targets (2,788 MB of sizes) sat over 74 MB of storage, and 716 of the 828 live ones (2,697 MB) had their whole storage range written by other targets since their own last write. On the console those cost the pool's bytes; here each kept a private Metal texture, and the texture-ID registry (treg_tex) made one for every registered ID, dead or not. Fix (behind DM_NVN_RT_RECLAIM=1): a shared texture is dead once other textures' render passes have covered its whole storage range in its pool since its own last write. Coverage counts only passes whose draws and clears (viewport ∩ scissor) covered their whole attachment; a partial pass covers nothing. A dead texture leaves the sharing table with its serials' mappings; its Metal texture goes to a free list keyed on the full Metal descriptor, is handed to the next texture of that descriptor after the fence of its last use and zero-filled as a new one is, and is released after 15 idle frames. A reclaimed serial's texture ID reads as an empty slot until a render pass writes it again, which notes the serial so its IDs are taken again. Never reclaimed: IMAGE textures, copy / clear destinations, readback sources, anything a pool copy overlaps, sparse pools, textures filled from pool bytes, and textures read before their next write after more than 4 deaths (luminance and bloom chains: 429 textures, 324 MB in a run to f4,978). Checked: Ocarina of Time on an M2 Pro 16 GB, Kokiri Forest route to f7,500. Render targets in the set stayed at 1.1-1.2 GB instead of 3.5 GB through the load, peak wired 7.6-8.8 GB, and swap grew by 0.05-2.4 GB. A third of attachment passes were partial and cover nothing. Shots at f1,500 and f2,500 match a tree without it in scene and brightness (127.4 / 96.3 against 128.9 / 96.1). The free-list key and the aliasing rule rest on seedds#1583's heap_alias and rt_sizes probes: a texture placed over another's memory reads its bytes only with the same width, height, bytes per pixel and compression, and a single dynamic-resolution step scrambles it. Those game runs were on a tree that also carried PRs 1417 and 1484 and the bind-time pointers. Rebased on main, with the switch off by default: host build, host tests, t_alias_takeover (28), t_image_alias (329), test_docs. No unit test drives the reclaim itself yet, and regress.py check was not run (only Ocarina of Time is available here). Reference entry and game note line added; env_switches.tsv lists the switch.
…ex ranges, the mesh pipeline archive, a mesh and task shader sweep, and the task-shader hang, each without the game Symptom: Ocarina of Time's open questions (which render targets can share heap memory, why task shaders hang the GPU, whether mesh shaders overflow their stage memory) were being answered with 16 GB game runs, and a game run that goes wrong can reset the GPU or panic the machine. Cause: there was no way to ask Metal one question at a time. Every answer needed the whole game. Fix: dm/gfx/tests/probes, one file per question, built by its own Makefile (make; make run for the four that need no input). Each probe's question, input and result on an M3 are in its README: - heap_alias: textures at one placement-heap offset share a layout only with the same width, height, bytes per pixel and compression; the pixel format does not matter. - rt_sizes: 855 dynamic-resolution steps at 2048x1152 give 21 distinct RGBA8 allocation sizes. - mesh_index_range: mesh indices past the vertex count do not hang the GPU. - mesh_archive: an MTL4Compiler mesh pipeline is stored in and found in a Metal 4 archive (0.18 ms against a 36 ms build); the host's mesh pipelines bypass it. - mesh_sweep.sh with mesh_sweep and mesh_guard.py: one draw of every translated mesh/task shader on zeroed inputs under shader validation, plus a guard band for threadgroup overflows, which validation does not check. Ocarina: 387 of 500 meshes overflow before the primitive-memory fix, 0 after. - task_hang: the 8 hanging task shaders loop 134,217,727 times when the raw pool table misses their range pointer, and complete when the bind-time site entry is filled. - raw_sites: the bind-time site path, 11 of 11 cases. - rt_reclaim_sim.py: render-target memory from a write/release log, 3.5 GB to about 1.2 GB with reclaim by overwrite. 04-graphics.md section 8 links them (05-debugging.md is at its word limit). Checked: make and make run on an M3 (macOS 26); lint_python.py (0 new); test_docs.py. No host, chain or tool change, so no regress run.
nafields
force-pushed
the
metal-probes
branch
from
November 7, 2026 04:37
8500122 to
53eaaad
Compare
thomasruegsegger
pushed a commit
to thomasruegsegger/nxnative
that referenced
this pull request
Nov 7, 2026
…carina of Time's Kokiri load no longer piles up 3.4 GB of render targets Symptom: at Ocarina of Time's Kokiri Forest load on a 16 GB M2 Pro, the residency set grew from 1.6 to 6.6 GB in about 30 s, 3.2 GB of it render targets (72 → 1,068 textures, 31 → 313 descriptors); swap followed wired (wired 3.0 → 9.8 GB while swap rose 1.1 → 3.2 GB and available memory fell to 15%), and the runs reached the +4 GB swap limit. Cause: the game keeps its render targets as texture objects over one 305 MB virtual pool, reused by overwrite: at f1,200, 781 live targets (2,788 MB of sizes) sat over 74 MB of storage, and 716 of the 828 live ones (2,697 MB) had their whole storage range written by other targets since their own last write. On the console those cost the pool's bytes; here each kept a private Metal texture, and the texture-ID registry (treg_tex) made one for every registered ID, dead or not. Fix (behind DM_NVN_RT_RECLAIM=1): a shared texture is dead once other textures' render passes have covered its whole storage range in its pool since its own last write. Coverage counts only passes whose draws and clears (viewport ∩ scissor) covered their whole attachment; a partial pass covers nothing. A dead texture leaves the sharing table with its serials' mappings; its Metal texture goes to a free list keyed on the full Metal descriptor, is handed to the next texture of that descriptor after the fence of its last use and zero-filled as a new one is, and is released after 15 idle frames. A reclaimed serial's texture ID reads as an empty slot until a render pass writes it again, which notes the serial so its IDs are taken again. Never reclaimed: IMAGE textures, copy / clear destinations, readback sources, anything a pool copy overlaps, sparse pools, textures filled from pool bytes, and textures read before their next write after more than 4 deaths (luminance and bloom chains: 429 textures, 324 MB in a run to f4,978). Checked: Ocarina of Time on an M2 Pro 16 GB, Kokiri Forest route to f7,500. Render targets in the set stayed at 1.1-1.2 GB instead of 3.5 GB through the load, peak wired 7.6-8.8 GB, and swap grew by 0.05-2.4 GB. A third of attachment passes were partial and cover nothing. Shots at f1,500 and f2,500 match a tree without it in scene and brightness (127.4 / 96.3 against 128.9 / 96.1). The free-list key and the aliasing rule rest on seedds#1583's heap_alias and rt_sizes probes: a texture placed over another's memory reads its bytes only with the same width, height, bytes per pixel and compression, and a single dynamic-resolution step scrambles it. Those game runs were on a tree that also carried PRs 1417 and 1484 and the bind-time pointers. Rebased on main, with the switch off by default: host build, host tests, t_alias_takeover (28), t_image_alias (329), test_docs. No unit test drives the reclaim itself yet, and regress.py check was not run (only Ocarina of Time is available here). Reference entry and game note line added; env_switches.tsv lists the switch. On this branch: a render target placed in its GPU-only pool's heap (pool_heap_texture, PR seedds#1453) is excluded from the reclaim, since the free list would hand a texture placed at one pool offset to a texture at another. (cherry picked from commit 4b231ac)
2 of 3 tasks
thomasruegsegger
pushed a commit
to thomasruegsegger/nxnative
that referenced
this pull request
Nov 7, 2026
…carina of Time's Kokiri load no longer piles up 3.4 GB of render targets Symptom: at Ocarina of Time's Kokiri Forest load on a 16 GB M2 Pro, the residency set grew from 1.6 to 6.6 GB in about 30 s, 3.2 GB of it render targets (72 → 1,068 textures, 31 → 313 descriptors); swap followed wired (wired 3.0 → 9.8 GB while swap rose 1.1 → 3.2 GB and available memory fell to 15%), and the runs reached the +4 GB swap limit. Cause: the game keeps its render targets as texture objects over one 305 MB virtual pool, reused by overwrite: at f1,200, 781 live targets (2,788 MB of sizes) sat over 74 MB of storage, and 716 of the 828 live ones (2,697 MB) had their whole storage range written by other targets since their own last write. On the console those cost the pool's bytes; here each kept a private Metal texture, and the texture-ID registry (treg_tex) made one for every registered ID, dead or not. Fix (behind DM_NVN_RT_RECLAIM=1): a shared texture is dead once other textures' render passes have covered its whole storage range in its pool since its own last write. Coverage counts only passes whose draws and clears (viewport ∩ scissor) covered their whole attachment; a partial pass covers nothing. A dead texture leaves the sharing table with its serials' mappings; its Metal texture goes to a free list keyed on the full Metal descriptor, is handed to the next texture of that descriptor after the fence of its last use and zero-filled as a new one is, and is released after 15 idle frames. A reclaimed serial's texture ID reads as an empty slot until a render pass writes it again, which notes the serial so its IDs are taken again. Never reclaimed: IMAGE textures, copy / clear destinations, readback sources, anything a pool copy overlaps, sparse pools, textures filled from pool bytes, and textures read before their next write after more than 4 deaths (luminance and bloom chains: 429 textures, 324 MB in a run to f4,978). Checked: Ocarina of Time on an M2 Pro 16 GB, Kokiri Forest route to f7,500. Render targets in the set stayed at 1.1-1.2 GB instead of 3.5 GB through the load, peak wired 7.6-8.8 GB, and swap grew by 0.05-2.4 GB. A third of attachment passes were partial and cover nothing. Shots at f1,500 and f2,500 match a tree without it in scene and brightness (127.4 / 96.3 against 128.9 / 96.1). The free-list key and the aliasing rule rest on seedds#1583's heap_alias and rt_sizes probes: a texture placed over another's memory reads its bytes only with the same width, height, bytes per pixel and compression, and a single dynamic-resolution step scrambles it. Those game runs were on a tree that also carried PRs 1417 and 1484 and the bind-time pointers. Rebased on main, with the switch off by default: host build, host tests, t_alias_takeover (28), t_image_alias (329), test_docs. No unit test drives the reclaim itself yet, and regress.py check was not run (only Ocarina of Time is available here). Reference entry and game note line added; env_switches.tsv lists the switch. On this branch: a render target placed in its GPU-only pool's heap (pool_heap_texture, PR seedds#1453) is excluded from the reclaim, since the free list would hand a texture placed at one pool offset to a texture at another. (cherry picked from commit 4b231ac) (cherry picked from commit 3cde45e)
This was referenced Nov 7, 2026
Open
thomasruegsegger
pushed a commit
to thomasruegsegger/nxnative
that referenced
this pull request
Nov 8, 2026
…carina of Time's Kokiri load no longer piles up 3.4 GB of render targets Symptom: at Ocarina of Time's Kokiri Forest load on a 16 GB M2 Pro, the residency set grew from 1.6 to 6.6 GB in about 30 s, 3.2 GB of it render targets (72 → 1,068 textures, 31 → 313 descriptors); swap followed wired (wired 3.0 → 9.8 GB while swap rose 1.1 → 3.2 GB and available memory fell to 15%), and the runs reached the +4 GB swap limit. Cause: the game keeps its render targets as texture objects over one 305 MB virtual pool, reused by overwrite: at f1,200, 781 live targets (2,788 MB of sizes) sat over 74 MB of storage, and 716 of the 828 live ones (2,697 MB) had their whole storage range written by other targets since their own last write. On the console those cost the pool's bytes; here each kept a private Metal texture, and the texture-ID registry (treg_tex) made one for every registered ID, dead or not. Fix (behind DM_NVN_RT_RECLAIM=1): a shared texture is dead once other textures' render passes have covered its whole storage range in its pool since its own last write. Coverage counts only passes whose draws and clears (viewport ∩ scissor) covered their whole attachment; a partial pass covers nothing. A dead texture leaves the sharing table with its serials' mappings; its Metal texture goes to a free list keyed on the full Metal descriptor, is handed to the next texture of that descriptor after the fence of its last use and zero-filled as a new one is, and is released after 15 idle frames. A reclaimed serial's texture ID reads as an empty slot until a render pass writes it again, which notes the serial so its IDs are taken again. Never reclaimed: IMAGE textures, copy / clear destinations, readback sources, anything a pool copy overlaps, sparse pools, textures filled from pool bytes, and textures read before their next write after more than 4 deaths (luminance and bloom chains: 429 textures, 324 MB in a run to f4,978). Checked: Ocarina of Time on an M2 Pro 16 GB, Kokiri Forest route to f7,500. Render targets in the set stayed at 1.1-1.2 GB instead of 3.5 GB through the load, peak wired 7.6-8.8 GB, and swap grew by 0.05-2.4 GB. A third of attachment passes were partial and cover nothing. Shots at f1,500 and f2,500 match a tree without it in scene and brightness (127.4 / 96.3 against 128.9 / 96.1). The free-list key and the aliasing rule rest on seedds#1583's heap_alias and rt_sizes probes: a texture placed over another's memory reads its bytes only with the same width, height, bytes per pixel and compression, and a single dynamic-resolution step scrambles it. Those game runs were on a tree that also carried PRs 1417 and 1484 and the bind-time pointers. Rebased on main, with the switch off by default: host build, host tests, t_alias_takeover (28), t_image_alias (329), test_docs. No unit test drives the reclaim itself yet, and regress.py check was not run (only Ocarina of Time is available here). Reference entry and game note line added; env_switches.tsv lists the switch. On this branch: a render target placed in its GPU-only pool's heap (pool_heap_texture, PR seedds#1453) is excluded from the reclaim, since the free list would hand a texture placed at one pool offset to a texture at another. (cherry picked from commit 4b231ac) (cherry picked from commit 3cde45e) (cherry picked from commit 9a04e57)
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
Several of Ocarina of Time's open questions were being answered with 16 GB game runs, and on 16 GB a run that goes wrong can reset the GPU or panic the machine. Three examples: which render targets can share heap memory, why some task shaders hang the GPU, and whether mesh shaders overflow their stage memory. These probes ask Metal one question each, with no game. The worst any of them does is time out one command buffer (about 22 ms of GPU time).
dm/gfx/tests/probes/, one file per question, with its own Makefile (make;make runruns the four that need no input). Its README gives each probe's question, input and result.docs/agents/04-graphics.md§8 links it. 05-debugging.md would be the natural place, but it is at its 6,000-word limit.Results on an M3:
heap_aliasrt_sizesheapTextureSizeAndAlignrounding. A texture free list keyed on the allocation size would reuse across steps; one keyed on width × height almost never would.mesh_index_rangemesh_archiveMTL4Compileris stored in a Metal 4 archive and found again (0.18 ms, against a 36 ms build). The host builds mesh pipelines withnewRenderPipelineStateWithMeshDescriptor, which bypasses the archive.mesh_sweep.sh(mesh_sweep+mesh_guard.py)task_hanguint(0-1)>>5= 134,217,727 times. They complete when the pool is in the table, or when the bind-time pointer site entry is filled.raw_sitesrt_reclaim_sim.pytask_hangcase C andraw_sitesneed the bind-time pointer translator (PR to follow). With main's translator, case C hangs like case B.Checked
makeandmake runindm/gfx/tests/probes;lint_python.py(371 findings, 0 new);test_docs.py(OK).regress.pyrun. The probes aren't inmake -C dm/gfx/tests runor CI.Docs
docs/agents/04-graphics.md§8; results in the probes README