Skip to content

nvn: preserve texture upgrades, BC render targets, and retired descriptor bounds - #1731

Open
ksolenk38ze wants to merge 1 commit into
seedds:mainfrom
ksolenk38ze:pr/nvn-tex-upgrade-bc-rt-and-feedback
Open

ksolenk38ze wants to merge 1 commit into
seedds:mainfrom
ksolenk38ze:pr/nvn-tex-upgrade-bc-rt-and-feedback

Conversation

@ksolenk38ze

@ksolenk38ze ksolenk38ze commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

What and why

In The Legend of Zelda: Ocarina of Time (0400486024DEC000), four texture and descriptor lifecycle interactions caused missing wall/tunnel fixtures, black environment reflections, or severe frame-time drops (60 fps down to 10-12 fps):

  1. Pixel-format view and usage upgrades across aliases: When a texture was upgraded for a pixel-format view (tex_upgrade_pfv) or additional usage (tex_upgrade), subsequent non-view resolves or alias lookups could see the child view's narrow usage mask or overwrite the upgraded texture with a fresh base allocation. Recording the parent texture's usage in g_alias_usage (g_upg_hint) and retaining MTLTextureUsagePixelFormatView and NvImageHeap across upgrades keeps the shared texture intact across dynamic-resolution steps.
  2. Block-compressed render targets and CPU-backed range clears: Creating a color render target with a block-compressed NVN format (RGB_DXT1 0x42, RGTC1_UNORM 0x49) failed Metal validation (MTLPixelFormatBC* is not color-renderable). Allocating an uncompressed shadow target (bcrt_attach) and BC-encoding back into the guest texture on rp_end (bc_encode_bc1, bc_encode_bc4), while skipping destructive range clears on CPU-backed descriptors, preserves sky/water reflection probes and pre-initialized texture data.
  3. Retired bindless descriptors and ghost package retention: In games with large bindless descriptor tables (g_ntreg > 2048, ~33,000 IDs in Ocarina of Time), dropping cached views on all ~5,766 retired descriptors at the end of every submission (treg_finish_submission) forced thousands of MTLTextureView recreations and residency mutations every frame (dropping frame rate to ~12 fps), whereas skipping retired descriptors in pool_entry left wall and tunnel textures bound to the dummy texture when the game finalized its temporary NVNtexture struct after registration. Keeping NvPkg records in g_ghost (NGHOST = 32768) on mtl_tex_release so __weak references in g_pkg_share remain alive while descriptors are registered, resolving already-made retired descriptors in pool_entry, and keeping retired descriptor views cached across submissions when g_ntreg > 2048 preserves both 60 fps and all environment fixtures.
  4. Depth-read render pass split and feedback/clear bounds: Ending the render pass before a draw samples R.rt.depth after an earlier draw in the same pass wrote depth (g_fb_dwrite), and clamping mirror_readback regions and clear_tex_tri slice counts to the target texture's dimensions, prevents TBDR depth-feedback flickering and out-of-bounds blits.

Related

Checked

  • Tests: make -C dm/gfx/tests GAME=0400486024DEC000 t_pool_notes t_upgrade_alias t_image_alias t_alias_takeover t_srgb_alias t_rt_lifetime t_pool_chunks t_feedback t_lookahead_bindless (all pass with 0 failures), python3 dm/tools/docs_symptoms.py
  • Games run, with title ID and version: The Legend of Zelda: Ocarina of Time (0400486024DEC000, v131072, SDK 22.2.8), tested across Kokiri Forest, Lost Woods, Hyrule Field, Zora's River, Zora's Domain, and Forest Temple at 60 fps with all tunnel/cave walls and reflections visible.
  • Devices and OS: Apple Silicon Mac, macOS 26.2

Docs

  • The finding is in its game note or its docs/reference/<subsystem>.md entry, and python3 dm/tools/docs_symptoms.py was run (the symptom index)
  • No game files, keys or data extracted from a game in the change or the attachments

@aksmith-git

Copy link
Copy Markdown
Contributor

One data point for the fps drops you describe, from Ocarina of Time (0400486024DEC000 v131072) on an iPad Pro (M5) and an M4 Pro Mac, in case it meets the same scenes: in Kokiri Forest's main colour pass (Docked 1536x864, RG11B10 + D32F, ~800 draws) the 11 indirect task/mesh grass draws cost 5.4-5.7 ms on the M5 and 4.7-4.9 ms on the M4 Pro, while the other 760 draws of the pass take 3.1-3.5 ms. The cost is in the vertex (mesh) phase, nearly the same on both GPUs and independent of the render size: the heavy grass mesh bodies (4,700-8,900 lines, a 2,100-3,973-word threadgroup stage array zeroed at every launch) run about 20x slower per threadgroup than the light variants the prepass uses at the same task counts (700-1,300 task threadgroups a frame, at most 32 mesh threadgroups each; no out-of-range counts). Encoding them as direct draws costs the same, so it is not the indirect path.

Have you seen this pass in your profiles, and do the descriptor and texture-upgrade fixes here touch anything these draws read? Happy to run this PR on the iPad against our numbers if useful.

@ksolenk38ze

Copy link
Copy Markdown
Contributor Author

Thanks @aksmith-git! The severe frame-time drop (60 fps down to 10-12 fps) addressed in this PR is separate from the 4.7-5.7 ms GPU mesh-stage cost in Kokiri Forest:

In Ocarina of Time, the guest texture registry grows to g_ntreg ~ 33,000 IDs with ~5,766 retired descriptors. When treg_finish_submission cleared r->view on all retired descriptors at the end of every submission while pool_entry still resolved retired descriptors whose NvPkg remained alive in g_ghost, every subsequent pool_gpu walk had to recreate thousands of MTLTextureView instances and mutate residency on the CPU submit thread every frame. Retaining retired descriptor views across submissions when g_ntreg > 2048 (until alias_note_evicted / treg_drop_im invalidates them) eliminates that CPU view-recreation churn while keeping wall/tunnel fixtures intact.

…ptor bounds

Retain explicit pixel-format view upgrades across subsequent non-view
resolves and record the parent texture's usage mask in the alias map
so later descriptor resolves do not downgrade or recreate the shared
texture. Allow block-compressed textures with CPU backing to serve as
color render targets via an uncompressed RGBA8 backing texture, and
suppress render-target range clears on descriptors backed by CPU-written
memory.

When resolving bindless texture IDs in pool walks and direct draws,
preserve retired descriptor views across submissions when the registry
exceeds 2048 entries, keep finalized NvPkg records in the ghost table
so shared weak references stay live while descriptors remain registered,
and clamp feedback readback regions and cleared array slices to the
target texture's dimensions.
@ksolenk38ze
ksolenk38ze force-pushed the pr/nvn-tex-upgrade-bc-rt-and-feedback branch from a43d23e to 90b8a99 Compare November 8, 2026 10:40
Fasen24-AI added a commit to Fasen24-AI/swordboiMacNative that referenced this pull request Nov 10, 2026
… retired descriptor bounds, depth-read pass split; merged with the memory guard's pinned objects and alias_let_go, NvPkg.used, the pool heaps)

Upstream pull requests applied in this commit (their authors' work, brought onto this tree with a three-way merge; the description is in each PR):
- seedds#1731 by ksolenk38ze (open): nvn: preserve texture upgrades, BC render targets, and retired descriptor bounds
Fasen24-AI added a commit to Fasen24-AI/swordboiMacNative that referenced this pull request Nov 10, 2026
…he dynamic-resolution climb, frag_z_snap, mul_extended; main's rh_texture and frag_z_snap replace seedds#1453's pool heaps and seedds#1742's frag_z; our seedds#1731 hints, compaction, cond_conservative and fixed resolution kept)

Upstream pull requests applied in this commit (their authors' work, brought onto this tree with a three-way merge; the description is in each PR):
- seedds#1453 by PromethOps (closed): A GPU-only pool's render targets share one placement heap: Ocarina of Time's 3 GB of render targets fit in 0.7 GB
- seedds#1731 by ksolenk38ze (open): nvn: preserve texture upgrades, BC render targets, and retired descriptor bounds
- seedds#1742 by cocoman93 (open): position.z one ULP down against a surface's own stored depth: fixes Ocarina of Time mist specks
Fasen24-AI added a commit to Fasen24-AI/swordboiMacNative that referenced this pull request Nov 10, 2026
…ing entry, and entries nothing looks up are swept (Ocarina of Time on a 24 GB Mac: residency set at its peak 8.0 GB against 11.5, footprint 12.3 GB against 14.9, no stall at an area change)

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 the textures in the residency set went from 2.3 GB to 5.4-6.5 GB,
about 4.8 GB of it in render-target formats (1,744 Depth32Float, 624 RGBA16Float), while only 796 targets (1.5 GB) were live. On a 24 GB M5 the system then ran out of free pages
(60-120 MB), compressed 5 GB in 10 s and Metal's submission queue sat in IOGPUCommandQueueSubmitCommandBuffers: frames of 62-92 ms for tens of seconds.

Two things kept those textures:

- the ring of finalized serials (g_ghost), 32,768 entries since PR seedds#1731: a texture evicted from the sharing table stays resident while a finalized serial still resolves to it, and at the
  400 textures a second this game finalizes the ring takes about 80 s to turn. 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). Shrinking the ring instead does not work: under about 10,000 entries the serials the registered descriptors still read are evicted and
  entered again every frame (59 to 155 million entries in 100 s, submit CPU from 7 to 16-23 ms), and over that nothing is freed sooner;
- nothing let an entry go by its age. A takeover skips a compressible entry for 1800 submissions past its last lookup (PR seedds#1731's hold) and is not tried again, it never evicts a
  heap-backed entry in this tree, and an entry whose storage nothing takes over left only with the memory guard, which reads kern.memorystatus_level: that still said 17-34 % available
  with 100 MB of free pages. A sweep every 120 frames now lets go of the entries nothing holds and no lookup found for 600 submissions (alias_sweep; DM_NVN_ALIAS_SWEEP,
  DM_NVN_ALIAS_SWEEP_IDLE).

The hold itself stays, and DM_NVN_ALIAS_KEEP_MB only names its bound (half of Metal's working set, as before; 0 turns it off). With it off the scripted walk used less memory still
(four runs: residency set 6.5-7.1 GB, footprint 11.4-11.9 GB), but an entry then lasts 16 submissions past its last lookup, and in the app at full screen, where the game's dynamic
resolution moves and its layouts alternate, the host made the same render targets again every frame: 16,819 textures (30 GB of allocations) in 300 frames, a submit CPU of 17.7 ms
against 7.9, frames of 34 ms for 5 s. In the app (full screen, MetalFX for the game's upscaler, native output, 150 s of the walk, one run each, so indicative): with
the hold off the host made 28,781 and 47,407 textures, against 14,902 before this commit and 15,010 with the hold on, and its worst 300-frame window was 34.0 ms; with the hold on
20.4 ms; before this commit 67.7 ms. The footprint at its peak in those runs: 13.8 GB before, 12.1 and 13.0 GB with the hold off, 11.2 GB with it on in a run without Game Mode
(GPU-bound at about 53 fps), and 13.6 GB at the tip of this branch with Game Mode on and 60 fps held (GPU allocation 9.3 GB against 10.6). In the app the stall is what goes away;
the memory gain is not established there.

Measured from the shell on the scripted walk through Castle Town (the host's own report of the residency set every 10 s, the system's memory every 2 s), handheld mode, dynamic
resolution on, from the same save, one run each on 10 Nov at the tip of this branch (this commit and the two after it, which do not touch memory) and at the commit before this one:

                              tip        before
  residency set at its peak   8.0 GB     11.5 GB
  textures in it              4.2 GB     5.2 GB
  heaps in it                 1.1 GB     4.2 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

1,200 consecutive frames around the area change, captured one by one with the hold off (the variant that evicts more), show no dark frame and no one-frame flicker, as before the
change.

Not covered: other areas than Castle Town, interiors, the pause menu held open, long sessions. The system still reaches its free-page floor at the area change (65 MB): this takes
3 GB and the stalls away, not all of the pressure. DM_NVN_GHOST_N=<entries> (the ring's size, for measuring) stays as a diagnostic.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eD437mxrMnW2UwLZhpwc7
Fasen24-AI added a commit to Fasen24-AI/swordboiMacNative that referenced this pull request Nov 10, 2026
… of ending (Ocarina of Time after 28 minutes of play: 23,100 textures made in 300 frames, frames of 93 ms and a footprint of 17 GB when the GPU's allocation passed half of Metal's working set)

PR seedds#1731's hold applies while the GPU's allocation is under a bound, half of Metal's working set (9,093 MB on a 24 GB Mac). Over it a compressible entry was let go 16 submissions
after its last lookup. That is a cliff: in the app at full screen the game alternates between the layouts of its dynamic resolution, and with the hold gone every takeover evicted the
render targets of the layout the game was about to use again. A player's session at 60 fps for 28 minutes crossed the bound on entering an area (7,855 to 10,308 MB), and in the next
900 frames the host made 9,889, then 15,793, then 23,100 textures per 300 frames (59 GB of allocations, 77 textures a frame), the submit CPU went from 5 to 41 ms, the GPU from 13 to
36 ms, frames to 93 ms, and the footprint from 13.2 to 17.0 GB with the releases waiting on the GPU. The storm keeps the allocation over the bound, so it does not end by itself: the
player restarted the game.

Over the bound the hold is now ALIAS_KEEP_SHORT submissions. This commit set it to 240; the review two commits later found that this game makes 28-37 submissions a frame, so 240 is
7-8 frames, no longer than the dynamic resolution holds a level, and raised it to 600, the idle sweep's own wait. DM_NVN_ALIAS_KEEP_MB=0 still turns the hold off entirely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eD437mxrMnW2UwLZhpwc7
Melonboy22 added a commit to Melonboy22/greenhat-game that referenced this pull request Nov 11, 2026
…tion gives back its oldest half

On a 16 GB M5 (Hyrule Field, about 15 minutes in) the guard's system side ended the game while
its evictions found almost nothing idle: GPU allocated 6.5 GB, footprint 12.2 GB, and the ring
of finalized serials (32,768 entries since PR seedds#1731, about 80 s of what Ocarina of Time lets
go) held 31,987. Now guard_evict also calls ghost_trim: the oldest half of the ring goes at
each eviction, down to DM_NVN_GHOST_TRIM_MIN entries (default 2048, about 5 s; 0 = off), and
the stop level goes straight to that floor before it re-checks. Textures go behind their
submissions' fences (obj_drop), as the ring's own eviction does; a trimmed serial resolves
anew, so a retired ID still read is made again from its storage (a render target's content
would read cleared).

The memory line every 300 frames is followed by what the ring alone holds (entries, textures
held by no live serial, MB), so the next log shows whether this is the lever.
t_ghost_trim: 10 checks, and 2 with the trim off. Not run in game yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UserDerezzed pushed a commit to UserDerezzed/nxnative that referenced this pull request Nov 12, 2026
…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
@UserDerezzed

Copy link
Copy Markdown

A heads-up for the rebase, about item 3 and the ghost table.

Since 034e91a (issue #2213, PR #2389), main keeps g_ghost by use. NGHOST is now 512 in nvn_owner.h, and once the table is past it, an entry with no lookup or put for GHOST_IDLE (16) frames is evicted. A trial merge of 90b8a99 onto main (f63ecbd) takes nvn_owner.m without a conflict, and the 32,768 cap is gone; the conflicts are in hle_nvn.c, nvn_dirty.c/.h, nvn_mtl.m and docs/env_switches.tsv. So after a rebase, the g_ntreg > 2048 keep runs against a 512-entry table that evicts idle entries.

The two together make a loop in Ocarina of Time. A retired ID's view that stays in a pool table is never looked up again, so its ghost entry is never marked as used. All of them (about 13,000 in Kokiri Forest) go idle 16 frames after the walk that resolved them, in the same frame. The commits then evict them, each eviction writes a note, the notes overflow what a patch takes (SER_LOG, POOL_IDS_MAX), and the next table is walked whole over all 33,024 IDs. That walk puts every entry back, and the cycle repeats.

I measured it on my branch, which carries this PR on top of main 2e6edda, in Kokiri Forest at a 120 fps target: a long frame exactly every 16 frames, 4.4 % of frames at 25 ms or more, about 750 ghost puts a frame and 102,340 patches refused as too many. Restoring the 32,768 cap (DM_NVN_GHOST_N=32768) hides it, but the table then sits full (resident 15.2 GB against 14.4 GB).

I sent a fix as #2585. The owner counts, per serial, the registered IDs that name it, and ghost_trim keeps an entry while any ID names it. seedds reviewed it (comment): on main alone every retired view is transient, so the loop can't happen there. The counting should land with this PR, and ideally replace the per-submission drop-and-retake outright rather than gate it at 2,048 IDs.

I'm reworking it along that review: the count kept under the lock mtl_reg_tex already takes, named entries off the idle list while named, and the memory line reporting named entries. Would you like it as a commit on this branch, or as a PR against it? And are you planning to rebase this one onto current main? If so, I'll base the change on that.

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.

3 participants