Repository navigation
Escape analysis: native code skips locks for thread-local objects - #5395
harivansh-afk wants to merge 4 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
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 |
⏭️ Performance benchmarks were skippedPerf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to To run them on this PR, do any of the following, then push a commit (or re-run CI):
|
Binary size checks failed❌ 2 violations · ✅ 1 passed
Details & how to fixViolations:
Add/update baselines:
[artifacts.bridge_wasm]
file_bytes = 28991680
gzip_bytes = 8427573
[artifacts.packed-program]
file_bytes = 41630368
gzip_bytes = 16981129Generated by |
185de65 to
c7fe027
Compare
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.
c7fe027 to
0417b93
Compare
|
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. |
|
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:
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.
TLDR
Stacked on #5264. Every array/map access takes a spin lock and every field access is atomic because
spawnlets 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
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.//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$localtwin that reads its receiver without the lock.lock_thread_local/lock_mut_thread_localon containers, Relaxed field load/store,_localcompiled-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.mask: u64. Bitimeans the caller proved argumenti, 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.Results (Spark, aarch64, native)
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 inlinelock cmpxchg, while the default aarch64 target calls an outlined__aarch64_cas1_acq.Safety
rust_differentialadversarial 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.Testing
baml_testscompiler2_mir::escape: classification cases, including recursion soundness and exact mask values.rust_backend, thebaml_testslib 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:
Steps 1 and 2 must run at the same commit: the pack records the toolchain revision.