镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

perry-codegen: close image_convolution 5%-target gap (377 ms → ≤281 ms) via IR-shape work for LLVM auto-vec #436

Closed

Description

@proggeramlug

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:

  1. clampIdx lowers as a dowhile/break chain with a 3-way phi. Zig's inline fn clamp_idx flattens to select(v<lo, lo, select(v>hi, hi, v)). Perry already has the right path in lower_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 in crates/perry-hir/src/lower.rs always emits the dowhile pattern.

  2. 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.

  3. 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/!noalias via buffer_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:

  1. Lower clamp-pattern functions directly to select(smin/smax) at every call site, not just int-required contexts. Either teach lower_call to recognize calls into clamp3_functions / clamp_u8_functions and emit the chain inline, or add a HIR pass that rewrites the dowhile-from-clamp pattern back to a Conditional ternary.

  2. 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 at Stmt::Let:527, LocalSet:857 (in expr.rs), and Update. Needs fixed-point iteration (transitive double-only chains).

  3. Pair source/dest buffers in the same noalias list when they're used in the same loop body — buffer_alias_base and buffer_data_slots should allocate scope ids in disjoint sets, not independent scopes.

Acceptance criteria

  • image_convolution 5-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_full stays 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

Estimated 3–5 days of focused codegen work.

Activity

  1. added
    enhancementNew capability or improvement
    performanceRuntime, compile-time, build-size, or memory performance
    on May 4, 2026
  2. added a commit that references this issue on May 4, 2026
  3. proggeramlug commented on May 4, 2026

    @proggeramlug
    ContributorAuthor

    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:

    • Plan Support custom menu bar items #1 (clamp inlining): is_clamp3 / is_clamp_u8 short-circuit is_inlinable in perry-transform/inline.rs; codegen lower_call.rs gets an f64-context arm emitting @llvm.smin.i32 / @llvm.smax.i32 directly. Cleaner IR (66 inline calls replace dowhile blocks), but no measurable perf impact for image_convolution specifically — LLVM collapses both forms to identical csel branchless asm.
    • Bonus: strictly-i32-bounded-locals extension in perry-codegen/collectors.rs. New is_strictly_i32_bounded_expr rejects the dangerous Add | 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_locals walks all writes (Stmt::Let init, LocalSet, Update) and returns the set where every write passes the strict check. Threaded through FnCtx at all 6 codegen entry points; stmt.rs::needs_i32_slot extended to index_used_locals.contains(id) || strictly_i32_bounded_locals.contains(id). Image_conv's FNV-1a h accumulator (writes (h ^ dst[i]) | 0 and imul32(h, K)) qualifies via the strict path; perry-codegen: integer_locals analysis silently truncates Add/Sub/Mul accumulators that exceed i32 range #435's sum, prod, etc. all fail (their writes are bare Add | Sub | Mul).

    What was investigated and shown not applicable:

    • Plan linux compilation and README #2 (drop dead double mirrors): DSE already eliminates them — verified 0 str d instructions in the hot blur loop.
    • Plan Using the fetch API #3 (paired noalias scopes): already correctly per-buffer-paired (!alias.scope !201 for src reads, !202 for 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 = 2160 give identical timing, so const-prop is not the bottleneck) and via -Rpass-missed=loop-vectorize remarks ("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.

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

    enhancementNew capability or improvementperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions