Skip to content

Escape analysis: native code skips locks for thread-local objects - #5395

Closed
harivansh-afk wants to merge 4 commits into
canaryfrom
brr/escape-analysis
Closed

harivansh-afk wants to merge 4 commits into
canaryfrom
brr/escape-analysis

Conversation

@harivansh-afk

@harivansh-afk harivansh-afk commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

TLDR

Stacked on #5264. Every array/map access takes a spin lock and every field access is atomic because spawn lets tasks share the heap. This adds a compile-time analysis that finds objects only one task can reach, and the native backend skips the synchronization for them, including through function parameters via a per-call locality mask. The bytecode consumer is stacked on this as #5407.

How

  • Analysis (baml_compiler2_mir::escape): a per-body unification points-to pass (Steensgaard). There is one node per local, merged by copies, and one contents node for every field and element; it is linear in body size. An object is thread-local when nothing that can reach it ever reaches a spawn, a sys-op, a cell, a global, a throw, an opaque call or a shared object. Calls are seen through per-function summaries, and a recursive component is analyzed as one graph. Anything the pass can't name is shared, so a miss costs a lock, never a race.
  • Builtin effects: //baml:effects(...) on builtins (reads, keeps(p), returns(element|fresh[+p]), compares(keys)) feeds the analysis. A declared array or map method gets a generated $local twin that reads its receiver without the lock.
  • Runtime: lock_thread_local / lock_mut_thread_local on containers, Relaxed field load/store, _local compiled-runtime accessors, and a map driver that takes guards without the lock when a twin started it. Debug builds still try the lock on a thread-local access and panic if another holder has it.
  • Native codegen: it uses the lock-free accessor or twin wherever the analysis proves an object thread-local.
  • Locality mask: a compiled function's direct entry takes mask: u64. Bit i means the caller proved argument i, and everything reachable from it, thread-local. A parameter-dependent access branches on the mask, and calls pass the bits they prove. The boxed entry, used by the VM, the host, closures, interface dispatch and runtime-compiled code, passes 0, so any caller the analysis doesn't see keeps every lock. Conditions on bits no compiled caller can set compile to the locked accessor with no branch.
  • Bytecode is unchanged in this PR.

Results (Spark, aarch64, native)

Workload This PR before the mask, vs the base Mask + pruning ≈ Total
merge-sort-20k 2.19× 1.18× ~2.6×
array-traversal-8k 1.08× 2.11× ~2.3×
quick-sort-3way-1k 1.95× 1.08× ~2.1×
array-build-sum-100k 1.47× 1.01× ~1.5×
live-walk 1.00× 1.33× ~1.3×
allocation low / high retention 1.18× / 1.15× — ~1.15×
richards, call-heavy, GC-heavy ~1.0× 1.00× ~1.0×

The totals multiply two A/B legs (ab.py, 10 interleaved rounds pinned to one core, every trial checked against its expected result). A single base-vs-head run is still to come. On x86 the lock-removal gains are smaller (merge-sort 1.21×): an uncontended CAS there is one inline lock cmpxchg, while the default aarch64 target calls an outlined __aarch64_cas1_acq.

Safety

  • rust_differential adversarial probes share containers between tasks every way the analysis must see: spawn, callee spawn, closure, field, stored deep, map value, map, insert, returned alias, recursion, iterator, a mask from mixed callers, and a mask through a closure. They run in every mode with the debug lock check armed.
  • ThreadSanitizer: 0 races on all probes. Controls: with the classifier forced to call everything thread-local it reports races, and with every mask forced set it reports 8 and 10 races on the mask probes.
  • Sessions: a redefined function does not retarget compiled callers, so summaries stay valid. Structural calls on user classes stay virtual (opaque).

Testing

  • baml_tests compiler2_mir::escape: classification cases, including recursion soundness and exact mask values.
  • rust_backend, the baml_tests lib suite, the BAML test suite at O2.

ThreadSanitizer recipe

The pack strips Cargo overrides and uses the pinned toolchain, so sanitize the generated crate it leaves behind:

# 1. pack the probes (leaves the generated crate in target/tmp/rust-differential)
BAML_RUST_DIFF_PROBES=ThreadLocalParallel cargo test -p baml_tests --test rust_differential -- --ignored
# 2. rebuild it with TSAN
RUSTFLAGS="-Zsanitizer=thread" cargo +nightly build -Zbuild-std --target aarch64-unknown-linux-gnu \
  --release --manifest-path target/tmp/rust-differential/Cargo.toml --target-dir target-tsan
# 3. run each probe, bytecode (0) and compiled (1)
BAML_COMPILED_BODIES=1 target-tsan/aarch64-unknown-linux-gnu/release/baml_rust_program EscapeSpawn

Steps 1 and 2 must run at the same commit: the pack records the toolchain revision.

@vercel

vercel Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
developer-docs Ready Ready Preview Oct 29, 2026 1:59am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.

Comment @coderabbitai help to get the list of available commands.

@github-actions

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.

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Binary size checks failed

❌ 2 violations · ✅ 1 passed

⚠️ Please fix the size gate issues or acknowledge them by updating baselines.

Artifact Platform File Gzip Gated on Baseline Delta Status
✅ baml-cli Linux 🔒 64.3 MB 27.4 MB file 73.1 MB -8.8 MB (-12.0%) OK
❌ packed-program Linux 🔒 41.6 MB 17.0 MB file 29.2 MB +12.5 MB (+42.8%) FAIL
❌ bridge_wasm WASM 29.0 MB 🔒 8.4 MB gzip 5.7 MB +2.8 MB (+48.8%) FAIL

🔒 = 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.

Details & how to fix

Violations:

  • packed-program (Linux) file_bytes: 41.6 MB exceeds limit of 30.1 MB (exceeded by +11.5 MB, policy: max_file_bytes)
  • packed-program (Linux) file_delta_pct: +42.8% exceeds limit of 3.0% (exceeded by +39.8pp, policy: max_delta_pct)
  • bridge_wasm (WASM) gzip_bytes: 8.4 MB exceeds limit of 5.9 MB (exceeded by +2.6 MB, policy: max_gzip_bytes)
  • bridge_wasm (WASM) gzip_delta_pct: +48.8% exceeds limit of 3.0% (exceeded by +45.8pp, policy: max_delta_pct)

Add/update baselines:

.ci/size-gate/wasm32-unknown-unknown.toml:

[artifacts.bridge_wasm]
file_bytes = 28991680
gzip_bytes = 8427573

.ci/size-gate/x86_64-unknown-linux-gnu.toml:

[artifacts.packed-program]
file_bytes = 41630368
gzip_bytes = 16981129

Generated by cargo size-gate · workflow run

@harivansh-afk harivansh-afk changed the title Escape analysis: skip locks for thread-local objects Escape analysis: native code skips locks for thread-local objects Oct 25, 2026
Base automatically changed from brr/native-v0 to canary October 26, 2026 03:13
Containers gain lock_thread_local / lock_mut_thread_local, which skip the
container lock for an object no other task can reach; debug builds still
try the lock and panic if another holder has it. Instance fields gain
Relaxed load and store. The compiled runtime exposes _local accessors for
element, length and field access.

Builtins declare their memory effects with //baml:effects(...): what they
keep, what they return, whether they compare keys. A declared array or map
method gets a generated $local twin that reads its receiver without the
lock; the map driver behind has/get/set/delete/index/get_or_insert takes
its guards without the lock when a twin started it.

(rebased onto canary) Applied without conflicts. Two comments that said
both backends select the $local twins now say the native backend does,
since the bytecode half (#5407) is not part of this branch:
baml_builtins2_codegen/src/types.rs, bex_vm/src/package_baml/map.rs.
Steensgaard-style unification points-to analysis per function body: an
object is thread-local when nothing that can reach it ever reaches a spawn,
a sys-op, a cell, a global, an opaque call or a shared object. Calls are
seen through per-function summaries; a recursive component is analyzed as
one graph. Builtins with declared effects are not opaque.

Each node records the parameters it is thread-local given (needs), and each
call the needs of its arguments, so a caller can tell a callee what it
proved: the basis of the locality mask. A coverage report lists every
container and field access and why a shared one is shared.
The native backend takes the lock-free accessor or builtin twin wherever
the analysis proves an object thread-local. A compiled function's direct
entry takes a locality mask: bit i says the caller proved argument i, and
everything reachable from it, thread-local. Accesses that depend on a
parameter branch on the mask; calls pass the bits they prove. The boxed
entry, through which the VM, the host, closures, interface dispatch and
runtime-compiled code call, passes 0, so a caller the analysis does not see
keeps every lock. Conditions on bits no compiled caller can set compile to
the locked accessor with no branch.

Adversarial probes share containers between tasks every way the analysis
must see (spawn, callee spawn, closure, field, stored deep, map value, map,
insert, returned alias, recursion, iterator, mixed and closure-passed
masks); forcing masks set makes TSAN report races on the mask probes.
@harivansh-afk

Copy link
Copy Markdown
Collaborator Author

Rebased onto canary (98502d2, post #5264 squash). Three commits, zero conflicts; the interpreter, bytecode emitter, opcode table, relink and all bytecode snapshots are byte-identical to canary (the bytecode half, #5407, is closed). Only patch difference from the previous head: two comments that said both backends select the lock-free twins now say the native backend. On this head: nextest over the five touched crates 1591 passed; rust_differential (all probes, bytecode/compiled/GC-stress/VM-frames at three telemetry levels) passed; fmt, clippy and wasm clippy clean; no snapshot changes.

@harivansh-afk

Copy link
Copy Markdown
Collaborator Author

A/B of this head (0417b93) against canary (98502d2) on Spark, cpu 15, thin-LTO packs, 3 trials, median ms/job (min-max). Interpreter row flat, native row:

workload row base candidate ratio
compute/merge-sort-20k rust-off 55.73 (55.66-55.78) 19.29 (19.27-19.39) 0.35
compute/json-aggregate-2k rust-off 27.00 (26.97-27.04) 24.27 (24.10-24.43) 0.90
compute/nbody-20k rust-off 68.39 (68.09-68.84) 64.19 (64.15-64.91) 0.94
classes/method-call-100k rust-off 6.21 (5.85-6.28) 6.49 (6.41-6.60) 1.05
compute/nested-loops-500x500 rust-off 0.26 0.26 1.00
programs/richards-50 rust-off 51.46 51.50 1.00
all six vm-off 0.999 to 1.007

Admission ledger unchanged (1078 admitted, 119 framed, 0 rejected over the six). The method-call cell was measured under load average 11 and will be re-run idle.

The effect declarations added to containers.baml and string.baml move
the stdlib lines these four snapshots render; only line numbers change.
@harivansh-afk

Copy link
Copy Markdown
Collaborator Author

Superseded by #5498: rebased onto canary after #5423, builtin effects as a static table, bytecode consumer included, locality mask kept.

This branch was successfully deployed

1 active deployment
Preview – developer-docs — caf784ea Deployed Oct 29, 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