Skip to content

Bulk string scanning and a simple-number fast path for JSON.parse#2725

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/json-scan-fast-paths
Jul 21, 2026
Merged

Bulk string scanning and a simple-number fast path for JSON.parse#2725
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/json-scan-fast-paths

Conversation

@lahma

@lahma lahma commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Campaign-2 item (follow-up to #2716#2723). Post-interning profiles put JsonParser.ScanStringLiteral at 8.5% self (now value strings) and numeric parsing at ~5%.

Changes

Bulk string scanning: the char-by-char scan becomes a bulk span copy — net8+ uses a SearchValues<char> over the full stop set (control chars, ", \, U+2028/U+2029) so IndexOfAny lands exactly where the per-char loop stopped; older TFMs use IndexOfAny('"','\') plus a short validation pass. Escapes keep per-char handling; key interning untouched; error messages and positions byte-for-byte preserved (locked by tests, including the U+2028 → UnexpectedEOS quirk).

Simple-number fast path: optional -, digits, optional fraction, no exponent, ≤15 total digits — one exact long numerator divided by an exact power of ten. Gated to net8+: testing revealed legacy net472 double.Parse is not correctly rounded even for ≤15 digits (1-ULP differences), so older TFMs keep deferring to the platform parser and bit-identity holds per platform by construction. -0 always defers. A 200,000-case property test proves bit-identity against double.Parse for covered shapes.

Numbers (default BDN job, same-base A/B vs main)

Row main this PR delta
JsonBenchmark.Parse bestbuy_dataset 134.5 ms 111.3 ms −17.2%
JsonBenchmark.Parse twitter 2.541 ms 2.153 ms −15.3%
json-parse-modern row (Jint_ParsedScript) 18.63 ms 17.57 ms −5.7%

Gates

Build clean on all 5 TFMs; JsonTests 131/131 on net10 + net472; full Jint.Tests 3733 passed / 0 failed; Test262 99,431 / 0 failed (JSON subset 502/502).

🤖 Generated with Claude Code

ScanStringLiteral now copies runs of ordinary characters in bulk instead
of one char at a time. On net8+ a SearchValues<char> over the closing
quote, the escape backslash, every control character (< 0x20) and the two
Unicode line separators locates the next stop exactly where the per-char
loop would have halted; older TFMs use IndexOfAny('"','\') plus a short
control/line-separator validation. Escape handling, interned-key routing
and every error message/position are preserved byte-for-byte (including
the U+2028/U+2029 break to UnexpectedEOS).

ScanNumericLiteral gains a fast path for the dominant shape -- optional
'-', integer digits, optional '.fraction', no exponent, <= 15 total
digits -- accumulating one exact long numerator and dividing once by an
exact power of ten. The result is bit-identical to double.Parse for every
accepted input because both operands are exactly representable. Gated to
net8+ where double.Parse is IEEE correctly-rounded; legacy runtimes keep
deferring to double.Parse (which can differ by an ULP and parses "-0" as
+0.0) so behavior stays byte-for-byte identical everywhere. Negative zero
also defers to double.Parse.

Tests: bulk-scan escapes at start/middle/end, internal-buffer-boundary
crossings, control-char rejection at exact positions and U+2028/U+2029
termination; a 200k-case number bit-identity property test plus edge
cases and negative/positive-zero handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U3NyGAFzB3uRWQnPUNL8i2
@lahma
lahma enabled auto-merge (squash) July 21, 2026 16:54
@lahma
lahma merged commit 43d2be2 into sebastienros:main Jul 21, 2026
7 of 8 checks passed
@lahma
lahma deleted the perf/json-scan-fast-paths branch July 21, 2026 17:17
lahma added a commit that referenced this pull request Jul 21, 2026
Single-session re-measure of all lanes on main with #2725/#2726
merged. Extends the campaign notes with the round-2 wins (JSON scan
fast paths, closure-read chain memoization) and records the
measured-and-dropped direct-dispatch experiment: the builtin-call
path is already at its cost floor, so the residual string-row gap is
interpreter dispatch itself, not call ceremony.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lahma added a commit that referenced this pull request Jul 22, 2026
…nd keys (#2738)

Four long-standing spec deviations surfaced while reviewing the 4.14
JSON parser changes (#2718/#2725/#2732), all verified against V8:

- '-09' / '-00' parsed as -9 / -0: the leading-zero rejection was
  guarded on builder length 1, which a minus sign defeated. The rule
  now applies to the first digit regardless of sign
  (int = zero / digit1-9 *DIGIT).
- '1.' / '1.e3' parsed as 1 / 1000: a decimal point now requires at
  least one fraction digit (frac = decimal-point 1*DIGIT).
- Raw U+2028/U+2029 inside strings threw SyntaxError: the JSON grammar
  permits any code point except quote, backslash and control characters
  below 0x20 raw inside a string. The line separators are removed from
  the bulk-scan stop set (a smaller set, marginally faster) and the
  scanner's terminator lane is gone.
- Escaped control characters in member keys were rejected while the
  same content was accepted as a value; any string is a valid key, so
  the key-only validation is removed. Raw control characters keep
  being rejected everywhere by the string scanner.

Full suite plus Test262 green; Test262 does not cover these forms
either way, so no exclusion changes.


Claude-Session: https://claude.ai/code/session_0115tQFNyyQqc1HQGPLUgZND

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant