Skip to content

Perf regression: fadd-chain vectorization lost on pure-accumulator loops since v0.5.22 #140

Description

@proggeramlug

Summary

Three accumulator-loop benchmarks regressed 2-4x between v0.5.22 and v0.5.162:

Benchmark v0.5.22 (RESULTS.md) v0.5.162 (fresh run) Change
loop_overhead 12 ms 32 ms 2.7x slower
math_intensive 14 ms 48 ms 3.4x slower
accumulate 24 ms 97 ms 4.0x slower

Perry still beats Rust on loop_overhead (~3x), but math_intensive and accumulate went from 3-4x Perry wins to essentially ties. Workloads are unchanged (checksums match between the two versions and between Perry and Rust output).

Root cause

Two compounding changes between v0.5.22 and v0.5.162. Minimal reproducer uses benchmarks/suite/02_loop_overhead.ts. I bisected by rebuilding Perry at e1cbd37 (v0.5.22 baseline) and comparing LLVM IR.

Cause 1 — asm sideeffect barrier from #74's fix (v0.5.91)

Commit df9cc32 ("fix: insert asm sideeffect barrier in pure loop bodies") emits call void asm sideeffect \"\", \"\"() at the end of any loop body whose statements are LLVM-pure (no calls, heap mutation, throws). Intended to prevent IndVarSimplify + loop-deletion from folding for (let i=0; i<N; i++) sum+=1 to closed-form sum=N and making the wrapping Date.now() calls report 0 ms.

The barrier does its intended job. It also blocks LLVM's loop vectorizer, which was turning the v0.5.22 fadd chain into a SIMD-ified parallel-accumulator reduction.

Cause 2 — i32 shadow counter for accumulator locals

Between v0.5.22 and v0.5.162 the codegen started emitting a parallel i32 "shadow" alongside the double alloca for integer-valued locals that participate in Update patterns. For a pure accumulator like sum = sum + 1, this materializes as:

for.body.2:
  %r20 = load i32, ptr %r3, align 4
  %r21 = add i32 %r20, 1
  store i32 %r21, ptr %r3, align 4
  %r22 = sitofp i32 %r21 to double
  store double %r22, ptr %r2, align 8   ; dead store — %r2 never read
  call void asm sideeffect \"\", \"\"()
for.update.3:
  %r23 = load double, ptr %r8, align 8
  %r24 = fadd reassoc contract double %r23, 1.000000e+00
  store double %r24, ptr %r8, align 8

Compare v0.5.22 — the pre-opt body was two fadd reassoc contracts and nothing else:

for.body.2:   %r14 = fadd reassoc contract double %r13, 1.0
for.update.3: %r16 = fadd reassoc contract double %r15, 1.0

The i32-shadow emission is useful when the counter is indexed into an array (it saves a sitofp per lookup), but for a pure-accumulator sum, the shadow + sitofp + dead store is net-negative. The asm sideeffect barrier then prevents mem2reg/DCE from cleaning it up.

Why it looks different across build paths

  • Default per-module-.o pipeline (what benchmarks actually run): loop survives, doesn't vectorize → 32 ms
  • PERRY_LLVM_BITCODE_LINK=1 whole-program pipeline: same v0.5.162 source, loop gets eliminated entirely → 0 ms reported between the two Date.now() calls
  • v0.5.22 default pipeline: clean body, LLVM vectorizes to <2 x double> parallel-accumulator reduction → 12 ms

v0.5.22 post-opt IR for reference (LLVM IR shown as v0.5.22 emitted it after opt -O3):

vector.body:
  %vec.phi   = phi <2 x double> [ <0.0, -0.0>, %entry ], [ %2, %vector.body ]
  %vec.phi14 = phi <2 x double> [ splat (-0.0), %entry ], [ %3, %vector.body ]
  %2 = fadd reassoc contract <2 x double> %vec.phi,   splat (1.0)
  %3 = fadd reassoc contract <2 x double> %vec.phi14, splat (1.0)
  %index.next = add nuw i32 %index, 4
  %4 = icmp eq i32 %index.next, 100000000
  br i1 %4, label %for.exit.4, label %vector.body

Four parallel f64 accumulators across two SIMD lanes each, 25M loop iterations, autovectorizer win. That's the shape we need back.

Confirmation: barrier alone is not the cause

I tested removing the in-body asm sideeffect barrier on v0.5.162 (leaving the rest of the codegen intact):

So the barrier is load-bearing for #74 and isn't the only contributor to the regression; the i32-shadow cruft is the other half.

Fix options

  1. Tighten the i32-shadow trigger — only emit the parallel i32 for loop counters that get used as array indices (the original use case), not for integer-valued accumulator locals in general. Needs a pass that distinguishes "counter-used-as-index" from "counter-used-as-accumulator" at HIR lowering time.
  2. Replace the in-body barrier with a post-loop black-box on the accumulatorcall void asm sideeffect \"\", \"r\"(double %final_sum)() after for.exit. Tells LLVM the sum is observable without poisoning the body. Needs care so LLVM doesn't just compute the closed-form and pass that into the asm (which defeats Date.now() does not advance during tight CPU-bound loops #74 again).
  3. Reorder the default LLVM pipeline to run mem2reg/DCE before the vectorizer so the shadow gets cleaned up. Harder — might shift other benchmarks and won't help if Date.now() does not advance during tight CPU-bound loops #74's barrier is still poisoning vectorizer heuristics.

Option (1) looks like the cleanest — the shadow counter exists for a reason (array indexing in nested-array benchmarks) but emitting it unconditionally was always going to regress accumulator patterns.

Reproduction

# Build both versions
git checkout e1cbd37   # v0.5.22 baseline
cargo build --release -p perry -p perry-runtime -p perry-stdlib
mv target/release/perry /tmp/perry-v0522
git checkout main      # v0.5.162
cargo build --release -p perry -p perry-runtime -p perry-stdlib

# Compile the same TS source with each
/tmp/perry-v0522/perry benchmarks/suite/02_loop_overhead.ts -o /tmp/loop_v0522
target/release/perry compile benchmarks/suite/02_loop_overhead.ts -o /tmp/loop_v0162

# Run
for i in 1 2 3 4 5; do /tmp/loop_v0522 | grep loop_; done  # ~12-20ms
for i in 1 2 3 4 5; do /tmp/loop_v0162 | grep loop_; done  # ~30-35ms

# Inspect IR (brew install llvm needed)
PERRY_LLVM_BITCODE_LINK=1 PERRY_LLVM_KEEP_IR=1 /tmp/perry-v0522/perry ... # prints bitcode paths
# llvm-dis the pre-opt + opt .bc files and diff for.body / for.update

Context

Found while investigating #139 (which was asking a different question — turned out to be unrelated, but the fresh rerun we did for that reply surfaced this regression). Scrutiny appreciated.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions