Skip to content

perf(compiler2): ~32x faster cold compile — remove redundant work, memoize package-invariant queries - #4016

Closed
hellovai wants to merge 2 commits into
canaryfrom
perf/compiler2-cold-compile
Closed

hellovai wants to merge 2 commits into
canaryfrom
perf/compiler2-cold-compile

Conversation

@hellovai

@hellovai hellovai commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

Cold check + emit on the in-repo test corpus (78 files, 25,838 lines) goes from 16.0s → ~0.50s (~32×). End-to-end baml check wall time on the same corpus goes from ~17s → ~0.55s. Everything here is single-threaded — no parallelism, no on-disk cache. Every win is one of three things:

  1. deleting work we were doing more than once,
  2. turning package/project-invariant computation into a Salsa-tracked query so it runs once instead of once-per-scope/per-function, or
  3. cutting allocation churn on the hot type-checking paths.

It also fixes two user-visible bugs found along the way (details below), and ships a new profiling harness (crates/tools_compile_profile) whose README documents the entire audit so the next person doesn't have to rediscover any of this.

Why

baml check on a real project was taking ~17 minutes. The pipeline is Salsa-based, but a cold CLI run gets zero benefit from Salsa's memoization across invocations — so cold-path cost is pure single-run efficiency. Profiling (query-event counts + samply CPU sampling) showed the time wasn't in any one algorithm; it was the same few computations being repeated tens of thousands of times.

How to review this PR

Each change is independent and documented at its call site with a comment explaining what was being recomputed and roughly what it cost. The full narrative, methodology, and per-change measurements live in crates/tools_compile_profile/README.md (section "The July 2026 cold-compile audit") — reading that first will make every diff hunk make sense.

The changes

Stop recomputing package/project-invariant data (the big one)

What Before After
Recursive-alias analysis (find_recursive_aliases, a whole-alias-graph DFS) ran inside every subtype/type-equality check — 18k executions runs once per package; result travels in the new baml_type::ResolvedAliases (alias map + precomputed recursive set), threaded by reference through TIR/MIR
Package alias map & impl-block list rebuilt (deep Ty clones + project walk) inside every infer_scope_types execution (~16k) package_resolved_aliases / package_impl_locs tracked queries
CST → AST lowering re-lowered per file by six different consumers (~31% of cold CPU) file_ast(db, file) tracked query
Project-wide class → type-tag map rebuilt per function lowered in MIR (~13% of cold CPU) class_type_tags_for_project tracked query
Callee generic params + bound lowering re-derived at every call site (~10% of cold CPU) callee_generics_for_func tracked query
Callee function body for direct calls PPIR cloned the entire ExprBody arena per call site ppir::function_body tracked, returns Arc

Stop doing the same inference twice

  • Lambda bodies were type-inferred twice: once inline while inferring the enclosing function, then again from scratch by the standalone lambda-scope query. The inline pass now captures the lambda's complete inference tables (NestedLambdaInference, moved not cloned) and the standalone query projects them. This is also a bug fix: diagnostics inside lambdas were previously reported twice.
  • Files with no $stream expansions had their semantic index built twice (identical results); PPIR's file_semantic_index now delegates to HIR's.
  • The builtin stdlib was eagerly type-checked on every baml check (a fixed cost floor even for a 1-line project) even though it can't produce user-actionable diagnostics. Diagnostics collection now skips builtin files; builtin scopes user code touches are still inferred lazily.

Make the remaining hot path cheap

  • Subtype/equality fast paths (baml_type::normalize): reflexivity short-circuit; co-inductive assumption bookkeeping (deep clone + full-tree hash per step) now only happens in the arms that can actually regress a cycle (μ-types, type vars, projections) — the millions of purely structural steps skip it. The safety argument is written out in a comment at the site.
  • heads_definitely_differ filter: MIR impl dispatch probes are overwhelmingly misses between types with different nominal heads — now decided without the two allocation-heavy normalization walks.
  • Interface-method-name pre-filter in MIR dispatch: dispatch_target_for_concrete enumerated every impl block in the package closure for every method call and field access. A per-package FxHashSet of interface-declared method names rejects the common plain-method case in one lookup (is_same_normalized_type calls: 952k → 275k).
  • mimalloc as the global allocator for baml_cli (and the profiler). System malloc was ~35% of remaining CPU — the workload is small-allocation heavy (Ty trees have deep value semantics). This is mitigation; the foundational fix (interning Ty) is scoped as future work in the README.

CLI-side bug fix

  • Diagnostic rendering rebuilt ariadne's line index per diagnostic. render_diagnostics constructed a fresh SourceCache — a line index over every project file — for each diagnostic. With 79 warnings that was 34% of total baml check wall time. Built once per batch now. (CLI 0.94s → 0.55s.)

New: crates/tools_compile_profile

A standalone profiling harness that runs the exact baml check pipeline against any project directory and reports wall-clock per stage, Salsa query execution/cache-hit counts by pipeline phase, top queries, and a "suspect" table (high-exec, low-hit queries — i.e. caching gaps). Supports --warm-runs, --repeat, --json, --check-only, and pairs with samply for line-level CPU profiles. The README documents how to use it, how to read its output, and the full audit above.

Correctness

  • Full test suite passes. Three snapshots changed, all expected: two reflect the duplicate-lambda-diagnostics fix (diagnostics no longer emitted twice), one reflects lambda-scope inference now being projected (same types, same diagnostics, different provenance).
  • The package_resolved_aliases query participates in a legitimate dependency cycle (aliases whose RHS is an associated-type projection resolve through inference, which consults the alias map); it now declares cycle_initial and seeds fixpoint iteration with the empty environment, mirroring infer_scope_types.
  • No language-semantics changes: every optimization computes the same values, just fewer times.

Numbers (test corpus, cold, median of 5)

Stage Before After
check (diagnostics) 8.1s 0.32s
emit (bytecode) 13.2s (worst measurement) 0.17s
pipeline total 16.0s ~0.50s
baml check CLI wall ~17s ~0.55s

Explicitly out of scope (documented as future work in the README)

  • Parallelism — untouched, remains available as a multiplier on top of this.
  • Ty interning / hash-consing — ~25% of remaining self-time is still clone/drop/malloc of Ty trees with deep value semantics. Staged plan (intern QualifiedTypeName → intern Ty → arena NormalTy temporaries) is written up in the README.

Made with Cursor

Summary by CodeRabbit

  • Performance

    • Improved cold compilation performance for baml check and baml build, particularly on macOS.
    • Reduced repeated work during type checking, interface resolution, and lambda analysis.
  • Bug Fixes

    • Improved handling of recursive type aliases and subtype checks.
    • Prevented compiler diagnostics from including internal built-in files.
    • Reduced duplicate diagnostics for nested lambda expressions.
    • Improved diagnostic rendering efficiency across multiple files.
  • Developer Tools

    • Added a standalone compiler profiling tool with timing, cache, query, JSON, and flamegraph-supporting reports.
    • Added comprehensive documentation and usage guidance for profiling compiler performance.

…moize package-invariant queries

Cold `check` + `emit` on the test corpus (78 files, 25.8k lines) drops from
16.0s to ~0.50s; end-to-end `baml check` wall time from ~17s to ~0.55s.
Single-threaded throughout — every win is deleted duplicate work, a new
Salsa-tracked query for package/project-invariant computation, or reduced
allocation churn. Ships a new `baml_compiler2_profile` harness whose README
documents the full audit, methodology, and per-change measurements.

Also fixes two user-visible bugs found along the way: diagnostics inside
lambdas were reported twice (lambda bodies were type-inferred twice), and
diagnostic rendering rebuilt ariadne's per-project line index once per
diagnostic (34% of `baml check` wall time on warning-heavy projects).

Co-authored-by: Cursor <[email protected]>
@vercel

vercel Bot commented Jul 14, 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 14, 2026 2:53pm
promptfiddle Ready Ready Preview, Comment Jul 14, 2026 2:53pm
promptfiddle2 Ready Ready Preview, Comment Jul 14, 2026 2:53pm

Request Review

@vercel
vercel Bot temporarily deployed to Preview – beps July 14, 2026 14:29 Inactive
@coderabbitai

coderabbitai Bot commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds cached AST and alias queries, reuses nested lambda inference, optimizes MIR dispatch and type normalization, introduces compiler profiling tooling, uses mimalloc in the CLI, reuses diagnostic source caches, and filters builtin-file diagnostics.

Changes

Compiler pipeline

Layer / File(s) Summary
Shared AST and semantic-index pipeline
baml_compiler2_hir/..., baml_compiler2_ppir/..., baml_lsp2_actions/...
AST lowering is exposed through a Salsa-tracked file_ast query and reused by HIR, PPIR, semantic indexing, and LSP checks.
Resolved alias environment propagation
baml_compiler2_tir/..., baml_compiler2_mir/..., baml_type/...
Alias handling now uses ResolvedAliases, including precomputed recursive-alias data, across inference, normalization, interface resolution, coherence, projections, and MIR lowering.
Cached generics and nested lambda inference
baml_compiler2_tir/src/builder.rs, baml_compiler2_tir/src/inference.rs
Callee generic facts are memoized, while nested lambda inference tables are stored by owning scopes and reused by lambda queries.
Shared MIR lowering state
baml_compiler2_mir/src/lower.rs, baml_compiler2_mir/Cargo.toml
Project-wide class type tags and package-wide interface method names are cached and used to skip irrelevant interface dispatch checks.
Normalization cycle handling and audit counters
baml_compiler2_tir/src/normalize.rs, baml_type/src/normalize.rs
Normalization gains profiling counters and head-mismatch fast paths; subtype cycle tracking is limited to expanding type comparisons.

Tooling and runtime support

Layer / File(s) Summary
Profiling, allocation, diagnostics, and wiring
tools_compile_profile/..., baml_cli/..., baml_compiler_diagnostics/..., baml_project/..., baml_language/stow.toml
A standalone profiling harness, CLI mimalloc allocator setup, shared Ariadne source caches, builtin diagnostic filtering, and related workspace wiring are added.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: rossirpaulo

Poem

I’m a rabbit with queries tucked under my ear,
Caching AST carrots makes compile time clear.
Lambdas hop once, aliases bloom,
Profiles light up the compiler room.
Mimalloc nibbles allocations with care—
Faster builds dance through the air!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: major cold-compile performance work through removing redundant work and memoizing queries.
✨ 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/compiler2-cold-compile

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.

The profiling harness is a dev tool, not a compiler crate — move it to
the tools_ namespace ("compile" added to approved prefixes). This also
drops the anyhow allowlist entry added for it: tools_* is already
covered by the global rule.

Co-authored-by: Cursor <[email protected]>
@vercel
vercel Bot temporarily deployed to Preview – beps July 14, 2026 14:36 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 July 14, 2026 14:44 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle July 14, 2026 14:53 Inactive

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

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 `@baml_language/crates/baml_compiler2_tir/src/normalize.rs`:
- Around line 90-129: Update heads_definitely_differ to treat List/EvolvingList,
Map/EvolvingMap, and RustType/Future/Resource/PromptAst/WatchAccessor as
same-family constructor pairs, returning false so their inner structure is
normalized and compared. Add these explicit match arms before the
discriminant-based wildcard, while preserving true for genuinely incompatible
stable heads.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 7e3b1ff0-2a41-402a-a38b-b6e02305d3a6

📥 Commits

Reviewing files that changed from the base of the PR and between 4146a2a and 73c8778.

⛔ Files ignored due to path filters (5)
  • baml_language/Cargo.lock is excluded by !**/*.lock
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/patterns_class_destructure/baml_tests__diagnostic_errors__patterns_class_destructure__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/patterns_class_destructure/baml_tests__diagnostic_errors__patterns_class_destructure__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_name_type_error/baml_tests__diagnostic_errors__test_expr_name_type_error__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase3a__optional_param_default_forward_reference_checks_lambda_bodies.snap is excluded by !**/*.snap
📒 Files selected for processing (30)
  • baml_language/Cargo.toml
  • baml_language/crates/baml_cli/Cargo.toml
  • baml_language/crates/baml_cli/src/main.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
  • baml_language/crates/baml_compiler2_hir/src/lib.rs
  • baml_language/crates/baml_compiler2_mir/Cargo.toml
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_ppir/src/lib.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_compiler2_tir/src/builder/associated_projection.rs
  • baml_language/crates/baml_compiler2_tir/src/builder/interface_resolution.rs
  • baml_language/crates/baml_compiler2_tir/src/callable.rs
  • baml_language/crates/baml_compiler2_tir/src/inference.rs
  • baml_language/crates/baml_compiler2_tir/src/interfaces.rs
  • baml_language/crates/baml_compiler2_tir/src/interfaces/coherence.rs
  • baml_language/crates/baml_compiler2_tir/src/interfaces/impl_rules.rs
  • baml_language/crates/baml_compiler2_tir/src/lower_type_expr.rs
  • baml_language/crates/baml_compiler2_tir/src/normalize.rs
  • baml_language/crates/baml_compiler2_tir/src/type_context.rs
  • baml_language/crates/baml_compiler_diagnostics/src/render.rs
  • baml_language/crates/baml_lsp2_actions/Cargo.toml
  • baml_language/crates/baml_lsp2_actions/src/check.rs
  • baml_language/crates/baml_project/src/check.rs
  • baml_language/crates/baml_tests/src/compiler2_hir.rs
  • baml_language/crates/baml_tests/src/compiler2_tir/inference.rs
  • baml_language/crates/baml_type/src/normalize.rs
  • baml_language/crates/tools_compile_profile/Cargo.toml
  • baml_language/crates/tools_compile_profile/README.md
  • baml_language/crates/tools_compile_profile/src/main.rs
  • baml_language/stow.toml

Comment on lines +90 to 129
/// True only when `lhs` and `rhs` provably normalize to different canonical
/// forms, judged from their outermost constructor alone.
///
/// Soundness rests on `normalize_impl` being head-stable for every variant
/// this decides on: a `Class` normalizes to a `Class` with the same qualified
/// name (only its args are rewritten), primitives map to fixed leaves, and so
/// on. Variants whose normal form can change shape entirely — `TypeAlias`
/// (expands), `Union` (canonicalization can collapse it to a single member) —
/// return `false` (undecided), as does any same-kind pair whose equality
/// depends on inner structure.
fn heads_definitely_differ(lhs: &Ty, rhs: &Ty) -> bool {
// Unstable heads: normalization may rewrite these into any other shape.
fn unstable(t: &Ty) -> bool {
matches!(
t,
Ty::TypeAlias(..)
| Ty::Union(..)
| Ty::AssociatedTypeProjection { .. }
| Ty::Media(baml_type::MediaKind::Generic, _)
| Ty::Infer { .. }
)
}
if unstable(lhs) || unstable(rhs) {
return false;
}
match (lhs, rhs) {
// Nominal heads: different names can never normalize equal.
(Ty::Class(q1, ..), Ty::Class(q2, ..))
| (Ty::Interface(q1, ..), Ty::Interface(q2, ..))
| (Ty::Enum(q1, _), Ty::Enum(q2, _)) => q1 != q2,
(Ty::EnumVariant(q1, v1, _), Ty::EnumVariant(q2, v2, _)) => q1 != q2 || v1 != v2,
(Ty::Literal(l1, _, _), Ty::Literal(l2, _, _)) => l1 != l2,
(Ty::Media(k1, _), Ty::Media(k2, _)) => k1 != k2,
(Ty::TypeVar(n1, _), Ty::TypeVar(n2, _)) => n1 != n2,
// Same shape, differing only in inner structure: undecided here.
_ if std::mem::discriminant(lhs) == std::mem::discriminant(rhs) => false,
// Different stable head constructors never normalize equal.
_ => true,
}
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '1,220p' baml_language/crates/baml_compiler2_tir/src/normalize.rs

Repository: BoundaryML/baml

Length of output: 8872


🏁 Script executed:

rg -n "Ty::(List|EvolvingList|Map|EvolvingMap|RustType|Future|Resource|PromptAst|WatchAccessor|Media\\(|TypeAlias|Union|AssociatedTypeProjection|Infer|Class|Interface|EnumVariant|Literal|TypeVar)" baml_language/crates/baml_compiler2_tir/src/normalize.rs baml_language/crates/baml_compiler2_tir/src -g '!target'

Repository: BoundaryML/baml

Length of output: 50372


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
p = Path("baml_language/crates/baml_compiler2_tir/src/normalize.rs")
lines = p.read_text().splitlines()
for start, end in [(620, 780), (2235, 2715), (1, 140)]:
    print(f"\n--- {start}-{end} ---")
    for i in range(start-1, min(end, len(lines))):
        print(f"{i+1}:{lines[i]}")
PY

Repository: BoundaryML/baml

Length of output: 32062


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
files = [
    "baml_language/crates/baml_compiler2_tir/src/normalize.rs",
    "baml_language/crates/baml_compiler2_tir/src/interfaces.rs",
    "baml_language/crates/baml_compiler2_tir/src/builder/associated_projection.rs",
]
for file in files:
    p = Path(file)
    text = p.read_text().splitlines()
    print(f"\n### {file} ({len(text)} lines)")
    if file.endswith("normalize.rs"):
        for start, end in [(620, 780), (2235, 2715)]:
            print(f"\n--- lines {start}-{end} ---")
            for i in range(start-1, min(end, len(text))):
                print(f"{i+1}:{text[i]}")
    else:
        # print only relevant snippets around candidate call sites
        for needle in ["is_same_normalized_type", "match_impl_head", "Ty::EvolvingList", "Ty::EvolvingMap"]:
            for idx, line in enumerate(text, 1):
                if needle in line:
                    lo = max(1, idx-6)
                    hi = min(len(text), idx+12)
                    print(f"\n--- {needle} at {idx} ---")
                    for j in range(lo, hi+1):
                        print(f"{j}:{text[j-1]}")
                    break
PY

Repository: BoundaryML/baml

Length of output: 29816


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
import re

text = Path("baml_language/crates/baml_compiler2_tir/src/normalize.rs").read_text()

# Extract the normalize_impl arms in a coarse way.
# We only care about surface Ty variants that map to the same StructuralTy head.
targets = {
    "Ty::List": "StructuralTy::List",
    "Ty::EvolvingList": "StructuralTy::List",
    "Ty::Map": "StructuralTy::Map",
    "Ty::EvolvingMap": "StructuralTy::Map",
    "Ty::RustType": "StructuralTy::Unknown",
    "Ty::Future": "StructuralTy::Unknown",
    "Ty::Resource": "StructuralTy::Unknown",
    "Ty::PromptAst": "StructuralTy::Unknown",
    "Ty::WatchAccessor": "StructuralTy::Unknown",
}

heads_cases = {
    "Ty::Class": "same-discriminant / nominal-name",
    "Ty::Interface": "same-discriminant / nominal-name",
    "Ty::EnumVariant": "same-discriminant / nominal-name",
    "Ty::Literal": "same-discriminant / literal",
    "Ty::Media": "same-discriminant / media-kind",
    "Ty::TypeVar": "same-discriminant / typevar",
}

for ty, target in targets.items():
    present = ty in text
    print(f"{ty:20} -> {target:22} present={present}")

print("\nHeads filter explicit cases:")
for ty, why in heads_cases.items():
    print(f"{ty:20} {why}")

print("\nWildcard note:")
m = re.search(r"_ if std::mem::discriminant\(lhs\) == std::mem::discriminant\(rhs\) => false,\n\s*// Different stable head constructors never normalize equal\.\n\s*_\s*=> true,", text)
print("wildcard+discriminant branch present =", bool(m))
PY

Repository: BoundaryML/baml

Length of output: 1095


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path

p = Path("baml_language/crates/baml_compiler2_tir/src/normalize.rs")
lines = p.read_text().splitlines()

# Print the test cases around the evolving list/map comparisons.
for start, end in [(2238, 2710), (2598, 2710)]:
    print(f"\n--- {start}-{end} ---")
    for i in range(start - 1, min(end, len(lines))):
        line = lines[i]
        if "EvolvingList" in line or "EvolvingMap" in line or "is_same_normalized_type" in line or "assert!" in line or "test" in line:
            print(f"{i+1}:{line}")
PY

Repository: BoundaryML/baml

Length of output: 2954


heads_definitely_differ needs same-family cases for constructors that collapse to the same normal form.

Ty::List/Ty::EvolvingList, Ty::Map/Ty::EvolvingMap, and Ty::RustType/Ty::Future/Ty::Resource/Ty::PromptAst/Ty::WatchAccessor all normalize to the same structural head, but they fall through to _ => true because their discriminants differ. That makes is_same_normalized_type return false without normalizing and can skip valid impl matches. Add explicit arms for these collapsing variants before the wildcard.

🤖 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 90 -
129, Update heads_definitely_differ to treat List/EvolvingList, Map/EvolvingMap,
and RustType/Future/Resource/PromptAst/WatchAccessor as same-family constructor
pairs, returning false so their inner structure is normalized and compared. Add
these explicit match arms before the discriminant-based wildcard, while
preserving true for genuinely incompatible stable heads.

Source: Coding guidelines

meefs pushed a commit to meefs/baml that referenced this pull request Jul 16, 2026
…rness (BoundaryML#4038)

## Summary

- Adds `baml_language/crates/tools_compile_profile`, a standalone
black-box profiling harness for the compiler pipeline (parse → HIR →
PPIR → TIR → MIR → emit). It reports wall-clock per phase,
per-Salsa-query execution / cache-hit / blocked counts, cold-run
variance, cold-vs-warm comparison (`--warm-runs`), and JSON output.
- Ported verbatim from PR BoundaryML#4016 (`perf/compiler2-cold-compile`), with
two adaptations: the `PROF_*` TIR audit counters (which only existed on
that branch) are removed rather than ported, and the "July 2026
cold-compile audit" README section is annotated as a historical record
predating BoundaryML#4032 / BoundaryML#3924.
- Workspace changes: `mimalloc = "0.1.52"` added to workspace deps (used
only by this tool, as its global allocator — noted in the README), and
`"compile"` added to the `tools` namespace `approved_prefixes` in
`stow.toml`.

Provenance: BoundaryML#4016. The remaining findings from that audit are being
re-landed as small independent PRs measured with this tool.

## Baseline on current canary (2660b8b)

Protocol: `cargo build --release -p tools_compile_profile`, then
`./target/release/tools_compile_profile
baml_language/crates/baml_tests/baml_src --repeat 5`
with `BAML_NO_BYTECODE_CACHE=1` and `BAML_CACHE_DIR` pointed at a fresh
temp dir (so BoundaryML#3924's disk cache is out of the picture). Corpus: 77
files, 25,212 lines. Apple Silicon, single-threaded, medians of 5 cold
runs.

| phase | median |
|---|---|
| check | 1.089 s |
| emit (bytecode) | 1.330 s |
| **total** | **2.420 s** (min 2.385, mean 2.442, max 2.524) |

For comparison, BoundaryML#4016's pre-optimization baseline on this corpus was
16.0s and its final state ~0.50s. Canary's own movement (BoundaryML#4032 deleting
the old TIR type algebra, BoundaryML#3924's caching work) already brought cold
compile from 16.0s to ~2.4s; the still-orthogonal optimizations from
BoundaryML#4016 target the remainder.

### Top 10 queries by executions (cold, representative run)

| exec | query |
|---|---|
| 15,590 | `infer_scope_types` |
| 2,262 | `function_in_scope_generic_param_bounds` |
| 2,256 | `function_body` |
| 2,131 | `callable_throws` |
| 625 | `class_generic_param_bounds` |
| 477 | `resolve_class_fields` |
| 419 | `function_parameter_defaults` |
| 419 | `function_signature` |
| 147 | `impl_data` |
| 147 | `impl_data_source_map` |

25,972 total query executions; TIR is 18,697 of them (72%).

## Test plan

- [x] `cargo build --release -p tools_compile_profile` clean
- [x] Clean run against `baml_language/crates/baml_tests/baml_src`
(`--repeat 5`)
- [x] Pre-commit hooks: cargo fmt, workspace clippy (`-D warnings`),
cargo stow, markdown validation

Made with [Cursor](https://cursor.com)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced a standalone compiler profiling harness to measure
compilation phase timings and query execution/caching behavior,
including cold vs warm runs.
* Generates human-readable and JSON reports, with optional compact
summary output and validation-only mode.
* **Documentation**
* Added comprehensive documentation covering CLI usage, report
interpretation, flamegraph pairing guidance, and profiling methodology.
* **Tests**
  * Added tests for query-to-phase mapping and report-related behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <[email protected]>
@hellovai

Copy link
Copy Markdown
Contributor Author

Superseded — closing in favor of the re-landed work, but keeping this branch as the reference implementation (do not delete).

Canary moved underneath this PR, so rather than rebase it we re-derived each still-orthogonal optimization from scratch against current canary and re-measured:

Result on the test corpus (cold, disk cache disabled, medians of 5): current canary 79aa0f44e 2.264s → #4058 1.003s (2.3x; check 2.8x, emit 1.9x). Full audit table and per-track detail are in #4058.

This PR's branch perf/compiler2-cold-compile stays as the historical reference.

@hellovai hellovai closed this Jul 16, 2026
hellovai added a commit that referenced this pull request Jul 16, 2026
…ect-wide tag map once, not per lowered function

build_class_type_tags walked every file's item tree and re-rendered +
re-hashed every class's fully-qualified name inside LoweringContext
construction — i.e. once per lowered function/let (~420x on the test
corpus; it was the single hottest MIR frame in a CPU sample, plus most of
the core::fmt time). Tags are content-addressed, so the map is a pure
function of the project's classes: it is now a #[salsa::tracked] query
keyed on the Project input, and LoweringContext borrows it like the rest
of the package-invariant data.

Re-land of #4016 audit item #4 (class_type_tags_for_project), which was
initially assumed superseded by #3924's content-addressed tags — #3924
changed the tag values but left the per-function rebuild in place.

Also adds the new tracked queries from this PR chain to the profiler's
phase_for_query table (per its README convention) so they stop landing
in the "other" bucket.

Cold check+emit on the corpus: 0.99s -> ~0.85s (medians of 5, disk cache
disabled); emit 0.64s -> 0.33s.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
meefs pushed a commit to meefs/baml that referenced this pull request Jul 16, 2026
…ns (2.4s → 0.81s cold) (BoundaryML#4058)

Re-lands the still-orthogonal cold-compile optimizations from BoundaryML#4016,
re-derived from
scratch against current `canary` (which moved underneath BoundaryML#4016 via BoundaryML#4032
and BoundaryML#3924).
Not a rebase of BoundaryML#4016 — every change was re-derived and re-measured.
BoundaryML#4016's single
biggest win (recursive-alias hoist) is intentionally **not** here: BoundaryML#4032
already captured
it by deleting the old TIR `StructuralTy` algebra. The profiler itself
already landed
separately as BoundaryML#4038.

## Measurement

Corpus: `crates/baml_tests/baml_src` (77 files, 25,212 lines). Protocol:
`tools_compile_profile ... --repeat 5`, disk cache disabled
(`BAML_NO_BYTECODE_CACHE=1`,
fresh `BAML_CACHE_DIR`). Cold-cache medians of 5 runs, single-threaded.

| | check | emit | **total** |
|---|---|---|---|
| canary (`8c29c827e`) | 1.114 s | 1.277 s | **2.392 s** (min 2.354 /
max 2.537) |
| this branch | 0.462 s | 0.345 s | **0.808 s** (min 0.791 / max 0.828)
|

**3.0x faster** cold check+emit, single-threaded.

(For reference, the pre-BoundaryML#4032 baseline this work originally started from
was ~16 s; BoundaryML#4032
alone brought cold compile to a few seconds, and this branch takes it
under ~1 s.)

Key query-count deltas (cold, corpus): `infer_scope_types` 15,590 →
**13,331** (PPIR→HIR
`file_semantic_index` delegation removes duplicate scope inference);
`package_resolved_aliases`
/ `package_impl_locs` no longer rebuilt inside every one of those
inference calls (now a
handful of per-package executions); new memoized queries `file_ast`
(131, once/file),
`callee_generics_for_func` (1,834), tracked PPIR `function_body`.

## What's in it (one commit per track)

- **`file_ast` tracked query** — lower CST→AST once per file (items +
lowering diagnostics +
env refs), shared by both `file_semantic_index` queries,
`ppir_expansion_items`, the
project-wide expansion collectors, and the LSP check path; PPIR
`file_semantic_index`
delegates to HIR's when a file has no `$stream` expansions; PPIR
`function_body` tracked.
- **package-level TIR queries** — `package_resolved_aliases` (+
`cycle_initial` seeding an
empty env, mirroring `infer_scope_types` — it sits in a real salsa cycle
via
associated-type-projection alias RHS) and `package_impl_locs` as tracked
queries, plus
`callee_generics_for_func`, so the alias map / impl-block list / callee
generics stop
  being rebuilt per inference call.
- **nested-lambda inference projection** — lambda bodies were inferred
twice (inline in the
owner scope, then again by the standalone `ScopeKind::Lambda` query),
which also emitted
duplicate diagnostics inside lambdas. The inline pass now captures the
lambda's tables and
the Lambda arm projects them; synthetic desugared `test`/`testset`
bodies fall through to
standalone inference so their diagnostics are still emitted. Snapshot
updates where the
  duplicate lambda diagnostics disappear are the point.
- **MIR dispatch prefilter + subtype fast paths** —
`dispatch_target_for_concrete` gates its
per-call impl enumeration behind a package-wide `FxHashSet` of
interface-declared method
names (own package + dependency closure); `baml_type::normalize` gets a
reflexivity +
`heads_definitely_differ` fast-reject in `equivalent()` (conservative:
same-kind nominal
pairs only — List/EvolvingList collapse to the same canonical head
post-BoundaryML#4032) and
restricts `is_subtype_of` co-inductive assumption bookkeeping to the
expanding arms
(Mu / TypeVar / AssociatedTypeProjection) via `is_subtype_of_inner`,
with a termination
argument in-comment. Re-derived onto the post-BoundaryML#4032 `baml_type` algebra
(the only
  equivalence path now).
- **memoized `class_type_tags_for_project`** — the project-wide class →
type-tag map was
rebuilt (every file's item tree walked, every class name re-rendered and
re-hashed)
inside every `LoweringContext` construction, i.e. once per lowered
function (~420x on
the corpus; the hottest MIR frame in a CPU sample). Now a
`#[salsa::tracked]` query
keyed on the `Project` input; `LoweringContext` borrows it. This is
BoundaryML#4016 audit item BoundaryML#4,
initially assumed superseded by BoundaryML#3924's content-addressed tags — BoundaryML#3924
changed the tag
*values* but left the per-function rebuild in place. Also adds this PR
chain's new
  tracked queries to the profiler's `phase_for_query` table.
- **match usefulness report reuse + emit buffer pre-sizing** — match
checking ran the full
usefulness matrix twice per `match` (exhaustiveness, then an identical
second pass for
unreachable-arm detection whenever no arm had a pattern error); the
reachability pass now
reuses the exhaustiveness report (exhaustiveness: ~11% -> ~1.7% of CPU
inclusive).
`StackifyCodegen` pre-sizes its bytecode/meta buffers and local/block
maps from the MIR's
  shape instead of growing from empty per function.
- **CLI mimalloc + diagnostic rendering** — mimalloc as `baml_cli`'s
global allocator; build
the ariadne `SourceCache` once per diagnostic batch instead of once per
diagnostic. Verified
byte-for-byte identical rendered diagnostics and clean
`BAML_CACHE_VERIFY=1` (so BoundaryML#3924's
  cached-diagnostic replay does not diverge).

## Deliberately not re-landed

- The BoundaryML#4016 recursive-alias hoist — superseded by BoundaryML#4032.
- Skip-builtin-diagnostics — superseded by BoundaryML#3924's per-toolchain
builtin-diagnostics cache.

## Testing

Full workspace test suite green except two pre-existing/environmental
failures unrelated to
this change: a Python cancellation pytest that fails identically on
clean `canary` (local
Python < 3.11: `ExceptionGroup`/`CancelledError.reason`). `cargo fmt` +
`clippy -D warnings`
clean.

Provenance: BoundaryML#4016 (reference implementation, kept as reference, not
merged).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>

This branch was previously deployed

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