Repository navigation
perf: a ~25M-instruction one-time cost is triggered by for...of, JSON.stringify on objects, and array-like access (but not by spread, Promise, or Object.keys) #10686
Description
Activity
Narrowed further — and two framings in the issue above are wrong
A 2×2 plus three controls. Baseline 113,987.
expression instructions Array.from([1,2,3])— real array, no mapper757,552 Array.from([1,2,3], x => x)— real array, with mapper25,383,740 Array.from({length:3,0:1,1:2,2:3})— array-like, no mapper22,209,941 Array.from({length:3,…}, x => x)— array-like, with mapper22,242,339 Correction 1 — it is not "the array-like path".
Array.fromon a real array with a mapper costs 25.4M. Both array-likeness and the mapper trigger it independently; only the bare array→array copy escapes, which perry evidently special-cases.Correction 2 — it is not "a runtime builtin invoking user JS". That was the obvious reading of the mapper result, and it is wrong:
expression instructions [1,2,3].map(x => x)770,416 [3,1,2].sort((a,b) => a-b)772,436 [1,2,3].forEach(x => x)769,722 All three call user code per element and all three are cheap.
And it is not the iterator protocol, which the spread result already suggested and a string control confirms:
expression instructions for (const v of [1,2,3]) s+=v— array25,628,360 for (const c of "abc") s+=c.length— string773,863 for (let i=0;i<a.length;i++) s+=a[i]769,138 [...[1,2,3]]758,464 for...ofover a string is cheap; over an array it is 33× more. So the string iterator is on a specialised path and the array iterator is not.What the trigger set now looks like
Expensive (~22–26M):
for...ofover an array ·Array.fromwith a mapper ·Array.fromon an array-like ·Array.prototype.slice.callon an array-like ·JSON.stringifyon an object · async functionsCheap (~0.75–0.84M):
[...arr]·Array.from(arr)·.map/.sort/.forEachwith callbacks ·for...ofover a string · indexedforloop ·JSON.stringifyon a primitive ·Promise/.then/Object.keys/Symbol.iterator/ descriptorsThe consistent reading is a generic indexed-element read path — the one the array iterator and the array-like protocol both go through — as against the dense-array fast path that
map,forEach, spread and indexed loops use. It is ~22M on first use and then ~9,400 instructions per element.That is a hypothesis about the shape, not a located mechanism. The bisect pair to start from is
[...[1,2,3]](758k) againstfor (const v of [1,2,3])(25.6M) — identical input, identical observable, 33× apart, both one line.The cost is flat in N at small N (
{length:1}22.2M,{length:3}22.2M) and linear above it, which is what "one-time init plus a slow per-element path" predicts.Mechanism located — and three corrections to what I wrote above
The ~25M is
populate_global_this_builtins: lazy construction of the entire globalThis builtin surface. My "generic indexed-element read path" hypothesis was wrong for the constant. It is right for the per-element cost, which is a separate defect.Callgrind inclusive tree for
for (const v of [1,2,3]), 27.5M Ir total:js_get_iterator → alloc_iterator_backing → attach_iterator_prototype → build_iterator_prototypes → set_bound_native_closure_name → set_builtin_property_attrs → note_descriptor_target_keyed → bootstrap_prototype_addr → js_get_global_this_builtin_value → js_get_global_this → populate_global_this_builtins 24,473,238 (88.9%)The forcing line is
descriptor_state.rs:617,if object_prototype_addr_matches(obj)— a bookkeeping flag. It wants one address, obtains it by looking upObjectby name on globalThis, and that builds the entire realm.JSON.stringify({a:1})reaches the identical terminal viacompute_object_proto_tojson_state(stringify_tojson_probe.rs:165→prototype_chain.rs:570). The actual work ofbuild_iterator_prototypesis ~120k; the other 99.5% is the realm.Cleanest single demonstration:
typeof globalThis= 749,436;typeof globalThis.Array= 25,168,698.It is one-time, proven rather than inferred: 1×
for...of= 25,588,441; 2× = 25,605,954; 4× = 25,638,236;for...of+JSON.stringify= 25,653,721.Why the cheap paths escape: they never ask an intrinsic-identity question.
[...[1,2,3]]'s entire 962k profile isld.sorelocation and mimalloc init, with no perry runtime above the noise floor. So the real split is not iteration, array-likeness, or user-JS callbacks — it is "does this operation ask about an intrinsic prototype's identity or contents".Corrections to the issue body
- "~220× hello world" was a bad denominator.
console.log("hello world")compiles to a 14,472-byte binary with the runtime dead-stripped — it is not a representative perry program. Real programs start around 750k. The honest figure is a ~24.9M constant on top of a ~750k floor. - Async is not a trigger.
async f(){ return 1 }plus.thencosts 858,587. The async rung in the ladder was expensive because its body containsArray.from({length:50}, …).Promise.all(array)is a trigger, because it iterates. - The trigger set is wider than listed. Plain dynamic property writes —
o["p" + i] = i— also force it, viafield_set_by_name/fast_paths.rs:71.
Per-element costs are a separate defect
Neither
bootstrap_prototype_addrnor the globalThis lookup appears in the N-delta:for...of: 2,754 instructions/element (js_for_of_next2,434, of which 665 is re-asking every step whether the user overrodeit.next)Array.fromon an array-like: 8,842/element — index → fresh key string → own-lookup miss → then a second full by-name walk ofObject.prototypefor a numeric key. This one is the generic path.
Proposed fix
Make the intrinsic-identity probes non-forcing.
Object.prototypeis created by population, so before population completes no object can be it, and the answer isfalsefrom one TLS load. This is not a heuristic: call counts show the probe resolves zero times out of 1,602 during population (resolve_prototype_addrchecks the memo first, so calls equal misses), and the single outer call is asking about the array-iterator prototype. The gate returns today's answers without paying for them.Predicted: triggers fall 25.6M → ~0.9M, and population itself falls 24.47M → ~15.7M, because 8.79M (36%) of it is those 1,602 re-derivations.
GLOBAL_THIS_READY(object/mod.rs:444) is already exactly this flag — and it is dead: stored twice, andgrep "GLOBAL_THIS_READY.load"returns nothing. Do not reuse it as-is: it is a process-globalAtomicBoolagainst a per-thread realm (#7988).Warning for whoever takes this
The same lazy population produces a correctness bug, filed as #10689: reading an inherited
Object.prototypemember as a value returnsundefineduntil the realm is populated, while calls work. That issue needs a read to start forcing; this one needs a probe to stop forcing. They pull in opposite directions on one mechanism, and a fix to either should state which reads force and which do not, with both cases as tests.- "~220× hello world" was a bad denominator.
- added 2 commits that reference this issue
on Sep 19, 2026 - added a commit that references this issue
on Sep 20, 2026
Summary
A ~25 million instruction one-time cost is triggered by a small set of ordinary operations —
for...of,JSON.stringifyon an object, and array-like indexed access — and not by a set of near-neighbours that look like they should be more expensive. Perry's whole bare startup is 114k instructions, so this is ~220× the entire cost ofconsole.log("hello world"), and roughly a quarter of node's complete startup, paid by any program that touches one of these.It is not linkage. The cheap and expensive programs below produce binaries of the same size (~8.2 MB).
The discriminator
Each program is
console.log("h", <expr>), compiled withperry compile, measured withperf stat -e instructions:u -r 3on perrymaster. Baselineconsole.log("hello world")= 113,987.typeof Symbol.iteratortypeof Promise[...[1,2,3]].length— spreadJSON.stringify(1).length— primitivetypeof Promise.resolve(1)Object.keys({a:1}).lengthtypeof Promise.resolve(1).then(x => x)Object.getOwnPropertyDescriptor({a:1},"a")Array.prototype.slice.call({length:2,0:"a",1:"b"})JSON.stringify({a:1}).length— objectfor (const v of [1,2,3]) s+=v[...[1,2,3]]costs 758k andfor (const v of [1,2,3])costs 25.6M — 34× more, over the same literal array. Both go through the iterator protocol, so the iterator protocol itself is not the trigger. LikewiseJSON.stringify(1)is cheap andJSON.stringify({a:1})is not;Symbol.iterator,Promise,.then,Object.keysandgetOwnPropertyDescriptorare all cheap.Array.from({length: 1}, fn)— a single element — costs 22,228,643, whileArray.from([7,8,9])costs 757,803. So it is the array-like path, notArray.from.I have localised the trigger set, not the mechanism. The tight clustering (22.2M–26.6M across unrelated triggers) suggests one lazily-initialised subsystem rather than several independent costs, but I have not confirmed that.
Why it matters: it is a constant, and it dominates real programs
A ladder of small programs, each doing a fixed small amount of work (instructions, perry vs node v26.8.1 vs bun 1.3.14):
console.log("hello world")new×200JSONround-trip ×100Promise.allEverything perry does well sits in the 0.8M–2.1M band. The three outliers are 20–40× that, and all three are explained by the constant above rather than by the work they do.
And it inverts at scale
Same array program, varying N:
Fitting the two intervals gives ~22M fixed + ~9,400 instructions per element for
Array.from({length:N}, fn), consistently across both. For comparison, the manual equivalent —for (let i=0;i<2000;i++) a.push(i)— costs 890,687 in total, about 390 instructions per element including all startup. So the array-like path is ~24× worse per element and carries the constant..mapitself is fine: adding it to the push loop costs 1,236,393 against 1,103,759 for a hand-written loop.Suggested starting points
for...of,JSON.stringifyon objects, array-like indexed access,Array.fromon array-likes, async) versus the cheap set (spread,Promise,.then,Object.keys,Symbol.iterator, descriptors) should identify one shared lazily-initialised path.[...arr]vsfor...of arris the cleanest pair to bisect — same input, same protocol, 34× apart.Measured on
origin/maincontent (a tree at main68a545439+ #10611, whose onlygc/delta versus current main is #10611 itself). All programs verified to produce output identical to node before any number was taken.