[frange] Consolidate signed-zero canonicalization of endpoints

Message ID 4b247bdb4b8d524fe9064392249b6435@quesejoda.com
State New
Headers
Series [frange] Consolidate signed-zero canonicalization of endpoints |

Checks

Context Check Description
linaro-tcwg-bot/tcwg_gcc_build--master-arm success Build passed
linaro-tcwg-bot/tcwg_gcc_build--master-aarch64 success Build passed

Commit Message

Aldy Hernandez July 16, 2026, 1:49 p.m. UTC
  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 <aldy@quesejoda.com>
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(-)
  

Comments

Aldy Hernandez July 22, 2026, 9:35 p.m. UTC | #1
I need this patch for other non functional changes I need in order to
continue on the frange + sub-ranges work.  We can tackle any problems
as follow-ups.

Also, I re-tested and included ppc64le-Linux in the mix, along with
LAPACK tests, and making sure its Fortran preprocessed files show no
changes in assembly output for -O2.

Pushed to trunk.

Attached is the latest version of the patch I pushed.

Happy when hacking.
Aldy
  

Patch

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;