fix(shaders): a loop that leaves by predicated EXITs ends at its closing self-branch (the last program that did not lower) #1810

Merged
Joey-Magnus merged 1 commit from fix/lower-end-at-closing-branch into main 2026-11-10 18:19:56 +00:00
Joey-Magnus commented 2026-11-10 17:21:07 +00:00 (Migrated from github.com)

The last of OoT's programs that fail to translate (f24a3ebf686232fb, a compute program with matrix multiplies) is refused as "native program has no unconditional EXIT". It's a loop that leaves only through @P0 EXIT, so it has no unconditional EXIT. It ends the way NVIDIA's compiler closes such programs: the loop's unconditional BRA back, then a BRA to itself that nothing reaches.

The end of the program (instructions 1808-1809, then NOP padding to 1823; its five EXITs at 646, 956, 1244, 1525 and 1528 are all @P0):

1808  00007947 ffffb710 0383ffff 000fea00   BRA 642     (the loop, unconditional)
1809  00007947 fffffff0 0383ffff 000fc000   BRA 1809    (the closing self-branch)

Change (lower_to_maxwell.h, BRA): the closing self-branch already ended a program after a jump table (computed_branch). It now also ends one when the instruction before it is an unconditional BRA or EXIT, so nothing can fall into it, and the program makes no calls. finish still refuses if any branch points past the end. A self-branch that can be fallen into is still not an end, and programs that end at an unconditional EXIT end there as before.

Tests

  • New tests/test_lower_end_at_closing_branch.py: @P0 EXIT; BRA 0; BRA self lowers and ends at the self-branch (refused on main). After a NOP, the self-branch is no end. @P0 EXIT; EXIT; BRA self ends at the EXIT as before.
  • run_all.py: all pass except nx2/test_nup_stand_in.py, red on main (fixed by #1790).
  • In game (Linux, RTX 4070 Ti, 595.99.02, tensor emulation on as by default), stacked on #1769/#1778/#1789/#1800/#1806: no program fails to lower any more (10 this morning). The startup build's failed programs are 22 → 1; the one left is a saved seed whose stage could not be rebuilt, not a translation failure.

🤖 Generated with Claude Code

The last of OoT's programs that fail to translate (`f24a3ebf686232fb`, a compute program with matrix multiplies) is refused as "native program has no unconditional EXIT". It's a loop that leaves only through `@P0 EXIT`, so it has no unconditional EXIT. It ends the way NVIDIA's compiler closes such programs: the loop's unconditional `BRA` back, then a `BRA` to itself that nothing reaches. **The end of the program** (instructions 1808-1809, then NOP padding to 1823; its five EXITs at 646, 956, 1244, 1525 and 1528 are all `@P0`): ``` 1808 00007947 ffffb710 0383ffff 000fea00 BRA 642 (the loop, unconditional) 1809 00007947 fffffff0 0383ffff 000fc000 BRA 1809 (the closing self-branch) ``` **Change** (`lower_to_maxwell.h`, BRA): the closing self-branch already ended a program after a jump table (`computed_branch`). It now also ends one when the instruction before it is an unconditional BRA or EXIT, so nothing can fall into it, and the program makes no calls. `finish` still refuses if any branch points past the end. A self-branch that can be fallen into is still not an end, and programs that end at an unconditional EXIT end there as before. **Tests** - New `tests/test_lower_end_at_closing_branch.py`: `@P0 EXIT; BRA 0; BRA self` lowers and ends at the self-branch (refused on `main`). After a NOP, the self-branch is no end. `@P0 EXIT; EXIT; BRA self` ends at the EXIT as before. - `run_all.py`: all pass except `nx2/test_nup_stand_in.py`, red on `main` (fixed by #1790). - In game (Linux, RTX 4070 Ti, 595.99.02, tensor emulation on as by default), stacked on #1769/#1778/#1789/#1800/#1806: **no program fails to lower any more** (10 this morning). The startup build's failed programs are 22 → 1; the one left is a saved seed whose stage could not be rebuilt, not a translation failure. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 commented 2026-11-10 17:50:57 +00:00 (Migrated from github.com)

Heads-up: this conflicts with main now (a lot landed today). Could you merge main into the branch (or rebase) and resolve the conflicts? Thanks!

Heads-up: this conflicts with main now (a lot landed today). Could you merge main into the branch (or rebase) and resolve the conflicts? Thanks!
hckrman101 (Migrated from github.com) approved these changes 2026-11-10 18:13:25 +00:00
hckrman101 (Migrated from github.com) left a comment

Nice small fix, and the test pins the case down well. I traced the rule. With no calls and no jump table, every transfer in the program is a relocation, and the self-branch is emitted into the output too. So a thread that somehow reached it would spin in both versions, and ending the capture there doesn't change what the program does. Programs that end at an unconditional EXIT still finish there first, so the new path only fires when that EXIT had a forward branch pending, or when the program has no unconditional EXIT at all (this one).

On #1830: it changes the same spot. Its rule is broader (any unconditional self-branch, once no relocation or call target lies past it), and it also looks sound. I'd land this one, though: it's focused, has a test, and merges cleanly, while #1830 is a large mixed branch that conflicts with main and still has blocking issues. #1830 should drop its copy when it rebases.

One thing worth taking from #1830 (non-blocking, see inline): check for pending forward branches before finishing. Without that check, a mid-program EXIT; BRA self with code after it that a branch reaches now fails in finish, where before it carried on and lowered. I don't know of a program shaped like that (NVIDIA puts the self-branch at the very end), so I wouldn't hold the PR for it.

Nit: the third test case only checks that {@P0 EXIT, EXIT, BRA self} lowers. CHECK(r.source_size == 2 * 16) would show that it ends at the EXIT, as the comment says.

CI is still pending. The branch is 33 commits behind main but merges cleanly. If CI shows main's known failures, a merge of main should clear them.

Nice small fix, and the test pins the case down well. I traced the rule. With no calls and no jump table, every transfer in the program is a relocation, and the self-branch is emitted into the output too. So a thread that somehow reached it would spin in both versions, and ending the capture there doesn't change what the program does. Programs that end at an unconditional EXIT still finish there first, so the new path only fires when that EXIT had a forward branch pending, or when the program has no unconditional EXIT at all (this one). On #1830: it changes the same spot. Its rule is broader (any unconditional self-branch, once no relocation or call target lies past it), and it also looks sound. I'd land this one, though: it's focused, has a test, and merges cleanly, while #1830 is a large mixed branch that conflicts with main and still has blocking issues. #1830 should drop its copy when it rebases. One thing worth taking from #1830 (non-blocking, see inline): check for pending forward branches before finishing. Without that check, a mid-program `EXIT; BRA self` with code after it that a branch reaches now fails in `finish`, where before it carried on and lowered. I don't know of a program shaped like that (NVIDIA puts the self-branch at the very end), so I wouldn't hold the PR for it. Nit: the third test case only checks that `{@P0 EXIT, EXIT, BRA self}` lowers. `CHECK(r.source_size == 2 * 16)` would show that it ends at the EXIT, as the comment says. CI is still pending. The branch is 33 commits behind main but merges cleanly. If CI shows main's known failures, a merge of main should clear them.
@ -3412,1 +3416,4 @@
};
const bool after_jump = index > 0 && call_targets.empty() && unconditional_jump(code[index - 1]);
if ((computed_branch || after_jump) && target == s64{index} * 16 && branch_predicate == (u64{7} << 16))
return finish(index); // the closing self-branch
hckrman101 (Migrated from github.com) commented 2026-11-10 18:13:25 +00:00

Suggestion (non-blocking): finish fails when any relocation targets past index, so a self-branch that isn't the real end now refuses the program instead of carrying on. Say @P1 BRA L; ...; EXIT; BRA self; L: ...; EXIT: main lowers that, and this PR fails it with "native branch or call leaves captured program". #1830's guard avoids that:

const bool pending = std::any_of(relocations.begin(), relocations.end(),
    [&](const Relocation& r) { return r.target > index; });
if ((computed_branch || (after_jump && !pending)) && ...)

(I left the jump-table path as it is.)

Suggestion (non-blocking): `finish` fails when any relocation targets past `index`, so a self-branch that isn't the real end now refuses the program instead of carrying on. Say `@P1 BRA L; ...; EXIT; BRA self; L: ...; EXIT`: main lowers that, and this PR fails it with "native branch or call leaves captured program". #1830's guard avoids that: ```cpp const bool pending = std::any_of(relocations.begin(), relocations.end(), [&](const Relocation& r) { return r.target > index; }); if ((computed_branch || (after_jump && !pending)) && ...) ``` (I left the jump-table path as it is.)
Commenting is not possible because the repository is archived.
No description provided.