perf(json): make deeply nested JSON.stringify O(depth^2)#887
Conversation
MakeIndent built the pretty-print indent with `Result := Result + FGap` in a loop (O(level^2) per call) and was called twice per object/array node, so deeply nested values spent O(depth^3) building indentation. Replace the accumulator loop with DupeString(FGap, ALevel), a single SetLength+Move pass (O(level)). Output is byte-identical (ES2026 §25.5.4.5/§25.5.4.6: the indent at depth d is the gap repeated d times), and DupeString returns '' for level <= 0 and for an empty gap, so the previous guards are no longer needed. Add exact-output regression tests (deep object, deep array, multi-char gap, depth-30 per-level indent width) and depth-50 indent benchmarks for both object and array nesting. Closes #811 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughRefactors JSON Stringify Buffered Writer Refactor
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
Suite TimingTest Runner (interpreted: 10,956 passed; bytecode: 10,956 passed)
MemoryGC rows aggregate the main thread plus all worker thread-local GCs. Test runner worker shutdown frees thread-local heaps in bulk; that shutdown reclamation is not counted as GC collections or collected objects.
Benchmarks (interpreted: 433; bytecode: 433)
MemoryGC rows aggregate the main thread plus all worker thread-local GCs. Benchmark runner performs explicit between-file collections, so collection and collected-object counts can be much higher than the test runner.
Measured on ubuntu-latest x64. |
Benchmark Results433 benchmarks · PR vs same-runner Interpreted: 🟢 23 improved · 🔴 35 regressed · 375 unchanged · avg +1.3% Typical per-run noise (median variance): interpreted ±3.2%, bytecode ±2.7%. Deltas within noise overlap and read as unchanged. arraybuffer.js — Interp: 🔴 3, 11 unch. · avg -2.6% · Bytecode: 🔴 3, 11 unch. · avg -1.5%
arrays.js — Interp: 19 unch. · avg -1.2% · Bytecode: 🟢 1, 🔴 2, 16 unch. · avg -1.9%
async-await.js — Interp: 6 unch. · avg +0.9% · Bytecode: 6 unch. · avg -2.7%
async-generators.js — Interp: 2 unch. · avg +1.9% · Bytecode: 2 unch. · avg -4.1%
atomics.js — Interp: 🔴 1, 5 unch. · avg +0.2% · Bytecode: 🟢 1, 5 unch. · avg +3.9%
base64.js — Interp: 🔴 1, 9 unch. · avg -1.4% · Bytecode: 10 unch. · avg -0.6%
classes.js — Interp: 🟢 2, 🔴 2, 27 unch. · avg -0.1% · Bytecode: 🟢 2, 🔴 4, 25 unch. · avg -0.5%
closures.js — Interp: 11 unch. · avg +0.8% · Bytecode: 11 unch. · avg +0.1%
collections.js — Interp: 🔴 2, 10 unch. · avg +1.3% · Bytecode: 🟢 1, 11 unch. · avg +3.0%
csv.js — Interp: 13 unch. · avg -2.0% · Bytecode: 13 unch. · avg +1.6%
destructuring.js — Interp: 🟢 3, 19 unch. · avg +0.9% · Bytecode: 🟢 3, 🔴 1, 18 unch. · avg +1.9%
fibonacci.js — Interp: 8 unch. · avg +1.7% · Bytecode: 🔴 2, 6 unch. · avg -3.9%
float16array.js — Interp: 🟢 2, 🔴 1, 29 unch. · avg +0.3% · Bytecode: 🟢 6, 26 unch. · avg +2.0%
for-of.js — Interp: 🔴 1, 6 unch. · avg -1.9% · Bytecode: 7 unch. · avg -2.0%
generators.js — Interp: 🔴 1, 3 unch. · avg +1.0% · Bytecode: 4 unch. · avg +1.1%
intl.js — Interp: 🟢 2, 4 unch. · avg +5.9% · Bytecode: 🔴 1, 5 unch. · avg -4.1%
iterators.js — Interp: 🟢 3, 🔴 1, 38 unch. · avg +2.5% · Bytecode: 🟢 3, 🔴 3, 36 unch. · avg -0.1%
json.js — Interp: 🟢 4, 19 unch. · avg +32.6% · Bytecode: 🟢 4, 🔴 2, 17 unch. · avg +26.7%
jsx.jsx — Interp: 🔴 2, 19 unch. · avg -2.1% · Bytecode: 🟢 4, 🔴 4, 13 unch. · avg +1.7%
modules.js — Interp: 🔴 2, 7 unch. · avg -5.3% · Bytecode: 🟢 1, 8 unch. · avg +1.7%
numbers.js — Interp: 11 unch. · avg +0.1% · Bytecode: 🟢 2, 🔴 1, 8 unch. · avg +2.2%
objects.js — Interp: 🔴 1, 6 unch. · avg -2.7% · Bytecode: 🔴 1, 6 unch. · avg -1.3%
promises.js — Interp: 12 unch. · avg -2.2% · Bytecode: 🔴 2, 10 unch. · avg -0.3%
property-access.js — Interp: 🔴 1, 4 unch. · avg -5.6% · Bytecode: 🔴 1, 4 unch. · avg -2.9%
regexp.js — Interp: 11 unch. · avg +0.7% · Bytecode: 🟢 1, 10 unch. · avg +1.3%
strings.js — Interp: 🔴 2, 17 unch. · avg -0.9% · Bytecode: 🟢 1, 18 unch. · avg +1.5%
temporal.js — Interp: 6 unch. · avg -1.9% · Bytecode: 🔴 1, 5 unch. · avg -0.4%
tsv.js — Interp: 9 unch. · avg +2.4% · Bytecode: 🔴 1, 8 unch. · avg -1.4%
typed-arrays.js — Interp: 🟢 6, 🔴 2, 14 unch. · avg +13.8% · Bytecode: 🟢 2, 🔴 8, 12 unch. · avg -1.5%
uint8array-encoding.js — Interp: 🔴 5, 13 unch. · avg -11.2% · Bytecode: 🟢 6, 🔴 3, 9 unch. · avg +18.4%
weak-collections.js — Interp: 🟢 1, 🔴 7, 7 unch. · avg -14.6% · Bytecode: 🟢 2, 🔴 4, 9 unch. · avg -6.6%
Deterministic profile diffDeterministic profile diff: no significant changes. Measured on ubuntu-latest x64. Each PR run also builds the |
test262 Conformance
Areas closest to 100%
Per-test deltas (+1 / -0)Newly passing (1):
Steady-state failures are non-blocking; regressions vs the cached main baseline (lower total pass count, or any PASS → non-PASS transition) fail the conformance gate. Measured on ubuntu-latest x64, bytecode mode. Areas grouped by the first two test262 path components; minimum 25 attempted tests, areas already at 100% excluded. Δ vs main compares against the most recent cached |
The recursive serializer built a result string per object/array level (SB.ToString) that the parent re-appended into its own buffer, re-copying the deepest content once per ancestor level — an O(depth^3) term that dominated deep stringify even after the indent fix in the previous commit. Thread a single TStringBuffer through the recursion (WriteValue/WriteObject/ WriteArray) so every value is written once; the root calls ToString once. This drops deep pretty-print from O(depth^3) to O(depth^2) (the unavoidable indentation-output size): a clean --prod A/B at depth 480 goes 583ms -> 62ms (~9.4x), per-doubling scaling falls from ~8x to ~3x. Output is byte-identical across JSON/JSON5, gap/no-gap, replacer, toJSON, RawJSON, and circular detection. Removes the now-dead StringifyValue. Add deep no-gap object/array, interleaved object/array, and JSON5 deep pretty-print regression tests, plus a depth-200 indent benchmark. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
JSON.stringifypretty-printing of deeply nested values was O(depth³) from two independent terms. This PR fixes both, bringing deep pretty-print to O(depth²) — the unavoidable size of the indentation that must appear in the output.MakeIndentbuilt the indent withResult := Result + FGapin a loop (O(level²) per call, called per node). Replaced withDupeString(FGap, ALevel)— a singleSetLength+Movepass (O(level)).SB.ToStringand the parent re-appended it, re-copying the deepest content once per ancestor level. Replaced by threading one sharedTStringBufferthrough the recursion (WriteValue/WriteObject/WriteArray) so every value is written once and the root callsToStringonce.{}/[]) with no output change.StringifyValue.LengthOfArrayLikenow runs inside thetry/finally, so a throwing.lengthcoercion no longer leaks a stack entry.toJSON, RawJSON, and circular-reference detection; the stringifier's re-entrancy (FGap/stack save-restore) is preserved.Closes #811
Testing
Details:
Full suite 10956/10956 passing in both interpreted and bytecode modes.
./format.pas --check: 370 files, all formatted correctly.Byte-identical cross-check: the new framing tests pass against both the pre-rewrite and post-rewrite code (old behavior == new behavior).
New regression tests: deep nested object/array per-level indentation, multi-character gap, depth-30 indent-width scaling, deep no-gap object/array, interleaved object/array, and a deep JSON5 pretty-print (trailing comma per level). New depth-50 object/array and depth-200 object indent benchmarks; all execute.
Performance (clean
--prodA/B, depth 120/240/480, 40 reps, min of 5):Per-doubling scaling falls from ~8× (O(depth³)) to ~3× (O(depth²)).
Code review:
/code-review(4 independent finder passes) — no defects.