[Bug][TIRx][FP8] DTypeConversion mis-encodes float8_e5m2 zero/subnormal values when converting to/from float32
Summary
On targets without native FP8 support, tirx.transform.FP8ComputeLegalize uses tvm::tirx::DTypeConversion for FP8 promotion and narrowing. DTypeConversion mis-encodes valid float8_e5m2 zero and subnormal values when converting to/from float32.
Environment
- Target:
llvmon x86_64 Linux - Dependencies:
numpy,ml_dtypes
The runtime reproductions used TVM v0.25.0.post1 (b3e249b7d75f8f3bc7cbee48188d3c80ae323437) and a source build based on upstream main commit 141a187f35e9b6263db6665bc99eb03146d9b23f. On 2026-09-11, upstream main was 8f328e802cfe5e41fcc8f5c17e7582b1c28bfce4, 15 commits ahead; the current source was inspected at that commit, and none of those intervening commits modified dtype_conversion.cc or unsupported_dtype_legalize.cc. Both runtime reproductions produced the same result below.
Reproduction
Save the following as reproduce-tvm-fp8-zero-subnormal-float32.py and run it in an environment containing TVM 0.25.0.post1:
python reproduce-tvm-fp8-zero-subnormal-float32.pyimport ml_dtypes
import numpy as np
import tvm
from tvm import tirx
from tvm.script import tirx as T
# +0, -0, smallest subnormal, middle subnormal, largest subnormal, smallest normal.
FP8_TO_F32_INPUT_BITS = np.array([0x00, 0x80, 0x01, 0x02, 0x03, 0x04], dtype=np.uint8)
FP8_TO_F32_EXPECTED_BITS = [0x00000000, 0x80000000, 0x37800000, 0x38000000, 0x38400000, 0x38800000]
F32_TO_FP8_INPUT_BITS = np.array(
[0x00000000, 0x80000000, 0x37800000, 0x38000000, 0x38400000, 0x38800000],
dtype=np.uint32,
)
F32_TO_FP8_EXPECTED_BITS = [0x00, 0x80, 0x01, 0x02, 0x03, 0x04]
@T.prim_func
def fp8_to_f32(A: T.Buffer((6,), "float8_e5m2"), B: T.Buffer((6,), "float32")):
for i in T.serial(6):
B[i] = A[i]
@T.prim_func
def f32_to_fp8(A: T.Buffer((6,), "float32"), B: T.Buffer((6,), "float8_e5m2")):
for i in T.serial(6):
B[i] = A[i]
fp8_to_f32_module = tirx.build(fp8_to_f32, target="llvm")
fp8_to_f32_src = tvm.runtime.tensor(FP8_TO_F32_INPUT_BITS.view(ml_dtypes.float8_e5m2))
fp8_to_f32_dst = tvm.runtime.tensor(np.zeros(6, dtype=np.float32))
fp8_to_f32_module["main"](fp8_to_f32_src, fp8_to_f32_dst)
fp8_to_f32_observed = [int(value) for value in fp8_to_f32_dst.numpy().view(np.uint32)]
f32_to_fp8_module = tirx.build(f32_to_fp8, target="llvm")
f32_to_fp8_src = tvm.runtime.tensor(F32_TO_FP8_INPUT_BITS.view(np.float32))
f32_to_fp8_dst = tvm.runtime.tensor(np.zeros(6, dtype=ml_dtypes.float8_e5m2))
f32_to_fp8_module["main"](f32_to_fp8_src, f32_to_fp8_dst)
f32_to_fp8_observed = [int(value) for value in f32_to_fp8_dst.numpy().view(np.uint8)]
print("float8_e5m2 -> float32 observed:", [f"0x{x:08X}" for x in fp8_to_f32_observed])
print("float8_e5m2 -> float32 expected:", [f"0x{x:08X}" for x in FP8_TO_F32_EXPECTED_BITS])
print("float32 -> float8_e5m2 observed:", [f"0x{x:02X}" for x in f32_to_fp8_observed])
print("float32 -> float8_e5m2 expected:", [f"0x{x:02X}" for x in F32_TO_FP8_EXPECTED_BITS])Expected behavior
The six inputs are, in order, +0, -0, the smallest subnormal, the middle subnormal, the largest subnormal, and the smallest normal:
For float8_e5m2 (bias = 15, mantissa bits = 2), an exponent field of zero denotes signed zero when the mantissa is zero and a subnormal otherwise. For a subnormal with mantissa M > 0, the value is (-1)^S × M × 2^-16; normal values use (-1)^S × (1 + M / 4) × 2^(E - 15). Therefore, 0x00 and 0x80 are +0 and -0, while 0x01, 0x02, and 0x03 are 2^-16, 2^-15, and 3 × 2^-16.
| value | +0 |
-0 |
smallest subnormal | middle subnormal | largest subnormal | smallest normal |
|---|---|---|---|---|---|---|
float8_e5m2 bits |
00 |
80 |
01 |
02 |
03 |
04 |
float32 bits |
00000000 |
80000000 |
37800000 |
38000000 |
38400000 |
38800000 |
float8_e5m2 -> float32 should produce the listed float32 words, and float32 -> float8_e5m2 should produce the listed FP8 bytes.
Actual behavior
The reproducer prints:
float8_e5m2 -> float32 observed: 0x38000000 0xB8000000 0x38200000 0x38400000 0x38600000 0x38800000
float8_e5m2 -> float32 expected: 0x00000000 0x80000000 0x37800000 0x38000000 0x38400000 0x38800000
float32 -> float8_e5m2 observed: 0x00 0x00 0x00 0x00 0x02 0x04
float32 -> float8_e5m2 expected: 0x00 0x80 0x01 0x02 0x03 0x04The smallest-normal control (0x04, 2^-14) is correct in both directions, while all tested exponent-zero cases are mis-encoded.
The same output was observed with the rebuilt main-derived source checkout described above.
Suspected cause
The relevant code is src/tirx/transform/dtype_conversion.cc. The two relevant branches are:
PrimExpr exponent_before_delta = ((src_uint_value << 1) >> (src_fp.mantissa + 1));
if (bias_delta >= 0) {
PrimExpr ret_exponent =
(bias_delta > 0) ? (cast(tgt_uint, exponent_before_delta + bias_delta) << tgt_fp.mantissa)
: (cast(tgt_uint, exponent_before_delta) << tgt_fp.mantissa);
return reinterpret(tgt_dtype, ret_mantissa | ret_exponent | ret_sign);
} else {
PrimExpr round_to_zero = exponent_before_delta < (-bias_delta);
PrimExpr ret_exponent = cast(tgt_uint, exponent_before_delta - (-bias_delta))
<< tgt_fp.mantissa;
return reinterpret(tgt_dtype, if_then_else(
round_to_zero, MakeConst(tgt_uint, 0), ret_mantissa | ret_exponent | ret_sign));
}- Widening: when the source exponent is
0, the code still applies the normal-value bias delta (112forfloat8_e5m2 -> float32). For this input,exponent_before_deltais0, so zero becomes2^-15instead of zero. - Narrowing: when
exponent_before_delta < -bias_delta, the underflow branch directly returns zero and does not construct a destination FP8 subnormal. It also loses the sign of-0.0.
TVM declares float8_e5m2 as an IEEE-style format in dtype_conversion.h. The smallest-normal control above is correct; the exponent-zero cases are not handled. FP8StorageLegalize only remaps the storage representation and is not implicated in this numeric conversion.
Impact
This is a silent numerical correctness issue in the software FP8 legalization path. A regular float8_e5m2 zero value is sufficient to trigger the mismatch.
This affects CPU builds and any target that falls back to the software FP8 conversion, including FP8 dequantization at storage/compute boundaries.
Suggested regression test
Regression coverage for +0, -0, subnormals, and the smallest normal in both directions would catch this case.
Source: apache/tvm