Summary
Target-independent Clang builtins that convert integer-encoded arbitrary floating point (currently only FP8) values to native floating-point types. They lower to llvm.convert.from.arbitrary.fp. No IR change is proposed.
Each spelling names the source encoding and the destination type, and takes one argument:
__builtin_elementwise_convert_from_<source_format>_<destination_type>(bits)
The operand is a scalar integer or a fixed-length integer vector. Conversion is elementwise and keeps the element count:
float scalar = __builtin_elementwise_convert_from_f8e5m2_f32(bits);
float4 packed = __builtin_elementwise_convert_from_f8e5m2_f32(bits4);
Three source encodings (f8e5m2, f8e4m3fn, f8e5m3fnu) and three destinations (f16 → _Float16, bf16 → __bf16, f32 → float) give nine builtins. Encoding the destination in the name keeps these ordinary CallExprs.
Motivation
C and C++ have no portable arithmetic types for these encodings, so they are stored as integers and converted to _Float16, __bf16, or float when needed.
HIP’s FP8 headers call AMDGPU builtins that match the instruction: an int holding four FP8 values, a byte index, and a second builtin for a pair.
float one = __builtin_amdgcn_cvt_f32_fp8((int)word, 0);
float2 two = __builtin_amdgcn_cvt_pk_f32_fp8((int)word, false);
The proposed builtin takes 8-bit elements. The same spelling works for one value or two:
float one = __builtin_elementwise_convert_from_f8e4m3fn_f32(bits); // unsigned char
float2 two = __builtin_elementwise_convert_from_f8e4m3fn_f32(bits2); // unsigned char2
Packed-word and lane-select APIs remain target-specific. No SPIR-V or universal codegen is claimed.
Existing LLVM support
%result = call float @llvm.convert.from.arbitrary.fp.f32.i8(
i8 %bits, metadata !"Float8E4M3FN")
The integer holds the encoding. Metadata names the interpretation. The intrinsic is overloaded on both types and has scalar and vector forms.
| Source suffix | Interpretation | Width | Proposal |
|---|---|---|---|
f8e5m2 |
Float8E5M2 |
8 | Exposed |
f8e4m3fn |
Float8E4M3FN |
8 | Exposed |
f8e5m3fnu |
Float8E5M3FNU |
8 | Exposed |
f8e5m2fnuz |
Float8E5M2FNUZ |
8 | Deferred |
f8e4m3fnuz |
Float8E4M3FNUZ |
8 | Deferred |
f6e3m2fn |
Float6E3M2FN |
6 | Deferred |
f6e2m3fn |
Float6E2M3FN |
6 | Deferred |
f4e2m1fn |
Float4E2M1FN |
4 | Deferred |
FNUZ is deferred because llvm.convert.from.arbitrary.fp does not lower it yet. The encodings are 8-bit and can be added later. Sub-byte encodings are deferred because Clang cannot spell their vector element types: there is no 6-bit element type, and <8 x _BitInt(4)> has sizeof 8 while <8 x i4> occupies 4 bytes.
The inverse intrinsic llvm.convert.to.arbitrary.fp also takes a rounding mode and a saturation flag. No builtin is proposed for it here. A narrowing family would still need one spelling per encoding, with rounding and saturation as ordinary constant arguments, so the naming question below is for the widening direction.
Scope
Widening conversions from the three exposed encodings to _Float16, __bf16, and float, for scalar and fixed-length vector operands, in C, C++, OpenCL, CUDA, and HIP.
Out of scope: IR changes, FNUZ, sub-byte encodings, f64, narrowing, rounding controls, constant evaluation, scalable vectors, and target-specific vector kinds.
Naming
The source suffix comes first so the name reads as convert-from-X-to-Y. LLVM IR mangles the destination first (llvm.convert.from.arbitrary.fp.f32.i8).
elementwise means a scalar or fixed-length vector, applied per element. Suffixes mean the same in every language mode. f16 is _Float16, not __fp16 or OpenCL half.
Putting both types in the name keeps an ordinary call. A destination type argument is not a call argument, so it needs a keyword, a parser production, and a new expression node (dependence, printing, profiling, TreeTransform, ASTImporter, serialization, AST consumers).
The suffix form costs one spelling per destination: 9 today.
Operand and result types
One argument. The source element type is an integer of exactly the width of the source suffix, not _Bool/bool or an enumeration. Wider containers are rejected even if the low bits hold the encoding. Signedness is ignored; the integer is a bit container. char, signed char, unsigned char, and _BitInt(8) are all fine where they are 8 bits.
Integer promotions and the usual arithmetic conversions do not apply, so the operand matches the intrinsic width. The downside is that C bit-manipulation expressions have type int:
unsigned char b;
__builtin_elementwise_convert_from_f8e5m2_f32(b >> 1); // error: 'int' is not 8 bits
__builtin_elementwise_convert_from_f8e5m2_f32((unsigned char)(b >> 1)); // ok
Accepting wider containers later would be source-compatible. Accepting a packed i32 and using the low 8 bits would bring back the instruction-shaped API.
A vector result has the same element count, the destination element type, and the same vector kind (GNU vector_size or Clang/OpenCL ext_vector_type). Scalable, sizeless, matrix, and target-specific fixed kinds are rejected.
Sema applies the usual target and language availability rules to the result type, including offload diagnostics. Dependent C++ calls are checked at instantiation.
Conversion semantics
Each element is interpreted as the named format and converted independently. The call lowers to llvm.convert.from.arbitrary.fp.
- Every defined integer bit pattern produces a defined result. Poison and undef propagate as usual.
- Finite values follow the intrinsic and are exact, except
Float8E5M3FNU→_Float16. That format hasmaxExponent16 vs 15 for_Float16, so the seven largest finite encodings (65536 through 114688) become infinity.bf16andf32are exact for every encoding. f8e4m3fnandf8e5m3fnuhave no infinity encoding.f8e5m3fnuhas no sign bit.- A NaN encoding produces a NaN. LangRef says quiet/signaling is preserved and the payload is only truncated or extended. The builtin does not provide that: hardware conversions often canonicalize NaNs, so sign, quiet vs signaling, and payload are unspecified.
- No dynamic rounding mode, no floating-environment side effects. The call may be speculated.
The vector form does not require a particular instruction or packing.
Builtin support queries
__has_builtin means Clang knows the spelling. That is how an encoding is queried; encodings have no other preprocessor name. It does not mean the destination type exists, and it does not mean the backend has a dedicated lowering:
Constant expressions
Not in the first patch. __has_constexpr_builtin returns 0, and a constant-expression context is diagnosed, including static-storage initializers.
Alternative: destination as a type argument
float value = __builtin_elementwise_convert_from_f8e4m3fn(encoded, float);
float4 packed = __builtin_elementwise_convert_from_f8e4m3fn(encoded4, float4);
The encoding stays in the name, so __has_builtin still tests the part that has no other query. The destination is a type argument: three names instead of nine, one per new encoding, typedefs and OpenCL half work directly, and f64 is a type-availability check rather than a new spelling.
Future directions
Narrowing, stochastic rounding, and scaled MX conversions are out of scope. They need IR changes: rounding on llvm.convert.to.arbitrary.fp is metadata and cannot hold a runtime seed or scale, and llvm.convert.from.arbitrary.fp has no scale operand. A later scaled family can add an argument or a second set of names. That is separate from dest-in-the-name here.
Target-specific notes
AArch64. __mfp8 is an extra scalar source: an opaque 8-bit container with no interpretation of its own. Neon vectors of __mfp8, and other Neon vector kinds, are rejected. Diagnostics for a bad operand should not mention __mfp8 on targets that lack the type. Widening a Neon vector while keeping the Neon kind can produce an invalid ABI type, which is why target-specific vector kinds are rejected in general.
AMDGPU. Target builtins put the destination first (__builtin_amdgcn_cvt_f32_fp8). Packed-word and lane-select forms stay on those builtins.
RISC-V / SVE. RVV and SVE sizeless vectors are rejected with the other unsupported vector kinds.
Open questions
- Destination in the name vs a type argument? Suffix form: 9 spellings, no typedef or OpenCL
halfdestinations,__has_builtinsilent on destination availability. Type-argument form: prototyped above. Neither needs a new mangling. - Constant evaluation in the first patch? Proposed: no.
References
llvm.convert.from.arbitrary.fpin the LangRef- [RFC] Introducing elementwise clz/ctz builtins
- [RFC]
__has_builtinbehavior on offloading targets - SPIR-V representation of OCP low-precision types
- HIP low-precision floating-point types · HIP FP8 header
AI Disclosure: AI was used in writing up this post and for the reference implementation. It has gone through several manual review iterations in the process.