Follow-up from #57, which closed the "make cfloat match IEEE" idea as wontfix.
cfloat is the encoding this package implements and zero-copy aliasing with IEEE
binary16 stays unsupported. What is still worth having is an explicit, cheap
recode so crossing that boundary by copy is correct by construction, instead
of relying on the accident that finite data aliases cleanly.
The transform is tiny and exact
The two encodings differ in exactly two patterns per sign, and the mapping
between them is a straight swap:
|
fp16 ↔ IEEE binary16 |
fp8e5m2 ↔ OCP E5M2 |
| positive |
0x7c00 ↔ 0x7ffe |
0x7c ↔ 0x7e |
| negative |
0xfc00 ↔ 0xfffe |
0xfc ↔ 0xfe |
Everything else is byte-identical. Both formats have exactly one infinity and the
same number of NaN encodings per sign, so this is a bijection.
Verified by applying the swap across the entire encoding space and comparing
decoded values against the reference:
fp16 <-> IEEE binary16 : 0 mismatches / 65536 , self-inverse = True
fp8e5m2 <-> OCP E5M2 : 0 mismatches / 256 , self-inverse = True
Two properties fall out that make this pleasant to ship:
- Self-inverse. One function serves both directions, and a round trip is
bit-identity. No separate to_/from_ implementations to keep in step.
- NaN payloads survive, because every pattern except the four is untouched.
Why not just use astype
astype is correct in the value domain and stays the recommended path for
arrays. The shim is for the case where you hold raw bytes known to be IEEE
binary16 — a file, a socket, an mmap, another framework's buffer — and would
otherwise reach for np.frombuffer(buf, dtype=ud.fp16), which is exactly the
unsupported aliasing that silently corrupts ±inf.
It is also substantially cheaper, because it is a byte copy plus four equality
tests rather than a decode/encode round trip through double. On 5M elements:
astype path : 93.4 ms
recode path : 15.2 ms (6.2x faster)
And it can be written in pure Python/NumPy — no C++, no rebuild:
b = np.frombuffer(raw, dtype=np.uint16).copy()
for x, y in ((0x7c00, 0x7ffe), (0xfc00, 0xfffe)):
i, j = b == x, b == y
b[i], b[j] = y, x
arr = np.frombuffer(b.tobytes(), dtype=ud.fp16)
To be explicit about what this is not
It is a copy, deliberately. #57 settled that zero-copy aliasing between these
types will not be supported, and this does not reopen that. The value is naming
the boundary crossing so it appears in the code, and making it cheap enough that
nobody is tempted to alias instead.
Sketch
Something like a small universal_dtypes.interop module:
recode_ieee(arr_or_buffer, dtype=ud.fp16) # IEEE bits -> fp16 array
recode_ieee(fp16_array) # fp16 -> IEEE bits (same function)
Open questions worth settling when this is picked up:
- Whether it takes/returns buffers, arrays, or both.
- Whether it should refuse dtypes with no IEEE counterpart loudly, rather than
silently no-op'ing.
- Whether
fp8e5m2 ↔ OCP E5M2 ships at the same time (the transform is already
verified above) or whether fp16 alone is enough to start.
- Whether to expose the pattern table so downstream code can do the same recode
on data we never see.
Note
While measuring the astype baseline for this issue, astype turned out to
convert some IEEE NaN payloads into infinity — a separate, live correctness
bug with an upstream root cause in cfloat, unrelated to the shim. The recode
proposed here handles those patterns correctly, but that is not a reason to use
the shim as a workaround; the bug should be fixed on its own terms. Filed
separately.
Follow-up from #57, which closed the "make
cfloatmatch IEEE" idea as wontfix.cfloatis the encoding this package implements and zero-copy aliasing with IEEEbinary16 stays unsupported. What is still worth having is an explicit, cheap
recode so crossing that boundary by copy is correct by construction, instead
of relying on the accident that finite data aliases cleanly.
The transform is tiny and exact
The two encodings differ in exactly two patterns per sign, and the mapping
between them is a straight swap:
fp16↔ IEEE binary16fp8e5m2↔ OCP E5M20x7c00↔0x7ffe0x7c↔0x7e0xfc00↔0xfffe0xfc↔0xfeEverything else is byte-identical. Both formats have exactly one infinity and the
same number of NaN encodings per sign, so this is a bijection.
Verified by applying the swap across the entire encoding space and comparing
decoded values against the reference:
Two properties fall out that make this pleasant to ship:
bit-identity. No separate
to_/from_implementations to keep in step.Why not just use
astypeastypeis correct in the value domain and stays the recommended path forarrays. The shim is for the case where you hold raw bytes known to be IEEE
binary16 — a file, a socket, an mmap, another framework's buffer — and would
otherwise reach for
np.frombuffer(buf, dtype=ud.fp16), which is exactly theunsupported aliasing that silently corrupts ±inf.
It is also substantially cheaper, because it is a byte copy plus four equality
tests rather than a decode/encode round trip through
double. On 5M elements:And it can be written in pure Python/NumPy — no C++, no rebuild:
To be explicit about what this is not
It is a copy, deliberately. #57 settled that zero-copy aliasing between these
types will not be supported, and this does not reopen that. The value is naming
the boundary crossing so it appears in the code, and making it cheap enough that
nobody is tempted to alias instead.
Sketch
Something like a small
universal_dtypes.interopmodule:Open questions worth settling when this is picked up:
silently no-op'ing.
fp8e5m2↔ OCP E5M2 ships at the same time (the transform is alreadyverified above) or whether
fp16alone is enough to start.on data we never see.
Note
While measuring the
astypebaseline for this issue,astypeturned out toconvert some IEEE NaN payloads into infinity — a separate, live correctness
bug with an upstream root cause in
cfloat, unrelated to the shim. The recodeproposed here handles those patterns correctly, but that is not a reason to use
the shim as a workaround; the bug should be fixed on its own terms. Filed
separately.