Skip to content

sqrt of a point interval can lose its upper end with g++ -O2 -frounding-math on x86-64 (1.92.0 and develop) #60

Closed

Description

@SonnyChecksIn

With the default policy, sqrt(interval<double>(x)) for a point interval can return an interval whose upper end is below the true square root. Seen with g++ 13.3.0 at -O2 -frounding-math (also -O3) on x86-64 Linux, with Boost 1.92.0 and with develop at ef305d5.

Reproducer

#include <boost/numeric/interval.hpp>
#include <cmath>
#include <cstdio>

typedef boost::numeric::interval<double> I;
volatile double inputs[2] = {3.0, 2.0};

int main() {
  int lost = 0;
  for (int i = 0; i < 2; ++i) {
    double x = inputs[i];
    I y = sqrt(I(x));
    bool ok = std::fma(y.upper(), y.upper(), -x) >= 0;  // exact sign of u*u - x
    std::printf("sqrt(%g): [%a, %a] %s\n", x, y.lower(), y.upper(),
                ok ? "encloses" : "UPPER END BELOW THE TRUE ROOT");
    if (!ok) ++lost;
  }
  return lost;
}

g++ -std=c++17 -O2 -frounding-math prints:

sqrt(3): [0x1.bb67ae8584caap+0, 0x1.bb67ae8584caap+0] UPPER END BELOW THE TRUE ROOT
sqrt(2): [0x1.6a09e667f3bccp+0, 0x1.6a09e667f3bccp+0] UPPER END BELOW THE TRUE ROOT

The expected result for 3 is [0x1.bb67ae8584caap+0, 0x1.bb67ae8584cabp+0]. That is what g++ -O0 and clang 18.1.3 at -O0, -O2 and -O3 print. Code shape matters. A single straight-line call did not miss at plain -O2 -frounding-math, because one of the two roots went through the C library sqrt call (the errno path). Adding -fno-math-errno (which -ffast-math also implies) makes that single call miss too.

Cause

rounded_arith_opp::sqrt_down sets downward rounding and evaluates sqrt(x). sqrt_up evaluates sqrt(x) again, relying on upward mode. For a point interval both receive the same value. Inside a loop, g++ emits one sqrtsd under downward mode and uses its result for both ends. The mode-change calls still happen, but the second root is never computed. The volatile store in force_rounding protects the result, not the operand. rounded_arith_std has the same shape and fails the same way. This is GCC bug 34678 (a value reused across a rounding-mode change), reached through this library.

Wider test. 96 point inputs (14 fixed values, including four exact squares, and 82 seeded values in [1, 4)), with each end checked exactly:

Build Default policy rounded_arith_std
g++ 13.3.0 -O2 or -O3 -frounding-math; -O2 alone; -O3 -march=native; -O2 -flto 92 of 96 miss (all upper ends) 92 of 96 miss
g++ -O0 or -O1; clang 18.1.3 -O0, -O2, -O3, or -O2 -ffp-model=strict; g++ -mfpmath=387 0 0

The existing tests mul.cpp and test_float.cpp pass in the failing builds. Their square-root checks use wide intervals, where the two ends have different operands.

At g++ -O2 and -O3, rounded_arith_std also loses point add, subtract, multiply and divide: 44, 10, 60 and 62 misses out of 64 pairs. The default policy computes the lower end with a negated expression and has 0 misses there.

Proposed fix (one store and one load per root). After setting the mode, read the operand back through a volatile local, so that the two roots cannot be merged:

namespace detail {
template<class T> inline T reload_operand(const T& x) { return x; }
inline float reload_operand(const float& x) { volatile float v = x; return v; }
inline double reload_operand(const double& x) { volatile double v = x; return v; }
inline long double reload_operand(const long double& x) { volatile long double v = x; return v; }
}
// rounded_arith_opp
T sqrt_down(const T& x)
{ BOOST_NUMERIC_INTERVAL_using_math(sqrt);
  this->downward(); T y = detail::reload_operand(x);
  T r = this->force_rounding(sqrt(y)); this->upward(); return r; }
T sqrt_up(const T& x)
{ BOOST_NUMERIC_INTERVAL_using_math(sqrt);
  T y = detail::reload_operand(x); return this->force_rounding(sqrt(y)); }

rounded_arith_std gets the same change. A unified diff applies cleanly to 1.92.0, to develop and to PR 58. With it, there are 0 misses in every build above, on 1.92.0 and on develop. A second, optional diff does the same for the eight add, subtract, multiply and divide functions of rounded_arith_std. With both diffs, there are 0 misses at g++ and clang -O0, -O2 and -O3.

Limits

What I can send: the two diffs, a regression test using lightweight_test.hpp (it includes an exact-policy control that must fail, and does), the probe for the four operations, and the full run log. A pull request with the fix and the test follows and links here.

Related: #23 and #54; GCC bug 34678; Trac ticket 12018 and pull request 4 (the same kind of merge in rounded_arith_opp add, multiply and divide under MSVC and clang); PR 58.

Sonny Guardian

Activity

  1. added a commit that references this issue on Oct 21, 2026
  2. mclow commented on Oct 21, 2026

    @mclow
    Contributor

    Is this a new bug in 1.92.0 - or has it been around for a while?

    I ask because I see that gcc bug #34678 is almost 20 years old.

  3. SonnyChecksIn commented on Oct 21, 2026

    @SonnyChecksIn
    Author

    Not new, as far as I can tell. The sqrt_down/sqrt_up bodies in rounded_arith_std and rounded_arith_opp are unchanged since the library was imported (git blame points to 135f901, 2003-01-22), so the library side has had this shape all along. What exposes it is the optimiser behaviour in GCC bug 34678: a value reused across a rounding-mode change. When that happens to the two roots of a point interval, the upper end is lost.

    I’ve only tested Boost 1.92.0 and develop with g++ 13.3.0 and clang 18.1.3, so I can’t say which GCC version first started merging these in practice. I haven’t bisected older compilers or Boost releases. I’m happy to run the reproducer against older GCC versions if that would help decide where the fix belongs.

    I uh... just like solving puzzles.

  4. mclow commented on Oct 21, 2026

    @mclow
    Contributor

    And I appreciate the analysis.

    Don't misinterpret my question - I'm not going to argue whether or not it should be fixed, just trying to figure out an approach. If this was a new bug, we could look at recent changes, and see what might have triggered the GCC behavior. But, as you say, if the code is unchanged from 2003, (and the GCC bug was first reported in 2008), then this has been around for a long, long time. (Almost 20 years).

  5. SonnyChecksIn commented on Oct 21, 2026

    @SonnyChecksIn
    Author

    When numbers on my screen do not make sense with the numbers I see in my head and I cannot explain it, I need to find out why.

  6. reach2sayan commented on Oct 21, 2026

    @reach2sayan
    Contributor

    @SonnyChecksIn @mclow @Lastique I think this is the story around it is well rounded. So far there are 3 bugs at play:

    1. library bug - On 32-bit x86, the library's GCC/clang path set the rounding mode with fldcw, which writes only the x87 control register. x86 has a second floating-point unit, SSE, with its own register, and that one was never touched. Set the x86 rounding mode with fesetround (fixes #23, #35, #41) #58 sets the mode with fesetround, which updates both registers (although in draft now, for some more changes are required) -
    2. gcc bug - @SonnyChecksIn woraround Stop g++ merging the two square roots of a point interval #61 reads the input through a volatile object just after each rounding switch
    3. msvc bug (tested on my CI - after including the above two fixed) - (32 bit only) microsoft crt std::sqrt ignores the rounding mode, fix : MSVC/clang-cl, compute both rounded roots with the x87 fsqrt instruction, as the library already does for rint with frndint.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions