Repository navigation
Description
Context
Companion to #47. honest_bench image_conv profile of Perry v0.5.30 shows two back-to-back bl calls per byte read in the inner blur loop: one for the buffer read (#47), one for a NaN-box wrapper. This issue is the second call.
At 0x100c in the profiled binary:
scvtf d0, w0 ; i32 result from buffer_get → f64
bl 0x10001edac ; NaN-box wrapper — full frame, tag-table setup, check, returnThe wrapper function starts:
sub sp, sp, #0x80
stp x26, x25, [sp, #0x30]
... ; 5× stp register spills
mov x20, #0x1; movk x20, #0x7ffc, lsl #48 ; TAG_UNDEFINED
mov x21, #0x2; movk x21, #0x7ffc, lsl #48 ; TAG_NULL
mov x22, #-0x3; movk x22, #0x8003, lsl #48
mov x23, #0x7fff000000000000 ; STRING_TAG
mov w24, #0x7ffe0000 ; INT32_TAG
mov x25, #0x7ffd000000000000 ; POINTER_TAG
fmov x19, d0
cmp x19, x20 ; dispatch by tag
...— i.e. it's a generic "coerce any JSValue to double" that sets up the full tag table to handle every possible incoming tag. When called right after scvtf it's doing pointless work: the incoming value is known to be a freshly-converted double from a known-int32 source.
Proposal
Type inference already tracks stability for some locals. Extend it (or add a loop-local pass) to mark a number as int32-stable when:
- its initializer is an integer literal,
|0,>>> 0,idx * k,+/-/*of other int32-stable locals, or aBuffer[idx]read (after perf: inline Buffer/Uint8Array bracket-read when statically typed (9 instr + bl → 1 instr) #47), - all assignments in scope are also int32-stable, and
- no observable floating-point consumer (e.g. it never flows into
Math.sqrt,/,Math.log, etc.).
For such locals, the codegen emits i32 throughout its live range: loads/stores stay as ldr w/str w, arithmetic stays as mul w/add w, and NaN-boxing happens only at the escape boundary (store to heap, argument pass to a generic callee, print, etc.).
Expected impact
Stacks on top of #47. With #47 already in place, the blur loop would be:
ldrb w0, [x_src, w_idx, uxtw] ; from #47
mul w0, w0, w_k ; keep i32
add w_acc, w_acc, w0— instead of ldrb → scvtf → bl nan_box → fmadd. Removes the second bl entirely (the profile showed it taking more samples than the first one — ~30+ instrs vs js_buffer_get's 9).
On the image_conv workload with #47 already applied, projected additional ~2× speedup. Combined with #47's autovectorization unblock, lands Perry ahead of Rust on tight compute loops.
Scope
- Pixel-heavy code: image convolution, resize, colorspace conversion, compositing.
- Hash loops: FNV, MurmurHash, xxhash (the
honest_bench/…/perry/image_conv.tsuses a hand-rolledimul32that would benefit). - Byte parsers: base64, UTF-8 validators, varint.
Counter-shape to be careful of
Locals that look int32 but occasionally hold NaN or Infinity (e.g. const n = parseInt(userInput) where userInput might be "abc") must not be specialized. The analysis has to trace the flow and bail on any edge where a non-int32 can enter. The compile-time type (number in TS) is not enough; actual use has to be checked.
Activity
perf: accumulator-pattern int-arithmetic fast path (closes #49, v0.5.…
proggeramlug commentedon Apr 16, 2026
This was implemented in v0.5.39 (Int32-stable local specialization). The full pipeline is in place:
collect_integer_let_idsanalysis,is_int32_producing_expr/can_lower_expr_as_i32checks, andlower_expr_as_i32emission — locals that are provably int32-stable stay in i32 throughout their live range, with NaN-boxing only at escape boundaries. Later versions (v0.5.50–v0.5.56) extended this further withtoint32_fastfor known-finite bitwise operands, clamp-function inlining via smin/smax intrinsics, and i32-native bitwise ops.fix(codegen): prove the dynamic-bound loop counter stays in i32 (#6072)
fix(codegen): prove the dynamic-bound loop counter stays in i32 (#6072)…
fix(codegen): an integer literal past 2^31 must not seed the i32 fast…
fix(gc): the aarch64 prologue decoder was blind to two real shapes (#…
fix(gc): the aarch64 fast stack-map walker misread every SVE-shaped p…