Skip to content

Commit d3cd0b4

Browse files
committed
Round exact ties to nearest-even when converting to float8_e8m0fnu
Converting a float/double/bfloat16 to float8_e8m0fnu (the mantissa-less, exponent-only scale type) rounded every exact tie up instead of to nearest-even, contradicting the type's round_style (round_to_nearest) and the RoundBitsToNearestEven routine used for the conversion. For example 3.0 (an exact tie between 2.0 and 4.0) converted to 4.0 rather than 2.0. RoundBitsToNearestEven derives the round-to-even ("last kept") bit as bit `roundoff` of the mantissa. For a mantissa-less target that bit is the always-1 implicit bit, so ties always rounded up; the result's true least-significant bit is the exponent LSB, which the implicit bit carries into during packing. Add an overload of RoundBitsToNearestEven that accepts the last-kept bit explicitly, and pass the exponent LSB for the kToMantissaBits == 0 case. Targets with mantissa bits are unchanged. 126 of the 254 e8m0 tie points were previously mis-rounded.
1 parent 8f7a061 commit d3cd0b4

3 files changed

Lines changed: 53 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ To release a new version (e.g. from `1.0.0` -> `2.0.0`):
2727
`complex32` arrays on NumPy 2.5+ ([#383](https://github.com/jax-ml/ml_dtypes/pull/383)).
2828
* Fixed byte-swapping of `bcomplex32` and `complex32` arrays
2929
([#383](https://github.com/jax-ml/ml_dtypes/pull/383)).
30+
* Fixed `float8_e8m0fnu` conversion to round exact ties to nearest-even instead
31+
of always rounding up.
3032

3133
## [0.6.0] - 2026-08-13
3234

ml_dtypes/include/float8.h

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,7 +1263,8 @@ struct Traits<float8_e8m0fnu> : public TraitsBase<float8_e8m0fnu> {
12631263
};
12641264

12651265
template <typename Bits>
1266-
constexpr inline Bits RoundBitsToNearestEven(Bits bits, int roundoff) {
1266+
constexpr inline Bits RoundBitsToNearestEven(Bits bits, int roundoff,
1267+
bool last_kept_bit) {
12671268
// Round to nearest even by adding a bias term.
12681269
// Consider a bit pattern
12691270
// FFF...FLRTT...T,
@@ -1272,10 +1273,21 @@ constexpr inline Bits RoundBitsToNearestEven(Bits bits, int roundoff) {
12721273
// - L is 1, R is 1, OR
12731274
// - L is 0, R is 1, any T is one.
12741275
// We do this by adding L to a bit pattern consisting of all T = 1.
1275-
Bits bias = ((bits >> roundoff) & 1) + (Bits{1} << (roundoff - 1)) - 1;
1276+
//
1277+
// L is the least-significant bit that is *kept* in the result. That is
1278+
// usually bit `roundoff` of `bits`, but a caller may pass a different value
1279+
// when that bit is not the result's LSB -- e.g. a mantissa-less target
1280+
// (E8M0) where the implicit bit carries into the exponent during packing, so
1281+
// the result's LSB is the exponent LSB, not bit `roundoff`.
1282+
Bits bias = Bits{last_kept_bit} + (Bits{1} << (roundoff - 1)) - 1;
12761283
return bits + bias;
12771284
}
12781285

1286+
template <typename Bits>
1287+
constexpr inline Bits RoundBitsToNearestEven(Bits bits, int roundoff) {
1288+
return RoundBitsToNearestEven(bits, roundoff, ((bits >> roundoff) & 1) != 0);
1289+
}
1290+
12791291
#if (defined(__cpp_lib_bitops) && __cpp_lib_bitops >= 201907L)
12801292
using std::countl_zero;
12811293
#else
@@ -1462,8 +1474,23 @@ struct ConvertImpl<From, To, kSaturate, kTruncate,
14621474
if constexpr (!kTruncate) {
14631475
// Rounding may cause a carry (e.g., 1.11... -> 10.00...).
14641476
// This carry will naturally flow into the exponent during packing.
1465-
normalized_mantissa =
1466-
RoundBitsToNearestEven(normalized_mantissa, alignment_shift);
1477+
if constexpr (kToMantissaBits == 0) {
1478+
// The target has no mantissa bits (E8M0). The implicit bit carries
1479+
// into the exponent during packing (Step 6), so the result's LSB is
1480+
// the truncated exponent's LSB, not bit `alignment_shift` of the
1481+
// mantissa (which is the always-1 implicit bit). Supply that bit
1482+
// explicitly so exact ties round to even rather than always up.
1483+
const ToBits trunc_exp_bits =
1484+
static_cast<ToBits>(std::max(0, target_biased_exponent_base));
1485+
const bool result_lsb =
1486+
(((normalized_mantissa >> alignment_shift) + trunc_exp_bits) &
1487+
1) != 0;
1488+
normalized_mantissa = RoundBitsToNearestEven(
1489+
normalized_mantissa, alignment_shift, result_lsb);
1490+
} else {
1491+
normalized_mantissa =
1492+
RoundBitsToNearestEven(normalized_mantissa, alignment_shift);
1493+
}
14671494
}
14681495
aligned_mantissa = normalized_mantissa >> alignment_shift;
14691496
} else {

ml_dtypes/tests/float8_test.cc

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,26 @@ TYPED_TEST(Float8Test, DownCasts) {
430430
}
431431
}
432432

433+
// Regression test: converting to a mantissa-less target (E8M0) must round
434+
// exact ties to nearest-*even*, not always up. Previously the round-to-even
435+
// bit was read from the always-1 implicit mantissa bit, so every tie rounded
436+
// up. The tie 1.5 * 2^k is equidistant between 2^k and 2^(k+1); ties-to-even
437+
// selects the encoding whose LSB is 0.
438+
TEST(Float8E8m0Test, ConvertFromTiesToEven) {
439+
auto rep = [](double x) {
440+
return float8_e8m0fnu::ConvertFrom</*kSaturate=*/false,
441+
/*kTruncate=*/false>(x)
442+
.rep();
443+
};
444+
EXPECT_EQ(rep(3.0), 0x80); // tie 2.0 (0x80, even) / 4.0 (0x81) -> 0x80
445+
EXPECT_EQ(rep(0.75), 0x7E); // tie 0.5 (0x7E, even) / 1.0 (0x7F) -> 0x7E
446+
EXPECT_EQ(rep(6.0), 0x82); // tie 4.0 (0x81) / 8.0 (0x82, even) -> 0x82
447+
EXPECT_EQ(rep(12.0), 0x82); // tie 8.0 (0x82, even) / 16.0 (0x83) -> 0x82
448+
// Non-ties round to the nearest value.
449+
EXPECT_EQ(rep(2.4), 0x80); // nearer 2.0
450+
EXPECT_EQ(rep(3.4), 0x81); // nearer 4.0
451+
}
452+
433453
TYPED_TEST(Float8Test, ConvertFromWithSaturation) {
434454
using Float8 = TypeParam;
435455

0 commit comments

Comments
 (0)