Skip to content

perf(codegen): take the inline bump allocator at new sites inside loops (#7469) - #7566

Merged
proggeramlug merged 4 commits into
mainfrom
perf/7469-inline-new-in-loops
Aug 7, 2026
Merged

perf(codegen): take the inline bump allocator at new sites inside loops (#7469)#7566
proggeramlug merged 4 commits into
mainfrom
perf/7469-inline-new-in-loops

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

#7469 workstream A. Takes the inline bump allocator at new sites inside loops and leaves everything else outlined.

Why the default was worth revisiting

The outlined allocator has been the default since the [#bloat] work, on this measurement: "win-win vs inline: −45 IR lines/site AND ~17% faster on an 8M-allocation loop (the inline bump bloated the hot loop, hurting icache/regalloc more than the saved call)."

The size half still holds. I re-measured it: ~268 bytes of machine code per site (+0 / +49,536 / +214,656 bytes across 10 / 200 / 800-site programs).

The speed half has inverted. Outlined is now 1.81× slower on churn_alloc and 1.78× on push_cls. Nothing about the inline bump changed — everything around the allocation got cheaper (#7474, #7486, #7487, #7501, #7525, #7532, #7535, #7536, #7552), so the surviving FFI call and the thread-local resolutions it performs now dominate what its bloat costs.

Why not just make TLS cheaper

Recorded so it is not attempted again: Mach-O has no local-exec TLS model. I built the entire runtime with -Ztls-model=local-exec; the blr through the TLV descriptor in layout_forget_object is byte-identical and churn_alloc moved 1.02×.

Per-call price is already at the plain-global floor:

access ns/call
plain global 1.24
Rust const-init thread_local! 1.29
C __thread 1.31
pthread_getspecific 2.11

What is expensive is the count — ~14 resolutions per allocation, because each FFI helper resolves independently and LLVM cannot CSE across the FFI boundary. Inlining removes the allocator's outright.

The gate

Per site rather than global. Loop membership is the cheapest sound proxy for "runs many times" and bounds the size cost to loop bodies. It reuses the existing loop_targets stack rather than adding a counter: switch frames push an empty continue label while every loop pushes a real one — the same discriminator Stmt::Continue's scan-outward-past-switch-frames logic already uses — so a new in a bare switch is correctly not-in-a-loop.

bench outlined gate ceiling (all-inline)
churn_alloc 1.32 s 0.73 s (1.81×) 1.81×
push_cls 1.30 s 0.72 s (1.81×) 1.78×
churn 1.62 s 1.04 s (1.56×) 1.56×
deeplist 1.66 s 1.61 s (1.03×) 1.03×
retain 3.15 s 3.06 s (1.03×) 1.03×
cycles 0.90 s 0.90 s (1.00×) 1.35×
tree 8.97 s 9.10 s (0.986×) 1.05×

It reaches the full unconditional-inline ceiling on the allocation-heavy shapes.

tree costs 1.4%, reproduced across two best-of-5 runs. It allocates in loops so it inlines, but its time is dominated by copying and promotion rather than the allocation call, so it pays the bloat without the win. That is the honest cost of a static proxy; a profile-guided or size-aware refinement could exclude it, and I have not attempted one. cycles gives up 0.35× against the ceiling because some of its sites sit outside loops.

Size, measured from both sides

program outlined gate all-inline
800 sites, none in a loop 13,150,096 13,150,096 (+0) +214,656
200 sites, none in a loop 12,454,160 12,454,160 (+0) +49,536
800 sites, all in loops 14,190,480 +214,656 +214,656

Zero growth for non-loop sites; never worse than unconditional inlining in the worst case.

Two earlier versions of that worst-case program measured +0 for every arm and would have been reported as a success: the first let the object be scalar-replaced (it never escaped), the second used a constant trip count that got unrolled away. Both are noted in the changelog so the next person building a size probe checks the subject is live.

Testing

  • 43 test-files/ programs across both arms: identical output, and identical under PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 — the arm that would catch a mis-written object header, since the inline bump writes the header and zero-fills slots in generated code rather than in the runtime. The one apparent diff was a console.time duration, which varies run-to-run on a single binary.
  • perry-codegen failure set unchanged from origin/main.
  • cargo fmt --all -- --check and scripts/check_file_size.sh clean.

PERRY_INLINE_NEW=1 still forces the inline form everywhere for A/B work. Its test is is_none(), so PERRY_INLINE_NEW="" enables inlining — an empty string is Some(""). That cost me one bogus A/B table and is now called out at the site.

Summary by CodeRabbit

  • Performance

    • Improved memory allocation for objects created inside loops, helping reduce allocation overhead and potentially improve runtime performance.
    • Retained the existing allocation behavior for objects created outside loops.
    • Added an option to enable inline allocation globally through the PERRY_INLINE_NEW environment variable.
  • Documentation

    • Documented the allocation behavior, configuration option, benchmarks, and related performance considerations.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d7cba42a-4ae1-4d3f-96e5-f4ea08dd2477

📥 Commits

Reviewing files that changed from the base of the PR and between 2b54d58 and 404985b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7566-inline-new-in-loops.md
  • crates/perry-codegen/src/lower_call/new.rs

📝 Walkthrough

Walkthrough

The code generator now uses inline allocation for new sites inside loops and outlined allocation elsewhere. PERRY_INLINE_NEW forces inline allocation globally. The release version and changelog were updated.

Changes

Loop-sensitive inline allocation

Layer / File(s) Summary
Detect loop allocation sites
crates/perry-codegen/src/lower_call/new.rs
The new helper detects loop frames through non-empty continue labels and excludes switch-only frames.
Select and document the allocator
crates/perry-codegen/src/lower_call/new.rs, changelog.d/7566-inline-new-in-loops.md, Cargo.toml, CLAUDE.md
Allocation selection now uses loop context and PERRY_INLINE_NEW. The changelog records the policy and validation results. Version metadata changed to 0.5.1324.

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

Sequence Diagram(s)

sequenceDiagram
  participant NewLowering
  participant LoopTargets
  participant Environment
  participant Allocator
  NewLowering->>LoopTargets: Check whether the new site is inside a loop
  NewLowering->>Environment: Read PERRY_INLINE_NEW
  Environment-->>NewLowering: Return override state
  NewLowering->>Allocator: Select inline allocation for loops or forced mode
  NewLowering->>Allocator: Select outlined allocation otherwise
Loading

Possibly related PRs

  • PerryTS/perry#7291: Changes a different new handling path for runtime-dispatched construction instances under moving GC.

Suggested reviewers: thehypnoo, jdalton, andrewtdiz

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/7469-inline-new-in-loops

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.

Ralph Küpper added 4 commits August 7, 2026 06:56
…oops

The outlined per-`new`-site allocator has been the default since [#bloat]:
it collapses ~145 lines of per-class-constant IR per site into one
js_object_alloc_class_inline_keys call. The size half of that decision still
holds — measured ~268 bytes of machine code per site, +214,656 over an
800-site program.

The SPEED half has inverted. The comment reads '~17% faster on an 8M-allocation
loop'; today the outlined form is 1.81x SLOWER on churn_alloc and 1.78x on
push_cls. Nothing about the inline bump changed — everything around the
allocation got cheaper (#7474 #7486 #7487 #7501 #7525 #7532 #7535 #7536 #7552),
so the surviving FFI call and the thread-local resolutions it performs now
dominate what its code bloat costs. Those resolutions cannot be made cheaper on
Darwin: Mach-O has no local-exec TLS model, and building the runtime with
-Ztls-model=local-exec leaves the blr through the TLV descriptor byte-identical
(measured 1.02x). Only their count can be reduced.

So the choice becomes per site rather than global. A `new` inside a loop takes
the inline bump; everything else keeps the outlined call and adds nothing to
binary size. Loop membership reuses the existing loop_targets stack — switch
frames push an empty continue label, every loop pushes a real one, the same
discriminator Stmt::Continue already relies on.

Measured: churn_alloc 1.81x, push_cls 1.81x, churn 1.56x — the full
unconditional-inline ceiling. Size +0 bytes for 800 sites none of which are in
loops; equal to all-inline when every site is. tree is -1.4%.
@proggeramlug
proggeramlug force-pushed the perf/7469-inline-new-in-loops branch from f411373 to 404985b Compare August 7, 2026 04:57
@proggeramlug
proggeramlug merged commit b4694a5 into main Aug 7, 2026
0 of 12 checks passed
@proggeramlug
proggeramlug deleted the perf/7469-inline-new-in-loops branch August 7, 2026 04:57
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Post-merge audit — verified independently before merging

Merged as v0.5.1324. Recording what I checked, because the PR's own validation
used the wrong instrument for one of its risks.

The validation gap. The body validates with PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1. Those are runtime probes, and CLAUDE.md is
explicit that the root-dominance class is invisible to every runtime GC probe
— "at the moment of the collection there is nothing for the collector to find".
This PR newly enables allocation IR specifically inside loop bodies, which
is exactly where PERRY_GC_MOVING_LOOP_POLLS=1 puts safepoints, so the static
checker was the instrument that mattered. It was not run.

Ran it. Green, with the subject proven live:

=== checked 2412 functions / 147 modules (147 .ll files, 9251 root stores)
=== violations: 0   (moving-minor reachable: 0)
=== seeded violations: 40 planted, 40 caught, 0 MISSED

--unrooted-allocas --moving-only (the CI form) also reads 0.

The hazard I went looking for, and why it is not live. The inline path loads
keys_ptr before the fast/slow branch and stores it into the object header
after the merge — so it is held in a register across js_inline_arena_slow_alloc,
which reaches arena_cell_alloc and "may push a new block + run GC". The
outlined path passes the same pointer as a call argument and never holds it
across a collection point, so this is a real differential introduced here.

It is nonetheless safe: js_build_class_keys_array allocates via
js_array_alloc_with_length_longlived (object/alloc.rs:337), and the longlived
arena is never swept, never reset, and distinct from both the nursery (copying
minor) and old-gen (defrag). The checker reaches the same conclusion
independently — it suppresses 339 class-keys allocas under an
IMMOVABLE_SOURCES exemption. Issue #179 put them there deliberately.

Worth stating plainly since it is load-bearing and unobvious: if the class
keys array ever stops being longlived-allocated, this becomes a live
use-after-free.

The gate does what it says — verified by per-function IR differential rather
than by trusting the description:

function main this PR
outOfLoop (site not in a loop) outlined=1 inline=0 outlined=1 inline=0
inLoop (site in a loop) outlined=1 inline=0 outlined=0 inline=1

Non-loop sites are byte-for-byte unchanged, which is the claim the "+0 bytes"
size rows rest on. Both arms and node agree on output.

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