Skip to content

WIP: DM_NVN_TEX_DEDUPE gives back the guest copy of uploaded textures (Ocarina of Time: about 1.9 GB less footprint) - #1386

Draft
david-on-spring wants to merge 7 commits into
seedds:mainfrom
david-on-spring:tex-dedupe-guest-pages
Draft

david-on-spring wants to merge 7 commits into
seedds:mainfrom
david-on-spring:tex-dedupe-guest-pages

Conversation

@david-on-spring

@david-on-spring david-on-spring commented Nov 5, 2026 •

Copy link
Copy Markdown

Draft, work in progress. Asked for in #1347. Refs #1281.

Stacked: this branch sits on my local Ocarina of Time integration, because the prototype was built and tested there. Review only the top commit, 562c1f4 (7 files, about +500 lines).

The commit below it, f3ac5ea, is #1299 + #1312 plus these other open PRs merged locally: #1300, #1315, #1319, #1320, #1329, #1331, #1340, #1343, #1345, #1353, #1357, #1359 and #1368. It also has my own fixes from #1347. Until those land, the diff against main shows all of them.

What and why

A texture the CPU wrote is held twice:

  • swizzled in its guest memory pool;
  • deswizzled in its private Metal texture.

The GPU only ever reads the Metal one. With DM_NVN_TEX_DEDUPE=1 (off by default), the host gives the guest copy back.

Release. Once an uploaded texture has gone DM_NVN_TEX_DEDUPE_AGE_MS (default 2000) without a write, its host pages that the dirty tracker still has write-protected and unwritten are:

  1. written to an unlinked file (DM_NVN_TEX_DEDUPE_DIR, else TMPDIR);
  2. given back with MADV_FREE_REUSABLE, which takes them out of phys_footprint at once;
  3. made PROT_NONE.

Restore. Any access by the game or the host faults and gets the same bytes back first (nvn_dirty.c). So do these:

  • a buffer over them going into the residency set (pool_res, gmem_map);
  • a large pool's chunk buffer being made (big_new), because Metal refuses a zero-copy buffer over PROT_NONE pages;
  • an upload reading them.

Scope. Only pages of pools outside the residency set are released. Uploads read through a transient buffer, so they don't wire pools. With DM_GUEST_FILE, the switch turns itself off.

DM_NVN_TEX_BREAKDOWN=1 logs host texture memory by kind, with the guest storage under each.

Measured

Ocarina of Time (0400486024DEC000 v131072) on a 16 GB M4, from Link's house out into Kokiri Forest. Same host binary, switch off (A) and on (B), six runs of 8 minutes alternated A B A B A B:

A1 A2 A3 B1 B2 B3
Footprint in the house (GB) 11.23 11.28 11.26 9.50 9.44 9.49
Footprint in the forest (GB) 11.53 12.18 11.95 – ¹ 10.36 9.90
Peak footprint (GB) 13.01 13.14 13.30 11.08 11.39 11.36
Compressor in the house (GB) 5.68 5.77 5.79 4.01 3.94 3.51
Graphics ledger in the house / forest (GB) 4.37 / 4.62 4.42 / 5.12 4.44 / 4.92 4.47 / – 4.37 / 5.10 4.39 / 4.76
Frame time p50 / p95, house (ms) 27.6 / 32.5 35.3 / 90.6 ² 40.4 / 97.4 ² 38.2 / 97.8 ² 39.0 / 104.6 ² 27.3 / 31.4
Frame time p50 / p95, outdoors (ms) 40.4 / 44.1 43.2 / 73.4 ² 43.1 / 66.2 ² – ¹ 42.8 / 68.0 ² 39.8 / 43.3
Given back – – – 4,673 textures, 1,908 MB 4,666 textures, 1,906 MB 4,664 textures, 1,905 MB

House and forest values are memstat medians (20 s before leaving the house; the last 5 minutes outdoors).

¹ B1 missed the front door under load and stayed in the house.
² Run under heavy CPU load from parallel shader builds, so compare A1 with B3 (quiet) and A2/A3 with B2.

  • The switch takes about 1.8 GB off the footprint in the house (11.3 to 9.5 GB) and outdoors (11.9 to 10.1 GB), and 1.9 GB off the peak (13.2 to 11.3 GB). That is the compressor shrinking: the guest copies had been sitting compressed.
  • GPU memory doesn't change: in this game it's mostly render targets.
  • Frame time doesn't change. On a quiet machine it was 27.6 vs 27.3 ms in the house and 40.4 vs 39.8 ms outdoors (p50).
  • No zero-copy buffer refusals, GPU out-of-memory or FATAL in any run.
  • Screenshots with and without the switch look the same.

Tests

  • New t_dirty_dedupe: release, restore on read, on write and on nvn_dirty_restore. It also covers a second thread faulting on a page being restored; that race was found and fixed.
  • t_dirty_race, t_dirty_retire_race, t_dirty_slots and t_nvn pass, t_nvn also with the switch on.

Open

  • A write that lands in the microseconds a page is writable during its release is lost if the kernel takes the page then.
  • A host system call that reads a released page gets EFAULT instead of the bytes. Nothing I know of does that.
  • The swap file grows to the bytes released, about 1.9 GB here.

Drafted with Claude; reviewed by me.

🤖 Generated with Claude Code

nanotchec and others added 7 commits November 5, 2026 08:48
…een: eventfds served offline, no virtual audio inputs, the movie library's NDVE shader pack

- hle_net.c serves nn::socket eventfds (a local counter, no network): gRPC in Nintendo's online library needs one for its poller and asserted at boot when every socket call failed. Poll / Select / Fcntl / Close handle them; errors set the socket code and the guest's errno. t_net_eventfd checks it.
- hle_audio.c answers ListVirtualInputDevices with none: SDK 22's client connected to auddmg:u itself and aborted.
- shader_split.py reads Movie/Ndve/*.dat (NDVE): raw NVN2 programs and the ones its NDSR record table names, the decoder's colour conversion among them; the prefilter lets the pack through. test_ndve checks it.
- build_ipa_from_nsp.py surveys a dump folder with no profile from its own ExeFS and RomFS (a NUP still needs a profile).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ditional clears (render_cond), NVN2 task and mesh shaders in BNSH slots 7 and 8, the title movie at 60 fps, and the first 3D scene's residency

- Profile 0400486024DEC000 with its note and games-table row. render_cond on: agl clears the copy of the prepass depth again under a condition the console skips, and run unconditionally that clear left the main opaque pass's EQUAL test failing everywhere, so the scene came out white behind the full-screen sky.
- shader_split.py reads NVN2 BNSH program slots 7 (task) and 8 (mesh). The six NVN slots alone dropped every mesh-shaded model (the door of Link's house among them); OoT's map gains 491 mesh and 17 task shaders. test_bnsh_nvn2.py.
- Raw global memory: the write log is a bitmap by memory pool entry, and the pool lookup takes a per-chunk hint, so the movie library's full-frame NDVE shaders no longer overflow the log and stall the GPU (title movie 47.6 ms to 16.67 ms median on an Apple M5). The chain moves to d1f10abd26a7 (with Star Fox's change from main).
- A bindless pool walk no longer re-creates finalized textures within a submission (the first 3D scene ran out of Metal memory); an asynchronous play report send completes offline.
- The pass dump names a bound compute program's shader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ptors on shared textures out of the ghost ring: Ocarina of Time's Kokiri Forest from a 46-49 ms frame to 16-21 ms

- pool_gpu built the whole table of a handle-indexed texture pool (33,025 IDs in Ocarina of Time) whenever a registration or a storage change had happened, and the game's threads register descriptors while the replay draws: about 94,000 builds, 55 s of a 90 s run. Registrations (reg_bump) and noted storage changes (ser_note) are now logged by generation, and a table takes again only the IDs they name: kept when nothing changes, else copied and patched, so draws already encoded read their own. Other bumps, sparse textures, a resized pool or a ring overrun still build it whole.
- About 12,000 of its IDs hold descriptors of finalized texture objects, each at the storage of a live texture. They went through the 512-entry ghost ring, about 10,000 puts and twice that in storage notes a frame, and their views were made again in every submission. A finalized serial over a texture the sharing table holds now resolves to it with no ghost entry, and a retired ID keeps its view of it across submissions until the texture leaves the table (alias_sweep).
- The pool line counts builds by cause and tables patched or kept; the pass dump names a bound compute shader. t_upgrade_alias checks the finalized serial's resolution rather than its ghost entry.

Submit went from 45 ms to 5-8 ms a frame; the GPU (14-21 ms) is now the limit. Resident memory holds at 13-14.3 GB over 160 s. Host and graphics suites pass; regress.py was not run (no prepared Mac builds on this machine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ory or aborting

Ocarina of Time (0400486024DEC000, v131072) now plays in Kokiri Forest on a 16 GB M4.
A 25-minute run at profile defaults, walking from Link's house into the village, had
no GPU out-of-memory error, hang or abort, at about 31 ms a frame.

Fixes:
- Pool tables give a retired texture ID without a view the dummy texture
  (profile key pool_skip_retired, on for this game; DM_NVN_POOL_SKIP_RETIRED overrides).
  Rebuilding the table remade 2.5 GB of finalized textures at a scene change,
  taking GPU allocation past Metal's working set.
- The alias region is 8 GB and the reserve grows to match. The game's streaming
  allocator spreads about 5,100 regions up to +4.95 GB, and with 2.38 GB
  AllocateAddressRegion failed, an nn::diag abort 5-15 minutes outdoors.
- The raw global memory table has 8,191 entries, since 4,095 hung the GPU.
  It leaves out pools without a buffer address (DM_NVN_GMEM_ALL lists them).
  The texture storage check no longer wires whole pools. Texture-only uploads use
  transient buffers. Unreferenced shared textures are swept (DM_NVN_ALIAS_SWEEP).
- nvnCommandBufferSetComputeWorkloadPriority stub. PR 1299's duplicate audio hook
  is removed. DM_MOVIE_SKIP skips movies.
- The GPU out-of-memory report and DM_CTL restop list resident textures by holder.

Includes the open upstream PRs 1300, 1319, 1320, 1329, 1331, 1340, 1343, 1315,
1345, 1353, 1357, 1359 and 1368, merged locally. 1360 is not included: it builds
on main's page-hash write log, and this base's entry-bitmap log is sized to the
fixed table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A texture the CPU wrote is held twice: swizzled in its guest memory pool, and
deswizzled in its private Metal texture. The GPU only ever reads the second.

With DM_NVN_TEX_DEDUPE=1 (off by default), the host gives the first copy back.
Once such a texture has gone DM_NVN_TEX_DEDUPE_AGE_MS (default 2000) without a
write, its write-protected, unwritten host pages are released:
- they are written to an unlinked file (DM_NVN_TEX_DEDUPE_DIR, else TMPDIR);
- they are given back with MADV_FREE_REUSABLE;
- they are made PROT_NONE.

Any later access by the game or the host puts the same bytes back first,
through the dirty-page tracker (nvn_dirty.c). So does a buffer over them going
into the residency set (pool_res, gmem_map), a large pool's chunk buffer being
made (big_new: Metal refuses a zero-copy buffer over PROT_NONE pages), or an
upload reading them. Only pages of pools outside the residency set are
released, and uploads read through a transient buffer.

DM_NVN_TEX_BREAKDOWN=1 logs host texture memory by kind, with the guest storage
under each. A new test, t_dirty_dedupe, covers release and restore, including a
second thread faulting on a page being restored.

Ocarina of Time on a 16 GB M4, from Link's house out into Kokiri Forest:
- about 1.9 GB of guest pages released, from 4,669 textures;
- peak footprint 11.5 GB (about 2 GB less in the house in earlier A/B runs);
- the picture is unchanged, and GPU memory is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added host The Horizon host: SVCs, services, threads, memory (dm/host) nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) packing Building an IPA or a Mac app: the NSP script, extraction, the shader split, the packers ios The iPhone and iPad app (dm/ios) tools Tools and scripts (dm/tools, scripts) docs Documentation shader chain The shader translation chain (DMSH_CHAIN): a change rebuilds every game's shaders game: 0400486024DEC000 A game new game Adds a game profile that main does not have yet game: 0400C3F00006E000 A game profile labels Nov 5, 2026
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 5, 2026
From upstream f3ac5ea (the Ocarina integration that seedds/nxnative draft PR
seedds#1386 sits on): the game's streaming allocator keeps about 5,100 address regions
in the alias region, spread up to +4.95 GB, and with the profile's 2.38 GB
VammManager::AllocateAddressRegion failed after 5-15 minutes outdoors (nn::diag
abort). mem_alias goes to 8 GB and mem_reserve grows by the same 5.6 GB so the
heap keeps its size. Profile values only; the rest of that commit (pool tables
skipping retired IDs, a 8,191-entry raw global table instead of seedds#1360's growing
one, translator changes) waits for its own upstream pull requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qteK29T18U8p1c9LsVY8g
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 5, 2026
… one NXForge has (seedds#1386 on f3ac5ea)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qteK29T18U8p1c9LsVY8g
@bettterlabs

Copy link
Copy Markdown
Contributor

A second 16 GB M4 on this branch (Mac mini, macOS 27.2, 562c1f4, chain 84aede056dea), docked, with the system-side numbers.

Four runs in the order off, on, on, off (DM_NVN_TEX_DEDUPE=1). The same save, in Kokiri Forest, was put back before each run. I didn't touch the controller after the load, and each run stopped 5 minutes after it. vm_stat was logged every second. The Mac started each run in about the same state: 79–81% free, 624–706 MB of swap used, and 0.9–1.3 GB in the compressor.

Over the 5 minutes after the load:

off (1) on (2) on (3) off (4)
footprint, median 9.54 GB 8.33 GB 8.30 GB 9.60 GB
compressor holds at the end 6.68 GB 5.14 GB 5.25 GB 6.42 GB
compressed during the 5 minutes 4.95 GB 3.42 GB 4.11 GB 6.96 GB
Mac free (memorystatus_level), median 22% 29% 29% 23%
swapped out 162 MB 0 19 MB 0
frame time p50 25.7 ms 25.3 ms 25.7 ms 25.6 ms

Both runs with the switch ended almost identically: 2,859 and 2,860 textures, 1,416 MB given back in each, pages put back 797, read back from the file 691, kept with a racing write 0, failed 0.

In this scene swap hardly moved in any run, so this says nothing about swap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 0400C3F00006E000 A game profile game: 0400486024DEC000 A game host The Horizon host: SVCs, services, threads, memory (dm/host) ios The iPhone and iPad app (dm/ios) new game Adds a game profile that main does not have yet nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) packing Building an IPA or a Mac app: the NSP script, extraction, the shader split, the packers shader chain The shader translation chain (DMSH_CHAIN): a change rebuilds every game's shaders tools Tools and scripts (dm/tools, scripts)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants