Why (double)(std::float16_t(1024) + std::float16_t(1025)) Evaluates to 2049 Instead of 2048 in C++
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.