Skip to content

bf16 primitive type#3983

Open
Jamesbarford wants to merge 1 commit into
rust-lang:masterfrom
Jamesbarford:rfc/bf16
Open

bf16 primitive type#3983
Jamesbarford wants to merge 1 commit into
rust-lang:masterfrom
Jamesbarford:rfc/bf16

Conversation

@Jamesbarford

@Jamesbarford Jamesbarford commented Jul 15, 2026

Copy link
Copy Markdown

View all comments

Add a bf16 primitive type to Rust, with support for portable scalar arithmetic that follows IEEE-754 semantics for rounding and subnormals. This has been spun off from the RFC Add bf16, f64f64 and f80 types

Important

Since RFCs involve many conversations at once that can be difficult to follow, please use review comment threads on the text changes instead of direct comments on the RFC.

If you don't have a particular section of the RFC to comment on, you can click on the "Comment on this file" button on the top-right corner of the diff, to the right of the "Viewed" checkbox. This will create a separate thread even if others have commented on the file too.

Rendered

Comment thread text/3983-bf16.md
@@ -0,0 +1,521 @@
# `bf16` primitive floating-point type

@kennytm kennytm Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the "Feature Name / Start Date / RFC PR / Rust Issue" header is gone?

View changes since the review

Comment thread text/3983-bf16.md
Comment on lines +413 to +424
### Literal Suffix

For a literal suffix `0bf16` may clash with the lexer's integer suffix `0b` for binary numbers thus;

```rust
let a = 0bf16;
```

Would need to be written;
```rust
let a = 0.0bf16;
```

@kennytm kennytm Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could also be disambiguated as 0_bf16.

Is the "unresolved" thing about whether to support the literal 0bf16? There is no other ways this conflict may arise. Since this is just a single special case IMO it is not harmful to also support this.

View changes since the review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, I had no idea that 0f64 and similar worked by itself. I would have expected the decimal part to be required always, as it is when the type isn't specified.

This feels particularly dubious considering how 0x1f64 is acceptable, but refers to the literal 8036 and not 1.0f64; 0b1f64 rightfully errors. Personally, I would just say maybe a good option to consider is just always requiring the decimal part for float literals just to avoid the need to disambiguate.

Or, at least, in this particular case, allow both 0_bf16 and 0.0bf16 but always lint float literals that don't have a decimal part, due to the dubiousness.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think 0_bf16 is clear enough to say it is a "0" of type bf16. Use lint clippy::unseparated_literal_suffix against 0bf16. There is no lint requiring like 5f32 must be written as 5.0f32 though.

Comment thread text/3983-bf16.md

`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.

@tgross35 tgross35 Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a double "as as" here

View changes since the review

Comment thread text/3983-bf16.md
Comment on lines +23 to +25
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 V1], [Neoverse N2][Neoverse N2], and [Neoverse V2][Neoverse V2]. On x86_64, `bf16` is supported through `AVX512_BF16` on platforms such as [Intel Cooper Lake][Intel Cooper Lake], [Intel Sapphire Rapids][Intel Sapphire Rapids] and later platforms with `AMX_BF16`, and [AMD Zen 4][AMD Zen 4] and later platforms with `AVX-512 BF16` support. On [RISC-V][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.

@tgross35 tgross35 Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the chip list can go in an appendix or footnotes (or link to wikipedia instead), IMO that's not especially interesting as long as the extension has been around for a while. Maybe it would be helpful to have something tabular in the reference-level explanation? E.g.:

  • aarch64 foofeature: ...
  • aarch64 barfeature: ...
  • x86 bazfeature: ...
  • RISC-V Zfbfmin: scalar f32 <-> bf16, ...
  • RISC-V Zvfbfmin: vector f32 <-> bf16, ...

View changes since the review

Comment thread text/3983-bf16.md
Comment on lines +50 to +112
### 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`.

```text
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:

```text
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 |
+---+-----------------+---------------+
```

@tgross35 tgross35 Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems more fitting for an appendix or external link than guide-level explanation

View changes since the review

Comment thread text/3983-bf16.md
Comment on lines +114 to +128
### 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][RFC-3514].

For more details, see reference-level explanation.

@tgross35 tgross35 Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the guide-level explanation I think it's more relevant to say:

  1. What math traits will be implemented?
  2. What as casts will be supported?
  3. There will be vector versions in core::arch
  4. Do we guarantee IEEE semantics?

View changes since the review

Comment thread text/3983-bf16.md

On AArch64, [the AAPCS64 specification defines `__bf16` as the Brain floating-point format with byte size 2 and natural alignment 2][aapcs64-bf16]. 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"][x86_64-bf16].

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.

@tgross35 tgross35 Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something we want/need to guarantee? I think it's fine to say it will usually size/alignment 2, but not tie ourselves into the alignment in case some platform does something different.

Also the last sentence seems a bit redundant, I'm not sure "not a new foreign calling convention" is needed

View changes since the review

Comment thread text/3983-bf16.md
Comment on lines +332 to +340
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][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.

@tgross35 tgross35 Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Counter: bf16 is kind of a weird type and probably shouldn't be preferred over one of the f* types unless a specific usecase is met. Keeping it from being available by default guides users in the right direction.

I believe core::arch:bf16 was proposed before as well, along with not allowing the bf16 suffix.

View changes since the review

Comment thread text/3983-bf16.md
Comment on lines +354 to +356
### Naming `bf16`

The name `bf16` maps directly to hardware-provider naming conventions, is a simple abbreviation of the official name [`bfloat16`][bfloat16_name], is similar to the [`__bf16` extension name in C++][__bf16_extension_name] 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`.

@tgross35 tgross35 Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we talked about this last I believe bf16, f16b, fb16, bfloat16, and __bf16 all had some thought. I agree that bf16 is fine, just noting that there might be some alternatives floating around the other RFC.

View changes since the review

Comment thread text/3983-bf16.md

### 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.

@tgross35 tgross35 Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Omitting scalar arithmetic means scalar operations could be omitted - huh?

View changes since the review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly I'm really struggling to understand this paragraph and think it could be rewritten

Comment thread text/3983-bf16.md
Comment on lines +348 to +352
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](#c-compiler-support).

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.

@tgross35 tgross35 Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think usize is any kind of precedent here - a different type size isn't remotely close to the complexities introduced by IEEE vs. FTZ or other similar variation.

View changes since the review

Comment thread text/3983-bf16.md
Comment on lines +346 to +352
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](#c-compiler-support).

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.

@tgross35 tgross35 Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was it determined that we should have bf16->f32->bf16 semantics rather than bf16 semantics? I would expect that most users either want:

  1. Do all operations at f32 without spending time at intermediate rounding, which I believe C does
  2. Correct bf16 semantics because you're doing something mathy

f32 with intermediate rounding gives predictable results but I'm not sure it brings an advantage over either of those two. (edit: does the difference even matter for the specified ops? #3983 (comment))

My preference would be to require bf16 semantics by default, then add methods like algebraic_add that can do entire operations as f32. That is, a + b + c only ever gives you a correctly rounded bf16 result, but a.algebraic(b).algebraic_add(c) could get lowered to any of the following:

// simplest, fastest on soft float targets and if there is hardware supporting
// bf16 arithmetic directly
addbf16(addbf16(a, b), c); 

// semantics defined in this RFC: allowed but unlikely
addf32(addf32(a as f32, b as f32) as bf16 as f32, c as f32) as bf16;

// fastest with hardware f32
addf32(addf32(a as f32, b as f32), c as f32) as bf16;

View changes since the review

@tgross35

Copy link
Copy Markdown
Contributor

This has been spun off from the RFC Add bf16, f64f64 and f80 types

Pinging @ecnelises who authored that RFC

Comment thread text/3983-bf16.md
Comment on lines +136 to +140
On AArch64, [the AAPCS64 specification defines `__bf16` as the Brain floating-point format with byte size 2 and natural alignment 2][aapcs64-bf16]. 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"][x86_64-bf16].

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](#unresolved-questions).

@tgross35 tgross35 Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do GCC and Clang agree on targets they support? This kind of thing has been most of the f16 bugs preventing stabilization thus far.

View changes since the review

Comment thread text/3983-bf16.md

### 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.

@RalfJung RalfJung Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
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 (albeit with a non-standard binary format) and only diverge when using vendor intrinsics gated by a target feature. Details for scalar and vector operations are discussed in greater depth below.

That binary format should be described in the Reference-level section.

View changes since the review

Comment thread text/3983-bf16.md

## Reference-level explanation

### ABI

@RalfJung RalfJung Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that ABI is a very niche aspect that we usually make basically no guarantees about, this is an odd way to start the reference-level section. I would have expected it to start by saying:

  • There's a new type, this is its representation
  • This are the main (scalar) operations on the type, and these are their specs

Start with the basics, only then descend into the weeds by discussing vector operations and ABI concerns.

View changes since the review

Comment thread text/3983-bf16.md
Comment on lines +174 to +176
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`][ieee-754]. 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also this goes without saying, that's how our spec works everywhere.

Comment thread text/3983-bf16.md

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.

@RalfJung RalfJung Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this paragraph hidden somewhere in the middle of the section? It's the main thing that this section needs to convey.

View changes since the review

Comment thread text/3983-bf16.md
- 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 `f32` precision, and the narrow then rounds that result to `bf16` precision.

The two-step rounding may produce results that differ by one [ULP][ULP_WIKI] 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.

@RalfJung RalfJung Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This paragraph makes basically the same point as the item above

Native bf16 arithmetic operations that round directly to bf16, rather than producing the same result as the specified through the above sequence, are not legal lowerings for scalar bf16 arithmetic.

The two should be merged. Currently the RFC uses way more words than necessary to convey its information, making it unnecessarily hard to find the key points.

View changes since the review

Comment thread text/3983-bf16.md
- 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 `f32` precision, and the narrow then rounds that result to `bf16` precision.

The two-step rounding may produce results that differ by one [ULP][ULP_WIKI] 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.

@RalfJung RalfJung Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow. This is extremely surprising. Does the double rounding theorem not apply here? bf16 has less than half the mantissa of f32 so I would have expected that it applies.

Do you have a concrete example of inputs where the two specs differ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought this is only referring to FMA and sanity checked that indeed that is the only one that fails https://gist.github.com/tgross35/a91c60bd655e24f912c7c7659a53cefd. But hm - I'm reading again and mul_add doesn't seem to be mentioned.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow. This is extremely surprising...

I need to check this, for some reason I thought it wouldn't however I think I'm wrong.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo mul_add should be defined to work directly on bf16 so you get the extra precision people expect, the fallback implementation is just (a as f64).mul_add(b as f64, c as f64) as bf16. defining all of add/sub/mul/div/sqrt to work directly on bf16 is equivalent to defining them to convert to f32 before the op and round back to bf16 after using nearest-ties-to-even mode.
for the other ops (except maybe reciprocal sqrt), defining them as using f32 arithmetic and convering back to bf16 after every op seems fine to me, since those aren't generally expected to provide bit-exact answers anyway (and where they are, bf16 can generally also represent those exact answers)

@RalfJung RalfJung Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah fully agreed, if it's just mul_add then we should be consistent with the other float types and require maximal precision. sqrt should also get maximal precision since it is a "core" IEEE operation. For transcendental functions we already make no precision guarantees so there's no reason to do anything else here.

Overall then this becomes an entirely normal IEEE-style float type, except that the binary format is not listed in the standard. But most of the standard is parametric over the format so that's not a big deal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants