Semantic Diff

#9296 Evaluate a let before the callee of the call it feeds

2 commits

ed12abd08e215a25e1661a5f4b71c9c513c99d4d..pr-9296

195aca5ee · mununki · Nov 13, 2026, 13:35 UTC

Evaluate a let before the callee of the call it feeds

Why

Lam_util.refine_let rewrote let x = e; f(x) to f(e) whenever f did not mention x. JavaScript evaluates the callee of a call before the arguments, so e then ran after f: a let that read state saw what the callee had changed, and an effect of the let came after the effects of the callee, or after its await. The same happened to the binding Lam_beta_reduce makes for an argument of an inlined function, and to the binding Lam_pass_sroa makes for a field of a local record. The rewrite now applies only when the two commute: one of them has no side effects and reads nothing that may change, or neither has side effects. The rewrite of let x = e; p(x) for a primitive p did the same to the call of an external function or class, which JavaScript reads from its global or module before the argument; it now applies to such a call only when e has no side effects. refine_let takes the assigned variables for this, and motion and conflict move from Lam_beta_reduce_util to Lam_analysis.

modifiedJS let read before a callee that changes it

type counter = {mutable n: int}let c = {n: 0}let next = () => {  c.n = c.n + 1  (x: int) => x}let readBeforeCall = () => {  let before = c.n  next()(before)}
js
Before
After
function readBeforeCall() {function readBeforeCall() { return next()(c.n); let before = c.n; return next()(before);}}

modifiedJS let with an effect before an awaited callee

let effectBeforeAwait = async () => {  let x = log("argument")  (await getHandler())(x)}
js
Before
After
async function effectBeforeAwait() {async function effectBeforeAwait() { return (await getHandler())(log("argument")); let x = log("argument"); return (await getHandler())(x);}}

modifiedJS let that replaces the field the callee is read from

type handler = {mutable call: int => int}let h = {call: x => x + 1}let swap = () => {  h.call = x => x + 100  1}let effectBeforeFieldRead = () => {  let x = swap()  h.call(x)}
js
Before
After
function effectBeforeFieldRead() {function effectBeforeFieldRead() { return h.call(swap()); let x = swap(); return h.call(x);}}

modifiedJS field of a local record

type snapshot = {mutable unused: int, saved: int}let recordField = () => {  let s = {unused: 0, saved: c.n}  next()(s.saved)}
js
Before
After
function recordField() {function recordField() { return next()(c.n); let s_saved = c.n; return next()(s_saved);}}

modifiedJS let that replaces an external function

@scope("globalThis") @val external hook: int => int = "letBeforeCalleeHook"let effectBeforeExternal = () => {  let x = install()  hook(x)}
js
Before
After
function effectBeforeExternal() {function effectBeforeExternal() { return globalThis.letBeforeCalleeHook(install()); let x = install(); return globalThis.letBeforeCalleeHook(x);}}

Review

  • How were the changes tested? let_before_callee_test has nine tests. Compiled with master, eight fail. One of them is for an @new external. inline_arg_order_cross_module_test gains a test that fails with master as well.
  • Which calls still take the value in place? Those where the order cannot matter: let x = swap(); cb(x) with a parameter cb compiles to cb(swap()), and let x = c.n; h.call(x) to h.call(c.n), since neither the field read nor c.n has side effects.
  • Which checked-in output changes? One, in Stdlib_PromiseTest: let s = switch e {...}; resolve(s) keeps s instead of assigning the result of the switch to a tmp, because a switch counts as having side effects and resolve is the external Promise.resolve.
  • Why does a call through a field of an immutable record keep the binding, as in let x = f(); r.call(x)? The field may still change: a record with a mutable field can be coerced to a type where the field is immutable, so f() may assign it through the other type.
  • Where do the assigned variables matter? Lam_pass_sroa makes the Lassigns and is the last Lambda pass, so the set is empty in Lam_pass_deep_flatten, Lam_pass_lets_dce and the inliner today; they compute it anyway, as Lam_pass_remove_alias does. It is not empty where Lam_compile inlines a function of another module under -bs-cross-module-opt, which happens after Lam_pass_sroa. There an argument that reads a local ref, by then an assigned variable, stays bound before a callee that assigns it; the new test in inline_arg_order_cross_module_test covers that.
b54982abb · mununki · Nov 13, 2026, 14:14 UTC

Collect the assigned variables without allocating for each node

Lambda_traverse.assigned_variables walked a term with iter, which allocates a closure for every node. The previous commit calls it in each Lam_pass_deep_flatten pass, which made the compiler allocate 0.6% more on the benchmarks. The walk now passes one callback to shallow_exists. With it, compiling Stdlib_List.res allocates 2,549,873 words where master allocates 2,588,255, since Lam_pass_remove_alias and Lam_pass_lets_dce already called it.