Skip to content

Pool tables for texture handles traced to no slot reuse their buffers and pass over IDs of another type - #1428

Closed
enjoyerofslop wants to merge 3 commits into
seedds:mainfrom
enjoyerofslop:perf/pool-table-churn
Closed

enjoyerofslop wants to merge 3 commits into
seedds:mainfrom
enjoyerofslop:perf/pool-table-churn

Conversation

@enjoyerofslop

Copy link
Copy Markdown
Contributor

What and why

Symptom: in a game whose shaders read the texture pool (the pool for handles traced to no slot lines), the pool table's source line leads the residency report. Every 300 frames it shows thousands of residency-set additions and removals and hundreds of commits. Found on The Legend of Zelda: Ocarina of Time (Switch 2, 0400486024DEC000), where 4,823 of 42,854 shaders read the pool.

Cause: two things in pool_gpu (dm/gfx/nvn/nvn_mtl.m).

  • A new buffer for every rebuild. Each rebuilt table went into a new buffer of 8 bytes per registered ID (264 KB at Ocarina of Time's 33,000). That meant an allocation, a residency-set addition and, behind the fence, a removal. A game that registers textures or changes their storage every frame rebuilds tables many times a frame.
  • Lookups before the type check. A walk looked up every registered ID before checking its texture type. So a 2D-array table walked every 2D ID, and remade the views of retired registrations (which go at every submission end) only to turn them away.

Change:

  • Spares. A replaced table becomes one of 64 spares (pool_buf_retire). A later rebuild writes it again once the submission that could still read it has completed (pool_buf, against g_done). Spares stay in the residency set. Argument blocks holding an old address are not reused past a rebuild (g_psnap_gen, unchanged).
  • Type skip. A registration keeps the Metal type tex_by_id gave it (treg_t.tt, cleared when the ID is registered again), and a walk passes over an ID of another type without looking it up. The type is fixed by the descriptor (its target, the view's target and the layer count), so it outlives the cached view.
  • Pool log line. It now counts table buffers made, reused and IDs of another type passed over.

Reference entry: Pool tables rebuilt into new buffers and walked over IDs of another type. The symptom index is regenerated. The entry also records what is still open: the table holds a Metal texture for every registered ID of its type, whether or not a shader reads it. In Ocarina of Time's Kokiri Forest that was about 4,200 textures (1.56 GB) that nothing read. Marking reads from the shaders to make them on demand cost about 22 % of GPU time, even with the marking switched off, because a fragment shader that can write memory runs its depth test after itself.

On #1299: that PR adds a copy-and-patch path to pool_gpu. With this change, its copy should come from pool_buf(p->buf.length) and the old table should go through pool_buf_retire(p->buf), and its pool_entry should get the same type check before tex_fast_rid. The Ocarina of Time numbers below are from main + #1299 + this change, merged that way.

Checked

  • Tests:
    • make -C dm/gfx/tests GAME=01007EF00011E000 t_pool_table: 20 checks OK. It covers:
      • a spare not written again before its submission completes, and written again after;
      • a 3D ID in a 2D table passed over without its view being made again;
      • a new registration forgetting the type;
      • a timing check: a walk over 20,000 2D IDs with dropped views in a 2D-array table takes 0.08 ms instead of 3.2 ms (M5 Max, Metal validation on).
    • The same test, t_cpu_tex (62) and t_pool_chunks (81) also pass on the Ocarina of Time tree (main + [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 + this change).
    • make -k -C dm/gfx/tests GAME=01007EF00011E000 run: every test passes but t_nvn. It aborts in up_owner_check ("upload batches belong to the submit thread only: up_begin called on thread ''", from present_enqueue → tex_mtl → pkg_upload), and it aborts the same way on main (865cc35) without this change.
    • python3 -m unittest dm/tools/tests/test_docs.py: OK.
  • Games run: The Legend of Zelda: Ocarina of Time, 0400486024DEC000, update v131072, with [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.
    • Setup: Kokiri Forest from the same save, along the same scripted route (walking, camera turns, dialog). About 250 s per run, two runs per host, alternated. The game does not run under DM_DETCLOCK, so the runs are on the wall clock and their timings drift apart.

    • Results (per 300 frames of gameplay, medians):

      before (2 runs) after
      residency commits 664, 678 56
      residency additions 3,136, 2,500 276
      commit time 70 ms, 46 ms 11 ms
      table buffers made / reused (whole run) all made 1,487 / 81,333
      lookups of IDs of another type (all looked up) 74 million passed over, about 5,900 a frame
    • Spare count: with 8 spares instead of 64, 41,483 tables were made: the oldest spare was pushed out before it came free.

    • Frame times: p50 is vsync-bound at 16.4 ms in all runs. p95 and long frames (48 to 52 per 1,000 frames) moved within the runs' noise. Time per full pool build was 2.07 and 3.64 ms in the two runs before and 2.95 ms after, so the before runs alone differ by more than the change.

  • regress.py: python3 scripts/regress.py check --baseline 865cc35f --candidate HEAD --games 01007EF00011E000,0100000000010000 (the full run), on the two NVN games this Mac has --platform mac builds of. regress.py impact lists Breath of the Wild among the games the change reaches, through the registration and lookup bookkeeping every NVN game has. Neither game has a shader that reads the texture pool, so the table code itself does not run in them.
    • Saves: the routes ran from this contributor's own saves, because the profiles' saves/outdoors and saves/look_around are not on this Mac.
      • Breath of the Wild waits at the load prompt: frame 2500 is not reached, in the baseline too.
      • Odyssey plays its opening from a new game.
    • The two flagged screenshots are run-to-run noise, not a change. The check's own baseline was a single run, so it reports a baseline noise of 0. Measured with regress.py's shot_hashes / shot_distance across five runs of 865cc35:
      • Breath of the Wild, 60 s (the title's artwork slideshow behind the load prompt): the baseline runs differ from each other by up to RGB 26.8 and 28 dHash bits. Each candidate run is within RGB 2.2 and 6.7, 2 bits, of a baseline run.
      • Odyssey, 95 s (the opening cutscene): baseline runs differ by up to RGB 14.9 and 9 bits. The candidate runs are within RGB 9.3 (8 bits) and 0.0 (one identical) of a baseline run.
    • Everything else is unchanged: shaders, frames reached, frame rate, p99, footprint and prebuild. validated.tsv was not recorded, as the routes did not run from the profiles' saves.

regress.py check (full): REGRESSION

Baseline 865cc35f 865cc35, candidate HEAD f537147; Apple M5 Max, macOS 27.0.1, Xcode 27.2; 918.6 s

game status shaders (base → cand) newly failing newly building changed MSL interface (new / known) boot fps p50 frame p99 ms peak MB prepare s new RED
The Legend of Zelda Breath of the Wild 01007EF00011E000 REGRESSION 19420/19420 → 19420/19420 0 0 0 0 / 0 FAIL 29.5 → 29.8 91.83 → 67.72 3057 → 3075 1.49 → 1.55 0
Super Mario Odyssey 0100000000010000 CHANGED 27984/27984 → 27984/27984 0 0 0 0 / 0 ok 59.9 → 59.9 18.63 → 18.39 2359 → 2380 None → None 0

The Legend of Zelda Breath of the Wild 01007EF00011E000: REGRESSION

  • REGRESSION: screenshot at 60 s differs (dHash 15 bits, RGB 23.3)
  • known (baseline too): frame 2500 not reached by 60 s (max 3206)
    • screenshots (s: dHash bits / RGB diff / baseline noise): 60: 15/23.3/0, 80: 4/1.89/0
    • baseline: exit None, first frame 2.9 s, max frame 3189, reached False, prepare 1.49 s, prebuild 90.7 s (15631 built, 0 failed), fps 29.5, p50/p99 33.35/91.83 ms, peak 3057 MB
    • candidate: exit None, first frame 2.9 s, max frame 3206, reached False, prepare 1.55 s, prebuild 90.3 s (15631 built, 0 failed), fps 29.8, p50/p99 33.35/67.72 ms, peak 3075 MB
    • time: chain 11.1 s, smoke 402.4 s

Super Mario Odyssey 0100000000010000: CHANGED

  • CHANGED: screenshot at 95 s moved (RGB 10.44)

    • screenshots (s: dHash bits / RGB diff / baseline noise): 70: 0/0.51/0, 95: 6/10.44/0
    • baseline: exit None, first frame 1.3 s, max frame 6520, reached True, prepare None s, prebuild 135.8 s (32864 built, 0 failed), fps 59.9, p50/p99 16.66/18.63 ms, peak 2359 MB
    • candidate: exit None, first frame 1.3 s, max frame 6505, reached True, prepare None s, prebuild 133.9 s (32864 built, 0 failed), fps 59.9, p50/p99 16.66/18.39 ms, peak 2380 MB
    • time: chain 13.7 s, smoke 491.1 s
  • Devices and OS: M5 Max (Mac17,6), macOS 27.0.1.

Docs

  • The finding is in its docs/reference/nvn-objects.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

🤖 Generated with Claude Code

… and pass over IDs of another type: Ocarina of Time's residency commits 12 times fewer

pool_gpu wrote every rebuilt table into a new buffer, 8 bytes per registered ID (264 KB at Ocarina of
Time's 33,000), so each rebuild was an allocation, a residency-set addition and, behind the fence, a
removal: the table's line led the residency report with thousands of additions every 300 frames. A
replaced table now becomes one of 64 spares and a later rebuild writes it again once the submission that
could still read it has completed (pool_buf / pool_buf_retire, against g_done); spares stay in the
residency set, and argument blocks holding an old address are not reused past a rebuild (g_psnap_gen).

A walk also looked every registered ID up before checking its type, so a 2D-array table made the
views of retired 2D registrations again, which go at every submission end, only to turn them away. A
registration now keeps the Metal type tex_by_id gave it (treg_t.tt, cleared when the ID is registered
again) and a walk passes over an ID of another type.

Ocarina of Time (with PR seedds#1299), Kokiri Forest, M5 Max, two runs per host: residency commits per 300
frames 664 and 678 -> 56, additions 3,136 and 2,500 -> 276; 1,487 table buffers made and 81,333 reused;
74 million lookups passed over. t_pool_table: a walk over 20,000 2D IDs with dropped views in a 2D-array
table takes 0.08 ms instead of 3.2 ms. Frame times did not change beyond the runs' noise. The reference
entry also records what is still open: the table holds a Metal texture for every registered ID of its
type (1.56 GB that nothing read in Kokiri Forest), and marking reads from shaders cost 22 % of GPU time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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
…traced to no slot reuse their buffers and pass over IDs of another type

Adapted to this tree's pool_gpu: its copy-and-patch path takes its copy from pool_buf and retires the old table through pool_buf_retire, as the PR asks for seedds#1299's path.
The type cache (treg_t.tt) goes into pool_entry after the descriptor-class check (ttype_apart, PR 1319), which already turns a 3D ID away from a 2D table but puts 2D and
2D-array together: t_pool_table's walk over 20,000 2D IDs in a 2D-array table, 4.42 ms looking them up, 0.19 ms passing over them (M4). Its two checks of the 3D ID say what
this tree does (turned away by its descriptor, no lookup). The pool log line keeps this tree's counters and adds table buffers made / reused and IDs passed over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AnkMister added a commit to AnkMister/nxnative that referenced this pull request Nov 6, 2026
Revised brief: keep only PR seedds#1434 (9eeacbb), seedds#1332 (7f5f2fc), seedds#1417 (1c1d453), and seedds#1428 (f537147). Remove all other prior ports and their follow-ups while preserving published history. Other glass and Mac agents own those items. Update the scoped report and references; exclude disabled SDK hooks from hit diagnostics.

Python tools match the baseline failure set; 12 docs/environment checks pass. Portable SDK gate and diagnostic checks pass GCC ASan/UBSan. Mac and Metal checks remain open.
fpedrolucas95 added a commit to fpedrolucas95/nxnative that referenced this pull request Nov 6, 2026
…itch 2) playable at 60 fps through Dodongo's Cavern

Resolved against this tree's own Ocarina work:
- Kept this tree's raw pool table (grows past 4,096 entries, two-word hints, per-entry write logs);
  the PR's merged-run table (GMEM_RUN) and one-word hints are left out, with the ABI and translator to match.
- Kept the pool table spares (seedds#1428), type cache, range-tracked GPU writes (seedds#1390), 1430's depth-read rule,
  the synchronous task / mesh pipeline build, and the item walk of NDVE packs.
- Taken from the PR: ALIAS_IDLE 512 and targeted storage notes on takeover, packaged-texture recycling (pkg_tex_put),
  cached zero-copy upload sources (texture-only pools keep a per-batch buffer), retired CPU-written IDs without a
  view get no texture, mesh pipelines without a vertex function, image binding upgrades with pixel-format views,
  F2FP/I2FP and other translator fixes not already here (its second F2IP dropped), loop_guard and tg_fence,
  the GPU watchdog, the eventfd poll timeout, the profile (16 GiB alias region, mem_map_limit, tex_flush_upload,
  tex_dirty_upload) and its shader tables.
- Tests adapted: t_alias_takeover (textures in a pool), t_cpu_tex (retired descriptors), t_res_guard (512-submission
  idle window), t_upgrade_alias (ghost entry), t_pool_chunks (contiguous pools in a growing table), t_present_alloc
  (layer sized to its pictures after upstream's crop change), t_hid (poolstat / texstat stubs).
- Translator rebuilt from the merged source; chain fe655346ea1c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n and its resources before it uses them

The test includes nvn_mtl.m, and with this branch's additions there the analyzer's path through the
test's main() reached two calls it had not before: newComputePipelineStateWithFunction: with the
function newFunctionWithName: may return nil, and copyFromTexture: with a texture newTextureWithDescriptor:
may return nil (clang-analyzer-nullability.NullablePassedToNonnull, 1 in the file against a baseline
of 0). The function is now checked before the pipeline is made, and the textures and buffers after
they are made; the test fails with a message either way, as it already did for the library and the
pipelines. lint_c.py tidy on the file: 0 findings (baseline 240, none above or below); the test passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 6, 2026
…ss over IDs of another type (Ocarina of Time's residency commits 12 times fewer)

Resolved against NXForge's pool table (seedds#1360's patching, seedds#1393's pool_skip_retired):
- pool_entry passes over an ID whose registration resolved to another texture type (treg_t.tt) after the gone check, so both the full walk and the patch path skip it
- the full build and the patch path take their buffers from the spares (pool_buf) and retire the replaced one (pool_buf_retire); pool_snap keeps its sub field
- the pool table log line keeps NXForge's why/patched/kept counts and adds made/reused/passed over
- t_int_float_view.m keeps main's checks; the Makefile has t_gmem_raw and t_pool_table
- t_pool_table marks the 2D table out of date before its second rebuild, since a patch on registrations alone would not walk the 3D ID

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qteK29T18U8p1c9LsVY8g
…e the place of this branch's

6121fd7 brought both halves of this pull request into main ("the idea is from PR 1428"): pool tables
reused once their fence has passed (pool_table_take / pool_table_give, sizes in 64 KB steps, 16
spares) and an ID of another texture type passed over by its registration's type (treg_t.tt1), in a
walk main has reworked (a budget, textures shaders read made first). This branch's own versions
(pool_buf / pool_buf_retire, POOL_SPARES 64, treg_t.tt) would have doubled them, so nvn_mtl.m is
main's, as is docs/reference/nvn-objects.md (main documents the reuse in memory.md, "Pool tables and
readback buffers made anew each frame"; the symptom index is regenerated).

What stays: t_pool_table, now on main's names (g_pool_made, g_pool_reused, tt1), which main has no
test for: a spare is not written again before its submission completes and is after, a 3D ID in a 2D
table is passed over without its view being made again, a new registration forgets the type, and a
walk over 20,000 2D IDs with dropped views passes over them in under a quarter of the time of looking
them up; and the clang-tidy fix to t_int_float_view. t_pool_table 20 checks, t_cpu_tex 59,
t_pool_chunks 102, t_int_float_view ok, test_docs OK, lint_c.py tidy on both tests 0 findings
(baseline 240, none above or below). t_feedback fails two of its 161 checks ("the fixture's shaders
compile") with main's code unchanged here: its new cases had not run when 6121fd7 landed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@seedds

seedds commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Thank you. Both ideas, reusing replaced pool tables and passing over IDs of another type, are on main in 6121fd7 and credited in docs/reference/memory.md. Tables made fell to 11 in 11,700 frames here. Long frames didn't change from this alone, which matches your measurement. 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
@jonpage0

jonpage0 commented Nov 6, 2026

Copy link
Copy Markdown

For the record, measured on an M3 Max (36 GB, macOS 27.0.1, Xcode 27.2) with Ocarina of Time v131072: this change adapted on top of #1430 and the 2026-11-05 main doubled the long frames in a 60 s window standing at the Kokiri balcony autosave (DM_CTL-driven, same save, no sampler): 466 against 235 [longframe] lines, 36 against 9 frames over 200 ms, submit-thread CPU p95 70.6 ms against 12.0 ms, 30.9 fps against 42.5. All native tests passed on it (t_pool_table, t_pool_retire, t_feedback, the gfx matrix with and without DM_NVN_ENC_THREAD=1), so it is the run-time cost, not a correctness problem, that showed. The walk that was actually hurting on this machine turned out to be alias_take_over's serial-table walk (#1439): with that change the same route went from 85 frames over 100 ms to 2.

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
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