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
Summary
A ~25 million instruction one-time cost is triggered by a small set of ordinary operations — for...of, JSON.stringify on 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 of console.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 with perry compile, measured with perf stat -e instructions:u -r 3 on perrymaster. Baseline console.log("hello world") = 113,987.
| expression | instructions |
|---|---|
typeof Symbol.iterator |
749,616 |
typeof Promise |
750,124 |
[...[1,2,3]].length — spread |
758,464 |
JSON.stringify(1).length — primitive |
757,911 |
typeof Promise.resolve(1) |
778,416 |
Object.keys({a:1}).length |
820,960 |
typeof Promise.resolve(1).then(x => x) |
827,157 |
Object.getOwnPropertyDescriptor({a:1},"a") |
834,979 |
| — | — |
Array.prototype.slice.call({length:2,0:"a",1:"b"}) |
25,220,706 |
JSON.stringify({a:1}).length — object |
25,177,651 |
for (const v of [1,2,3]) s+=v |
25,626,927 |
[...[1,2,3]] costs 758k and for (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. Likewise JSON.stringify(1) is cheap and JSON.stringify({a:1}) is not; Symbol.iterator, Promise, .then, Object.keys and getOwnPropertyDescriptor are all cheap.
Array.from({length: 1}, fn) — a single element — costs 22,228,643, while Array.from([7,8,9]) costs 757,803. So it is the array-like path, not Array.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):
| rung | perry | node | bun | node/perry |
|---|---|---|---|---|
console.log("hello world") |
113,987 | 94,868,916 | 47,469,296 | 832× |
| string building | 859,558 | 100,388,665 | 47,736,722 | 117× |
classes + new ×200 |
1,201,465 | 103,617,514 | 48,899,378 | 86× |
| 3-module ESM graph | 861,994 | 108,191,235 | 48,094,708 | 126× |
| closures ×500 | 1,067,680 | 106,379,529 | 50,421,679 | 100× |
| regex ×100 | 2,115,993 | 104,886,122 | 47,974,854 | 50× |
| Map/Set ×500 | 1,882,749 | 109,334,200 | 50,867,713 | 58× |
JSON round-trip ×100 |
27,529,671 | 108,996,836 | 48,369,059 | 3.9× |
async + Promise.all |
23,278,364 | 103,913,660 | 49,080,346 | 4.4× |
| array builtins | 40,850,299 | 111,063,580 | 57,733,839 | 2.7× |
Everything 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:
| N | perry | node | node/perry |
|---|---|---|---|
| 200 | 24,144,240 | 107,976,259 | 4.47× |
| 2,000 | 40,853,521 | 109,793,417 | 2.68× |
| 20,000 | 210,693,156 | 129,719,815 | 0.61× — perry loses |
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.
.map itself is fine: adding it to the push loop costs 1,236,393 against 1,103,759 for a hand-written loop.
Suggested starting points
- The trigger set (
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. - Separately, the ~9,400 instructions per element on the array-like path is its own defect and would remain after the constant is fixed.
Measured on origin/main content (a tree at main 68a545439 + #10611, whose only gc/ delta versus current main is #10611 itself). All programs verified to produce output identical to node before any number was taken.
Activity
proggeramlug commentedon Sep 19, 2026
Narrowed further — and two framings in the issue above are wrong
A 2×2 plus three controls. Baseline 113,987.
Array.from([1,2,3])— real array, no mapperArray.from([1,2,3], x => x)— real array, with mapperArray.from({length:3,0:1,1:2,2:3})— array-like, no mapperArray.from({length:3,…}, x => x)— array-like, with mapperCorrection 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:
[1,2,3].map(x => x)[3,1,2].sort((a,b) => a-b)[1,2,3].forEach(x => x)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:
for (const v of [1,2,3]) s+=v— arrayfor (const c of "abc") s+=c.length— stringfor (let i=0;i<a.length;i++) s+=a[i][...[1,2,3]]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.proggeramlug commentedon Sep 19, 2026
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: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
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 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.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.refactor(stdlib): remove commander native binding
refactor(stdlib): remove commander native binding
refactor(stdlib): remove commander native binding