Repository navigation
Render targets a game moved leave GPU memory: ghost serials go with their sharing entry, and an idle sweep (Ocarina of Time, Castle Town) - #2312
Conversation
…aves the table takes its finalized serials with it, and an idle sweep lets go of entries nothing looked up for 600 submissions Ocarina of Time lays a frame's render targets out with a linear allocator in one GPU-only pool. When one of them changes size the ones after it move, and each move is a new entry in the sharing table (pool, offset, description) with a Metal texture of its own. Walking into another area of Castle Town on a 24 GB M5, the textures in the residency set went from 2.3 GB to 5.4-6.5 GB, about 4.8 GB of them render targets the game had already let go (1,744 MB Depth32Float, 624 MB RGBA16Float, while 796 live targets took 1.5 GB). The Mac ran out of free pages (60-120 MB), compressed 5 GB in 10 s, and frames took 62-92 ms for tens of seconds. Three things kept those textures: - the ghost ring. A finalized serial that a replayed record resolved keeps its texture until the ring turns. With 512 entries the ring is too small for this game: under about 10,000 entries the serials its registered descriptors still read are evicted and entered again every frame (59 to 155 million puts in 100 s, submit CPU from 7 to 16-23 ms; the bursts in issue seedds#2213 look alike). PR seedds#1731 makes it 32,768, and then at the 400 textures a second this game finalizes it takes about 80 s to turn, holding what the game let go for that long. The ring is 32,768 here, and an entry that leaves the sharing table now takes its ghost serials with it (ghost_purge; DM_NVN_GHOST_TAKEOVER=0 for the old behaviour, DM_NVN_GHOST_N to measure other sizes); - nothing let an entry go by its age. Only a takeover over the same storage or the pool's Finalize removed one. Every 120 frames a sweep now lets go of the entries nothing holds and no lookup found for 600 submissions (alias_sweep, with PR seedds#1589's idle eviction; DM_NVN_ALIAS_SWEEP, DM_NVN_ALIAS_SWEEP_IDLE); - a takeover spares an entry looked up in the last 16 submissions. For compressible entries that was too short: at full screen, while the game's dynamic resolution moves, its alternating layouts were made again every frame (16,819 textures, 30 GB of allocations, in 300 frames; submit CPU 17.7 ms against 7.9; frames of 34 ms). A compressible entry now outlives a takeover for 1,800 submissions while the GPU's allocation is under half of Metal's working set (PR seedds#1731's hold; DM_NVN_ALIAS_KEEP_MB sets the bound, 0 turns it off), and for 600 over it. Ending the hold at the bound instead was a cliff: a session that crossed it on entering an area (7,855 to 10,308 MB) made 23,100 textures in 300 frames, frames of 93 ms and a footprint of 17 GB. Measured on an M5 with 24 GB, on a branch that also carries PR seedds#1731 and PR seedds#1589, from the shell on a scripted walk through Castle Town from one save (handheld, dynamic resolution on; the host's residency report every 10 s, the system's memory every 2 s), one run each: after before residency set at its peak 8.0 GB 11.5 GB footprint 12.3 GB 14.9 GB wired, system-wide 10.8 GB 13.6 GB 300-frame windows > 17.5 ms 0 of 54 10 of 32 (seven of them 42-69 ms) textures made in the walk 18,410 32,071 In the app at full screen, 150 s of the same walk: worst 300-frame window 19-20 ms against 67.7 ms. With 8 GB of the Mac held wired, so the game and the rest live in 16 GB, the walk completed with two windows over 17.5 ms. 1,200 consecutive frames around the area change, captured one by one, show no dark frame and no one-frame flicker. One behaviour changes for a texture ID that names a finalized texture: when a takeover lets that texture go, the ID's serial goes with it and the ID resolves anew in the takeover's submission (patched, no table built), where before it kept the old texture until the ring let the serial go and then resolved anew the same way. t_pool_notes's takeover case now expects that (it fails with DM_NVN_GHOST_TAKEOVER=0). Checked: t_alias_takeover under Metal validation, with three new cases: a finalized serial a replayed record resolved goes with the texture a takeover lets go (fails with DM_NVN_GHOST_TAKEOVER=0), the sweep lets go of an entry nothing resolves to at the first sweep after 600 submissions and keeps the live ones (fails with DM_NVN_ALIAS_SWEEP=0), and over the hold's bound a compressible entry outlives a takeover past 16 submissions and goes past 600. The whole graphics suite (make -C dm/gfx/tests run, M5, macOS 27.2) passes, except t_pool_retire once on its 1 s wait while the Mac was building something else; alone it passed three times out of three. test_docs passes; the Mac host builds. Not run: the game on main, whose shaders do not convert there since 0662f9d (issue seedds#2272); other areas than Castle Town, interiors, sessions longer than 34 minutes; regress.py on other games. The system still reaches its free-page floor at the area change (65 MB): this takes the stalls and about 3 GB away, not all of the pressure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014eD437mxrMnW2UwLZhpwc7
|
Data point from a long session with this PR ported (Ocarina of Time v131072, M4 Pro 24 GB, macOS 26.7.2). Our build is an older base with this PR's ghost purge and idle sweep, and with the ghost ring at 32,768.
At about 1 MB per entry (devMlGUE's measure in #2213), a ring this large can hold several GB in a long session. Main's 034e91a keeps the ghost table by use and drains it back to 512 once entries go idle, so it looks like a better base for this PR's purge and sweep. We have ported 034e91a under them and will report after a long session. |
|
With this merged, Ocarina of Time's ground in Kokiri Forest breaks after a minute or two of play. Square patches turn dark and streaked when Link comes back to a place he has already been, a patch can be seen switching from good to bad in the distance, and the patches spread as he keeps moving. Seen on an M3 Max (36 GB, macOS 27.2), v131072, in a tree with other PRs merged. What breaks The patches are the game's cached shadow maps: 384x384 Depth32Float render targets (NVN format 0x34, COMPRESSIBLE) in one pool with flags 0x2a1 (VIRTUAL | COMPRESSIBLE | GPU_CACHED | CPU_NO_ACCESS), plus one square map of the same format whose size changes (466 to 1368). The game renders each one once for an area and samples it while the area is in view.
To see what the sweep lets go and what comes back, I logged each eviction by key, and each later texture made over an evicted key with whether its first use writes it (a render-target attach) or reads it. One Kokiri session: 2,978 entries (7.6 GB) let go in the first 84 s. 400 of them were made again in that time, 280 of those read before any pass wrote them. All 400 were render targets: the shadow maps above, plus the dynamic-resolution targets (960x540, 1920x1080, 640x360, 480x270), which come back 10 to 2,221 frames later. What didn't fix it Keeping an idle entry while a registered texture ID still names its key (NVN has no unregistration, so a registered ID can be read at any time): in gameplay the sweep found 0 such entries. The game re-registers a new texture object each time it reuses a shadow map, so nothing but the storage links the old texture to the new one. Why the idle rule can't tell An entry nobody looked up for 600 submissions may be a render target the game has let go (the Castle Town case this PR is for), or one whose bytes the game expects to find again over the same storage, as on the console, where the bytes are the memory. For a texture the GPU wrote, the host's Metal texture is the only copy of those bytes, so letting it go loses them. The sweep's own comment says as much about the read path: "the IDs that read it read 0 at the next walk". A direction for a fix Make the eviction lossless instead of guessing. Before the sweep lets go of a GPU-written entry in storage the CPU cannot write, store its pixels into its NVN storage. The host already has the GPU swizzle for that ( I'm working on this and can offer it as a commit for this branch, if that suits you. |
What and why
Walking into another area of Castle Town in Ocarina of Time, on my 24 GB M5, the textures in the residency set went from 2.3 GB to 5.4-6.5 GB. About 4.8 GB of them were render targets the game had already let go (1,744 MB Depth32Float, 624 MB RGBA16Float, while the live targets took 1.5 GB). The Mac ran out of free pages, compressed 5 GB in 10 s, and frames took 62-92 ms for tens of seconds.
The game lays a frame's render targets out with a linear allocator in one GPU-only pool, so when one changes size the ones after it move, and each move is a new sharing entry with a Metal texture of its own. Three things kept the old ones:
The change, all in
nvn_mtl.m:ghost_purge;DM_NVN_GHOST_TAKEOVER=0restores the old behaviour,DM_NVN_GHOST_Nsets other sizes for measuring);alias_sweep, with GPU memory guard, the system's side: stop before the Mac runs out of RAM (kern.memorystatus_level, DM_NVN_SYS_MARGIN_MB; on #1482) #1589's idle eviction;DM_NVN_ALIAS_SWEEP,DM_NVN_ALIAS_SWEEP_IDLE);DM_NVN_ALIAS_KEEP_MB), and for 600 over it. Ending the hold at the bound instead was a cliff: a session that crossed it made 23,100 textures in 300 frames, with frames of 93 ms.From #1731 and #1589 this takes only the ring size, the hold and the idle eviction it needs, so it stands on main by itself.
One behaviour changes for a texture ID that names a finalized texture. When a takeover lets that texture go, the ID now resolves anew in the takeover's submission (patched, no table built). Before, it kept the old texture until the ring let its serial go, then resolved anew the same way.
t_pool_notes's takeover case now expects this.Reference entry: Render targets a game moved stayed in GPU memory.
Checked
make -C dm/gfx/tests GAME=0400486024DEC000 run. Everything passes exceptt_pool_retire, which failed once on its 1 s wait while the Mac was building something else; alone it passed three times out of three.t_alias_takeoverhas three new cases. A finalized serial goes with the texture a takeover lets go (fails withDM_NVN_GHOST_TAKEOVER=0). The sweep lets go of an entry nothing resolves to after 600 submissions and keeps the live ones (fails withDM_NVN_ALIAS_SWEEP=0). Over the hold's bound, a compressible entry outlives a takeover past 16 submissions and goes past 600.test_docspasses.test_env_switchesfails on main already, forDM_NVN_VCONV_KEEPand four switches quoted but not read; none of this PR's switches.0400486024DEC000, v131072), on my branch, which also carries nvn: preserve texture upgrades, BC render targets, and retired descriptor bounds #1731 and GPU memory guard, the system's side: stop before the Mac runs out of RAM (kern.memorystatus_level, DM_NVN_SYS_MARGIN_MB; on #1482) #1589. Not on main: this game's shaders do not convert there since 0662f9d (Ocarina of Time (0400486024DEC000 v131072, Mac): 22,296 shaders fail to compile in Metal since 0662f9d, dm_blend0'smain0_out&has no address space qualifier #2272). That is also why there is noregress.pyreport: it is the only game I have here.Docs
docs/reference/rendering.mdentry (and the ghost table's entry innvn-objects.md), andpython3 dm/tools/docs_symptoms.pywas run🤖 Generated with Claude Code
https://claude.ai/code/session_014eD437mxrMnW2UwLZhpwc7