diff --git a/changelog.d/7483-directparser-float-parity.md b/changelog.d/7483-directparser-float-parity.md new file mode 100644 index 0000000000..4b68a0b085 --- /dev/null +++ b/changelog.d/7483-directparser-float-parity.md @@ -0,0 +1 @@ +**Fixed: JSON DirectParser decimal→f64 now bit-identical to the tape materializer and node (#7477, PR #7483).** The DirectParser's small fixed-point fast path computed `int as f64 + (frac as f64 / 10^k)` — two IEEE roundings — and was one ulp off the correctly-rounded value for literals like `260.75197` (returned `0x40704c0811b1d92c` = 260.75197000000003 instead of `0x40704c0811b1d92b`). The tape materializer uses `str::parse::` (correctly rounded, matches V8 strtod), so every DirectParser-routed parse — blobs under 1 KB or over 16 MB, all non-array roots, `PERRY_JSON_TAPE=0` — silently got divergent floats, which also surfaced as longer restringified output (`260.75197000000003` round-trips at +9 chars). Fix in `crates/perry-runtime/src/json/parser.rs`: the fast path now accumulates all digits into one integer mantissa and, when the mantissa is ≤ 2^53 and the scale is an exact power of ten, performs a single correctly-rounded IEEE division (the Clinger fast path); wider tokens fall through to `str::parse` on the full token. `bench_field_access` checksum under `PERRY_JSON_TAPE=0` moves from 2552986400 to 2552985550, matching the tape path and node. New unit test `direct_parser_number_bits_match_strtod` pins the diverging literals plus the full `i * 3.14159` value space against `str::parse` bits; all 12 json gap tests remain byte-identical to node v26.5.1; perf on `bench.ts` / `json_parse_1mb` is neutral within host noise. diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index 55c2f4e89b..f6feab6ff0 100644 --- a/crates/perry-runtime/src/json/mod.rs +++ b/crates/perry-runtime/src/json/mod.rs @@ -1228,4 +1228,78 @@ mod tests { ); } } + + /// #7477: the DirectParser must produce bit-identical f64s to the tape + /// materializer, whose number path is `str::parse::()` (correctly + /// rounded, matches V8's strtod for round-trippable inputs). The + /// DirectParser's small fixed-point fast path computed + /// `int as f64 + (frac as f64 / 10^k)` — TWO IEEE roundings (the division + /// rounds, then the addition rounds again), which is off by one ulp for + /// some literals. Node agrees with the tape, so the DirectParser is the + /// wrong one. + /// + /// `260.75197` (= 83 * 3.14159 in f64) is the minimal diverging literal + /// from `bench_field_access`: correct bits 0x1.04c0811b1d92bp+8, the + /// double-rounded fast path returned 0x1.04c0811b1d92cp+8 — whose + /// shortest round-trip stringification is "260.75197000000004", which is + /// how a pure parse divergence surfaced as a stringify-length checksum + /// mismatch (2552986400 vs node's 2552985550). + #[test] + fn direct_parser_number_bits_match_strtod() { + let parse_direct = |s: &str| -> u64 { + let bytes = s.as_bytes(); + let mut parser = DirectParser::new(bytes); + let value = unsafe { parser.parse_number() }; + assert!( + !parser.has_trailing_content(), + "parse_number left trailing input on {s:?}" + ); + value.bits() + }; + let mut cases: Vec = vec![ + // The two bench_field_access literals that diverged (#7477), + // plus their negations. + "260.75197".to_string(), + "521.50394".to_string(), + "-260.75197".to_string(), + "-521.50394".to_string(), + // Assorted shapes through every parse_number arm: integer fast + // path, fixed-point fast path, exponent / long-token fallback. + "0".to_string(), + "-0".to_string(), + "0.1".to_string(), + "-0.1".to_string(), + "0.3".to_string(), + "3.14159".to_string(), + "1.0000001".to_string(), + "123.456".to_string(), + "999999999999999.9".to_string(), + "9007199254740993.1".to_string(), + "0.000000001".to_string(), + "12345678901234567890".to_string(), + "1e10".to_string(), + "-2.5e-3".to_string(), + "1.7976931348623157e308".to_string(), + ]; + // The full bench_field_access value space: shortest round-trip + // renderings of i * 3.14159. Two of these (i = 83, 166) diverged + // under the double-rounding fast path. + for i in 0..10000 { + cases.push(format!("{}", i as f64 * 3.14159)); + } + for s in &cases { + let want: f64 = s.parse().unwrap(); + let got = parse_direct(s); + assert_eq!( + got, + want.to_bits(), + "DirectParser::parse_number({s:?}) = {:#018x} ({}), \ + str::parse (tape/node) = {:#018x} ({})", + got, + f64::from_bits(got), + want.to_bits(), + want, + ); + } + } } diff --git a/crates/perry-runtime/src/json/parser.rs b/crates/perry-runtime/src/json/parser.rs index e45fc957d2..4e8f2a9848 100644 --- a/crates/perry-runtime/src/json/parser.rs +++ b/crates/perry-runtime/src/json/parser.rs @@ -769,8 +769,21 @@ impl<'a> DirectParser<'a> { // Small fixed-point fast path: JSON API feeds often contain // `"score": 123.5`-style values. Avoid the general decimal - // parser for short non-exponent decimals by accumulating the - // integer and fractional digits once and scaling by a tiny table. + // parser for short non-exponent decimals by accumulating ALL + // the digits into one integer mantissa and dividing once by an + // exact power of ten. + // + // #7477: this must be bit-identical to `str::parse::` (the + // tape materializer's and V8-strtod's answer). The previous form + // `int as f64 + (frac as f64 / 10^k)` rounded TWICE — the + // division rounds, then the addition rounds again — and was one + // ulp off for literals like `260.75197`. The single-division + // form is the classic Clinger fast path: when the decimal + // mantissa m fits in 2^53 (exactly representable) and 10^k is an + // exact f64 (all powers up to 10^22 are), `m as f64 / 10^k` is + // ONE correctly-rounded IEEE operation on the exact rational + // m/10^k — the same double a correct decimal parser produces. + // Anything wider falls through to `str::parse` below. if has_dot { self.pos += 1; let frac_start = self.pos; @@ -782,30 +795,38 @@ impl<'a> DirectParser<'a> { && (self.input[self.pos] == b'e' || self.input[self.pos] == b'E'); let int_len = int_end - int_start; let frac_len = frac_end - frac_start; - if !exp_after_frac && int_len > 0 && int_len <= 15 && frac_len > 0 && frac_len <= 9 { - let mut int_acc: u64 = 0; + if !exp_after_frac + && int_len > 0 + && frac_len > 0 + && frac_len <= 9 + && int_len + frac_len <= 17 + { + // ≤ 17 digits always fits u64 (10^17 < 2^63); the ≤ 2^53 + // check below is the exact-representability gate. + let mut mantissa: u64 = 0; for &b in &self.input[int_start..int_end] { - int_acc = int_acc * 10 + (b - b'0') as u64; + mantissa = mantissa * 10 + (b - b'0') as u64; } - let mut frac_acc: u64 = 0; for &b in &self.input[frac_start..frac_end] { - frac_acc = frac_acc * 10 + (b - b'0') as u64; + mantissa = mantissa * 10 + (b - b'0') as u64; + } + if mantissa <= (1u64 << 53) { + const POW10: [f64; 10] = [ + 1.0, + 10.0, + 100.0, + 1_000.0, + 10_000.0, + 100_000.0, + 1_000_000.0, + 10_000_000.0, + 100_000_000.0, + 1_000_000_000.0, + ]; + let magnitude = mantissa as f64 / POW10[frac_len]; + let value = if neg { -magnitude } else { magnitude }; + return JSValue::number(value); } - const POW10: [f64; 10] = [ - 1.0, - 10.0, - 100.0, - 1_000.0, - 10_000.0, - 100_000.0, - 1_000_000.0, - 10_000_000.0, - 100_000_000.0, - 1_000_000_000.0, - ]; - let magnitude = int_acc as f64 + (frac_acc as f64 / POW10[frac_len]); - let value = if neg { -magnitude } else { magnitude }; - return JSValue::number(value); } } if self.pos < self.input.len()