Skip to content

nvn/owner: a ghost entry a registered texture ID names is in use, so pool tables stop being walked whole every 16 frames - #2585

Closed
UserDerezzed wants to merge 1 commit into
seedds:mainfrom
UserDerezzed:nvn-ghost-registered
Closed

UserDerezzed wants to merge 1 commit into
seedds:mainfrom
UserDerezzed:nvn-ghost-registered

Conversation

@UserDerezzed

Copy link
Copy Markdown

What and why

In Ocarina of Time's Kokiri Forest at a 120 fps target, a frame hitches every 16 frames exactly (by frame count, not by time) while the GPU stays at its usual ~13 ms. On those frames the replay thread's submit takes 25-41 ms: 294 M instructions against 53 M for the neighbouring frames, or 88,000 instructions a draw against 23,000 on frames with as many draws. A Time Profiler trace, cut into the replay thread's bursts, puts 75 % of those frames' replay time in full pool table walks (encode_args_ → pool_gpu → pool_entry over all 33,024 registered IDs), against 13 % elsewhere. The log counts tables built because ... storage and 102,340 patches refused as too many.

Cause. A texture ID the game still registers for a texture it has finalized (a retired registration) resolves through g_ghost when a walk takes it. With more than 2,048 registered IDs its view is not a transient one taken again each submission (pool_snap.trans), so the pool table holds the view and no lookup comes again until the table is rebuilt. Since 034e91a the ghost table judges use by lookups and puts alone, so every such entry (about 13,000 in Kokiri Forest) goes idle GHOST_IDLE frames after the walk that resolved it, all in the same frame. Past NGHOST the commits evict them, and each eviction writes a note. The notes run past what a patch takes (SER_LOG, POOL_IDS_MAX), so the next table is walked whole, which puts them all back in one frame. That's a loop with the idle span as its period, at about 750 puts a frame.

Change. The owner counts, per serial, the registered texture IDs that name it (own_reg_name):

  • The registration names the serial when it links the ID under it.
  • treg_unlink takes the name back, and so does treg_gone (the pool's Finalize).
  • A registration made again over a gone one names the serial again.

ghost_trim stamps an idle entry an ID names as used again, in its turn of the budget, instead of evicting it. Once no ID names it, it goes as before. A gone ID (its storage finalized) does not keep it, so moved render targets are still let go. The memory line now reports the entries kept and the serials named. NGHOST, GHOST_IDLE and the 034e91a design are unchanged; this fixes how use is measured for entries that only a pool table reads.

Related: issue #2213 and PR #2389 (where the table kept by use came from), and PR #2312, which raises the table to 32,768. With DM_NVN_GHOST_N=32768 the beat also goes, but the table fills to 32,768 (resident 15.2 GB against 14.4 GB with this change).

A note for PR #2312 if both land: its ghost_purge scans the ghost table's whole capacity for each shared texture let go. With a large table (32,768 with #2312's cap, or ~14,000 kept here), I saw two isolated 51-92 ms replay frames in a few minutes, in the windows with the most purges (+4,000 or more). With 512 entries they don't show. An index from object to serial (g_ghost_held already counts by object) would make it cheap. On main there is no ghost_purge, so this doesn't apply here.

Reference entry: Finalized textures a pool table holds went idle together; the game note links it.

Checked

  • Tests:

    • t_serial_owner: 184 checks. The new case fails 3 checks without the change: 2,048 named entries stay through four idle spans with no eviction, note or put; they drain once no longer named; one with a second name stays; with no names left the table is back at 512.
    • make -C dm/host/tests GAME=0400486024DEC000 run: 143 / 143.
    • make -k -C dm/gfx/tests GAME=0400486024DEC000 run (Metal), on this branch rebased on 41acde7: every test passes. In earlier runs, made while the game was running on the same GPU, t_feedback's "with nothing running: a commit" check failed now and then. It does the same on main 41acde7 without this change (1 of 2 runs), so it looks timing-dependent, not caused by this change.
    • lint_c.py tidy on the changed files: none above the baseline.
    • python3 -m unittest dm/tools/tests/test_docs.py.
  • Games run, with title ID and version: The Legend of Zelda: Ocarina of Time 0400486024DEC000 v131072, walking around Kokiri Forest at a 120 fps target, per-frame TSV (DM_NVN_TSV) and the memory lines compared across runs on the same build and route. The host was this change applied to my oot-stack branch (main 2e6edda plus PRs, e016fd04); the changed functions are the same as on main except that the stack also carries PR Render targets a game moved leave GPU memory: ghost serials go with their sharing entry, and an idle sweep (Ocarina of Time, Castle Town) #2312's port, whose conflicts were the stats struct and the memory line only.

    before DM_NVN_GHOST_N=32768 this change
    run length 92 s 127 s 219 s
    long frames exactly 16 frames apart 266 none none
    frames ≥ 25 ms 4.4 % 0.7 % 0.4 %
    frame p95 / p99 24.5 / 28.9 ms 18.6 / 22.6 ms 18.3 / 22.7 ms
    fps (GPU-bound) 66.7 66.9 69.0
    ghost puts a frame ~750 ~40 ~42
    patches refused as too many 102,340 0 0
    ghost table ~13,000 32,768 ~14,000
    resident, mean 14.8 GB 15.2 GB 14.4 GB
  • Devices and OS: MacBook Pro M3 Max (36 GB), macOS 27.2.1 (26B131), on a 100 W adapter.

  • scripts/regress.py check: not run. Ocarina of Time is the only game I have locally, and the harness isn't set up on this Mac. regress.py impact lists Breath of the Wild, Pokémon Scarlet and Tears of the Kingdom as affected (texture pool registration and finalize) and Hades through a broad rule. The change can only keep an idle ghost entry longer while a registered ID still names it; it never evicts more, writes no extra notes, and changes nothing for a game with no more than NGHOST finalized textures in use.

  • Measurements before and after: above. Not checked: iOS, long sessions or area transitions beyond Kokiri Forest and its surroundings.

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

…pool tables stop being walked whole every 16 frames

In Ocarina of Time's Kokiri Forest at a 120 fps target on an M3 Max, a frame hitched every 16 frames exactly (by frame count, not time), with the GPU at its usual 13 ms: the replay thread's submit took 25-41 ms on that frame, 294 M instructions against 53 M for its neighbours (88,000 a draw against 23,000 on frames as heavy). A Time Profiler trace put 75 % of those frames' replay time in full pool table walks (encode_args_ -> pool_gpu -> pool_entry over all 33,024 registered IDs), against 13 % elsewhere; the log counted tables built because a storage note came and 102,340 patches refused as too many.

The cause is the ghost table's idea of use. A texture ID the game still registers for a texture it finalized (a retired registration) resolves through g_ghost when a walk takes it, and with more than 2,048 IDs its view is not a transient one taken again each submission, so the table holds it and no lookup comes again. Kept by lookups and puts alone (034e91a), every such entry, about 13,000 in Kokiri Forest, went idle GHOST_IDLE frames after the walk that resolved it, all in the same frame; past NGHOST the commits evicted them, each eviction a note, the notes ran past what a patch takes (SER_LOG, POOL_IDS_MAX), and the next table was walked whole, which put them all back in one frame. About 750 puts a frame.

The owner now counts, per serial, the registered texture IDs that name it (own_reg_name: the registration names it when it links the ID under the serial; treg_unlink and treg_gone, a pool's Finalize, take the name back; a registration made again over a gone one names it again). ghost_trim stamps an idle entry an ID names used again, in its turn of the budget, instead of evicting it; once no ID names it, it goes as before, and a gone ID does not keep it. The memory line reports the entries kept and the serials named.

Measured on the same build and route (oot-stack e016fd04 with this change, 219 s against 92 s before): long frames 16 frames apart 266 to none; frames of 25 ms or more 4.4 % to 0.4 %; p95 24.5 to 18.3 ms; 66.7 to 69.0 fps, the GPU being the limit; ghost puts about 750 to 42 a frame; patches refused 0; the table about 14,000 entries; resident memory 14.8 to 14.4 GB on average. Raising the cap instead (DM_NVN_GHOST_N=32768, as PR seedds#2312 proposes) also removes the beat but fills the table to 32,768 (15.2 GB).

Reference entry: Finalized textures a pool table holds went idle together (docs/reference/nvn-objects.md); the game note links it, symptom index regenerated.

Checked: t_serial_owner (184 checks; the new case fails 3 without the change), make -C dm/host/tests run (143), lint_c tidy on the changed files (none above the baseline), test_docs.
@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 13, 2026
@seedds

seedds commented Nov 13, 2026

Copy link
Copy Markdown
Owner

Thanks for the careful diagnosis and the numbers. Before landing this, could you reproduce the 16-frame beat on plain main, with no other PRs on top?

Reading main, I don't think the trigger is there:

  • The PR says a retired ID's view "is not a transient one taken again each submission" with more than 2,048 registered IDs. That rule is PR nvn: preserve texture upgrades, BC render targets, and retired descriptor bounds #1731's (item 3: retired descriptor views kept across submissions when g_ntreg > 2048), which is open and not on main.
  • On main every view of a retired registration is transient: treg_finish_submission drops it at the end of the submission. The next submission finds the table stale and takes those IDs again (pool_snap.trans). Each one goes pool_entry → tex_fast_rid → tex_by_id → tex_mtl_ → tex_get → own_resolve_ref, which stamps the ghost entry (ghost_used). Past POOL_IDS_MAX (8,192) the table is built whole, which stamps them all too.
  • So on main the entries a pool table reads are used every submission and should not go idle together. The new branch in ghost_trim would never be taken.

What would help:

  1. Your "before" run (Kokiri Forest, 120 fps target, same route) on origin/main alone: are the long frames 16 frames apart there, and what do the memory line's ghost count and puts, and the pool line's because ... storage builds and too many refusals, show?
  2. If the beat is there on main, a DM_NVN_TREG_ID trace or a short note of the path by which a retired ID's entry goes unused for 16 frames on main. I'd like to see what I missed.
  3. If it's only on your stack: does your stack carry nvn: preserve texture upgrades, BC render targets, and retired descriptor bounds #1731's retired-view keep? If so, the two belong together. The ID counting here is the right way to say "in use" once views stay in tables across submissions, but it should land with that change (and ideally replace the per-submission drop-and-retake outright rather than gate it at 2,048 IDs).

A few smaller points for whichever form lands:

  • Every changed registration now takes g_smu twice more (treg_unlink's removal and mtl_reg_tex's add), on top of own_tex_live's. KTGL games (Age of Calamity) change about 17,500 IDs a frame. Folding the count into the lock mtl_reg_tex already takes would avoid most of it.
  • Named idle entries are re-stamped through the commit's trim budget, which delays the eviction of unnamed idle entries behind them. Keeping named entries out of the idle list while they are named would be simpler and cost no budget.
  • The memory line's "idle ones kept for a registered ID" counts re-stamps since startup, not entries.

@UserDerezzed

Copy link
Copy Markdown
Author

Sorry about that: I measured only on my stack and blamed main without checking that the trigger was there. Confirmed by reading main (f63ecbd): it isn't. The g_ntreg <= 2048 gate on transient retired views is 90b8a99, the single commit of #1731, and my stack carries it. On main, tex_mtl_ queues every retired registration's view for treg_finish_submission, so the next submission takes those IDs again and own_resolve_ref stamps their ghost entries. Nothing a table reads goes idle, and the new branch in ghost_trim wouldn't be taken. The PR's cause section is wrong for main.

So the beat needs both pieces: #1731's views kept across submissions, and 034e91a's idle eviction by lookups and puts. On my stack before 034e91a the ghost table's cap was 32,768, so it only showed after the resync took main's 512.

I'll close this one. The counting belongs with #1731, reworked along your three points:

  • Count under the lock mtl_reg_tex already takes, not with two more g_smu round trips per changed ID.
  • Take a named entry off the idle list while any ID names it, and put it back when the last name goes, so it costs no trim budget.
  • Report the named entries, not re-stamps since startup.

Replacing the per-submission drop-and-retake outright, rather than gating it at 2,048 IDs, makes sense to me once the count exists. Since that's #1731's design, I can offer it there as a follow-up commit if its author wants it.

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