A number[] gets the specialized numeric-array index path when it arrives as a parameter, but falls all the way back to the fully generic js_dyn_index_get when it is reached through a closure capture (module-scope const read inside a function). Same declared type, same loop body, 27x difference.
This matters a lot for bundled code: a bundle is overwhelmingly module-scope consts captured by closures, so in practice almost nothing gets the fast path.
Repro
Parameter (fast):
function hot(a: number[]): number {
let s = 0;
for (let r = 0; r < 2000; r++) for (let i = 0; i < 1000; i++) s += a[i];
return s;
}
const arr: number[] = [];
for (let i = 0; i < 1000; i++) arr.push(i);
// ... time hot(arr)
Captured (slow) — the only change is that arr is read from the enclosing scope instead of being passed in:
const arr: number[] = [];
for (let i = 0; i < 1000; i++) arr.push(i);
const hot = (): number => {
let s = 0;
for (let r = 0; r < 2000; r++) for (let i = 0; i < 1000; i++) s += arr[i];
return s;
};
// ... time hot()
Numbers (best of 5, 2M iterations)
| shape |
time |
| node |
1.1 – 1.3 ms |
perry, number[] as a parameter |
2.0 ms |
perry, number[] captured |
53.6 ms |
perry, untyped array captured (const arr = []) |
53.6 ms |
Note the last row: the captured typed array is no faster than an untyped one — the annotation buys nothing once the value is captured.
Cause (from the emitted IR)
PERRY_LLVM_KEEP_IR=1, grepping the array helpers in each:
parameter version:
2 @js_typed_feedback_numeric_array_index_get_guard <-- specialized path
captured version:
2 @js_dyn_index_get <-- fully generic path
2 @js_array_length
So the specialization decision is made from the local type of the accessed base, and a capture/upvalue read is typed as unknown. The declared element type is available at the declaration site; it just isn't propagated to the capture.
Suggested direction
Propagate the declared/inferred type of a captured binding to its read sites (upvalue reads and module-scope reads), so js_typed_feedback_numeric_array_index_get_guard is selected there too. A guarded fast path is already the mechanism used for parameters — this is about making the guard reachable, not about proving anything new.
Same question likely applies to captured object bindings (class-field get/set guards) — worth checking whether those degrade identically.
A
number[]gets the specialized numeric-array index path when it arrives as a parameter, but falls all the way back to the fully genericjs_dyn_index_getwhen it is reached through a closure capture (module-scopeconstread inside a function). Same declared type, same loop body, 27x difference.This matters a lot for bundled code: a bundle is overwhelmingly module-scope
consts captured by closures, so in practice almost nothing gets the fast path.Repro
Parameter (fast):
Captured (slow) — the only change is that
arris read from the enclosing scope instead of being passed in:Numbers (best of 5, 2M iterations)
number[]as a parameternumber[]capturedconst arr = [])Note the last row: the captured typed array is no faster than an untyped one — the annotation buys nothing once the value is captured.
Cause (from the emitted IR)
PERRY_LLVM_KEEP_IR=1, grepping the array helpers in each:So the specialization decision is made from the local type of the accessed base, and a capture/upvalue read is typed as
unknown. The declared element type is available at the declaration site; it just isn't propagated to the capture.Suggested direction
Propagate the declared/inferred type of a captured binding to its read sites (upvalue reads and module-scope reads), so
js_typed_feedback_numeric_array_index_get_guardis selected there too. A guarded fast path is already the mechanism used for parameters — this is about making the guard reachable, not about proving anything new.Same question likely applies to captured object bindings (class-field get/set guards) — worth checking whether those degrade identically.