Skip to content

Replace local mutable blocks in one pass over the function - #9258

Draft
cknitt wants to merge 2 commits into
masterfrom
refs-in-loop-perf
Draft

cknitt wants to merge 2 commits into
masterfrom
refs-in-loop-perf

Conversation

@cknitt

@cknitt cknitt commented Nov 7, 2026

Copy link
Copy Markdown
Member

Part of #9256 (many refs updated in one loop).

The cause was scalar replacement (Lam_pass_sroa): it analyzed and rewrote a block's whole scope once per block, so n refs used in one function cost O(n²). Now one traversal decides every block and one rewrite replaces them all.

The old order replaced blocks from the innermost out, so when an inner block had a never-read field, the pure values written to or initializing that field were already gone when the blocks around it were analyzed. To give the same result, an occurrence inside such a value is held in a pending slot until the inner block is decided, and dropped if the field turns out never to be read. The rewrite then drops exactly those values without rewriting them.

Measured with bsc on linux-arm64, allocated words and wall time, n refs each updated once in one for loop:

Refs Before After
250 12.9M, 48 ms 4.6M, 34 ms
500 42.2M, 130 ms 8.8M, 64 ms
1000 151M, 417 ms 17.4M, 121 ms

The generated JavaScript is unchanged for the test suites and about 1,600 generated functions with refs and mutable records stored in, read from and written to each other. A first version without the pending slots differed on 3 of them.

New tests in sroa_test.res: a ref stored only in a write-only ref, a ref read only by a write-only ref (both still become scalars), and several refs updated in one loop.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kbf3gK1dcjbe97yY1wRMgs

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Changelog

💅 Polish

  • Compile functions with many local refs or mutable records in linear time.

Semantic Diff

2 commits · Full page

Replace local mutable blocks in one pass over the function

52c1aa6 · cknitt + Claude · Nov 07, 2026, 10:04 UTC

Why

The pass analyzed and rewrote a block's whole scope once per block, so n refs used in one function cost O(n²): 1,000 refs updated in one loop allocated 151M words. One traversal now decides every block and one rewrite replaces them, so the same input allocates 17M.

A value written to, or initializing, a never-read field is held back until its block is decided, as the old innermost-first order dropped such values before analyzing the blocks around them. The generated JavaScript is the same for the test suites and 1,600 generated functions.

✨ Analyze blocks without allocating a closure per node

ba4c395 · cknitt + Claude · Nov 07, 2026, 19:02 UTC · new

The benchmarks showed the single pass allocating up to 0.9% more for functions without local blocks: each node passed a new closure to the traversal. The innermost slot is now a reference, so the traversal reuses one closure, and other inputs allocate as much as before.

@codecov

codecov Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.47312% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.74%. Comparing base (bf35764) to head (ba4c395).

Files with missing lines Patch % Lines
compiler/core/lam_pass_sroa.ml 92.47% 7 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##           master    #9258   +/-   ##
=======================================
  Coverage   81.74%   81.74%           
=======================================
  Files         457      457           
  Lines       65713    65751   +38     
=======================================
+ Hits        53715    53748   +33     
- Misses      11998    12003    +5     
Files with missing lines Coverage Δ
compiler/core/lam_pass_sroa.ml 86.08% <92.47%> (+0.37%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pkg-pr-new

pkg-pr-new Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@9258

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@9258

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@9258

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@9258

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@9258

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@9258

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@9258

commit: ba4c395

@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown

Why:
    The pass analyzed and rewrote a block's whole scope once per block, so
    n refs used in one function cost O(n²): 1,000 refs updated in one loop
    allocated 151M words. One traversal now decides every block and one
    rewrite replaces them, so the same input allocates 17M.

A value written to, or initializing, a never-read field is held back until
its block is decided, as the old innermost-first order dropped such values
before analyzing the blocks around them. The generated JavaScript is the
same for the test suites and 1,600 generated functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kbf3gK1dcjbe97yY1wRMgs
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt
cknitt force-pushed the refs-in-loop-perf branch from 9862905 to 52c1aa6 Compare November 7, 2026 18:06
@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

🎉 Compiler benchmarks

Allocated words

In total: 456,989,425 → 444,961,995 (-2.63%).

🎉 Improvement: 1 benchmark allocates more than 1% less.

Benchmark Base Head Change
stress/RefsInLoop.res 17,385,182 5,364,070 -69.15%
packages/@rescript/runtime/Primitive_hash.res 1,501,651 1,497,275 -0.29%
packages/@rescript/belt/src/Belt_Array.res 2,842,879 2,839,869 -0.11%

Instructions

In total: 10,226,961,997 → 10,032,277,336 (-1.90%).

🎉 Improvement: 1 benchmark executes more than 1% fewer instructions.

Benchmark Base Head Change
stress/RefsInLoop.res 411,396,517 214,259,235 -47.92%
packages/@rescript/belt/src/Belt_internalAVLtree.res 166,149,467 166,562,149 +0.25%
packages/@rescript/belt/src/Belt_List.res 145,581,009 145,924,298 +0.24%

Pull request ba4c395 compared with base bf35764 in this run.

The benchmarks showed the single pass allocating up to 0.9% more for
functions without local blocks: each node passed a new closure to the
traversal. The innermost slot is now a reference, so the traversal reuses
one closure, and other inputs allocate as much as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kbf3gK1dcjbe97yY1wRMgs
Signed-off-by: Christoph Knittel <ck@cca.io>

This branch has not been deployed

No deployments
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