Repository navigation
json tape: full-array scans pay 2.3× the direct parser (field_access 2981ms vs bun 223) — decomposition + roadmap #7478
Description
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
- 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).
- 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). - 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
docs(engine-plan): step 7 is a measured backlog; sequence the JSON wo…
docs(engine-plan): status quo + remaining work; history split to its …
proggeramlug commentedon Aug 6, 2026
Step 2 (reparse-on-materialize) is up as #7499.
Root-caused the intermittent SIGSEGV from the prototype:
DirectParseris only sound inside a no-move window in three separate ways — itsinput: &[u8]is derived from the blobStringHeaderfor the whole parse, it carries an unrooted raw-pointer shape cache (hot_shape_keys/hot_shape_array) across every allocation, andarray_push_parse_fastskips the generational barrier throughnote_array_slot_layout_onlyexplicitly on the strength ofjs_json_parse's suppression. The prototype called it without one. #7499 wraps the parse in a nesting-safeGcSuppressScope.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_arrayforce-materializes whenever any sparse-cache bit is set, sofield_accessdoes reachforce_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 10klazy_getcalls before it. #7499 therefore gates the reparse oncached_count * 2 < cached_lengthso 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. the1_json_pipelinefilter-all-records shape), and the existing random-accesscumulative_walk_stepstrigger, which fires at a cache count of ~4 on a 10k array.Also filed #7500 —
PERRY_GC_VERIFY_EVACUATION=1aborts on anyPERRY_JSON_TAPE=1workload (lazy sparse-cache slots report missing old->young edges). A/B'd against the merge-base: pre-existing, not from #7499.test(json): express the reparse mutation test through across_mut
perf(json): batch-materialize a lazy tape array by re-parsing its blob
test(json): express the reparse mutation test through across_mut
perf(json): batch-materialize a lazy tape array by re-parsing its blob (
proggeramlug commentedon Aug 6, 2026
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: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:
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.
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_accessreachesforce_materialize_lazywith the cache bitmap already full — the 2984 ms was spent in the 10 000
lazy_getcalls before materialization, not in the merge walk. So the remainingwork is the early batch-flip trigger: detect scan-shaped access and hand off to
the batch parser before paying those calls.
The
idiomaticrow 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_accessmaterially under 1350 ms androundtripstill at ~192 ms with the memcpy path intact.proggeramlug commentedon Aug 6, 2026
Early batch-flip is up as #7537. Recording the decomposition it was built from, because it redirects two of this issue's premises.
The
idiomaticrow flips two switches (PERRY_JSON_TAPE=0andPERRY_GEN_GC=0), so "tape off" alone had never been measured. All four combinations, pinned quiet host, 11 runs,origin/main@ fc5e56e: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).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 - scanis 707 ms under gen-GC but 309 ms under mark-sweep for the identical tree, and post-fliptape+markSweepfield_access is 1759 ms at sigma 2.1, 76 MB. So the next lever is not the materializer — it is (a) releasing the tape andblob_stroncematerializedis 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):field_accessbaseline checksum is2706164693; node says2706164683. One record'snestedstringifies as{"field0":8740,"field1":17480}— thefieldNfallback 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=1faults intest_json_tape_eager_materialization_handles_survive_copied_minor_gc(stale from-space deref in the eager materializer). A/B'd against reverted source: identical.docs(json): quote the measured 1.8x element-wise/batch ratio
perf(json): flip lazy-tape scans to the batch parser early (#7478)
docs(json): quote the measured 1.8x element-wise/batch ratio
perf(json): flip lazy-tape scans to the batch parser early (#7478) (#…
docs: changelog fragment for #7478 closure
docs(engine-plan): close #7478 — the JSON tape's scan penalty, re-mea…
docs: changelog fragment for #7478 closure
proggeramlug commentedon Aug 8, 2026
Re-ran this issue's own decomposition on current
mainbefore 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.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
roundtripdid 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
idiomaticarm, which flips two switches. Measuring all fourPERRY_JSON_TAPE×PERRY_GEN_GCcombinations,field_accessnow decomposes additively: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
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
idiomaticrow'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.mditem 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 == 0and 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 --lib1915/1915 including the two cases that assert which producer ran viareparse_materializations(). Built-p perry -p perry-runtime-static -p perry-stdlib-static,PERRY_RUNTIME_DIRpinned, archive mtime asserted moved,PERRY_NO_AUTO_OPTIMIZE=1on every compile.docs(engine-plan): close #7478 — the JSON tape's scan penalty, re-mea…
docs: changelog fragment for #7478 closure
docs(engine-plan): close #7478 — the JSON tape's scan penalty, re-mea…