Skip to content

Mesh geometry draws gathered into one dispatch: Donkey Kong Bananza's depth prepass, 480 mesh draws 1.50 → 0.67-0.72 ms on an M1 - #1087

Closed
AdyaGMD wants to merge 1 commit into
seedds:mainfrom
AdyaGMD:geo-mesh-runs
Closed

AdyaGMD wants to merge 1 commit into
seedds:mainfrom
AdyaGMD:geo-mesh-runs

Conversation

@AdyaGMD

@AdyaGMD AdyaGMD commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What and why

A Metal mesh draw costs an M1 about 2.7 µs on the GPU however few triangles it draws. Donkey Kong Bananza draws its one geometry-shader pair (vertex f9c807930abd1234, culling kernel 5d537f7630ec3671) as mesh draws (geo_mesh.tsv), and its 960x540 depth prepass in the heavy areas has 420–550 of them a frame, 15–350 triangles each. In DM_NVN_GPUPROF the pass showed an idle gap before it that grew with its draw count (about 2.3 µs a draw, 1.0–2.0 ms a frame), and the GPU counters showed that stretch at about 4 % occupancy.

Fix:

  • dmsh_mesh.compose gives each mesh entry a run twin (dm_mesh_run, dm_mesh_run_z, ..._p16): it takes a table of draws at the draw slot (each draw's arguments, the running total of its threadgroups, and the addresses bound at the vertex table's slots for it), finds its draw by halving, and runs the same body with the draw's own threadgroup and primitive numbers.
  • The host (geo_mesh_take in nvn_geometry.inc) lets a mesh draw that fits wait instead of dispatching, recording g_vt_addr after its arguments are bound. The run is drawn as one dispatch before any operation other than bindings, a program bind and draws (geo_mesh_keeps, from mtl_exec), before a draw of another pair, pass or kind, and before its pass ends (rp_end, defer_fallback). Bindings only change the replay state, so nothing else reaches the encoder between the draws of a run.
  • A run takes only draws whose result does not depend on their order: no colour writes, no stencil writes, depth written only with a test that keeps the nearest or farthest, and a fragment shader with no resources. Occlusion query counting may be on (see the second commit). A draw that cannot join a run logs why, once per reason. DM_NVN_GEO_MESH_RUN=0 turns runs off; the log's geometry mesh draws line counts draws in runs and what ended them.

Reference: Mesh geometry draws gathered into one dispatch.

Checked

  • Tests (M1, before the rebase onto this main): make check_t_geometry_mesh (new: six geometry draws through mtl_exec, each with its own uniform and storage counter, a scissor change in the middle; in runs, with runs off, and on the encoding thread; under Metal validation), check_t_geometry, check_t_geometry_hazards, check_t_geometry_feedback, t_nvn, alpha and logic replay; test_dmsh_geometry.py, including mesh_primitive_id.m's new run cases (97 translated triangles as four draws, both depth modes, three index types) with DMSH_TEST_XLATE and DMSH_TEST_GPU=1; test_docs.py; lint_python.py (no new finding).
  • Benchmark on an M1 with Bananza's pair: 480 draws of the prepass's sizes 1.50 → 0.67–0.72 ms; 480 one-triangle draws 1.24–1.32 → 0.28 ms; 2,100 draws of 32 triangles 4.37 → 0.66 ms.
  • Games run, with title ID and version: Donkey Kong Bananza 040074A01BF12000 v458752, handheld, the heaviest view of its heavy area, MetalFX upscaler (A game's own upscaler drawn by MetalFX, the player's choice in the launcher: Donkey Kong Bananza's NDSR network 3.5-5 ms → about 1 ms a frame on an M1 #1086) and the border-colour change (Pixel shaders without the border-colour correction in their main library: it spilled registers in every pixel where it never ran #1032) on. Correction: the first version of this PR refused runs while samples-passed counting was on, which stays on for the rest of the game after its first reset, so in Bananza no draw ever joined a run (0 of them in 0 runs); the 60 fps measured with A game's own upscaler drawn by MetalFX, the player's choice in the launcher: Donkey Kong Bananza's NDSR network 3.5-5 ms → about 1 ms a frame on an M1 #1086 did not include runs. The second commit allows runs while counting (a report ends a run and a pass keeps one query slot, so each query's interval holds whole runs and whether any sample passed does not depend on their order). With it: 142,000–155,000 mesh draws in 300 runs per 300 frames (one run of about 475 draws a frame), every mesh draw in a run. The view had fewer draws than the run before it (6,600 against 7,870 a frame), so its GPU time is not a like-for-like comparison.
  • Devices and OS: MacBook Pro (13-inch, M1, 8-core GPU), macOS 26.6.2, Xcode 27.2.
  • scripts/regress.py: not run. The chain changes (dmsh_mesh.py: new entries beside the existing ones, whose text is unchanged); the full Bananza build on this chain built all 23,638 shaders.
  • Not checked: the iPhone; other games with geo_mesh.tsv pairs.

Docs

  • The finding is in docs/reference/shaders.md and the game note, DM_NVN_GEO_MESH_RUN in env_switches.tsv; python3 dm/tools/docs_symptoms.py was run
  • A chain tool changed: dmsh_chain.py --header was run and dm/gfx/nvn/dmsh_chain.h is part of the change
  • No game files, keys or data extracted from a game in the change or the attachments

🤖 Generated with Claude Code

… depth prepass, 480 mesh draws 1.50 → 0.67-0.72 ms on an M1

A Metal mesh draw costs the M1 about 2.7 us however few triangles it has. Donkey Kong Bananza's 960x540 depth prepass draws 420-550 geometry-shader draws a frame as mesh draws
(vertex f9c807930abd1234, culling kernel 5d537f7630ec3671, 15-350 triangles each): 1.0-2.0 ms of nearly idle GPU before the pass.

dmsh_mesh.compose gives each mesh entry a run twin (dm_mesh_run, dm_mesh_run_z, ..._p16) that takes a table of draws at the draw slot: each draw's arguments, the running total of
its threadgroups and the addresses bound at the vertex table's slots for it. A threadgroup finds its draw by halving and runs the same body with the draw's own numbers. The host
(geo_mesh_take) lets a mesh draw that fits wait instead of dispatching, recording g_vt_addr after its arguments are bound, and draws the run as one dispatch before any operation but
bindings, a program bind and draws (geo_mesh_keeps, from mtl_exec), before another draw (geo_mesh_draw, draw_), and before its pass ends (rp_end, defer_fallback). A run takes only
draws whose result does not depend on their order: no colour writes, no stencil writes, an order-free depth test, no occlusion query counting, and a fragment shader with no
resources. DM_NVN_GEO_MESH_RUN=0 turns runs off.

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 shader chain The shader translation chain (DMSH_CHAIN): a change rebuilds every game's shaders game: 040074A01BF12000 Donkey Kong Bananza labels Nov 3, 2026
@seedds

seedds commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Landed as 84ec307 on main, unchanged apart from the rebase: the chain header regenerated (DMSH_CHAIN 3df48f2a4fcb, after #1063) and the Bananza note's "Found on" line placed beside #1086's. You're credited as co-author.

Checked on main (M4 Pro): check_t_geometry_mesh in all three modes, check_t_geometry, check_t_geometry_hazards, check_t_geometry_feedback, test_dmsh_geometry.py, test_docs.py. NieR:Automata (010056B015FE8000), the other game with geo_mesh.tsv pairs (its shadow maps), builds 1225/1225 shaders on the new chain, and all 96 of its mesh libraries get run twins. Its route was not run (no save here), so runs in NieR's shadow maps have not been seen in game yet. If you can, a NieR run with and without DM_NVN_GEO_MESH_RUN=0 would close that gap.

Thanks!

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

Labels

docs Documentation game: 040074A01BF12000 Donkey Kong Bananza 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants