Skip to content

perf(codegen): version counted loops once at entry — no per-iteration flag or f64 counter shadow - #11053

Closed
proggeramlug wants to merge 2 commits into
mainfrom
perf-loop-i32-versioning
Closed

proggeramlug wants to merge 2 commits into
mainfrom
perf-loop-i32-versioning

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Part of the property-read parity campaign. A disassembly of function run(n,O){let h=0;for(let k=0;k<n;k++)h+=O.a+O.b;return h;} (53 instructions/iteration vs node's ~12, all inline) showed 5 of the 53 spent on the loop counter, not on the reads. Every iteration ran a cmpb $0,-0x44(%rbp) representation-flag test, and every backedge advanced a double copy of the i32 counter (vmovapd load, vaddsd 1.0, store) that the body never reads.

Change: the representation is decided once, before the loop. When the entry guard proves the start and bound are integral i32 values, the loop keeps only the i32 counter and materializes a double where k is read and on exit. With a strict < and a sole ++, the counter cannot pass n, so it cannot overflow. Larger, fractional or non-number bounds run in doubles from entry. These cases keep today's lowering: captures, try regions, labels, counter mutation, and bodies that are not straight-line. The canonical-i32 kill switch disables this too.

# before, backedge                      # after, backedge
vmovapd -0x70(%rbp),%xmm0               dec %r12d
inc     %r13d                           jne body
vaddsd  <1.0>(%rip),%xmm0,%xmm0
vmovapd %xmm0,-0x70(%rbp)
jmp     head   (+ cmpb flag test at head)

instructions:u per iteration, taken as the slope between 1M and 5M iterations (min of 3 at each count). Output is byte-identical to node 26 on all 32 runs. A = main f5cfbff (perry sha256 1f1ef6c5fee7…), B = this branch (bb654007a784…):

fixture A B
q_par (parameter receiver) 53 47
q_loc (local receiver) 36 27
p1 55 47
p2 55 47
p3 115 106
p4 432 411
p2m 51 46
p4m 429 411

Validation

  • codegen suite --test-threads=1: 1,672 passed, 0 failed, 1 ignored
  • new executable + IR test crates/perry/tests/loop_i32_versioning.rs (both canonical-i32 modes), covering:
    • a bounded crossing above INT32_MAX (starts at 2147483645, n = 2**31+5)
    • escaping per-iteration closures
    • post-loop reads
    • arguments
    • catch observation
  • GC gates: shadow and native lowering corpus, 177/177 sources, 0 hazards, 40/40 planted violations caught
  • all eight merge-train gate scripts rc=0
  • sabotage: removing the fractional admission changes the witness 12 -> 9; removing the range admission changes the boundary checksum 28 -> 0

Found while testing, and not caused by this change: a function-scoped var counter captured by a closure hangs on main (#11052). This change does not admit that case.

https://claude.ai/code/session_01EQdCw7BN4AAnn2hAbNXg33

Summary by CodeRabbit

  • New Features
    • Eligible counted loops now use a faster integer-counter path when their bounds and structure allow it. Loops that may involve fractional values, non-numbers, overflow, or other incompatible behavior retain their existing semantics.
  • Tests
    • Added coverage for integer, fractional, negative, and boundary counts, as well as counters affected by closures, exceptions, and changes inside the loop.
  • Documentation
    • Documented the loop optimization and the conditions under which it applies.

@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 73ca3d2b-b72f-46fa-823c-faa95c993088

📥 Commits

Reviewing files that changed from the base of the PR and between ff81b22 and 6630c2f.

📒 Files selected for processing (1)
  • changelog.d/11053-loop-i32-versioning.md
💤 Files with no reviewable changes (1)
  • changelog.d/11053-loop-i32-versioning.md

Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The loop lowerer adds a guarded i32-counter specialization for eligible dynamic-bound for loops. The fast path uses an i32 counter; the slow path retains the original storage. New tests check output semantics and emitted LLVM IR with canonical i32 locals enabled and disabled.

Changes

Dynamic-Bound Loop Counter Versioning

Layer / File(s) Summary
i32 counter specialization and dispatch
crates/perry-codegen/src/stmt/loops.rs, crates/perry-codegen/src/stmt/loops/i32_counter.rs
lower_for tries the new i32-counter lowering before the generic fallback. The specialization emits guarded fast and slow paths. Shared CFG lowering can skip a second dynamic-bound attempt.
Runtime and IR regression coverage
test-files/test_gap_loop_i32_versioning.ts, crates/perry/tests/loop_i32_versioning.rs, changelog.d/11053-loop-i32-versioning.md
The fixture covers numeric bounds, counter observations, captures, exceptions, and mutation. The integration test checks program output and LLVM IR with canonical i32 locals enabled and disabled. The changelog describes the versioning rules and coverage.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 6630c

No merge-blocking issue is established; the change is ready for normal checks.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 4 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main codegen change: entry-time counted-loop versioning that removes per-iteration representation checks and the f64 counter shadow.
Description check ✅ Passed The description provides a detailed summary, concrete implementation changes, validation results, performance measurements, and scope limitations. It does not use every template heading, and it omits …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 4 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Status from the merge-train side: this is one gate away from being landable, but I have held it because the decision about whether it is worth landing looks unresolved.

Only failing step on the current head:

lint :: Require a changelog.d/ fragment for crates/ changes

Everything else is green. That is a one-file fix — changelog.d/11053-loop-counter-versioning.md, format in changelog.d/README.md.

What I do not want to do is land it on my own judgement, because the admission numbers recorded during triage were narrow: 5 of 392 counted loops admitted in the tsc workload, 0 of 9 in Zod. The measured per-iteration win is real and the asm diff is convincing, but a tier that admits ~1% of real loops buys little while adding a lowering path that has to stay correct forever — and this codebase has a standing rule against a fast path that is rarely hit alongside a slow path that always is.

So, two questions for you as the author:

  1. Do those admission numbers still hold, or have the sibling PRs in the property-read campaign widened what the entry guard accepts?
  2. If they hold — land it anyway as groundwork for perf: the loop-hoisting tiers only admit single-statement, call-free bodies — a 6x store-path win produces 0.000% on real programs #10741 (which is specifically about widening loop-hoisting admission), or close it and fold the entry-guard idea into that work?

Tell me which and I will either add the fragment and put it in the next train, or close it with your reasoning recorded. I will not sit on it either way.

…representation flag or f64 counter shadow

A dynamic-bound `for (let k = 0; k < n; k++)` loop tested a representation
flag on every iteration and advanced a double copy of the i32 counter on
every backedge, although the body never read it. The representation is now
decided once before the loop: when the entry guard proves the bound and
start are integral i32 values, the loop keeps only the i32 counter and
materializes a double where `k` is read and on exit. Strict `<` with a
sole `++` means the counter never passes `n`, so it cannot overflow;
larger, fractional or non-number bounds run in doubles from entry.
Captures, try regions, labels, counter mutation and non-straight-line
bodies keep the existing lowering, and the canonical-i32 kill switch also
disables this.

instructions:u per iteration (1M vs 5M slope, output identical to node):
q_par 53 -> 47, q_loc 36 -> 27, p1 55 -> 47, p2 55 -> 47, p3 115 -> 106,
p4 432 -> 411, p2m 51 -> 46, p4m 429 -> 411.

Covered by an executable + IR test (both canonical-i32 modes), a bounded
crossing above INT32_MAX, escaping closures, post-loop reads and
`arguments`; sabotaging the fractional or range admission turns the
witness red. A function-scoped `var` counter captured by a closure hangs
on main before this change and is not admitted here: #11052.

Claude-Session: https://claude.ai/code/session_01EQdCw7BN4AAnn2hAbNXg33
@proggeramlug
proggeramlug force-pushed the perf-loop-i32-versioning branch from e8c3b89 to ff81b22 Compare September 23, 2026 14:22
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landing this — the owner's call, as groundwork for #10741 rather than on its own merits.

What was actually blocking it: the changeset gate, not the change. The fragment existed as changelog.d/astra-ivshadow-loop-counter.md, but the gate requires a PR-keyed filename, changelog.d/<PR>-<slug>.md — that naming is what keeps in-flight PRs from colliding on the same fragment. Renamed to changelog.d/11053-loop-i32-versioning.md, rebased onto current main, and pushed to this PR's own branch (head ff81b22704, asserted against headRefOid).

I also recorded the admission scope in the fragment, because it is the thing most likely to be misread later:

the entry guard admits 5 of 392 counted loops in the tsc workload and 0 of 9 in Zod. It is landed as groundwork for #10741 … not because a ~1% admission rate pays for itself on its own. Anyone measuring a workload and finding no change should check the admission count before suspecting the lowering.

That matters because the asm diff is genuinely convincing — 5 of 53 instructions per iteration — and someone benchmarking a real program will see nothing move and reasonably conclude the lowering is broken. It isn't; it just almost never fires yet. #10741 is the work that changes that.

Nothing else is red: cargo fmt --all -- --check and check_file_size.sh are clean on the rebased head, and the only other CI failure was the public-baseline step, which is a known red on main under an owner decision (#10704/#11062/#11063 landed without regenerating the artifact).

It goes into the next merge train.

…n scope

The changeset gate requires a PR-keyed filename, changelog.d/<PR>-<slug>.md
-- it matches ^changelog\.d/[0-9]+-[^/]+\.md$ over the files the PR ADDS, so
astra-ivshadow-loop-counter.md was invisible to it. PR-keying is what stops
in-flight PRs colliding on one fragment.

Also records the measured admission scope, because it is the thing most
likely to be misread: 5 of 392 counted loops in tsc, 0 of 9 in Zod. The
per-iteration asm win is real (5 of 53 instructions), so someone
benchmarking a real program will see nothing move and reasonably conclude
the lowering is broken. It is not; it almost never fires yet. #10741 is
the work that widens admission, and this is its entry-guard machinery.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main in merge train 270 (#11132, v0.5.1653). The train rebase gives commits new SHAs, so GitHub cannot close this automatically.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant