Skip to content

Task and mesh shaders as compute on M1/M2: OoT Kokiri Forest GPU 35.9 → 34.6 ms (−1.3 ms, 3.6%) - #2413

Open
AdyaGMD wants to merge 3 commits into
seedds:mainfrom
AdyaGMD:mesh-compute-m1
Open

AdyaGMD wants to merge 3 commits into
seedds:mainfrom
AdyaGMD:mesh-compute-m1

Conversation

@AdyaGMD

@AdyaGMD AdyaGMD commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What

The M1 and M2 have no mesh-shading hardware (Apple9 / M3 and later do). On them, any render pass with a mesh draw costs 0.14–0.24 ms more, and the mesh stage runs about 20 % slower per meshlet than the same code as a compute kernel. With DM_NVN_MESH_COMPUTE (on by default on a Mac whose GPU lacks MTLGPUFamilyApple9, i.e. the M1 and M2; =0 turns it off; off elsewhere), task and mesh shaders run as kernels before their render pass, and their triangles are drawn with a plain indexed draw.

  • Look-ahead (mk_hoist_try): the draw that opens a pass scans ahead through it, like cs_hoist_try does for dispatches. Every task / mesh draw it meets becomes a job.
  • One compute encoder before the pass, in phases with a barrier between each:
    1. mk_ind: indirect records → dispatch and draw arguments.
    2. Task kernels.
    3. mk_scan: a scan of the task launch counts.
    4. Mesh kernels.
  • Replay draws each job as an indexed draw: the side library's pass-through vertex function plus the game's fragment shader (mk_pso, built in the background; the native mesh draw is used until it's ready).
  • Side libraries: <key>_bk.metallib, generated from the translated source by the new dm/gfx/tools/dmsh_meshcompute.py. A mac-app build runs it (mesh_side_libs in build_ipa_from_nsp.py, reused while the library and generator are unchanged), and pack_app.py --msl-extra adds them to the app's shader image. A failed generation leaves them out; without one, a draw stays native.
  • Where the scan stops: at a draw that may write memory, a dispatch cs_hoist_try didn't move, another render target, and other ops. It passes uniform updates, but keeps native any draw whose uniform buffers or indirect record one of them touched.
  • Indirect draws: each (record, mesh shader) has a GPU-written feedback slot holding the largest count seen and, for task draws, the largest meshlet total. These size the next frames' slots. The first time a task draw's record is seen, it draws natively while a probe records its count.

The grass flicker it had on the way

The first sizing reserved count × 32 meshlets per task. A colour-pass shader with a ~100-byte vertex record went over the 64 MB limit and stayed native, while its depth prepass shader (a 16-byte record) converted. The colour pass tests for equal depth. The kernel's position can differ from the mesh stage's in the last bit, so the test failed on scattered pixels and grass blades flickered white. Sizing from the meshlet totals the GPU actually counted fixed it.

Performance

Ocarina of Time (0400486024DEC000), Kokiri Forest looking down at the map, MacBook with an M1 (8-core GPU), fullscreen 2880×1800. Mesh compute was alternated on and off every 600 frames within one run. Frames 1500–4800, the second 300 frames of every turn:

GPU p50 Frame p50
Mesh compute on (3 turns) 34.3–35.7 ms (mean 34.6) 36.9 ms
Off (3 turns) 35.1–36.6 ms (mean 35.9) 38.2 ms

−1.3 ms per frame (3.6 %), and on was lower in every adjacent pair. 82 % of mesh draws convert (78,829 of 95,766 over 1,800 frames). The rest are shaders with no side library (per-primitive outputs, point topology), first sights, and pipelines still building.

Separate runs drifted 3–4 ms apart (window state, time into the session), which is more than the gain. So DM_NVN_MESH_COMPUTE accepts ab for the existing DM_AB phases.

Switches (all diagnostic, in docs/env_switches.tsv)

  • DM_NVN_MESH_COMPUTE=0|1|ab: the feature (default on for M1 / M2 Macs).
  • DM_NVN_MK_IND=0|task|notask: which indirect draws convert.
  • DM_NVN_MK_UBSTOP=1: end the look-ahead at the first uniform update.
  • DM_NVN_MK_EQ=1: keep shaders drawn with an equal depth test native. It made things worse; kept for diagnosis.

Second commit

  • On by default for M1 / M2, and the side libraries packed into the app (checked: an OoT mac-app build put all 492 into the app's image).
  • The first commit didn't compile on main: it used a memory-write tracker and a same-target check from my working tree. Now a draw that may write memory is prog_swrites, and any target change ends the look-ahead. The measurement above was made with the working tree's versions; on main the scan may stop sooner in passes that change targets. This branch's host hasn't been run in game.

Known / not run

  • For about 3 s after the start, while pipelines build and records are first seen, a prepass and its colour pass can take different paths, and the grass flickers. Building the pipelines while the game loads would fix that.
  • Not run:
    • The DM_AB form of the switch in game (the measurement used an equivalent switch of its own, folded into DM_AB for this PR).
    • Any test suite.
    • iPhone.
    • M3 and later (it stays off unless set).
    • Other games.
  • The generator's output was checked identical, for all 512 of OoT's mesh / task shaders, to the side libraries that were measured.

🤖 Generated with Claude Code

…e: Ocarina of Time's slow spot on an M1, GPU 35.9 -> 34.6 ms a frame (-1.3 ms, 3.6 %)

The M1 and M2 have no mesh-shading hardware (Apple9, M3 on, has it): a render pass with any mesh draw costs 0.14-0.24 ms more, and the mesh stage runs about 20 % slower per
meshlet than the same code as a kernel. With DM_NVN_MESH_COMPUTE=1 (off by default) the draw that opens a pass looks ahead through it (mk_hoist_try, as cs_hoist_try does for
dispatches) and runs every task / mesh draw it meets as kernels in one compute encoder before the pass: indirect records to dispatch and draw arguments (mk_ind), the task kernels,
a scan of their launch counts (mk_scan), the mesh kernels, with a barrier between the phases. Replay draws each of them as a plain indexed draw: the side library's pass-through
vertex function and the game's fragment shader (mk_pso, built in the background; the native mesh draw until then). The kernels come from a side library per shader,
<key>_bk.metallib in the DM_MSL_DIR overlay, generated from the translated source by the new dm/gfx/tools/dmsh_meshcompute.py (its build command writes them all for a game).
The scan stops at a draw that may write memory, a dispatch cs_hoist_try did not move, another target and other ops; it passes uniform updates and keeps native a draw whose
uniform buffers or indirect record one of them touched (DM_NVN_MK_UBSTOP=1 stops there). A job is taken only in the pass its scan opened (mk_take).

Indirect draws: their {count, first} is written by the GPU, so each (record, mesh shader) has a feedback slot the GPU writes: the largest count seen and, with a task shader, the
largest meshlet total, which size the next frames' slots (half again and a margin). A task draw's record seen for the first time is drawn natively while a probe keeps its count.
Sized at count x 32 meshlets a task, as at first, a shader writing a large vertex record went past the 64 MB limit and stayed native while its depth prepass, another shader with
a 16-byte record, converted; the colour pass's equal depth test then failed on scattered pixels, where the kernel's position differs from the mesh stage's in the last bit, and
grass blades flickered white. Keeping native the shaders drawn with an equal depth test (DM_NVN_MK_EQ=1) made it worse, since the prepass shader still converted.

Measured (Ocarina of Time, 0400486024DEC000, Kokiri Forest looking down at the map, MacBook M1 8-core GPU, fullscreen 2880x1800): mesh compute alternated on and off every
600 frames in one run, frames 1500-4800 (three turns each, the second 300 frames of every turn): GPU p50 34.3-35.7 ms (mean 34.6) on, 35.1-36.6 (mean 35.9) off; frame p50 36.9
on, 38.2 off; on was lower in every adjacent pair. 82 % of the mesh draws convert (78,829 of 95,766 in 1,800 frames); the rest are shaders with no side library (1,376 draws:
per-primitive outputs, point topology), first sights and pipelines still building. Runs compared across processes drifted 3-4 ms apart, more than the gain, so DM_NVN_MESH_COMPUTE
takes ab for the DM_AB phases.

Known: for about 3 s after the start, while the pipelines build and records are first seen, a prepass and its colour pass can take different paths and the grass flickers.
Not run: the DM_AB form of the switch in game (the measurement used an equivalent switch of its own, folded into DM_AB here), any test suite, iPhone, M3 and later (where it stays
off unless set), other games.

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) docs Documentation labels Nov 12, 2026
@AdyaGMD AdyaGMD changed the title Task and mesh shaders as compute on M1/M2: OoT slow spot GPU 35.9 → 34.6 ms (−1.3 ms, 3.6%) Task and mesh shaders as compute on M1/M2: OoT Kokiri Forest GPU 35.9 → 34.6 ms (−1.3 ms, 3.6%) Nov 12, 2026
…, M2), its side libraries built into the app, and the branch compiling on main

DM_NVN_MESH_COMPUTE now defaults to on where the GPU lacks MTLGPUFamilyApple9 on macOS (the M1 and M2); =0 turns it off, and it stays off elsewhere (the iPhone is not measured).
The side libraries come with the app instead of a DM_MSL_DIR overlay: a mac-app build (build_ipa_from_nsp.py mesh_side_libs) runs dmsh_meshcompute.py build on the Mac library
into <work>/shader/mk-b-<chain>, reused while the library's image and the generator are unchanged, and pack_app.py --msl-extra adds them to the app's shader image
(shader_lib.subset extra); a generator failure leaves them out, and those draws stay native.

The first commit did not compile on main: mk_draw_writes used draw_note_writes and its counters, and the scan's target check used csh_rt_same, both from a working tree
with other changes. Now mk_draw_writes is prog_swrites (any shader of the draw stores to a buffer or image), and a target change ends the scan. The measurement in the first
commit was made with the working tree's versions; the scan may now stop sooner in a pass that changes targets.

Checked: the host compiles. Not run: this branch's host in game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added packing Building an IPA or a Mac app: the NSP script, extraction, the shader split, the packers mac The Mac front end and app (dm/mac, front_mac.m) tools Tools and scripts (dm/tools, scripts) labels Nov 12, 2026
build took only the shader map's rows of stage mesh, and the map names a task shader's stage task, so no task shader got its kernel and every task draw stayed native
("no task library": in Ocarina of Time 47,746 of 90,656 mesh-stage draws in 2,100 frames, a third converting instead of 82 %). The measurement in the first commit used
side libraries made before this tool, which had them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UserDerezzed pushed a commit to UserDerezzed/nxnative that referenced this pull request Nov 13, 2026
…fter memory writes, not on every barrier: Ocarina of Time -1.3 ms a frame on an M1

resource_vis() put MTL4VisibilityOptionResourceAlias on every barrier while an image heap was alive, about 230 barriers a frame. Ocarina of Time keeps one from its first frame
(its dynamic-resolution targets are a resolution family, one storage shared by several sizes), and a counter-free Metal System Trace of its Kokiri Forest slow spot on an M1
showed the GPU's stages never overlapping: vertex work started under a running fragment phase in 0.9 % of kicks, compute under fragment in 0 %, and the GPU was idle a quarter
of each frame. The relaxed draw-pass barriers allowed the overlap; the alias flush on each of them took it away. A family changes the size it draws at between frames, not
inside one, so the flush is needed at a submission's start and after a write that another view of the memory might read. Now, with DM_NVN_VIS_HEAPFRAME (on by default; =0
flushes on every barrier again, ab for the DM_AB phases), an image heap's alias flush is on the first barrier of each submission (g_alias_heap_pend, set by mtl_sub_begin) and
on the first barrier after an encoder that may have written memory (g_alias_pend: any compute or copy encoder, a draw pass with shader writes, g_pass_swrite). Large chunked
pools without an image heap keep the flush on every barrier, as before.

Measured (0400486024DEC000, Kokiri Forest, MacBook M1 8-core GPU, fullscreen, with the light-pass merge and mesh compute of seedds#2441 / seedds#2413 on, in a tree with
other local changes): DM_NVN_VIS_HEAPFRAME=ab:1/3 in 300-frame phases, frame p50 36.61 / 36.25 / 37.20 / 37.27 ms with it (mean 36.83) against 38.12 / 38.16 / 38.15 without
(38.14): -1.3 ms, lower in every phase. Its GPU p50 (the sum of command-buffer durations) rises to 37.7-39.7 ms, as command buffers now overlap. With it a Metal System
Trace shows vertex under fragment in 32.8 % of kicks and the GPU idle 21 % of the time. Screenshots look right (the fog of seedds#2487 included). Dropping the flush altogether
(a diagnostic) gave -1.5 to -1.8 ms; this keeps most of that.

Not run: this branch's host in game, any test suite, the iPhone, M3 and later, other games.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@devMlGUE

Copy link
Copy Markdown
Contributor

Tested this on an M1 Pro (16-core GPU) in a tree with main b06f4e0 and your other M1 PRs merged (#2577 #2574 #2573 #2560 #2559 #2526 #2561 #2441 #2593, not #2525). Built from my own copy; all 512 side libraries compiled, 0 failed.

Found a visual issue: grass flickers and some flowers turn solid bright white. It started around frame 37,500 (I'd been around Hyrule Castle for a while and walked to the Market at the end), and from then on almost every log window had "indirect task draws had more meshlets than their slots (later tasks dropped that frame)", 31 times with 6 to 20 draws each. The slots kept growing (256 → 401 → 641) but the drops kept going until I quit. With only DM_NVN_MESH_COMPUTE=0 the flowers look right, the grass stops flickering and those lines are gone.

One note on the merge: main now skips cs_hoist_try in passes that replay deferred draws (!defer_q_draw), so I added the same check to the mk_hoist_try call when resolving the conflict. Happy to test a fix.

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

Labels

docs Documentation mac The Mac front end and app (dm/mac, front_mac.m) 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 tools Tools and scripts (dm/tools, scripts)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants