fix(format): decode floats by their datatype fields, not their size
Every 2-byte float was decoded as IEEE half, so bfloat16 (HDF5 2.0's H5T_FLOAT_BFLOAT16*, or any custom 8-bit-exponent type) read wrong: 1.5 as 1.9375, +inf as NaN. 1-byte FP8 floats were refused. Read the exponent/mantissa location and size and the bias from the datatype message: IEEE half/single/double keep their existing paths (half still through clawhdf5_format::float16), any other IEEE-style layout up to 64 bits whose values fit f64 (bfloat16, FP8 E4M3/E5M2, ...) is decoded generically, and the bulk-copy and zero-copy fast paths now require the IEEE layout rather than just the size. Datatypes with fields that describe no float still fall back to IEEE by size; layouts that cannot be represented in f64 (x87 80-bit, binary128) remain an error. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
@@ -188,3 +188,88 @@ with h5py.File("{path}", "r") as f:
|
||||
let i8be = file.dataset("i8be").unwrap();
|
||||
assert_eq!(i8be.read_u64().unwrap(), vec![(1 << 40) + 5, 0, 7, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floats_decode_by_their_datatype_fields() {
|
||||
// Every 2-byte float used to decode as IEEE half, so bfloat16 1.5 read as
|
||||
// 1.9375 and +inf as NaN; 1-byte FP8 floats were refused.
|
||||
skip_if_no_python!();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("float_layouts.h5");
|
||||
let script = format!(
|
||||
r#"{PRELUDE}
|
||||
def custom(base, fields, bias, size):
|
||||
# fields: (sign pos, exponent pos, exponent size, mantissa pos, mantissa size)
|
||||
t = base.copy()
|
||||
t.set_fields(*fields)
|
||||
t.set_ebias(bias)
|
||||
t.set_precision(size * 8)
|
||||
t.set_size(size)
|
||||
return t
|
||||
|
||||
def write(f, name, ftype, raw):
|
||||
raw = np.ascontiguousarray(raw)
|
||||
space = h5py.h5s.create_simple(raw.shape)
|
||||
ds = h5py.h5d.create(f.id, name.encode(), ftype, space)
|
||||
ds.write(h5py.h5s.ALL, h5py.h5s.ALL, raw, mtype=ftype)
|
||||
|
||||
# bfloat16: 1.5, -2.25, +inf, 0, 3.140625, 1, -0, smallest subnormal,
|
||||
# largest finite, NaN
|
||||
bf16 = np.array([0x3FC0, 0xC010, 0x7F80, 0x0000, 0x4049, 0x3F80, 0x8000, 0x0001,
|
||||
0x7F7F, 0x7FC1], "<u2")
|
||||
# 8-bit patterns, all 256 of them
|
||||
fp8 = np.arange(256, dtype="u1")
|
||||
types = {{
|
||||
"bf16_le": (custom(h5py.h5t.IEEE_F32LE, (15, 7, 8, 0, 7), 127, 2), bf16),
|
||||
"bf16_be": (custom(h5py.h5t.IEEE_F32BE, (15, 7, 8, 0, 7), 127, 2), bf16.byteswap()),
|
||||
"e4m3": (custom(h5py.h5t.IEEE_F32LE, (7, 3, 4, 0, 3), 7, 1), fp8),
|
||||
"e5m2": (custom(h5py.h5t.IEEE_F32LE, (7, 2, 5, 0, 2), 15, 1), fp8),
|
||||
}}
|
||||
with h5py.File("{path}", "w") as f:
|
||||
for name, (t, raw) in types.items():
|
||||
write(f, name, t, raw)
|
||||
vals = np.array([1.5, -2.25, np.inf, -np.inf, 0.0, -0.0, 6e-8, 65504, 1e-40, 1e300])
|
||||
f.create_dataset("f2_be", data=vals.astype(">f2"))
|
||||
f.create_dataset("f4_be", data=vals.astype(">f4"))
|
||||
f.create_dataset("f8_le", data=vals.astype("<f8"))
|
||||
with h5py.File("{path}", "r") as f:
|
||||
for name in list(types) + ["f2_be", "f4_be", "f8_le"]:
|
||||
v = f[name].astype("<f8")[()]
|
||||
# NaN payloads differ between converters; compare NaN as one value.
|
||||
v[np.isnan(v)] = np.nan
|
||||
emit(name, v.view("<u8"))
|
||||
"#,
|
||||
path = path.display()
|
||||
);
|
||||
let expected = run_python(&script);
|
||||
|
||||
let canonical = |v: f64| if v.is_nan() { f64::NAN } else { v };
|
||||
let file = File::open(&path).unwrap();
|
||||
for name in [
|
||||
"bf16_le", "bf16_be", "e4m3", "e5m2", "f2_be", "f4_be", "f8_le",
|
||||
] {
|
||||
let ds = file.dataset(name).unwrap();
|
||||
let want: Vec<u64> = parse(&expected[name]);
|
||||
let got: Vec<u64> = ds
|
||||
.read_f64()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|v| canonical(v).to_bits())
|
||||
.collect();
|
||||
assert_eq!(got, want, "{name} as f64");
|
||||
// f32 reads agree too (every value here is exact in f32 except the
|
||||
// f64 dataset, which rounds like `as f32`).
|
||||
let got32: Vec<u32> = ds
|
||||
.read_f32()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|v| if v.is_nan() { f32::NAN } else { v }.to_bits())
|
||||
.collect();
|
||||
let want32: Vec<u32> = want
|
||||
.iter()
|
||||
.map(|&b| canonical(f64::from_bits(b)) as f32)
|
||||
.map(|v| if v.is_nan() { f32::NAN } else { v }.to_bits())
|
||||
.collect();
|
||||
assert_eq!(got32, want32, "{name} as f32");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user