fix(parser): correctly round large nondecimal literals (#26379)

Large hexadecimal, binary, and octal literals can round incorrectly when their digits are accumulated with repeated `f64::mul_add` calls. For example, `0x10000000000000801` currently becomes `2^64` instead of the correctly rounded `2^64 + 4096`.

Preserve the leading significant bits and a sticky bit in the nondecimal slow paths, then round once and scale by a power of two. This also covers numeric separators and legacy octal literals, without allocating or changing the existing integer fast paths.
This commit is contained in:
camc314 committed 2026-09-07 09:06:48 +00:00
1 parent 1bb1c8c9e9
commit 9a023379c6
1 file changed
+133 -48
+133 -48
View File
@@ -114,10 +114,6 @@ fn parse_decimal_with_underscores(s: &str) -> f64 {
#[cold]
#[inline(never)]
fn parse_decimal_slow(s: &str) -> f64 {
// NB: Cannot use the `mul_add` loop method that `parse_binary_slow` etc use here,
// as it produces an imprecise result.
// For the others it's fine, presumably because multiply by a power of 2
// just increments f64's exponent. But multiplying by 10 is more complex.
s.parse::<f64>().unwrap()
}
@@ -145,10 +141,9 @@ const fn binary_byte_to_value(b: u8) -> u8 {
/// single instruction, but many others do not.
///
/// Unfortunately, this approach has the chance to overflow for excessively
/// large numbers. In such cases, we fall back to mul_add. Note that right now
/// we consider leading zeros as part of that length. Right now it doesn't seem
/// worth it performance-wise to check and strip them. Further experimentation
/// could be useful.
/// large numbers. In such cases, we fall back to `parse_nondecimal_slow`.
/// Leading zeros count toward this length; checking and stripping them may not
/// be worth the performance cost. Further experimentation could be useful.
#[expect(clippy::cast_precision_loss, clippy::cast_lossless)]
fn parse_binary(s: &str) -> f64 {
/// binary literals longer than this many characters have the chance to
@@ -173,12 +168,7 @@ fn parse_binary(s: &str) -> f64 {
#[cold]
#[inline(never)]
fn parse_binary_slow(s: &str) -> f64 {
let mut result = 0_f64;
for &b in s.as_bytes() {
let value = f64::from(binary_byte_to_value(b));
result = result.mul_add(2.0, value);
}
result
parse_nondecimal_slow::<1>(s.bytes().map(binary_byte_to_value))
}
#[expect(clippy::cast_precision_loss, clippy::cast_lossless)]
@@ -207,14 +197,7 @@ fn parse_binary_with_underscores(s: &str) -> f64 {
#[cold]
#[inline(never)]
fn parse_binary_with_underscores_slow(s: &str) -> f64 {
let mut result = 0_f64;
for &b in s.as_bytes() {
if b != b'_' {
let value = f64::from(binary_byte_to_value(b));
result = result.mul_add(2.0, value);
}
}
result
parse_nondecimal_slow::<1>(s.bytes().filter(|&b| b != b'_').map(binary_byte_to_value))
}
// ==================================== OCTAL ====================================
@@ -254,12 +237,7 @@ fn parse_octal(s: &str) -> f64 {
#[cold]
#[inline(never)]
fn parse_octal_slow(s: &str) -> f64 {
let mut result = 0_f64;
for &b in s.as_bytes() {
let value = f64::from(octal_byte_to_value(b));
result = result.mul_add(8.0, value);
}
result
parse_nondecimal_slow::<3>(s.bytes().map(octal_byte_to_value))
}
#[expect(clippy::cast_precision_loss, clippy::cast_lossless)]
@@ -287,14 +265,7 @@ fn parse_octal_with_underscores(s: &str) -> f64 {
#[cold]
#[inline(never)]
fn parse_octal_with_underscores_slow(s: &str) -> f64 {
let mut result = 0_f64;
for &b in s.as_bytes() {
if b != b'_' {
let value = f64::from(octal_byte_to_value(b));
result = result.mul_add(8.0, value);
}
}
result
parse_nondecimal_slow::<3>(s.bytes().filter(|&b| b != b'_').map(octal_byte_to_value))
}
// ==================================== HEX ====================================
@@ -346,12 +317,7 @@ fn parse_hex(s: &str) -> f64 {
#[cold]
#[inline(never)]
fn parse_hex_slow(s: &str) -> f64 {
let mut result = 0_f64;
for &b in s.as_bytes() {
let value = f64::from(hex_byte_to_value(b));
result = result.mul_add(16.0, value);
}
result
parse_nondecimal_slow::<4>(s.bytes().map(hex_byte_to_value))
}
#[expect(clippy::cast_precision_loss, clippy::cast_lossless)]
@@ -380,14 +346,35 @@ fn parse_hex_with_underscores(s: &str) -> f64 {
#[cold]
#[inline(never)]
fn parse_hex_with_underscores_slow(s: &str) -> f64 {
let mut result = 0_f64;
for &b in s.as_bytes() {
if b != b'_' {
let value = f64::from(hex_byte_to_value(b));
result = result.mul_add(16.0, value);
parse_nondecimal_slow::<4>(s.bytes().filter(|&b| b != b'_').map(hex_byte_to_value))
}
/// Convert power-of-two radix digits with a single rounding to nearest, ties to even.
#[cold]
#[expect(clippy::cast_precision_loss)]
fn parse_nondecimal_slow<const BITS: u32>(digits: impl Iterator<Item = u8>) -> f64 {
let mut significand = 0_u64;
let mut exponent = 0_u32;
let mut sticky = 0_u8;
for digit in digits {
if significand < (1 << 54) {
significand = (significand << BITS) | u64::from(digit);
} else {
exponent += BITS;
if exponent > 1023 {
return f64::INFINITY;
}
sticky |= digit;
}
}
result
// Retain at least 55 significant bits: the 53 f64 bits, a rounding bit,
// and a sticky bit. Any nonzero discarded digit makes the lowest retained
// bit sticky, distinguishing an exact halfway value from one just above it.
// The integer cast rounds once; scaling by a power of two is exact unless
// the result overflows to infinity.
significand |= u64::from(sticky != 0);
(significand as f64) * f64::from_bits(u64::from(exponent + 1023) << 52)
}
// ==================================== BIGINT ====================================
@@ -508,6 +495,104 @@ mod test {
);
}
#[test]
#[expect(clippy::cast_precision_loss)]
fn test_nondecimal_rounding() {
let expected = 0x10000000000000801_i128 as f64;
assert_eq!(parse_int("0x10000000000000801", Kind::Hex, false), Ok(expected));
assert_eq!(parse_int("0x1_0000_0000_0000_0801", Kind::Hex, true), Ok(expected));
assert_eq!(
parse_int(
"0b10000000000000000000000000000000000000000000000000000100000000001",
Kind::Binary,
false
),
Ok(expected)
);
assert_eq!(
parse_int(
"0b1_00000000_00000000_00000000_00000000_00000000_00000000_00001000_00000001",
Kind::Binary,
true
),
Ok(expected)
);
assert_eq!(parse_int("0o2000000000000000004001", Kind::Octal, false), Ok(expected));
assert_eq!(parse_int("0o2_000_000_000_000_000_004_001", Kind::Octal, true), Ok(expected));
}
#[test]
#[expect(clippy::cast_precision_loss)]
fn test_nondecimal_rounding_boundaries() {
for bits in 54..128 {
let base = 1_u128 << bits;
let half_ulp = 1_u128 << (bits - 53);
// Both even and odd significands, immediately below, at, and above halfway.
for offset in [
half_ulp - 1,
half_ulp,
half_ulp + 1,
3 * half_ulp - 1,
3 * half_ulp,
3 * half_ulp + 1,
] {
let value = base + offset;
for (digits, prefix, kind) in [
(format!("{value:b}"), "0b", Kind::Binary),
(format!("{value:o}"), "0o", Kind::Octal),
(format!("{value:x}"), "0x", Kind::Hex),
(format!("{value:o}"), "0", Kind::Octal),
] {
for leading in ["", "00000000000000000000"] {
let literal = format!("{prefix}{leading}{digits}");
assert_eq!(parse_int(&literal, kind, false), Ok(value as f64), "{literal}");
if prefix != "0" {
let separated =
digits.chars().map(|c| c.to_string()).collect::<Vec<_>>().join("_");
let literal = format!("{prefix}{leading}{separated}");
assert_eq!(
parse_int(&literal, kind, true),
Ok(value as f64),
"{literal}"
);
}
}
}
}
}
}
#[test]
fn test_nondecimal_rounding_extremes() {
for (digits, expected) in [
// A sticky bit hundreds of digits after the rounding bit.
(
format!("1{}1{}1", "0".repeat(52), "0".repeat(969)),
f64::from_bits(0x7fe0000000000001),
),
// Largest finite f64, then immediately below and at the overflow midpoint.
(format!("{}{}", "1".repeat(53), "0".repeat(971)), f64::MAX),
(format!("{}0{}", "1".repeat(53), "1".repeat(970)), f64::MAX),
(format!("{}{}", "1".repeat(54), "0".repeat(970)), f64::INFINITY),
(format!("1{}", "0".repeat(1100)), f64::INFINITY),
("0".repeat(1100), 0.0),
(format!("{}1", "0".repeat(1100)), 1.0),
] {
// Convert the same exact integer to every radix, including separators.
let value = BigInt::from_str_radix(&digits, 2).unwrap();
for (radix, prefix, kind) in
[(2, "0b", Kind::Binary), (8, "0o", Kind::Octal), (16, "0x", Kind::Hex)]
{
let digits = if radix == 2 { digits.clone() } else { value.to_str_radix(radix) };
let literal = format!("{prefix}{}{digits}", "0".repeat(65));
assert_eq!(parse_int(&literal, kind, false), Ok(expected), "{literal}");
let separated = digits.chars().map(|c| c.to_string()).collect::<Vec<_>>().join("_");
let literal = format!("{prefix}{separated}");
assert_eq!(parse_int(&literal, kind, true), Ok(expected), "{literal}");
}
}
}
#[test]
fn test_large_number_of_leading_zeros() {
assert_all_ints_eq(