Repository navigation
perf: object_deep_clone is 37.5x bun (657ms vs 17.5ms) — the worst row in the public artifact, newly visible now that it runs #7533
Description
The worst row in the public artifact
App-pattern ratios from the public baseline at v0.5.1299 (pinned quiet mini,
AC power, CPU-quiet gate passed, node 22.23.1 / bun 1.3.14, 11 runs/cell):
| kernel | perry | bun | node | perry/bun |
|---|---|---|---|---|
| object_deep_clone | 657.0 ms | 17.5 ms | 56.9 ms | 37.54× |
| promise_all_chains | 259.7 | 22.7 | 64.0 | 11.44× |
| json_parse_1mb | 438.2 | 68.1 | 127.1 | 6.43× |
| map_1m | 1233.7 | 256.5 | 320.1 | 4.81× |
| batch | 127.8 | 26.5 | 74.8 | 4.82× |
| json_stringify_1mb | 97.3 | 38.5 | 95.1 | 2.53× |
| buffer_transcode | 58.2 | 43.9 | 85.8 | 1.33× |
| date_format_parse | 36.0 | 44.8 | 116.3 | 0.80× (win) |
object_deep_clone is 37.5× bun and 11.5× node — far the worst cell, and
also the worst absolute gap (657 ms against 17.5 ms).
Why it is new to the backlog
It was not slow before; it was broken. Both this kernel and
promise_all_chains failed outright until today (#7475 → fixed by #7495,
#7497 → fixed by #7516/#7529), so neither has ever appeared in a performance
sweep. The engine plan's backlog table — which lists json_parse_1mb 6.27×
as the worst row — predates their working at all.
So these two rows are not regressions from the fixes; they are the first
measurement of code that previously crashed. But that leads directly to the
first thing to check.
First hypothesis to test, and it is a self-suspicion
The kernel is deep clone: spread- and iteration-heavy. That is exactly the
code the same fixes rooted, and rooting means re-reading through a handle at
every use:
- fix(gc): root the iterator drain's live values across .next() (#7475) #7495 rooted five live values in
js_iterator_to_array's drain loop — a loop
bounded at 100 000 iterations, now re-reading each per turn. - fix(gc): root the inlined spread's
[Symbol.iterator]walk (#7498) #7527 replaced a borrowed&strkey withHeapKeyBytes, a 64-byte copy per
property-name read on the prototype-walk tower. - fix(gc): Promise.all at scale read globalThis and its own combinator state from retired from-space (#7497) #7516 rooted 14 sites including
js_array_clone(every[...arr]/Array.from).
Each is individually cheap and all are necessary — they fix real
use-after-frees, and none of them may be reverted. But on a workload that does
nothing except spread and copy objects, "individually cheap" is the wrong unit.
Measure whether the re-reads are material here before assuming the 37.5× is all
pre-existing: build at f06270d06 (before today's rooting stack) and compare.
If the gap is largely pre-existing, that is the answer and the rooting work is
exonerated on the record.
If the re-reads ARE material, the fix is not to remove them. It is to hoist:
re-read once per loop iteration rather than per use, or keep the value in a
shadow-stack slot the collector rewrites in place so no re-read is needed at
all. #7487's pooled frame allocas are the precedent — they turned three FFI
calls per temporary into a store and a load.
Where to look after that
js_object_deep_clone / the structured-clone path, js_array_clone
(array/flat_clone.rs), and the property enumeration tower. Note
object_deep_clone at 657 ms vs promise_all_chains at 260 ms suggests the
cost is in the cloning itself rather than anything promise-shaped.
Acceptance
A real profile first (symbolicated, PERRY_DEBUG_SYMBOLS=1, on the pinned
host) saying where the 657 ms goes. No fix should be attempted before that
number exists — the #7510 experience is that a profile more than a few weeks
old sends people at the wrong 14%.
Activity
proggeramlug commentedon Aug 6, 2026
Phase 1/2 answers — profiled, and the rooting stack is exonerated
PR #7540. Full detail there; the two things this issue asked for:
Where the 657 ms goes: one line
array_from_spread_valueis 90.45% of the entire process. Symbolicated,PERRY_DEBUG_SYMBOLS=1, 4665 leaf samples, main thread, pinned quiet mini. The line istags: [...o.meta.tags]— a spread of a 3-element string array.dc_full(the kernel)dc_spread[...o.meta.tags]dc_map.map(x => ({…}))dc_litdc_slicetags.slice()dc_arrfromArray.from(tags)dc_readArray.from(tags)is 66× cheaper than[...tags]for an identical result — it reachesjs_array_clone's memcpy tail; the spread runs the full ECMA-262 protocol. Inside it:js_object_get_symbol_property(the@@iteratorresolve) 30.89%,js_iterator_to_array(the.next()drain) 34.34%, classification probes ~13%,RuntimeHandleScope~5%,_tlv_get_addr~17% cross-cutting.Two structural costs, neither observable for an ordinary array.
@@iteratorresolves through the by-name prototype tower and then builds a named bound closure. And every.next()allocates five heap objects inbuild_iter_result(the{value, done}object, its two key strings, its keys array), plus the iterator object once per spread — ~25 allocations for a 3-element spread, where bun does one allocation and a 24-byte memcpy.The self-suspicion: no, and stated plainly
Two independent answers, both exonerating #7495 / #7516 / #7527.
1. At
f06270d06the kernel does not run. Own build, identical-pset:object_deep_cloneexits 1 withTypeError: next is not a function— the #7475 defect. There is no "before" timing to regress from.2. On every path that runs in both arms,
mainis faster. Interleaved best-of-5, quiet host:f06270d06maindc_litdc_mapdc_read/dc_slice/dc_map_numdc_arrfromThe one measurable rooting cost is
dc_arrfrom— ~40 ns perjs_array_clonecall from #7497'sRuntimeHandleScopein the memcpy tail. That path is not on this kernel at all, and 0.02 s against 6.20 s would be 0.3% if it were. The 37.5× is entirely structural and entirely pre-existing.Phase 3 result
Dense fast path in
array_from_spread_value, guarded on realGC_TYPE_ARRAY+ not exotic +Array.prototype[Symbol.iterator]unmodified + no own@@iterator(probed without invoking an accessor, so a getter still sees exactly one call).object_deep_clonedc_spread(N=500 000)15.3× on the kernel, 49× on the spread. The row moves from 37.5× bun to ~2.3×, and from 11.5× node to 0.67× node — a win.
Two defects found on the way
js_array_map_discardnever rooted its callback — Method/closure bodies get no shadow-stack GC frames #6081's missed sibling. Latent until the fast path removed ~25 allocations per iteration and moved a collection into its window;PERRY_GC_PROTECT_FROMSPACE=1then named the site exactly (retiredobj_type=4atjs_array_map_discard + 788). Confirmed pre-existing: the kernel faults under the same instrument at the parent commit too, at a different site. Fixed in the PR.[...arr]divergences from node, unchanged by the PR and verified identical atf06270d06:[...MyArr.from([1,2,3])]on aclass MyArr extends Arraythrowsvalue is not iterable, and a replacedArray.prototype[Symbol.iterator]is ignored by spread. Worth their own issues.Residual
The
@@iteratorby-name tower walk andbuild_iter_result's five-allocations-per-.next()still run for every non-dense spread (Sets, Maps, generators, iterators). Off the common path now, but each deserves its own ticket.perf(array): copy an ordinary dense array's spread instead of driving…
fix(gc): root js_array_map_discard's callback across the dispatch loop (
perf(array): copy an ordinary dense array's spread instead of driving…
fix(runtime): root the callback across every Array.prototype iteratio…
fix(runtime): root the callback across every Array.prototype iteratio…