You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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
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.
Replace the in-body barrier with a post-loop black-box on the accumulator — call 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).
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
# Runforiin 1 2 3 4 5;do /tmp/loop_v0522 | grep loop_;done# ~12-20msforiin 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.
Summary
Three accumulator-loop benchmarks regressed 2-4x between v0.5.22 and v0.5.162:
loop_overheadmath_intensiveaccumulatePerry still beats Rust on
loop_overhead(~3x), butmath_intensiveandaccumulatewent 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 ate1cbd37(v0.5.22 baseline) and comparing LLVM IR.Cause 1 —
asm sideeffectbarrier from #74's fix (v0.5.91)Commit
df9cc32("fix: insert asm sideeffect barrier in pure loop bodies") emitscall 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 foldingfor (let i=0; i<N; i++) sum+=1to closed-formsum=Nand making the wrappingDate.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
Updatepatterns. For a pure accumulator likesum = sum + 1, this materializes as:Compare v0.5.22 — the pre-opt body was two
fadd reassoc contracts and nothing else: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. Theasm sideeffectbarrier then prevents mem2reg/DCE from cleaning it up.Why it looks different across build paths
.opipeline (what benchmarks actually run): loop survives, doesn't vectorize → 32 msPERRY_LLVM_BITCODE_LINK=1whole-program pipeline: same v0.5.162 source, loop gets eliminated entirely → 0 ms reported between the twoDate.now()calls<2 x double>parallel-accumulator reduction → 12 msv0.5.22 post-opt IR for reference (LLVM IR shown as v0.5.22 emitted it after opt -O3):
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 sideeffectbarrier 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
call void asm sideeffect \"\", \"r\"(double %final_sum)()afterfor.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).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
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.