Skip to content

perf(shader): convert f16 operands with UnpackHalf2x16 where f16 denormals are kept - #3988

Merged
adriadam10 merged 1 commit into
boykopovar:mainfrom
karanvora2599:perf/shader-f16-unpack-half
Oct 13, 2026
Merged

adriadam10 merged 1 commit into
boykopovar:mainfrom
karanvora2599:perf/shader-f16-unpack-half

Conversation

@karanvora2599

@karanvora2599 karanvora2599 commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

What

On devices that can keep f16 denormals, f16 operands are converted to f32 with UnpackHalf2x16 again, instead of the integer decode #1463 introduced. Results are unchanged on every device, NaN payloads included.

Background.

  • fix(shader): convert f16 bits to f32 without UnpackHalf2x16 #1463 (58f596e) replaced UnpackHalf2x16 in EmitF16BitsToF32 with an integer decode of sign, exponent and mantissa. The reason was an AMD Windows driver that folds the mask of a byte-selected (SDWA BYTE_n) half into the conversion and converts the unmasked register.
  • EmitF16BitsToF32 runs for every f16 operand read (IR ConvertF32F16), f16 image and format data, and f16 interpolation, on every GPU. On NVIDIA the integer decode costs 8 to 12 instructions more per read than UnpackHalf2x16.
  • UnpackHalf2x16 alone isn't exact everywhere:
    • Without DenormPreserve for 16-bit floats, SPV_KHR_float_controls lets a driver flush subnormal halves. None of the three drivers here does, but nothing stops one.
    • NaN bits change. NVIDIA returns the default NaN 0x7fffffff for every NaN half (OpFConvert from a real f16 does the same), and the Intel UHD quiets them (0x7c01 → 0x7fc02000). The integer decode keeps sign and payload (0x7f802000).

How.

  • New target flag float16DenormPreserve, set when the device reports shaderDenormPreserveFloat16 and denormBehaviorIndependence is ALL: the same rule float64DenormPreserve uses since ebaa687. Like that flag, it is serialized (request version 17) and part of the cache key.
  • With the flag, EmitF16BitsToF32 uses UnpackHalf2x16 and declares DenormPreserve 16. SPV_KHR_float_controls requires that mode to keep "denormalized values obtained via unpacking an integer". The mode is declared once per module, shared with the native f32 → f16 conversion of feat(recompiler): convert f32 to f16 natively when the device rounds 16-bit floats to nearest even #1252.
  • A NaN result is replaced by bits built from the half: ((int)(half << 16) >> 3) | 0x7f800000 puts the sign at bit 31 and the payload at bits 22:13, which is what the integer decode returns.
  • Without the flag, the integer decode stays. That covers lavapipe (no f16 denormal preservation) and RADV, which reports 32_BIT_ONLY because AMD sets f16 and f64 denormals with one register field.
  • Halves of fields narrower than 16 bits (a BitFieldUExtract/SExtract of fewer than 16 bits under the ConvertF32F16, which is how SDWA byte selects arrive) keep the integer decode on every device. fix(shader): convert f16 bits to f32 without UnpackHalf2x16 #1463's case can't come back even if AMD's Windows driver reports ALL; I couldn't check what it reports.

Which GPUs.

  • NVIDIA: it reports shaderDenormPreserveFloat16 on Pascal through Blackwell, Pascal included even though it has no shaderFloat16, and INDEPENDENCE_ALL (NVK reports ALL for every generation it supports). The Intel UHD here reports both too.
  • Reviewers' runs: all three NVIDIA cards take the new path, and agc_driver_f16_to_f32_unpack passes with both decodes:
    • RTX 3080 Ti (Ampere), by Dextroid17;
    • RTX 5070 (Blackwell, Windows), by TideOPL;
    • RTX 5060 Ti (Blackwell, Linux), by Kikoarl.
  • AMD Windows (fix(shader): convert f16 bits to f32 without UnpackHalf2x16 #1463's case): TideOPL checked the AMD Windows driver on an integrated RDNA2 (gfx1036, amdvlk 23.20.44). It reports 32_BIT_ONLY, so it keeps the integer decode, and the test passes there with "integer decode only".

Before / after on an RTX 4060 Laptop (Ada, driver 591.86). SASS instructions per instruction from APS5_PIPELINE_STATS binary size over a chain of 16, FLOAT_MODE 0xc0:

Instruction main this PR UnpackHalf2x16 without the NaN rebuild
v_cvt_f32_f16 12.5 5.5 1.0
v_cmp_*_f16 21.0 11.5 4.0
v_add/sub/mul_f16 27.5 18.0 12.0
v_fma_f16 39.5 30.0 24.0
v_max/min_f16 35.0 26.5 / 26.0 19.5 / 20.0
v_med3_f16 90.5 72.5 67.5
v_pk_add/mul_f16 72.5 53.5 41.5
v_pk_fma_f16 110.0 91.0 79.5
v_fma_mix_f32 (f16 sources) 28.0 18.5 12.5
v_cvt_u16_f16 18.5 10.5 6.5

The f16 unary instructions (v_floor/ceil/trunc/rndne/fract/rcp/sqrt/rsq/exp/log/sin/cos_f16) each lose 8. 55 opcodes change; every other opcode in the sweep compiles to the same size.

Run time, dependent chains over 16,384 groups of 32 lanes, median of 15 interleaved dispatches in one process, flag off vs on:

Chain integer decode this PR
v_cvt_f32_f16 × 2048 3.95 ms 1.99 ms
v_fma_mix_f32 × 2048 10.16 ms 6.55 ms
v_max_f16 × 2048 20.35 ms 16.90 ms
v_pk_add_f16 × 2048 35.03 ms 29.18 ms
v_mul_f16 × 2048 17.61 ms 15.41 ms
v_add_f16 × 2048 17.06 ms 15.06 ms
v_pk_fma_f16 × 1024 20.81 ms 18.60 ms

Why results don't change.

  • f32 represents every half exactly. Under DenormPreserve 16, UnpackHalf2x16 returns that exact value for every non-NaN half, subnormals included, and like the integer decode it reads only the low 16 bits.
  • For a NaN half the rebuilt bits are sign << 31 | 0x7f800000 | payload << 13, the integer decode's special-value branch.
  • Byte-selected halves take the same integer decode as on main.

Tested

Windows 11, MinGW-w64 GCC 15.2.0, Release, main at a11c8f7 + #3291's fix (b54fe62) so that the BDA tests don't hang on driver 591.86. RTX 4060 Laptop (driver 591.86), Intel UHD Graphics (Raptor Lake, driver 101.6790), lavapipe from Mesa 25.2.8 (the version CI installs) and 26.2.4 (Windows builds, GALLIUM_OVERRIDE_CPU_CAPS=avx).

  • New agc_driver_f16_to_f32_unpack:
    • It converts all 65,536 halves, with nonzero high bits, through v_cvt_f32_f16, and the byte-selected half of each lane through v_add_f16_sdwa ... src0_sel:BYTE_1 with −0.
    • It compares every result with an exact decode, once per decode the device supports. The RTX 4060 and the Intel UHD run both decodes, lavapipe the integer decode.
  • New agc_driver_f16_to_f32_unpack_spirv (no GPU needed, so CI runs it). With the flag, the v_cvt_f32_f16 program declares DenormPreserve 16 and has one UnpackHalf2x16. The v_add_f16 SDWA program has one too, for its whole-register v7, while the byte-selected v4 is decoded with integers. Without the flag, neither program has either.
  • Negative checks:
    • Without the NaN rebuild, agc_driver_f16_to_f32_unpack fails on the RTX 4060 (v_cvt_f32_f16 of 0xd9a47c01 is 0x7fffffff, expected 0x7f802000) and on the Intel UHD (is 0x7fc02000).
    • Without the DenormPreserve 16 mode, agc_driver_f16_to_f32_unpack_spirv fails (v_cvt_f32_f16 lacks DenormPreserve 16 with float16DenormPreserve=true). The device test still passes on both GPUs, as neither driver flushes unpacked subnormal halves without the mode.
    • Sending byte-selected halves to UnpackHalf2x16 makes agc_driver_f16_to_f32_unpack_spirv fail with one UnpackHalf2x16 more than expected.
  • agc_shader_memory and agc_shader_disk_cache check the flag's serialization, the version 17 prefix, legacy requests and the cache key.
  • Shader tests (agc_driver_*, agc_shader_*, agc_spirv_*, vector_*, agc_masked_select, agc_validate_program, agc_denormal*, agc_float16_*; 325 tests) on a11c8f7 + b54fe62:
  • Rebased onto main 0a8b1d3 (this branch): the new tests, agc_shader_memory, agc_shader_disk_cache and the f16, SDWA, VOPC compare table, interpolation and mix tests (29) pass on the RTX 4060 and lavapipe 25.2.8, and on the Intel UHD all pass or are skipped except agc_driver_vop1_float_unary, as above.

Limits.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed in TechnicalDebt
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • AI-assisted: yes

@Kikoarl

Kikoarl commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Tested and verified on Linux (CachyOS x86_64) with an NVIDIA GeForce RTX 5060 Ti (Blackwell, driver 615.78.08, Vulkan 1.4):

  • Both new tests pass cleanly on physical hardware:
    • agc_driver_f16_to_f32_unpack: PASSED (0.45s)
    • agc_driver_f16_to_f32_unpack_spirv: PASSED (0.00s)
  • Full shader test suite (agc_driver_*, agc_shader_*, agc_spirv_*, vector_*, agc_masked_select, agc_validate_program, agc_denormal*, agc_float16_*; 325 tests total): 100% passed (41.57s).
  • Verified live on Astro Bot (PPSA21567): dynamic shader compilation and execution behave normally with zero SPIR-V validation issues or regressions.
  • tools/check_conventions.py passes clean.

Confirms support and correct behavior on Blackwell architecture and Linux. Great work! LGTM.

@Dextroid17 Dextroid17 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verification of the new tests on two devices, and one correction to the merge-order note

What I built and ran. Your head 977116c3 in a clean worktree, Release with ANYPS5_ENABLE_SPIRV_TOOLS=ON, GCC 16. Ran on lavapipe (Mesa 26.2.4, LLVM 23.1.1 — the CI device) and on an RTX 3080 Ti (Ampere — a generation your body says you could not test).

lavapipe   agc_driver_f16_to_f32_unpack_spirv         PASS   (GPU-free, so CI runs it)
lavapipe   agc_driver_f16_to_f32_unpack               PASS   "… (65536 halves, integer decode only)"
lavapipe   agc_shader_memory / agc_shader_disk_cache  PASS
3080 Ti    agc_driver_f16_to_f32_unpack               PASS   "… (65536 halves, both decodes)"
3080 Ti    agc_driver_f16_to_f32_unpack_spirv         PASS

The one line worth putting in the body is the 3080 Ti's: "both decodes" means the device reported float16DenormPreserve, so the UnpackHalf2x16 path ran with its NaN rebuild and every one of the 65,536 halves was compared against the exact decode, payloads included. That is the half you could not run yourself, and it holds on a second NVIDIA generation.

Negative control (mine). One token: force EmitF16BitsToF32 to always take EmitF16BitsToF32Decoded, tests kept.

agc_driver_f16_to_f32_unpack_spirv   FAIL
  AGC graphics: f16 to f32 unpack: v_cvt_f32_f16 lacks DenormPreserve 16 with float16DenormPreserve=true
git checkout 977116c3 -- core/shader/recompiler/SpirvBackend/src/SpirvModuleEmitter.cpp   →   PASS

That is the same failure your own DenormPreserve-off check reports — so this arm reproduces your control from an independent driver of the flag rather than adding a new failure mode. Worth saying plainly: what it establishes is that the SPIR-V test bites when the mode is dropped, not that it caught something new. In the same arm the device test still passes on lavapipe, which is correct rather than a gap: with the flag false the two forms are the same code there.

One correction: the version collision is a different PR. The body says "Request version 17 is also what #2440 used; whichever lands second takes 18." #2440 is closed and unmerged (2026-10-09) and never took 17 — its diff bumps the version 13 → 14, while its own body says 16. main is at 16 (version < 1u || version > 16u), so 17 is free today — but #1077 takes 17 as well, with the identical assertion string NVNQQREAAAA=. Naming #1077 instead would point a reviewer at the PR that can actually collide.

Series. #3557 is stacked on #3531 (its body says so: "Only the last commit is new"), and #4011 contains this PR's commit; #3590 merged earlier today. So one line in each body naming which of the others it overlaps would save a reviewer reconstructing the order — and #3531 was re-pushed during the day, so the shas a reader sees in an older review may no longer match.

What I could not verify. No AMD GPU here and no NVIDIA generation other than Ampere; and I verified equivalence, not your instruction counts — no APS5_PIPELINE_STATS before/after, so the 8–12 instructions per read and the runtime medians are your measurements, quoted rather than reproduced.

None of the above is a blocker on the devices I ran. The two things I could not close are the AMD gap (yours to state) and the version renumbering (#1077).

Hermes Agent 🤖 at your service — AI assistance: the build, the runs and this write-up were produced by an AI agent on the contributor's machine.

@TideOPL

TideOPL commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

I read through this and tested it on Windows with an NVIDIA card and AMD's Windows driver, since the description mentions both as open points.

Code

  • The NaN rebuild checks out: (int)(bits << 16) >> 3 puts the sign at bit 31 (its copies at bits 30 to 28 fall under 0x7f800000), and the mantissa at bits 22:13. The result is sign << 31 | 0x7f800000 | m << 13, the same as the integer decode.
  • Routing the native f32→f16 path from feat(recompiler): convert f32 to f16 natively when the device rounds 16-bit floats to nearest even #1252 through EmitF16DenormPreserve also stops a module that uses both from declaring DenormPreserve 16 twice.
  • NarrowF16Field only catches BitFieldU/SExtract with a count under 16. A narrow half that reaches the conversion another way (a BitwiseAnd 0xff, or through a Phi) would still use UnpackHalf2x16 on a device reporting INDEPENDENCE_ALL. As far as I can tell, no current AMD driver reports that (see below), so this is a note rather than a blocker.

AMD Windows driver (the open question from #1463)
The integrated Radeon Graphics of a Ryzen 7 7800X3D (RDNA2, gfx1036, AMD proprietary driver 23.20.44) reports denormBehaviorIndependence = SHADER_FLOAT_CONTROLS_INDEPENDENCE_32_BIT_ONLY (shaderDenormPreserveFloat16 = true). So float16DenormPreserve stays off there, and AMD on Windows keeps the integer decode. agc_driver_f16_to_f32_unpack passes on it with "integer decode only", including the SDWA BYTE_1 case. The RTX 5070 below reports INDEPENDENCE_ALL, so it takes the new path.

Tested by reviewer
Windows 11 (26200), WinLibs GCC 15.2.0 r7, Release. This PR merged onto main c5da823: current main doesn't build on Windows right now (HostPoll in Socket.cpp is only defined under #ifndef _WIN32, from 3ae2583).

RTX 5070 (Blackwell, 610.88) integrated Radeon (RDNA2, 2 CUs, gfx1036, amdvlk 23.20.44)
agc_driver_f16_to_f32_unpack pass (both decodes) pass (integer decode only)
agc_driver_f16_to_f32_unpack_spirv pass pass
f16 / SDWA / VOPC / interpolation / mix tests and agc_shader_memory, agc_shader_disk_cache (33) 33/33 26/33: the same 7 fail on main without this PR*
Balatro (LÖVE): fresh save, 90 to 120 s same as main: intro, then title menu same as main: title menu; the log ends with the same null call inside amdvlk64.dll as on main
Minecraft Dungeons II (UE5) same as main: studio logos, then the existing wave64 v_readlane_b32 lane-63 stop same as main: logos, then the existing packed pixel ancillary input stop

* On that AMD driver, on main too: agc_driver_div_fixup_f16, float16_dot, float16_fma_rounding, float16_rounding, float16_ternary_clamp, interpolation_f16 and lds_mixed_width_atomics. They're unrelated to this PR; I'll raise them separately.

This adds Blackwell to the Ada results in the description. I didn't measure performance: neither game gets far enough on main for a meaningful A/B, and an iGPU says nothing about speed. Not run: Linux, discrete AMD, other NVIDIA generations.

Looks good to me.

AI-assisted: yes. Written with AI assistance (Claude Code), with the tests run on my machine; I reviewed it.

@karanvora2599

Copy link
Copy Markdown
Contributor Author

Thanks @TideOPL, @Dextroid17 and @Kikoarl. Ampere and two Blackwell cards are now in the description.

@TideOPL, the AMD Windows run settles the open point from #1463: amdvlk reports 32_BIT_ONLY, so AMD keeps the integer decode. NarrowF16Field only matters if some driver later reports ALL. Agreed on its limit: a BitwiseAnd 0xff or a Phi of byte fields would get past it. Translation produces SDWA byte selects only as BitFieldUExtract/SExtract today, so I'd leave it narrow unless you'd rather I widen it.

@Dextroid17 you're right, #2440 never reached 17. #1077 and #4031 both take 17 and change the same agc_shader_memory assertions; the description now names them.

@blawesom

Copy link
Copy Markdown
Contributor

Rebase handoff — rebased onto current main, authorship unchanged.

8 files conflicted, all the same shape: main landed the registration-modules/fragment-descriptor-set plumbing through the recompiler's request/serializer/cache-key structures while this PR threads float16DenormPreserve through the same structures. The resolution keeps both — every field/serialization/cache-key entry from main plus this PR's f16 denorm field. The serializer tests keep both round-trip checks, and the legacy-trailer test now asserts both new flags unset.

Verified on Linux (CachyOS, kernel 7.2.9, GCC 16.2.1, Ninja, Release, BUILD_TESTING=ON, AMD RX 9070 XT / RADV):

  • builds; tools/check_conventions.py --base origin/main passes
  • full suite: 954/954 passed — including both the registration-modules and f16-denorm serialization round-trips

To pick it up:

git fetch https://github.com/blawesom/AnyPS5 rebase/3988
git checkout <your branch>
git merge --ff-only FETCH_HEAD

Head is 9b269a26d.

…rmals are kept

Since boykopovar#1463, every f16 operand read decodes sign, exponent and mantissa
with integer operations, 8 to 12 instructions more per read than
UnpackHalf2x16 on NVIDIA. The decode works around an AMD Windows driver
that folds the mask of a byte-selected half into UnpackHalf2x16.

A new target flag, float16DenormPreserve, is set when the device reports
shaderDenormPreserveFloat16 and denormBehaviorIndependence ALL, the same
rule as float64DenormPreserve. With it, EmitF16BitsToF32 uses
UnpackHalf2x16 and declares DenormPreserve for 16-bit floats, under
which SPV_KHR_float_controls keeps unpacked subnormal halves. NVIDIA
returns the default NaN for every NaN half, so the NaN bits are rebuilt
from the half: its sign at bit 31, its payload at bits 22:13. The
DenormPreserve 16 mode is declared once, shared with the native f32 to
f16 conversion. Without the flag (lavapipe; RADV reports 32_BIT_ONLY)
the integer decode stays. Halves of fields narrower than 16 bits, such
as SDWA byte selects, keep the integer decode on every device.

The flag is serialized (request version 17) and part of the cache key.

New test agc_driver_f16_to_f32_unpack converts all 65,536 halves, with
nonzero high bits, through v_cvt_f32_f16, and byte-selected halves
through v_add_f16 SDWA, with each decode the device supports, and
compares the bits with an exact decode. agc_driver_f16_to_f32_unpack_spirv
checks which decode the SPIR-V uses with and without the flag.
@adriadam10
adriadam10 force-pushed the perf/shader-f16-unpack-half branch from 977116c to e4f212b Compare October 13, 2026 10:28
@adriadam10

Copy link
Copy Markdown
Collaborator

Checked on RADV and CI's lavapipe, plus the HW oracle (gfx1035): v_cvt_f32_f16 over all 65,536 halves with IEEE=0 and f16 denormals kept equals the integer decode bit for bit, NaN payloads included, so results are unchanged.

main conflicted in the request/target plumbing (registration modules, vertex subgroups, request version 20), so I rebased: float16DenormPreserve is request version 21 now, and the agc_shader_memory assertions follow (version prefix, truncation range, unsupported version 22). Build and the agc tests pass, including the new agc_driver_f16_to_f32_unpack* and the f16/SDWA/compare tests on CI's lavapipe. Merging once the full Build is green.

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 13, 2026
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
#4451 core/libs/prx/libSceAgcDriver/tests/ShaderMemory.cpp L1832-1833
#5097 core/libs/prx/libSceAgcDriver/tests/ShaderMemory.cpp L1832-1833

@adriadam10
adriadam10 merged commit ff8fe4d into boykopovar:main Oct 13, 2026
8 checks passed
@Amoenus

Amoenus commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Found a request-version collision during independent CPU-only public-source checks: #5132.

All 12 fixtures emitted by original main075 as version21 throw RequestSerializer: truncated data on unmodified main140; all 24 current21 false/true requests round-trip. The F16 tail was added without changing the already-used version21 layout.

Writer/max/gate-to-22 alone is incomplete: all 12 current21 inputs with F16=true then silently deserialize as false. The issue includes a synthetic reproduction; no title or GPU failure is claimed.

Would you confirm this narrower compatibility rule: retain writer21, accept exactly zero remaining bytes as historical F16=false or one byte0/1 as the current F16 flag, and throw for other version-21 tails? It preserves both emitted layouts without taking #5097's version22. This is a proposal, not an implemented/tested repair. AI-assisted: yes.

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

Labels

agc libSceAgc* build shader core/shader

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants