Repository navigation
Description
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:
collectors/ptr_shape_elements.rs(repsel row 1: scoping Ptr<Shape> beyond locals — measured, promotion is 0 on the motivating workload; build --opt-report (#6952) first #7034 §3 / perf(repsel): open the array-element escape forPtr<Shape>(#7034 §3) #7149) licenses only
const r = A[i]inside the region that ownsA— a callback parameter is the
call argumentposition, which needs clone-and-route (repsel row 1: scoping Ptr<Shape> beyond locals — measured, promotion is 0 on the motivating workload; build --opt-report (#6952) first #7034 §1), not a
containment relaxation.stmt/element_shape_loop.rs(repsel: element-shape proofs through arrays — measured 6.2× vs node; route = invariant bit → versioned-loop consumer → element Ptr<Shape> #7480/perf(repsel): per-array homogeneous element-shape invariant (#7480) #7496 runtime invariant + versioned loop
clone) matches aforloop overA, and a callback body is not one.
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
forEach/mapelement parameter — one array, one class, the parameter
is the element. This is the narrow case and the 10.1% above.reduceaccumulator 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
proggeramlug commentedon Aug 15, 2026
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-devcompiler andlibperry_{runtime,stdlib}.a,PERRY_RUNTIME_DIRpinned,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>for+const r = A[i]A.forEach(cb)The
Ptr<Shape>verdicts match this issue exactly (2 vs 0/1 denied), and theprinted 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
forbaseline; currentmainruns the sameprogram 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
forEachwas17,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
maindoes notreproduce 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,
arrbuilt and readinside one function), which is where the versioned element-shape clone
actually fires:
for+const x = arr[i]arr.forEach(cb)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 fieldtraffic):
forEach,s = s + x.a + x.bforEach,s = s + 1So ~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:
Re-scope
Ptr<Shape> 0 selected / 1 denied) and it costs ~54 instructions per field read.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
forloop.higher-order callback invocation costs ~368 instructions per element
(closure dispatch through
js_array_forEach+ capture-box traffic for anyaccumulator the callback closes over). That is where the 21x lives, and it
subsumes both shapes (1) and (2) named in this issue, including the
reduceaccumulator case that motivated thebatch.tsreference.Concretely: I would file the invocation-cost issue first, measure a
forEach/map/reduceinlining or a raw-f64 capture-box tier against it, andonly 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 distand--releaseruntime. The 23/77 split is measured with anopt-level-1 runtime; both sides of that split are runtime calls
(
js_object_get_field_by_name_f64andjs_typed_feedback_class_field_get_guardon one side,
js_array_forEachandjs_closure_get_capture_bitson theother), 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/reducemeasured separately fromforEach.clone-and-route side.