Skip to content

Raw global memory at Metal's own GPU addresses, opt-in per game (shader_raw_direct, Ocarina of Time) - #1833

Draft
MartinBouteruche wants to merge 9 commits into
seedds:mainfrom
MartinBouteruche:raw-direct
Draft

MartinBouteruche wants to merge 9 commits into
seedds:mainfrom
MartinBouteruche:raw-direct

Conversation

@MartinBouteruche

Copy link
Copy Markdown

Draft, for discussion. Raw global memory (shaders reading through an address they loaded from memory) read at Metal's own GPU address, with no pool table lookup. Opt-in per game with a new profile key, shader_raw_direct, set only for Ocarina of Time. Every other game's jobs, shaders and host behaviour stay as on main.

Why

On an M1 Pro, Ocarina of Time is GPU-bound and latency-bound: every limiter is low, and its raw-memory compute shaders run at 384 threads per threadgroup. The lookup (2 MiB hint plus a 13-step binary search) holds registers across every raw access. Measured offline on the game's 62 starved compute shaders (maxTotalThreadsPerThreadgroup):

variant threads / threadgroup
lookup as on main 384
lookup out of line, looped search 384
2 MiB window table instead of the search ~490
address used as is (this PR) ~560

What it does

  • Host (hle_nvn.c, nvn_mtl.m, when mtl_gva_on()):
    • NVN hands the game Metal's own GPU addresses: a pool's no-copy buffer gpuAddress plus the offset.
    • A large chunked pool gets one extra buffer over all of it that only provides the addresses. It is made resident once raw memory may reach the pool.
    • Every address the game passes back at an NVN entry point is mapped to the CPU through a table of GPU ranges sorted by address.
    • The pool table stays, with GPU ranges and a delta of 0, for residency and the write log.
  • Translator (GlobalToStorage.TryGenerateRawDirectOp, through IGpuAccessor.QueryHostRawDirect and the job's raw_direct):
    • A raw access is a pointer built from the address's two words. A 64- or 128-bit load stays one vector load.
    • Loads have no check: the GPU reads 0 at an unmapped address, as a failed lookup returned, and any branch cost the registers back.
    • Stores and atomics outside macOS's GPU address range are dropped. They log their 2 MiB chunk, modulo the log's bits, in the write log.
  • Diagnostics that established the safety, kept as switches:
    • DM_NVN_GPUVA_DIAG: what the pool table holds.
    • DM_NVN_GPUVA_SHIFT: hands out every GPU address moved into an unmapped range and counts CPU accesses through one.

Evidence

All measured on an M1 Pro (32 GB) during development on a branch with this change:

  • Address width: Metal's pool buffer addresses stayed below 2^37 (0x15_0000_0000 to 0x18_cf50_a000 after 10 min).
  • No address mixing: with DM_NVN_GPUVA_SHIFT, OoT made no CPU access through a GPU address and handed back no CPU address as a GPU one, over 9.8 M addresses handed out.
  • Long run: 22.5 M Metal addresses handed out over 5 min, none of them a CPU address, no new [RED] line, picture correct.
  • Speed: Kokiri Forest at 854x480, same spot, MetalFX upscaler, against the lookup build of that time: GPU 39.4 → 27.4 ms, frame 33.2 → 23.6 ms.

Not done yet (why it is a draft)

  • No fresh A/B on current main: the numbers above come from my development branch (2026-11-07), not from a bench.py run of this exact commit against main.
  • No 5-game regression run (regress.py check): the key is off for every other game, and test_dmsh_raw_last.py (main's lookup test) passes unchanged with this translator, so their translation should be byte-identical. regress.py chain on a couple of games would confirm it.
  • Untested on sparse memory pools: games with sparse pools (Donkey Kong Bananza) have not run with the key. A sparse pool whose pages are not mapped at its GPU address would get its CPU address handed out (a [RED] line says so), and its raw accesses would read 0.

Tests

  • test_dmsh_raw_direct.py (with DMSH_TEST_XLATE): helper shape, and GPU reads of three Metal buffers through their own addresses, scalar and 128-bit.
  • test_dmsh_raw_last.py, test_dmsh_ampere_mem.py, test_dmsh_meshstage.py, test_nsp_chain.py and test_docs.py pass.
  • test_env_switches.py fails as on main: DM_NVN_CUT_FIRST, DM_NVN_RT_RECLAIM, DM_NVN_VC_WAYS, DM_NVN_BC_ALIAS.
  • Graphics tests (make -C dm/gfx/tests run): same results as main. t_pool_chunks, t_nvn, t_gmem_words and t_geometry now check both modes: with the key off (GAME=010015100B514000) they run main's checks unchanged; with it on (GAME=0400486024DEC000) they run the direct-address ones. Writing them turned up two host fixes, included here:

Questions for reviewers

  1. Is a per-profile opt-in acceptable as a first step, or should this wait until it can be on for every NVN game?
  2. Does anyone have a game with sparse memory pools on hand to try shader_raw_direct 1?
  3. Could someone with an M3 or later run OoT with and without the key? Those GPUs allocate registers dynamically, so the gain may be smaller there; I only have the M1 Pro.

Docs: a new reference entry in docs/reference/shaders.md ("Raw global memory read through Metal's own GPU addresses"), and the key's row in dm/games/README.md.

🤖 Generated with Claude Code

MartinBouteruche and others added 9 commits November 8, 2026 17:15
…would cover, and the Metal GPU addresses of the pool buffers (research for raw memory access without the lookup)
…into an unmapped shadow, move them back at every NVN entry, the raw pool table and driver constants in the game's view; count CPU accesses through moved addresses (repaired in the fault handler) and CPU addresses handed in

Ocarina of Time, 6 minutes with a scene change: 9.77 M addresses handed out, no CPU access through one, no CPU address handed in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… GPU addresses (a large pool's from a buffer over all of it, resident only once raw global memory may reach it) and maps each one handed back to the CPU; the raw pool table holds GPU ranges with delta 0

Ocarina of Time, 5 minutes with a scene change: correct picture, 10.4 M addresses handed out, no CPU address handed in, frame times unchanged. The GPU page fault at its first scene load happens with the switch off as well.
The shift diagnostic's guest-range functions get weak stand-ins, so the graphics unit tests link again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ss itself (chain cfb1ac0c52d4)

The translator (GlobalToStorage.TryGenerateRawDirectOp) accesses an address loaded from memory as it is: no pool table, no search. Loads have no check (an Apple GPU reads 0 where nothing
is mapped, and any branch, even one that only returns 0, cost the whole gain); stores and atomics outside the GPU's address range are dropped and set their 2 MiB chunk's bit in the write log.
dmsh_abi.h DMSH_RAW_DIRECT makes the host hand out Metal's addresses, read the log by chunk (gva_chunk_ranges) and make the reachable pools resident at each commit.
Ocarina of Time's 62 raw-memory shaders: 384 -> 562 threads a threadgroup on average (M1 Pro), as the hand-edited prototypes measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…self (chain 535496428142)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…CT), not a switch; test_dmsh_raw_direct.py replaces the pool table lookup's test (chain db98b8fa2a9f)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rs reference), the lookup's entries marked superseded; symptom index

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er_raw_direct, on for Ocarina of Time only (chain 01c2651261b7)

dmsh_build.py gives the translator raw_direct for such a profile (IGpuAccessor.QueryHostRawDirect, read by GlobalToStorage), and the host takes DM_GAME_SHADER_RAW_DIRECT (mtl_gva_on) in place of the chain-wide DMSH_RAW_DIRECT: every other game's jobs, shaders and host behaviour stay as on main, with the pool table's lookup. test_dmsh_raw_last.py is back for the lookup; test_dmsh_raw_direct.py asks for raw_direct. A large pool's address buffer is made with pool_buf, like its other buffers, so one made over a write-protected page takes the GPU's writes; the pools raw global memory reaches are made resident before the commit even when no slot needs patching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (main's expectations) and, under the profile's shader_raw_direct (mtl_gva_on), Metal's

t_nvn: a pool's and a buffer's GPU address are Metal's own and map back to the storage. t_geometry: the c0 storage buffer rows carry the address the game holds. t_gmem_words: the
table's entries are the pools' GPU ranges. t_pool_chunks: a large pool reached by raw global memory is its address buffer's whole GPU range, made resident, while bindings keep their
chunks; the lookup hint and the write log's bits are 2 MiB chunks of GPU addresses; and a large pool's address buffer made over a page protected before the pool was added takes
the GPU's writes (fails with the buffer made by newBufferWithBytesNoCopy instead of pool_buf, issue seedds#1591). With mtl_gva_on 0 every check is main's.

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) 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 labels Nov 8, 2026
@lunks

lunks commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Data point from an M5 Pro (24 GB), Ocarina of Time v131072. We ported this PR onto our tree and read maxTotalThreadsPerThreadgroup from the compiled pipelines of the 731 compute shaders that read raw global memory: 584 compile on our tree (147 don't, with the key on and off alike). All 584 report 1024 with the pool table and 1024 with shader_raw_direct, so the register-pressure limit the PR describes (384 → ~560 on an M1 Pro) doesn't show on this GPU. The ALU cost of the lookup is not measured and nothing was booted with it.

Separately, in a compute and an object/mesh pipeline, reads at null, 0x100, an unmapped hole, a stray CPU address and an address above 0x70_0000_0000 returned 0 with no error, and stores there completed. One note on the store window: a fresh process's first Metal buffer got a GPU address of about 1.1 TB, outside 0x10_0000_0000..0x70_0000_0000; we have not looked at where the game's own pools land.

@dpadr

dpadr commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Data point from a MacBook Air M2 (10-core GPU, 16 GB), macOS 27.0, Ocarina of Time v131072.

Build: this PR (shader_raw_direct 1) ported onto our OoT tree (base 7f50222, plus #1902 and #1897), chain cd098f1631bd, with a full shader rebuild. Handheld, 1440x810 main pass (dynamic resolution off), MetalFX upscaler, frame-rate hold off, target 60. Each run walks a route through Kokiri Forest.

Speed (paired A/B, interleaved runs, windows with the CPU clock below 2.9 GHz excluded; 5 runs on A, 7 on B). Caveat: A isn't exactly B minus this PR. B also carries:

Of those, only #1902 / #1897 change gameplay shaders, and they are rounding fixes. Most of the difference should be this PR, but it isn't isolated.

A (without) B (with #1833) Δ (95 % CI)
frame p50 32.72 ms 29.45 ms −3.27 ms [−4.51, −2.63]
frame p95 −3.84 ms [−7.28, −2.35]
GPU span p50 30.20 ms 26.62 ms −3.58 ms [−4.35, −2.49]
footprint +0.04 GiB

[gpushift] NVN hands out Metal's GPU addresses (shader_raw_direct) was present in every run with the PR and absent in every run without it.

Reach: in a profiled gameplay frame, 0 of the 272 shaders (fragment, compute, task, mesh) still call the pool lookup. We matched on the lookup helper's signature. On the build without the PR, some of the same shaders did call it.

What unchecked reads do on the M2. One-thread probe through a 64-bit address; every command buffer completed with no error and no page fault:

  • null, a CPU address, unmapped GPU ranges, just past a buffer's end, and a freed range not yet reused: all read 0;
  • a freed range that a new buffer took (Metal reused it at once, for the next buffer of the same size) reads the new buffer's data, whether or not that buffer is resident in the pass.

So a stale pointer into a freed pool would read another pool's data rather than 0. That differs from the lookup only when Metal reuses a GPU range that the guest hasn't.

Stale-pointer check: two gameplay runs with DM_NVN_GPUVA_SHIFT=1 and the watchdog on: 0 hangs, 0 faults, 0 watchdog kills, 0 [RED] lines. A further gate run on the promoted build was also clean. These runs don't cover long sessions with heavy pool churn. Kokiri Forest's picture matched the build without the PR.

We now use it as our daily build for OoT on the Air.

@dpadr

dpadr commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Follow-up to my comment above, on uncraftablexd's point about the fixed store window. On the M2 Air (macOS 27.0), every OoT pool buffer sat inside the first window: the [gpushift] GPU address table line ranged from 0x15_0006_8000 to 0x18_689d_0000 (4,537 buffers at the peak) across all our runs, within [0x10_0000_0000, 0x70_0000_0000). So no raw stores or atomics were dropped in our A/B, and the foliage drew normally; the picture matched the build without the PR. The −3.27 ms holds for the M2.

It agrees with the M4 Pro report, though: the window depends on the Mac, so taking its bounds from the host's address table instead of a literal would make the result portable.

FeReuss pushed a commit to FeReuss/nxnative-oot that referenced this pull request Nov 10, 2026
…s (PR seedds#1833) with the M4 fix, on the synced main

The branch is PR seedds#1833 (shader_raw_direct, on for Ocarina of Time) plus one commit that lets raw stores and atomics reach pool buffers above 2^40, which an M4's addresses are.

Conflicts resolved by hand:
- GlobalToStorage.cs: the PR's structure (TryGenerateRawDirectOp, EmitRawAccess) with main's two changes to the lookup path kept: the 'a miss reads 0 in every word of a vector load' block in TryGenerateRawStorageOp's tail (the direct op has no miss path for loads: the GPU reads 0 at an unmapped address) and the GlobalVectorGroup guard of the follower scan.
- nvn_mtl.m (mtl_pool_del): main collects the retired chunks in 'gone' and releases them behind the fence; the PR's whole-pool address buffer (va) joins that batch instead of a separate defer_obj_release.
- The translator is rebuilt from the merged sources (dotnet publish; dmsh_xlate.dll, Ryujinx.Graphics.Shader.dll, Ryujinx.Common.dll, Spv.Generator.dll and the pdb replaced, the universal launcher and the other files kept), dmsh_chain.lock updated with tool_lock.py --update-lock (only the four translator fingerprints change), dmsh_chain.h regenerated. tool_lock.py --check-tools passes.
- Docs: game-patches.md, symptoms.md and env_switches.tsv regenerated.

Checked: test_dmsh_raw_direct, test_xlate_anycpu, test_nsp_chain, the translator tests for border-fc, float-atomic-add, seedds#2081, seedds#2030 and layer_out, docs tests; the host builds for Ocarina of Time. Not run: a game.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
FeReuss pushed a commit to FeReuss/nxnative-oot that referenced this pull request Nov 10, 2026
…on scope

The test builds a namespace for dmsh_build.py's job_for by hand; PR seedds#1833's job_for reads the module constant RAW_DIRECT (shader_raw_direct), so both JobFor tests failed with NameError once the two met. The checks are about the early-fragment flag, so the constant is False.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
stan69b pushed a commit to stan69b/nxnative that referenced this pull request Nov 10, 2026
…d the allocator pool tried and reverted

Game note, "For an iPhone or iPad build": a new current bullet for the iPhone 17 at the owner's
Kokiri spot. Its table gives the GPU span, the GPU work and the gaps before and inside
submissions, before and after A-2. Gameplay GPU p50 is 21-24 ms, under the cap's 33.3 ms, so
shader pull requests seedds#1752 and seedds#1833 were not tried. The frame is CPU-bound at 16-20 fps under
thermal state 1-2. The bullet records the uncapped A/B of A-2, and a command allocator pool of
128 that removed its waits but no frame time, at a cost of about 300 MB (not kept). Open:
translation cost, samples-passed reports, footprint growth. The CPU timeline the table was
measured with is a local diagnostic, not in this pull request.

rendering.md: the A-2 entry gets its measured result. A Found-on line in the note, and the
outdated iPhone line under "Not run on an iPhone yet" now points to the section.
stan69b pushed a commit to stan69b/nxnative that referenced this pull request Nov 10, 2026
…d the allocator pool tried and reverted

Game note, "For an iPhone or iPad build": a new current bullet for the iPhone 17 at the owner's
Kokiri spot. Its table gives the GPU span, the GPU work and the gaps before and inside
submissions, before and after A-2. Gameplay GPU p50 is 21-24 ms, under the cap's 33.3 ms, so
shader pull requests seedds#1752 and seedds#1833 were not tried. The frame is CPU-bound at 16-20 fps under
thermal state 1-2. The bullet records the uncapped A/B of A-2, and a command allocator pool of
128 that removed its waits but no frame time, at a cost of about 300 MB (not kept). Open:
translation cost, samples-passed reports, footprint growth. The CPU timeline the table was
measured with is a local diagnostic, not in this pull request.

rendering.md: the A-2 entry gets its measured result. A Found-on line in the note, and the
outdated iPhone line under "Not run on an iPhone yet" now points to the section.
stan69b pushed a commit to stan69b/nxnative that referenced this pull request Nov 10, 2026
…d the allocator pool tried and reverted

Game note, "For an iPhone or iPad build": a new current bullet for the iPhone 17 at the owner's
Kokiri spot. Its table gives the GPU span, the GPU work and the gaps before and inside
submissions, before and after A-2. Gameplay GPU p50 is 21-24 ms, under the cap's 33.3 ms, so
shader pull requests seedds#1752 and seedds#1833 were not tried. The frame is CPU-bound at 16-20 fps under
thermal state 1-2. The bullet records the uncapped A/B of A-2, and a command allocator pool of
128 that removed its waits but no frame time, at a cost of about 300 MB (not kept). Open:
translation cost, samples-passed reports, footprint growth. The CPU timeline the table was
measured with is a local diagnostic, not in this pull request.

rendering.md: the A-2 entry gets its measured result. A Found-on line in the note, and the
outdated iPhone line under "Not run on an iPhone yet" now points to the section.
stan69b pushed a commit to stan69b/nxnative that referenced this pull request Nov 10, 2026
…d the allocator pool tried and reverted

Game note, "For an iPhone or iPad build": a new current bullet for the iPhone 17 at the owner's
Kokiri spot. Its table gives the GPU span, the GPU work and the gaps before and inside
submissions, before and after A-2. Gameplay GPU p50 is 21-24 ms, under the cap's 33.3 ms, so
shader pull requests seedds#1752 and seedds#1833 were not tried. The frame is CPU-bound at 16-20 fps under
thermal state 1-2. The bullet records the uncapped A/B of A-2, and a command allocator pool of
128 that removed its waits but no frame time, at a cost of about 300 MB (not kept). Open:
translation cost, samples-passed reports, footprint growth. The CPU timeline the table was
measured with is a local diagnostic, not in this pull request.

rendering.md: the A-2 entry gets its measured result. A Found-on line in the note, and the
outdated iPhone line under "Not run on an iPhone yet" now points to the section.
stan69b pushed a commit to stan69b/nxnative that referenced this pull request Nov 12, 2026
…d the allocator pool tried and reverted

Game note, "For an iPhone or iPad build": a new current bullet for the iPhone 17 at the owner's
Kokiri spot. Its table gives the GPU span, the GPU work and the gaps before and inside
submissions, before and after A-2. Gameplay GPU p50 is 21-24 ms, under the cap's 33.3 ms, so
shader pull requests seedds#1752 and seedds#1833 were not tried. The frame is CPU-bound at 16-20 fps under
thermal state 1-2. The bullet records the uncapped A/B of A-2, and a command allocator pool of
128 that removed its waits but no frame time, at a cost of about 300 MB (not kept). Open:
translation cost, samples-passed reports, footprint growth. The CPU timeline the table was
measured with is a local diagnostic, not in this pull request.

rendering.md: the A-2 entry gets its measured result. A Found-on line in the note, and the
outdated iPhone line under "Not run on an iPhone yet" now points to the section.
stan69b pushed a commit to stan69b/nxnative that referenced this pull request Nov 12, 2026
…d the allocator pool tried and reverted

Game note, "For an iPhone or iPad build": a new current bullet for the iPhone 17 at the owner's
Kokiri spot. Its table gives the GPU span, the GPU work and the gaps before and inside
submissions, before and after A-2. Gameplay GPU p50 is 21-24 ms, under the cap's 33.3 ms, so
shader pull requests seedds#1752 and seedds#1833 were not tried. The frame is CPU-bound at 16-20 fps under
thermal state 1-2. The bullet records the uncapped A/B of A-2, and a command allocator pool of
128 that removed its waits but no frame time, at a cost of about 300 MB (not kept). Open:
translation cost, samples-passed reports, footprint growth. The CPU timeline the table was
measured with is a local diagnostic, not in this pull request.

rendering.md: the A-2 entry gets its measured result. A Found-on line in the note, and the
outdated iPhone line under "Not run on an iPhone yet" now points to the section.
stan69b pushed a commit to stan69b/nxnative that referenced this pull request Nov 12, 2026
…d the allocator pool tried and reverted

Game note, "For an iPhone or iPad build": a new current bullet for the iPhone 17 at the owner's
Kokiri spot. Its table gives the GPU span, the GPU work and the gaps before and inside
submissions, before and after A-2. Gameplay GPU p50 is 21-24 ms, under the cap's 33.3 ms, so
shader pull requests seedds#1752 and seedds#1833 were not tried. The frame is CPU-bound at 16-20 fps under
thermal state 1-2. The bullet records the uncapped A/B of A-2, and a command allocator pool of
128 that removed its waits but no frame time, at a cost of about 300 MB (not kept). Open:
translation cost, samples-passed reports, footprint growth. The CPU timeline the table was
measured with is a local diagnostic, not in this pull request.

rendering.md: the A-2 entry gets its measured result. A Found-on line in the note, and the
outdated iPhone line under "Not run on an iPhone yet" now points to the section.
stan69b pushed a commit to stan69b/nxnative that referenced this pull request Nov 12, 2026
…d the allocator pool tried and reverted

Game note, "For an iPhone or iPad build": a new current bullet for the iPhone 17 at the owner's
Kokiri spot. Its table gives the GPU span, the GPU work and the gaps before and inside
submissions, before and after A-2. Gameplay GPU p50 is 21-24 ms, under the cap's 33.3 ms, so
shader pull requests seedds#1752 and seedds#1833 were not tried. The frame is CPU-bound at 16-20 fps under
thermal state 1-2. The bullet records the uncapped A/B of A-2, and a command allocator pool of
128 that removed its waits but no frame time, at a cost of about 300 MB (not kept). Open:
translation cost, samples-passed reports, footprint growth. The CPU timeline the table was
measured with is a local diagnostic, not in this pull request.

rendering.md: the A-2 entry gets its measured result. A Found-on line in the note, and the
outdated iPhone line under "Not run on an iPhone yet" now points to the section.
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) 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