Skip to content

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

@proggeramlug

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:

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

  1. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    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_value is 90.45% of the entire process. Symbolicated, PERRY_DEBUG_SYMBOLS=1, 4665 leaf samples, main thread, pinned quiet mini. The line is tags: [...o.meta.tags] — a spread of a 3-element string array.

    probe (N=500 000) best isolates
    dc_full (the kernel) 6.20 s everything
    dc_spread 5.93 s just [...o.meta.tags]
    dc_map 0.32 s .map(x => ({…}))
    dc_lit 0.15 s the two object literals
    dc_slice 0.12 s tags.slice()
    dc_arrfrom 0.09 s Array.from(tags)
    dc_read 0.05 s property reads only (loop floor)

    Array.from(tags) is 66× cheaper than [...tags] for an identical result — it reaches js_array_clone's memcpy tail; the spread runs the full ECMA-262 protocol. Inside it: js_object_get_symbol_property (the @@iterator resolve) 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. @@iterator resolves through the by-name prototype tower and then builds a named bound closure. And every .next() allocates five heap objects in build_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 f06270d06 the kernel does not run. Own build, identical -p set: object_deep_clone exits 1 with TypeError: 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, main is faster. Interleaved best-of-5, quiet host:

    probe f06270d06 main
    dc_lit 0.200 s 0.150 s main 25% faster
    dc_map 0.380 s 0.320 s main 16% faster
    dc_read / dc_slice / dc_map_num — — equal
    dc_arrfrom 0.070 s 0.090 s main ~40 ns/call slower

    The one measurable rooting cost is dc_arrfrom — ~40 ns per js_array_clone call from #7497's RuntimeHandleScope in 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 real GC_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).

    before after node v26.5.1
    object_deep_clone 610 ms 40 ms 60 ms
    dc_spread (N=500 000) 5.92 s 0.12 s —

    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_discard never 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=1 then named the site exactly (retired obj_type=4 at js_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.
    • Two pre-existing [...arr] divergences from node, unchanged by the PR and verified identical at f06270d06: [...MyArr.from([1,2,3])] on a class MyArr extends Array throws value is not iterable, and a replaced Array.prototype[Symbol.iterator] is ignored by spread. Worth their own issues.

    Residual

    The @@iterator by-name tower walk and build_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.

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