Skip to content

perf(codegen): inline small hot (in-loop) functions via inlinehint#6855

Merged
proggeramlug merged 3 commits into
mainfrom
perf/inline-hot-small
Jul 26, 2026
Merged

perf(codegen): inline small hot (in-loop) functions via inlinehint#6855
proggeramlug merged 3 commits into
mainfrom
perf/inline-hot-small

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What

Inline small hot (in-loop) functions into their loops — the residual gap a
tight integer-math kernel has vs V8 after #6850 (native Math.imul +
typed-array-param reads) landed. V8 inlines a small hot function into its loop;
Perry left it out-of-line, so the call itself dominated the residual.

Perry already force-inlines functions <= 8 statements with alwaysinline
(unconditional). A NaN-boxed bit-mixer like mix is ~10 statements and costs
~800 in LLVM's inline model (GC shadow-frame calls + typed-array reads +
double↔i32 marshaling), well above -O3's base threshold, so it stayed a call.

Bias, don't force. This adds a distinct inlinehint path, gated so we
only pay code size where it buys speed:

  • small9..=SIZE_CAP statements (default cap 20);
  • hot — has ≥1 call site inside a loop (a whole-module HIR pre-pass —
    collectors/hot_callees.rs — collects such callee ids; a function only ever
    called from cold/straight-line code is never hinted);
  • few call sites — ≤ MAX_SITES total (default 4).

The linker raises LLVM's -inlinehint-threshold (default 850) so the hinted
kernels actually inline. Crucially this lifts the ceiling only for functions
Perry stamped inlinehint; every other function keeps the base -O3 threshold.

alwaysinline is kept for the genuinely tiny (<= 8). noinline cases
(try/setjmp/volatile) are respected via to_ir's has_try-first attribute
precedence.

Stacks on #6850 (merged to main).

Why the call-site cap (the anti-bloat backstop)

-inlinehint-threshold raises the inline ceiling at every call site of a
hinted callee (LLVM can't tell hot from cold per-site through a function
attribute). Without the cap, a small kernel that is also called from many cold
sites would be duplicated at all of them. Capping total call sites bounds the
duplication. A synthetic of 300 fns × 40 cold sites, hinted without the cap,
grew its optimized IR 5.7× (205K → 1.17M lines); with the cap those
broadly-called fns are excluded → 0%.

Structural proof (load-independent)

  • mix carries inlinehint in the emitted IR (flag off: no attribute).
  • After clang -O3, the caller loop no longer calls mix: 0 bl
    (BR26) relocations to _perry_fn_..._mix with the flag on, 2 with it off.

Binary-size (flag OFF vs ON, byte-precise __text section)

program hints Δ total Δ __text
5 real test-files 0 0.000% 0.000%
helper × 40 cold sites (>cap) 0 0.000% 0.000%
large program (>6MB IR → -Os) 0.000% 0.000%
25 hot kernels 25 +0.34% +0.57%
50 hot kernels 50 +0.79% +1.13%
100 hot kernels 100 +1.57% +2.25%
250 hot kernels (all-kernels synthetic) 250 +3.86% +5.72%

Real programs and broadly-called helpers see zero change. Only a synthetic
composed almost entirely of distinct hot kernels approaches/exceeds the ~2-3%
budget — a shape real code doesn't have — and even that stays bounded (the cap
turns the 5.7× uncapped blowup into ~4%).

Perf

mix 40M-call microbench, min-of-7, contended box (load ~27): OFF 1024 ms →
ON 904 ms
. (A separate run reproduced the prompt's exact OFF baseline of
1862 ms → 1406 ms ON.) Absolutes are load-inflated; the structural proof above
is the load-independent evidence.

Correctness

  • test-files/test_gap_inline_hot_small.ts (new): a small hot function whose
    loop-carried result feeds Int32Array writes — byte-identical to Node 26 with
    the flag ON, OFF, and under PERRY_GC_FORCE_EVACUATE=1.
  • ON-vs-OFF behavioral diff over all 408 existing test_gap_* programs: no
    output differences (inlining is semantics-preserving).
  • object_cache unit tests updated + green (the 4 new env vars are folded into
    the object cache key so a warm cache can't serve a mismatched .o).
  • Full release gap suite + cargo test --workspace delegated to CI (the box was
    too saturated to build --release locally).

Flags

PERRY_INLINE_HOT_SMALL (default on) gates the whole feature.
PERRY_INLINE_HOT_SMALL_CAP / _THRESHOLD / _MAX_SITES tune the size window,
hint threshold, and call-site cap. No version bump / no CHANGELOG.md (fragment
in changelog.d/).

https://claude.ai/code/session_01UGDwjukzhowLDJYsMPFwMv

Summary by CodeRabbit

  • Performance Improvements

    • Improved execution speed for small, frequently used functions called within loops by applying an LLVM inlining hint under a new “inline-hot-small” heuristic.
    • Updated O3 compilation to allow hinted inlining within controlled thresholds to limit code growth.
  • Reliability

    • Preserved existing non-inlining behavior for async/generator and explicit non-inlined cases.
    • Kept output consistency across flag settings and garbage-collection modes.
  • Testing / Configuration

    • Added a deterministic typed-array workload to validate inlining behavior.
    • Extended the object-cache key to account for the inline-hot-small environment settings.

After native Math.imul + typed-array-param reads (#6850), the only residual
gap of a tight integer-math kernel vs V8 is function-call overhead: V8 inlines
a small hot function into its loop; Perry left it out-of-line. A NaN-boxed
bit-mixer (`mix`, ~10 statements) costs ~800 in LLVM's inline model once GC
shadow-frame calls + typed-array reads + double<->i32 marshaling are counted —
well above the base -O3 threshold — so -O3 kept it as a call.

Bias, don't force. Perry keeps `alwaysinline` only for the genuinely tiny
(<= 8 statements). This adds a distinct `inlinehint` path for functions that
are:
  - small (9..=SIZE_CAP statements, default cap 20),
  - called from inside a loop (a whole-module HIR pre-pass collects callee ids
    with an in-loop call site — approximates "hot" for an AOT compiler), AND
  - called from few total sites (default <= 4).

The linker raises `-inlinehint-threshold` (default 850) so the hinted kernels
actually inline; this lifts LLVM's ceiling ONLY for functions Perry stamped
`inlinehint` — every other function keeps the base -O3 threshold, so cold code
is untouched.

Anti-bloat is the whole point, and it rests on two backstops:
  1. The loop gate: a function only ever called from cold/straight-line code is
     never hinted, so cold utility functions can't bloat the binary.
  2. The call-site cap: `-inlinehint-threshold` raises the ceiling at EVERY
     call site of a hinted callee (LLVM can't tell hot from cold per-site
     through a function attribute), so a hot kernel also called from many cold
     sites would be duplicated at all of them. Capping total call sites bounds
     the duplication.

Measured binary-size delta (flag OFF vs ON), byte-precise __text section:
  - real programs (5 test-files): 0 hints, 0.000% delta;
  - broadly-called helper (300 fns x 40 cold sites): excluded by cap, 0%;
  - large programs (>6MB IR compile at -Os): flag inert, 0%;
  - realistic hot-kernel density: +0.34% (25 kernels), +0.79% (50), +1.57%
    (100); an all-kernels synthetic (250) reaches +3.86% — bounded by the cap
    (without it the same program's optimized IR grew 5.7x).

`mix` 40M-call microbench (min-of-many, contended box load ~27): OFF 1024ms ->
ON 904ms. Structural proof (load-independent): the caller loop no longer emits
a `bl` to `mix` (0 vs 2 BR26 relocations), and `mix` carries `inlinehint`.

Gated behind PERRY_INLINE_HOT_SMALL (default on); PERRY_INLINE_HOT_SMALL_CAP /
_THRESHOLD / _MAX_SITES tune the window, hint threshold, and call-site cap. All
four are folded into the object cache key. Existing `noinline` cases
(try/setjmp/volatile) are respected via to_ir's has_try-first attribute
precedence. Adds test_gap_inline_hot_small.ts (byte-identical to Node ON/OFF
and under PERRY_GC_FORCE_EVACUATE=1).

Stacks on #6850 (merged).

Claude-Session: https://claude.ai/code/session_01UGDwjukzhowLDJYsMPFwMv
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5d0416b9-8fc7-4a99-9899-0450e3c11f7c

📥 Commits

Reviewing files that changed from the base of the PR and between 99d3212 and f1c14b1.

📒 Files selected for processing (1)
  • crates/perry-codegen/src/collectors/hot_callees.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-codegen/src/collectors/hot_callees.rs

📝 Walkthrough

Walkthrough

Adds configurable whole-module analysis for small functions called inside loops, emits LLVM inlinehint attributes for eligible functions, tunes the LLVM hint threshold, separates object-cache keys by inline settings, and adds deterministic regression coverage.

Changes

Inline-hot-small code generation

Layer / File(s) Summary
Inline policy and LLVM attribute contract
crates/perry-codegen/src/codegen/helpers.rs, crates/perry-codegen/src/function.rs
Adds cached environment controls, size and call-site limits, and LlFunction support for emitting inlinehint while preserving existing attribute precedence.
Hot-loop callee collection
crates/perry-codegen/src/collectors/*
Traverses module HIR, records direct calls inside loops, resets loop context for closures, and filters callees by total call-site count.
Hint application and LLVM threshold wiring
crates/perry-codegen/src/codegen/*, crates/perry-codegen/src/linker.rs
Adds the collected callee set to CrossModuleCtx, marks eligible functions during compilation, and passes the configured hint threshold to optimized Clang builds.
Codegen configuration cache separation
crates/perry/src/commands/compile/object_cache.rs, crates/perry/src/commands/compile/object_cache/object_cache_tests.rs, test-files/test_gap_inline_hot_small.ts, changelog.d/6855-inline-hot-small.md
Includes inline settings in object-cache keys, extends cache-key tests, adds deterministic typed-array coverage, and documents the implementation and benchmark behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant HIR
  participant collect_hot_loop_callees
  participant compile_function
  participant LlFunction
  participant Clang
  HIR->>collect_hot_loop_callees: scan loop-local FuncRef calls
  collect_hot_loop_callees->>compile_function: provide hot_loop_callees
  compile_function->>LlFunction: mark eligible functions inline_hint
  LlFunction->>Clang: emit inlinehint
  Clang->>Clang: apply inlinehint-threshold at O3
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main codegen change: inlining small hot functions via inlinehint.
Description check ✅ Passed The description is thorough and covers summary, rationale, tests, and results, though it doesn't follow the template's exact headings.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
✨ 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/inline-hot-small

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/collectors/hot_callees.rs`:
- Around line 47-57: Extend the hot-callee scan in the class traversal around
walk_stmts to visit every executable class-member body, including getters,
setters, static methods, computed-member functions, and applicable class
initializer expressions. Ensure all such call sites are recorded before the
existing max_call_sites cap is applied, while preserving the current handling of
constructors and regular methods.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7973dc7f-b847-4aa2-b25f-bf2105584e83

📥 Commits

Reviewing files that changed from the base of the PR and between 6899e75 and 99d3212.

📒 Files selected for processing (12)
  • changelog.d/6855-inline-hot-small.md
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/helpers.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/opts.rs
  • crates/perry-codegen/src/collectors/hot_callees.rs
  • crates/perry-codegen/src/collectors/mod.rs
  • crates/perry-codegen/src/function.rs
  • crates/perry-codegen/src/linker.rs
  • crates/perry/src/commands/compile/object_cache.rs
  • crates/perry/src/commands/compile/object_cache/object_cache_tests.rs
  • test-files/test_gap_inline_hot_small.ts

Comment thread crates/perry-codegen/src/collectors/hot_callees.rs
…ensus

CodeRabbit (#6855): collect_hot_loop_callees only walked hir.init,
free functions, class constructors, and instance methods. It missed
static methods, getters/setters, computed-key members (body + key
expr), and instance/static field initializers.

The miss matters for the anti-bloat cap, not just hot-callee discovery:
`max_call_sites` bounds inlinehint duplication, so any call site the
census doesn't see is a call site the cap can't count. A small function
whose extra call sites hide in a getter or a field initializer could
slip under the cap and get hinted despite being widely used, defeating
the size backstop. Field/computed-key exprs are walked with
in_loop=false so they feed the count without spuriously marking a
callee hot.

Verified byte-exact vs Node 26.5.0 on a class kernel whose getter,
static method, and field initializer each carry an in-loop call
(flag on and off).
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Addressed in f1c14b1. collect_hot_loop_callees now walks every executable class-member body before the max_call_sites cap is applied:

  • static methods (chain(c.static_methods))
  • instance/static getters and setters (c.getters / c.setters — static accessors already live here per the Class docs)
  • computed-key members — both the member body and the key expression (cm.function.body + cm.key_expr)
  • instance + static field initializers, plus their computed key exprs (c.fields / c.static_fields, each key_expr + init)

Field-initializer and computed-key expressions are walked with in_loop = false: they feed the call-site count (so the cap stays a true upper bound) without spuriously marking a callee hot. Any loop nested inside a member body still flips in_loop via the normal statement walk, so this also picks up genuinely-hot callees hiding in accessors/static methods.

Verified byte-exact vs Node 26.5.0 on a class kernel whose getter, static method, and field initializer each carry an in-loop call to a small helper (identical output flag-on and flag-off).

@proggeramlug
proggeramlug merged commit 23fbca4 into main Jul 26, 2026
29 of 30 checks passed
@proggeramlug
proggeramlug deleted the perf/inline-hot-small branch July 26, 2026 17:57
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