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]].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

  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

  1. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    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 mapper 757,552
    Array.from([1,2,3], x => x) — real array, with mapper 25,383,740
    Array.from({length:3,0:1,1:2,2:3}) — array-like, no mapper 22,209,941
    Array.from({length:3,…}, x => x) — array-like, with mapper 22,242,339

    Correction 1 — it is not "the array-like path". Array.from on 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 — array 25,628,360
    for (const c of "abc") s+=c.length — string 773,863
    for (let i=0;i<a.length;i++) s+=a[i] 769,138
    [...[1,2,3]] 758,464

    for...of over 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...of over an array · Array.from with a mapper · Array.from on an array-like · Array.prototype.slice.call on an array-like · JSON.stringify on an object · async functions

    Cheap (~0.75–0.84M): [...arr] · Array.from(arr) · .map / .sort / .forEach with callbacks · for...of over a string · indexed for loop · JSON.stringify on a primitive · Promise / .then / Object.keys / Symbol.iterator / descriptors

    The 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) against for (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.

  2. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    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 up Object by name on globalThis, and that builds the entire realm. JSON.stringify({a:1}) reaches the identical terminal via compute_object_proto_tojson_state (stringify_tojson_probe.rs:165 → prototype_chain.rs:570). The actual work of build_iterator_prototypes is ~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 is ld.so relocation 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

    1. "~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.
    2. Async is not a trigger. async f(){ return 1 } plus .then costs 858,587. The async rung in the ladder was expensive because its body contains Array.from({length:50}, …). Promise.all(array) is a trigger, because it iterates.
    3. The trigger set is wider than listed. Plain dynamic property writes — o["p" + i] = i — also force it, via field_set_by_name/fast_paths.rs:71.

    Per-element costs are a separate defect

    Neither bootstrap_prototype_addr nor the globalThis lookup appears in the N-delta:

    • for...of: 2,754 instructions/element (js_for_of_next 2,434, of which 665 is re-asking every step whether the user overrode it.next)
    • Array.from on an array-like: 8,842/element — index → fresh key string → own-lookup miss → then a second full by-name walk of Object.prototype for a numeric key. This one is the generic path.

    Proposed fix

    Make the intrinsic-identity probes non-forcing. Object.prototype is created by population, so before population completes no object can be it, and the answer is false from one TLS load. This is not a heuristic: call counts show the probe resolves zero times out of 1,602 during population (resolve_prototype_addr checks 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, and grep "GLOBAL_THIS_READY.load" returns nothing. Do not reuse it as-is: it is a process-global AtomicBool against 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.prototype member as a value returns undefined until 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.

  3. added 2 commits that reference this issue on Sep 19, 2026
    6034f6a
    2ecd513
  4. added a commit that references this issue on Sep 20, 2026
    aba0fcd
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