Skip to content

Pixel shaders without the border-colour correction in their main library: it spilled registers in every pixel where it never ran - #1032

Closed
AdyaGMD wants to merge 1 commit into
seedds:mainfrom
AdyaGMD:border-plain
Closed

AdyaGMD wants to merge 1 commit into
seedds:mainfrom
AdyaGMD:border-plain

Conversation

@AdyaGMD

@AdyaGMD AdyaGMD commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

What and why

border_emu wraps every sample through a sampler member in dm_bs, a branch the host turns on only for a sampler whose border colour Metal has no sampler state for. The GPU allocates registers for the whole shader, the branch included, so every pixel paid for it even where it never ran. In Donkey Kong Bananza's heavy area no draw set a border bit, yet the material 6f9fd9cb5fbc873b spilled 336 bytes a thread on an M1 (160 without the correction); 6d1b110994db5413 288 → 80. Seven of the frame's pixel shaders spilled, and the GPU counters showed it underfilled (about 39 % occupancy).

A pixel shader's main library is now built without the correction (border_plain: the samples are taken back to their first argument, the twin members and the border block stay declared, so the argument buffer is unchanged). The correcting source goes into a side library <key>_bb, built through ps_side_finish like _ba / _bl. The host uses it only for a pipeline drawn while a sampler member has a twin (border_key, bit 1 of pso_key.logic; bb_fn loads it on first need). The alpha-test and logic-op libraries are still built from the correcting source, and the two !k->logic checks upstream added since are now !(k->logic & 1) so a border pipeline keeps its blending.

Reference entry: a note under Border colours Metal has no sampler state for; the Bananza README lists where it was found.

Checked

  • Tests (M1, Metal): dm/gfx/tests/test_dmsh_border.py (13, new: every sample taken back, argument buffer kept, unchanged without corrections, the plain source compiles), t_border (50, new border_key cases: twin sampler, Metal's own sampler, SAMPI, no border block), alpha replay (788), logic replay (929), t_nvn (227), t_pso_skey, test_dmsh_wide, dm/tools/tests/test_docs.py.

  • scripts/regress.py chain --baseline origin/main (03d92a9 → 6e2bed0), both games it reaches most: 0 newly failing, 0 interface changes.

    game shaders (base → cand) newly failing changed MSL
    Splatoon Raiders 0400380020056000 27191/27195 → 27191/27195 (4 fail in both) 0 15576
    Donkey Kong Bananza 040074A01BF12000 23638/23638 → 23638/23638 0 13220

    Neither game has a smoke route on this Mac, so there is no runtime section in the report.

  • Games run: Donkey Kong Bananza 040074A01BF12000 v458752, handheld (DM_OPMODE=0), the same heavy area, camera moving, DM_NVN_GPUPROF=every:60:

    • first with the 486 shaders of one heavy frame built this way (DM_MSL_DIR overlay): scene pass 5.1–5.3 → 3.24 ms, GPU busy 18.8–22.5 → 14.8 ms a frame;
    • then a full build (23,638 shaders, 13,220 _bb libraries): scene pass 3.35 ms, no _bb library loaded in that area, no RED lines. Whole-frame GPU busy (17.5 ms in that run) varies with the view, so I'd treat the scene-pass number as the like-for-like one.
  • Devices and OS: MacBook Pro (13-inch, M1, MacBookPro17,1), 8-core GPU, macOS 26.6.2, Xcode 27.2.

  • Not checked: Splatoon Raiders in game (static chain check only), the iPhone, a game that actually draws with a twin sampler at runtime (covered by t_border only).

Docs

  • The finding is in its docs/reference/rendering.md entry and the game note, and 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

…ary: it spilled registers in every pixel where it never ran

border_emu wraps every sample through a sampler member in dm_bs, a branch the host turns on only for
a sampler whose border colour Metal lacks. The GPU gives registers to the whole shader, the branch
included, so every pixel paid for it: in Donkey Kong Bananza's heavy area no draw set a bit, while the
SilverStar material 6f9fd9cb5fbc873b spilled 336 bytes a thread (160 without) on an M1.

A pixel shader's main library is now built without the correction (border_plain: the samples back,
the twins and the block still declared, so the argument buffer is the same); the correcting source
goes into the side library <key>_bb. The host takes it only for a pipeline drawn while a sampler
member has a twin (border_key, bit 1 of pso_key.logic; bb_fn loads it on first need). The alpha test
and logic operation libraries keep being built from the correcting source.

Measured with the 486 shaders of one heavy frame built this way (handheld, M1): the scene pass
5.1-5.3 -> 3.2 ms with more draws, GPU busy 18.8-22.5 -> 14.8 ms a frame.

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
@AdyaGMD

AdyaGMD commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor Author

by the way @seedds, DK Bananza uses upscaling which is fine on newer M chips that have tensor chips, but uses 4-5ms on chips without, I'm experimenting with replacing it with MetalFX, this will make the game look slightly different though (I haven't tested yet), so I'm going to add it as an optional setting in the menu before the game boots, this will finally make the game run at 60fps on M1 devices (alongside some other optimizations I found but haven't PRed yet), but I don't know if this is "too hacky" for nxnative, what do you think?

@AdyaGMD

AdyaGMD commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor Author

update: I implemented metalfx in my testing and I can't tell any visual difference between the game's upscaler and metalfx, so im going to make it default on GPUs without tensor cores, I will PR the MetalFX later in the day, I'm still looking for more things to optimize for Bananza (and by extension, other games) on older GPUs to hopefully also get Docked mode run decently too.

image

Without MetalFX (the game's own upscaler):

image

With MetalFX:

image

This area used to run at 38ms GPU time (15-25FPS) before all the optimizations I implemented for Bananza

@seedds

seedds commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks, the register-spill measurements are convincing. I'm not landing it in tonight's unattended run, for two reasons. It's a chain change (every game's border-capable pixel shaders change MSL). And it adds a third side library (_bb) to every pixel shader with a border block, on top of _ba / _bl, which #757 already flags as the shader-build cost. That tradeoff is the maintainer's call. If it goes ahead, the numbers that would settle it are the shader-build time and the library bytes before/after for one big game (BotW or TotK), with regress.py chain on two games.

seedds added a commit that referenced this pull request Nov 11, 2026
…sampler with a twin: every pixel shader with border-colour corrections declares the function constant dm_border_off (DMSH_BORDER_FC, 216) and tests it before the mask bit in dm_bs, and the host specializes each function with it true when no sampler bound to the draw's stages has a border twin (border_need, pso_key.border), so the branch and the registers it kept live across every sample are gone from the pipeline instead of skipped per pixel. The mask branch made Apple's compiler keep the sample's operands for the correction that never ran, which spilled registers in shaders with many samples. A pipeline builder takes an explicit off flag from the draw's key (pso_build, pso_build_ex, pso_build_dev, pso_desc4, pbase_key, cps_new_b, the mesh and capture pipelines, the prewarm and pso_offline.py's archives), and the specialized function is found there (fn_off, fn4_of_b, fn_dev_b); descriptors hold only real Metal functions. A draw compares its stages' need with the bound pipeline's (prog_border), so a sampler with a twin bound after the pipeline was picked gets the correcting pipeline. DM_NVN_BORDER_KEEP=1 keeps the corrections in every pipeline and =ab alternates by DM_AB phases. Ocarina of Time standing on the forest deck, in-process A/B on an Apple M6: GPU p50 10.66-10.88 ms without the corrections, 11.01-11.26 ms with them, frame time 16.67 ms both. Compiled for the M1's GPU (applegpu_g13g, 300 of its pixel shaders): mean code 16,232 -> 14,531 bytes, per-thread scratch lower in 77 shaders, higher in 11, the worst 240-352 bytes -> 0. AdyaGMD's measurement of the side-library form on an M1, Splatoon Raiders: 31.6 -> 19.8 ms a frame; not reproduced here. t_border (113 checks), the 136 gfx suites, test_dmsh_border, test_pso_offline, test_docs pass; regress.py check on Bastion and NieR:Automata: 60 fps, every shader builds, no new red line. The side library of PR #1203 is not taken: it compiles every pixel shader with corrections twice. Not run: Tears of the Kingdom (the one known game that binds a twin; its build is not on hand), the iPhone, an M1. The function constant is FeReuss's design and code (PR #1929); the diagnosis of the spill and the M1 numbers are AdyaGMD's (PRs #1203, #1032); the need for a per-draw choice is givethesourceplox's finding (PR #1073)

Front-ends: the shader chain version changes; shaders are rebuilt.

Co-authored-by: FeReuss <102842515+FeReuss@users.noreply.github.com>
@seedds

seedds commented Nov 11, 2026

Copy link
Copy Markdown
Owner

The problem this PR found is fixed on main by 511a0a2, in the form of PR #1929: a function constant that the host sets when no bound sampler has a border twin, so the correction's branch is compiled out of the pipeline. The diagnosis (the mask branch keeps the sample's registers live and spills) and the M1 measurement of Splatoon Raiders, 31.6 -> 19.8 ms, are credited to you in the commit message.

The side library is not taken because on today's main it compiles every pixel shader that has corrections a second time and stores a second library for it, where the constant costs one specialization of the pipelines that need it. Compiled for the M1's GPU (applegpu_g13g), 300 of Ocarina of Time's pixel shaders have 10% less code with the constant set and 77 of them less per-thread scratch (the worst from 240-352 bytes to 0).

Not measured here: Splatoon Raiders or any game on an M1. If you can rerun your scene on main, the frame time there would settle whether the constant gives all of the 31.6 -> 19.8 ms. Thank you.

@seedds seedds closed this Nov 11, 2026
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