Skip to content

Round exact ties to nearest-even when converting to float8_e8m0fnu - #398

Open
EylonKrause wants to merge 2 commits into
jax-ml:mainfrom
EylonKrause:fix/e8m0-round-ties-to-even
Open

Round exact ties to nearest-even when converting to float8_e8m0fnu#398
EylonKrause wants to merge 2 commits into
jax-ml:mainfrom
EylonKrause:fix/e8m0-round-ties-to-even

Conversation

@EylonKrause

Copy link
Copy Markdown

Summary

Converting a float/double/bfloat16 to float8_e8m0fnu — the mantissa-less, exponent-only scale type used by the MX formats — rounds every exact tie up instead of to nearest-even, contradicting the type's own rounding mode (std::numeric_limits<float8_e8m0fnu>::round_style == round_to_nearest) and the RoundBitsToNearestEven routine used for the conversion.

3.0 is exactly halfway between the representable values 2.0 (0x80) and 4.0 (0x81); ties-to-even should select 2.0 (even encoding), but the code produces 4.0. A sweep of all tie midpoints shows 126 of the 254 are mis-rounded (every tie whose nearest-even value is the smaller one).

// before this fix:
float8_e8m0fnu::ConvertFrom<false, false>(3.0).rep()   // 0x81 (4.0); want 0x80 (2.0)
float8_e8m0fnu::ConvertFrom<false, false>(0.75).rep()  // 0x7F (1.0); want 0x7E (0.5)

Cause

RoundBitsToNearestEven(bits, roundoff) derives the round-to-even ("last kept") bit as (bits >> roundoff) & 1. For a mantissa-less target roundoff == kFromMantissaBits, so that bit is the always-1 implicit leading bit — hence it is always 1 and every tie rounds up. The result's actual least-significant bit is the exponent LSB, into which the implicit bit carries during packing.

Fix

Add an overload of RoundBitsToNearestEven that takes the last-kept bit explicitly (the existing two-argument form is unchanged and simply forwards the old default). In ConvertImpl, for kToMantissaBits == 0, supply the exponent LSB so exact ties round to even. Targets with mantissa bits are entirely unaffected.

Testing

  • New test Float8E8m0Test.ConvertFromTiesToEven checks that ties round to even (and non-ties to nearest).
  • Verified directly against the header: all 254 e8m0 tie midpoints now round to the nearest-even encoding (was 126 mismatches), and a differential sweep of every cross-format float8 -> float8 conversion is unchanged.

Disclosure: this contribution was authored with an AI coding assistant (Claude) and reviewed before submission.

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.
@EylonKrause
EylonKrause force-pushed the fix/e8m0-round-ties-to-even branch from febd39a to d3cd0b4 Compare August 19, 2026 07:46
@jakevdp

jakevdp commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Thanks – one complication here is that this PR only touches the NumPy conversion, and does not change the behavior of conversion on-device for programs generated by JAX. Currently both behaviors match:

>>> import ml_dtypes
>>> import numpy
>>> np.float32(3.0).astype(ml_dtypes.float8_e8m0fnu)
4

>>> import jax.numpy as jnp
>>> from jax import lax
>>> lax.convert_element_type(jnp.float32(3.0), jnp.float8_e8m0fnu)
Array(4, dtype=float8_e8m0fnu)

If we merge this change as-is, it will mean that host-side casting with NumPy would not match device-side casting with JAX/XLA, and that could end up being confusing.

I'm curious what @hawkinsp thinks of this – is this behavior something we should seek to change in ml_dtypes and/or XLA?

@hawkinsp

Copy link
Copy Markdown
Collaborator

I think I agree with the PR author that we need to round to nearest even so this is a bug we should fix in both places.

@jakevdp jakevdp self-assigned this Aug 19, 2026
@EylonKrause

Copy link
Copy Markdown
Author

Thanks both! Agreed — round-to-nearest-even is what float8_e8m0fnu's own round_style (round_to_nearest) and the RoundBitsToNearestEven routine promise, so host and device should match on it.

This PR is self-contained to the ml_dtypes (host/NumPy) conversion; the XLA device lowering is a separate change. A couple of options, whichever you prefer:

  • If you'd like host and device fixed in lockstep, I'm happy to take a look at the XLA e8m0 conversion and put up a matching change so they land together. One caveat up front: I can't build XLA locally (it's beyond my dev box), so I'd be leaning on your CI/review there rather than a local run.
  • If it's fine for the host path to be corrected first, this change is ready as-is and the XLA side can follow.

Either way works for me — let me know how you'd like to sequence it.

@sw23

sw23 commented Aug 25, 2026

Copy link
Copy Markdown

Can you check that this PR works for the lowest binade (i.e. 0x0)? It might be good to add a unit tests:

EXPECT_EQ(rep(0x1.8p-127), 0x00);  // tie 2^-127 (0x00, even) / 2^-126 (0x01) -> 0x00

@EylonKrause

Copy link
Copy Markdown
Author

Good catch — the lowest binade was wrong. The first version read the round-to-even bit after the denormal adjustment had already widened the round position, so ties where the target exponent is clamped to 0 still rounded up (0x1.8p-127 gave 0x01, not 0x00).

Fixed it properly: since e8m0 has no mantissa and no subnormals, consecutive encodings are always one power of two apart, so I now round the source fraction about its own binade midpoint (bit kFromMantissaBits) and break ties toward the even result encoding — whose LSB is the low bit of the truncated biased exponent. That works uniformly, no special-casing of the clamped range.

I verified it against round-to-nearest-even for every finite positive float32 and every binade tie (lowest through highest) — 0 mismatches. Added your suggested case plus the two neighbours:

EXPECT_EQ(rep(0x1.8p-127), 0x00);  // tie 2^-127 (0x00, even) / 2^-126 (0x01) -> 0x00
EXPECT_EQ(rep(0x1.4p-127), 0x00);  // nearer 2^-127
EXPECT_EQ(rep(0x1.cp-127), 0x01);  // nearer 2^-126

The first version read the round-to-even bit after widening the round position
by the denormal adjustment, so ties in the lowest binade (target exponent
clamped to 0) still rounded up -- e.g. 1.5*2^-127 gave 0x01 instead of 0x00.

E8M0 has no mantissa and no subnormals: consecutive encodings are always a full
power of two apart, so round the source fraction about its own binade midpoint
(bit kFromMantissaBits), not about alignment_shift, and break ties toward the
even result encoding, whose LSB is the low bit of the truncated (round-toward-
zero) biased exponent. Verified against round-to-nearest-even for every finite
positive float32 and every binade tie, including the lowest and highest.
@EylonKrause
EylonKrause force-pushed the fix/e8m0-round-ties-to-even branch from 2d56dc1 to 728ce42 Compare August 26, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants