Skip to content

Passes ended for same-pass reads are counted by cause, and a pool shader can count as a depth reader only when its tables can hold the depth storage (DM_NVN_FB_DPOOL=1, opt-in) - #1396

Closed
PucaVaz wants to merge 1 commit into
seedds:mainfrom
PucaVaz:depth-read-splits

Conversation

@PucaVaz

@PucaVaz PucaVaz commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

What and why

Follows #1385. In The Legend of Zelda: Ocarina of Time, Link's house, docked, fb_prepare ends about 72 of 175 render passes a frame. This PR finds out which rule ends them, and tries the narrowing the issue proposed. The narrowing is sound but ends no fewer passes in this game, so it's opt-in and this PR is a draft.

What the counts show. The periodic log has a new line that counts these passes by cause. In Link's house, all of them are depth splits after a fragment shader with texture pool bindings: colour 0, depth named by a slot or handle 0, depth after a pool shader 71.7 a frame. They follow pool-reading material shaders that write depth themselves (5490496d71e49df2, 8c9918f14dc7b061, f906b8026cdd7c4b and others, into a 128×128 Depth32Float target); 222 of 227 splits in a 400-pass window were of that kind. Correction: an earlier version of this text named dbda7678a92ef6e5, which does not draw in Link's house in later runs; @deanpektas27 caught the same on #1385. In #1385, @bettterlabs counted about 77 a frame with the existing same-pass feedback sampling line: 76,858 over frames 1,241–2,240. That was on a Mac mini M4, handheld, in Link's bedroom, on an older base without lunks's table fix. That line doesn't split them by cause.

The change.

  • DM_NVN_FB_DPOOL=1 takes a pool shader as a depth reader only when an ID from the bound pool base up lies over the depth attachment's storage in guest memory, or has storage that isn't known (a sparse pool, no address, a virtual pool mapped since). pool_gpu's tables hold nothing else, so the rule is sound. It compares storage, not serials, so aliases and views count. No ID is resolved to a texture; the walk runs once per registration generation and is cached.
  • 0, the default, keeps today's rule; ab alternates with DM_AB.
  • The periodic line passes ended before a write to an attachment an earlier draw read counts passes ended by cause (colour; depth named by a slot or handle; depth only a pool or dynamic shader could read) and pool draws found able or unable to reach the depth attachment.

Why it's off by default. In Ocarina of Time every pool draw with a depth attachment has a table that can hold it:

  • The game registers the depth texture itself in the pool it reads. For example, ID 12413 holds a view of the 1920×1080 Depth32Float attachment, with the same serial.
  • It also keeps other render targets in the depth buffer's memory: about 8,000 of its 16,600 IDs lie over that storage.

I also tried a stricter test that compares Metal memory instead (the attachment's texture, views of it, shared buffers or heaps). It found the depth texture's own view in every table too, so it isn't in this PR.

What the splits cost, and what could get it. These measurements come from a throwaway build, not this PR.

  • A switch that never took a pool shader as a depth reader (unsound, so measurement only) ran Link's house at a frame time p50 of 33.7 → 27.4 ms and a GPU span p50 of 34.4 → 27.6 ms. Passes went from 175 to 103 a frame. That's over 5 paired phases of 600 frames each, with DM_AB=600.
  • Such draws could read a snapshot of the depth attachment in place of the table entries that hold it, the way the colour rule and Same-pass sampling of a depth attachment reads a copy #901 do for slot reads. That would take about 4 snapshots a frame here (distinct barrier, submission and depth keys among those draws), not 72 pass ends. That's a larger change (pool table variants), so I've left it for a follow-up, noted in the reference entry.

Reference entry: Pool shaders taken as depth readers. Related: #1068 narrowed the same rule for TEXU handles (fb_texu_reads, 185b7db). #901 gives slot reads of the depth attachment a copy. #1340 (bindless readers in the TBDR store lookahead) and #1367 (deferred uniform updates in TBDR mode) are other steps toward fewer passes (#159).

Checked

  • Benchmark. The Legend of Zelda: Ocarina of Time (0400486024DEC000, v131072), docked (DM_OPMODE=1, DM_SAVED_CHOICES=0, windowed).

    • Build: lunks's fix/gmem-pool-table (574a38f) plus this commit, because the profile is in [WIP] The Legend of Zelda: Ocarina of Time (Switch 2, 0400486024DEC000, v0, SDK 22.2.8) runs on the Mac through the title movie into Kokiri Forest #1299. The app is a clone of that build with only dmhost swapped.
    • Scene: the Link's house autosave, standing still with the camera fixed.
    • A/B: each run alternates the two rules with DM_AB=600 DM_NVN_FB_DPOOL=ab. The first 150 frames of each phase and the loading phases are left out.
    • Measures: frame time is @frame_ms from DM_NVN_TSV. GPU span is @gpu_end_ms − @gpu_beg_ms per frame (first command buffer start to last end), not the host's summed command-buffer time, which adds up overlapping intervals. DM_NVN_GPUPROF=every:600 gave the encoder span and idle time.
    Docked, Link's house Run 1, old Run 1, new Run 2, old Run 2, new
    Phases of 600 frames 16 16 11 10
    Frame time p50 / p95 (ms) 34.26 / 40.79 34.26 / 40.61 49.08 / 90.57 48.95 / 95.89
    GPU span p50 / p95 (ms) 35.43 / 40.94 35.12 / 40.70 48.80 / 67.90 48.67 / 67.34
    Render passes a frame 175.0 175.0 174.8 174.8
    Attachment load / store a frame (MB) 444 / 460 444 / 460 444 / 460 444 / 460
    Passes ended a frame: colour / depth named / depth after a pool shader 0 / 0 / 71.7 0 / 0 / 71.7 0 / 0 / 71.7 0 / 0 / 71.7
    Pool draws with a depth attachment a frame: could reach it / could not – 74.7 / 0 – 74.7 / 0
    Profiled frames: span / idle between encoders p50 (ms) 36.03 / 11.17 36.20 / 11.39 46.07 / 20.98 46.03 / 21.34
    • Paired by neighbouring phases (new − old):
      • Run 1: frame p50 +0.17 ms (−0.64 to +0.81), GPU span p50 −0.02 ms (−0.43 to +0.73).
      • Run 2: frame p50 −0.01 ms, span −0.73 ms (−4.4 to +3.3).
      • dm/tools/gpuprof.py --ab (summed command-buffer time; its base includes the loading phases): FB_DPOOL − base GPU p50 −0.44 ms and frame p50 +0.12 ms in run 1; −0.75 and +0.54 in run 2.
    • Run 2 ran slower throughout, its title screen included (frame p50 29.4 ms against 23.5 ms in run 1). It came after about an hour of back-to-back runs, so the fanless Air was probably throttling, though pmset -g therm recorded no warning. Its profiled frames also show 237 render passes against 192 in run 1, with the same count in the TSV; I didn't find out why.
  • Correctness.

    • Screenshots: DM_NVN_SHOT at frames 4800, 5400 and 6000, in two runs with DM_NVN_FB_DPOOL=0 and =1: same save, the same menu presses at the same frames, camera still.
      • Rule 0 against rule 1: max 255, mean difference 3.4–5.2 levels, 2.4–4.1% of pixels off by more than 32.
      • Two frames of one run (600 frames apart) differ by about as much: mean 2.7–5.8, 1.6–4.6% by more than 32.
      • The amplified difference images show only the animated parts: leaves and grass in the wind, Link's idle pose, Navi, the dust in the light shaft. Walls, bed, pots and floor are identical.
    • Decisions: in the =1 run the new log line shows every pool draw found able to reach the depth attachment (could not: 0.0), so the rule took the same decisions as =0.
  • Tests: make -C dm/gfx/tests GAME=010015100B514000 check_t_fb_dpool: 49 checks, 0 failed (new test). The full make -C dm/gfx/tests GAME=010015100B514000 run passes. python3 -m unittest dm.tools.tests.test_docs dm.tools.tests.test_env_switches: OK. docs_symptoms.py was run.

  • Lint and build: dm/tools/lint_c.py tidy on the three changed C and Objective-C files finds 0 file/check pairs above the baseline, and 0 findings in the new test. (This machine has LLVM 22.1.1; the baseline was made with 23.1.4.) nvn_mtl.m with the host's flags gives 56 warnings before and after; hle_nvn.c gives 0.

  • Device and OS: MacBook Air M4 (fanless, 24 GB), macOS 27.2, Xcode 27.2, on the charger the whole time (pmset -g batt). pmset -g therm recorded no thermal or performance warning, and the host's thermal field reads -1 on the Mac.

  • Not checked: scripts/regress.py, because only Ocarina of Time is built here. The default path changes only by the counters, but DM_NVN_FB_DPOOL=1 could change the passes in any game with pool shaders, such as Mario Party Superstars. If you want, run DM_NVN_FB_DPOOL=1 on the TBDR-sensitive games. A table with no ID over the depth storage costs a walk of every registered ID once per registration generation. That could matter in a game that registers on every bind (KTGL, Hyrule Warriors: Age of Calamity).

Docs

  • The finding is in its docs/reference/rendering.md entry, the switch is in docs/env_switches.tsv, and python3 dm/tools/docs_symptoms.py was run (the symptom index)
  • A chain tool changed: not applicable
  • No game files, keys or data extracted from a game in the change or the attachments

Drafted with Claude; reviewed by me.

🤖 Generated with Claude Code

…der can count as a depth reader only when its tables can hold the depth storage (DM_NVN_FB_DPOOL=1, opt-in)

In The Legend of Zelda: Ocarina of Time, docked in Link's house, fb_prepare
ends about 72 of 175 render passes a frame (seedds#1385). The periodic log now
counts these passes by cause. All of them are depth splits after a fragment
shader with texture pool bindings: colour 0, depth named by a slot or handle
0, depth after a pool shader 71.7 a frame.

fb_prepare takes every shader with TEXP or TEXD bindings as a depth reader,
because which texture a pool handle names is known only on the GPU. The
next depth write then ends the pass. Ocarina's material shader
dbda7678a92ef6e5 reads a texture2d<float> pool and writes depth, in about
75 draws a frame.

A pool shader reads through a table of every ID registered from the bound
base up (pool_gpu). So it can reach the depth attachment only through an ID
whose storage lies over the attachment's. With DM_NVN_FB_DPOOL=1, such a
shader counts as a depth reader only when an ID does, or when an ID's
storage isn't known (a sparse pool, no address, or a virtual pool mapped
since). Storage is compared in guest memory, so aliases and views count. No
ID is resolved to a texture, and the walk's answer is kept per registration
generation. 0, the default, keeps the old rule; ab alternates with DM_AB.

The rule is off by default because it ends no fewer passes in Ocarina. The
game registers the depth texture itself in the pool it reads (a view of the
1920x1080 Depth32Float, ID 12413 in one run). About 8,000 of its 16,600 IDs
also lie over the depth storage.

A throwaway build that never took a pool shader as a depth reader
(unsound, not part of this change) ran Link's house at a frame time p50 of
33.7 -> 27.4 ms, with 175 -> 103 passes a frame. So these splits cost about
6 ms a frame there. Giving such draws a snapshot of the depth attachment in
place of the table entries that hold it would take about 4 copies a frame.
That approach is left open in the reference entry.

Measured on a MacBook Air M4 (24 GB, on the charger). The host was built
from lunks's fix/gmem-pool-table with this change; docked, standing in
Link's house, with DM_AB=600 DM_NVN_FB_DPOOL=ab:
- Run 1, 16 phases each: frame time p50 34.26 / 34.26 ms, GPU span p50
  35.43 / 35.12 ms. Both had 175.0 passes and 444 / 460 MB of attachment
  loads / stores a frame.
- Run 2, throttled (its title screen also ran slower): frame time p50
  49.08 / 48.95 ms.
Screenshots of frames 4800, 5400 and 6000 with the rule off and on differ
only where the scene animates, by as much as two frames of one run do.

Checked: make -C dm/gfx/tests GAME=010015100B514000 check_t_fb_dpool (new,
49 checks) and the full make -C dm/gfx/tests GAME=010015100B514000 run
pass. test_docs and test_env_switches pass. clang-tidy finds nothing above
the baseline in the changed files, and nvn_mtl.m keeps its 56 warnings with
the host's flags.

_Drafted with Claude; reviewed by me._
@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation labels Nov 5, 2026
fpedrolucas95 pushed a commit to fpedrolucas95/nxnative that referenced this pull request Nov 6, 2026
…0 passes a frame stay open in Ocarina of Time

fb_prepare took every draw with pool handles or dynamic tables as a depth reader, because only the GPU knows what the handle names, and the next draw writing depth ended the pass. Ocarina of Time's material shaders are pool shaders that write depth, so each draw ended the pass the one before it opened. PucaVaz found the rule and the cost in Link's house (issue seedds#1385, PR seedds#1396), and that asking the pool tables doesn't help: the game registers its depth textures in the pool those shaders read.

A draw that writes depth itself is no longer taken as a reader: sampling the attachment it writes would be a feedback loop, undefined on the console too. In Kokiri Forest the reader wrote depth in 67,730 of 67,739 splits. Pool draws that leave depth alone still count, as do real slots and TEXU handles. This is an inference from the API, not a proof about any shader. DM_NVN_FB_DSELF=0 counts them as before; ab alternates with DM_AB.

On the author's stack (M5 Pro, 1280x720, game upscaler, encoding thread off, A/B over 6 paired phases): 183 to 112 passes a frame, attachments 286 / 316 to 244 / 273 MB, GPU p50 20.3 to 16.5 ms, frame p50 20.7 to 16.8 ms. Not run on this commit, on main, or on another game.

Check: check_t_feedback, with a new case: 176 checks, 0 failed; with the change taken out, 3 of them fail. test_docs, test_env_switches, test_gfx_tests_makefile, docs_symptoms.py --check; lint_c.py tidy on nvn_mtl.m and t_feedback.m: 0 file/check pairs above the baseline.
fpedrolucas95 pushed a commit to fpedrolucas95/nxnative that referenced this pull request Nov 6, 2026
…0 passes a frame stay open in Ocarina of Time

fb_prepare took every draw with pool handles or dynamic tables as a depth reader, because only the GPU knows what the handle names, and the next draw writing depth ended the pass. Ocarina of Time's material shaders are pool shaders that write depth, so each draw ended the pass the one before it opened. PucaVaz found the rule and the cost in Link's house (issue seedds#1385, PR seedds#1396), and that asking the pool tables doesn't help: the game registers its depth textures in the pool those shaders read.

A draw that writes depth itself is no longer taken as a reader: sampling the attachment it writes would be a feedback loop, undefined on the console too. In Kokiri Forest the reader wrote depth in 67,730 of 67,739 splits. Pool draws that leave depth alone still count, as do real slots and TEXU handles. This is an inference from the API, not a proof about any shader. DM_NVN_FB_DSELF=0 counts them as before; ab alternates with DM_AB.

On the author's stack (M5 Pro, 1280x720, game upscaler, encoding thread off, A/B over 6 paired phases): 183 to 112 passes a frame, attachments 286 / 316 to 244 / 273 MB, GPU p50 20.3 to 16.5 ms, frame p50 20.7 to 16.8 ms. Not run on this commit, on main, or on another game.

Check: check_t_feedback, with a new case: 176 checks, 0 failed; with the change taken out, 3 of them fail. test_docs, test_env_switches, test_gfx_tests_makefile, docs_symptoms.py --check; lint_c.py tidy on nvn_mtl.m and t_feedback.m: 0 file/check pairs above the baseline.
@seedds

seedds commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Thank you for the counts and A/B measurements: they showed that what a pool could hold doesn't narrow the rule here. Main now uses the shaders' read tracking instead (pool_ds_read, 6121fd7), which took the walk from 182 to about 101 passes a frame, so I'm closing this. Final checks on the rebased tree (full shader build, walks, regression) are still running, so please report anything you see.

@seedds seedds closed this Nov 6, 2026
cheemu21 pushed a commit to cheemu21/kid-with-sword-remake that referenced this pull request Nov 6, 2026
…0 passes a frame stay open in Ocarina of Time

fb_prepare took every draw with pool handles or dynamic tables as a depth reader, because only the GPU knows what the handle names, and the next draw writing depth ended the pass. Ocarina of Time's material shaders are pool shaders that write depth, so each draw ended the pass the one before it opened. PucaVaz found the rule and the cost in Link's house (issue seedds#1385, PR seedds#1396), and that asking the pool tables doesn't help: the game registers its depth textures in the pool those shaders read.

A draw that writes depth itself is no longer taken as a reader: sampling the attachment it writes would be a feedback loop, undefined on the console too. In Kokiri Forest the reader wrote depth in 67,730 of 67,739 splits. Pool draws that leave depth alone still count, as do real slots and TEXU handles. This is an inference from the API, not a proof about any shader. DM_NVN_FB_DSELF=0 counts them as before; ab alternates with DM_AB.

On the author's stack (M5 Pro, 1280x720, game upscaler, encoding thread off, A/B over 6 paired phases): 183 to 112 passes a frame, attachments 286 / 316 to 244 / 273 MB, GPU p50 20.3 to 16.5 ms, frame p50 20.7 to 16.8 ms. Not run on this commit, on main, or on another game.

Check: check_t_feedback, with a new case: 176 checks, 0 failed; with the change taken out, 3 of them fail. test_docs, test_env_switches, test_gfx_tests_makefile, docs_symptoms.py --check; lint_c.py tidy on nvn_mtl.m and t_feedback.m: 0 file/check pairs above the baseline.
(cherry picked from commit 5cb7d71)
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