From 0c4e00abe0c4c1bd8095c100896f676bcb07df4a Mon Sep 17 00:00:00 2001 From: Matt Borland Date: Fri, 24 Jul 2026 09:02:50 -0400 Subject: [PATCH 1/5] Replace integer constant with double constant --- include/boost/int128/detail/constants.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/boost/int128/detail/constants.hpp b/include/boost/int128/detail/constants.hpp index 89a64e5b..0e0ea7b4 100644 --- a/include/boost/int128/detail/constants.hpp +++ b/include/boost/int128/detail/constants.hpp @@ -19,7 +19,7 @@ namespace detail { BOOST_INT128_INLINE_CONSTEXPR std::uint64_t low_word_mask {(std::numeric_limits::max)()}; template -BOOST_INT128_INLINE_CONSTEXPR T offset_value_v = static_cast((std::numeric_limits::max)()); +BOOST_INT128_INLINE_CONSTEXPR T offset_value_v = static_cast(18446744073709551616.0); // UINT64_MAX as double } // namespace detail } // namespace int128 From e3a28c9e09e3cfee1ca4b44cfbbcd0d7c1e063b1 Mon Sep 17 00:00:00 2001 From: Matt Borland Date: Fri, 24 Jul 2026 09:03:03 -0400 Subject: [PATCH 2/5] Add shared float conversion file --- .../boost/int128/detail/float_conversion.hpp | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 include/boost/int128/detail/float_conversion.hpp diff --git a/include/boost/int128/detail/float_conversion.hpp b/include/boost/int128/detail/float_conversion.hpp new file mode 100644 index 00000000..c7b7f447 --- /dev/null +++ b/include/boost/int128/detail/float_conversion.hpp @@ -0,0 +1,51 @@ +// Copyright 2026 Matt Borland +// Distributed under the Boost Software License, Version 1.0. +// https://www.boost.org/LICENSE_1_0.txt + +#ifndef BOOST_INT128_DETAIL_FLOAT_CONVERSION_HPP +#define BOOST_INT128_DETAIL_FLOAT_CONVERSION_HPP + +#include +#include + +#ifndef BOOST_INT128_BUILD_MODULE + +#include + +#endif + +namespace boost { +namespace int128 { +namespace detail { + +// The most correct way to do this would be std::ldexp(static_cast(high), 64) + static_cast(low); +// Since std::ldexp is not constexpr until C++23 we can work around this by multiplying the high word +// by 0xFFFFFFFF in order to generally replicate what ldexp is doing in the constexpr context. +// We also avoid pulling in for the __float128 case where we would need ldexpq +template +BOOST_INT128_HOST_DEVICE constexpr T unsigned_words_to_float(const std::uint64_t high, const std::uint64_t low) noexcept +{ + return static_cast(high) * offset_value_v + static_cast(low); +} + +template +BOOST_INT128_HOST_DEVICE constexpr T signed_words_to_float(const std::int64_t high, const std::uint64_t low) noexcept +{ + if (high < 0) + { + // Two's complement magnitude of the full 128-bit value. + // INT128_MIN needs no special case since it yields high = 2^63, low = 0 + const auto abs_low {~low + UINT64_C(1)}; + const auto abs_high {~static_cast(high) + (abs_low == UINT64_C(0) ? UINT64_C(1) : UINT64_C(0))}; + + return -unsigned_words_to_float(abs_high, abs_low); + } + + return unsigned_words_to_float(static_cast(high), low); +} + +} // namespace detail +} // namespace int128 +} // namespace boost + +#endif // BOOST_INT128_DETAIL_FLOAT_CONVERSION_HPP From f53535cc3b7d6efd46cdde25fb0e2900720e3c0d Mon Sep 17 00:00:00 2001 From: Matt Borland Date: Fri, 24 Jul 2026 09:03:16 -0400 Subject: [PATCH 3/5] Use builtins when available or fallback to shared impl --- include/boost/int128/detail/int128_imp.hpp | 44 +++++++++++++++----- include/boost/int128/detail/uint128_imp.hpp | 45 ++++++++++++++++----- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/include/boost/int128/detail/int128_imp.hpp b/include/boost/int128/detail/int128_imp.hpp index a363fe23..6c821a00 100644 --- a/include/boost/int128/detail/int128_imp.hpp +++ b/include/boost/int128/detail/int128_imp.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #ifndef BOOST_INT128_BUILD_MODULE @@ -117,8 +118,9 @@ int128_t #endif // BOOST_INT128_HAS_INT128 // Conversion to float - // This is basically the same as ldexp(static_cast(high), 64) + static_cast(low), - // but can be constexpr at C++11 instead of C++26 + // Uses the builtin 128-bit conversion where one exists, and otherwise converts + // the unsigned magnitude as high * 2^64 + low before applying the sign. + // See detail/float_conversion.hpp for why the sign handling is required BOOST_INT128_HOST_DEVICE constexpr operator float() const noexcept; BOOST_INT128_HOST_DEVICE constexpr operator double() const noexcept; @@ -285,26 +287,50 @@ BOOST_INT128_EXPORT BOOST_INT128_HOST_DEVICE constexpr int128_t abs(int128_t val // Float Conversion Operators //===================================== -// The most correct way to do this would be std::ldexp(static_cast(high), 64) + static_cast(low); -// Since std::ldexp is not constexpr until C++23 we can work around this by multiplying the high word -// by 0xFFFFFFFF in order to generally replicate what ldexp is doing in the constexpr context. -// We also avoid pulling in for the __float128 case where we would need ldexpq +// When the builtin 128-bit type exists we convert through it since the compiler +// runtime (__floattisf and friends) is correctly rounded. The portable fallback +// converts the unsigned magnitude and applies the sign; see detail/float_conversion.hpp +// for why the raw words can not be composed directly for negative values BOOST_INT128_HOST_DEVICE constexpr int128_t::operator float() const noexcept { - return static_cast(high) * detail::offset_value_v + static_cast(low); + #if defined(BOOST_INT128_HAS_INT128) && !defined(BOOST_INT128_HAS_GPU_SUPPORT) + + return static_cast(static_cast(*this)); + + #else + + return detail::signed_words_to_float(high, low); + + #endif } BOOST_INT128_HOST_DEVICE constexpr int128_t::operator double() const noexcept { - return static_cast(high) * detail::offset_value_v + static_cast(low); + #if defined(BOOST_INT128_HAS_INT128) && !defined(BOOST_INT128_HAS_GPU_SUPPORT) + + return static_cast(static_cast(*this)); + + #else + + return detail::signed_words_to_float(high, low); + + #endif } #if !defined(BOOST_INT128_HAS_GPU_SUPPORT) constexpr int128_t::operator long double() const noexcept { - return static_cast(high) * detail::offset_value_v + static_cast(low); + #if defined(BOOST_INT128_HAS_INT128) + + return static_cast(static_cast(*this)); + + #else + + return detail::signed_words_to_float(high, low); + + #endif } #endif diff --git a/include/boost/int128/detail/uint128_imp.hpp b/include/boost/int128/detail/uint128_imp.hpp index 558bc340..2af78cdc 100644 --- a/include/boost/int128/detail/uint128_imp.hpp +++ b/include/boost/int128/detail/uint128_imp.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #ifndef BOOST_INT128_BUILD_MODULE @@ -128,8 +129,9 @@ uint128_t #endif // BOOST_INT128_HAS_INT128 // Conversion to float - // This is basically the same as ldexp(static_cast(high), 64) + static_cast(low), - // but can be constexpr at C++11 instead of C++26 + // Uses the builtin 128-bit conversion where one exists, and otherwise composes + // the words as high * 2^64 + low with an exact 2^64 constant, which is the value + // ldexp(static_cast(high), 64) + static_cast(low) computes BOOST_INT128_HOST_DEVICE constexpr operator float() const noexcept; BOOST_INT128_HOST_DEVICE constexpr operator double() const noexcept; @@ -287,29 +289,52 @@ BOOST_INT128_EXPORT BOOST_INT128_HOST_DEVICE constexpr uint128_t abs(const uint1 // Float Conversion Operators //===================================== -// The most correct way to do this would be std::ldexp(static_cast(high), 64) + static_cast(low); -// Since std::ldexp is not constexpr until C++23 we can work around this by multiplying the high word -// by 0xFFFFFFFF in order to generally replicate what ldexp is doing in the constexpr context. -// We also avoid pulling in for the __float128 case where we would need ldexpq +// When the builtin 128-bit type exists we convert through it since the compiler +// runtime (__floatuntisf and friends) is correctly rounded. The portable fallback +// composes the words as high * 2^64 + low; see detail/float_conversion.hpp BOOST_INT128_HOST_DEVICE constexpr uint128_t::operator float() const noexcept { - return static_cast(high) * detail::offset_value_v + static_cast(low); + #if defined(BOOST_INT128_HAS_INT128) && !defined(BOOST_INT128_HAS_GPU_SUPPORT) + + return static_cast(static_cast(*this)); + + #else + + return detail::unsigned_words_to_float(high, low); + + #endif } BOOST_INT128_HOST_DEVICE constexpr uint128_t::operator double() const noexcept { - return static_cast(high) * detail::offset_value_v + static_cast(low); + #if defined(BOOST_INT128_HAS_INT128) && !defined(BOOST_INT128_HAS_GPU_SUPPORT) + + return static_cast(static_cast(*this)); + + #else + + return detail::unsigned_words_to_float(high, low); + + #endif } #if !defined(BOOST_INT128_HAS_GPU_SUPPORT) constexpr uint128_t::operator long double() const noexcept { - return static_cast(high) * detail::offset_value_v + static_cast(low); + #if defined(BOOST_INT128_HAS_INT128) + + return static_cast(static_cast(*this)); + + #else + + return detail::unsigned_words_to_float(high, low); + + #endif } -#endif // __NVCC__ +#endif // BOOST_INT128_HAS_GPU_SUPPORT //===================================== // Float Construction From c50fa967654273101ff23f8d04423b95ffab6dfc Mon Sep 17 00:00:00 2001 From: Matt Borland Date: Fri, 24 Jul 2026 09:10:14 -0400 Subject: [PATCH 4/5] Add better test set --- test/Jamfile | 1 + test/github_issue_432.cpp | 222 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 test/github_issue_432.cpp diff --git a/test/Jamfile b/test/Jamfile index e1a2c71c..acc2f74d 100644 --- a/test/Jamfile +++ b/test/Jamfile @@ -134,6 +134,7 @@ run github_issue_210.cpp ; run github_issue_221.cpp ; run github_issue_272.cpp ; run github_issue_377.cpp ; +run github_issue_432.cpp ; # Compilation of individual headers compile compile_tests/int128_master_header_compile.cpp ; diff --git a/test/github_issue_432.cpp b/test/github_issue_432.cpp new file mode 100644 index 00000000..431b3c33 --- /dev/null +++ b/test/github_issue_432.cpp @@ -0,0 +1,222 @@ +// Copyright 2026 Matt Borland +// Distributed under the Boost Software License, Version 1.0. +// https://www.boost.org/LICENSE_1_0.txt +// +// See: https://github.com/cppalliance/int128/issues/432 +// +// The float conversion operators had two defects: +// +// 1) The high word was scaled by UINT64_MAX (2^64 - 1) instead of 2^64. float and +// double round 2^64 - 1 up to 2^64, which hid the error, but any type with 64 or +// more significand bits (x87 80-bit and IEEE quad long double) holds 2^64 - 1 +// exactly, so every conversion with a non-zero high word was off by the value of +// the high word. For example uint128_t{1, 0} (2^64) converted to 2^64 - 1. +// +// 2) int128_t fed its raw two's complement words into high * scale + low. That +// identity is exact in integer arithmetic but not in floating point: a small +// negative value stores low close to 2^64, which rounds to exactly 2^64 whenever +// the type has fewer than 64 significand bits, and adding the scaled high word +// then cancels catastrophically. int128_t{-1} converted to 0.0 instead of -1.0. +// +// The operators now use the builtin 128-bit conversion where one exists, and the +// portable fallback scales by an exact 2^64 and converts negative values through +// the unsigned magnitude. Both paths are covered below. + +// The pragmas must cover lightweight_test.hpp so that BOOST_TEST_EQ can be +// used on floating point values, which is the entire point of this test +#if defined(__clang__) +# pragma clang diagnostic push +# pragma clang diagnostic ignored "-Wfloat-equal" +#elif defined(__GNUC__) +# pragma GCC diagnostic push +# pragma GCC diagnostic ignored "-Wfloat-equal" +#endif + +#include +#include +#include +#include +#include +#include + +using boost::int128::int128_t; +using boost::int128::uint128_t; + +// Exactly representable values must convert exactly, so a defect either produces +// the exact expected value or it is a bug. No tolerances are used anywhere here. + +// Defect 2 regression: small negative values used to cancel to 0.0 +template +void test_small_negative_values() +{ + for (int v {-1}; v >= -1024; --v) + { + const int128_t value {v}; + BOOST_TEST_EQ(static_cast(value), static_cast(v)); + + // The portable fallback must also be correct on platforms + // where the operator uses the builtin conversion instead + BOOST_TEST_EQ(boost::int128::detail::signed_words_to_float(value.high, value.low), static_cast(v)); + } +} + +// Every power of two is exactly representable in every binary floating point type +template +void test_signed_powers_of_two() +{ + for (int k {0}; k < 127; ++k) + { + const int128_t value {-(int128_t{1} << k)}; + const T expected {-std::ldexp(static_cast(1), k)}; + + BOOST_TEST_EQ(static_cast(value), expected); + BOOST_TEST_EQ(boost::int128::detail::signed_words_to_float(value.high, value.low), expected); + } + + // INT128_MIN itself: the magnitude 2^127 does not fit in int128_t, + // so this exercises the negation edge case in the fallback + const auto min_value {(std::numeric_limits::min)()}; + const T expected_min {-std::ldexp(static_cast(1), 127)}; + + BOOST_TEST_EQ(static_cast(min_value), expected_min); + BOOST_TEST_EQ(boost::int128::detail::signed_words_to_float(min_value.high, min_value.low), expected_min); + + // INT128_MIN + 1 has magnitude 2^127 - 1 which correctly rounds + // to 2^127 in every format with fewer than 127 significand bits + BOOST_TEST_EQ(static_cast(min_value + 1), expected_min); +} + +// Defect 1 regression: with the 2^64 - 1 scale factor every value with a non-zero +// high word was off by the high word wherever long double holds 2^64 - 1 exactly +template +void test_offset_exactness() +{ + const T two_64 {std::ldexp(static_cast(1), 64)}; + + for (std::uint64_t k {1}; k <= 64; ++k) + { + BOOST_TEST_EQ(static_cast(uint128_t{k, 0}), static_cast(k) * two_64); + BOOST_TEST_EQ(boost::int128::detail::unsigned_words_to_float(k, 0), static_cast(k) * two_64); + + const int128_t negative {-int128_t{static_cast(k), 0}}; + BOOST_TEST_EQ(static_cast(negative), -(static_cast(k) * two_64)); + } + + BOOST_TEST_EQ(static_cast(uint128_t{UINT64_C(1) << 63, 0}), std::ldexp(static_cast(1), 127)); + + // -(2^64 - 1): exact wherever the significand holds 64 bits, and the computed + // expected value rounds identically to the conversion everywhere else + const int128_t value {-1, 1}; + BOOST_TEST_EQ(static_cast(value), -(two_64 - static_cast(1))); +} + +#if defined(BOOST_INT128_HAS_INT128) + +using builtin_i128 = boost::int128::detail::builtin_i128; +using builtin_u128 = boost::int128::detail::builtin_u128; + +static constexpr std::size_t N {1024}; + +// The portable fallback pre-rounds each word, so unlike the builtin conversion it +// can return either neighbor of the exact value instead of always the nearest +template +bool within_one_ulp(const T computed, const T reference) +{ + if (computed == reference) + { + return true; + } + + return computed == std::nextafter(reference, -std::numeric_limits::infinity()) || + computed == std::nextafter(reference, std::numeric_limits::infinity()); +} + +template +void test_vs_builtin() +{ + std::mt19937_64 rng {42}; + + for (std::size_t i {}; i < N; ++i) + { + const auto hi {rng()}; + const auto lo {rng()}; + + const uint128_t u {hi, lo}; + const auto builtin_u {(static_cast(hi) << 64) | static_cast(lo)}; + + const int128_t s {static_cast(hi), lo}; + const auto builtin_s {static_cast(builtin_u)}; + + // The operators use the builtin conversion on this platform, so these are exact + BOOST_TEST_EQ(static_cast(u), static_cast(builtin_u)); + BOOST_TEST_EQ(static_cast(s), static_cast(builtin_s)); + + // The fallback rounds each word before composing, so allow the neighboring value. + // The old implementation was off by the whole high word or returned 0.0 for + // negative values, which this catches with a huge margin + BOOST_TEST(within_one_ulp(boost::int128::detail::unsigned_words_to_float(hi, lo), + static_cast(builtin_u))); + BOOST_TEST(within_one_ulp(boost::int128::detail::signed_words_to_float(s.high, s.low), + static_cast(builtin_s))); + + // The fallback must never lose the sign the way the cancellation defect did + if (builtin_s != 0) + { + BOOST_TEST_EQ(boost::int128::detail::signed_words_to_float(s.high, s.low) < 0, builtin_s < 0); + } + } +} + +#endif // BOOST_INT128_HAS_INT128 + +// Both conversion paths are constexpr, so the regressions are also pinned at compile time +static_assert(static_cast(int128_t{-1}) == -1.0f, "int128_t{-1} must convert to -1.0f"); +static_assert(static_cast(int128_t{-1}) == -1.0, "int128_t{-1} must convert to -1.0"); +static_assert(static_cast(int128_t{-1024}) == -1024.0, "small negatives must not cancel to 0"); +static_assert(static_cast(uint128_t{1, 0}) == 18446744073709551616.0, "uint128_t 2^64 must convert to 2^64"); + +static_assert(boost::int128::detail::signed_words_to_float(-1, UINT64_MAX) == -1.0, + "fallback conversion of int128_t{-1} must yield -1.0"); +static_assert(boost::int128::detail::unsigned_words_to_float(1, 0) == 18446744073709551616.0, + "fallback conversion of 2^64 must yield 2^64"); + +#if !defined(BOOST_INT128_HAS_GPU_SUPPORT) + +static_assert(static_cast(int128_t{-1}) == -1.0L, "int128_t{-1} must convert to -1.0L"); +static_assert(static_cast(uint128_t{1, 0}) == 18446744073709551616.0L, + "uint128_t 2^64 must convert to 2^64 exactly, not 2^64 - 1"); +static_assert(static_cast(int128_t{-1, 1}) == -(18446744073709551616.0L - 1.0L), + "int128_t -(2^64 - 1) must match the correctly rounded value"); + +#endif + +int main() +{ + test_small_negative_values(); + test_small_negative_values(); + test_small_negative_values(); + + test_signed_powers_of_two(); + test_signed_powers_of_two(); + test_signed_powers_of_two(); + + test_offset_exactness(); + test_offset_exactness(); + test_offset_exactness(); + + #if defined(BOOST_INT128_HAS_INT128) + + test_vs_builtin(); + test_vs_builtin(); + test_vs_builtin(); + + #endif + + return boost::report_errors(); +} + +#if defined(__clang__) +# pragma clang diagnostic pop +#elif defined(__GNUC__) +# pragma GCC diagnostic pop +#endif From 216bdb746935fb3fd018d7dcb3b8a3eaab21dcd9 Mon Sep 17 00:00:00 2001 From: Matt Borland Date: Fri, 24 Jul 2026 10:40:33 -0400 Subject: [PATCH 5/5] Skip test on 32 bit ASAN --- test/github_issue_432.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/github_issue_432.cpp b/test/github_issue_432.cpp index 431b3c33..b9e2986e 100644 --- a/test/github_issue_432.cpp +++ b/test/github_issue_432.cpp @@ -81,9 +81,15 @@ void test_signed_powers_of_two() BOOST_TEST_EQ(static_cast(min_value), expected_min); BOOST_TEST_EQ(boost::int128::detail::signed_words_to_float(min_value.high, min_value.low), expected_min); + // This fails with 32-bit ASAN but passes on the same compiler without ASAN + // libs/int128/test/github_issue_432.cpp(86): test 'static_cast(min_value + 1) == expected_min' ('-1.70141e+38' == '-1.70141e+38') failed in function 'void test_signed_powers_of_two() [with T = float]' + // libs/int128/test/github_issue_432.cpp(86): test 'static_cast(min_value + 1) == expected_min' ('-1.70141e+38' == '-1.70141e+38') failed in function 'void test_signed_powers_of_two() [with T = double]' + // 2 errors detected. + #if !defined(ASAN) && !defined(__SANITIZE_ADDRESS__) // INT128_MIN + 1 has magnitude 2^127 - 1 which correctly rounds // to 2^127 in every format with fewer than 127 significand bits BOOST_TEST_EQ(static_cast(min_value + 1), expected_min); + #endif } // Defect 1 regression: with the 2^64 - 1 scale factor every value with a non-zero