Skip to content

A GPU copy into a texture taken from its storage no longer uploads all of it again: Star Fox's 7-10 s stalls at scene changes - #1310

Closed
lunks wants to merge 1 commit into
seedds:mainfrom
lunks:pr/copy-absorb
Closed

lunks wants to merge 1 commit into
seedds:mainfrom
lunks:pr/copy-absorb

Conversation

@lunks

@lunks lunks commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Ready for review, not ready to merge: it changes the upload path of every game whose textures are uploaded from their storage, and the numbers below come from a build of our fork. If you have these games, any of this helps; paste the result here.

Missing:

  • regress.py check, on any game you have built.
  • Star Fox on main: the prologue's scene changes before and after, as longframe lines and the re-upload count. DM_NVN_COPY_ABSORB=0 turns the change off, for a comparison on one build.
  • A game that writes a copied texture from the CPU as well: that path keeps its re-upload, and only the tests cover it.

What and why

Who needs it: Star Fox players. At each scene change of its prologue the game stalled for seconds (7.6 s and 10.0 s frames in a player's log, 280 re-uploads and 12 GB in one frame), and the picture, sound and controller drifted behind. Its note lists no stall yet. Streaming games that fill large storage-backed textures with GPU copies may show the same; none other seen.

Cause: Star Fox's streamer fills BC7 2D arrays (512x512x128 with 10 levels, 43 MB; 1024x1024x64 with 6, 85 MB) with nvnCommandBufferCopyBufferToTexture, a level of a layer per copy. Such a texture is uploaded from its storage, so the host writes each copy's bytes into the storage too (nvn_tex_mirror_b2t), and that write marked the pages like a CPU write. The next copy's lookup of its destination then uploaded the whole array again, though the copy encoded on the GPU had already put the same bytes into the host texture. DM_NVN_REUPLOADS=1 (new, a diagnostic) showed each re-upload following one copy that had written 1-17 of the array's 2,752 16 KB pages.

After a mirrored copy is encoded (copy_b2t, copy_t2t), pkg_copy_absorb stamps and protects the storage again and keeps the host texture when no page inside it was written since its upload except those of the copied levels (nvn_dirty_since_except) and its own bytes in shared edge pages are unchanged. Any other write still uploads it at its next use. DM_NVN_COPY_ABSORB=0 turns it off. Reference entry: A GPU copy into a texture uploaded from its storage uploaded all of it again.

Related: #1238 changes copy_b2t and the dirty-upload tracking too (it marks GPU ownership with tex_gpu_write). It has nothing like the absorb, but the two touch neighbouring lines and t_readback.m, so whichever merges second needs a rebase.

Checked

  • Tests (M5 Pro, macOS 27.0, over main 056e41f): make -C dm/gfx/tests GAME=040074A01BF12000 run and make -C dm/host/tests GAME=040074A01BF12000 run pass, including t_readback (copy_stream: nine buffer-to-texture copies into a packaged array upload it once instead of 10 times, every byte of the host texture and the storage checked) and t_dirty_slots (the new tracker query). test_docs.py passes.
  • Measured before and after, Star Fox (0400E88025B1A000, v131072) on a build of our fork (not main), headless on an M5 Pro, 240 s of the same route, switch off / on: 986 / 0 re-uploads (986 copies absorbed); frames over 1 s 5 / 1; worst frame 7.6 s / 1.6 s (the rest is first uploads of 117 new textures, 400 MB).
  • Not measured: main's build, play with this change, any other game. Donkey Kong Bananza never mirrors a copy (none in any of its logs, 0 re-uploads), so it should not reach this path.
  • regress.py check: not run.

Docs

  • The finding is in docs/reference/nvn-objects.md and Star Fox's note, and python3 dm/tools/docs_symptoms.py was run (the symptom index); DM_NVN_COPY_ABSORB and DM_NVN_REUPLOADS are in docs/env_switches.tsv
  • A chain tool changed: not applicable
  • No game files, keys or data extracted from a game in the change or the attachments

@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation game: 0400E88025B1A000 A game profile labels Nov 5, 2026
@lunks
lunks marked this pull request as ready for review November 5, 2026 16:12
WizardOfCodez pushed a commit to WizardOfCodez/nxnative that referenced this pull request Nov 5, 2026
…al camera gets an off-screen native window so the avatar settings no longer crash

- The rewriter's frame-step stub at main+0xb4498ac (build a7730d0f…) replaces the renderer's
  scene scale with DM_RESOLUTION_SCALE when it is set; the launcher offers it as "Resolution"
  (Game's own / 75% / 100%) for this build only. patch_nso.py gives frame-step code
  ctx.area(offset), a patch area in reach of the site, since main's text is 189 MB.
- nn::chat::GetVirtualCameraNativeWindowHandle returns an off-screen native window instead
  of 0: the game makes an NVN window of it; such a window acquires at once and presents
  nowhere.
- Probes: DM_PROBE_FP=1 logs s0-s15, and DM_PROBE_WATCH / DM_PROBE_POLL take as+<offset>.
- The game note records the picture's cause chain (with seedds#1304 and seedds#1305), the measurements,
  and the Venom 2 out-of-memory that seedds#1310 removes.
fpedrolucas95 pushed a commit to fpedrolucas95/nxnative that referenced this pull request Nov 6, 2026
…from its storage no longer uploads all of it again: Star Fox's 7-10 s stalls at scene changes

# Conflicts:
#	docs/env_switches.tsv
@dpadr

dpadr commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Star Fox (0400E88025B1A000, v131072) on a 16 GB MacBook Air M2 (10-core GPU), macOS 27.0. Each run plays the prologue cinematic through without skipping. The A/B is DM_NVN_COPY_ABSORB on and off on one build.

Build: 7bccfc88 with local uncommitted changes (shader chain e675a6edbfc5), plus this PR, #1319 and #1393, all cherry-picked. So it is not plain main. #1319's DM_NVN_RES_LIMIT_MB=11800 was set as a stop before the Mac's working set, and DM_NVN_POOL_SKIP_RETIRED=1 (#1393) in every run with it.

Run Prologue GPU allocated, peak Residency set, peak
Before (no #1310, #1319 or #1393) out of GPU memory at prologue_part3_stage 16.3 GB 12.5 GB
#1310 on, DM_NVN_TEX_MIP_SKIP=1 parts 2-5, the tutorial outro and the menu 3.4 GB 2.0 GB
#1310 on, DM_NVN_TEX_MIP_SKIP=0 (full textures) parts 2-5, the tutorial outro and the menu 4.3 GB 3.0 GB
#1310 off (DM_NVN_COPY_ABSORB=0), mip skip 0 out of GPU memory at prologue_part3_stage 13.5 GB 12.4 GB, textures 11.0 GB

With DM_NVN_COPY_ABSORB=0, the longframe lines before the stop show:

  • frames re-uploading 20-41 textures each, 0.9-2.3 GB per frame, the longest 848 ms;
  • 366 upload staging blocks made, 8,320 MB of them swapped out;
  • then kIOGPUCommandBufferCallbackErrorOutOfMemory on the upload command buffers, and the residency limit stopped the game.

With this PR on, nothing like it: the cinematic held a 33.4 ms median frame, with load hitches up to about 0.5 s.

So on a 16 GB Mac this change is what lets Star Fox's prologue fit at all, not only what removes its stalls. Mip skipping saves only about 1 GB more here, and Star Fox does not need it.

Not checked: plain main, regress.py check, and whether #1393 is needed beside this.

@lunks

lunks commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the A/B. Your result is the clearest one for this PR: on a 16 GB Mac the prologue fits only with DM_NVN_COPY_ABSORB on. I'll rebase it onto main (it conflicts in nvn_mtl.m now) and re-run its tests.

…l of it again

Star Fox stalled for seconds at every scene change of its prologue (7.6 s and
10.0 s frames in the player's log), with 280 re-uploads and 12 GB in one frame.
Its streamer fills BC7 2D arrays (512x512x128 with 10 levels, 43 MB;
1024x1024x64 with 6, 85 MB) with nvnCommandBufferCopyBufferToTexture, a level
of a layer per copy. Such a texture is uploaded from its storage, so the host
writes each copy's bytes into the storage as well (nvn_tex_mirror_b2t), and
that write marked the pages like a CPU write. The next copy's lookup of its
destination then uploaded the whole array again, though the copy encoded on the
GPU had already put the same bytes into the host texture. A new diagnostic,
DM_NVN_REUPLOADS=1, showed each re-upload following one copy that had written
1-17 of the array's 2,752 16 KB pages.

After a mirrored copy is encoded (copy_b2t, copy_t2t), pkg_copy_absorb stamps
and protects the storage again and keeps the host texture when no page inside
it was written since its upload except those of the copied levels (the new
nvn_dirty_since_except) and its own bytes in shared edge pages are unchanged.
Any other write still uploads it at its next use. DM_NVN_COPY_ABSORB=0 turns
it off; the packaged textures line counts the absorbed copies.

Measured on a build of our fork (not main), headless on an M5 Pro, 240 s of the
same route, switch off / on: 986 / 0 re-uploads (986 copies absorbed), frames
over 1 s 5 / 1, worst frame 7.6 s / 1.6 s (the rest is first uploads of new
textures). t_readback's copy_stream case uploads its array once for 9 copies
instead of 10 times and checks every byte of the host texture and the storage;
t_dirty_slots covers the new tracker query. Bananza never mirrors a copy (none
in any of its logs, 0 re-uploads), so it does not reach this path. On this
commit over main: the gfx and host tests pass for 040074A01BF12000; no regress
run.
seedds added a commit that referenced this pull request Nov 10, 2026
… all of it again: Star Fox's 7-10 s frames at its prologue's scene changes, and its running out of GPU memory on a 16 GB Mac (PR #1310)

A copy into such a texture is written into its storage as well (nvn_tex_mirror_b2t / _t2t),
and that write marked the pages as a CPU write does. The next lookup of the texture, the
next copy's own, uploaded all of it again, though the copy encoded on the GPU had already
put the same bytes into the host texture. Star Fox's streamer fills BC7 2D arrays (43 MB,
85 MB) a level of a layer per nvnCommandBufferCopyBufferToTexture: 280 re-uploads, 12 GB,
in one frame of a player's log (10.0 s).

Once the copy is encoded, pkg_copy_absorb stamps and protects the storage again and keeps
the host texture when no page inside it was written since its upload but those the mirror
wrote (nvn_dirty_since_except) and its own bytes in shared edge pages are unchanged. Any
other write still uploads it at its next use. The mirror reports the ranges it wrote
(mspan_t), so a 3D texture's slice copies (GitHub #1856) are excepted as its whole level;
ranges recomputed as layers lay past a volume and none of its copies was absorbed.
DM_NVN_COPY_ABSORB=0 turns it off; DM_NVN_REUPLOADS=1 logs each re-upload with the pages
written since and the copies not absorbed.

Measured by the PR, on fork builds, not this commit: Star Fox, M5 Pro, 240 s, switch off /
on: 986 / 0 re-uploads, worst frame 7.6 / 1.6 s. dpadr, 16 GB MacBook Air M2: off, out of
GPU memory at prologue_part3_stage (13.5 GB allocated); on, the whole prologue at 4.3 GB.
Star Fox is not on this Mac: not measured on main.

Checked here: gfx and host tests (GAME=0400E88025B1A000); t_readback copy_stream (a 2D
array, 9 copies, one upload) and copy_stream_3d (8 slice copies into a volume, all
absorbed, host texture and storage checked byte for byte), t_dirty_slots. regress.py check
on Octopath Traveler II: PASS, 59.8 fps both, 0 changed MSL, 0 new RED, peak 4,698 -> 4,739
MB; re-uploads 1 -> 0, 4 copies absorbed. Minecraft Dungeons II not run (its shader
package needs splitting again).

Co-authored-by: Pedro Nascimento <91118+lunks@users.noreply.github.com>
@seedds

seedds commented Nov 10, 2026

Copy link
Copy Markdown
Owner

Landed on main as bbea4c9, rebased, with you as co-author. Thank you, and thanks to dpadr for the A/B on the 16 GB M2: it showed this is what lets the prologue fit there.

What changed from the PR:

  • 3D copies. Since this PR, main mirrors 3D copies as well (Buffer-to-texture copies into a 3D texture uploaded from its storage are not mirrored (LEGO Star Wars models lit at 65504) #1856, LEGO Star Wars' light-probe volumes, filled a slice at a time). The excepted ranges were computed as layer0 + zoffset + z layers, but a 3D texture's slices all lie in one level of layer 0, so every 3D copy would have failed the range check and kept re-uploading. Now nvn_tex_mirror_b2t / _t2t report the storage ranges they made writable (mspan_t, at most 64; a copy writing more is not absorbed), and pkg_copy_absorb excepts exactly those. nvn_tex_level_range stays, as the helper both use.
  • t_readback has a copy_stream_3d case: 8 slice copies into a packaged 32x32x8 volume, all absorbed, the host texture and the storage checked byte for byte, and a CPU write into level 1 afterwards still uploads it.
  • The rest is as you wrote it: nvn_dirty_since_except, the edge-page check, DM_NVN_COPY_ABSORB, DM_NVN_REUPLOADS, the packaged textures count and the docs (merged with main's 3D text).

Checked on this commit: gfx and host tests (GAME=0400E88025B1A000). regress.py check on Octopath Traveler II: PASS, 59.8 fps both, 0 changed MSL, 0 new red lines, peak 4,698 → 4,739 MB. It reaches this path: re-uploads 1 → 0, 4 copies absorbed.

Not checked: Star Fox on main (not on this Mac), so the numbers in the note are still from your fork builds. If you or dpadr can run the prologue on current main and paste the packaged textures line and the longframe lines, that would confirm it.

@seedds seedds closed this Nov 10, 2026
FeReuss pushed a commit to FeReuss/nxnative-oot that referenced this pull request Nov 10, 2026
Conflicts resolved by hand:
- seedds#2085, seedds#2078 and seedds#2065 came back from upstream as reworked commits (6f50de0, 12a44e0, 5e1c1ae): our three merges were reverted first, so the merge brings upstream's version instead of two copies.
- GPU copies into a texture (copy_b2t, mirror_rb_land, nvn_obj.h): upstream's seedds#1310 rework (mspan_t, pkg_copy_absorb, for the readback case) and our seedds#2077 (mirror_rb_own, gsb_mark, for a texture the GPU owns) cover different cases and both stay: own = !rb is taken before the synchronous mirror, so the two paths exclude each other. mirror_rb_own passes no span.
- loader.c: upstream's render-scale parse with the stub's largest scale (Hades II) and our graphics-scale tier block. t_readback.m: upstream's additive tests added to ours. pso_offline.py: its header comment. Docs and tables keep both sides; DM_RENDER_SCALE rows, render_base and the settings table take upstream's Hades II range plus our text.
- dmsh_chain.h regenerated; .github/workflows/tests.yml follows upstream (a test list).

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

seedds commented Nov 10, 2026

Copy link
Copy Markdown
Owner

One more game on main (bbea4c9): Minecraft Dungeons II (0100A7C01B792000, v131072), Unreal Engine 5, M4 Pro. Title screen, 100 s per run, DM_NVN_COPY_ABSORB off / on, with DM_NVN_REUPLOADS=1:

re-uploads copies absorbed red lines
off 9 0 0
on 3 12 0
  • Off: a 512x512x6 cube map (BC, one level) was uploaded again after each face's buffer copy, five times in frame 144, and a 512x512 10-level texture after each of its level copies. Each re-upload line named one copy behind it, as in Star Fox's log.
  • On: all of those were absorbed. The three re-uploads left each had no copy behind them (CPU writes into 1-3 of 11 pages), so they still upload, as they should.
  • The screenshots at 90 s match, except the animated campfire and flowers.

regress.py check against 5e1c1aec (main just before this change): PASS, 15,826 / 15,826 shaders, 0 changed MSL. That covers the shader chain only: the game has no smoke route, hence the manual runs above. Its uploads are small (0.2-1.5 MB), so frame times don't show a difference here. Star Fox on main is still the measurement that's missing.

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

Labels

docs Documentation game: 0400E88025B1A000 A game profile 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