Skip to content

Compiler perf: 2.9x faster cold compile (1.41s → 482ms empty project) - #3911

Merged
hellovai merged 2 commits into
canaryfrom
hellovai/compiler-perfo
Jul 4, 2026
Merged

hellovai merged 2 commits into
canaryfrom
hellovai/compiler-perfo

Conversation

@hellovai

@hellovai hellovai commented Jul 4, 2026 •

Copy link
Copy Markdown
Contributor

Results

cargo bench --bench compiler_benchmark -- compile_empty_project (median, 100 samples). The "empty" project compiles the entire builtin stdlib (55 files, ~9k LOC, 199 classes, 33 interfaces), so this benchmark measures the fixed cost every cold compile pays.

Change Median Step Δ
Baseline (canary) 1410 ms —
1. Normalize fast paths + memoized alias DFS 1175 ms −17%
2. Kill double MIR lowering 805 ms −31%
3. Package-shared dispatch cache 664 ms −17%
4. Inference views instead of metadata copies 482 ms −27%

Net: −66%, 2.9× faster. The wins compound because they attack the same hot path from four angles: the interface-dispatch machinery inside MIR lowering, which the profile showed dominating compile time (samply @2khz, symbolicated; see per-fix numbers below).

The four changes

1. tir/normalize.rs: stop redoing alias-graph analysis per type comparison

  • is_same_normalized_type / is_subtype_of get a lhs == rhs fast path. Sound because normalize is a pure function of (ty, aliases) and StructuralTy::is_subtype_of is explicitly reflexive — equal inputs always produced true through the slow path.
  • find_recursive_aliases was a fresh DFS (with fresh HashSets) per alias, per call: O(A·(A+E)). It is now a single memoized tri-color DFS over the whole graph: O(A+E). Same semantics ("reaches a cycle" is a per-node property, root-independent, so memoizing across roots is sound).

Why it wins: these functions sit at the innermost level of match_ty_pattern, called per recursion level per dispatch candidate. They were 49%/40% inclusive of compile time.

2. emit/lib.rs Pass 1: don't lower every function twice

Pass 1 called lower_function on every function in the project solely to check "is this an intrinsic?" for global-slot assignment; Pass 4 then lowered everything again to emit bytecode (lower_function is not a Salsa query, so nothing deduped the two). Pass 1 now reads the same fact off the item tree (FunctionBodyDef::Builtin(kind) — the exact field lower_function copies verbatim into MirFunctionKind) and gets the fq name from def_to_item_ref. Full MIR lowering now runs exactly once per function.

Why it wins: MIR lowering was 61% inclusive of compile time, and half of it was the duplicate.

3. mir/lower.rs: memoize interface-dispatch candidate resolution per package

interface_method_candidates_for rescans every implementor of an interface — running type-pattern matching and normalization per candidate — at every call site. The same questions recur constantly (every for loop asks the same Iterator.next question). New DispatchCandidateCache (Rc<RefCell<FxHashMap>>), one per package, shared across all functions the emit driver lowers via new lower_function_cached / lower_let_body_cached entry points (existing lower_function / lower_let_body signatures unchanged — they use a private fresh cache).

Correctness: a transitive audit of the 17-method resolution family showed its only inputs are the Salsa db, package-level PackageLoweringData, the request args, and the function's generic_param_bounds — so the cache key is (interface, type args, assoc bindings, method, receiver ty, sorted bounds), scoped per package.

Why it wins: candidate resolution + dispatch-switch emission were 33%/36% inclusive; they drop to ~8%/9% after this change.

4. mir/lower.rs + tir/inference.rs: read TIR inference through views, not copies

LoweringContext::new (and new_for_let) eagerly deep-copied all nine TIR inference tables — expr types, pattern types, resolutions, call plans, path types, coercions, exhaustive-match marks — out of the Salsa-cached per-scope ScopeInference values into fresh per-function merged maps, re-keying every entry. Per compile, the whole project's inference output was cloned, re-hashed, and dropped. That was ~17% of compile time in pure memory churn, and it's a materialized-view anti-pattern: the source data is already resident, immutable, and 'db-stable behind returns(ref).

The context now holds scope_inference: FxHashMap<FileScopeId, &'db ScopeInference<'db>> (function scope + descendants — exactly the range the old merge covered, so absence semantics are preserved for the callers that key behavior on a missing entry) and reads through tir_* point accessors that dispatch MetadataScope::Body → body tables, ::ParameterDefault → default tables. tir/inference.rs gains the missing point accessors (function_coercion, parameter_defaults(), and point lookups on DefaultParameterInference). All 94 read sites converted; the nine maps were verified strictly read-only after construction before the change.

Architecturally this also makes LoweringContext construction cheap and mostly pure — a prerequisite for eventually making lower_function itself a tracked query (LSP incrementality) without dragging a copy layer along.

What's deliberately NOT in this PR

Follow-ups identified by the post-change profile (remaining 482ms ≈ salsa machinery 32%, normalize cluster ~36%, TIR inference ~23%, SipHash string-hashing leaf 20%):

  • thread the precomputed ResolvedAliases.recursive set through is_same_normalized_type (it's still recomputed on the non-equal path),
  • cache registry_dispatch_target_for_concrete_receiver (uncached sibling of change 3),
  • salsa-track resolved_aliases_for_package,
  • type interning to kill string hashing.

Validation

  • Changes 1–3: full baml_tests integration suite green (2,600+ tests incl. all bytecode/MIR snapshots).
  • Change 4: mir/emit/tir unit tests green; relying on CI for the full suite run.
  • Behavior-preservation arguments per change are documented in code comments at each site.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Smarter compilation pipeline now reuses inference and dispatch results across closely related lowering steps.
  • Performance

    • Reduced repeated type-analysis and dispatch-candidate resolution during compilation for faster builds.
    • Added quicker type comparisons and more efficient recursive type-alias cycle detection.
  • Bug Fixes

    • Preserved correct global ordering when handling built-in functions during compilation.
    • Improved robustness of type resolution, call planning, and coercions for more consistent output.

Four independent fixes to the compile pipeline, found by profiling
compile_empty_project (which compiles the full 55-file stdlib):

1. normalize: lhs==rhs fast path in is_same_normalized_type /
   is_subtype_of, and find_recursive_aliases rewritten as a single
   memoized tri-color DFS (O(nodes+edges) instead of a fresh DFS per
   alias).

2. emit Pass 1: read intrinsic-kind off the item tree instead of fully
   MIR-lowering every function just to inspect one field. Previously
   every function in the project was lowered twice (Pass 1 + Pass 4).

3. mir: package-shared DispatchCandidateCache memoizing
   interface_method_candidates_for across all functions lowered in a
   package. Key includes the function's generic_param_bounds - the only
   per-function input of the resolution family (verified by transitive
   self-field audit).

4. mir: LoweringContext no longer deep-copies all nine TIR inference
   tables into per-function merged maps; it now holds &'db ScopeInference
   views per FileScopeId and reads through tir_* point accessors.

Co-Authored-By: Claude Fable 5 <[email protected]>
@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment Jul 4, 2026 1:37am
promptfiddle Ready Ready Preview, Comment Jul 4, 2026 1:37am
promptfiddle2 Ready Ready Preview, Comment Jul 4, 2026 1:37am

Request Review

@vercel
vercel Bot temporarily deployed to Preview – beps July 4, 2026 00:10 Inactive
@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e7ea1274-d0e6-44bd-93c3-c84fc551368f

📥 Commits

Reviewing files that changed from the base of the PR and between 87edecf and de932a0.

📒 Files selected for processing (1)
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • baml_language/crates/baml_compiler2_mir/src/lower.rs

📝 Walkthrough

Walkthrough

This PR adds shared per-package dispatch caching and per-scope TIR point lookups to MIR lowering, threads the new cached lowering entry points through emit, and updates type normalization with identical-type fast paths plus shared memoized cycle detection.

Changes

Dispatch caching and TIR accessor refactor

Layer / File(s) Summary
TIR point-lookup accessors and public exports
baml_language/crates/baml_compiler2_tir/src/inference.rs, baml_language/crates/baml_compiler2_mir/src/lib.rs
Adds point-lookup accessors on DefaultParameterInference and ScopeInference, and re-exports DispatchCandidateCache and cached lowering functions.
Dispatch cache and LoweringContext refactor
baml_language/crates/baml_compiler2_mir/src/lower.rs
Introduces DispatchCacheKey/DispatchCandidateCache and rebuilds LoweringContext to carry per-scope inference views plus the shared dispatch cache.
Lowering paths switched to tir_* accessors
baml_language/crates/baml_compiler2_mir/src/lower.rs
Updates expression, pattern, path, call-plan, coercion, resolution, and exhaustiveness lookups to read through the new tir_* accessors.
Cached entry points and emit wiring
baml_language/crates/baml_compiler2_mir/src/lower.rs, baml_language/crates/baml_compiler2_emit/src/lib.rs
Adds cached lowering entry points and threads per-package dispatch caches through bytecode generation and init-function lowering.

Type normalization fast-paths and memoized cycle detection

Layer / File(s) Summary
Subtype/equality fast-paths and shared memoized cycle detection
baml_language/crates/baml_compiler2_tir/src/normalize.rs
Adds identical-type early returns and replaces per-alias DFS cycle detection with shared memoized state.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant generate_project_bytecode_with_opt
  participant lower_function_cached
  participant LoweringContext
  participant DispatchCandidateCache

  generate_project_bytecode_with_opt->>lower_function_cached: pass package dispatch_cache
  lower_function_cached->>LoweringContext: new_for_function(scope_inference, dispatch_cache)
  LoweringContext->>DispatchCandidateCache: lookup DispatchCacheKey
  alt cache miss
    LoweringContext->>LoweringContext: compute candidates
    LoweringContext->>DispatchCandidateCache: insert candidates
  end
  LoweringContext-->>lower_function_cached: lowered MIR
Loading

Possibly related PRs

  • BoundaryML/baml#3635: Both PRs refactor baml_compiler2_mir/src/lower.rs around shared lowering inputs and cached state passed through lowering contexts.
  • BoundaryML/baml#3638: Both PRs touch MIR interface-dispatch behavior in baml_compiler2_mir/src/lower.rs.
  • BoundaryML/baml#3834: Both PRs modify call-lowering paths in baml_compiler2_mir/src/lower.rs.

Poem

I’m a rabbit with caches in my paws,
Hopping past the old merged-map laws.
Types look up fast, cycles remember,
Dispatch stays warm through each bytecode ember.
Hop hop — the compiler burrows with cause. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main change: a compiler performance improvement with a specific benchmark result.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 hellovai/compiler-perfo

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.

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 July 4, 2026 00:16 Inactive
@github-actions

github-actions Bot commented Jul 4, 2026 •

Copy link
Copy Markdown

Binary size checks passed

✅ 7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
✅ baml-cli Linux 🔒 21.6 MB 9.2 MB file 21.5 MB +73.2 KB (+0.3%) OK
✅ packed-program Linux 🔒 15.7 MB 6.6 MB file 15.6 MB +128.1 KB (+0.8%) OK
✅ baml-cli macOS 🔒 16.6 MB 8.0 MB file 16.6 MB +49.8 KB (+0.3%) OK
✅ packed-program macOS 🔒 12.2 MB 5.8 MB file 12.1 MB +99.6 KB (+0.8%) OK
✅ baml-cli Windows 🔒 18.2 MB 8.2 MB file 18.1 MB +52.7 KB (+0.3%) OK
✅ packed-program Windows 🔒 13.1 MB 5.9 MB file 13.0 MB +99.4 KB (+0.8%) OK
✅ bridge_wasm WASM 14.5 MB 🔒 4.1 MB gzip 4.1 MB +51.4 KB (+1.3%) OK

🔒 = the size this artifact is GATED on (ceiling + delta). Binaries gate on file size (installed binary); WASM gates on gzip (download size). The other size is shown for information only.


Generated by cargo size-gate · workflow run

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
baml_language/crates/baml_compiler2_tir/src/normalize.rs (1)

55-74: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a unit test for the memoized cross-root cycle detection.

The per-alias→shared-state rewrite is subtle (gray/black memoization reused across roots). A small #[cfg(test)] case exercising a diamond/shared alias graph — e.g. A → B, A → C, B → C → B — would lock in that all reachable aliases are classified recursive regardless of aliases key iteration order, and guard against future regressions. As per coding guidelines ("Prefer writing Rust unit tests over integration tests where possible").

🤖 Prompt for 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.

In `@baml_language/crates/baml_compiler2_tir/src/normalize.rs` around lines 55 -
74, Add a Rust unit test for find_recursive_aliases that exercises shared
memoized DFS state across roots using a diamond-shaped alias graph such as A→B,
A→C, and B↔C, then assert that all aliases reachable from the cycle are marked
recursive regardless of HashMap key iteration order. Place the test under
cfg(test) near find_recursive_aliases/has_cycle so it guards the gray/black
state reuse behavior and prevents regressions in cross-root cycle detection.

Source: Coding guidelines

🤖 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.

Nitpick comments:
In `@baml_language/crates/baml_compiler2_tir/src/normalize.rs`:
- Around line 55-74: Add a Rust unit test for find_recursive_aliases that
exercises shared memoized DFS state across roots using a diamond-shaped alias
graph such as A→B, A→C, and B↔C, then assert that all aliases reachable from the
cycle are marked recursive regardless of HashMap key iteration order. Place the
test under cfg(test) near find_recursive_aliases/has_cycle so it guards the
gray/black state reuse behavior and prevents regressions in cross-root cycle
detection.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b4b5bee9-c165-4f7a-90ab-017407be4d8a

📥 Commits

Reviewing files that changed from the base of the PR and between 680dc42 and 87edecf.

📒 Files selected for processing (5)
  • baml_language/crates/baml_compiler2_emit/src/lib.rs
  • baml_language/crates/baml_compiler2_mir/src/lib.rs
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_tir/src/inference.rs
  • baml_language/crates/baml_compiler2_tir/src/normalize.rs

The pub struct's doc linked the private
LoweringContext::interface_method_candidates_for, which fails
`-D rustdoc::private-intra-doc-links` in CI. Plain code formatting
instead of an intra-doc link.

Co-Authored-By: Claude Fable 5 <[email protected]>
@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel
vercel Bot temporarily deployed to Preview – beps July 4, 2026 01:19 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 July 4, 2026 01:25 Inactive
@hellovai
hellovai enabled auto-merge July 4, 2026 01:27
@hellovai
hellovai added this pull request to the merge queue Jul 4, 2026
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle July 4, 2026 01:37 Inactive
Merged via the queue into canary with commit 07b9bcb Jul 4, 2026
53 checks passed
@hellovai
hellovai deleted the hellovai/compiler-perfo branch July 4, 2026 01:48

This branch was previously deployed

3 inactive deployments
Preview – promptfiddle — de932a0e Deployed Jul 4, 2026 by vercel[bot]
Preview – promptfiddle2 — de932a0e Deployed Jul 4, 2026 by vercel[bot]
Preview – beps — de932a0e Deployed Jul 4, 2026 by vercel[bot]
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