ReviewOS

zig-utils/zig-js

JSON.stringify: eliminate recursive nested-output copying

#558
Closed chris opened this 9 days ago ·
9 days ago

Parents: #473, #493 Related: #460, #461, #519, #556, #557

Problem

SerializeJSONObject builds every property value in a fresh temporary buffer, copies it into an object-local temporary buffer, then copies that complete object into its parent. A depth-n acyclic chain therefore recopies the growing encoded suffix at every ancestor. The #556 exact-parent rows still reach 178.7 million candidate instructions and about 2.55 GB peak RSS at depth 4,096 after active-cycle membership itself became indexed; the real-cycle row, which throws before unwinding successful output, needs only 21.9 million instructions and about 57 MB peak RSS.

Scope

  • Reuse the benchmark-first depth 512/1,024/2,048/4,096 rows landed in 4be2bc64, and add controls only if needed to isolate omission/pretty-print behavior before the runtime change.
  • Serialize object members into one authoritative output buffer with exact rollback for values omitted by undefined, callable, or Symbol semantics.
  • Eliminate successful-child recopy at each ancestor while preserving braces, commas, indentation, property order, and abrupt completion.
  • Keep output growth bounded by the existing allocator/depth contracts and release invocation-local storage on success, throw, or OOM.

No-workaround rules

  • No lower depth cap, flattened input preprocessing, output precomputation, benchmark recognition, callback suppression, changed omission semantics, global scratch buffer, system JSON library, or altered property order.
  • Do not touch stacks, queues, jobs, Promise/microtask, or Worker lifecycle code.

Acceptance

  • Benchmark-first acyclic depth rows with frozen topology, output length/leaf position/checksums, construction outside timing, ten warmups, and one scored job exist before the runtime change.
  • Successful nested object output performs O(total output bytes + visited properties) copying rather than copying each descendant suffix at every ancestor.
  • Empty objects, first/middle/last omitted properties, getters, replacer/toJSON, Proxy keys/descriptors, pretty indentation, throws, and property order preserve exact behavior.
  • Focused OOM/deep/GC/TSan, forced execution modes where eligible, JSON.stringify test262, and full units pass without regressions.
  • Seven-pair exact-parent evidence publishes wall, CPU, peak RSS, instructions/cycles/energy availability, thermal state, and dispersion with no discarded samples; shallow/wide controls remain neutral.

Security boundary

Nesting depth, output width, omitted-member placement, callback/proxy behavior, and allocation pressure are attacker-controlled. The fix removes amplification without exposing partial output or weakening exact SerializeJSONObject ordering and abrupt-completion rules.

Resolution

9 days ago

Benchmark-first foundation is already on main in https://github.com/zig-utils/zig-js/commit/4be2bc64, before any #558 runtime change. The depth 512/1024/2048/4096 rows construct graphs outside timing, run ten warmups plus one scored stringify, and validate exact length, leaf offset, delimiters, and frozen checksums. The #556 evidence commit https://github.com/zig-utils/zig-js/commit/ac9dc52b also records the pre-#558 indexed-cycle baseline and isolates the remaining successful-output amplification from the real-cycle control.

9 days ago

Runtime checkpoint landed in 3ec9847f: perf(json): stream nested stringify output (#558).

The stringifier now writes each object member directly into the invocation output buffer and rolls back to an exact mark for omitted properties. Successful descendants are no longer copied once per ancestor. Unpublished output uses the Context temporary allocator, so OOM tears down partial output exactly and never publishes a partial assignment.

Validation:

  • test/built-ins/JSON/stringify: 66 → 66 passing, 0 failures
  • focused JSON units: 11/11 in Debug and TSan, 0 leaks
  • full units: 1,632 passed, 1 expected skip, 0 failures, 0 leaks
  • bounded precise-GC OOM witness, forced tree-walker/required-bytecode witness, and moving-compaction witness
  • all six frozen benchmark checksums preserved

A current-source smoke at depth 4096 retired about 42.0M process instructions; the exact-parent A/B against ac9dc52b is next, before this issue is closed.

9 days ago

Completed by 3ec9847f (perf(json): stream nested stringify output (#558)) with evidence published by 61652299 (docs(json): publish nested-output evidence (#558)).

The implementation now uses one invocation-local authoritative output buffer, exact rollback for omitted object members, and temporary-allocation teardown on success, throw, and OOM. It preserves observable getter, toJSON, replacer, Proxy, property-order, indentation, and abrupt-completion semantics.

Measured exact-parent result at depth 4,096:

  • retired instructions: 179,392,635 -> 42,001,313 (0.2341x)
  • median peak RSS: 1,836,728,320 -> 57,098,240 bytes (0.0311x)
  • shallow control: 0.984x wall, 0.9663x instructions
  • real-cycle control: neutral peak RSS and process CPU; 1.0381x instructions

Validation is recorded in the commits and dated overview: Test262 66/66, full units 1,632 passed plus 1 expected skip, focused Debug/TSan and bounded-OOM/GC/deep witnesses, six raw schema validations, benchmark validation family, and 259-file docs link gate.

Sign in to comment on this issue.