Skip to content

HFMA2 rounds once to half, as Ampere does (chain change) - #1902

Open
fxd0h wants to merge 1 commit into
seedds:mainfrom
fxd0h:up/hfma2-round-once
Open

fxd0h wants to merge 1 commit into
seedds:mainfrom
fxd0h:up/hfma2-round-once

Conversation

@fxd0h

@fxd0h fxd0h commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

What and why

The translator writes HFMA2 as fma(a, b, c) on the halves unpacked to float, then packs with half2(float2(..)): two roundings. The hardware rounds a * b + c once. Measured on a Jetson Orin (sm_87, PTX fma.rn.f16x2) on Nov 7, 70 of 2,097,152 lanes were one half ULP off (99.9967% identical). For example, -3136 × -8.125 + 0.000124693 gives 25488 on the Orin and 25472 on Metal.

hfma_msl rewrites a half-only fma that is packed back to half into dmsh_hfma. That helper rounds the float result to odd from a TwoSum, so the conversion to half rounds once. It's compiled in safe math with contraction off, because fast math folds the TwoSum. FFMA → F2FP and HFMA2.F32 are left alone, since the hardware rounds twice there as well. Reference entry: HFMA2 rounds twice to half.

Checked

  • test_dmsh_hfma.py (8 tests). Its fixture holds 1,500 of the Orin's edge triples plus the 70 lanes. After the change, 0 of 2,097,152 lanes differ, in default, fast, safe and relaxed math. test_dmsh_hmma.py, test_nsp_chain.py and test_docs.py also pass.
  • M5 Max, macOS 27.2. No game run: the 70 lanes are rare inputs, and no picture change is expected.
  • It's a chain change, so every game's shaders that use HFMA2 rebuild.

@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 labels Nov 9, 2026
Know-It-All-Brother added a commit to Know-It-All-Brother/nxnative that referenced this pull request Nov 9, 2026
…ules with its functions, so a rewrite that uses one (struct in PR seedds#1902's hfma_msl) runs as it does in a build instead of raising NameError
@Know-It-All-Brother

Copy link
Copy Markdown
Contributor

I ran this branch through the Orin value test from #2041, together with #2049. ptxas compiles fma.rn.f16x2 to HFMA2.MMA, and without #2049 the fixture's HFMA2 ops don't translate at all. The test now picks up hfma_msl from finish(), so it runs your rewrite as a build would.

No regressions. Of the 193 ops, the only one that changes against main plus #2049 is hmma_16816_f16, and that's because this branch is based before #1897 (HMMA's once-per-8 rounding). It goes away on a rebase.

Your rewrite covers HFMA2.MMA too. Its MSL has the same shape as HFMA2's, and hfma_msl turns both lanes of the plain and .SAT forms into dmsh_hfma.

It doesn't fire on .RELU. The Orin keeps a NaN result under .RELU (PTX's .relu gives the canonical NaN), so #2049 changes .RELU from a plain max(v, 0) to keep the NaN. The MSL between the fma and the pack is now:

float _63 = fma(_51, _55, _59);
bool _69 = isnan(_63);
float _71 = fast::max(_63, 0.0);
float _73 = _69 ? _63 : _71;
...
uint _81 = as_type<uint>(half2(float2(_73, _79)));

Could hfma_msl also match this select form, applying the clamp to dmsh_hfma's result? If you'd rather keep the pattern simple, I can move .RELU in #2049 so it's applied after the pack instead. Just tell me which you prefer.

What this can't confirm. The fixture has 1,024 cases per op and none of the rare double-rounding inputs, so it doesn't prove the single rounding itself. Your 4,570-case fixture does that. What it does show is that nothing else moves.

Running it also turned up a bug in my test: it didn't give the rewrites dmsh_build.py's imports, and hfma_msl uses struct. That's fixed in #2041.

To rerun once #2041 is in: DMSH_TEST_XLATE=dm/gfx/tools/dmsh_xlate_bin/dmsh_xlate python3 dm/gfx/tests/test_dmsh_ampere_orin.py, with AMPERE_ORIN_ONLY=f16x2 for the half ops.

@Know-It-All-Brother

Copy link
Copy Markdown
Contributor

A heads-up about an interaction with #1774 (the shader loop guard), which I hit when I put this PR and #1774 on one branch together. Neither breaks on its own, but whichever lands second will.

loop_guard inserts uint dm_lg = 0u; (void)dm_lg; at the start of every function body, including dmsh_hfma. Metal only accepts #pragma METAL fp at file scope or at the start of a compound statement, so the helper's two pragmas stop compiling, and every shader with an HFMA2 fails to build:

error: '#pragma METAL fp' can only appear at file scope or at the start of a compound statement
#pragma METAL fp math_mode(safe)

Your test doesn't see it because its kernels don't go through loop_guard. The Orin suite in #2041 does, since it applies every rewrite in finish().

The fix I used, at the end of loop_guard, keeps a function's leading pragmas ahead of the guard:

return re.sub(r'(\n    uint dm_lg = 0u;[^\n]*)((?:\n#pragma [^\n]*)+)', r'\2\1', '\n'.join(out))

With that, this PR and #1774 build together, and test_dmsh_hfma.py, test_dmsh_loop_guard.py and the Orin suite all pass. Alternatively, loop_guard could skip functions that have no loop, since dmsh_hfma has none. Either way, it's probably worth a test case on whichever PR lands second.

Know-It-All-Brother added a commit to Know-It-All-Brother/nxnative that referenced this pull request Nov 9, 2026
…ules with its functions, so a rewrite that uses one (struct in PR seedds#1902's hfma_msl) runs as it does in a build instead of raising NameError

(cherry picked from commit 5a7bf54)
Know-It-All-Brother added a commit to Know-It-All-Brother/nxnative that referenced this pull request Nov 9, 2026
…eps a function's leading #pragma lines first so that helper compiles beside PR seedds#1774's loop guard (PR seedds#1902), with this branch's translator rebuilt from its own source; chain 681014b2ecf1
@fxd0h
fxd0h force-pushed the up/hfma2-round-once branch 2 times, most recently from ac76a7a to 6e08938 Compare November 9, 2026 22:28
@fxd0h

fxd0h commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

thanks for running it through the orin suite. rebased on main, and hfma_msl now also takes the isnan(v) ? v : max(v, 0) form, so you can keep .RELU as it is in #2049. moved the pragmas into a block of their own inside the helper, so a line loop_guard puts at the top of the body can't break them, whichever lands first. added tests for both

AmateurWizrd pushed a commit to AmateurWizrd/nxnative that referenced this pull request Nov 10, 2026
…ules with its functions, so a rewrite that uses one (struct in PR seedds#1902's hfma_msl) runs as it does in a build instead of raising NameError

(cherry picked from commit 5a7bf54)
…lf ULP off to 0, 99.9967 to 100 percent identical)

The translator writes HFMA2 as fma(a, b, c) in float on the unpacked halves and packs with
half2(float2(..)): two roundings. Measured against a Jetson Orin (sm_87, PTX fma.rn.f16x2,
1,048,576 random triples + 24,389 edge ones), 39 + 31 lanes of 1,048,576 each way differ by
one half ULP (-3136 * -8.125 + 0.000124693: Orin 25488, Metal 25472).

dmsh_build.py hfma_msl rewrites the fma whose three operands are halves (unpacked, negated,
or a literal a half holds) and whose every use ends in a pack to half (through .SAT's clamp and
.RELU's max) to dmsh_hfma: the float result rounded to odd, from a TwoSum of the exact
product and the addend, so the conversion to half rounds the exact value once. The helper
carries `#pragma METAL fp math_mode(safe)` and contract(off): without them fast math folds the
TwoSum and 26 of the 70 lanes stay wrong (17 + 9). An FFMA then F2FP and HFMA2.F32 are left
alone (the hardware rounds twice there as well). Translator binary and lock unchanged.

After: 0 of 2,097,152 lanes differ (100 percent), same in default, fast, safe and relaxed math.
Cost: a 16M-element kernel 45.6-48.0 vs 47.1-47.9 ps an element (memory bound, same); 34 of
244 fmas of an Ocarina of Time shader rewritten, +592 bytes of AIR. dmsh_chain.h moves to
acdba0750359.

test_dmsh_hfma.py: the rewrite, and on the GPU 4570 Orin outputs (the 70 differing triples, 3000
random, 1500 edge; numbers only in hfma_orin_fixture.u32) in every math mode; the old kernel
fails the same 70.
@fxd0h
fxd0h force-pushed the up/hfma2-round-once branch from 6e08938 to 843a35b Compare November 10, 2026 04:35
Fasen24-AI added a commit to Fasen24-AI/swordboiMacNative that referenced this pull request Nov 10, 2026
…nds once to half), seedds#1909 (fragment uniform reads bound to the binding length), seedds#1901 (dispatch_overlap on for Ocarina), seedds#1459 (MetalFX takes agl_ndsr_vr, no-curve variants linear), seedds#1903 (render scale stubs for Ocarina; replaces the seedds#1497 ceiling we carried)

Hand-resolved: the mesh draw keeps the task isolation of seedds#1842 and the copy paths our nvn_tex_level_range / DM_SHARPEN; seedds#1497's stub, test and BINDING entry go; the build-keyed rule names the render scale function (main+0x31d3200). Chain e906c7467690.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eD437mxrMnW2UwLZhpwc7

Upstream pull requests applied in this commit (their authors' work, brought onto this tree with a three-way merge; the description is in each PR):
- seedds#1459 by PromethOps (open): DM_UPSCALER=metalfx takes agl's agl_ndsr_vr.pck networks too, and a variant with no curve and no power is drawn as one colour space: Ocarina of Time's upscaler is MetalFX's
- seedds#1497 by givethesourceplox (open): Add a render-scale ceiling for Ocarina v131072
- seedds#1842 by ycarogabriel-lgtm (open): Ocarina of Time: opt-in task isolation (DM_NVN_ISOLATE_TASK), the Hi-Z task a5288 in command buffers of its own, a workaround for its GPU hangs on an M1
- seedds#1897 by Know-It-All-Brother (closed): HMMA with an f16 accumulator rounds once per 8 products, as Ampere does (chain change)
- seedds#1901 by FifteenPen (open): The game's compute dispatches with no game barrier between them run side by side (profile key dispatch_overlap, on for Ocarina of Time): Kokiri Forest GPU p50 15.63 → 14.76 ms
- seedds#1902 by fxd0h (open): HFMA2 rounds once to half, as Ampere does (chain change)
- seedds#1903 by FifteenPen (open): Ocarina of Time takes the launcher's Render scale: the 3D views at that fraction of the game's own size, the dynamic resolution held at its top level (Kokiri Forest docked at 0.75: GPU p50 15.77 → 14.31 ms, 60 fps held)
- seedds#1909 by oz-rinkliff (open): Bound translated fragment uniform reads to the NVN binding length
Fasen24-AI added a commit to Fasen24-AI/swordboiMacNative that referenced this pull request Nov 10, 2026
…1909 seedds#1897 seedds#1917 seedds#1920 seedds#1936) and seedds#1902, ahead of merging main

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eD437mxrMnW2UwLZhpwc7

Upstream pull requests applied in this commit (their authors' work, brought onto this tree with a three-way merge; the description is in each PR):
- seedds#1897 by Know-It-All-Brother (closed): HMMA with an f16 accumulator rounds once per 8 products, as Ampere does (chain change)
- seedds#1902 by fxd0h (open): HFMA2 rounds once to half, as Ampere does (chain change)
- seedds#1909 by oz-rinkliff (open): Bound translated fragment uniform reads to the NVN binding length
- seedds#1917 by FifteenPen (closed): A draw that writes depth and samples its own depth attachment reads a copy of it: Ocarina of Time's light-blue dashes over the rock at Death Mountain Trail's cave entrance are gone
- seedds#1920 by nawangwe (closed): A GPU-written span's sliver inside a texture does not make it the GPU's: Ocarina of Time's terrain tiles no longer vanish or show another tile's surface (seedds#1566)
- seedds#1936 by TheGoldfish11 (closed): Write resolution-family image textures uncompressed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation 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