Skip to content

Incremental compilation: content-addressed bytecode caching with per-file recompilation - #3924

Merged
antoniosarosi merged 87 commits into
canaryfrom
antonio/bytecode-cache
Jul 15, 2026
Merged

antoniosarosi merged 87 commits into
canaryfrom
antonio/bytecode-cache

Conversation

@antoniosarosi

@antoniosarosi antoniosarosi commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

What this does

BAML stops recompiling the world — across every command, across process restarts, with no daemon. On an 82-file project (release, medians):

  • Nothing changed → baml run in 69ms (173×): the cached Program loads and the compiler never runs. baml test --list in 12ms (1403×): the cached discovery list serves and the engine never boots.
  • Any typical edit → 675ms instead of 11.9s (17.7×): the dirty file re-typechecks and re-emits; everything else — bytecode units, typed interfaces, diagnostics — serves from cache. Insensitive to dirty count (1 or 5 dirty ≈ same).
  • baml check is incremental too (4.9× warm): clean files' diagnostics — warnings included — serve from cache and render byte-identically.
  • The editor benefits: the LSP seeds the same caches at project load (~60% faster cold-open first-diagnostics in a debug-build micro-benchmark, 2.1s → 0.8s, driven almost entirely by the stdlib interface seed; the mechanism proof is the stdlib-derivation counter dropping 6 → 0), evicting seeds on edit to stay sound in a long-lived database.
  • Cache writes are O(changed): an edit writes a ~14KB unit entry, not a multi-MB image. Units are individually content-addressed (CAS) — BAML_CACHE_REMOTE=<url> shares immutable entries through any HTTP GET/PUT store (CI fills, laptops read); a broken remote silently degrades to local.

The design follows the convergent architecture of rustc/javac/tsc/Go/Roslyn (surveyed against their sources): persist per-unit typed interfaces and compiled artifacts, never the type-checker's state; re-derive cheap structure every run; gate blast radius on interface hashes; verify with oracles.

How it works

Content-addressed store, no invalidation logic: every compile input — a fingerprint of the compiler binary itself, options, baml.toml, every file's (path, content) — folds into a sha256 key; the key is the address. Changed inputs are a miss; stale entries age out. Entry kinds: the whole-project Program blob, per-file CompilationUnit entries (content-addressed by their own bytes; the manifest maps rel_path → unit_key), the per-project manifest, the test --list discovery blob, and four per-toolchain build constants (stdlib bytecode, stdlib typed interface, builtin diagnostics — all keyed by fingerprint + opt level only). Every entry self-validates an 80-byte header (magic, format version, key echo, length, payload sha256) before a payload byte is trusted. Format ships as v1.

Per-file relocatable units + load-time linker (B-693): each file compiles to a CompilationUnit whose cross-file references are symbolic — Local(i) for its own definitions, Import(name) otherwise, plus an export table. link(units) assigns final indices and patches operands through the same exhaustive operand walker the VM already uses — a new opcode with an index fails compilation rather than escaping relocation (this fired twice during development, in both directions: an added opcode forced classification; a deleted feature's opcodes forced removal). Class type tags are content-addressed (CLASS_BASE + fnv1a64(fq_name)) so baked dispatch tables never shift.

Each unit carries its typed interface (javac-style unification): a borsh fragment of per-function exact callable_throws, stored beside the bytecode under the same content key — artifact and interface cannot drift apart.

Per-file typecheck skip: on a warm compile, only dirty files run inference.

  • Diagnostics: per-file diagnostics (warnings included) are cached in the manifest and served for clean files; builtin (stdlib) diagnostics — a compiler-build constant — are cached once per toolchain, eliminating a ~1,900-scope re-walk every warm compile (scope inferences on a warm edit: 15, vs 15,644 cold).
  • Interfaces: clean files' fragments seed callable_throws and the stdlib interface seeds package_interface, so a dirty file's inference consumes cached typed interfaces instead of pulling clean callees' bodies transitively. Emit provably never reads these seeds — a stale seed could only ever affect diagnostics, never bytecode.
  • The LSP seeds the same data at project load; because a seed short-circuit hides the seed→file-content edge from salsa, the LSP evicts per-file seeds on any content edit (clear-all, since a seed also hides transitive callees — pinned by a 3-level transitive test).

The invalidation layer (per-file in the manifest): content hash; signature hash (bodies blanked); layout hash (type/impl declarations only — a function-signature edit in a mixed file no longer dirties the world); defined/referenced/signature-referenced names; per-function throw facts. Dirty = changed ∪ added ∪ name propagation ∪ three mechanisms: a signature-meaning cascade (type-identity edits flowing through content-unchanged callees re-propagate to fixpoint), a throws-taint closure over reverse call edges pruned at closed throws contracts (throws is body-inferred but interface-visible), and a layout sentinel (files that bake field offsets/discriminants without naming the type — deliberately coarse: layout edits approach full recompile, and that's the sound trade).

Failure posture: only error-free compiles are cached; torn/corrupt/missing entries degrade to recomputing that file (a missing unit dirties exactly its file), then to full compile — never an abort. baml check reads the cache but never advances the manifest (a check-written manifest would point at units that were never emitted).

Correctness

Three tiers of verification:

  1. Always-on (every compile): header + payload-sha validation on every entry load; sampled field verification — a deterministic 1-in-32 of warm compiles (keyed off the cache key, RNG-free, replayable) re-derives one served file's diagnostics + throws fragment on a fresh un-seeded db and hard-errors on divergence. Zero measurable cost at default rate.
  2. Every CI run: byte-identity and parity oracles across the scenario matrix (body/signature/layout/alias/throws edits, add/delete file) — relink ≡ full compile, served diagnostics ≡ honest check; every sentinel and closure is proven load-bearing by a test that fails when the mechanism is disabled.
  3. Opt-in (BAML_CACHE_VERIFY=1): the honest full compile runs and every servable artifact is compared byte-for-byte — program, diagnostics, fragments, stdlib interface, builtin diagnostics, discovery list. Final pre-merge sweep: 15/15 scenarios × commands, zero bails; 15/15 stdout byte-identity vs no-cache; 545/545 tests.

The diff also survived a 30-agent adversarial review (4 dimensions × 2 skeptics per finding); its two confirmed soundness findings (the signature-meaning cascade and a verify-oracle gating bug) are fixed in this PR with regression scenarios.

Knobs: BAML_CACHE_DIR, BAML_CACHE_REMOTE, BAML_CACHE_VERIFY, BAML_CACHE_SAMPLED_VERIFY, BAML_CACHE_DEBUG, and per-layer opt-outs (BAML_NO_BYTECODE_CACHE, BAML_NO_STDLIB_INTERFACE_CACHE, BAML_NO_DIAGNOSTICS_CACHE, BAML_NO_CALLABLE_THROWS_CACHE, BAML_NO_DISCOVERY_CACHE, BAML_NO_LSP_CACHE_SEED).

Measured (release, medians; 82-file corpus)

Scenario No cache Full stack Speedup
cold first compile 11.9s 12.1s 0.98× (pays cache write)
warm no-change (run) 11.9s 69ms 173×
warm body edit (1 dirty) 11.9s 675ms 17.7×
warm throws edit (5 dirty) 11.9s 676ms 17.7×
warm signature edit (layout sentinel, 80 dirty) 11.9s 11.5s ~1× (sound fallback)
warm test --list 16.8s 12ms 1403×
warm check (no-change / body edit) 4.8s 0.98s / 1.0s 4.9×
small project (5f): no-change / body edit 0.66s 16ms / 127ms 41× / 5.2×
cache bytes written per edit — ~14KB unit + program blob was ~9MB

Counters on a warm edit: reuse plan 81 clean/1 dirty · units written 1, reused 81 · scope inferences 15 (cold: 15,644) · stdlib derivations 0.

Not in this PR

  • Stdlib embedded in the release binary — deliberately deferred to keep the binary small; the per-toolchain cache-materialized model covers it functionally.
  • Cross-machine near-miss reuse — units are individually shareable today (exact-state hits + CAS repopulation work over the remote); a shared manifest/index protocol for "CI compiled a state close to yours" is a designed future extension.
  • Pre-existing bug surfaced while testing (identical with and without the cache): legacy functions [...] tests fail with FunctionNotFound { name: "[Greet]" } — filed as B-695.

Design lineage: cross-language survey of CPython/Go/V8/rustc/TypeScript/JVM/.NET, a verified salsa-incrementality study (the LSP was already per-scope incremental; the CLI was cold-per-process — this PR is the disk-persistence answer, chosen over a daemon), and per-mechanism research into rustc's red-green/.rmeta, javac's .class completion, tsc's .tsbuildinfo, Go's action IDs, and Roslyn's ref assemblies — reports in thoughts/antonio/ (untracked).

Summary by CodeRabbit

  • New Features

    • Added content-addressed bytecode caching to speed repeated build, check, and test runs, including stdlib reuse and deterministic compilation/linking.
    • Added warm caching for diagnostics and baml test --list discovery, with optional remote sharing and faster incremental reruns.
    • Improved incremental reuse planning with stronger correctness checks (including sampled verification).
  • Bug Fixes

    • Strengthened cache safety: corrupted/stale entries automatically fall back to fresh compilation, and stricter verification can hard-fail on mismatches.
    • Improved consistency of diagnostics collection/rendering to keep error output stable across cold vs warm runs.

Tier 1 of the incremental-compilation plan (whole-project bytecode
cache, Go-build-cache-shaped: no invalidation logic, every compile
input folded into a content-addressed key).

- emit_determinism test: two fresh ProjectDatabases over identical
  sources serialize to byte-identical borsh Programs, for both the
  stdlib-only fixture and the full baml_src project. This is the
  invariant content-addressing rests on; it also proves FileId
  assignment is deterministic (per-db counter + sorted discovery).
- bex_cache: content-addressed store (bytecode/<xx>/<key>.bexc) with
  a versioned entry header (magic, format version, key echo, payload
  sha256), sha256 key over compiler fingerprint + options + manifest
  + sorted relative-path/content pairs (length-prefixed framing),
  compiler fingerprint = hash of the running executable memoized by
  (len, mtime), atomic temp+rename writes, silent-None loads, and an
  age-based trim.

Not yet wired into the CLI — that lands next.
On `baml run` (project mode), compute the content-addressed key from
the resolved sources before any compilation: a hit deserializes the
cached Program and boots the engine directly, skipping typecheck and
emit entirely (measured 7.8s -> 0.49s on a debug-build cold/warm pair).
A miss follows the existing compile path and writes through on
error-free compiles only. Warnings are already not surfaced by
`baml run` on success, so skipping diagnostics on a hit is
behavior-identical.

- project_load: split the strict load path into resolve_project_sources
  (root + validated manifest + file contents, no db) and
  build_db_from_sources; load_project_from_inner now composes them, and
  the format-hint check reuses the in-memory sources instead of
  re-reading every file.
- bytecode_cache (CLI): CacheContext computes the key (compiler
  fingerprint, OptLevel::Two, emit_test_cases, manifest, sorted
  root-relative path/content pairs). Knobs: BAML_NO_BYTECODE_CACHE=1,
  BAML_CACHE_DIR, BAML_CACHE_VERIFY=1 (tripwire: always compile, hard-
  fail if bytes differ from the cached entry).
- bex_cache: load_raw for the verify path, cargo-style .gitignore in
  the cache dir, maybe_trim (5-day age, at most one scan/day).

baml test is deliberately not cached yet: it needs the db for legacy
HIR test discovery; that lands separately.

Verified end-to-end: cold run writes an entry; warm run hits with
identical output; source edit misses and recompiles; corrupt entry is
silently recompiled; error projects cache nothing; BAML_CACHE_VERIFY
passes; BAML_NO_BYTECODE_CACHE writes nothing; baml check unaffected.
Function::source_file is display/metadata-only (backtraces, event
metadata, reflection locations) — never opened from disk — so strip the
project root at emit. Serialized Programs become location-independent
(a cached or packed blob no longer embeds the machine's project path)
and runtime backtraces are machine-independent. <builtin>/ stubs and
paths outside the root are kept verbatim.

Verified: cache blob for a test project contains zero absolute-path
occurrences and the root-relative baml_src/main.baml; emit-determinism
and bytecode-display tests pass.
On a bytecode-cache hit, baml test now skips the database entirely
(typecheck, HIR discovery, emit — measured 19.5s -> 0.63s debug
cold/warm): the compiled Program already carries everything the run
needs. Testsets execute inside the VM off the blob, and the legacy
test list is reconstructed from Program::test_cases — the same
(test, target functions) pairs HIR discovery yields — with the target
function's source_file supplying the --list display path. Cache key
uses emit_test_cases: true, so run and test blobs coexist.

Legacy discovery now renders root-relative paths (matching emit), so
--list output is byte-identical between compiled and cache-served
runs — verified on a project with both a testset and a legacy
functions-block test.

Note: legacy functions-block tests fail identically on both paths
today (FunctionNotFound { name: "[Greet]" } — the list rendering of
the functions ref leaks into the lookup name); pre-existing on canary,
not addressed here.
Two changes that together make the compiled stdlib user-independent —
the prerequisite for a precompiled stdlib artifact:

1. compiler2_all_files now returns builtin stubs BEFORE user files.
   Everything assigned by whole-project iteration order (emit slots,
   MIR class_type_tags) gives builtins a stable per-group prefix;
   within-group order is unchanged and no package receives
   contributions from both groups, so HIR merge behavior is
   unaffected.

2. Emit passes 1-4.6 are extracted into emit_file_group, run once for
   the builtin group and once for the user group (EmitTables threads
   the accumulated name->index maps across groups so user code can
   reference builtin items). Previously each pass looped all files, so
   segments interleaved (classes B,U / enums B,U / ... / functions
   B,U) and builtin enum/function/let indices shifted with user class
   counts. Now the ObjectPool and globals table are [stdlib...][user...]
   and the stdlib prefix is byte-stable for a given compiler build.
   Per-group Pass 1 re-syncs its slot counter to program.globals.len()
   because $init/helper synthesis consumes slots beyond the named
   items; package_init_order accumulates across groups (builtins
   first, as the loader already expects).

Also fixes latent nondeterminism in Pass 4.6: the $init_test chainer
loop iterated a HashMap while allocating objects/globals — benign
today only because a single package ever carries $init_test functions;
now sorted.

Full baml_tests suite passes; only the two bytecode-display snapshots
churn (pure index renumbering). Emit determinism holds (compile-twice
byte-equality on the stdlib fixture and full baml_src).
The Go model (post-1.20 GOROOT/pkg removal): the stdlib slice is
materialized into the cache on first use, keyed only by compiler
fingerprint + opt level, then spliced into every subsequent compile —
of any project — instead of re-lowering 55 builtin files per compile.

Emit:
- generate_stdlib_program(db, opt): full pipeline over the builtin
  file group alone. Because builtins occupy a contiguous
  user-independent prefix of every index space (previous commit), the
  result is byte-identical to the stdlib prefix of any full compile.
- generate_project_bytecode_with_stdlib(db, options, opt, base):
  seeds the output Program and EmitTables from the base slice
  (name->slot maps from the Program; class/enum/interface metadata
  recovered from the pool objects; type-tag counter = class count;
  per-package impl_rules/recursive aliases cleared so the trailing
  whole-program passes regenerate them verbatim), then emits only the
  user group. Skips all builtin body lowering — and because salsa is
  lazy, stdlib TIR inference never runs either.
- Oracle test: splice output is asserted byte-identical to a full
  compile for both fixtures, with the base built from a DIFFERENT
  project's db — proving the slice is genuinely user-independent
  (this also settles that builtin dispatch fast-paths don't absorb
  user impls).

CLI: compile_program(db, options, cache) reuses/materializes the
stdlib entry on the project-miss path for both baml run and baml test;
first-ever run pays roughly one full compile (the stdlib build and the
splice share one salsa db, so stdlib queries are already memoized).

Measured (debug build): edit->run loop 5.6s (full compile) -> 3.4s
(splice); unchanged rerun stays 0.6s (project-blob hit);
BAML_CACHE_VERIFY passes over the spliced path.
@vercel

vercel Bot commented Jul 6, 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 15, 2026 3:01pm
promptfiddle Ready Ready Preview, Comment Jul 15, 2026 3:01pm
promptfiddle2 Ready Ready Preview, Comment Jul 15, 2026 3:01pm

Request Review

@vercel
vercel Bot temporarily deployed to Preview – beps July 6, 2026 20:51 Inactive
@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds content-addressed bytecode caching, relocatable linking, deterministic compiler emission, incremental diagnostics and throw analysis, cache-aware CLI and LSP flows, byte-identical compilation oracles, and refreshed artifact size baselines.

Changes

Cache-backed compilation

Layer / File(s) Summary
Cache backend and persisted formats
baml_language/crates/bex_cache/*, baml_language/crates/baml_cli/src/bytecode_cache.rs
Adds deterministic keys, manifests, local and remote storage, integrity checks, compiler fingerprints, and serialized diagnostic artifacts.
Relocatable units and linker
baml_language/crates/bex_vm_types/src/{unit,link,relink}.rs
Adds symbolic units, operand traversal, import rebasing, generic-function deduplication, package merging, and deterministic linking.
Deterministic emission and reuse
baml_language/crates/baml_compiler2_emit/*, baml_language/crates/baml_compiler2_mir/*, baml_language/crates/baml_type/src/typetag.rs
Adds stdlib compilation and splicing, content-derived class tags, stable metadata, deterministic initialization, and incremental reuse APIs.
Incremental analysis and diagnostics
baml_language/crates/baml_compiler2_tir/*, baml_language/crates/baml_project/*, baml_language/crates/baml_workspace/*, baml_language/crates/baml_compiler_diagnostics/*
Adds seeded throw/interface data, narrowed diagnostics, serialized diagnostic types, and full-project diagnostic collection.
CLI and LSP cache flows
baml_language/crates/baml_cli/src/*, baml_language/crates/bex_project/src/{seed,project}.rs
Separates source resolution from database construction and integrates cache hits, reuse plans, incremental diagnostics, test discovery caching, verification, and LSP seed invalidation.
Validation and compatibility coverage
baml_language/crates/baml_tests/tests/*, baml_language/crates/baml_cli/tests/*
Compares full compilation with relinking, stdlib splicing, seeded analysis, diagnostic caching, and cached test discovery.
Supporting build and API updates
baml_language/**/Cargo.toml, baml_language/.ci/size-gate/*, baml_language/.cargo/size-gate.toml
Updates workspace dependencies, artifact size measurements, and related diagnostics call sites.

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

Possibly related PRs

Poem

I’m a rabbit with bytes in a burrow so neat,
Cache keys hop softly on deterministic feet.
Relinked units join the compile parade,
Fresh diagnostics bloom where old ones stayed.
Every hop lands on a byte-perfect line.

🚥 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 captures the main change: incremental compilation with content-addressed bytecode caching and per-file recompilation.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch antonio/bytecode-cache

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 6, 2026 20:57 Inactive
Conflicts: the two bytecode-display snapshots (canary's Array.sum
stdlib addition vs this branch's index regrouping) — regenerated on
the merged tree. B-628's consume_flat_report fix merged cleanly with
the cache restructure. Determinism + splice-oracle tests pass on the
merged stdlib.
@vercel
vercel Bot temporarily deployed to Preview – beps July 6, 2026 21:06 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.

🧹 Nitpick comments (2)
baml_language/crates/bex_cache/src/lib.rs (2)

66-96: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Sorted-files invariant only enforced in debug builds.

KeyInputs::files documents a hard "sorted by path" precondition, but the only enforcement is debug_assert! (line 87-90), which is compiled out in release. If an upstream caller (e.g. CacheContext::open) ever regresses the pre-sort guarantee, this silently degrades to a discovery-order-dependent key in production, defeating cache hit-rate without any signal — the exact kind of contract break the module doc warns about ("Key completeness").

Consider sorting defensively inside compute_key itself (cheap relative to compilation) instead of just asserting, so correctness doesn't depend on every future caller preserving order.

♻️ Proposed defensive sort
 pub fn compute_key(inputs: &KeyInputs<'_>) -> CacheKey {
     let mut h = Sha256::new();
     h.update(MAGIC);
     h.update(FORMAT_VERSION.to_le_bytes());
     h.update(inputs.compiler_fingerprint);
     h.update([inputs.opt_level, u8::from(inputs.emit_test_cases)]);
     match inputs.manifest {
         Some(m) => {
             h.update([1u8]);
             h.update((m.len() as u64).to_le_bytes());
             h.update(m.as_bytes());
         }
         None => h.update([0u8]),
     }
     h.update((inputs.files.len() as u64).to_le_bytes());
-    debug_assert!(
-        inputs.files.windows(2).all(|w| w[0].0 <= w[1].0),
-        "KeyInputs::files must be sorted by path"
-    );
-    for (path, content) in inputs.files {
+    let mut sorted: Vec<&(String, &str)> = inputs.files.iter().collect();
+    sorted.sort_by(|a, b| a.0.cmp(&b.0));
+    for (path, content) in sorted {
         h.update((path.len() as u64).to_le_bytes());
         h.update(path.as_bytes());
         h.update((content.len() as u64).to_le_bytes());
         h.update(content.as_bytes());
     }
     CacheKey(h.finalize().into())
 }
🤖 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/bex_cache/src/lib.rs` around lines 66 - 96, The
sorted-by-path precondition for KeyInputs::files is only checked with
debug_assert! in compute_key, so release builds can silently produce
order-dependent cache keys if a caller regresses. Update compute_key in
bex_cache::lib to defensively enforce sorting itself, using the existing
KeyInputs::files input before hashing so correctness does not depend on upstream
callers like CacheContext::open preserving order.

342-354: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer tempfile::NamedTempFile over hand-rolled atomic write.

write_atomic derives its temp path from std::process::id() alone, so two same-process, same-key store() calls (e.g. concurrent compiles in a future multi-threaded flow) would race on the identical tmp path. tempfile is already a dependency (dev-only) and its NamedTempFile::new_in(...).persist(...) pattern gives OS-guaranteed unique names plus atomic replace, removing this whole hazard class with less custom code to maintain.

♻️ Proposed refactor using tempfile
-fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
-    let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
-    let result = (|| {
-        let mut f = fs::File::create(&tmp)?;
-        f.write_all(data)?;
-        f.flush()?;
-        fs::rename(&tmp, path)
-    })();
-    if result.is_err() {
-        let _ = fs::remove_file(&tmp);
-    }
-    result
-}
+fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
+    let dir = path.parent().expect("entry path has parent");
+    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
+    tmp.write_all(data)?;
+    tmp.flush()?;
+    tmp.persist(path).map_err(|e| e.error)?;
+    Ok(())
+}

And move tempfile from [dev-dependencies] to [dependencies] in Cargo.toml.

🤖 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/bex_cache/src/lib.rs` around lines 342 - 354,
`write_atomic` currently builds a temp filename from only `std::process::id()`,
which can collide for concurrent writes in the same process; replace this
hand-rolled temp-path logic with `tempfile::NamedTempFile` in `write_atomic` and
persist it atomically to the target path. Update the `bex_cache` crate so
`tempfile` is available at runtime by moving it from dev-dependencies to
dependencies, and keep the `store()`/`write_atomic()` flow using the new unique
temp-file approach.
🤖 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/bex_cache/src/lib.rs`:
- Around line 66-96: The sorted-by-path precondition for KeyInputs::files is
only checked with debug_assert! in compute_key, so release builds can silently
produce order-dependent cache keys if a caller regresses. Update compute_key in
bex_cache::lib to defensively enforce sorting itself, using the existing
KeyInputs::files input before hashing so correctness does not depend on upstream
callers like CacheContext::open preserving order.
- Around line 342-354: `write_atomic` currently builds a temp filename from only
`std::process::id()`, which can collide for concurrent writes in the same
process; replace this hand-rolled temp-path logic with `tempfile::NamedTempFile`
in `write_atomic` and persist it atomically to the target path. Update the
`bex_cache` crate so `tempfile` is available at runtime by moving it from
dev-dependencies to dependencies, and keep the `store()`/`write_atomic()` flow
using the new unique temp-file approach.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b68544f6-6618-4281-8cb4-0105a0c7c6f2

📥 Commits

Reviewing files that changed from the base of the PR and between fd32171 and b9a1729.

⛔ Files ignored due to path filters (3)
  • baml_language/Cargo.lock is excluded by !**/*.lock
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snap is excluded by !**/*.snap
📒 Files selected for processing (13)
  • baml_language/Cargo.toml
  • baml_language/crates/baml_cli/Cargo.toml
  • baml_language/crates/baml_cli/src/bytecode_cache.rs
  • baml_language/crates/baml_cli/src/lib.rs
  • baml_language/crates/baml_cli/src/project_load.rs
  • baml_language/crates/baml_cli/src/run_command.rs
  • baml_language/crates/baml_cli/src/test_command.rs
  • baml_language/crates/baml_compiler2_emit/src/lib.rs
  • baml_language/crates/baml_compiler2_hir/src/lib.rs
  • baml_language/crates/baml_tests/Cargo.toml
  • baml_language/crates/baml_tests/tests/emit_determinism.rs
  • baml_language/crates/bex_cache/Cargo.toml
  • baml_language/crates/bex_cache/src/lib.rs

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 July 6, 2026 21:12 Inactive
@github-actions

github-actions Bot commented Jul 6, 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.

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle July 6, 2026 21:24 Inactive
@github-actions

github-actions Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Binary size checks passed

✅ 7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
✅ baml-cli Linux 🔒 22.6 MB 9.7 MB file 22.3 MB +360.3 KB (+1.6%) OK
✅ packed-program Linux 🔒 16.0 MB 6.7 MB file 15.6 MB +372.2 KB (+2.4%) OK
✅ baml-cli macOS 🔒 17.4 MB 8.4 MB file 17.1 MB +281.5 KB (+1.6%) OK
✅ packed-program macOS 🔒 12.4 MB 5.9 MB file 12.1 MB +282.0 KB (+2.3%) OK
✅ baml-cli Windows 🔒 19.0 MB 8.6 MB file 18.7 MB +302.6 KB (+1.6%) OK
✅ packed-program Windows 🔒 13.3 MB 6.0 MB file 13.0 MB +298.5 KB (+2.3%) OK
✅ bridge_wasm WASM 14.9 MB 🔒 4.2 MB gzip 4.2 MB +15.2 KB (+0.4%) 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

Three independent pieces the per-file relink builds on:

1. Content-addressed class type tags: CLASS_BASE + fnv1a64(fq_name)
   masked to 47 bits (baml_type::typetag::class_type_tag), replacing
   the ordinal counter in both emit Pass 2 and MIR's
   build_class_type_tags — they now agree by construction instead of
   by iteration-order lockstep. A class keeps its tag no matter what
   other code exists, so tags baked into match tables, comparison
   chains, and constants never shift between compiles — eliminating
   tag relocation entirely (and giving dynamically loaded classes
   order-independent tags). Hash collisions are a compile error.
   Codegen note: ordinal tags were small/consecutive, so some type
   switches compiled to a direct sparse jump table; hashed tags route
   these through the dense_tag perfect hash (+1 instruction per type
   match). Full suite passes.

2. bex_vm_types::relink::visit_index_operands: visits every
   cross-function index operand of a compiled Function (GlobalIndex /
   ObjectIndex in instructions, constants, class-init plans). The
   instruction match is exhaustive on purpose — an opcode added with
   an index operand fails compilation here rather than silently
   escaping relocation.

3. baml_cli::file_signature: per-file signature hash = source text
   with FUNCTION_BODY-family node ranges blanked (length-framed gap
   hashing; body content AND length excluded). Body edits keep the
   hash; declaration/type/field/attribute edits change it. TS's
   version/signature split at source level.
Both findings evaluated as legitimate (not false positives):

- compute_key now sorts the file list itself instead of debug_assert-
  trusting callers: an unsorted caller in release would silently
  produce discovery-order-dependent keys (hit-rate loss with no
  signal; never incorrectness — content is fully hashed either way).
- write_atomic temp names gain a per-process atomic counter and
  create_new, so same-process concurrent stores can't truncate each
  other's temp file (the pid-only suffix collided by construction in
  any threaded host); kept dependency-free rather than promoting
  tempfile to a runtime dep.
@vercel
vercel Bot temporarily deployed to Preview – beps July 6, 2026 21:42 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 July 6, 2026 21:48 Inactive
generate_project_bytecode_with_reuse(db, options, opt, base, prev,
clean_files): user files listed clean are spliced from the previous
Program instead of re-lowered — their TIR inference, MIR lowering, and
emit never run (salsa is lazy). Everything else recompiles normally.

Relocation is clone-rebase-remap over the previous program using the
exhaustive operand walker: a clean file's functions+lambdas occupy a
contiguous pool range (grouped by Function::source_file); refs within
the range rebase by delta, GlobalIndex operands remap old slot -> fq
name -> new slot, object refs to classes/enums/interfaces remap by
name. Class type tags are content-addressed and never rewritten. Any
construct the splice can't relocate — plus defensive invariants like
global-slot misalignment — aborts with LoweringError::ReuseUnsupported
so callers fall back to a full (stdlib-spliced) compile.

The relink_oracle suite asserts byte-identity with a full compile
across genuinely index-shifting edits: body-only edit, added function,
added class (class-object translation), added lambda (range growth),
deleted file — plus a probe test planting a marker in prev's cosmetic
Function field and finding it in the relink output, proving the splice
path executes rather than passing vacuously.

Not yet wired into the CLI: invalidation policy (which files are
clean) lands next with the manifest.
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle July 6, 2026 22:01 Inactive
Completes Tier 3. Alongside each project blob the cache now keeps a
manifest (fixed per-project key, overwritten each successful compile):
per file — content hash, signature hash, defined symbol names, and
referenced symbol names extracted from the compiled bytecode via the
operand walker (so desugared references are included).

On a project-key miss, plan_reuse diffs current sources against the
manifest: dirty = changed ∪ added ∪ files whose referenced names
intersect Δ (the last-segment names defined by signature-changed,
added, or removed files — name-level matching deliberately catches
shadowing and impl additions). One round suffices: signature changes
only originate from edits. Everything else is clean and splices;
diagnostics re-check dirty files only (a clean file references nothing
whose signature changed, so its diagnostics match the previous
error-free compile). Fallback chain: relink -> ReuseUnsupported ->
stdlib splice -> plain; files whose previous object range is
non-contiguous ($init let-helpers) are demoted individually instead
of failing the relink. BAML_CACHE_VERIFY now forces the full path so
the tripwire keeps covering key completeness, and BAML_CACHE_DEBUG=1
reports plan sizes and fallback reasons on stderr.

Verified on a 74-file corpus: body edit -> 'reuse plan: 72 clean, 1
dirty', byte-verify passes. Honest numbers: the corpus relink saves
only ~12% wall clock in debug — whole-package analyses that per-file
reuse does NOT yet skip (throw-set graphs walk every function body at
PPIR level, $init lowers every let, build_packages lowers every impl)
dominate this interface-heavy corpus, and 'baml test --list' carries a
~30s deserialize+VM-discovery floor. Those are the named follow-ups;
the correctness machinery (oracle-tested byte-identity, probe-tested
splice execution) is what this PR establishes.
@antoniosarosi antoniosarosi changed the title Bytecode cache + precompiled stdlib: warm runs skip the compiler entirely Three-tier compilation caching: blob cache, precompiled stdlib, per-file relink Jul 6, 2026
throws is inferred from function bodies, so it is interface the
body-blanked signature hash deliberately cannot see: a body edit in
one file can change the TRANSITIVE inferred throws of an untouched
caller in another file — its stored throws_type metadata and, through
catch lowering, potentially its bytecode. Splicing such a file would
reproduce stale metadata (demonstrated: new oracle scenario fails
without this fix by exactly the stale-throws bytes).

The package-wide throw graph is already solved on the relink path, so
the gate costs map lookups per clean-file function: compare each
freshly solved transitive throws against the previous program's
stored throws_type; any mismatch demotes that file to a normal
recompile. No fixpoint needed — the graph is solved globally, so every
affected file's own set differs and demotes independently.
@vercel
vercel Bot temporarily deployed to Preview – beps July 6, 2026 22:52 Inactive
@antoniosarosi antoniosarosi changed the title Three-tier compilation caching: blob cache, precompiled stdlib, per-file relink Incremental compilation: content-addressed bytecode caching with per-file recompilation Jul 6, 2026
@vercel
vercel Bot temporarily deployed to Preview – beps July 6, 2026 22:54 Inactive
…rphaned doc, drop the assertion-free cold-open measurement
Canary replaced the exit-blocking telemetry flush with a durable local
queue, superseding this branch's flush-grace cap. The old telemetry.rs
is deleted in favor of the new module tree; call sites are compatible.
Also applies a nightly-clippy question_mark simplification in the new
project_id walk.

This branch was previously deployed

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