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.stringify on objects, array-like indexed access, Array.from on array-likes, async) versus the cheap set (spread, Promise, .then, Object.keys, Symbol.iterator, descriptors) should identify one shared lazily-initialised path. [...arr] vs for...of arr is 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.
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.