Since C++11, we are able to do floating point math at compile time. C++23 and C++26 added constexpr
to some functions, but not to all.
constexpr
floating point math is weird in general, because the results aren't perfectly accurate. However, constexpr
code is supposed to always deliver consistent results. How does C++ approach this issue?
Questions
- How does
constexpr
floating point math work?- Are the results the same for all compilers?
- Are the results the same between compile-time and runtime for the same compiler?
- Why are some functions
constexpr
, but others not (likestd::nearbyint
)
C++ imposes very few restrictions on the behavior of
float
and other floating-point types. This can lead to possible inconsistencies in the results, both between compilers, and between runtime/compile-time evaluation by the same compiler. Here is the tl;dr on it:silent errors through NaN as an extension
results in a compiler error
10.0 / 3.0
floating-point environment; results may vary
results can differ from runtime
-ffast-math
and other compiler optimizations
as a result; IEEE-754 conformance is broken
implementation-defined effect
as arithmetic with
+
and*
constexpr
since C++23,some
constexpr
since C++26,with some errors disallowed at compile-time
Floating-Point Errors
Some operations can fail, such as division by zero. The C++ standard says:
- [expr.mul]/4
In constant expressions, this is respected, and so it's not possible to produce NaN through operations or raise
FE_DIVBYZERO
at compile time.No exception is made for floating point numbers. However, when
std::numeric_limits<float>::is_iec559()
istrue
, most compilers will have IEEE-754 compliance as an extension. For example, division by zero is allowed and produces infinity or NaN depending on the operands.Rounding Modes
C++ has always allowed differences between compile-time results and runtime results. For example, you can evaluate:
The result might not always be the same, because the floating point environment can only be changed at runtime, and thus the rounding mode can be altered.
C++'s approach is to make the effect of the floating point environment implementation-defined. It gives you no portable way to control it (and thus rounding) in constant expressions.
- [cfenv.syn]/Note 1
Compiler Optimizations
Firstly, compilers can be eager to optimize your code, even if it changes its meaning. For example, GCC will optimize away this call:
The semantics change even more with flags like
-ffast-math
which allows the compiler to reorder and optimize operations in a way that is not IEEE-754 compliant. For example:For IEEE-754 floating point numbers, addition and subtraction are not commutative. We cannot optimize this to:
(big() - big()) + 3.14f
. The result will be0
, because3.14f
is too small to make any change tobig()
when added, due to lack of precision. However, with-ffast-math
enabled, the result can be3.14f
.Mathematical Functions
There can be runtime differences to constant expressions for all operations, and that includes calls made to mathematical functions.
std::sqrt(2)
at compile-time might not be the same asstd::sqrt(2)
at runtime. However, this issue is not unique to math functions. You can put these functions into the following categories:No FPENV Dependence / Very Weak Dependence (
constexpr
since C++23) [P05333r9]Some functions are completely independent of the floating-point environment, or they simply cannot fail, such as:
std::ceil
(round to next greater number)std::fmax
(maximum of two numbers)std::signbit
(obtains the sign bit of a floating-point number)Furthermore, there are functions like
std::fma
which just combine two floating point operations. These are no more problematic than+
and*
at compile-time. The behavior is is the same as calling these math functions in C (see C23 Standard, Annex F.8.4), however, it is not a constant expression in C++ if exceptions other thanFE_INEXACT
are raised,errno
is set, etc (see [library.c]/3).Weak FPENV Dependence (
constexpr
since C++26) [P1383r0]Other functions are dependent on the floating point environment, such as
std::sqrt
orstd::sin
. However, this dependence is called weak, because it's not explicitly stated, and it only exists because floating-point math is inherently imprecise.It would be arbitrary to allow
+
and*
at compile-time, but not math functions which have the exact same issues.Mathematical Special Functions (not
constexpr
yet, possibly in the future)[P1383r0] deemed it too ambitious to add
constexpr
to for mathematical special functions, such as:std::beta
std::riemann_zeta
Strong FPENV Dependence (not
constexpr
yet, possibly never)Some functions like
std::nearbyint
are explicitly stated to use the current rounding mode in the standard. This is problematic, because you cannot control the floating-point environment at compile time using standard means. Functions likestd::nearbyint
aren'tconstexpr
, and possibly never will be.Conclusion
In summary, there are many challenges facing the standard committee and compiler developers when dealing with
constexpr
math. It has taken decades of discussion to lift some restrictions onconstexpr
math functions, but we are finally here. The restrictions have ranged from arbitrary in the case ofstd::fabs
, to necessary in the case ofstd::nearbyint
.We are likely to see further restrictions lifted in the future, at least for mathematical special functions.