Floating-point std::saturating_cast

Document number:
P4355R0
Date:
2026-08-30
Audience:
SG6
Project:
ISO/IEC 14882 Programming Languages — C++, ISO/IEC JTC1/SC22/WG21
Reply-to:
Jan Schultke <janschultke@gmail.com>
Matt Borland <matt@mattborland.com>
GitHub Issue:
wg21.link/P4355/github
Source:
github.com/eisenwave/cpp-proposals/blob/main/src/float-saturating-cast.cow

std::saturating_cast should accept floating-point operands.

Contents

1

Introduction

1.1

Hardware support

1.2

Comparison with other languages

1.3

The problem

1.4

Proposal

2

Motivation

2.1

Quantization

2.1.1

Optimal output using Rust

2.1.2

Low-quality output using C++

2.2

Safety

2.3

Engine implementation

3

Design

3.1

Strategy

3.2

Handling of NaN

3.3

Floating-point exceptions and constant expressions

3.4

Other saturating operations

3.5

std::simd overloads

4

Impact on existing code

5

Implementation experience

5.1

Possible implementation

6

Wording

6.1

[version.syn]

6.2

[numeric]

6.3

[simd]

7

References

1. Introduction

C++ is currently an outlier among programming languages in terms of the conversion between floating-point types and integer types. The behavior is specified in [conv.fpint] paragraph 1:

A prvalue of a floating-point type can be converted to a prvalue of an integer type. The conversion truncates; that is, the fractional part is discarded. The behavior is undefined ([ub:conv.fpint.float.not.represented]) if the truncated value cannot be represented in the destination type.

Notably, any infinities and NaNs result in UB, and any value outside the representable range (possibly as the result of some numerical instability) results in UB.

1.1. Hardware support

The status quo actually makes sense because it allows for high performance on a variety of architectures. Mainstream architectures typically have an instruction for converting floating-point numbers to integers. Let's focus on the conversion from float32_t to int32_t.

Input CVTTSS2SI
(x86_64)
FCVT.W.S
(RISC-V)
FCVTZS
(AArch64)
i32.trunc_sat_s_f32
(WASM)
−∞ INT32_MIN INT32_MIN INT32_MIN INT32_MIN
−250 INT32_MIN INT32_MIN INT32_MIN INT32_MIN
small x trunc(x) trunc(x) trunc(x) trunc(x)
+250 INT32_MIN INT32_MAX INT32_MAX INT32_MAX
+∞ INT32_MIN INT32_MAX INT32_MAX INT32_MAX
NaN INT32_MIN INT32_MAX 0 0

Because everything but the handling of small values which are actually representable as the resulting integer is UB, the float → int conversion can always be lowered to a single instruction.

The INT32_MIN results on x86_64 are just an error indicator. The IA flag is also raised, indicating an invalid instruction.

For RISC-V, the assumption is that the RTZ (round to zero) rounding mode was explicitly specified.

1.2. Comparison with other languages

Similar to comparing hardware capabilities, we can also compare how other languages handle this conversion:

Input C++ Rust Java C# (checked) C# (unchecked)
−∞ UB INT32_MIN INT32_MIN exception thrown unspecified
−250 UB INT32_MIN INT32_MIN exception thrown unspecified
small x trunc(x) trunc(x) trunc(x) trunc(x) trunc(x)
+250 UB INT32_MAX INT32_MAX exception thrown unspecified
+∞ UB INT32_MAX INT32_MAX exception thrown unspecified
NaN UB 0 0 exception thrown unspecified

The key insight is that the saturating behavior is considered so useful that it is baked into the core language of Rust and Java.

When specifying -fno-strict-float-cast-overflow, Clang's behavior matches that of Rust and Java.

1.3. The problem

The problem is that making almost every part of the tables above undefined behavior is not very useful for some domains. It is fairly common that when quantizing floating-point values to integers, some values are outside the representable range, especially in the domain of signal processing.

In that domain, saturating behavior would be much more useful. Frustratingly, we have a std::saturating_cast function, but that function is only defined for integer types.

1.4. Proposal

The way to solve the problem would be to extend std::saturating_cast to also support floating-point types.

It would also be possible to make changes to the core language, aligning C++ with Rust and Java, but this would have considerable performance cost which massive amounts of existing code would silently incur. Clang users would all have to start using -fstrict-float-cast-overflow to restore the old behavior, but this flag would no longer be a compliant extension because it would introduced UB where the standard defines behavior.

2. Motivation

We already see some evidence that a saturating cast from floating-point to integer is useful. This is the behavior of the core-language conversion in Rust and Java, and AAarch64 has an instruction which implements this behavior exactly.

In C++, one can similarly imagine many applications for this behavior:

2.1. Quantization

It is common in calculations related to RGB colors to represent the linearized color values as floating-point numbers in the range [0, 1]. When stored as pixel values, they are typically quantized to 8-bit integers. It is also common for some values to fall outside the [0, 1] range due to numerical instability or because the original color space had a wider gamut. That makes the UB in the conversion from float to std::uint8_t a safety hazard. While the problem can be solved by clamping to the range [0, 1] before the conversion, this is pessimistic because on e.g. ARM, the underlying FCVTZU instruction already clamps (saturating has a clamping effect).

2.1.1. Optimal output using Rust

Looking at rustc output lets us compare to the theoretical optimum:

pub fn clamped(x: f32) -> u8 { return (x.clamp(0.0, 1.0) * 255f32) as u8; } pub fn unclamped(x: f32) -> u8 { return (x * 255f32) as u8; } example[ceccecd8beb92274]::clamped: movi d1, #0000000000000000 fcmp s0, #0.0 mov w8, #1132396544 mov w9, #255 fcsel s0, s1, s0, mi fmov s1, #1.00000000 fcmp s0, s1 fcsel s0, s1, s0, gt fmov s1, w8 fmul s0, s0, s1 fcvtzu w8, s0 cmp w8, #255 csel w0, w8, w9, lo ret example[ceccecd8beb92274]::unclamped: mov w8, #1132396544 mov w9, #255 fmov s1, w8 fmul s0, s0, s1 fcvtzu w8, s0 cmp w8, #255 csel w0, w8, w9, lo ret

The output for unclamped shows that f32 as u8 was simply lowered to a fcvtzu instruction, then clamped to the range [0, 255] via cmp and csel. This is the theoretical optimum we would expect.

2.1.2. Low-quality output using C++

C++ developers are essentially forced to get the clamped output (see https://godbolt.org/z/dqofdPW7a) because without clamping, they would run into UB for infinities and large values:

#include <algorithm> #include <cstdint> #include <numeric> std::uint8_t clamped(float x) { return std::saturating_cast<std::uint8_t>(unsigned(std::clamp(x, 0.f, 1.f) * 255)); } clamped(float): movi d1, #0000000000000000 fcmp s0, #0.0 mov w8, #1132396544 fcsel s0, s1, s0, mi fmov s1, #1.00000000 fcmp s0, s1 fcsel s0, s1, s0, gt fmov s1, w8 fmul s0, s0, s1 fcvtzu w8, s0 cmp w8, #256 csinv w0, w8, wzr, lo ret

The frustrating part is that the theoretical optimum on ARM is known, but there is no way to express it in the C++ language without UB. Getting to the optimal compiler output simply through optimizations also seems unrealistic. If std::saturating_cast accepted floating-point types, we could go straight from float to std::uint8_t, producing the same output as Rust's f32 as u8 conversion.

2.2. Safety

Another obvious problem with a language facility that has so much UB is safety. If someone wanted to avoid core language UB, float → int conversions would present a minefield, and there is no clear alternative.

std::saturating_cast is that safer alternative: whenever a C++ developer is unsure whether a float → int conversion is safe, they can use std::saturating_cast to avoid UB. For the conversions that are already well-defined, this results in no change in behavior, and on platforms like AAarch64 or WASM, it results in no performance cost either.

2.3. Engine implementation

The saturating behavior is gradually becoming the de-facto standard for how to handle conversions from floating-point to integer types. Languages like Rust and Java handle conversions like this and architectures like AAarch64 and WASM have instructions for this behavior. That makes it increasingly relevant for C++ to have such an operation as well, to make the implementation of compilers, WASM runtimes, emulators, etc. easier and more efficient.

3. Design

3.1. Strategy

The overall strategy for std::saturating_cast is to match the Rust and Java behavior, as well as the FCVTZS and i32.trunc_sat_s_f32 instructions.

3.2. Handling of NaN

The proposed behavior is to return zero for NaN inputs, which matches the aforementioned precedent.

One could also match the RISC-V FCVT.W.S behavior of returning INT32_MAX for NaN inputs, but there is a real benefit to keeping the result for NaN inputs distinct: If std::isfinite(x) is false for the original input, the result of the saturating cast also provides information about which non-finite value was passed in (−∞, +∞, or NaN).

3.3. Floating-point exceptions and constant expressions

The handling of floating-point exceptions should be consistent with the rest of the standard library, such as any functions in <cmath>, and should try to be consistent with the FCVTZS instruction. The Arm Architecture Reference Manual Armv8 specifies:

Invalid Operation

Occurs if the floating-point input is a NaN, infinity, or a numerical value that cannot be represented in the destination register. An out of range integer or fixed-point result is saturated to the size of the destination register.

Inexact

Occurs if the numeric result that differs from the input value.

Curiously, C's Annex F leaves it unspecified whether a float → int conversion raises the inexact exception, so out of caution and to grant some implementation freedom, we should not mandate it either.

A domain error should only take if the input is NaN. Otherwise, std::saturating_cast is quasi-useless during constant evaluation. static_cast and std::saturating_cast would be equivalent in that case because everything that is UB in static_cast turns into a domain error in std::saturating_cast, and domain errors disqualify expressions from being constant expressions in the standard library. It also doesn't make any design sense for an operation that is explicitly saturating to report a domain error when it saturates.

3.4. Other saturating operations

The rest of the saturation arithmetic library is not affected. That is, there is no floating-point support for std::saturating_add etc. These functions are simply not in scope for the paper, and the motivation does not apply to them because floating-point operations are already saturating (in the sense that they clamp to −∞ and +∞).

I also don't propose floating-point types as a result of std::saturating_cast because converting to floating-point types is already quasi-saturating.

3.5. std::simd overloads

[P2956R3] proposes std::simd overloads for saturation arithmetic, including std::saturating_cast. At the time of writing, the paper is in LWG, so we expect it to be included in C++29. Naturally, std::simd::saturating_cast should also support floating-point types.

4. Impact on existing code

The proposed change only relaxes the constraints on std::saturating_cast to allow floating-point types. Existing uses with integer operands are not affected.

5. Implementation experience

The proposed behavior is available in LLVM as the llvm.fptoui.sat.* and llvm.fptosi.sat.* family of intrinsics. However, these are not yet exposed as a standalone Clang intrinsic. To emit them, one must specify -fno-strict-float-cast-overflow, and that doesn't have any effect on constant evaluation, so infinities and NaNs need explicit handling at compile time.

5.1. Possible implementation

A pure library implementation with some if statements for range checks is theoretically possible, but a waste of time from an implementer viewpoint. The goal is to emit exactly one e.g. FCVTZS instruction for the conversion (or whatever the minimal equivalent is on other architectures), and this requires compiler intrinsics. Nonetheless, a library implementation looks as follows:

#include <limits> #include <cmath> #include <bit> template<class R, class F> constexpr R saturating_cast(F x) noexcept { if (std::isnan(x)) { if consteval { return R(x); // Make the call not a constant expression. } else { // TODO: Emit domain error. // On platforms like ARM where NaN gets converted to zero by static_cast, // we could simply return R(x) here, // but that also depends on whether R(NaN) is treated // as an optimization opportunity (poison value) instead of being zero. return R(0); } } if constexpr (std::numeric_limits<R>::digits >= std::numeric_limits<F>::max_exponent) { // Every truncated finite number is representable as an integer of type R, // so we don't have to worry about numbers with large magnitude when casting. // Infinities and NaNs are the only non-representable values. // // In practice, this happens in e.g. float32_t → unsigned __int128 if (std::isinf(x)) { return x < F(0) ? std::numeric_limits<R>::lowest() : std::numeric_limits<R>::max(); } } else if constexpr (std::is_signed_v<R>) { // Otherwise, we need to compute the bounds of the range (min, max) // (i.e. exclusive on both ends). // that does not cause casting to have undefined behavior. // Infinities also fall outside that range and need no explicit handling. // Since integers use two's complement, // this is one past the greatest integer, // so already the bound we are looking for. constexpr F max = -F(std::numeric_limits<R>::lowest()); if (x >= max) { return std::numeric_limits<R>::max(); } // We use lowest() because it is a power of two, // so there is no risk of rounding. // It can only overflow to infinity. constexpr F min = F(std::numeric_limits<R>::lowest()); if constexpr (min - 1 < min) { // Numbers within 1 distance of lowest() get truncated to lowest(), // so lowest() is not the bound; the next lower integer is the bound. if (x <= min - 1) { return std::numeric_limits<R>::lowest(); } } else if (x < min) { // In this case, there exists no integer directly below lowest() // due to limited floating-point precision. // This makes lowest() a genuine exclusive bound. return std::numeric_limits<R>::lowest(); } } else { // We use bit_floor to get a power of two, avoiding rounding issues. // Multiplication by two then gets us one past the greatest integer. constexpr F max = F(std::bit_floor(std::numeric_limits<R>::max())) * 2; if (x >= max) { return std::numeric_limits<R>::max(); } // Since the conversion truncates, negative values greater than -1 result in 0. // Cast UB only happens if the truncated value is not representable in the result. if (x <= F(-1)) { return std::numeric_limits<R>::lowest(); } } return R(x); }

6. Wording

The changes are relative to [N5054].

[version.syn]

In [version.syn], bump the __cpp_lib_saturation_arithmetic feature-test macro.

#define __cpp_lib_saturation_arithmetic 202603L 20XXXXL // freestanding, also in <numeric>

[numeric]

Change [numeric.sat.cast] as follows:

template<class R, class T> constexpr R saturating_cast(T x) noexcept;

Constraints: R and T are is a signed or unsigned integer types ([basic.fundamental]). T is a signed or unsigned integer type or a cv-unqualified floating-point type.

Returns: If x is NaN, zero. Otherwise, let v be the value of x with the fractional part (if any) discarded. If x v is representable as a value of type R, return x v; otherwise, returns either the largest or smallest representable value of type R, whichever is closer to the value of x v.

Remarks: If and only if T is is a floating-point type, floating-point exceptions may be raised, unless the value of x is exactly representable in type R. Whether the inexact exception is raised is a property of the implementation. A domain error occurs if x is NaN, in which case a function call expression is not a constant expression.

SEE ALSO: ISO/IEC 9899:2024 7.12.1

[simd]

If [P2956R3] has been applied, change [simd.syn] as follows:

template<class U, simd-integral simd-vec-type V> constexpr rebind_t<U, V> saturating_cast(const V& v) noexcept;

If [P2956R3] has been applied, change [simd.alg] as follows:

template<class U, simd-integral simd-vec-type V> constexpr rebind_t<U, V> saturating_cast(const V& v) noexcept;

Constraints: Both U and typename V::value_type are is a signed or unsigned integer types ([basic.fundamental]). typename V::value_type is a signed or unsigned integer type or a cv-unqualified floating-point type.

Returns: A rebind_t<U, V> where the ith element is initialized to the result of saturating_cast<U>(v[i]) for all i in the range [0, V::size()).

7. References

[N5054] Thomas Köppe. Working Draft, Programming Languages — C++ 2026-07-16 https://open-std.org/jtc1/sc22/wg21/docs/papers/2026/n5054.pdf
[P2956R3] Daniel Towner, Ruslan Arutyunyan. Allow std::simd overloads for saturating operations 2026-06-11 https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2026/p2956r3.html