Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/7483-directparser-float-parity.md
Original file line number Diff line number Diff line change
@@ -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::<f64>` (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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the old-result decimal rendering.

Line 1 says 0x40704c0811b1d92c rendered as 260.75197000000003. The parser regression documentation identifies its shortest round-trip output as 260.75197000000004. Use the same value in this release note.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7483-directparser-float-parity.md` at line 1, Update the
old-result decimal rendering in the changelog entry to 260.75197000000004,
keeping the associated hexadecimal value and all other release-note details
unchanged.

74 changes: 74 additions & 0 deletions crates/perry-runtime/src/json/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1228,4 +1228,78 @@ mod tests {
);
}
}

/// #7477: the DirectParser must produce bit-identical f64s to the tape
/// materializer, whose number path is `str::parse::<f64>()` (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<String> = 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,
);
}
}
}
65 changes: 43 additions & 22 deletions crates/perry-runtime/src/json/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<f64>` (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;
Expand All @@ -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()
Expand Down
Loading