Skip to content

Keep texture views for finalized textures that registered IDs still resolve - #2482

Closed
dpadr wants to merge 1 commit into
seedds:mainfrom
dpadr:vc2-finalized-views-2
Closed

dpadr wants to merge 1 commit into
seedds:mainfrom
dpadr:vc2-finalized-views-2

Conversation

@dpadr

@dpadr dpadr commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What and why

Symptom. Creating texture views (newTextureViewWithPixelFormat) was the largest single cost on Ocarina of Time's (Switch 2) submit thread. The view cache hit, yet the game made about 330 views a frame in Kokiri Forest, and about 310 of them were the same views made again.

Cause. The view cache (vc_get / vc_put, Texture views cached by texture and view parameters) kept views only for live textures.

  • A texture finalized while one of its IDs is still registered resolves through g_ghost.
  • vc_set_for refused that texture's set, because nothing would have dropped it later.
  • So every submission that read the ID made a transient view, which treg_finish_submission released at the end of the submission, and the next submission made it again.
  • OoT has about 37 submissions a frame.

Change.

  • A finalized texture that g_ghost still holds keeps its view set, marked as a finalized texture's.
  • Once a frame, vc_ghost_sweep, called from tex_by_id's slow path under g_rmu, drops the sets of serials that g_ghost has let go.
    • The owner evicts idle ghost entries under g_smu (own_ghost_trim), which can't take g_rmu, so the eviction itself can't drop the set.
    • Both checks look at the ghost table through a new owner function, own_ghost_has / own_ghost_has_locked: a look that is not a use, so a cached view never keeps its serial in the table (Finalized textures in use stay in their table: no 100–490 ms frames at a streamed transition on 0400486024DEC000 #2389 keeps entries by use). Every tex_by_id lookup still resolves the ID's texture, which is a use, so a finalized texture the game reads stays, and its set with it.
    • The views hold their texture only as long as g_ghost already does, plus up to a frame.
  • DM_NVN_VC_KEEP: on by default; 0 turns it off, and ab alternates it with the DM_AB phases for an in-run A/B.

Why on by default. This is general host code. It changes no translated output, only whether an identical Metal view is made again. Each kept set lives no longer than its ghost entry (plus up to a frame), and all sets together are bounded by VC_MAX, and each set is dropped by the same paths as before (the texture's pool finalized, VC_MAX) plus the sweep. With 4 slots per texture as before, OoT showed no evictions (0.0 a frame), so the slot count is unchanged.

Relationship to #2260 (open, roandegraaf: views cached by the Metal texture, behind this cache). The two fix different misses of the same cache, and they are complementary:

Reference entry: Texture views re-made for finalized textures every submission; symptom index regenerated; a line under OoT's "Found on this game".

Checked

  • Measurement: in-run A/B on Ocarina of Time (0400486024DEC000, v131072, Switch 2).
    • Setup:

      • the Kokiri Forest route, replayed from a pinned save, to frame 12,000;
      • DM_AB=240, so phases of 240 frames alternating off and on; 21 + 21 phases from frame 1,800;
      • handheld full resolution (1440×810), 60 fps target;
      • each on-phase compared with the mean of its two off-neighbours: 20 pairs, median with a bootstrap 95 % interval.
    • Host: the same change on our cut4 line (these numbers were measured before the rebases onto main b21eb3f and then 0c23e0b, where the serial tables moved to nvn_owner.m and Finalized textures in use stay in their table: no 100–490 ms frames at a streamed transition on 0400486024DEC000 #2389 changed how long the ghost table keeps entries; the view-cache logic is the same. A fresh in-run A/B on current main follows once our main reference build reaches that chain). Its other switch, 16 slots a texture, never came into play: 0.0 evictions a frame with the switch off.

    • Results:

      off → on B − A [95 %]
      submit-thread cycles a frame 27.89 → 25.83 M −1.72 M [−1.91, −1.49] (−6 %)
      submit-thread instructions a frame 61.77 → 56.59 M −5.37 M [−5.65, −5.06]
      views made a frame 328–347 → 38.4–38.9 (about 290–310 of them for finalized textures)
      views reused a frame 87 → 366–414
      one view about 15,400 instructions, 5,600–9,000 cycles
      frame time p50 −0.17 ms [−0.46, +0.02] (noise)
      GPU p50 +0.28 ms [+0.16, +0.43]
      attachment traffic and passes unchanged

      The views made with the change on (about 37 a frame) are new textures' first views, which no cache can avoid.

    • Frame time: the A/B ran mostly GPU-bound, with the CPU clock varying from 1.6 to 2.6 GHz over the run, so frame p50 moved within noise. The result is stated in cycles and instructions, which don't depend on the clock.

    • Why the cycles still matter. Burn controls on the same Mac and route added a fixed amount of submit-thread work (3 M cycles spun at each frame's first submission, in alternating DM_AB phases):

      • at about 3 GHz, about 95 % of the extra work is hidden behind the GPU;
      • at about 2.1 GHz (background load, or heat on a fanless M2) it lands in full, at about +0.57 ms a frame per M cycles.

      The M2 Air spends long stretches of play at that lower clock, so −1.72 M cycles is about −1 ms a frame there.

    • GPU p50: the +0.28 ms is small and has no identified cause. The change encodes the same views and the same draws, and per-pass GPU times (DM_NVN_GPUPROF) are within noise.

  • Device: MacBook Air M2, 16 GB, macOS 27.0.
  • regress.py impact origin/main..HEAD (3fd81cd0 on main 0c23e0b):
    • affected: Breath of the Wild, Pokémon Scarlet, Tears of the Kingdom (texture finalize / register / pool paths);
    • not affected: Hades;
    • no usage manifest: 159 profiles, OoT among them.
    • None of the three affected games is on this machine.
  • regress.py check --games: not run. The local NVN games with Mac builds (Animal Well 010020D01AD24000, Mina 0100DCD01F306000) have shader packages split before packages carried their shader keys, and main's f7a5452 (Ocarina of Time (0400486024DEC000) black screen with sound on c78d7375 / chain 89f44f9b6bb0: the present pass pairs VS a5b1bd74e47458d7 (writes loc0 only) with FS 29e3c9dd92b8a571 (reads loc1/loc2) #1841) refuses them, so the check stopped before any comparison, on the baseline and this branch alike. No regression check was done for this change.
  • Tests:
    • On 3fd81cd0 (main 0c23e0b; M2, macOS 27.0). The PR head 4e5750d is rebased onto main 596d719: it adds the switch's row to docs/env_switches.tsv and keeps nvn-mtl: write image buffer textures below 16-byte alignment through a shadow #1399's new tbv fields in the view-set struct beside the ghost flag. On 4e5750d: make dmhost, t_nvn 311 checks and t_serial_owner 176, 0 failed. The full run: t_nvn 311 checks, 0 failed (also with DM_NVN_VC_KEEP=0); t_serial_owner 176/0; t_rt_lifetime 9/0; t_queue_lifetime 38/0; t_cpu_tex 105/0; t_pool_notes 91/0; t_readback 218 cases, 0 failed.
    • dm/tools/tests/test_docs.py (6 tests) and test_env_switches.py OK on 600d3c7 (run with python3 -m unittest).
  • Not checked:
    • iPhone;
    • Breath of the Wild, Tears of the Kingdom and Pokémon Scarlet (no local game files);
    • a long session for memory: the kept sets follow the ghost table's entries and VC_MAX, as above.

Docs

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

🤖 Generated with Claude Code

@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation game: 0400486024DEC000 A game labels Nov 12, 2026
@dpadr
dpadr force-pushed the vc2-finalized-views-2 branch from a478810 to 600d3c7 Compare November 12, 2026 17:30
…ture views: every submission that read such an ID made its Metal view again, about 310 of the 330–347 views Ocarina of Time (Switch 2) made a frame in Kokiri Forest

The view cache kept views only for live textures. A texture finalized while one of its IDs is still registered resolves through g_ghost, and vc_set_for refused its set because nothing would drop it. tex_by_id then made a transient view that treg_finish_submission released at the submission's end, and the next submission made it again.

Such a texture's set is now kept, marked as a finalized texture's. Once a frame, from tex_by_id, vc_ghost_sweep drops the sets of the serials g_ghost has let go. The ring evicts under g_smu, which can't take g_rmu. The views hold the texture only as long as g_ghost already does, plus up to a frame. DM_NVN_VC_KEEP (default 1): 0 turns it off, ab alternates it with the DM_AB phases.

In-run A/B on Ocarina of Time's Kokiri Forest route (M2 Air 16 GB, 20 pairs of 240-frame phases):
- views made 330–347 → 38 a frame, none from eviction;
- the submit thread −1.72 M cycles a frame [−1.91, −1.49] and −5.37 M instructions [−5.65, −5.06];
- frame time within noise (GPU-bound).

On main 0c23e0b (serial tables in nvn_owner.m, seedds#2389's ghost table kept by use): vc_set_for and vc_ghost_sweep ask the owner through own_ghost_has / own_ghost_has_locked, new: a look at the ghost table that is not a use, so a cached view never keeps its serial in the table. Every tex_by_id lookup still resolves the ID's texture (a use), so a finalized texture the game reads stays in the table, and its set with it; once the owner lets an idle entry go, the next sweep drops the set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dpadr
dpadr force-pushed the vc2-finalized-views-2 branch from 600d3c7 to 4e5750d Compare November 12, 2026 17:42
seedds added a commit that referenced this pull request Nov 13, 2026
…eps its views until the ghost table lets it go (PR #2482)

The view cache (vc_get / vc_put) kept views only for live textures. A texture the game finalized while one of its IDs is still registered resolves through g_ghost, and vc_set_for refused its set, as nothing would drop it. So every submission that read the ID made a transient Metal view, released at the submission's end, and the next submission made it again: about 310 of the 330-347 views Ocarina of Time (Switch 2) made a frame in Kokiri Forest.

Such a texture's set is now kept, marked as a finalized texture's. At the first commit of each frame, right after own_ghost_trim, vc_ghost_sweep drops the sets of serials the ghost table no longer holds (the eviction runs under g_smu, which cannot take g_rmu). The checks look at the ghost table without counting as a use (own_ghost_has), so a kept view does not keep its texture in the table. A texture buffer's 2D rows stay out of these sets: a finalized texture's rows are kept by the memory they view (g_tb2_gone), which other textures' descriptors over that memory share. DM_NVN_VC_KEEP (default 1): 0 turns it off, ab alternates it with the DM_AB phases. The view-cache log line counts the finalized textures' sets.

Measured in the PR, on a line before #2389 (in-run A/B, OoT Kokiri Forest, M2 Air, 20 pairs): views made 330-347 -> 38 a frame, submit thread -1.72 M cycles a frame [-1.91, -1.49], frame time within noise (GPU-bound). Not measured again on main: no OoT libraries on disk.

Checked: t_vc_ghost (new; 19 checks, 17 with DM_NVN_VC_KEEP=0), t_nvn, t_serial_owner, t_rt_lifetime, t_queue_lifetime, t_cpu_tex, t_pool_notes, t_tbuf2d, t_tbuf_align, t_readback, t_alias_takeover, t_upgrade_alias, t_pool_chunks; regress.py check on Bastion (makes no views) and Return of the Obra Dinn (4 finalized sets kept; its intermittent GPU hang hit baseline and candidate alike over 3 runs a side): fps and frame times unchanged.

Co-authored-by: dpadr <34929160+dpadr@users.noreply.github.com>
@seedds

seedds commented Nov 13, 2026

Copy link
Copy Markdown
Owner

Landed on main as 9339e55, with you as co-author. Thank you: the diagnosis was right, and so was fixing it in the view cache.

What changed from this branch:

  • Rebased onto current main. Main had dropped the tbv field from the view set.
  • Texture-buffer rows stay out of a finalized texture's set. tbuf_as_2d also stores through vc_set_for, and with the branch as it was, a finalized texture's rows would have gone into that texture's own set instead of g_tb2_gone. g_tb2_gone is keyed by the memory the rows view, so Unreal Engine 5.4's descriptors over the same memory share one view (Yoshi and the Mysterious Book made one per bind before it). vc_set_for now takes a ghost_ok flag, and the rows path passes 0.
  • The sweep runs at the commit. It ran on tex_by_id's slow path, so a frame where every ID took the fast path never swept, and a static scene could keep textures alive after the ghost table let them go. It now runs at the first submission commit of each frame, right after own_ghost_trim, and drops every released set in one pass (no cap of 64).
  • The cost comment says 5,600–9,000 cycles a view, from your measurement, rather than "about 30 us".
  • The view-cache log line now counts the finalized textures' sets.
  • A new test, t_vc_ghost, covers views kept across submissions, the switch's 0 mode, rows kept out of the set, the sweep dropping and keeping sets, and finalizing the pool.

Checks: the gfx tests above, plus t_nvn, t_serial_owner, t_cpu_tex, t_pool_notes, t_tbuf2d and the lifetime and alias tests, all pass. regress.py check on Bastion and Return of the Obra Dinn showed no change in fps or frame times. Obra Dinn reaches this path (4 finalized textures' sets kept). Its intermittent GPU hang showed up on the baseline and the candidate alike over three runs each.

Not measured again: the OoT gain on current main. There are no OoT shader libraries on this Mac right now. Your DM_NVN_VC_KEEP=ab A/B on a current host, with or without #2260, would be welcome here.

@seedds seedds closed this Nov 13, 2026
seedds added a commit that referenced this pull request Nov 14, 2026
Texture views were kept by the serial that asked (vc_get), with the views of finalized textures read through a registered ID in sets of their own (PR #2482, DM_NVN_VC_KEEP). Ocarina of Time initializes about 40 render targets again every frame over the same storage, each a new serial in a new texture ID; the sharing table hands each one the Metal texture the last frame's had, but its views (depth read as (R, 0, 0, 1), RGBX with alpha 1, sRGB views, image slots' writable views, which img_fit kept only by ID) were made again every frame on the submit thread.

A view depends only on the texture it views and its parameters, so the cache is now keyed by the Metal texture, for sampled and image views alike: one lookup, no parentTexture check per hit. A view retains its parent, so the key cannot name another texture while its entry exists. An entry is made only while the owner holds its texture (a live or ghost entry, a pin or the sharing table: own_holds_locked, by address) and goes at the first commit after the owner lets it go: the owner lists the objects that leave their last holder (gone_note, at a count reaching zero, a texture leaving the sharing table, a rename) and vc_commit takes the list (own_gone_take_locked); past 65,536 between two commits it checks every entry instead. A pool's finalize still drops its views. This replaces the cache by serial, PR #2482's finalized textures' sets with DM_NVN_VC_KEEP (removed), and the cache by shared texture the PR first added beside them. Texture buffers' 2D rows are kept with the buffer texture while it is live, as before by serial.

Measured on an M4 Pro (20-core GPU, macOS 27.2), Ocarina of Time, kokiri-deck with scripts/bench.py, three runs a variant against main f43284e (chain a3ee07f000cb): views made about 40 a frame -> about 1.5 (118,209-120,204 -> 4,397-4,711 by frame 5,700, image views now counted too), 2,349-2,406 -> 700-786 textures holding views; frame time unchanged (the game's 30 fps step), no red lines, the pictures right. Submit-thread time not measured on its own (about 0.2 M cycles a frame at PR #2482's 5,900 cycles a view).

Checked: t_view_reuse (19, the PR's test adapted), t_vc_lifetime (19, replaces t_vc_ghost: ghost reuse with no view made, the ghost table alone holding, pool finalize, the list's overflow scan) and t_view_swizzle, t_nvn, t_alias_takeover, t_upgrade_alias, t_image_srgb, t_image_usage, t_serial_owner, t_read_owner, t_view_levels, t_stencil_view, t_srgb_alias, t_pfv, t_cpu_tex, t_feedback, t_partial_upload, t_rt_lifetime, t_queue_lifetime, t_pool_notes, t_tbuf2d, t_tbuf_align, t_pool_chunks; lint_c tidy; test_docs. regress.py check on OCTOPATH TRAVELER II, two runs a side: PASS (frame p99 18.16/18.03 -> 17.92/18.0 ms, same shaders, no new red lines). Not run: Age of Calamity (the texture-ID ring the serial cache was made for; no build on this Mac), the iPhone.

Co-authored-by: stan69b <3304418+stan69b@users.noreply.github.com>
@seedds

seedds commented Nov 14, 2026

Copy link
Copy Markdown
Owner

A correction to my reply above: keeping a finalized texture's texture-buffer rows out of its set was wrong, and your branch had it right.

Measured on Yoshi and the Mysterious Book (M6, from an empty save to the opening dialogue): its menus read finalized textures' rows only, and g_tb2_gone misses on about a third of those binds. With the rows in the finalized texture's set, as you had them, the menus made 12 rows views instead of about 40,000.

Main has since moved to the cache keyed by Metal texture (#2542), which kept the live-only rule. 8d003e1 removes it, so a finalized texture's rows stay with it while the owner holds it. To frame 5,100, 114,091 binds made 40,308 rows views before; with the change, 169,774 made 788. You're credited as co-author there too.

Kayyluhh added a commit to Kayyluhh/nxnative that referenced this pull request Nov 14, 2026
…d-texture view sets take views the cache by Metal texture (seedds#2260) finds; seedds#2366's upscaler scales behind mfx_order; t_sbw keeps seedds#1320's and main's cases
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 0400486024DEC000 A game 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