Summary
Add a primitive type, bf16, to Rust for the 16-bit Brain Floating Point format.
bf16 is a 16-bit floating-point format derived from f32. It keeps the same 1-bit sign and 8-bit exponent layout as f32, but stores only the top 7 fraction bits. This gives it approximately the same dynamic range as f32, but much lower precision. The primary role of bf16 is representing bfloat16 values in memory, in APIs, in FFI signatures where the target ABI supports bfloat16, and as an element type for SIMD and matrix operations. It is expected to be uncommon for a bf16 to be used as a general-purpose arithmetic type in the same way as f32 or f64.
bf16 is not an IEEE-754 binary floating-point format however this RFC specifies the proposed bf16 type as following portable IEEE-754 semantics for conversions and scalar arithmetic. These are specified as as widening to f32, performing the corresponding f32 operation, and narrowing back to bf16 using round-to-nearest, ties-to-even (RNE) without flushing subnormals to zero. As such, implementations may use native bf16 instructions for scalar arithmetic and scalar conversion only where they produce identical results.
Hardware support is present in x86_64, AArch64 and RISC-V to name a few. bf16 support and implementation varies across architectures, and even similar vector or matrix instructions on the same architecture can differ in their handling of rounding, subnormals, NaNs, and accumulation. Target-specific vendor SIMD and matrix intrinsics expose the semantics of the underlying hardware and are defined as target-dependent in this RFC.
Acknowledgements
Thanks to Folkert de Vries, Trevor Gross, Tyler Mandry, Ralf Jung and Sayantan Chakraborty for providing early feedback on a draft of this RFC.
Motivation
bf16 is useful where memory bandwidth, storage size, and vector throughput matter more than decimal precision. The main use case is machine learning and AI workloads, where values are often stored or multiplied in bf16 while accumulation happens in wider precision.
Increasingly bf16 is found in hardware across architectures to support these workloads. The below is not an exhaustive list but illustrative of bf16's prevalence in hardware.
On AArch64, bf16 is supported by Armv8.6-A and later, implementations that include the bf16 architectural extension, include platforms based on Neoverse V1, Neoverse N2, and Neoverse V2. On x86_64, bf16 is supported through AVX512_BF16 on platforms such as Intel Cooper Lake, Intel Sapphire Rapids and later platforms with AMX_BF16, and AMD Zen 4 and later platforms with AVX-512 BF16 support. On RISC-V, bf16 is supported through the ratified Zfbfmin, Zvfbfmin, and Zvfbfwma extensions, providing scalar conversion, vector conversion, and vector widening multiply-accumulate respectively.
Consequently, bf16 is not limited to accelerator hardware; it is a natively supported scalar/vector format on modern CPUs, with availability discoverable through target-feature mechanisms.
A primitive bf16 type lets Rust APIs use the intended numeric format directly, while still allowing target-specific operations to expose the behaviour of the underlying hardware. For example x86_64 has instructions like VADDBF16 which prove tricky to make in stdarch without a bf16 type. As more bf16 instructions are introduced Rust's support will fall behind.
Beyond raw hardware support, bf16 is already supported in the surrounding ecosystem. A bf16 a type is provided by stdfloat in C++23 with std::bfloat16_t, a vendor extension in GCC and Clang (__bf16), and a first-class scalar type in LLVM IR (bfloat). Rust today cannot name this type directly, so FFI signatures, SIMD element types, and codegen all route around the gap with u16, wrapper types, and ad-hoc lowering rules (see Current Rust support). A driving motivation for a primitive bf16 is to close that interop gap with a bf16 type, in a similar spirit that RFC 3892 proposes core::num::Complex to match C99 _Complex at the FFI boundary.
Guide-level explanation
Naming the 16-bit Brain floating-point bf16
The proposed name for the Brain Floating Point type is bf16. See rationale as to why.
Usage
This RFC proposes the bf16 type to behave like the other primitive floating-point types in Rust, with standard traits implemented. It can be used in bindings, passed to functions, stored in arrays and structs, converted to and from other numeric types, and used with the usual floating-point operations. It follows the IEEE-754 standard for floating point types for scalar operations and only diverge when using vendor intrinsics gated by a target feature. Details for scalar and vector operations are discussed in greater depth below.
// Simple `bf16` scalar operations followed by printing
let a: bf16 = 6.7;
let b: bf16 = 42.69;
let c = a + b;
println!;
Intro to Floating Point bit representation
Floating-point types represent real numbers approximately using a fixed number of bits. These bits are divided into three fields: a sign bit, which determines whether the value is positive or negative. Exponent bits, which determine the scale or range of the value and fraction bits, also commonly called mantissa or significand bits, which determine the precision of the value within that range. Below is the bit representation of an f32.
31 30 23 22 0
+---+-----------------+----------------------------------------+
| S | Exponent | Mantissa |
+---+-----------------+----------------------------------------+
1 8 bits 23 bits
Trade-offs between representations
Increasing the number of exponent bits allows a format to represent much larger and much smaller values. Increasing the number of fraction bits allows it to represent values more precisely. Different floating-point formats therefore make different trade-offs between range, precision, storage size, and hardware efficiency.
bf16 is a 16-bit floating-point format intended to preserve the dynamic range of f32 while using half the storage. It does this by using the same number of exponent bits as f32, but fewer mantissa bits. This makes bf16 useful in machine learning and numerical workloads where range is often more important than precision, and where data movement, memory bandwidth, and hardware throughput matter.
Compared with IEEE-754 binary16 / f16, bf16 trades precision for range:
| data type | sign bits | exponent bits | fraction bits |
|---|---|---|---|
f16 | 1 | 5 | 10 |
bf16 | 1 | 8 | 7 |
f32 | 1 | 8 | 23 |
That makes the essence of bf16 feel more like a compact storage/compute companion to f32, rather than a smaller-range replacement for it. bf16 will generate the bfloat type in LLVM IR. It is also equivalent to C++ std::bfloat16_t, and GCC's and clang's __bf16 vendor extension.
The bf16 layout is conceptually similar to taking the high 16 bits of an IEEE-754 binary32 / f32 value:
f32, 32 bits:
31 30 23 22 0
+---+-----------------+----------------------------------------+
| S | Exponent | Mantissa |
+---+-----------------+----------------------------------------+
1 8 bits 23 bits
bf16, 16 bits:
15 14 7 6 0
+---+-----------------+---------------+
| S | Exponent | Mantissa |
+---+-----------------+---------------+
1 8 bits 7 bits
Relationship:
f32:
+---+-----------------+---------------+--------------------------+
| S | E E E E E E E E | M M M M M M M | discarded lower f32 frac |
+---+-----------------+---------------+--------------------------+
\___________________ _______________/
kept as bf16 high 16 bits
bf16:
+---+-----------------+---------------+
| S | E E E E E E E E | M M M M M M M |
+---+-----------------+---------------+
Vector operations
A first-class bf16 type lets Rust SIMD, dot-product, and matrix APIs use the natural element type for bf16 data instead of u16, wrapper types, or architecture-specific encodings. This improves clarity, and portability of API surfaces.
Target-specific vector and matrix operations may still have target-specific numerical behaviour. APIs exposing hardware instructions should be understood as exposing those instructions, not as promising bit-exact results across architectures.
For more details, see reference-level explanation.
Scalar arithmetic
The main motivation for bf16 is SIMD and matrix support, not scalar arithmetic. Scalar bf16 operations are specified portably as widening operands to f32, performing the corresponding Rust f32 operation, then narrowing the result to bf16 using round-to-nearest, ties-to-even (RNE). Subnormal results are not flushed to zero by default.
Implementations may use native bf16 instructions only when they produce the same observable result. Instructions with different rounding, flushing, semantics remain available through target-specific intrinsics. NaN payload behaviour is not strengthened beyond Rust's existing floating-point guarantees from RFC-3514.
For more details, see reference-level explanation.
Reference-level explanation
ABI
This RFC does not propose a new platform ABI for bf16.
On AArch64, the AAPCS64 specification defines __bf16 as the Brain floating-point format with byte size 2 and natural alignment 2. On x86_64, bf16 ABI compatibility should likewise follow the platform psABI and toolchain support for __bf16, which is stated as having "the same size, alignment, parameter passing and return rules as _Float16".
Within Rust, bf16 shall have a size 2 and alignment 2, all bit patterns are valid values, and its memory representation is the 16-bit Brain floating-point encoding described above. These are properties of the Rust type itself, not a new foreign calling convention.
For extern "C" and other foreign ABIs, Rust should not independently choose how bf16 is passed or returned. Instead, on targets where the platform defines and toolchains implement an ABI for the corresponding C/C++ __bf16 type, Rust's bf16 should be ABI-compatible with that type. On platforms where the ABI is undefined, see Unresolved Questions.
Vector operations
A first-class bf16 type is primarily useful for target-specific SIMD and Matrix APIs. These APIs commonly operate on vectors or tiles of bf16 values and accumulate into f32, reflecting the way bf16 is typically used in machine learning and numerical code.
For example, Arm exposes instructions that multiply vectors of bf16 values and accumulate into f32 results. x86_64 platforms expose similar functionality through features such as AVX512_BF16 and, on some Intel platforms, AMX_BF16.
With a primitive bf16 type, Rust APIs can model these operations using the natural element type of the source operands:
// Example Neon intrinsic that would be added to `std::arch::aarch64`
;
Without a scalar bf16 type, these APIs must instead represent bf16 data using u16, wrapper types, or backend-specific encodings. Using u16 preserves the raw bits, but it loses the meaning of the value. It also cannot use the usual floating-point surface of the language, such as casts, literals, constants, formatting, arithmetic traits, or generic bounds over floating-point types. That makes the APIs less clear, less portable, and less directly connected to the hardware operations they expose. If adopting a wrapper approach like core::arch::x86_64::bf16 Rust would need a bf16 type per architecture, this could cause confusion especially if names for the type diverged.
For operations that are hardware-specific, some bf16 instructions can have different numerical semantics across architectures. For example, Arm BFDOT and related bf16 dot-product instructions operate on bf16 inputs and accumulate into f32 results, but their pseudocode specifies architecture-defined intermediate rounding behaviour, and this behaviour can differ further when enhanced bf16 support is enabled.
By contrast, x86_64's VDPBF16PS specifies bf16 dot products accumulated into f32 using round-to-nearest ties to even for the FMA accumulation, while also treating input denormals as zero, flushing output denormals to zero, and ignoring MXCSR.
Therefore, target-specific SIMD, dot-product, and matrix APIs should be understood as exposing the hardware operation, not as promising target-independent bit-exact results. Portable Rust APIs may use bf16 as the element type for clarity and type safety, but portability of the source-level API should not be read as portability of every numerical detail of the underlying target-specific instruction. This is true today with intrinsics for "packed/vector float min" for example;
x86_64: _mm_min_ps(-0.0, +0.0) -> +0.0 bits 0x00000000
AArch64: vminq_f32(-0.0, +0.0) -> -0.0 bits 0x80000000
While ostensibly similar they produce different results.
Scalar Arithmetic
While this section is detailed, the primary use case of bf16 is not expected to be scalar arithmetic but SIMD/matrix support. Moreover a compelling argument can be made for not making scalar arithmetic portable which is laid out in the rationale and alternatives section. So too has a case for not including scalar arithmetic at all. Nevertheless this RFC proposes portable semantics.
For narrowing an f32 to bf16, the conversion shall use the IEEE-754-2019 default rounding-direction attribute for binary floating-point results, namely roundTiesToEven. For subnormals the default delivers a rounded result that may or may not be zero. In other words subnormals are not flushed to zero by default.
This is a semantic specification, not an implementation requirement. An implementation may use any instruction sequence, including native bf16 instructions, only when the observable result is identical to the specified model for all non-NaN inputs. The result of scalar bf16 arithmetic must not depend on whether the target has native bf16 arithmetic, on optimisation level, or on whether the expression is evaluated at compile time or run time.
Scalar arithmetic for bf16 where no native scalar instruction are present are specified as if both operands are first converted exactly to f32, the corresponding Rust f32 operation is performed, and the resulting f32 value is then narrowed to bf16 using round-to-nearest, ties-to-even (RNE). This keeps things portable between targets and the rounding mode the same as the IEEE-754 specification.
This looks as follows (Widen -> f32 operations -> narrow using RNE):
bf16 + bf16
-> widen both operands exactly to f32
-> perform Rust f32 addition
-> narrow the f32 result to bf16 using RNE
In code, this looks as follows;
let a = 1.1bf16;
let b = 2.2bf16;
let c = a + b;
Which is semantically equivalent to:
let c = as bf16;
Where the as bf16 cast performs the narrowing under round-to-nearest, ties-to-even, as specified above.
As a direct consequence:
- Native
bf16arithmetic operations that round directly tobf16, rather than producing the same result as the specified through the above sequence, are not legal lowerings for scalarbf16arithmetic. They produce different result bits for some inputs. Such operations remain available through explicit target-specific intrinsics, where the user has opted in to native semantics. - The narrowing step always rounds to nearest, ties-to-even. Truncation is not a conforming narrowing.
- The arithmetic performs two IEEE rounding steps: the inner operation rounds to
f32precision, and the narrow then rounds that result tobf16precision.
The two-step rounding may produce results that differ by one ULP from a hypothetical single-rounded native bf16 operation for a small fraction of inputs. This is a deliberate trade-off in favour of portable, bit-exact semantics; targets without native bf16 arithmetic could not otherwise be supported uniformly. Code that requires single-rounded native semantics should use target-specific intrinsics.
As with Rust's existing floating-point types, this specification does not strengthen Rust's guarantees about NaN payload preservation; NaN payloads remain unspecified within the limits Rust already allows for f32 and f64.
Please see footnotes on bf16 conversion for a more indepth exploration into hardware considerations and support.
Display
Display for bf16 should follow the same round-trip principle as Rust's existing primitive floats: it should produce a decimal representation that parses back to the same bf16 value via FromStr, preferably using the shortest such representation.
The maximum number of decimal digits needed to uniquely round-trip any bf16 value is given by the standard formula ceil(1 + p × log10(2)) from IEEE-754, 2019, where p = 8 for bf16, yielding 4. In practice, shortest-round-trip formatters use fewer digits where the value permits, so most bf16 values will display with 1-3 significant digits.
Special values follow the existing primitive-float conventions: NaN, inf, -inf. Debug should be consistent with Display for ordinary values; its exact format is not specified here.
As with f32 and f64, displayed values may differ from the source literal when the literal is not exactly representable in the format. This effect is more visible for bf16 because of its lower precision.
Constants
bf16 should expose the same style of constants as other primitive float types:
BITS
DIGITS // approximately 2 decimal digits
EPSILON
INFINITY
MANTISSA_DIGITS // 8, including the implicit leading bit
MAX
MAX_10_EXP
MAX_EXP
MIN
MIN_10_EXP
MIN_EXP
MIN_POSITIVE
NAN
NEG_INFINITY
RADIX // 2
The half crate documents bf16::MANTISSA_DIGITS as 8, including the implicit bit, and DIGITS as 2 decimal digits. However, a crate-defined type cannot fully substitute for a primitive type. Though its use is demonstrative of users' need for a bf16 type.
Literal suffix
bf16 supports a literal suffix.
let a = 0.64545bf16;
Conversions
Conversions between bf16 and integer types are numeric conversions.
From Conversions
Conversions from bf16 to a wider floating-point type shall preserve the represented value by expanding the bf16 bit pattern into the destination floating-point format.
At minimum:
let x: bf16 = 1.0bf16;
let y: f32 = f32from;
println!; // 2
No From<f16>
From<f16> for bf16 and vice versa should not exist in either direction as neither direction is value preserving. f16 has 11 bits of precision while bf16 has 8. A bf16's range vastly exceeds that of an f16 and From impl's are for lossless conversions. Ostensibly the naming including 16 makes them look similar when reading the type however how they use those 16 bits differs wildly.
Lossy Conversions
Lossy conversions to bf16 should be available through casts, performing down casting using round-to-nearest, ties to even:
let x: bf16 = 3.1415927f32 as bf16;
let y: bf16 = 3.141592653589793f64 as bf16;
Raw bit conversion:
Traits
bf16 follows the same general pattern as the other primitive floats, supporting:
Add,Sub,Mul,Div,RemNegPartialEq,PartialOrdCopy,Clone,DefaultDebug,Display, and the other formatting traitsSum,ProductFromStr
These scalar operations must be implemented in conformance to the widen -> f32 operations -> narrow using RNE as specified above.
Drawbacks
-
As with RFC 3453, support for this type has a fairly specific usecase as opposed to general purpose programming.
bf16is important for machine learning, numerical code, storage, conversion, and target-specific SIMD or matrix operations, but most Rust code will not use it directly. -
Many targets do not provide native scalar
bf16arithmetic. On those targets, scalar operations must be lowered using a wider floating-point type or helper routines, which may make scalarbf16arithmetic slower than users expect. This seems acceptable because scalar arithmetic is not expected to be the primary use case forbf16. -
bf16is not simply a smallerf32or an alias for IEEE binary16 /f16. It has its own layout, precision, rounding behaviour, NaN behaviour, and conversion rules. Adding it as a primitive type therefore requires Rust to specify these semantics explicitly. -
The most significant drawback is that native hardware behaviour and portable emulated behaviour may not be identical for all arithmetic operations. This RFC specifies
bf16scalar arithmetic in terms of widening operands tof32, performing the operation inf32, and narrowing the result back tobf16, so thatbf16arithmetic has portable Rust semantics rather than target-specific native semantics.
Rationale and alternatives
Placement: global namespace versus core::num
Primitive types;f16/f32/f64/f128, bool, char etc... lives in the global namespace and are always in scope without an import. This RFC proposes the primitive bf16 would by default do the same, reserving the bare name bf16 in every Rust program.
An alternative is to make bf16 a compiler-known primitive that is reached by path, as core::num::bf16, and is not added to the prelude. This mirrors the placement proposed for Complex in RFC 3892, which positions a numeric interchange type in core::num rather than the global namespace.
The Tradeoffs
A path only core::num::bf16 does not consume a globally reserved name which signals that this is a specialist interchange type rather than a general-purpose arithmetic type which arguably a bf16 could be. This could also set a precedent for future numeric formats without committing prelude real estate each time. If we want bf16 to be something that is only used as an exchange format then use core::num::bf16; could be a more natural fit.
However a global namespace is consistent with f16/f128 and the rest of the primitives, requires no import, and is what users would need if they are wanting to suffix floating point numbers with bf16 to define bf16's. Though whether this is desirable needs to be ironed out.
This decision is reversible across an edition. Prelude membership is edition-scoped, so bf16 can ship path-only and later be added to a future edition's prelude (or the reverse) without breaking existing code. This RFC proposes a global namespace first.
Scalar Arithmetic Alternatives
Having a bf16 type in the global namespace could reasonably set the expectation of having scalar operations baked in. All other globally available primitive numerical types have scalar operations. However omitting scalar arithmetic for a bf16 means that the rather complicated and, depending on support, possibly inefficient scalar operations could be omitted for bf16 which makes the type simpler and prevents a user being confused with the resultant code of widening to f32, performing the operations and narrowing to bf16 along with preventing misuse of the type. It seems reasonable a user could utilise the From and as casts themselves, perform scalar arithmetic in an f32 and convert back to bf16 without relying on built in support. Moreover vendor specific intrinsics could be used, where available, to perform scalar operations.
Rust does already accept target-dependent behaviour in a primitive: usize differs in size between 32-bit and 64-bit targets, so identical source an overflow on one target and not another. This could be seen as a precedent for letting bf16 scalar arithmetic follow whatever the hardware supports, thus producing plausibly faster code at the cost of portability. For instance the aforementioned VADDBF16 instruction flushes subnormals to zero, which the portable specification would forbid. Ostensibly this should, theoretically, be more performant that converting to f32 doing the operation and narrowing back to bf16.
The usize precedent is weaker than first appears as usize is deterministic per target: given a target triple its behaviour is fully specified and stable. Its variation is structural 32-bits vs 64-bits and the type system prevents it being mixed with fixed sized integers. However bf16 would vary on the same target triple depending on what target features have been made available. This problem is described in the Prior Art.
Portable scalar arithmetic could prove useful if predictable behaviour is expected. Baking this into the language could lessen the possible footguns from the myriad of disparate, and somewhat confusing, hardware support available. Thus inefficiencies in using this type for scalar arithmetic is deemed an acceptable tradeoff favouring predictable behaviour for a feature that is not anticipated to be the main usecase of the type.
Naming bf16
The name bf16 maps directly to hardware-provider naming conventions, is a simple abbreviation of the official name bfloat16, is similar to the __bf16 extension name in C++ and is immediately distinguishable from the similarly named f16 by the b prefix, meaning brain and f denoting float. This abbreviation also follows Rust's naming conventions for the currently supported numerical types where float is abbreviated with f or integer with an i.
bf16 as a library type
Adding a new float primitive could invite a slippery slope of further including special float types like f8, f4 or any other so called "minifloats". Conceivably bf16 could be a library type as opposed to a primitive. However in Rust today there is no possible way for a user to define custom as casts, literal suffixes or a way to lower to a specified LLVM primitive. Until these exist a bf16 library type cannot be the target of a built-in as cast, cannot define a 1.0bf16 suffix, cannot fix foreign-ABI passing rules and cannot lower to LLVM's bfloat. Having bf16 defined as a primitive scalar addresses all of these limitations. Thus the RFC proposes bf16 as a primitive for now while acknowledging a library type could be possible in the future.
Prior Art
C++ compiler support
The C++23 standard includes std::bfloat16_t in <stdfloat> as part of the extended floating-point types introduced by P1467. This was motivated by the prevalence of availability of bf16 on hardware, library support through class wrappers being cumbersome to use and a desire for more portable code. (for the section on adding extended floating point types see here)
GCC 13 implements the C++23 P1467 extended floating-point types, including std::bfloat16_t and the bf16 literal suffix. Clang's support for extended floating-point types, at the time of writing, is more limited and varies by feature; see the Clang C++ status page (then search the page for P1467). Both GCC and Clang nevertheless support bfloat16 through the __bf16 type.
For arithmetic operations the standard states;
Trivial implementations of the math functions for extended floating-point types that are no bigger than long double can be done by casting the arguments to a standard floating-point that is at least as big as the extended floating-point type, doing the calculations with the standard floating-point type, then casting the result back down to the extended floating-point type
Of which the proposal here for up-casting to f32, doing the binary operation then downcasting to bf16 via round to nearest, ties even would be compatible with the C++ standard.
Further more the standard states:
An implementation may also provide additional types that represent floating-point values and define them (and cv-qualified versions thereof) to be extended floating-point types. The standard and extended floating-point types are collectively termed floating-point types. [...] Except as specified in [basic.extended.fp], the object and value representations and accuracy of operations of floating-point types is implementation-defined.
As such, during the time of writing, whether compiling for x86_64 using emulated or hardware specific instructions, subnormals will be handled differently.
Current Rust support
Rust already has an experimental core::arch::x86::bf16 / core::arch::x86_64::bf16 type for AVX-512 BF16 intrinsics. This type is nightly-only, x86-specific, and documented as "the BFloat16 type used in AVX-512 intrinsics" rather than as a general-purpose primitive type. Its API is limited to raw from_bits and to_bits operations, and the stdarch tracking issue describes it as "just a wrapper around u16" intended to make intrinsic signatures more readable.
That approach is useful for stdarch, but it does not solve the general language problem. A u16 wrapper can represent the raw bits of a bfloat16 value, and such a wrapper could be made available across architectures. However, it would still make bf16 a library-defined convention rather than a primitive floating-point type known to the language and compiler.
This distinction matters for operations that are built into the language or depend on compiler lowering. A wrapper cannot be the target of built-in float casts or literal suffixes, cannot by itself define how bf16 values are passed and returned for foreign ABIs, and does not give Rust a scalar type that corresponds directly to LLVM's bfloat type. It also risks fragmenting the ecosystem into multiple incompatible wrapper types for the same 16-bit format.
A primitive bf16 gives Rust one common type for scalar values, memory layout, conversions, FFI where the target ABI supports bfloat16, and target-specific SIMD or matrix intrinsics.
There is precedent for adding new primitive floating-point types to Rust with f16 and f128 RFC 3453 improving the ergonomics and capabilities of the language.
There is also a code generation reason not to treat this as merely an x86 stdarch convention. Some LLVM intrinsics use LLVM's first-class bf16 / bf16xN types in their signatures, but Rust cannot currently name those types directly. As a result, Rust intrinsic signatures have to describe bfloat16 operands using integer-like types such as i16, u16, or vectors of those types, and rustc then needs special-case lowering rules to connect those signatures to LLVM intrinsics that actually expect bf16 or bf16xN. This is an ad hoc workaround: it applies only to selected intrinsics and does not give users or libraries a general Rust type for the format.
PR #140763 is an example of the current workaround: it adds temporary codegen bypasses so Rust signatures using i16 / i16xN can call LLVM intrinsics whose real signatures use bf16 / bf16xN. The need for that bypass illustrates the gap this RFC is intended to close.
This RFC has been split, and expanded upon, from an RFC proposing bf16, f64f64 and f80 types. This RFC focuses specifically on bf16 and addresses feedback about naming the type, floating-point semantics, especially the behaviour of scalar arithmetic and conversions to and from other floating-point types.
This RFC proposes that bf16 should be that first-class Rust type, rather than requiring each architecture-specific module to invent its own wrapper and each backend to add ad hoc lowering rules from integer-like Rust types to LLVM's bf16 type. A primitive bf16 gives Rust a single semantic type for the format, while still allowing target-specific APIs such as x86 AVX-512 BF16, Intel AMX BF16, and Arm BF16 intrinsics to expose the underlying hardware operations using bf16 as the operand type.
Unresolved Questions
ABI on targets without a defined bf16 calling convention
The reference ABI section above focuses on platforms where a specified ABI exists. That leaves open what Rust should do on targets whose ABI does not (yet) specify how bf16 is passed and returned. f16 faces similar problems and several questions carry over:
- Should
bf16be rejected inextern "C"signatures on such targets, via an error, or should Rust pick a provisional lowering? - If provisional then should Rust follow Clang's behaviour on that target? However Clang and GCC have historically differed on how
_Float16' are passed on some targets so "match the C toolchain" is not a single answer and a provisional choice may risk a silent ABI break of the platform standardises something different. - Most issues stem from
bf16being available on a target without the required FP/SIMD registers being available. This problem is not exclusive tobf16.f3232-bit targets without SSE have unsound floating point behaviour, as mentionedf16has similar issues without SSE registers on a 32-bit x86 target.
Where it is not possible to fix these issues a reasonable compromise is suggested in RFC-3514 for floating point semantics where compatibility is documented for the target in a proposed "support page". Presently platform support information is documented in platform-support.md along with platform support errata.
Literal Suffix
For a literal suffix 0bf16 may clash with the lexer's integer suffix 0b for binary numbers thus;
let a = 0bf16;
Would need to be written;
let a = 0.0bf16;
Future possibilities
Portable bf16 vector operations
A first-class bf16 primitive opens the door to portable vector operations on bf16, but specifying those operations is out of scope for this RFC. The reasons are the same divergences documented for narrowing in the cross-architecture conformance table: even when two architectures provide an instruction with the same source-level shape, their handling of rounding, subnormals, and intermediate precision can differ. Arm's BFDOT and x86_64's VDPBF16PS both compute a bf16 dot product accumulating into f32, but they do not produce bit-identical results, and the difference is not a bug; it is a deliberate ISA-level choice.
Specifying portable vector semantics therefore requires settling several questions that the scalar specification in this RFC does not answer:
- Whether portable vector ops should follow a widen-to-f32 / op-in-f32 / narrow model (analogous to the scalar specification), accepting the performance cost on hardware whose native instructions take a different path, or whether some looser "approximately equivalent" semantics should be permitted for vector operations as a deliberate trade-off.
- How fused multiply-add and dot-product reductions should behave across targets, given that the intermediate accumulator width and rounding behaviour are architecture-defined for the native instructions.
- How subnormal handling should be specified when some targets flush by default and others preserve.
- Whether portable vector
bf16should be part ofcore::simd(the portable SIMD effort) or a separate surface, and how it should interact with target-feature gating.
Unifying bf16 usages
For example in stdarch's core::arch::x86_64::bf16 type should be transitioned away from being a wrapper to using the bf16. This path should, where possible, follow for other wrapper types that are aiming to use bf16.
Footnotes
bf16 conversions
The below explores some of the real world nuances and considerations for architecture support.
Narrowing: with native conversion support
Some architectures, including AArch64, x86_64, and RISC-V, provide dedicated instructions for converting to and from bf16. These instructions are not interchangeable: their behaviour can differ in rounding mode selection, subnormal handling, NaN handling, and interaction with floating-point control registers.
The examples below cover AArch64, x86_64, and RISC-V, but the same considerations apply to any target with native bf16 conversion instructions. The word "conforming" in this section is used to implicate following the semantics of RNE and not flushing subnormals to zero, following IEEE-754 as described in the previous section of this RFC.
x86_64
On x86_64 with AVX512_BF16, VCVTNEPS2BF16 performs RNE and does not respect or update MXCSR. This makes it independent of the dynamic MXCSR.RC rounding mode. For subnormals, however, the instruction flushes to zero. Thus, VCVTNEPS2BF16 under what is proposed in this RFC is not a conforming lowering for an f32 -> bf16 narrowing operation that is required to preserve representable bf16 subnormal results. If this behaviour is required for a program one can use the relevant platform specific intrinsic. Thus this instruction, under the behaviour proposed in this RFC, could not be used.
AArch64
On AArch64 with the bf16 architectural extension, narrowing from f32 to bf16 can be performed by BFCVT. In the normal FPCR mode (FPCR.AH == 0), the BFCVT-family instructions use the rounding mode selected by FPCR.RMode of whichRMode = 0b00 selects RNE. If FPCR.AH == 1, the BFCVT-family instructions use RNE regardless of RMode. Therefore, for an unconditional RNE specification, BFCVT is a conforming lowering only when the compilation/runtime model guarantees that the relevant execution observes RNE, for example because FPCR.RMode == 0b00 or because FPCR.AH == 1 gives BFCVT-family instructions RNE semantics regardless of RMode. Subnormal preservation is a separate requirement from the rounding mode. AArch64 BFCVT is a conforming lowering for this specification only when the execution environment both selects RNE for the conversion and does not enable a mode that flushes the relevant denormal/subnormal results to zero. In the normal FPCR-zero state, this means FPCR.RMode == 0b00 and FPCR.FZ == 0.
On Linux/arm64, a newly exec'd task starts with zeroed user FPSIMD state. The kernel's arm64 FPSIMD code stores FPSR and FPCR, then the exec/reset path initializes this user state to zero. Consequently, at process entry after execve(), the user FPCR is zero: FPCR.RMode[23:22] == 0b00, which is Round to Nearest, i.e. nearest-even. This is an initial process state, not a guarantee that FPCR cannot later be changed by user code or libraries. See source.
Rust assumes FPCR remains in a conforming state across the lifetime of a process; code that mutates FPCR.RMode or FPCR.FZ may observe non-conforming bf16 results, analogous to existing expectations about MXCSR on x86_64 or effects the FPCR register has on f32. Thus BFCVT conforms to the behaviour proposed in the RFC.
RISC-V
On RISC-V with Zfbfmin, narrowing from f32 to bf16 is performed by FCVT.BF16.S, which is a standard narrowing floating-point-to-floating-point conversion that rounds according to the frm field (either the static rounding mode encoded in the instruction or the dynamic mode in the frm CSR). Setting frm to RNE (0b000) selects round-to-nearest, ties-to-even, making FCVT.BF16.S a conforming lowering under this specification when the execution environment selects RNE rounding for the conversion. Static encoding of the rounding mode in the instruction itself avoids dependence on the dynamic frm CSR and is the preferred lowering where the compiler can emit it directly.
The three architectures above each provide a hardware narrow from f32 to bf16, but no two are conforming lowerings for this RFC's specification under the same conditions:
| Architecture | Instruction | Rounding | Subnormal behaviour | Conforming as default lowering? |
|---|---|---|---|---|
AArch64 (bf16 ext.) | BFCVT | Per FPCR.RMode when FPCR.AH == 0; RNE when FPCR.AH == 1 | Per FPCR.FZ | Only when FPCR.RMode == 0b00 and FPCR.FZ == 0 (or FPCR.AH == 1 with FPCR.FZ == 0) |
x86_64 (AVX512_BF16) | VCVTNEPS2BF16 | RNE, independent of MXCSR.RC | Flushes subnormal outputs to zero, ignores MXCSR | No, fails to preserve subnormals |
RISC-V (Zfbfmin) | FCVT.BF16.S | Per instruction-encoded RM, or frm CSR | Preserved | Yes, when RM/frm selects RNE |
Narrowing: without native conversion support or where hardware does not provide a portable solution
Where no hardware instruction performs an RNE narrow from f32 to bf16 or hardware does not provide a portable solution, the narrowing must be emulated. A straightforward RNE implementation inspects bit 15 of the f32 significand and the sticky bits below it to decide whether to round up, with the usual ties-to-even tiebreak; this is a handful of integer ops. This is the same kind of operation normally provided by soft-float conversion helpers such as __truncsfbf2.