Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ To release a new version (e.g. from `1.0.0` -> `2.0.0`):
`complex32` arrays on NumPy 2.5+ ([#383](https://github.com/jax-ml/ml_dtypes/pull/383)).
* Fixed byte-swapping of `bcomplex32` and `complex32` arrays
([#383](https://github.com/jax-ml/ml_dtypes/pull/383)).
* Fixed `float8_e8m0fnu` conversion to round exact ties to nearest-even instead
of always rounding up.

## [0.6.0] - 2026-08-13

Expand Down
33 changes: 29 additions & 4 deletions ml_dtypes/include/float8.h
Original file line number Diff line number Diff line change
Expand Up @@ -1263,7 +1263,8 @@ struct Traits<float8_e8m0fnu> : public TraitsBase<float8_e8m0fnu> {
};

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

template <typename Bits>
constexpr inline Bits RoundBitsToNearestEven(Bits bits, int roundoff) {
return RoundBitsToNearestEven(bits, roundoff, ((bits >> roundoff) & 1) != 0);
}

#if (defined(__cpp_lib_bitops) && __cpp_lib_bitops >= 201907L)
using std::countl_zero;
#else
Expand Down Expand Up @@ -1462,8 +1474,21 @@ struct ConvertImpl<From, To, kSaturate, kTruncate,
if constexpr (!kTruncate) {
// Rounding may cause a carry (e.g., 1.11... -> 10.00...).
// This carry will naturally flow into the exponent during packing.
normalized_mantissa =
RoundBitsToNearestEven(normalized_mantissa, alignment_shift);
if constexpr (kToMantissaBits == 0 && kFromMantissaBits > 0) {
// The target has no mantissa bits (E8M0): consecutive encodings are a
// full power of two apart, so round the source fraction about its own
// binade midpoint (bit `kFromMantissaBits`), not about
// `alignment_shift` (which the denormal adjustment widens, misplacing
// the tie). Ties go to the even *result* encoding, whose LSB is the
// low bit of the truncated (round-toward-zero) biased exponent.
const bool result_lsb =
((target_biased_exponent_base + 1) & 1) != 0;
normalized_mantissa = RoundBitsToNearestEven(
normalized_mantissa, kFromMantissaBits, result_lsb);
} else {
normalized_mantissa =
RoundBitsToNearestEven(normalized_mantissa, alignment_shift);
}
}
aligned_mantissa = normalized_mantissa >> alignment_shift;
} else {
Expand Down
25 changes: 25 additions & 0 deletions ml_dtypes/tests/float8_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,31 @@ TYPED_TEST(Float8Test, DownCasts) {
}
}

// Regression test: converting to a mantissa-less target (E8M0) must round
// exact ties to nearest-*even*, not always up. Previously the round-to-even
// bit was read from the always-1 implicit mantissa bit, so every tie rounded
// up. The tie 1.5 * 2^k is equidistant between 2^k and 2^(k+1); ties-to-even
// selects the encoding whose LSB is 0.
TEST(Float8E8m0Test, ConvertFromTiesToEven) {
auto rep = [](double x) {
return float8_e8m0fnu::ConvertFrom</*kSaturate=*/false,
/*kTruncate=*/false>(x)
.rep();
};
EXPECT_EQ(rep(3.0), 0x80); // tie 2.0 (0x80, even) / 4.0 (0x81) -> 0x80
EXPECT_EQ(rep(0.75), 0x7E); // tie 0.5 (0x7E, even) / 1.0 (0x7F) -> 0x7E
EXPECT_EQ(rep(6.0), 0x82); // tie 4.0 (0x81) / 8.0 (0x82, even) -> 0x82
EXPECT_EQ(rep(12.0), 0x82); // tie 8.0 (0x82, even) / 16.0 (0x83) -> 0x82
// Lowest binade (clamped exponent): the tie 1.5*2^-127 is equidistant from
// 2^-127 (0x00, even) and 2^-126 (0x01); ties-to-even selects 0x00.
EXPECT_EQ(rep(0x1.8p-127), 0x00); // tie 2^-127/2^-126 -> even 0x00
EXPECT_EQ(rep(0x1.4p-127), 0x00); // nearer 2^-127
EXPECT_EQ(rep(0x1.cp-127), 0x01); // nearer 2^-126
// Non-ties round to the nearest value.
EXPECT_EQ(rep(2.4), 0x80); // nearer 2.0
EXPECT_EQ(rep(3.4), 0x81); // nearer 4.0
}

TYPED_TEST(Float8Test, ConvertFromWithSaturation) {
using Float8 = TypeParam;

Expand Down