Skip to content

A uniform word tested against constants is read by a volatile load per test, so Metal's front end folds no switch that Apple's GPU compiler takes hours over - #2372

Closed
bymaxmarc wants to merge 1 commit into
seedds:mainfrom
bymaxmarc:pr/uniform-tests-volatile
Closed

bymaxmarc wants to merge 1 commit into
seedds:mainfrom
bymaxmarc:pr/uniform-tests-volatile

Conversation

@bymaxmarc

@bymaxmarc bymaxmarc commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

What and why

Since e45694b (chain b7249b1c2b66), a uniform read at a fixed offset is bounded once per binding by the host, and the per-read bounds tests left the shader. The translator writes a GLSL switch on a uniform word as Ryujinx's structurizer gives it: one test of the word per case, with each case nested in the next (bool _5 = as_type<int>((*spvDescriptorSet0.fp_c5)._m0[16].w) != 102;). With the reads plain, the Metal front end's SimplifyCFG (at -O1 too) folds a chain of such tests into an LLVM switch whose cases jump into nested blocks past their joins. applegpu-nt then takes such a pixel shader for hours and tens of gigabytes. Nintendo Switch Sports Resort's (04008E001F098000) Mac app pipeline archive ran 15 h in its first group on four pipelines: pixel 7c2becb054a53765 with three vertex shaders, and 692008b305ce264f with 2265ca4db20439bf. 692008b305ce264f went from no switch in its IR to 42, and 7c2becb054a53765 from 6 to 66. The same slowness would hold Preparing pipelines on a device.

The fix: dmsh_rewrite.uniform_tests reads a word that the shader tests for equality with an integer constant more than once through dm_uword, a volatile load of that word per test (as_type<int>(dm_uword((*spvDescriptorSet0.fp_c5)._m0[16], 3)) != 102). The front end can neither merge one test's load with the next nor move it out of its block, so no chain is folded. A uniform load per test costs less than the bounds test each read had before e45694b. A word tested once, a test against a variable and an ordering test are left as they are. dmsh_build.py calls it after the other MSL rewrites (before blend_zero), and on the geometry stage and its reader. Turning the switches back into compares (metal-opt -passes=lowerswitch) does not help: the cost comes from the control-flow graph, not the instruction.

This is a chain change: dmsh_chain.h goes from d24f05cf9d78 to a9bb9ce825a2, and every game's shaders are rebuilt once. dmsh_chain.lock is unchanged (no tool binary changed).

Reference entry: Tests of one uniform word folded into switches, symptom index regenerated.

This PR and the special-register-0x84 PR (#2373) each change dm/gfx/nvn/dmsh_chain.h, so whichever lands second needs dmsh_chain.py --header run again on top of the first.

Checked

  • Tests on this branch (main ccd834e + 1 commit), Apple-silicon Mac, macOS 27.2, Xcode with Metal 32023.922:
    • python3 dm/gfx/tests/test_dmsh_uniform_tests.py: 5 tests pass. They cover the forms rewritten and left, and with Xcode, dmsh_build.py's MAC_METAL and ios_metal.py's command give the plain shader a switch in its IR and the rewritten one none. The test reads MAC_METAL from dmsh_build.py, which on main names dmsh_chain.MAC_MIN_FLAG, so it evaluates that name.
    • python -m unittest discover -s dm/tools/tests: 1,469 tests OK, 27 skipped, as on main (includes test_nsp_chain.py, which checks the header).
    • python -m unittest discover -s dm/gfx/tests -p 'test_*.py': 494 tests, the 489 that main has plus the 5 new ones. The same 10 modules fail to import on this branch and on main, because they load a game profile at import and DM_GAME was not set (test_gfpak, test_kpka, test_monolith, test_mt_framework, test_needle, test_pg_shader, test_pkz, test_shader_split_progress, test_snowdrop, test_sumo). No other failure.
    • make -k -C dm/gfx/tests GAME=010015100B514000 -j8 run (M2 Max): exit 0, every check passed.
  • Games: Nintendo Switch Sports Resort 04008E001F098000 v65536 on the user's Mac (Apple silicon, M2 Max 32 GB, macOS 27.2), measured on the fork's work branch with an earlier main (this commit's chain there, before rebasing). Measured alone with metal-tt 32023.922 (applegpu_g14s), 2265ca4db20439bf + 692008b305ce264f: chain 592225da745b (before e45694b) 7.4 s / 0.25 GB; b7249b1c2b66 stopped at 600 s / 13.4 GB; with this 6.3 s / 0.21 GB. The four pipelines in one run took 15.7 s / 1.07 GB. Bisected on 692008b305ce264f: the old MSL without its fixed-offset bounds tests hangs, and with them kept only on the 910 reads that feed an equality test it takes 3.1 s. With this and the separate pipeline-archive PR, the whole archive (11,075 pipelines) built in 4 min 45 s on that branch.
  • regress.py: not run. Only this game has a build on this Mac. The rewrite reaches every game whose shaders test a uniform word against constants more than once, so it most likely reaches the Switch 2 games and the large material shaders (Odyssey, Tears of the Kingdom). Their shaders were not rebuilt or compared here. A maintainer's regress.py check --games on those is the open check.
  • Not checked: the shaders of this branch's chain (a9bb9ce825a2) were not rebuilt for any game, and the GPU cost of the volatile loads was not measured in a frame.

Docs

  • The finding is in docs/reference/shaders.md, and python3 dm/tools/docs_symptoms.py was run (the symptom index)
  • 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

…er test, so the front end folds no switch for Apple's GPU compiler

Since reads at fixed uniform offsets are plain again (e45694b: the host pads a short binding, chain b7249b1c2b66), the Metal front end's SimplifyCFG folds the translator's GLSL switches (one test of a uniform word per case, `bool _5 = as_type<int>((*spvDescriptorSet0.fp_c5)._m0[16].w) != 102;`) into LLVM switches whose cases jump into nested blocks past their joins. applegpu-nt then takes such a pixel shader for hours and tens of GB: Nintendo Switch Sports Resort's Mac app pipeline archive ran 15 h in its first batch on four pipelines (pixel 7c2becb054a53765 with three vertex shaders, 692008b305ce264f with 2265ca4db20439bf).

- dmsh_rewrite.uniform_tests: a word tested for equality with an integer constant more than once is read through dm_uword, a volatile load of the word per test, so no chain is folded. A word tested once, a test against a variable and an ordering test are left as they are
- dmsh_build.py calls it after the other rewrites (before blend_zero, which adds main0's last parameter and the entries that share its body), and on the geometry stage and its reader
- test_dmsh_uniform_tests.py: the forms rewritten and left; with Xcode, the Mac and iPhone commands give the plain shader a switch and the rewritten one none (MAC_METAL is read from dmsh_build.py with dmsh_chain.MAC_MIN_FLAG evaluated)
- docs/reference/shaders.md: Tests of one uniform word folded into switches; symptom index regenerated; chain header (d24f05cf9d78 -> a9bb9ce825a2 on main ccd834e)

Measured alone with metal-tt 32023.922 (applegpu_g14s, M2 Max 32 GB), 2265ca4db20439bf + 692008b305ce264f: chain 592225da745b 7.4 s / 0.25 GB; b7249b1c2b66 killed at 600 s / 13.4 GB; with this 6.3 s / 0.21 GB. The four pipelines in one run 15.7 s / 1.07 GB; 672c0510444c7b53 + 8679aa3ec010eb7e 13.6 s, 38b608050e482e53 + 931f4ed1270b2a30 24.0 s (68 and 36 switches before). Bisected on 692008b305ce264f: the old vertex library with the new pixel library hangs, the new vertex with the old pixel 4.2 s; the old MSL without its fixed-offset bounds tests hangs, with them kept only on the 910 reads that feed an equality test 3.1 s; metal-opt -passes=lowerswitch on the new IR still hangs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
seedds added a commit that referenced this pull request Nov 12, 2026
…s read by a volatile load per test, so Metal's front end folds no chain of those tests into a switch, which Apple's GPU compiler took minutes to hours and gigabytes over (PR #2372): since the host bounds fixed-offset uniform reads once a binding (e45694b, chain b7249b1c2b66) the reads are plain, and the translator writes a GLSL switch on a word as one test per case with each case nested in the next (bool _5 = as_type<int>((*spvDescriptorSet0.fp_c5)._m0[16].w) != 102); SimplifyCFG (at -O1 too) folds such a chain into an LLVM switch whose cases jump into nested blocks past their joins, and applegpu-nt then takes the graph for hours. Nintendo Switch Sports Resort's (04008E001F098000) pixel shader 692008b305ce264f went from no switch in its IR to 42 and 7c2becb054a53765 from 6 to 66; 2265ca4db20439bf + 692008b305ce264f alone (metal-tt 32023.922, applegpu_g14s, M2 Max): 7.4 s / 0.25 GB on chain 592225da745b, stopped at 600 s / 13.4 GB on b7249b1c2b66, 6.3 s / 0.21 GB with this; the Mac app's archive of 11,075 pipelines built in 4 min 45 s (the contributor's numbers, with a separate archive change). dmsh_rewrite.uniform_tests reads each such test through dm_uword, a volatile load of that word, which the front end can neither merge with the next test's load nor move out of its block; a word tested once, a test against a variable and an ordering test are left. Only game slots (c3 and up): the driver's banks are left as written, since dmsh_entry.vs_base_instance looks for the read of c0 row 100 by its text after this rewrite and the host fills those banks. dmsh_build.py calls it after the other MSL rewrites (before blend_zero), and on the geometry stage and its reader. A workaround for Apple's compiler (a platform limit), not a fix of the translator; turning the switches back into compares (lowerswitch) does not help, the graph is the cost. Chosen over PR #2271's volatile load for every integer fixed-offset read of a game slot, which on older-chain MSL would change 32 to 56% of shaders (Ocarina of Time, Mario Kart World, Metroid Prime Remastered samples) where this changes under 1% and at most 0.15% of fixed reads, keeping e45694b's GPU saving. Chain change: DMSH_CHAIN 8d9afacc48e2; the lock is unchanged. New reference entry (Tests of one uniform word folded into switches), symptom index regenerated. Checked: test_dmsh_uniform_tests (the forms rewritten and left, the driver's banks left, and with Xcode the plain shader's IR has a switch with dmsh_chain.MAC_METAL and ios_metal.METAL and the rewritten one none); dm/tools/tests (1,496) and dm/gfx/tests (544) with the tool venv; test_docs, test_nsp_chain, test_mac_metal; lint_python.py, nothing new. regress.py on NieR:Automata (moderate reach, one light game with a shader of this form): chain diff 1,225/1,225 built on both sides, one shader's MSL changed (14206a699c7c386c, used on the route); check --quick boots and runs the route on both, no new red line, the 100 s screenshot differs by RGB 6.16 at a different animation moment (61 frames apart), within NieR's run-to-run noise there. Not run: the GPU cost in a frame, PR #2271's six pairs on this chain, Switch Sports Resort (not on this Mac), the iPhone

Co-authored-by: bymaxmarc <354601562+bymaxmarc@users.noreply.github.com>
@seedds

seedds commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Landed on main as dfc5a15 (you are credited as co-author), with three changes on top:

  • The test compiles with dmsh_chain.MAC_METAL: main moved the Mac compile there (d2ceb8f), so the dmsh_build.py lookup no longer found it.
  • The rewrite reads only game slots (c3 and up). The driver's banks are left as written: dmsh_entry.vs_base_instance runs after this rewrite and finds the read of c0 row 100 by its text, so a vertex shader testing that word twice would have lost its base instance. A test case covers c0 to c2.
  • Rebased; chain 8d9afacc48e2.

Checked here: your test and the tool/gfx suites; regress.py on NieR:Automata (1,225/1,225 shaders build on both sides, one shader changed, quick run clean apart from route-timing noise in one screenshot). On older-chain MSL this rewrite changes under 1% of shaders (Ocarina of Time, Mario Kart World, Metroid Prime Remastered samples), so the GPU cost should be small, but it is not measured in a frame yet. If you can, a frame-time comparison on Switch Sports Resort with and without it would close that.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants