Skip to content

repsel: array-callback parameters carry no element-shape fact — 10.1% vs the equivalent for-loop (#7151 item 2) #8103

Closed

Description

@proggeramlug

Successor to #7151 item 2, split out with a measurement so it is one live
quantified item instead of one of five unranked ones. #7151 is being closed;
this is the only one of its five forms with a material number.

The gap

A.map(cb) / A.forEach(cb) / A.reduce(cb, …) callback parameters carry
no element-shape fact, from either mechanism:

So every r.field in a callback body re-enters the guard diamond that both
mechanisms delete for the equivalent for loop.

Measured on main @ 514ff7c9a

macOS arm64, --release compiler and --release
libperry_{runtime,stdlib}.a, PERRY_RUNTIME_DIR pinned, /usr/bin/time -l
instructions retired, medians of 3 interleaved reps. Both programs build the
same 20,000-element C[], read two fields off every element 200 times (4M
element reads), and print the identical t: 80000000000.

// baseline — the covered form
for (let i = 0; i < arr.length; i++) { const r = arr[i]; s = s + r.a + r.b; }

// probe — the same work through a callback
arr.forEach((r: C) => { s = s + r.a + r.b; });
Ptr<Shape> consumed instructions retired vs baseline
for + const r = A[i] 2 15,800,121,583 —
A.forEach(cb) 0 17,391,614,644 +10.1%

--opt-report on the forEach probe: Ptr<Shape> 0 selected / 1 denied,
*** Ptr<Shape> promoted 0 of 1 candidates in this build. ***

For scale, the other four forms in #7151 measure −0.3% (direct A[i].field,
already served by the versioned clone), +1.5% (let r = A[i]), ≲1% (sort
then read), and ~0 for the element-rule half of non-empty literals — full table
and method in #7151 (comment).

Why it is worth its own issue

#7170's profile of benchmarks/app-patterns/kernels/batch.ts put the reducer's
acc[idx] field traffic (js_object_set_field_by_name 3.57% +
js_put_value_set_dyn_ic_miss 3.14% inclusive) in exactly this position, and
#7151 item 2 already named it: "it is where batch.ts's hot field traffic
actually lives"
.

Two shapes, and they are not the same problem

  1. forEach/map element parameter — one array, one class, the parameter
    is the element. This is the narrow case and the 10.1% above.
  2. reduce accumulator parameter — acc[idx] inside the reducer, i.e. a
    second array reached through a parameter. Needs the array-level fact to
    cross the call boundary, not just the element fact.

(1) alone would be worth scheduling if it can be done as clone-and-route on the
callback with an all-call-sites-agree obligation, which is #6925's precedent.

Not measured

x86-64; --profile dist; map/reduce separately from forEach; any real
dependency-JS population (#7152 measured the outbound call argument escape at
5 of 746 there, which is a different direction from this inbound one); and I did
not attempt an implementation, so there is no cost estimate for the
clone-and-route side.

Activity

  1. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    Verdict: still real, but the published **10.1% is wrong by more than an

    order of magnitude**, and the prize is a minority of the gap. Keeping open,
    re-scoped. I did not fix it.

    Re-measured at fb87d8923, macOS arm64, --profile perry-dev compiler and
    libperry_{runtime,stdlib}.a, PERRY_RUNTIME_DIR pinned,
    PERRY_NO_AUTO_OPTIMIZE=1, /usr/bin/time -l, medians of 3 interleaved.

    The programs from this issue, reproduced exactly

    Module-level, 20,000 elements, 200 reps, both printing the issue's own
    t: 80000000000:

    Ptr<Shape> instructions peak RSS vs baseline
    for + const r = A[i] 2 selected 92,113,198 4,587,928 —
    A.forEach(cb) 0 / 1 denied 1,320,317,570 4,587,928 +1333%

    The Ptr<Shape> verdicts match this issue exactly (2 vs 0/1 denied), and the
    printed checksum is byte-identical to the one quoted here, so it is the same
    workload. But the absolute numbers are not: this issue reports
    15,800,121,583 for the for baseline; current main runs the same
    program in 92,113,198 — 171x less. Perry finishes it in 10 ms wall; Node
    26.5.1 takes 140 ms.

    The physics still agree: the increment attributable to forEach was
    17,391,614,644 - 15,800,121,583 = 1.59 G there, and it is 1.23 G here.
    So both measurements found roughly the same absolute cost; the "+10.1%" is
    that cost divided by a 15.7 G common term that current main does not
    reproduce and that I could not reconstruct. Whatever it was, it diluted the
    ratio ~170x. The percentage in the title should not be used to schedule
    this.

    Function-local formulation (same class, same reads, arr built and read
    inside one function), which is where the versioned element-shape clone
    actually fires:

    instructions peak RSS
    for + const x = arr[i] 91,147,942 4,637,080
    arr.forEach(cb) 1,907,932,078 4,899,224

    21x, ~14 instructions per element read vs ~469. Linear in reps
    (100/200/400 checked), so nothing is being optimised away.

    Sizing the actual prize — the part that matters

    The element-shape fact only addresses the field reads. Isolate them by
    replacing the callback body with s = s + 1 (same 4M invocations, no field
    traffic):

    instructions
    forEach, s = s + x.a + x.b 1,907,932,078
    forEach, s = s + 1 1,472,051,254
    difference (8M guarded field reads) 435,880,824

    So ~54 instructions per guarded field read, and ~23% of the forEach
    program's total cost.
    The other 77% is per-callback machinery that an
    element-shape fact does not touch — ~368 instructions per invocation.

    The callback body IR says the same thing:

    2 js_typed_feedback_class_field_get_guard    <- what #8103 would delete
    2 js_object_get_field_by_name_f64            <- what #8103 would delete
    2 js_dynamic_string_or_number_add            <- capture-box accumulator
    2 js_closure_get_capture_bits                <- capture-box accumulator
    1 js_box_get_bits / 1 js_box_set_bits / 3 write barriers
    

    Re-scope

    1. The item is real. The fact is genuinely absent (Ptr<Shape> 0 selected / 1 denied) and it costs ~54 instructions per field read.
    2. Its ceiling is ~21% of the forEach form, not 10.1% and not 21x.
      Landing clone-and-route on the callback parameter would take the probe from
      1.91 G to roughly 1.51 G — still 16x the equivalent for loop.
    3. The dominant term is a different issue that does not exist yet: array
      higher-order callback invocation costs ~368 instructions per element
      (closure dispatch through js_array_forEach + capture-box traffic for any
      accumulator the callback closes over). That is where the 21x lives, and it
      subsumes both shapes (1) and (2) named in this issue, including the
      reduce accumulator case that motivated the batch.ts reference.

    Concretely: I would file the invocation-cost issue first, measure a
    forEach/map/reduce inlining or a raw-f64 capture-box tier against it, and
    only then decide whether the remaining ~23% is worth the clone-and-route
    obligation. On the campaign's own record — repsel's realised effect on root
    count was ~4% with one arm negative — a 23%-ceiling item behind a 77% one is
    not the next thing to build.

    Not measured / caveats

    • --profile dist and --release runtime. The 23/77 split is measured with an
      opt-level-1 runtime; both sides of that split are runtime calls
      (js_object_get_field_by_name_f64 and js_typed_feedback_class_field_get_guard
      on one side, js_array_forEach and js_closure_get_capture_bits on the
      other), so the ratio is less biased than a generated-vs-runtime comparison
      would be — but it is not zero-biased, and I did not bound it.
    • map / reduce measured separately from forEach.
    • x86-64.
    • No implementation attempted, so there is still no cost estimate for the
      clone-and-route side.
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