Round low precision float constants when storing them - #4374
Wint3rNight wants to merge 12 commits into
Conversation
|
|
||
| namespace { | ||
|
|
||
| // IEEE 754 binary16 <-> binary32, rounding toward zero on the way down. |
There was a problem hiding this comment.
Would be nice to use hex_float.h if possible.
There was a problem hiding this comment.
Ah nice, that works — no build changes needed and it gives the same values. Switched.
|
|
||
| TConstUnionArray unionArray(1); | ||
| unionArray[0].setDConst(d); | ||
| unionArray[0].setDConst(RoundToDeclaredPrecision(d, baseType)); |
There was a problem hiding this comment.
I think we need to do this for all setDConst, so it would probably make sense to do it based on the TConstUnion's type (and let it be floating point types other than double)
There was a problem hiding this comment.
Yeah that's better, catches folded results too. Looks like setDConst needs to stop hardcoding EbtDouble and the switches in ConstantUnion.h only handle double right now. Having a go at it.
There was a problem hiding this comment.
Pushed. One thing, I left EbtFloat mapping to EbtDouble instead of giving it its own tag. A double holds a float exactly so there's nothing to round, and when I did tag it separately a bunch of stuff broke that was using type == EbtDouble to mean "is a float" sign() went down the int path, struct compares came out false. So only the narrower types get their own tag now. Can switch it if you'd rather.
There was a problem hiding this comment.
using type == EbtDouble to mean "is a float"
IMO these sort of things are worth fixing. It would be nice to see what the complete changes needed would look like before preemptively making these kind of concessions.
There was a problem hiding this comment.
Fair enough, did the full version.
The one I'd missed was promoteConstantUnion, it's how an int literal in an initializer becomes a float, and it wasn't passing a type, so {3, ...} came out EbtDouble while 3.0 was EbtFloat. With that fixed the EbtFloat special case isn't needed, so it's gone. Five places total were using type == EbtDouble to mean "is a float".
e24c910 to
631223c
Compare
631223c to
952d56f
Compare
|
I have not had a chance to do a detailed review, but I did point codex at the change and asked it to review. Please check on these:
|
castTo checked supportsInfinity() for the destination format but not for the source, so casting from one of the MX formats treated its largest exponent as Inf or NaN. Converting e2m1 6.0 to float produced a NaN and e2m1 4.0 produced an infinity, even though both are ordinary values in that format.
…ther low-precision formats Follow-up to review on KhronosGroup#4374. - floatBitsTo*/*BitsToFloat are shared with the bfloat16 and 8-bit float built-ins, which are not 32 bits wide. Folding those as float produced the wrong bits, so decline to fold them. - intBitsToFloat, uintBitsToFloat, clamp and mix stored their result without the declared type, so it kept the default EbtDouble and compared unequal to a literal of the same value. - RoundToDeclaredPrecision now covers bfloat16, e5m2, e4m3, e2m1, e3m2, e2m3, ue8m0 and mxint8, each matching the corresponding Builder::make*Constant.
|
Fixed all four. Low-width bit-casts — declined to fold them, those enums are shared with the bfloat16/8-bit built-ins. Came in with #4373 which is already merged, so happy to split it out. intBitsToFloat and the clamp/mix paths — same root cause, any setDConst without the type compares unequal now. 7 sites, incl. int64BitsToDouble/uint64BitsToDouble. The other formats — did all of them. Ran into a separate bug doing it: castTo checks supportsInfinity() on the destination but not the source, so converting e2m1 6.0 to float gives a NaN and 4.0 gives an inf. Split that into its own commit since it's not really this PR's problem. No golden changes from it. |
|
A few more comments from codex:
folds to 0x7fe12345, rather than preserving 0x7fa12345. NaN encodings should remain unfolded unless TConstUnion can retain their raw 32-bit representation.
|
|
All fixed, thanks. castTo now uses the format's own isNan()/isInfinity(), and a NaN converted into e4m3 encodes as all ones. Fixed the test comment too. |
- castTo classified NaN and Inf with the IEEE bit pattern, gated on supportsInfinity(). e4m3 has a NaN but no infinity, so its NaN was read as an ordinary value, and a NaN converted into e4m3 was encoded as 0x7C, which is 384.0. Classify with the format's own isNan() and isInfinity(), and encode NaN into a format without an infinity as all ones, its only NaN encoding if it has one. - Bit casting a NaN folded through the double the constant is stored in, and the float-to-double conversion quiets a signaling NaN: floatBitsToUint(uintBitsToFloat(0x7fa12345u)) folded to 0x7fe12345. Decline to fold signaling NaN patterns so those bits come from the real instruction. A quiet NaN survives the round trip exactly, so it keeps folding and a NaN in a const initializer keeps compiling. - ue8m0 was folded by masking the float's exponent bits, which reads encoding 0x00 as 0.0 and 0xFF as infinity. The format has no zero and no infinity: 0x00 is 2^-127 and 0xFF is NaN. Decode the encoding the back end emits explicitly instead. - mxint8 has no NaN encoding at all, and makeFloatMXINT8Constant cast a NaN to int, which is undefined and lands on encoding 0x00. Pin a NaN to zero deterministically on both sides so the folded value and the emitted constant agree. - castTo shifted a widening NaN payload in the narrow source type, so the payload truncated to nothing and the fallback emitted payload 1, a signaling NaN. Shift in a type wide enough for either format.
266180b to
d04fe78
Compare
|
codex is basically happy with it now. I still haven't had time to manually review, sorry. The three previous correctness issues are fixed. I found one remaining, lower-priority completeness gap:
GLSL permits—but does not strictly require—additional pure built-ins such as this to be treated as constant expressions, so I would not call this a core-spec violation. GLSL |
|
Thanks Jeff, glad the correctness issues are resolved. Agreed on the sNaN gap. The decline is there because the double storage would quiet the signaling bit, so the folded pattern wouldn't match what the instruction produces. Fixing it properly probably means retaining raw float bits in TConstUnion rather than routing through double. Would you prefer that addressed in this PR, or is it cleaner as a separate change? Happy to go either way. |
|
I think people would generally consider this value-dependent behavior to be unexpected and a bug. So if it's a regression from this change, then I think it should be fixed as part of this change. |
intBitsToFloat/uintBitsToFloat now store the exact 32-bit pattern via a supplementary rawFloatBits field in TConstUnion, so signaling NaN payloads survive without passing through the float-to-double widening that quiets them. Removes the isFloatSignalingNanPattern guard.
|
Fixed, added a rawFloatBits field to TConstUnion so the exact 32-bit pattern from *BitsToFloat is preserved without going through double. isFloatSignalingNanPattern is gone. |
|
codex says: The signaling-NaN constant-expression issue is fixed, but the raw-bit implementation introduces one correctness regression:
|
Numeric comparison read the raw bits whenever both operands carried them, which inverted two IEEE results: +0.0 == -0.0 folded to false, and a NaN compared equal to itself. Comparison goes back through dConst, which gets both right, and the raw bits stay out of it. Negate and abs had the opposite problem. They folded through the double, which quiets a signaling NaN, but IEEE 754 makes them sign-bit operations that leave the payload alone. They now flip or clear the sign in the raw bits when the constant carries them.
|
Yep, that's a real bug, fixed. operator== doesn't touch the raw bits anymore, it compares dConst like it used to, so +0.0 == -0.0 is true again and a NaN isn't equal to itself. Raw bits are only read by the bitcast folds and the SPIR-V emitter now. While I was in there I noticed abs() and unary minus had the opposite problem, going through the double meant they were quieting sNaN payloads, and IEEE treats those two as sign-bit ops. Fixed them to flip/clear the sign bit directly instead. Tests for both in constFold.frag, and the negated sNaN in spv.constFoldLowPrecision.comp so the emitted OpConstant is covered too. |
|
codex found two more issues with NaN handling:
|
c178989 to
499569a
Compare
|
I think the main remaining things are to round to fp32 when baseType == EbtFloat is used, and to remove the default baseType = EbtDouble and make sure we always pass in the correct type. |
|
Both done. Removing the default turned up a real one: HLSL clip() was calling setDConst(0) on the integer path, building a double-typed union for an integer compare. Mirrored the scalar path below it. spv.double.comp declares pi four times with LF on two of them, we were folding all four to the same value and deduplicating them, so it had never actually tested anything. That's the 60 → 61 id bound. 16 baselines regenerated, all fp32 rounding. Failure set unchanged. |
5eaa931 to
0f9a9fe
Compare
RoundToDeclaredPrecision handled every narrow float format but returned EbtFloat unrounded, so a fold that overflowed or lost bits in fp32 kept double precision until emission: (1e20 * 1e20) / 1e20 folded to a finite 1e20 where the target produces inf. Remove the EbtDouble default from setDConst so callers must pass the type the value was declared as, and assert it. Four HLSL call sites relied on the default; two built a double-typed union for an integer comparison, which the scalar path alongside them already got right.
0f9a9fe to
41f6946
Compare
|
codex found two more minor issues:
|
MXINT8 is fixed point with no signed zero -- makeFloatMXINT8Constant ends in a cast to int, which drops the sign, so -0.0 is emitted as encoding 0. The fold kept the sign bit, leaving folding and emission disagreeing. The other formats are unaffected: the sign-magnitude ones represent -0.0 legitimately and ue8m0 has no zero at all. HLSL clip()'s vector path builds its zero with arg0's TType, so the union has to match arg0's basic type; it was always EbtInt, which put an int zero inside a uint constant. Switch over the integer widths. The scalar path is fine as it stands -- it hands addBinaryMath a plain int and promotion resolves it.
|
Both fixed. Checked the other ten formats for the same −0.0 leak, the sign-magnitude ones (bfloat16, e5m2, e4m3, e2m1) legitimately keep it and ue8m0 has no zero at all, so mxint8 was the only one. Test pins both directions so a broader "canonicalize every zero" would fail it. clip() now switches over every integer width rather than just uint. The scalar path turned out to be fine, it hands addBinaryMath a plain int and the usual promotion fixes it up; only the vector path forces the TType with no conversion step. |
|
Thanks. codex didn't find any more issues. I skimmed through the code and it generally looks ok to me, though I haven't thought about it in great detail. |
|
Thanks for pushing on those. I have updated the pr description, it was still describing the original fp16-only version. |
| #include "SymbolTable.h" | ||
| #include "propagateNoContraction.h" | ||
|
|
||
| #include "SPIRV/hex_float.h" |
There was a problem hiding this comment.
We have avoided including dependencies from the SPIRV/ directory in files under the glslang/ tree because, among other things, the SPIRV/ directory is not built at all if the ENABLE_SPIRV cmake option is disabled. I think the solution here might be to just move the hex_float files somewhere under the main glslang/ hierarchy.
There was a problem hiding this comment.
Moved hex_float.h and bitutils.h to glslang/Include/
| 0:10 'w6' ( temp double) | ||
| 0:10 Constant: | ||
| 0:10 1.2345678901235e+15 | ||
| 0:10 1.2345679481405e+15 |
There was a problem hiding this comment.
While this change in result is absolutely correct per the spec, I am a little big concerned that this is going to cause regressions in user shaders that implicitly relied on an unsuffixed float literal being parsed as a double and maintaining that precision. The fact that glslang's own test suite relies on this behavior does not make me optimistic that we can get away with this without causing regressions for users.
There was a problem hiding this comment.
I dropped the rounding for float/double and kept it just for the sub 32-bit types. If you'd rather have float rounding behind an opt-in flag, say so and I'll do that instead.
Fixes #4241.
TConstUnionstores every float type in adouble, so a constant keeps bits its declared typecan't hold, and those bits then take part in constant folding:
As suggested in the issue, this rounds to the declared precision and stores back as a double
rather than adding a union member per float type. The rounding lives in
RoundToDeclaredPrecision, called fromTConstUnion::setDConst, so a constant is rounded oncewhen it's stored and every later fold sees the value the back end will emit.
It started as fp16 only and grew to cover every float type, because each one left out turned out
to be the same bug in a different format:
Builder::make*ConstantperformsEbtFloatrounds too, so an intermediate that overflows or loses bits in fp32 does so while folding:(1e20*1e20)/1e20isinfnow, not a finite1e20intBitsToFloat/uintBitsToFloatkeep the exact 32-bit pattern in a supplementary raw-bits field, since float→double widening quiets an sNaNmin/max/clamp/mix-with-bool/faceforwardreturn an operand rather than computing one, so they copy the wholeTConstUnion;<=/>=are spelled as disjunctions so they stay false for unordered operands-0.0to+0.0to matchsetDConst'sbaseType = EbtDoubledefault is gone and the invariant is asserted; four HLSL call sites relied on it, two of which built a double-typed union for an integer comparisonspv.double.compis the clearest case. It declares pi four times withLFon two of them,specifically to tell suffixed and unsuffixed apart — and all four were folding to the same value
and being deduplicated into a single SPIR-V constant, so the test had never been able to check
what it was written for. That's the id bound going 60 → 61.
lf-suffixed literals keep fulldouble precision, which is the control that shows the rounding isn't over-applied.
Tests:
Test/constFoldFloat16.fragandTest/spv.constFoldLowPrecision.compare new, plusadditions to
Test/constFold.frag. The low-precision file pins both directions, mxint8 has tocanonicalize
-0.0and e5m2 has to keep it so, an over-broad "canonicalize every zero" changefails it.
Both ctest jobs are green with
ENABLE_OPT=ON:glslang-gtests2018/2018 andglslang-testsuiteclean. Each fix was reverted on its own to confirm a specific test goes red.