Skip to content

json tape: full-array scans pay 2.3× the direct parser (field_access 2981ms vs bun 223) — decomposition + roadmap #7478

Closed

Description

@proggeramlug

json_polyglot/field_access (parse → touch every element → stringify) is the lazy tape's worst case: 2981 ms vs bun's 223, while the same tape wins roundtrip outright (194 vs bun 221, node 384). This issue records tonight's decomposition and the two dead ends, so the next attempt starts from measurements instead of the plausible-but-wrong model I started with.

Decomposition (50 iterations, quiet host, same binary)

phase tape on tape off
parse only 210 ms 1220 ms
parse + full scan 3030 ms 1287 ms
parse + stringify 254 ms 1756 ms

Tape wins parse 6× (deferred materialization) and stringify 7× (unmutated-tape memcpy). The full scan is the entire loss: ~56 ms/iter for scan+materialize vs ~24 ms/iter for the direct parser's tree build. Both build the same 10k-record tree — the tape's per-element materialization path is ~2.3× the batch parser.

Two approaches tried, measured, and rejected

1. Earlier batch-flip triggers. Added distinct-element coverage (trip at half) and sequential-streak (trip at 16 consecutive) triggers alongside the existing walk-steps heuristic — the walk-steps trigger provably cannot fire on sequential access (1 step/element accumulates n, threshold is 2n). Both triggers verified firing (lldb breakpoint: 1 materialize per iteration). Result: time noise-equal (min 3050 old vs 3322 new), RSS regressed 180 → 213 MB — the full tree materializes while blob+tape+cache are still live, and total materialization work is unchanged because every element gets materialized either way. Reverted.

2. Re-parse the retained blob with the DirectParser inside force_materialize_lazy (lazy is top-level-array-only, so the blob is exactly the array's source; patch the few handed-out cached elements back over the fresh slots for identity). This is the structurally right fix — batch parse at 24 ms/iter instead of 56 — but it is blocked by #7477: the DirectParser's float parsing diverges from the tape's (and from node), so the prototype produced checksum 2552986400 where node says 2552985550. It also had an intermittent SIGSEGV in my rooting; both are why it was reverted rather than debugged into shape at 4 a.m.

The path that wins both

  1. Fix JSON DirectParser parses floats differently from the tape materializer — and node agrees with the tape #7477 (DirectParser float divergence — a correctness bug in its own right).
  2. Re-land the reparse-on-materialize with the identity patch loop (the prototype's shape, with the rooting done carefully: root the parsed result before re-reading the header, per-element handle across store_array_slot).
  3. Keep a trip heuristic only as the decision of when to batch — the streak/coverage work is sound for that, it just cannot pay while the batch materializer is the same speed as the element-wise one.

Expected: field_access ≈ 210 + 50×(24 + ε) + memcpy-stringify ≈ ~1500 ms (from 2981), while roundtrip keeps its 194 ms win — the tape then wins or ties every JSON benchmark against the direct parser, and the remaining gap to bun (~223 ms) becomes a parser-speed workstream (shared with json_parse_1mb, currently 6.27× vs bun), not a tape-policy one.

One more datum

The per-element merge branch in force_materialize_lazy (fresh RuntimeHandleScope + safepoint per element, to honor a handful of cached slots) looked like the cost and is not: replacing it with root-batch + patch-cached-slots measured neutral. The copied-minor sabotage test (test_json_tape_force_materialize_sparse_cache_handles_survive_copied_minor_gc) hooks the ForceLazyArrayRooted safepoint — any rework of that function must keep firing it, or the instrument silently loses its subject.

Activity

  1. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    Step 2 (reparse-on-materialize) is up as #7499.

    Root-caused the intermittent SIGSEGV from the prototype: DirectParser is only sound inside a no-move window in three separate ways — its input: &[u8] is derived from the blob StringHeader for the whole parse, it carries an unrooted raw-pointer shape cache (hot_shape_keys/hot_shape_array) across every allocation, and array_push_parse_fast skips the generational barrier through note_array_slot_layout_only explicitly on the strength of js_json_parse's suppression. The prototype called it without one. #7499 wraps the parse in a nesting-safe GcSuppressScope.

    One correction to the plan's expectation, worth recording before step 3 is attempted: the reparse alone does not move field_access, and cannot. try_stringify_lazy_array force-materializes whenever any sparse-cache bit is set, so field_access does reach force_materialize_lazy — but with the bitmap full (all 10k elements were handed out by the scan). At that point the element-wise merge is already the cheap producer: it copies 10k cached JSValues and materializes nothing. All 2981 ms was spent in the 10k lazy_get calls before it. #7499 therefore gates the reparse on cached_count * 2 < cached_length so it does not turn a cheap merge into a full redundant parse.

    That makes step 3 load-bearing rather than optional for this benchmark: the trigger has to fire early, while the cache is still mostly empty, so the reparse replaces the remaining per-element work instead of following it. The step-1 measurement (trigger neutral, RSS 180 -> 213 MB) was taken with the slow materializer on the other side; the RSS half of that cost is unchanged by #7499 and should be re-measured together with the time half.

    Benefiting today, without any new trigger: full-array ops on a lazy array whose elements have not been individually walked (.map/.filter/spread/for…of/sort, i.e. the 1_json_pipeline filter-all-records shape), and the existing random-access cumulative_walk_steps trigger, which fires at a cache count of ~4 on a 10k array.

    Also filed #7500 — PERRY_GC_VERIFY_EVACUATION=1 aborts on any PERRY_JSON_TAPE=1 workload (lazy sparse-cache slots report missing old->young edges). A/B'd against the merge-base: pre-existing, not from #7499.

  2. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    Quiet-host numbers from the public baseline at v0.5.1299 (pinned mini, AC power, CPU-quiet gate passed, node 22.23.1 / bun 1.3.14). These settle the direction.

    field_access — the lazy tape is a NET NEGATIVE, not merely insufficient:

    config median p95 σ RSS
    perry optimized (gen-gc + lazy tape) 2984 ms 3341 136.4 219 MB
    perry idiomatic (mark-sweep, no lazy) 1350 ms 1351 0.7 61 MB
    bun 218 218 0.4 91 MB
    node 380 384 2.2 94 MB
    rust serde_json (LTO) 183 199 4.7 10 MB

    Our "optimized" configuration is 2.2× slower than our unoptimized one on this
    workload
    , and carries 3.6× the RSS (219 MB vs 61 MB). Against bun it is 13.7×.

    Two things worth pulling out beyond the headline:

    1. σ = 136.4 against every other row's < 5. The lazy path's cost is not just
      high, it is variable — 2843…3341 ms across samples where bun varies by 1 ms.
      Whatever the materializer is doing has data-dependent cost that nothing else
      in the table exhibits. That is a lead in its own right.
    2. The roundtrip arm shows the tape is worth keeping. Same binary, same run:
      192 ms optimized vs 1307 ms idiomatic — a 6.8× win, beating bun (216) and node
      (379) and landing within ~8% of Rust serde_json (178). So this is not "remove
      the tape"; it is "stop paying for it on scan-shaped access".

    What this means for the fix. #7499 landed the reparse-on-materialize path but
    is a deliberate no-op here, because field_access reaches force_materialize_lazy
    with the cache bitmap already full — the 2984 ms was spent in the 10 000
    lazy_get calls before materialization, not in the merge walk. So the remaining
    work is the early batch-flip trigger: detect scan-shaped access and hand off to
    the batch parser before paying those calls.

    The idiomatic row is also a useful upper bound on what a trivial fix is worth:
    if the tape simply declined this shape and fell back, we would be at ~1350 ms
    (2.2× better) without any new machinery. The batch-flip should beat that, but
    anything that does not is worse than switching off.

    Acceptance should be both arms: field_access materially under 1350 ms and
    roundtrip still at ~192 ms with the memcpy path intact.

  3. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    Early batch-flip is up as #7537. Recording the decomposition it was built from, because it redirects two of this issue's premises.

    The idiomatic row flips two switches (PERRY_JSON_TAPE=0 and PERRY_GEN_GC=0), so "tape off" alone had never been measured. All four combinations, pinned quiet host, 11 runs, origin/main @ fc5e56e:

    variant default (tape+genGC) tape off (genGC) tape+markSweep idiomatic
    parse only 189 · s1.7 · 69 MB 1412 · 166 MB 189 · 67 MB 1184 · 57 MB
    parse+scan 2729 · s214.9 · 208 MB 1468 · 170 MB 2957 · s8.8 · 66 MB 1226 · 58 MB
    roundtrip 201 · 87 MB 1594 · 159 MB 201 · 88 MB 1433 · 59 MB
    field_access 2942 · s101.8 · 222 MB 1556 · 161 MB 3247 · s7.7 · 67 MB 1478 · 61 MB
    1. The gap is the tape, not the GC. field_access 2942 -> 1556 on the tape switch alone; the collector is worth a further 78 ms. So idiomatic's 1350 is a fair tape floor after all — but for a different reason than "mark-sweep is faster here" (with the tape ON, mark-sweep is 305 ms slower).

    2. The sigma=136 lead is a gen-GC x tape interaction, not the materializer. Identical tape, different collector: scan sigma 214.9 -> 8.8, field_access 101.8 -> 7.7; tape-off under gen-GC is sigma 17.6. Neither factor alone does it. Same story for RSS: 208 MB vs 66 MB for the same tape. Each parse allocates header+tape as one ~2.4 MB block (200,002 entries here), which rounds to a dedicated oversized arena block and is retained — so the 219 MB is the generational collector holding those blocks, not the tape's size.

    Result of the flip (15 runs, arms back to back): roundtrip 202 -> 201 ms unchanged; field_access 2981 -> 2016 ms (-32%); parse+scan 2729 -> 1339 ms (-51%, sigma 215 -> 26). On a pure scan the tape now beats its own tape-off arm (1339 vs 1545), so it has stopped being a net negative on that shape.

    It does not clear the 1350/1477 floor, and cannot in this design — the tape build is purely additive whenever the whole tree is materialized anyway, and the flip hands off to a producer that re-tokenizes the blob. Turning the tape off for this shape is still ~500 ms better. Recording that plainly per the acceptance bar.

    Where the remaining cost moved: full - scan is 707 ms under gen-GC but 309 ms under mark-sweep for the identical tree, and post-flip tape+markSweep field_access is 1759 ms at sigma 2.1, 76 MB. So the next lever is not the materializer — it is (a) releasing the tape and blob_str once materialized is set (both provably dead; needs the tape out-of-line, offset 0 is unaffected), and (b) batch-materializing from the tape so the 189 ms stops being additive.

    Two pre-existing bugs surfaced while measuring (both on origin/main, neither fixed in #7537):

    • The lazy tape emits wrong JSON on this very benchmark. field_access baseline checksum is 2706164693; node says 2706164683. One record's nested stringifies as {"field0":8740,"field1":17480} — the fieldN fallback for an unreadable key header, i.e. the tape materializer produced an object with lost key pointers. Needs tape AND gen-GC together (each alone is clean), and needs a full element-wise scan — a 100-of-10,000 prefix touch does not reproduce it in 400 iterations. Worth its own issue.
    • PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_ZEAL=1 faults in test_json_tape_eager_materialization_handles_survive_copied_minor_gc (stale from-space deref in the eager materializer). A/B'd against reverted source: identical.
  4. proggeramlug commented on Aug 8, 2026

    @proggeramlug
    ContributorAuthor

    Re-ran this issue's own decomposition on current main before touching anything. The headline is fixed — the three-step roadmap shipped in full and nobody had measured the result together.

    Fresh decomposition — v0.5.1370 (9617779046)

    Pinned quiet mini, taskpolicy -t 0 -l 0, 11 interleaved rounds (every arm gets sample r before any arm gets r+1), 176/176 samples asserted, every arm's checksum byte-identical to node 26.5.1.

    phase tape on: then → now tape off: then → now
    parse only 210 → 160 ms 1220 → 1196 ms
    parse + full scan 3030 → 1233 ms 1287 → 1224 ms
    parse + stringify 254 → 171 ms 1756 → 1449 ms
    field_access 2981 → 1721 ms — → 1480 ms

    The title claim is gone. A full scan cost 2.3× the direct parser (3030 vs 1287). It is now 1.01× (1233 vs 1224) — the tape neither wins nor loses a scan, which is the correct resting state for a structure whose entire value is on the paths that skip materialization. And roundtrip did not pay for it: 254 → 171 ms.

    The two remaining terms are orthogonal — that is what closes it

    The issue's floor came from the idiomatic arm, which flips two switches. Measuring all four PERRY_JSON_TAPE × PERRY_GEN_GC combinations, field_access now decomposes additively:

    tape on tape off tape term
    gen-GC 1721 ms 1480 ms +241
    mark-sweep 1133 ms 938 ms +195
    gen-GC term +588 +542

    The tape costs ~200 ms whichever collector runs; the collector costs ~560 ms whether or not the tape exists. This issue documented these as an interaction (scan σ 214.9 under gen-GC vs 8.8 under mark-sweep for the identical tape) — that interaction is exactly what #7539 removed, and it is gone.

    The ~200 ms is the tape build (parse-only is 160 ms). It is structural, and #7537 already said so: the build is purely additive whenever the whole tree ends up materialized anyway, and nothing can predict scan-shaped access before the parse. The remaining ~560 ms is a generational-collector term the tape-off arm carries identically — GC-campaign work, not tape policy, so it should not keep this issue open.

    What actually fixed it

    roadmap step landed as
    1 — DirectParser float divergence (#7477) #7483
    2 — reparse-on-materialize #7499
    3 — keep a trip heuristic (early batch flip) #7537
    (not on the roadmap) tape out of the old generation #7539
    (surfaced by the work) wrong JSON on this very benchmark (#7538) #7546

    Neither documented dead end was re-run: the batch-flip trigger's RSS regression (180 → 213 MB) was a consequence of the tape sitting in the old generation, which #7539 fixed at the source — RSS on this benchmark is now 149 MB default, against 219 MB at the v0.5.1299 sweep.

    A note on the acceptance bar

    The bar set in this comment was "field_access materially under 1350 ms and roundtrip still at ~192 ms". Roundtrip clears it (171 ms). field_access at 1721 ms does not — but 1350 was the idiomatic row's measured value at v0.5.1299, and that row is now 938 ms. A bar quoted as a number silently becomes a bar against a different build; the single-switch form of the same question (tape on vs tape off, same collector) went 1.89× → 1.16×, which is the comparison that survives a moving baseline.

    Worth recording because this issue nearly cost a second wasted attempt: its headline was stale in both directions — the 2.3× had already been fixed by four merges, and the floor it would have been judged against had itself moved by 400 ms.

    Closing. docs/engine-plan.md item 2 updated in #7654, along with the "re-measure before scoping" note (three instances → four).

    Verification: both reported runs on a host gated to rustc == 0 and 1-min load < 1.5 held across two consecutive checks; a first attempt was discarded when another agent's build started mid-run (load 1.86 → 8.57, every σ 50–600). The harness fails rather than blanks on a cell that produced no sample or checksum. Tape liveness asserted (tape-on/off differ 7.5× on parse-only, 8.5× on roundtrip); cargo test -p perry-runtime --lib 1915/1915 including the two cases that assert which producer ran via reparse_materializations(). Built -p perry -p perry-runtime-static -p perry-stdlib-static, PERRY_RUNTIME_DIR pinned, archive mtime asserted moved, PERRY_NO_AUTO_OPTIMIZE=1 on every compile.

  5. added 3 commits that reference this issue on Aug 8, 2026
    4507fc2
    090d93c
    227183b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions