From 5e19676f4e2d4006d1d5cf7c781cab326789fcd4 Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Thu, 6 Nov 2025 03:46:44 +0300 Subject: [PATCH 1/6] gh-141042: fix sNaN's (un)packing for mixed floating-point formats --- .../C_API/2025-11-06-06-28-14.gh-issue-141042.brOioJ.rst | 3 +++ Objects/floatobject.c | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/C_API/2025-11-06-06-28-14.gh-issue-141042.brOioJ.rst diff --git a/Misc/NEWS.d/next/C_API/2025-11-06-06-28-14.gh-issue-141042.brOioJ.rst b/Misc/NEWS.d/next/C_API/2025-11-06-06-28-14.gh-issue-141042.brOioJ.rst new file mode 100644 index 000000000000000..22a1aa1f4053183 --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2025-11-06-06-28-14.gh-issue-141042.brOioJ.rst @@ -0,0 +1,3 @@ +Make qNaN in :c:func:`PyFloat_Pack2` and :c:func:`PyFloat_Pack4`, if while +conversion to a narrower precision floating-point format --- the remaining +after truncation payload will be zero. Patch by Sergey B Kirpichev. diff --git a/Objects/floatobject.c b/Objects/floatobject.c index 1fefb12803ec19c..f4d809f89c8c2e5 100644 --- a/Objects/floatobject.c +++ b/Objects/floatobject.c @@ -2030,6 +2030,10 @@ PyFloat_Pack2(double x, char *data, int le) memcpy(&v, &x, sizeof(v)); v &= 0xffc0000000000ULL; bits = (unsigned short)(v >> 42); /* NaN's type & payload */ + /* set qNaN if no payload */ + if (!bits) { + bits |= (1<<9); + } } else { sign = (x < 0.0); @@ -2202,7 +2206,10 @@ PyFloat_Pack4(double x, char *data, int le) if ((v & (1ULL << 51)) == 0) { uint32_t u32; memcpy(&u32, &y, 4); - u32 &= ~(1 << 22); /* make sNaN */ + /* if have payload, make sNaN */ + if (u32 & 0x3fffff) { + u32 &= ~(1 << 22); + } memcpy(&y, &u32, 4); } #else From 105cb1a8262edecc991d1cf908aa390d997744c5 Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Thu, 6 Nov 2025 07:41:49 +0300 Subject: [PATCH 2/6] add tests --- Lib/test/test_capi/test_float.py | 52 ++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_capi/test_float.py b/Lib/test/test_capi/test_float.py index 983b991b4f163d8..3d3b649fbef3a8f 100644 --- a/Lib/test/test_capi/test_float.py +++ b/Lib/test/test_capi/test_float.py @@ -29,6 +29,21 @@ NAN = float("nan") +def make_nan(size, sign, quiet, payload): + if size == 8: + payload &= 0x7ffffffffffff + i = (sign << 63) + (0x7ff << 52) + (quiet << 51) + payload + elif size == 4: + payload &= 0x3fffff + i = (sign << 31) + (0xff << 23) + (quiet << 22) + payload + elif size == 2: + payload &= 0x1ff + i = (sign << 15) + (0x1f << 10) + (quiet << 9) + payload + else: + raise ValueError("size must be either 2, 4, or 8") + return i + + class CAPIFloatTest(unittest.TestCase): def test_check(self): # Test PyFloat_Check() @@ -202,16 +217,8 @@ def test_pack_unpack_roundtrip_for_nans(self): # HP PA RISC uses 0 for quiet, see: # https://en.wikipedia.org/wiki/NaN#Encoding signaling = 1 - quiet = int(not signaling) - if size == 8: - payload = random.randint(signaling, 0x7ffffffffffff) - i = (sign << 63) + (0x7ff << 52) + (quiet << 51) + payload - elif size == 4: - payload = random.randint(signaling, 0x3fffff) - i = (sign << 31) + (0xff << 23) + (quiet << 22) + payload - elif size == 2: - payload = random.randint(signaling, 0x1ff) - i = (sign << 15) + (0x1f << 10) + (quiet << 9) + payload + payload = random.randint(signaling, 0xfffffffffffff) + i = make_nan(size, sign, not signaling, payload) data = bytes.fromhex(f'{i:x}') for endian in (BIG_ENDIAN, LITTLE_ENDIAN): with self.subTest(data=data, size=size, endian=endian): @@ -221,6 +228,31 @@ def test_pack_unpack_roundtrip_for_nans(self): self.assertTrue(math.isnan(value)) self.assertEqual(data1, data2) + @unittest.skipUnless(HAVE_IEEE_754, "requires IEEE 754") + def test_pack_unpack_nans_for_different_formats(self): + pack = _testcapi.float_pack + unpack = _testcapi.float_unpack + + for endian in (BIG_ENDIAN, LITTLE_ENDIAN): + with self.subTest(endian=endian): + byteorder = "big" if endian == BIG_ENDIAN else "little" + + # Convert sNaN to qNaN, if payload got truncated + data = make_nan(8, 0, False, 0x80001).to_bytes(8, byteorder) + snan_low = unpack(data, endian) + qnan4 = make_nan(4, 0, True, 0).to_bytes(4, byteorder) + qnan2 = make_nan(2, 0, True, 0).to_bytes(2, byteorder) + self.assertEqual(pack(4, snan_low, endian), qnan4) + self.assertEqual(pack(2, snan_low, endian), qnan2) + + # Preserve NaN type, if payload not truncated + data = make_nan(8, 0, False, 0x80000000001).to_bytes(8, byteorder) + snan_high = unpack(data, endian) + snan4 = make_nan(4, 0, False, 16384).to_bytes(4, byteorder) + snan2 = make_nan(2, 0, False, 2).to_bytes(2, byteorder) + self.assertEqual(pack(4, snan_high, endian), snan4) + self.assertEqual(pack(2, snan_high, endian), snan2) + if __name__ == "__main__": unittest.main() From 4d654aafadd194221a3c5af2378a2b40661471ef Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Thu, 6 Nov 2025 07:46:01 +0300 Subject: [PATCH 3/6] extend fix to RISC-V --- Objects/floatobject.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Objects/floatobject.c b/Objects/floatobject.c index f4d809f89c8c2e5..aefd944c9710a06 100644 --- a/Objects/floatobject.c +++ b/Objects/floatobject.c @@ -2216,7 +2216,7 @@ PyFloat_Pack4(double x, char *data, int le) uint32_t u32; memcpy(&u32, &y, 4); - if ((v & (1ULL << 51)) == 0) { + if ((v & (1ULL << 51)) == 0 && (u32 & 0x3fffff)) { u32 &= ~(1 << 22); } /* Workaround RISC-V: "If a NaN value is converted to a From 3f6b0efcb56b811e3eecd113918bec9297dfbac9 Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Thu, 6 Nov 2025 08:18:37 +0300 Subject: [PATCH 4/6] + skip in 32-bit mode --- Lib/test/test_capi/test_float.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_capi/test_float.py b/Lib/test/test_capi/test_float.py index 3d3b649fbef3a8f..c88d25e39e60a61 100644 --- a/Lib/test/test_capi/test_float.py +++ b/Lib/test/test_capi/test_float.py @@ -229,6 +229,7 @@ def test_pack_unpack_roundtrip_for_nans(self): self.assertEqual(data1, data2) @unittest.skipUnless(HAVE_IEEE_754, "requires IEEE 754") + @unittest.skipUnless(sys.maxsize != 2147483647, "requires 64-bit mode") def test_pack_unpack_nans_for_different_formats(self): pack = _testcapi.float_pack unpack = _testcapi.float_unpack From e4e08828e81f3ba9ce5aaa601ff723a188574eae Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Wed, 12 Nov 2025 02:16:08 +0300 Subject: [PATCH 5/6] correct RISC-V workaround --- Objects/floatobject.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Objects/floatobject.c b/Objects/floatobject.c index aefd944c9710a06..ef613efe4e7f445 100644 --- a/Objects/floatobject.c +++ b/Objects/floatobject.c @@ -2216,9 +2216,6 @@ PyFloat_Pack4(double x, char *data, int le) uint32_t u32; memcpy(&u32, &y, 4); - if ((v & (1ULL << 51)) == 0 && (u32 & 0x3fffff)) { - u32 &= ~(1 << 22); - } /* Workaround RISC-V: "If a NaN value is converted to a * different floating-point type, the result is the * canonical NaN of the new type". The canonical NaN here @@ -2229,6 +2226,10 @@ PyFloat_Pack4(double x, char *data, int le) /* add payload */ u32 -= (u32 & 0x3fffff); u32 += (uint32_t)((v & 0x7ffffffffffffULL) >> 29); + /* if have payload, make sNaN */ + if ((v & (1ULL << 51)) == 0 && (u32 & 0x3fffff)) { + u32 &= ~(1 << 22); + } memcpy(&y, &u32, 4); #endif From 577d663afb914a94158d566933cd82804efee244 Mon Sep 17 00:00:00 2001 From: Sergey B Kirpichev Date: Wed, 12 Nov 2025 04:19:24 +0300 Subject: [PATCH 6/6] + fix helper --- Lib/test/test_capi/test_float.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_capi/test_float.py b/Lib/test/test_capi/test_float.py index c88d25e39e60a61..df7017e6436a694 100644 --- a/Lib/test/test_capi/test_float.py +++ b/Lib/test/test_capi/test_float.py @@ -29,19 +29,21 @@ NAN = float("nan") -def make_nan(size, sign, quiet, payload): +def make_nan(size, sign, quiet, payload=None): if size == 8: - payload &= 0x7ffffffffffff - i = (sign << 63) + (0x7ff << 52) + (quiet << 51) + payload + payload_mask = 0x7ffffffffffff + i = (sign << 63) + (0x7ff << 52) + (quiet << 51) elif size == 4: - payload &= 0x3fffff - i = (sign << 31) + (0xff << 23) + (quiet << 22) + payload + payload_mask = 0x3fffff + i = (sign << 31) + (0xff << 23) + (quiet << 22) elif size == 2: - payload &= 0x1ff - i = (sign << 15) + (0x1f << 10) + (quiet << 9) + payload + payload_mask = 0x1ff + i = (sign << 15) + (0x1f << 10) + (quiet << 9) else: raise ValueError("size must be either 2, 4, or 8") - return i + if payload is None: + payload = random.randint(not quiet, payload_mask) + return i + payload class CAPIFloatTest(unittest.TestCase): @@ -217,8 +219,7 @@ def test_pack_unpack_roundtrip_for_nans(self): # HP PA RISC uses 0 for quiet, see: # https://en.wikipedia.org/wiki/NaN#Encoding signaling = 1 - payload = random.randint(signaling, 0xfffffffffffff) - i = make_nan(size, sign, not signaling, payload) + i = make_nan(size, sign, not signaling) data = bytes.fromhex(f'{i:x}') for endian in (BIG_ENDIAN, LITTLE_ENDIAN): with self.subTest(data=data, size=size, endian=endian):