Skip to content

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

@proggeramlug

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]].lengthspread 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}).lengthobject 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

  1. 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.
  2. 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

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