Skip to content

A pool draw that writes depth no longer counts as reading it: about 70 passes a frame stay open in Ocarina of Time - #1430

Closed
lunks wants to merge 1 commit into
seedds:mainfrom
lunks:pr/fb-dself
Closed

lunks wants to merge 1 commit into
seedds:mainfrom
lunks:pr/fb-dself

Conversation

@lunks

@lunks lunks commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

What and why

In Ocarina of Time (Switch 2, 0400486024DEC000, v131072; profile in #1299) about 70 render passes a frame end in fb_prepare for no reader, as @PucaVaz found and measured in #1385 and #1396: a pool or dynamic-handle fragment shader could name the depth attachment, only the GPU knows, so fb_prepare takes it as a depth reader and the next draw that writes depth ends the pass. The game's material shaders are pool shaders that write depth, so each draw ends the pass the one before it opened. Asking the pool tables does not help (#1396): the game registers its depth texture in the pool those shaders read.

Change: a pool or dynamic-handle draw that writes depth itself is no longer taken as a reader of it (S.ds.dt && S.ds.dw): sampling the attachment you are writing is a feedback loop, undefined on the console too. Pool draws that leave depth alone still count (water reading the scene's depth), as do real slots and TEXU handles. DM_NVN_FB_DSELF=0 counts them as before; ab alternates with DM_AB. It is on by default; say so if you would rather have it opt-in.

Evidence, and how far to trust it:

Stacking on #1396: that draft turns the line into g_fb_dread = fb_depth_reader(fs), so the two conflict in nvn_mtl.m (git merge-tree: that line, plus the generated docs). With #1396 merged first, the rule goes inside fb_depth_reader, on its dynamic and pool branches: int self = S.ds.dt && S.ds.dw && fb_dself();, then if (fs->rf.dyn && !self) and if (fs->rf.pool && !self). It must not be an early return 0 before them: that would also skip the final fs->rf.bindless && fb_texu_reads(...) check, which keeps a TEXU handle that names the depth a reader (found by @deanpektas27 when stacking the two). With both in, #1396's t_fb_dpool needs a pool draw that leaves depth alone for its cases, as every pool draw in it writes depth now. I will rebase this onto whichever lands first.

Reference entry: Pool shaders that write depth taken as depth readers. The symptom index is regenerated.

Checked

  • Tests (Apple M5 Pro, macOS 27, niced; make -C dm/gfx/tests GAME=010015100B514000 check_t_feedback): 176 checks, 0 failed. New case depth_self_case: pool draws that write depth stay in one pass; a pool draw that leaves depth alone counts as a reader and the next draw that writes depth ends the pass; with the rule off a pool draw that writes depth counts as before. With the change taken out of fb_prepare, 3 of its checks fail. test_docs.py, test_env_switches.py and test_gfx_tests_makefile.py pass; docs_symptoms.py --check current; lint_c.py tidy on nvn_mtl.m and t_feedback.m: 0 file/check pairs above the baseline.
  • Games run: none for this branch (the numbers above are from the author's stack, which carries the same change).
  • Not run: the rest of make -C dm/gfx/tests run, the host tests, regress.py, any game on main. The rule applies to every game with pool or dynamic-handle fragment shaders that write depth; only Ocarina of Time is known to have them.
  • Devices and OS: Apple M5 Pro, macOS 27.

Docs

  • The finding is in docs/reference/rendering.md and python3 dm/tools/docs_symptoms.py was run
  • No chain tool changed
  • No game files, keys or data extracted from a game in the change

…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.
@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation labels Nov 6, 2026
fpedrolucas95 pushed a commit to fpedrolucas95/nxnative that referenced this pull request Nov 6, 2026
… longer counts as reading it: about 70 passes a frame stay open in Ocarina of Time
@deanpektas27

Copy link
Copy Markdown

Played on a branch that has #1299's profile, since this one can't be run alone.

Setup: Apple M3 Max, 36 GB, macOS 27.0.1. Ocarina of Time v131072 from a NUP, Mac build, windowed (2560x1440 window, 1536x864 scene), Kokiri Forest autosave. The branch is main (865cc35) + #1299 + #1360 (merged onto #1299's hints) + other open Ocarina PRs, with #1396 and #901, then this PR's commit 5cb7d71 cherry-picked on top. I didn't merge the PR because its base would have brought in main's newer alias-region and audio commits.

A/B: DM_NVN_FB_DSELF=ab DM_AB=300, standing still outside on the path into the village. Medians of 7 paired phases (phases 4–17, 225 frames each):

rule off rule on
passes a frame 184.6 114.1
GPU p50 / p90 29.43 / 33.42 ms 23.93 / 25.02 ms
frame p50 / p90 29.68 / 33.53 ms 24.08 / 25.50 ms
attachments a frame (load / store) 390 / 395 MB 332 / 337 MB

Every pair moved the same way: GPU p50 5.2–6.0 ms lower. The p90 drops by about 8 ms, so the frame pacing is steadier too. #1396's by-cause line in an earlier run on the same branch had 59.8–74.4 depth splits a frame after pool or dynamic shaders, and 0.0–0.1 for every other cause.

Picture: one screenshot with DM_NVN_FB_DSELF=0 and one with the default, from the same spot on that path (fog, light shafts, grass, foliage edges, stone path, shadows, NPCs at a distance). I see no difference apart from the falling leaves and a fairy, which move anyway. This is a visual comparison, not a pixel diff, and the camera angle differs slightly. No GPU errors in any of the runs.

Rule off (DM_NVN_FB_DSELF=0, HUD GPU 29.1 ms):
Screenshot 2026-11-05 at 9 19 27 PM

Rule on (default, HUD GPU 25.5 ms):
Screenshot 2026-11-05 at 9 20 09 PM

Stacking on #1396, two notes:

@lunks

lunks commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, a second machine and a bigger scene is what this needed. You are right about the early return: the rule belongs inside fb_depth_reader on the dyn and pool branches, as you wrote it, so the TEXU check at the end still runs. I corrected the stacking paragraph in the description. t_fb_dpool needs a pool draw that leaves depth alone for its cases, which is for whichever lands second to carry.

@bettterlabs

Copy link
Copy Markdown
Contributor

A third machine, a 16 GB one where the GPU is the limit: Mac mini M4, macOS 27.2, docked (2048x1152 scene in a 2560x1440 window), v131072. In earlier powermetrics samples during play, the GPU held its top clock (1,578 MHz) and was about 97% busy. Host only: david-on-spring's #1386 branch (562c1f4 on f3ac5ea, chain 84aede056dea) + #1396 + 5cb7d71, with the rule inside fb_depth_reader as deanpektas27 describes.

DM_NVN_FB_DSELF=ab DM_AB=600, camera still. Medians of the phases that fall wholly inside the still stretch:

outdoors, off (3) outdoors, on (3) Link's house, off (2) Link's house, on (2)
passes a frame 178.6 105.8 181.8 109.2
GPU p50 / p90 44.8 / 53.1 ms 35.6 / 38.1 ms 42.7 / 64.6 ms 32.4 / 56.7 ms
frame p50 / p90 32.3 / 35.8 ms 26.7 / 27.7 ms 41.3 / 60.8 ms 25.9 / 47.4 ms
attachments a frame (load / store) 318 / 328 MB 309 / 318 MB 306 / 334 MB 288 / 316 MB

That's about 31 → 37 fps outdoors (in Kokiri Forest, by a chest) and 24 → 39 fps in the house. Attachment traffic drops by only 9–18 MB a frame here, against 58 on the M3 Max and 42 on your M5 Pro, so on this M4 most of the gain doesn't come from attachment traffic.

Picture, as a pixel diff. In Link's house, DM_NVN_SHOT took a pair of screenshots 15 frames before and 15 frames after each switch of the rule, with the camera unchanged. The four pairs from the still stretch differ by a mean of 1.6–3.5 levels (0.6–2.1% of pixels by more than 32). Two shots under the same rule 570 frames apart differ by 2.8–4.5 (1.7–2.8%). Amplified, the pairs differ only where something moves: Link's idle pose, Navi, grass and leaves. Walls, bed, curtain and lamps match.

Both runs logged only the REDs this build always logs (the title movie's AV1 packet; slot 7's texture type mismatch). No GPU errors, and both exited cleanly.

Drafted with Claude; reviewed by me.

@PucaVaz

PucaVaz commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

I ran this PR's rule against the snapshot follow-up to #901 that I'd been building (pool draws read a copy of the depth attachment through their pool tables instead of ending the pass), in one process: Ocarina of Time, Link's house, docked, MacBook Air M4, DM_AB=600 with the phases cycling through today's rule, DM_NVN_FB_DSELF and the snapshot. 14 phases each:

per frame today DSELF snapshot
render passes 175.3 103.2 104.9
passes ended in fb_prepare 72.1 0.0 0.0
depth copies 0 0 2.75
attachments load / store (MB) 446 / 461 406 / 420 422 / 436
frame p50 / p95 (ms) 35.97 / 42.19 30.22 / 32.44 30.80 / 33.10
GPU span p50 / p95 (ms) 36.50 / 43.23 30.52 / 34.51 31.11 / 34.80

Paired against the neighbouring phases of today's rule (frame / GPU span p50): DSELF −5.61 / −6.06 ms, the snapshot −4.98 / −5.45 ms. Snapshot minus DSELF: +0.68 / +0.65 ms. A second, throttled run gave the same order (DSELF −5.86 / −5.87, snapshot −4.90 / −4.62). In this scene the snapshot buys nothing over this rule and costs copies and table builds, so I'm not opening it.

Do the draws read the depth they write? In my runs the passes end between material draws that write depth into a 128×128 Depth32Float target (222 of 227 in a window of 400 passes), each after a reader that wrote depth itself, which matches your 67,730 of 67,739 and @deanpektas27's 128×128 count. A diagnostic build read every 32-bit word of the uniform and storage buffers bound to every pool draw with a depth attachment as a texture handle, in windows of about two frames. For the 128×128 target (220–225 such draws a window, all writing depth, about 3.2 GB scanned) no word named either of its two pool IDs, in 3 of 4 runs; in the other run 24 words a window matched its IDs that time (0x201c, 0x201d) and I didn't capture where. The remaining few splits are on a second 1920×1080 Depth32Float target, drawn by the shadow-receiving materials (fa20be590aae1d99, 74a006f0005b2f7a, e2f868a2799ed178, the family of dbda7678a92ef6e5): a handle naming it sits in their fragment storage buffer 0, but all 138 of their pool reads take their handle from the light records in storage buffers 1 or 2, which hold none. So, within what a CPU scan can see (handles computed on the GPU, or written by it after the scan, are not covered), these draws don't read the depth they write. dbda7678a92ef6e5 itself didn't draw in Link's house in any of my runs.

Picture. Screenshots at frames 4,800, 5,400 and 6,000 at a fixed render scale (DM_UPSCALE_RATIO=1.5), same save and presses. DSELF against today's rule: mean difference 4.8–5.5 levels, 3.7–4.2% of pixels by more than 32. Two runs of today's rule against each other: 5.2–7.9 and 4.0–6.1%. The differences are Link, Navi, leaves, grass and the dust in the light, as between any two runs.

Drafted with Claude; reviewed by me.

@seedds

seedds commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Thank you, your numbers independently confirmed the cause. Main took a different rule (a shader counts as a depth reader only once a depth texture was seen read through a pool, 6121fd7), as yours assumes a shader never reads the depth it writes. A counter now shows if a later area needs your condition too; if it does, this is the idea to revisit. 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
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 6, 2026
) replaces the pull request heads

Upstream landed Ocarina of Time in main (chain adec7d81d970: "holds 60 fps in
Kokiri Forest, 4 GB less") and closed the pull requests it folded in, with
reworded and partly different code. Mixing a pull request head with its landed
version hunk by hunk silently broke things (two GlobalToStorage tables, a
duplicate AF2ip, two mtk_pso definitions), so the rule here is one version per
feature:

- Pull requests upstream closed (seedds#1299, seedds#1300, seedds#1312, seedds#1343, seedds#1345, seedds#1353,
  seedds#1354, seedds#1359, seedds#1360, seedds#1373, seedds#1428, seedds#1430, seedds#1434): their hunks are taken out
  of our side before the three-way merge, so upstream's landed version stands
  alone. Gone with them: bindless pool table patching (reg_bump), the ghost
  table's per-submission hold, the growing raw pool table and its write-log
  sizing, seedds#1428's pool_buf spares (upstream's pool_table_take is the same
  idea), the ixc-based indirect mesh draw (upstream uses mdx), and their tests
  (t_pool_table, t_mesh_indirect, t_gmem_raw, finalized_bindless_pool,
  test_bnsh_nvn2, test_ndve).
- Open pull requests that upstream's landing supersedes: seedds#1394 (upstream's
  mtl_pool_addressed) and seedds#1393 pool_skip_retired (upstream's budgeted pool
  walk; the profile does not use it) are dropped with their tests and docs.
  seedds#1368's mesh pipeline prewarm is dropped too: upstream kept the background
  build in nvn_geometry.inc, and the prewarm's mtk_build / mtk_claim /
  g_warm_mps went with it.
- Kept, as upstream does not have them: NXForge's own features (GPU watchdog,
  present filters and anti-aliasing, aniso floor, quick settings, pipeline
  archive compaction, upload breakdown, memory report lines) and the still
  open pull requests seedds#1304, seedds#1305, seedds#1308, seedds#1320, seedds#1331, seedds#1332, seedds#1355, seedds#1356,
  seedds#1369, seedds#1376, seedds#1377, seedds#1379, seedds#1382, seedds#1390, seedds#1391, seedds#1392, seedds#1414, seedds#1420,
  seedds#1436, seedds#1439, seedds#1446, seedds#1453.
- Combined where both sides changed the same lines: GW_N 1 << 16 with seedds#1320's
  wide-write intervals (GW_PAGES = SBW_WPAGES, 128 slots), the CPU upload log
  with upstream's linear upload route, arch_open_end before the compaction
  limit, cbn_prog in geo_gs_one (seedds#1382).
- Translator: upstream's sources and binaries, plus seedds#1414's
  InstEmitAmpereMem diff and the LDSM SubgroupBarrier in
  InstEmitAmpereTensor.cs. The binaries and dmsh_chain.lock are upstream's
  until the Mac rebuilds them (dotnet publish, tool_lock --update-lock,
  dmsh_chain --header); dmsh_chain.h is regenerated for now.
- RED_ONCE keys: upstream took 153 and 154, so the present anti-aliasing lines
  move to 156 and 157 and seedds#1420's packaged-entry line to 158 (array 159).
- Host: hle_net.c and t_net_eventfd.c are upstream's (its eventfd poll waits
  out the whole timeout, which our 5d7920c fixed another way).
- Kept ours: dm/tools/romfs_image.py and its test, so the 26 GB RomFS image is
  not packed again.

dm/tools tests on Linux: same failures as on 875449e (Linux-only quirks),
plus test_nlg4 and test_dmsh_pool_arrays, which fail on upstream main too.
Not yet built on the Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qteK29T18U8p1c9LsVY8g
fpedrolucas95 added a commit to fpedrolucas95/nxnative that referenced this pull request Nov 6, 2026
…n Kokiri Forest, about 4 GB less memory) beside this tree's own work

Kept from this tree: the growing raw pool table with two-word hints (plus upstream's read-before-OR on its write
log), seedds#1439's held count, seedds#1436's diagnostics, seedds#1447's texture release, seedds#1435, the idle evictions, the GPU watchdog,
range-tracked GPU writes, the packaged-texture cache (g_pkg_alias), the synchronous task / mesh pipeline build with
prewarm, this tree's F2IP.

Taken from upstream: the pool tables (only what shaders read is made, within a budget; pool draws count as depth
readers only when one was seen reading, replacing seedds#1430's rule; table reuse), sub_cut's busy rule, the indirect
task / mesh draw through mdx, the empty mesh fragment, the larger GPU-write table (65,536 slots), linear textures by
rows, the atomic alias on any 32-bit format, its eventfds (with this tree's select and fcntl on them), its
prepo Send and audio update lock, its shader split and Ocarina profile and tables.

Duplicates both sides added were removed (SDK 22 answers, F2IP, test stubs); RED_ONCE 153 split; t_pool_table
(the replaced pool table code) removed; tests adapted to the taken rules. Translator rebuilt; chain 695a9393d125.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

5 participants