Skip to content

Soft textures: a large block-compressed texture kept without its finest level, its level-0 pages back - #1599

Open
oliverames wants to merge 8 commits into
seedds:mainfrom
oliverames:pr-soft-textures
Open

oliverames wants to merge 8 commits into
seedds:mainfrom
oliverames:pr-soft-textures

Conversation

@oliverames

@oliverames oliverames commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

What and why

A large block-compressed texture a game loads from its files today becomes one full-size Metal texture and keeps the whole guest storage wired. The kartnative and plumber-racer-2 forks (MIT) solved this for Mario Kart World on the iPhone, and this carries their soft-texture family into the tree on its own anchors:

  • tex_soft (gated, default off) makes such a texture's host texture from its level 1 (half the size, one level less, sampled through the same normalized coordinates).
  • nvn_dirty_release gives back the guest pages only that level holds after a 300-frame grace (soft_sweep, run once a submission), unless the game copies from that texture (soft_harden re-uploads it whole first; kartnative's line records a copied-from texture that lost level 0 drawing purple).
  • The uploads of soft textures run on the CPU while the pool has no zero-copy buffer (mtl_pool_wrapped): iOS wires a buffer's whole pool, for a pool the GPU may never address (their measurement: wrapped pools 3.7 GB → 851 MB on an iPhone 18 Pro).
  • ReadTexels is answered from the texture's storage, so it gets the level the game names; a level-0 read of a released texture reads 0.

No game changes unless DM_NVN_TEX_SOFT=1 is set (and DM_NVN_TEX_RELEASE=1 for the release). There's no profile key for it, so everything here is opt-in.

GPU copies into a soft texture work as of 2026-11-10. A texture that a GPU copy writes into is made whole first, the same way one that's copied from already was, so the copy lands at the level the game names and reaches the texture's storage. That covers buffer-to-texture copies, texture-to-texture copies and copies through an uncompressed alias. Before, they landed one mip level off in the half-size host texture, and since main's ab41d6a17 a texture copy never reached storage, so the next upload dropped it.

One case can't be made whole: with DM_NVN_TEX_RELEASE=1, a texture whose finest level's storage has already gone back stays soft. A copy from or into its level 0 is skipped with one red line. A copy at another level goes to the level one lower in the host texture. A buffer copy into it still reaches storage; a texture copy into it doesn't, and the log says so.

A released level is gone from the game's own memory too: once the system has taken the pages they read as zeros and nothing puts the bytes back. The comment on nvn_dirty_release said a later write brought them back, which the code never did; the comment and the reference entry now say what happens.

Still open: a texture view that starts above level 0 isn't shifted, so it shows one level coarser. The GPU direct upload (nvn_deswz_gpu_direct_m4) can't take the level shift, so it is now refused for a soft texture (!sk in the selection, f72cc280); no texture qualifies for both today (the direct path takes RGBA16F arrays of at most 64x64, a soft texture is block-compressed and at least 512x256), so this guards the pairing rather than fixing a live case. t_soft_texture checks it with a soft texture in a wrapped pool and the direct upload on: no direct upload, each host level holds the game's next level (190 checks). That's from reading the view lookup, not from a test, and the reference entry says so.

Where it came from:

  • kartnative (pahorema): the origin of the checks and the release pattern (their tex_soft, nvn_dirty_release, mem_map_limit lines).
  • plumber-racer-2 (cheemu21): the iPhone numbers and the LiveContainer recipe (their note inside the fork).
  • PR #1383: the file-backed guest memory and sparse work this was measured through; the port applies to that tree's anchors too (branch pr-1383-deploy of my fork carries the same diff).

Docs: docs/reference/soft-textures.md describes the mechanism and docs/env_switches.tsv has both switches. If this lands, the game-note rows (dm/games/<id>/README.md, memory section) take the settings and the numbers game by game, as the fork lines already document for Mario Kart World.

Checked

Tests and checks:

  • Nov 12: main 0c23e0b59 merged in (head 18454f81). Main's partial re-upload (d2556cffa) asked about the written bytes of the host level, which for a soft texture holds the game's level one finer, so a rewritten game level would have left its host level stale; it now asks about the game's level, and t_soft_texture gained 3 checks for that path (fails with the shift forced to 0). On that head: t_soft_texture 175, t_partial_upload 3,585, t_direct_upload 26,384 and 26,388, t_cpu_tex 105, t_upload_slots 305, t_upload_src 41, t_serial_owner 176 checks, all 0 failed; test_docs, test_env_switches, test_gfx_tests_makefile and the iOS compile check (263 files) pass. t_nvn, t_readback, t_cpu_storage and t_bc_alias weren't rerun.
  • Earlier: rebased onto 25c2546e on 2026-11-10 (head 218fd092), on an Apple M2 Pro Mac, with GAME=040074A01BF12000 where a test takes a game.
  • t_soft_texture (new): 172 checks, 0 failed. It covers the half-size upload and level shift on the GPU and the CPU, a CPU write, a buffer copy, a texture copy and an alias copy into a soft texture, a copy from one, a copy in and out in one command buffer, and the released case. Before the fix, 19 of its checks failed and Metal's validation stopped it.
  • While writing that test I found that the port to main had lost the level shift in the CPU upload, which left a soft texture on that path empty ([RED] deswizzle failed). That's fixed in the same commit. The phone runs below were on the WIP: Mario Kart World 1.8.0 on iOS - rendering, memory and presentation #1383 tree, which has the shift, and their log has no such line, so those numbers stand.
  • t_nvn: 298 checks, 0 failed. t_readback: 217 and 218 cases, 0 failed. t_direct_upload: 26,384 checks, 0 failed. t_cpu_storage: 135 checks, 0 failed. t_bc_alias: 19 checks, 0 failed. The readback, direct upload and CPU storage tests give the same counts with DM_NVN_TEX_SOFT=1 DM_NVN_TEX_RELEASE=1.
  • t_dirty_slots, t_dirty_race, t_dirty_retire_race: pass. make -C dm/host builds.
  • test_lint_c.py (19 tests), test_docs.py (6) and test_env_switches.py (1) pass, and the symptom index is current.
  • clang-tidy on the changed files against main: nothing above the baseline.
  • I haven't run a game on this head.
  • On the phone: built through scripts/build_ipa_from_nsp.py's iOS path for the same game and driven on the device (the numbers above). The GPU checks run on the M2 Pro with Metal 4; the tests here exercise the dirty-layer paths this feature relies on without a GPU.
  • nvn_dirty_release touched no other caller; with DM_GUEST_FILE and the guest in files, dm_guest_file_backed() makes it a no-op (the guest's memory is not the process's).
  • Not checked locally: scripts/regress.py check over the local games. The local NSP folders no longer carry their shader/build run assets (they went to the Trash in this workspace's sources-only cleanup on November 5, 2026, so the harness discovers no Mac builds); CI's queued runs (host unit tests, CPU-only tests, tool tests, build check, sanitizers, coverage, unsigned iOS app) cover the change. Happy to run the local check once the game data is restored from the Trash.

Games run, with title ID and version: Mario Kart World 1.8.0 (0400C3F00006E000).

Devices and OS: iPhone Duo (iPhone19,4, iOS 27.3); the GPU checks ran on an M2 Pro with Metal 4, macOS version not stated.

scripts/regress.py check --games: not run, see the "Not checked locally" line above.

Measurements before and after (iPhone Duo, iPhone19,4, iOS 27.3, Mario Kart World 1.8.0, the #1383 family's same anchors; a full free-roam drive of 4.2 min, auto-accelerate, with the switches on against without):

  • footprint 3,737 MiB at boot, max 3,868 through the session, last 3,863, against 4,457–5,262 (peak 5,708) without, under the 4 GB bar the fork records for the whole cup, on this phone, in the first session on the ported switches,
  • whole-frame record lost its spike tail: p50 51.2 / p95 81.4 / max 372 ms against 57.6 / 215.2 / 963.5 without (the spike tail belonged to the file-backed page-in stalls the CPU upload path concentrates instead),
  • [RED] count 38 in the window (the driver-side MTL4 victim discards that every lane reports open, unchanged by this feature: recorded in the same session's log for whoever takes that lead).

Log excerpts of that run (boot ramp + the driven window; the released-pages line arms after SoftWait):

[dm] [mem] experimental shared-file guest backing: 11520 MiB reserved; inactive bytes may page to storage
[dm] [nvn-mtl] large block-compressed textures kept without their finest level (DM_NVN_TEX_SOFT=1)
memory budget: footprint 3737 MiB, available 4454 MiB, estimated limit 8192 MiB, peak 3740 MiB
memory budget: footprint 3863 MiB, available 4323 MiB, estimated limit 8192 MiB, peak 3868 MiB
[dm] [longframe] frame 17xxx (241 s) whole frame 51.8 ms, submit+present 24.4 ms, GPU 19.4 ms; new pipelines 0

The full run's log.txt / memory-budget.tsv / memstat.tsv and the keep-side run they compare against are filed in the parallelanygame track tree (logs/pr1383-mkw-run-20261106/driven3-* and driven2-*); happy to attach the full text here if you prefer.

Front ends

None: no front end has to act on this.

Docs

  • The finding is in its docs/reference/soft-textures.md entries, and python3 dm/tools/docs_symptoms.py was run (the symptom index is in the diff).
  • A chain tool changed: dmsh_chain.py --header was run and dm/gfx/nvn/dmsh_chain.h is part of the change. Not applicable: no chain tool changed
  • No game files, keys or data extracted from a game in the change or the attachments

@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) labels Nov 6, 2026
@github-actions github-actions Bot added the docs Documentation label Nov 6, 2026
@oliverames
oliverames force-pushed the pr-soft-textures branch 3 times, most recently from 8b69d92 to e968204 Compare November 7, 2026 20:19
cfryerdev pushed a commit to cfryerdev/nxnative that referenced this pull request Nov 7, 2026
…a, gmem commit repoint on the test stack

seedds#1589 (carrying seedds#1391 + seedds#1482) the GPU memory guard reads the residency
set's size and stops before macOS runs out of RAM; hand-merged with
seedds#1590's ghost-active alias tables. seedds#1599 soft textures (gated, default
off). seedds#1662's tip commit: the ocarina plays into the Mac's microphone
(DM_MIC=1). seedds#1427 raw pool table words follow the pools at commit, as a
candidate for seedds#1326's page faults (its window is skipped, not faulted:
kept for the record). Full-map shader library (42,848), clearing the
no-stamp and c1_full red lines.

In-game: the GPU page fault at scene changes (seedds#1326) remains on this
stack - one session lost the picture at frame 55,940 with the device
revoked; the fault predates seedds#1585, which was excluded after the M2 Pro
report but is not its cause.

Front-ends: host change only, chain 2027aa29b1b7.
@oliverames

Copy link
Copy Markdown
Contributor Author

Rebased onto main 7f50222: two conflicts in nvn_mtl.m (mtl_sub_begin kept main's line without the removed g_rc_npend/g_rc_skip plus soft_sweep(); tex_get kept main's tex_pin version). Tests: t_dirty_slots, t_dirty_race, t_dirty_retire_race pass; t_link_ticks (compiles nvn_mtl.m) 40 checks, 0 failed; test_docs 11 OK. No GPU run.

Oliver Ames added 4 commits November 10, 2026 07:51
…its files is kept without its finest level, and its level-0 pages go back

A first upload of such a texture makes a host texture of its level 1 (Mario Kart World keeps large BC formats from its files with a packaged layout; the host texture that skips level 0 is sampled through the same normalized coordinates), and the guest pages only level 0 holds go back after 300 frames (nvn_dirty_release) unless the game copies from that texture (soft_harden keeps it whole, as Mario Kart World copies source textures into its terrain arrays; the kartnative line notes a copied-from texture that lost level 0 drawing purple). iOS wires the whole memory pool behind a zero-copy buffer, so these uploads run on the CPU while the pool has none (mtl_pool_wrapped) instead of handing the pool a buffer it never addresses (the kartnative/plumber-racer-2 measurements: wrapped pools 3.7 GB -> 851 MB there).

The mechanics: nvn_dirty_release gives back only still-protected whole pages whose bytes the host keeps (read-only again after the madvise dance, then given all-zero or left as-is); with the guest in files (DM_GUEST_FILE) there is nothing to give back (dm_guest_file_backed). The pipeline: tex_soft gates the descriptor (level shift sk) in tex_desc, and every upload reads the source one level down (pkg_upload_, CPU deswizzle included); the copy hooks (nvnCopyTexture t2t/t2b) harden the source first (soft_harden, then a pkg_upload of the whole levels); ReadTexels sees one lower level (tex_soft_level, an unmapped level-0 read reads 0). All default-off: the switches are DM_NVN_TEX_SOFT and DM_NVN_TEX_RELEASE, so no game changes until its profile names tex_soft; the sweep runs once a submission (mtl_sub_begin) and leaves a 300-frame grace (soft_sweep), as kartnative's does.

Where it came from: the kartnative (pahorema) and plumber-racer-2 (cheemu21) forks (MIT) had this first, for Mario Kart World on an iPhone 18 Pro in LiveContainer at about 3.9 GB. The port keeps their behaviour and takes this tree's own anchors (tex_desc, pkg_upload_, the copies, ReadTexels, the dirty layer). Measured on an iPhone Duo (iOS 27.3) through the seedds#1383 family's same anchors: a full free-roam session at 3.7-3.9 GB where the same content without the switches sat at 4.5-5.3, and the whole-frame record lost its p95 spikes (51/81 ms p50/p95 with the switches as against 57/215 without). The tests in this PR run the CPU-only layer here; the GPU checks and the phone runs are recorded on the source lines.
…e, with the kartnative/plumber-racer-2 origin noted
…_TEX_SOFT and DM_NVN_TEX_RELEASE get their rows in the switch table

tex_soft_release assigned realloc's result to g_soft_pend itself (clang-tidy bugprone-suspicious-realloc-usage); it now takes the new block
and the new capacity only when the allocation succeeded, and dies as before when it failed. docs/env_switches.tsv gives both switches a class and a meaning
(test_env_switches.py). Two comments named the guest-file case wrongly: the switch is DM_GUEST_FILE, and nvn_dirty_release gives nothing
back while the guest is in files.
oliverames pushed a commit to oliverames/nxnative that referenced this pull request Nov 10, 2026
…me on an iPhone, and the phone restarts some minutes later (Mario Kart World 1.8.0, iOS 27.3 beta); open

On an iPhone 19,4 the picture stopped 2 to 3 s into a race, 184 s after launch, with the overlay at 0 fps, the process alive, no [RED] line and no GPU error. Every thread waited and none spun. The state dump has the replay thread in iokit_user_client_trap under res_commit (pool_res, with g_mu held by its caller and g_res_mu across the call) and the GPU's completion queue waiting for a mutex in post_complete, with the main fence at submitted 206262 / completed 206256.

Ending the app did not end the process. The system's stack report 15 minutes later still has it with one thread, NVN queue replay, in an uninterruptible kernel wait with a read-write lock held and a kernel thread blocked on it, and the phone at 7.2 GB wired and 34 MB free. The phone then restarted itself.

It happened twice in four runs of one build (main 04811a6 with PRs seedds#1709, seedds#1695, seedds#1262, seedds#1599 and seedds#1713 merged): in that race, and an hour later at the title's demo with no button pressed, 353 s after launch, with the same thread state. Both came in a scene load (1,840 allocations added to the residency set in 678 commits in the 300 frames before the second). With main's 6f50de0 and 12a44e0 added, four races did not freeze, which is too few to say it is gone. The game makes about five residency commits a frame in a race. No fix: the entry says what was ruled out, what the host could change, and what to collect from the phone (SIGALRM for the host's dump, then systemCrashLogs). The game note links it under Open.
…ad on the CPU reads the level it sizes

With DM_NVN_TEX_SOFT=1, a region the game copied on the GPU into a large block-compressed texture showed one mip level off, or was gone at the texture's next upload, with "[RED] GPU write into ser=... not written into its storage (the host texture does not hold its bytes)" in the log. A copy into the texture followed by a copy from it in one command buffer read the texture without the first copy.

The host texture of a soft texture is half the size and holds the game's level n + 1 at level n. A buffer-to-texture copy (copy_b2t), a texture-to-texture copy (copy_t2t_enc) and a copy through an uncompressed alias (bc_alias_copy) took the game's level number as it was. The buffer copy still reached the storage, with no upload to follow it; the texture copy did not, as gsb_store takes only a host texture of the storage's size (gsb_host_ok). Only a texture the game copied from was made whole.

soft_harden now runs on the destination of those three copies before the copy is encoded, as it does on a source: the texture is uploaded again with its finest level and stays whole, so the copy takes the ordinary path and reaches the storage. Its upload has no held GPU store to wait for (gsb_held): a soft texture's storage gets none, as every copy into it comes there first.

A texture whose finest level's storage has gone back (DM_NVN_TEX_RELEASE=1) cannot be made whole and stays soft. A copy from or into its level 0 is skipped, and a copy at another level takes the level one lower in the host texture (tex_soft_level), where the sizes agree. A buffer copy into it still reaches the storage; a texture copy into it does not and is reported by the red line above. Before, these copies used the game's level and sizes on the half-size texture: Metal's validation stopped the test on a texture-to-buffer copy.

The test also found the upload on the CPU, which a soft texture takes while its pool has no zero-copy buffer, empty: the loop sized each level's rows for level l + 1 and deswizzled level l, which nvn_deswizzle refuses ("[RED] deswizzle failed"). It now deswizzles level l + sk, as the upload on the GPU encodes it.

copy_b2t and copy_t2b return at once for a copy with no texture, which had no host texture and returned a line later: with the new calls clang-tidy followed that path to the lines between.

Checked: the new t_soft_texture, 172 checks: the half-size upload and its level shift on the GPU and on the CPU, a CPU write uploaded again the same way, a buffer copy, a texture copy and an alias copy into level 1 (the texture whole afterwards, the region at level 1 and not at level 2, the storage's bytes, the region still there after the next upload, no red line), a copy from level 0, a copy in and a copy out in one command buffer, and the released case. On the tree before this commit 19 of its checks fail, among them both copy cases, and Metal's validation stops it in the released case. docs/reference/soft-textures.md has the two entries, the symptom index their lines, and the two switch rows say what a copy does.
Oliver Ames added 3 commits November 12, 2026 12:24
Conflict: dm/gfx/nvn/nvn_mtl.m, five hunks.
- mtl_pool_del: main moved the body to pool_take_out / pool_gone_release; kept
  the PR's mtl_pool_wrapped beside them.
- mtl_sub_begin: main's nvn_dirty_touch for g_pkg_kept plus the PR's soft_sweep.
- NvPkg: main's own, lost_gen and part fields plus the PR's released.
- pkg_upload_: main's partial re-upload (need, pkg_written, up_copy_add) with
  the PR's level shift sk and keep_cpu in the staged total and the source
  buffer (src_batch).

Adaptations:
- pkg_written takes sk and asks about the byte range of the game's level
  l + sk, so a soft texture's partial re-upload reads the rewritten level and
  copies the others from the texture it replaces. Without it a written level 2
  left host level 1 stale.
- pkg_upload's forward declaration and soft_harden's call take the new
  arguments.
- t_soft_texture: the CPU-write case writes only the level's own pages, so the
  partial path is taken, and checks it (175 checks).
- docs/reference/soft-textures.md names the partial re-upload.
…its host level l always holds the game's level l + 1 (PR seedds#1599)

The direct path (nvn_deswz_gpu_direct_m4) reads the game's level l into host level l and had no level shift, while the CPU and staged paths read l + sk. A soft texture in a wrapped pool could have reached it and been uploaded one level off. None can today (tex_soft wants a block-compressed format, the direct path RGBA16F up to 64x64), so this keeps the two apart on purpose: refusing is one term in the selection, and shifting would touch the kernel's per-level offsets and the all-levels dispatch for a case that does not exist.

Checked: t_soft_texture 190 checks 0 failed (175 before; the new block holds the selection and each host level), t_direct_upload 26388, t_partial_upload 3585, t_upload_src 41, test_docs.py OK, compile_check.py 263 files 0 failed. The new test passes before the fix too, as no soft texture is direct-eligible; it guards the pairing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation host The Horizon host: SVCs, services, threads, memory (dm/host) 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.

1 participant