Repository navigation
Description
Summary
After aed21292 (static trip-count for-loop unroll + smart channel-SIMD gate), image_convolution median is 377 ms vs the v0.5.81 baseline of 268 ms — +40.5 % over baseline, 5%-target is ≤281 ms. Perry beats Rust on this hardware (377 vs 414 ms) and is faster than every other interpreter by 3–4×; the only language ahead is Zig at 247 ms. Both Zig and perry hit the same LLVM backend; the gap comes from LLVM's auto-vectorizer firing on Zig's IR shape and not firing on perry's.
Why LLVM auto-vec fires for Zig but not perry
Inspecting otool -tv on the Zig binary shows the kernel uses dup.4s, mul.4s, umull2.2d, add.4s, tbl.16b — outer-loop vectorization across 4 adjacent output pixels with NEON 128-bit vectors. Perry's optimized IR has only scalar mul i32 / add i32 chains.
Three structural differences in the IR shape that block LLVM's vectorizer on perry's output:
-
clampIdxlowers as adowhile/breakchain with a 3-way phi. Zig'sinline fn clamp_idxflattens toselect(v<lo, lo, select(v>hi, hi, v)). Perry already has the right path inlower_expr_as_i32(crates/perry-codegen/src/expr.rs:10089— emits@llvm.smin.i32/smax.i32) but it only fires when an i32 result is required by the surrounding context. The HIR inliner incrates/perry-hir/src/lower.rsalways emits the dowhile pattern. -
Double mirror stores on integer-stable mutable locals. After the recent forward-closure pass + i32-init path landed, most integer locals have BOTH an i32 slot AND a double slot, both updated on every write. The double mirror is opaque memory traffic that LLVM treats as preventing vectorization. Many integer locals never have a double reader — those mirror stores are dead but kept for safety.
-
Buffer-read alias metadata is per-load, not per-loop.
Uint8ArrayGet's GEP+@llvm.assume+load i8(expr.rs:5947+) emits per-access!alias.scope/!noaliasviabuffer_alias_metadata_suffix. Inside a hot loop reading the source buffer and writing the destination, the source-read scope and dest-write scope are disjoint sets — but LLVM's vectorizer struggles to prove this when the per-load metadata is independently scoped.
Proposed plan (Option A: codegen IR-shape only, no intrinsics)
Three focused changes:
-
Lower clamp-pattern functions directly to
select(smin/smax)at every call site, not just int-required contexts. Either teachlower_callto recognize calls intoclamp3_functions/clamp_u8_functionsand emit the chain inline, or add a HIR pass that rewrites the dowhile-from-clamp pattern back to aConditionalternary. -
Add a
collect_double_readers(stmts) -> HashSet<u32>analysis that reports which integer-stable locals genuinely need their double slot maintained. Locals not in that set can drop the mirror atStmt::Let:527,LocalSet:857(in expr.rs), andUpdate. Needs fixed-point iteration (transitive double-only chains). -
Pair source/dest buffers in the same
noaliaslist when they're used in the same loop body —buffer_alias_baseandbuffer_data_slotsshould allocate scope ids in disjoint sets, not independent scopes.
Acceptance criteria
image_convolution5-run median ≤ 281 ms (preferably ≤ 268 ms to clear σ).- Output checksum
2ba2e053(matches Bun byte-for-byte). - ECS workloads still pass (query-perf 500, sync-hotpath 4000, perf-comprehensive 10k+migration).
- All suite benchmarks unchanged.
json_pipeline_fullstays at -27 % vs baseline (no regression from the IR-shape changes).- 15-run honest_bench (
HONEST_BENCH_WARMUP=5 HONEST_BENCH_MEASURED=15) stable σ.
Per-language ranking on M1 Max (5-run median)
- image_conv: zig 247, perry 377, rust 414, bun 949, node 1288
Related
- Earlier session work on this gap: commits
cf540b92,f02541a4,33d8dc41,3f5c69a5,4f895dd8,817c4b56,aed21292. - The verify-output discipline from #PERRY-LESSONS: every iteration must diff against Bun.
- Issue perry-codegen: integer_locals analysis silently truncates Add/Sub/Mul accumulators that exceed i32 range #435 (integer_locals overflow) overlaps this slightly but is a correctness issue, not a perf one. Don't merge them.
Estimated 3–5 days of focused codegen work.
Activity
perf(codegen): #436 close image_convolution gap — strict-i32-bounded …
proggeramlug commentedon May 4, 2026
Closed by 30cc953.
Outcome: image_convolution recovered from a v0.5.496 regression (~440 ms) back to v0.5.495 baseline (~310-320 ms median, 15-run measurement, σ stable). Output checksum 2ba2e053 matches Bun byte-for-byte. json_pipeline_full hash b7e8a588 unchanged. test_issue_435_int32_truncation regression test still matches Node byte-for-byte across all 9 bug shapes. Workspace unit tests clean.
What landed:
is_clamp3/is_clamp_u8short-circuitis_inlinableinperry-transform/inline.rs; codegenlower_call.rsgets an f64-context arm emitting@llvm.smin.i32/@llvm.smax.i32directly. Cleaner IR (66 inline calls replace dowhile blocks), but no measurable perf impact for image_convolution specifically — LLVM collapses both forms to identicalcselbranchless asm.perry-codegen/collectors.rs. Newis_strictly_i32_bounded_exprrejects the dangerousAdd | Sub | Mul-of-int-stable arm (the perry-codegen: integer_locals analysis silently truncates Add/Sub/Mul accumulators that exceed i32 range #435 overflow shape) but accepts pure bitwise /expr | 0/expr >>> 0/ Buffer-byte loads /MathImul/Update/ calls to clamp / returns_integer functions.collect_strictly_i32_bounded_localswalks all writes (Stmt::Let init, LocalSet, Update) and returns the set where every write passes the strict check. Threaded throughFnCtxat all 6 codegen entry points;stmt.rs::needs_i32_slotextended toindex_used_locals.contains(id) || strictly_i32_bounded_locals.contains(id). Image_conv's FNV-1ahaccumulator (writes(h ^ dst[i]) | 0andimul32(h, K)) qualifies via the strict path; perry-codegen: integer_locals analysis silently truncates Add/Sub/Mul accumulators that exceed i32 range #435'ssum,prod, etc. all fail (their writes are bareAdd | Sub | Mul).What was investigated and shown not applicable:
str dinstructions in the hot blur loop.!alias.scope !201for src reads,!202for dst writes, with cross-listed!noalias !301/!302).Remaining ~30-40 ms gap to the 281 ms target is from LLVM's auto-vectorizer not firing on the outer y/x loops — verified empirically (literal H/W vs
const H = 2160give identical timing, so const-prop is not the bottleneck) and via-Rpass-missed=loop-vectorizeremarks ("could not determine number of loop iterations" + "value used outside the loop"). Closing this remaining gap is a structurally larger LLVM-level optimization beyond #436's stated plans #1/#2/#3 and best tracked as a separate issue if pursued.fix(codegen): x++/x-- disqualifies the i32 fast path (accumulator ove…