[Mlir-commits] [llvm] [mlir] [APFloat] Report the sign and the zero a conversion cannot represent (PR #216056)
Hung Kuan Tseng
llvmlistbot at llvm.org
Thu Aug 13 07:09:37 PDT 2026
https://github.com/Tim096 created https://github.com/llvm/llvm-project/pull/216056
Fixes #215445, in the direction @tgymnich confirmed there.
`APFloat::convert` reports through `losesInfo` what rounding lost, but not what
the target format has no encoding for at all. Two properties of a format are not
rounding:
| property | formats today | what happens |
|---|---|---|
| `hasSignedRepr == false` | `f8E8M0FNU`, `f8E5M3FNU` | the sign bit is carried into a format with no room for it |
| `hasZero == false` | `f8E8M0FNU` | zero is replaced by the smallest normalized value, 2^-127 |
Both were reported as `opOK` with `losesInfo == false`. Callers gate on
`losesInfo` -- that is how `arith.truncf`'s folder decides whether a constant
fold is legal -- so they kept a value the format cannot hold.
### What went wrong in MLIR
```mlir
%c = arith.constant -2.000000e+00 : f32
%t = arith.truncf %c : f32 to f8E8M0FNU // --canonicalize
```
folded, and printing the folded constant reached `convertFromString`, whose
leading-minus path is an `llvm_unreachable`:
```
This floating point format does not support signed values
UNREACHABLE executed at llvm/lib/Support/APFloat.cpp:3190!
```
The zero case did not crash, it disagreed: the folder produced 2^-127, while
`--arith-expand=include-f8e8m0` reads the same `0x00` encoding back as `0.0`.
### The two commits
1. **`APFloat`**: report both as `opInexact` with `losesInfo` set. The values
that come out are unchanged, only the status is. The NaN path is restructured
to fall through to the common tail instead of returning early, so the checks
see every conversion. `arith.truncf` then leaves both constants alone.
2. **MLIR parser**: `arith.constant -2.000000e+00 : f8E8M0FNU` needs no pass at
all -- parsing and printing the file was enough to assert, because the
attribute is built with `FloatAttr::get` and never consults `losesInfo`. Both
literal paths now emit a diagnostic:
```
error: negative floating point literal for a type with no signed representation
```
### One test changed on purpose
`APFloatTest.ConvertDoubleToE8M0FNU` pinned the old status for the zero case:
```cpp
// For E8M0, zero encoding is represented as the smallest normalized value.
EXPECT_FALSE(losesInfo);
EXPECT_EQ(status, APFloat::opOK);
```
The substituted value is kept -- only the two lines above change, since 2^-127
is not the value that went in. Please say if that substitution is meant to be
reported as exact; everything else here follows from it.
### Not covered
Writing `0.0 : f8E8M0FNU` as a literal still yields 2^-127 silently. That is a
value question rather than a crash, and rejecting the literal is a bigger
decision than this patch should take; the folder no longer produces it.
### Verification
| check | result |
|---|---|
| `ADTTests` | 2188 passed, 0 failed |
| `check-mlir` | 3848 passed, 0 failed (611 unsupported, 1 expectedly failed) |
| `git clang-format` | clean |
Both reproducers from the issue were rerun: the fold is gone (`-2.0` and `0.0`
into `f8E8M0FNU`, and `-2.0` into `f8E5M3FNU` all stay as `arith.truncf`), and
the literal now produces a diagnostic instead of an abort.
>From 77bf8c72735af16f793ba40292be161cefad920e Mon Sep 17 00:00:00 2001
From: Hung-Kuan Tseng <tseng.tim096 at gmail.com>
Date: Thu, 13 Aug 2026 22:08:48 +0800
Subject: [PATCH 1/2] [APFloat] Report the sign and the zero a conversion
cannot represent
`convert` reports through `losesInfo` what rounding lost, but not what the
target format has no encoding for at all. Two properties are not rounding:
- `hasSignedRepr == false` (`f8E8M0FNU`, `f8E5M3FNU`): the sign bit is
carried into a format that has no room for it. Printing the result
reaches `convertFromString`, which is `llvm_unreachable` for a leading
minus.
- `hasZero == false` (`f8E8M0FNU`): zero is replaced by the smallest
normalized value, 2^-127, at the end of `convert`.
Both were reported as `opOK` with `losesInfo == false`, so a caller that
gates on `losesInfo` -- which is how MLIR's `arith.truncf` folder decides
whether a constant fold is legal -- kept a value the format cannot hold.
Folding `arith.truncf %cst : f32 to f8E8M0FNU` for a negative constant
therefore crashed on print, and for `0.0` it produced 2^-127 while
`--arith-expand=include-f8e8m0` reads that same encoding back as `0.0`.
Report both as `opInexact` with `losesInfo` set. The values that come out
are unchanged; only the status is. The NaN path is restructured to fall
through to the common tail rather than return early, so that these checks
see every conversion.
`ConvertDoubleToE8M0FNU` pinned the previous status for the zero case; it
now expects the loss to be reported, with the substituted value unchanged.
---
llvm/lib/Support/APFloat.cpp | 64 ++++++++++++++---------
llvm/unittests/ADT/APFloatTest.cpp | 58 +++++++++++++++++++-
mlir/test/Dialect/Arith/canonicalize.mlir | 24 +++++++++
3 files changed, 118 insertions(+), 28 deletions(-)
diff --git a/llvm/lib/Support/APFloat.cpp b/llvm/lib/Support/APFloat.cpp
index 63007d88ed4d4..c7f776d257936 100644
--- a/llvm/lib/Support/APFloat.cpp
+++ b/llvm/lib/Support/APFloat.cpp
@@ -2548,35 +2548,36 @@ APFloat::opStatus IEEEFloat::convert(const fltSemantics &toSemantics,
*losesInfo =
fromSemantics.nonFiniteBehavior != fltNonfiniteBehavior::NanOnly;
makeNaN(false, sign);
- return is_signaling ? opInvalidOp : opOK;
- }
-
- // If NaN is negative zero, we need to create a new NaN to avoid converting
- // NaN to -Inf.
- if (fromSemantics.nanEncoding == fltNanEncoding::NegativeZero &&
- semantics->nanEncoding != fltNanEncoding::NegativeZero)
- makeNaN(false, false);
-
- // If the source has no significand, there are no payload bits to carry
- // over, and an all-zero significand would encode an Inf. Create a new NaN.
- if (!APFloat::hasSignificand(fromSemantics))
- makeNaN(false, sign);
+ fs = is_signaling ? opInvalidOp : opOK;
+ } else {
+ // If NaN is negative zero, we need to create a new NaN to avoid
+ // converting NaN to -Inf.
+ if (fromSemantics.nanEncoding == fltNanEncoding::NegativeZero &&
+ semantics->nanEncoding != fltNanEncoding::NegativeZero)
+ makeNaN(false, false);
+
+ // If the source has no significand, there are no payload bits to carry
+ // over, and an all-zero significand would encode an Inf. Create a new
+ // NaN.
+ if (!APFloat::hasSignificand(fromSemantics))
+ makeNaN(false, sign);
- *losesInfo = lostFraction != lfExactlyZero || X86SpecialNan;
+ *losesInfo = lostFraction != lfExactlyZero || X86SpecialNan;
- // For x87 extended precision, we want to make a NaN, not a special NaN if
- // the input wasn't special either.
- if (!X86SpecialNan && semantics == &APFloatBase::semX87DoubleExtended)
- APInt::tcSetBit(significandParts(), semantics->precision - 1);
+ // For x87 extended precision, we want to make a NaN, not a special NaN
+ // if the input wasn't special either.
+ if (!X86SpecialNan && semantics == &APFloatBase::semX87DoubleExtended)
+ APInt::tcSetBit(significandParts(), semantics->precision - 1);
- // Convert of sNaN creates qNaN and raises an exception (invalid op).
- // This also guarantees that a sNaN does not become Inf on a truncation
- // that loses all payload bits.
- if (is_signaling) {
- makeQuiet();
- fs = opInvalidOp;
- } else {
- fs = opOK;
+ // Convert of sNaN creates qNaN and raises an exception (invalid op).
+ // This also guarantees that a sNaN does not become Inf on a truncation
+ // that loses all payload bits.
+ if (is_signaling) {
+ makeQuiet();
+ fs = opInvalidOp;
+ } else {
+ fs = opOK;
+ }
}
} else if (category == fcInfinity &&
semantics->nonFiniteBehavior == fltNonfiniteBehavior::NanOnly) {
@@ -2596,6 +2597,17 @@ APFloat::opStatus IEEEFloat::convert(const fltSemantics &toSemantics,
fs = opOK;
}
+ // The target may have no encoding for a negative value, or none for zero.
+ // The paths above only report what rounding lost, so report these here too:
+ // a caller that checks losesInfo would otherwise accept a result the target
+ // cannot represent, and printing that result asserts.
+ if ((sign && !semantics->hasSignedRepr) ||
+ (category == fcZero && !semantics->hasZero)) {
+ *losesInfo = true;
+ if (fs == opOK)
+ fs = opInexact;
+ }
+
if (category == fcZero && !semantics->hasZero)
makeSmallestNormalized(false);
return fs;
diff --git a/llvm/unittests/ADT/APFloatTest.cpp b/llvm/unittests/ADT/APFloatTest.cpp
index 4ef2814e1a7b3..03a35d3191cc4 100644
--- a/llvm/unittests/ADT/APFloatTest.cpp
+++ b/llvm/unittests/ADT/APFloatTest.cpp
@@ -2334,6 +2334,59 @@ TEST(APFloatTest, Float8E8M0FNUNaNConvert) {
}
}
+// Test that converting into a format that cannot represent the sign, or that
+// has no encoding for zero, reports the loss. Callers decide whether to keep
+// the result from losesInfo, and the value that comes out is not the value
+// that went in: the sign bit has nowhere to go, and zero is replaced by the
+// smallest normalized value.
+TEST(APFloatTest, ConvertLosesUnrepresentableSignAndZero) {
+ // Neither format has a sign; only Float8E8M0FNU also lacks a zero.
+ const fltSemantics *NoSignSemantics[] = {&APFloat::Float8E8M0FNU(),
+ &APFloat::Float8E5M3FNU()};
+
+ for (const fltSemantics *Sem : NoSignSemantics) {
+ for (double Value : {-2.0, -0.0}) {
+ APFloat test(Value);
+ bool losesInfo = false;
+ APFloat::opStatus status =
+ test.convert(*Sem, APFloat::rmNearestTiesToEven, &losesInfo);
+ EXPECT_TRUE(losesInfo);
+ EXPECT_EQ(status, APFloat::opInexact);
+ }
+
+ // The magnitude alone still converts exactly.
+ APFloat test(2.0);
+ bool losesInfo = true;
+ APFloat::opStatus status =
+ test.convert(*Sem, APFloat::rmNearestTiesToEven, &losesInfo);
+ EXPECT_FALSE(losesInfo);
+ EXPECT_EQ(status, APFloat::opOK);
+ EXPECT_FALSE(test.isNegative());
+ }
+
+ // Float8E8M0FNU has no zero either, and substitutes 2^-127 for one.
+ {
+ APFloat test(0.0);
+ bool losesInfo = false;
+ APFloat::opStatus status = test.convert(
+ APFloat::Float8E8M0FNU(), APFloat::rmNearestTiesToEven, &losesInfo);
+ EXPECT_TRUE(losesInfo);
+ EXPECT_EQ(status, APFloat::opInexact);
+ EXPECT_EQ(APInt(8, 0), test.bitcastToAPInt());
+ }
+
+ // Float8E5M3FNU does have one, so a positive zero converts exactly.
+ {
+ APFloat test(0.0);
+ bool losesInfo = true;
+ APFloat::opStatus status = test.convert(
+ APFloat::Float8E5M3FNU(), APFloat::rmNearestTiesToEven, &losesInfo);
+ EXPECT_FALSE(losesInfo);
+ EXPECT_EQ(status, APFloat::opOK);
+ EXPECT_TRUE(test.isZero());
+ }
+}
+
TEST(APFloatTest, getLargest) {
EXPECT_EQ(3.402823466e+38f, APFloat::getLargest(APFloat::IEEEsingle()).convertToFloat());
EXPECT_EQ(1.7976931348623158e+308, APFloat::getLargest(APFloat::IEEEdouble()).convertToDouble());
@@ -9881,13 +9934,14 @@ TEST(APFloatTest, ConvertDoubleToE8M0FNU) {
EXPECT_EQ(status, APFloat::opOK);
// For E8M0, zero encoding is represented as the smallest normalized value.
+ // That is a different value, so the conversion reports the loss.
test = APFloat(APFloat::IEEEdouble(), "0.0");
status = test.convert(APFloat::Float8E8M0FNU(), APFloat::rmNearestTiesToEven,
&losesInfo);
EXPECT_TRUE(test.isSmallestNormalized());
EXPECT_EQ(0x1.0p-127, test.convertToDouble());
- EXPECT_FALSE(losesInfo);
- EXPECT_EQ(status, APFloat::opOK);
+ EXPECT_TRUE(losesInfo);
+ EXPECT_EQ(status, APFloat::opInexact);
// Test that the conversion of a power-of-two value is precise.
test = APFloat(APFloat::IEEEdouble(), "8.0");
diff --git a/mlir/test/Dialect/Arith/canonicalize.mlir b/mlir/test/Dialect/Arith/canonicalize.mlir
index 89632305fa99d..1916784fb0602 100644
--- a/mlir/test/Dialect/Arith/canonicalize.mlir
+++ b/mlir/test/Dialect/Arith/canonicalize.mlir
@@ -1452,6 +1452,30 @@ func.func @truncFPConstantRounding() -> bf16 {
return %0 : bf16
}
+// f8E8M0FNU has no encoding for a negative value, so this conversion is not
+// lossless and is NOT folded. It used to fold, and printing the result of the
+// fold asserted in APFloat.
+// CHECK-LABEL: @truncFPConstantE8M0Negative
+// CHECK: arith.constant -2.000000e+00 : f32
+// CHECK: truncf
+func.func @truncFPConstantE8M0Negative() -> f8E8M0FNU {
+ %cst = arith.constant -2.000000e+00 : f32
+ %0 = arith.truncf %cst : f32 to f8E8M0FNU
+ return %0 : f8E8M0FNU
+}
+
+// It has no encoding for zero either: the 0x00 pattern is 2^-127. Folding used
+// to produce that value silently, while the expansion in ExpandOps.cpp reads
+// the same pattern back as 0.0.
+// CHECK-LABEL: @truncFPConstantE8M0Zero
+// CHECK: arith.constant 0.000000e+00 : f32
+// CHECK: truncf
+func.func @truncFPConstantE8M0Zero() -> f8E8M0FNU {
+ %cst = arith.constant 0.000000e+00 : f32
+ %0 = arith.truncf %cst : f32 to f8E8M0FNU
+ return %0 : f8E8M0FNU
+}
+
// CHECK-LABEL: @tripleAddAdd
// CHECK: %[[cres:.+]] = arith.constant 59 : index
// CHECK: %[[add:.+]] = arith.addi %arg0, %[[cres]] : index
>From c3b8cccc8639ecae961f041a029c88d19b3076a5 Mon Sep 17 00:00:00 2001
From: Hung-Kuan Tseng <tseng.tim096 at gmail.com>
Date: Thu, 13 Aug 2026 22:08:49 +0800
Subject: [PATCH 2/2] [mlir][asm] Reject a negative float literal for a type
with no sign
`f8E8M0FNU` and `f8E5M3FNU` have no encoding for a negative value. The
parser converted the literal anyway, and the attribute that came out kept
the sign bit, so printing it asserted inside APFloat:
%c = arith.constant -2.000000e+00 : f8E8M0FNU
This floating point format does not support signed values
UNREACHABLE executed at llvm/lib/Support/APFloat.cpp:3190!
No pass has to run for this; parsing and printing the file is enough.
Emit a diagnostic instead, in both the scalar (`parseFloatAttr`) and the
dense-elements (`parseFloatFromLiteral`) paths.
---
mlir/lib/AsmParser/AttributeParser.cpp | 9 +++++++++
mlir/lib/AsmParser/Parser.cpp | 8 ++++++++
mlir/test/IR/invalid-builtin-attributes.mlir | 18 ++++++++++++++++++
3 files changed, 35 insertions(+)
diff --git a/mlir/lib/AsmParser/AttributeParser.cpp b/mlir/lib/AsmParser/AttributeParser.cpp
index ca8e4ae2cecbc..ba314fac86a37 100644
--- a/mlir/lib/AsmParser/AttributeParser.cpp
+++ b/mlir/lib/AsmParser/AttributeParser.cpp
@@ -339,6 +339,7 @@ ParseResult Parser::parseAttributeDict(NamedAttrList &attributes) {
/// Parse a float attribute.
Attribute Parser::parseFloatAttr(Type type, bool isNegative) {
+ SMLoc loc = getToken().getLoc();
auto val = getToken().getFloatingPointValue();
if (!val)
return (emitError("floating point value too large for attribute"), nullptr);
@@ -353,6 +354,14 @@ Attribute Parser::parseFloatAttr(Type type, bool isNegative) {
if (!isa<FloatType>(type))
return (emitError("floating point value not valid for specified type"),
nullptr);
+ // A type with no signed representation, such as f8E8M0FNU, has no encoding
+ // for a negative value. The conversion inside FloatAttr::get keeps the sign
+ // bit, and printing the attribute that comes out of it asserts.
+ if (isNegative && !APFloat::semanticsHasSignedRepr(
+ cast<FloatType>(type).getFloatSemantics()))
+ return (emitError(loc, "negative floating point literal for a type with no "
+ "signed representation"),
+ nullptr);
return FloatAttr::get(type, isNegative ? -*val : *val);
}
diff --git a/mlir/lib/AsmParser/Parser.cpp b/mlir/lib/AsmParser/Parser.cpp
index 952d7e460c6e2..5cfd2f793313b 100644
--- a/mlir/lib/AsmParser/Parser.cpp
+++ b/mlir/lib/AsmParser/Parser.cpp
@@ -405,6 +405,14 @@ ParseResult Parser::parseFloatFromLiteral(std::optional<APFloat> &result,
if (!val)
return emitError(tok.getLoc()) << "floating point value too large";
+ // A type with no signed representation, such as f8E8M0FNU, has no encoding
+ // for this value at all; the conversion below would keep the sign bit and
+ // produce a value that asserts when it is printed.
+ if (isNegative && !APFloat::semanticsHasSignedRepr(semantics))
+ return emitError(tok.getLoc())
+ << "negative floating point literal for a type with no signed "
+ "representation";
+
result.emplace(isNegative ? -*val : *val);
bool unused;
result->convert(semantics, APFloat::rmNearestTiesToEven, &unused);
diff --git a/mlir/test/IR/invalid-builtin-attributes.mlir b/mlir/test/IR/invalid-builtin-attributes.mlir
index 0d00b3141fbc6..1989ce663cbb2 100644
--- a/mlir/test/IR/invalid-builtin-attributes.mlir
+++ b/mlir/test/IR/invalid-builtin-attributes.mlir
@@ -675,3 +675,21 @@ func.func @expect_to_parse_literal() {
%0 = arith.constant dense<[23]> : tensor<1x!unknown<>>
return
}
+
+// -----
+
+// f8E8M0FNU has no encoding for a negative value. Converting the literal used
+// to keep the sign bit, and printing the result asserted in APFloat.
+func.func @negative_literal_without_signed_repr() {
+ // expected-error at below {{negative floating point literal for a type with no signed representation}}
+ %0 = arith.constant -2.000000e+00 : f8E8M0FNU
+ return
+}
+
+// -----
+
+func.func @negative_elements_literal_without_signed_repr() {
+ // expected-error at below {{negative floating point literal for a type with no signed representation}}
+ %0 = arith.constant dense<[1.000000e+00, -2.000000e+00]> : tensor<2xf8E8M0FNU>
+ return
+}
More information about the Mlir-commits
mailing list