If you have worked with C++23's extended floating-point types in <stdfloat> (such as std::float16_t and std::bfloat16_t), you may have encountered surprising arithmetic results when casting expressions to float or double.

Consider this counterintuitive scenario:

#include <iostream>
#include <stdfloat>

int main() {
    // Output: 2048
    std::cout << (std::float16_t(1024) + std::float16_t(1025)) << '\n';

    // Output: 2049.0 (Unexpected!)
    std::cout << (double)(std::float16_t(1024) + std::float16_t(1025)) << '\n';
}

Since std::float16_t has 11 bits of significand (10 explicit + 1 implicit), the exact integer 2049 cannot be represented and should round to nearest even (2048). Why does casting the exact same expression directly to double produce 2049.0?

The Cause: Emulation and Excess Precision Optimization

This behavior comes down to how compilers implement 16-bit floating-point types on architectures that lack native 16-bit hardware arithmetic instructions (such as standard x86-64 without AVX-512-FP16).

1. Software Emulation via 32-bit Float

When target hardware does not have dedicated FP16 ALU instructions, GCC emulates std::float16_t arithmetic by widening the operands to standard 32-bit float, performing the addition, and then narrowing the result back to 16-bit:

// What happens behind the scenes
float temp = (float)a + (float)b;       // 1024.0f + 1025.0f = 2049.0f
std::float16_t result = (std::float16_t)temp; // Narrowed and rounded to 2048.0

2. Redundant Cast Elimination

When you write (double)(std::float16_t(1024) + std::float16_t(1025)), the compiler sees the following abstract sequence:

(double)((std::float16_t)((float)1024 + (float)1025))

Under default compiler optimization and excess precision rules, the compiler recognizes that the intermediate float value (2049.0f) is being converted immediately to a wider type (double). The optimizer collapses the conversion chain, eliminating the intermediate 16-bit truncation/rounding step:

(double)(1024.0f + 1025.0f) // Evaluates directly to 2049.0

Why Did std::cout Print 2048?

When you pass (std::float16_t(1024) + std::float16_t(1025)) directly into std::cout << ..., the standard library receives the argument via a function call parameter of type std::float16_t (or an overloaded helper). Passing the value across the function boundary forces the compiler to materialize and truncate the value to actual 16-bit storage before printing, yielding the rounded value 2048.

How to Ensure Strict 16-Bit Rounding

If your application depends on exact 16-bit round-off semantics, you can use the following strategies:

1. Force Value Materialization via a Variable

Assign the result to an explicit std::float16_t variable before widening:

std::float16_t sum = std::float16_t(1024) + std::float16_t(1025);
double result = static_cast<double>(sum); // Guaranteed to be 2048.0

2. Enable Strict Standard Precision Flags

Ensure standard-compliant evaluation of intermediate precision by passing -fexcess-precision=standard to GCC/Clang:

g++ -std=c++23 -O2 -fexcess-precision=standard main.cpp

3. Compile for Targets with Native FP16 Support

If targeting modern processors with native half-precision hardware (such as x86 with AVX-512 FP16 or ARMv8.2-A+ with +fp16), enable the corresponding architecture flags:

# For x86-64 targets supporting AVX512-FP16
g++ -std=c++23 -mavx512fp16 main.cpp

# For ARM / AArch64 targets supporting FP16
g++ -std=c++23 -march=armv8.2-a+fp16 main.cpp

With hardware FP16 instructions, the CPU natively rounds the addition directly to 16 bits without intermediate 32-bit widening, consistently returning 2048.0.