The methods frange::set() and frange::flush_denormals_to_zero() canonicalize ranges containing floating point zero endpoints differently. Whereas set() includes both +0.0 and -0.0 in the range, so contains_p([+-]0.0) works, even under -ffast-math, flush_denormals_to_zero() always sets the lower bound of a positive denormal as +0.0, regardless of HONOR_SIGNED_ZEROS. This means that
contains_p(-0.0) would theoretically fail under -ffast-math.

In reality this last scenario doesn't happen because all users of zero endpoints in the code are special casing signed zeros. Also, building a range with -0.0
would cause the set() canonicalization to kick in, and set the range to
[-0.0, +0.0] anyhow. Not only is this fragile at best, but in follow-up patches I'd like to do some cleanup in this area, and one representation across the board is in the spirit of what we've always tried to do with irange: a given range should be representable in only one way (ok, not exactly cause we have
irange_bitmask's which complicate things, but we try ;-)).

Ultimately I'd like to implement sub-ranges for frange so we can represent inequality with a constant, and tackle a couple of the signed zero PRs in this
area.  But initially I just want to provide an frange_cmp() total order
comparison function that respects signed zeros, and lets users not have to worry
about special casing anything when dealing with endpoints.

For example, frange_cmp() will order -0.0 strictly below +0.0, so a membership test becomes just two endpoint comparisons: -0.0 lands outside [+0.0, x] but inside [-0.0, x], and nobody has to first ask "is this a zero, and if so which one?". But that only works if a range always spells its zero endpoints the same way. Today set() spells the lower zero of [0.0, 5.0] as -0.0 under -ffast-math, while flush_denormals_to_zero() spells it +0.0, so one range ends up with two bit patterns, and frange_cmp() would tell you [+0.0, 5.0] is a strict subset of [-0.0, 5.0] even though they are the same range. Once both producers run through canonicalize_zeros() the order is well defined, and when frange_cmp() gets implemented, contains_p(), and the eventual sub-range union/intersect code
can just call it and drop their signed-zero special cases entirely.

This patch moves the canonicalization into one function that both set() and flush_denormals_to_zero() can share. With it flush_denormals_to_zero() moves the denormal endpoint to the zero on its side and lets canonicalize_zeros()
settle the sign, exactly as set() does.

I've included a selftest to test this: under -ffast-math, flushing
[+DENORM, 5.0] used to yield [+0.0, 5.0], while set() on the same range yields [-0.0, 5.0]. With this patch, flushing also yields [-0.0, 5.0], matching set().

There's no observable change with this patch-- every consumer that reads the sign of a zero endpoint already special-cases !HONOR_SIGNED_ZEROS, so nothing
today can tell the two representations apart.

Tested on x86-64 Linux with a full bootstrap and regtests. I also tested the LAPACK suite for regressions, and I ran f951 over all the preprocessed Fortran files in LAPACK and made sure there are no changes in the assembly with and
without this patch.

This is an internal-consistency fix and a prerequisite for upcoming patches
which will make the sign of a zero endpoint load-bearing.

I've been away for a while and my mind is probably mush, so I could appreciate a
second set of eyes before I push this.
From 9dd937d1df25405a6aaa1bd39129edaa276a583b Mon Sep 17 00:00:00 2001
From: Aldy Hernandez <[email protected]>
Date: Wed, 15 Jul 2026 09:16:10 +0000
Subject: [PATCH 1/5] [frange] Consolidate signed-zero canonicalization of
 endpoints

The methods frange::set() and frange::flush_denormals_to_zero() canonicalize
ranges containing floating point zero endpoints differently.  Whereas set()
includes both +0.0 and -0.0 in the range, so contains_p([+-]0.0) works, even
under -ffast-math, flush_denormals_to_zero() always sets the lower bound of a
positive denormal as +0.0, regardless of HONOR_SIGNED_ZEROS.  This means that
contains_p(-0.0) would theoretically fail under -ffast-math.

In reality this last scenario doesn't happen because all users of zero endpoints
in the code are special casing signed zeros.  Also, building a range with -0.0
would cause the set() canonicalization to kick in, and set the range to
[-0.0, +0.0] anyhow.  Not only is this fragile at best, but in follow-up patches
I'd like to do some cleanup in this area, and one representation across the
board is in the spirit of what we've always tried to do with irange: a given
range should be representable in only one way (ok, not exactly cause we have
irange_bitmask's which complicate things, but we try ;-)).

Ultimately I'd like to implement sub-ranges for frange so we can represent
inequality with a constant, and tackle a couple of the signed zero PRs in this
area.  But initially I just want to provide an frange_cmp() total order
comparison function that respects signed zeros, and lets users not have to worry
about special casing anything when dealing with endpoints.

For example, frange_cmp() will order -0.0 strictly below +0.0, so a membership
test becomes just two endpoint comparisons: -0.0 lands outside [+0.0, x] but
inside [-0.0, x], and nobody has to first ask "is this a zero, and if so which
one?".  But that only works if a range always spells its zero endpoints the same
way.  Today set() spells the lower zero of [0.0, 5.0] as -0.0 under -ffast-math,
while flush_denormals_to_zero() spells it +0.0, so one range ends up with two
bit patterns, and frange_cmp() would tell you [+0.0, 5.0] is a strict subset of
[-0.0, 5.0] even though they are the same range.  Once both producers run
through canonicalize_zeros() the order is well defined, and when frange_cmp()
gets implemented, contains_p(), and the eventual sub-range union/intersect code
can just call it and drop their signed-zero special cases entirely.

This patch moves the canonicalization into one function that both set() and
flush_denormals_to_zero() can share.  With it flush_denormals_to_zero() moves
the denormal endpoint to the zero on its side and lets canonicalize_zeros()
settle the sign, exactly as set() does.

I've included a selftest to test this: under -ffast-math, flushing
[+DENORM, 5.0] used to yield [+0.0, 5.0], while set() on the same range yields
[-0.0, 5.0].  With this patch, flushing also yields [-0.0, 5.0], matching set().

There's no observable change with this patch-- every consumer that reads the
sign of a zero endpoint already special-cases !HONOR_SIGNED_ZEROS, so nothing
today can tell the two representations apart.

Tested on x86-64 Linux with a full bootstrap and regtests.  I also tested the
LAPACK suite for regressions, and I ran f951 over all the preprocessed Fortran
files in LAPACK and made sure there are no changes in the assembly with and
without this patch.

This is an internal-consistency fix and a prerequisite for upcoming patches
which will make the sign of a zero endpoint load-bearing.

I've been away for a while and my mind is probably mush, so I could appreciate a
second set of eyes before I push this.

gcc/ChangeLog:

        * value-range.cc (frange::flush_denormals_to_zero): Collapse a
        denormal endpoint to the zero of the same sign and defer the sign
        to canonicalize_zeros.
        (frange::canonicalize_zeros): New.
        (frange::set): Use it.
        (range_tests_flush_denormals): New.
        (range_tests_floats): Call it.
        * value-range.h (class frange): Declare canonicalize_zeros.
---
 gcc/value-range.cc | 92 +++++++++++++++++++++++++++++++++++-----------
 gcc/value-range.h  |  1 +
 2 files changed, 71 insertions(+), 22 deletions(-)

diff --git a/gcc/value-range.cc b/gcc/value-range.cc
index 722eca9abca..7c7e34cf829 100644
--- a/gcc/value-range.cc
+++ b/gcc/value-range.cc
@@ -995,17 +995,51 @@ frange::flush_denormals_to_zero ()
     return;
 
   machine_mode mode = TYPE_MODE (type ());
-  // Flush [x, -DENORMAL] to [x, -0.0].
+
+  // Flush a denormal endpoint to a zero of the same sign: a +denormal lower
+  // bound to +0.0, and a -denormal upper bound to -0.0.  Then call
+  // canonicalize_zeros to rewrite the sign to whatever the flags make
+  // canonical.  For example, under !HONOR_SIGNED_ZEROS (-fno-signed-zeros) a
+  // range reaching zero must hold both signs of it, so:
+  //
+  //     [ +DENORMAL, 5.0 ]  flushes to  [ -0.0, 5.0 ]
+  //
+  // keeping contains_p (-0.0) true; under HONOR_SIGNED_ZEROS the sign stands 
and
+  // it stays [ +0.0, 5.0 ].
   if (real_isdenormal (&m_max, mode) && real_isneg (&m_max))
-    {
-      if (HONOR_SIGNED_ZEROS (m_type))
-       m_max = dconstm0;
-      else
-       m_max = dconst0;
-    }
-  // Flush [+DENORMAL, x] to [+0.0, x].
+    m_max = dconstm0;
   if (real_isdenormal (&m_min, mode) && !real_isneg (&m_min))
     m_min = dconst0;
+  canonicalize_zeros (m_min, m_max);
+}
+
+// Canonicalize the signed zeros of the endpoints LO and HI according with what
+// the target and flags want:
+//
+//   !MODE_HAS_SIGNED_ZEROS: the mode has no signed zero, so any zero is +0.0.
+//
+//   !HONOR_SIGNED_ZEROS: the two zeros are one value, so widen the range to
+//   include both signs of it.
+//
+//   Otherwise the sign is a real distinction, and we keep it.
+
+void
+frange::canonicalize_zeros (REAL_VALUE_TYPE &lo, REAL_VALUE_TYPE &hi)
+{
+  if (!MODE_HAS_SIGNED_ZEROS (TYPE_MODE (m_type)))
+    {
+      if (real_iszero (&lo, 1))
+       lo.sign = 0;
+      if (real_iszero (&hi, 1))
+       hi.sign = 0;
+    }
+  else if (!HONOR_SIGNED_ZEROS (m_type))
+    {
+      if (real_iszero (&hi, 1))
+       hi.sign = 0;
+      if (real_iszero (&lo, 0))
+       lo.sign = 1;
+    }
 }
 
 // Setter for franges.
@@ -1047,20 +1081,7 @@ frange::set (tree type,
       m_neg_nan = false;
     }
 
-  if (!MODE_HAS_SIGNED_ZEROS (TYPE_MODE (m_type)))
-    {
-      if (real_iszero (&m_min, 1))
-       m_min.sign = 0;
-      if (real_iszero (&m_max, 1))
-       m_max.sign = 0;
-    }
-  else if (!HONOR_SIGNED_ZEROS (m_type))
-    {
-      if (real_iszero (&m_max, 1))
-       m_max.sign = 0;
-      if (real_iszero (&m_min, 0))
-       m_min.sign = 1;
-    }
+  canonicalize_zeros (m_min, m_max);
 
   // For -ffinite-math-only we can drop ranges outside the
   // representable numbers to min/max for the type.
@@ -3746,6 +3767,32 @@ range_tests_signbit ()
   ASSERT_TRUE (!r0.signbit_p (signbit));
 }
 
+static void
+range_tests_flush_denormals ()
+{
+  // We need -0.0 to exist for any of this to mean anything.
+  if (!MODE_HAS_SIGNED_ZEROS (TYPE_MODE (float_type_node)))
+    return;
+
+  int save_flag = flag_signed_zeros;
+  flag_signed_zeros = 0;
+
+  // Flushing a positive denormal lower bound to zero must canonicalize that
+  // zero to -0.0 to agree with set().
+  frange flushed = frange_float ("1e-40", "5");
+  flushed.clear_nan ();
+  flushed.flush_denormals_to_zero ();
+
+  frange built = frange_float ("0", "5");
+  built.clear_nan ();
+
+  ASSERT_TRUE (flushed == built);
+  ASSERT_TRUE (flushed.contains_p (dconstm0));
+  ASSERT_TRUE (flushed.contains_p (dconst0));
+
+  flag_signed_zeros = save_flag;
+}
+
 static void
 range_tests_floats ()
 {
@@ -3754,6 +3801,7 @@ range_tests_floats ()
   if (HONOR_NANS (float_type_node))
     range_tests_nan ();
   range_tests_signbit ();
+  range_tests_flush_denormals ();
 
   if (HONOR_SIGNED_ZEROS (float_type_node))
     range_tests_signed_zeros ();
diff --git a/gcc/value-range.h b/gcc/value-range.h
index e6dc59eb706..5a2f8a0fcb9 100644
--- a/gcc/value-range.h
+++ b/gcc/value-range.h
@@ -667,6 +667,7 @@ private:
   bool union_nans (const frange &);
   bool intersect_nans (const frange &);
   bool combine_zeros (const frange &, bool union_p);
+  void canonicalize_zeros (REAL_VALUE_TYPE &, REAL_VALUE_TYPE &);
 
   tree m_type;
   REAL_VALUE_TYPE m_min;
-- 
2.53.0

Reply via email to