fix(lower-go): assign each branch its own closure at a disagreeing join - #767
Conversation
|
Posted by Claude on @mparrett's behalf. @nnunley — this came out of a local adversarial review of the PR head. The P1 it raised is real, and reproduced exactly as written: Fixed in dd050cc. Instead of a boolean "derived through a cycle" flag, each resolution now carries the set of still-on-the-stack nids it leaned on, and only results that leaned on none are memoized. A parameter's own back edge is discharged where it is found rather than recorded — That distinction turned out to matter. A plain boolean taint was the first attempt, and it cost a second
StatusGreen on the new head: Still open: re-running For reference, the baseline that leg diverges against is pre-existing: |
|
Posted by Claude on @mparrett's behalf. Parity follow-up, as promised — with the honest version of the answer. The I could not get a trustworthy number out of the untagged leg. It replays the lowering passes through the interpreted engine, its only failure bucket is So the timing question the boolean-taint attempt raised is still open on my side. What does not depend on timing, and is green: If the untagged |
|
Posted by Claude on @mparrett's behalf. Correcting myself on the last paragraph above: I suggested CI as the place to settle the untagged It does not run in CI at all — The useful consequence is that I had conflated two questions that want different instruments:
Running the raised-timeout parity now and will post the counts. The speed half I will flag as unmeasured rather than guess at it. |
|
Posted by Claude on @mparrett's behalf. Parity numbers, measured the way that actually works: ir-stress
So the The count moves Two things I am explicitly not claiming:
|
|
Posted by Claude on @mparrett's behalf. Better evidence for the leg I left unmeasured above, from a machine neither of us controls. I ran
It also retires the caveat I raised earlier: the raised timeout turned out to be a workaround for my machine, not something the measurement needs. On a dedicated runner the default 5000ms budget never fires — 48.71s against 49.36s on the untagged leg — so the counts were never at risk there. The Whole suite runs in roughly 4.5 minutes. I have written that up separately as an infrastructure suggestion rather than piling it onto this PR. |
|
Nice, tight fix — and the One observation for future reference rather than a request to change this PR:
Both are correct now, but there are two bespoke "phi consistency across a possibly-cyclic CFG" algorithms living ~2000 lines apart in this file. Modeling closure identity as a lattice value ( Not proposing it here — this fix is small and well-tested, and the unification wouldn't be an efficiency win (SCCP-style fixpoints do potentially several sweeps vs. one memoized pass here). But if another "compile-time value, no local" category shows up later and needs the same treatment, that's the signal to fold |
|
Reviewed with a dual-engine comparison (bytecode vs The fix works as claimed. The conditional-join case: (loop [i 0 acc []]
(if (< i 4)
(recur (inc i) (conj acc (if (even? i) (fn [] i) (fn [] (- i)))))
(mapv (fn [f] (f)) acc)))On base, native lowering loses branch identity entirely: Related pre-existing defect, not introduced or worsened here, and not blocking this PR: the per-iteration capture value is still wrong in loops on both base and this head. Minimal case: (defn capture-each [n]
(loop [i 0 acc []]
(if (< i n)
(recur (inc i) (conj acc (fn [] i)))
(mapv (fn [f] (f)) acc))))Bytecode: Root cause is adjacent to the code this PR touches but distinct from it: While fixing #766 independently before finding this PR, I also wrote structural unit tests in the |
0d88c4c to
981e765
Compare
nnunley
left a comment
There was a problem hiding this comment.
Reviewed at 981e7655106f5f29698c23aef5f6fa03c7ccbc8c. No blockers found.
The active-stack-before-memo ordering, dependency-carrying cycle sentinel, and dependency-free cache gate preserve the loop fixed-point invariant without leaking path-dependent answers. Disagreeing joins retain a real local, and the destination-only edge screen lets each incoming branch materialize its own closure.
Regression coverage exercises distinct closures, closure-versus-nil, branch-local capture, and both retain/replace paths for a loop-carried closure. The generated Go pins the per-branch assignments and the semantic mutants die.
Local validation:
make loweredmake native-entry-gatego build -tags gogen_ir ./...
All passed. GitHub checks are also green on the reviewed head.
Residual non-blocking risk: memo query order and mutually recursive multi-parameter SCCs are not directly unit-tested. Malformed anchorless CFG cycles and cycles through incomplete push-closed construction remain outside source-producible builder invariants; no valid counterexample was found.
A closure that captures a local cannot be lifted to a top-level fn, so it
lowers to an inline rt.BoxNativeFn expression. Closures are compile-time
entities in this pass: they carry no runtime Go local, and closure-expr
re-materializes them at each use site.
closure-info*'s :block-arg case resolved a block parameter with `some`,
taking the first incoming edge that resolved to a closure. At an if-join
whose edges disagree — two different fn literals, or a closure against nil
— the parameter was still classified as a closure value, so it got no
local and both edges skipped assignment. The result was an empty
`if {} else {}` with the then-branch closure hoisted to the enclosing
function's unconditional return: the other branch was dropped, and a
closure over a let-binding that only one branch established escaped with
that binding unset.
The lowering builds and, unless it happens to strand a dead temporary, Go
does not reject it — so the wrong branch is returned silently.
Resolve every incoming edge instead, and treat the parameter as a closure
value only when they unanimously agree. A disagreeing join now falls back
to a real local, and emit-assignments-for-target no longer screens the arg
side, so each edge materializes its own rt.BoxNativeFn into that local.
The visited guard yields a :cycle sentinel rather than nil so unanimity can
tell a loop's back edge apart from an edge that genuinely carries no
closure. Without that distinction a loop-carried closure would lose the
local-free treatment the lineage walk exists to provide (#266). :cycle is
path-dependent, so it is never written to the per-function memo.
Verified with a native-entry fixture covering all three disagreeing joins:
it lowers through scripts/lg-compile --entry-frame, matches a committed AST
shape, builds, and prints byte-exact output that the pre-fix lowering got
wrong.
Fixes #766
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of the #766 fix found that it reintroduced the same silent miscompile through the cache. unanimous-closure sets a :cycle source aside instead of counting it as disagreement, so a disagreeing join nested inside a loop resolved its back edge to :cycle, dropped it, and returned a concrete closure. That answer holds only for a walk with the loop header on the stack, but closure-info* excluded a result from the memo only when it was literally :cycle — so the concrete one was cached as if it were path-independent, and every later query reused it. A loop-carried closure that a conditional either replaces or retains lost the retain branch: (defn choose-loop [flag x y] (loop [f (fn [] x) i 0] (if (< i 1) (recur (if flag (fn [] y) f) (inc i)) (f)))) (choose-loop false 1 2) returned 2 instead of 1, from a binary that built and exited 0. Carry the set of still-on-the-stack nids an answer leaned on, and cache only answers that leaned on none. A parameter's own back edge is discharged where it is found rather than recorded: `p = agree(inits…, p)` has `agree(inits…)` as its fixed point, which is a property of the block graph and not of the walk. That keeps the ordinary loop-carried closure fully cacheable — a plain boolean taint would have locked it out of the memo too, and the per-param-per-edge re-walk of shared block-arg lineage is what made lowering time quadratic to begin with (#266). Add a native-entry fixture covering conditional replacement and retention of a loop-carried closure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
981e765 to
9850437
Compare
Fixes #766.
Why
A closure that captures a local cannot be lifted to a top-level
fn, so it lowers to an inlinert.BoxNativeFn. When such a closure was a branch of a conditional, gogen emitted it as the enclosing function's unconditionalreturnand collapsed the conditional to an emptyif {} else {}, dropping the other branch:(both-branches false 1 2)returned thethenclosure.(if flag (fn [] x) nil)returned a closure instead ofnil, and a closure over aletbinding that only the taken branch established escaped with that binding unset.The failure is silent. Go rejects the result only when the lowering happens to strand a dead temporary; otherwise the binary builds, exits 0, and returns the wrong branch's value. In the reported case one function failed with
declared and not used; a sibling of the same shape compiled clean and had wrong semantics in the native binary, so "it builds" was not evidence the lowering was right.Root cause
Closures are compile-time entities in this pass: they carry no runtime Go local, and
closure-exprre-materializes them at each use site.closure-info*resolved the:block-argcase withsome, taking the first incoming edge that resolved to a closure. At a join whose edges disagree — two differentfnliterals, or a closure againstnil— the parameter was still classified as a closure value, so it got no local and both edges skipped assignment. The single use site then materialized whichever closure came first.Non-capturing closures were unaffected: they lift to top-level
fns, so the edges carry:load-varnodes and the join gets an ordinary local.What changed
Resolve every incoming edge, and treat the parameter as a closure value only when they unanimously agree. A disagreeing join falls back to a real local.
emit-assignments-for-targetno longer screens the arg side, so each edge materializes its ownrt.BoxNativeFninto that local. A template threaded into a DCE-killed param is still skipped by the existinglive?and const-param tests on the param.The visited guard yields a
:cyclesentinel rather thannil, so unanimity can tell a loop's back edge apart from an edge that carries no closure. Without that distinction a loop-carried closure would lose the local-free treatment the lineage walk exists to provide (gogen_ir: residual timeouts + unresolved-dep symbols in lowering #266).A
:cyclesource is set aside rather than counted as disagreement, which makes the surviving answer true only for a walk with that loop header on the stack. Each result therefore carries the set of still-on-the-stack nids it leaned on, and only results that leaned on none are memoized. A parameter's own back edge is discharged where it is found instead of recorded:p = agree(inits…, p)hasagree(inits…)as its fixed point, which is a property of the block graph rather than of the walk, so the ordinary loop-carried closure stays fully cacheable.A plain boolean "derived through a cycle" flag is not enough in either direction. Excluding only a literally-
:cycleresult caches a path-dependent concrete answer, and a disagreeing join nested in a loop then loses a branch the same way gogen: a capturing closure inside a conditional is emitted as the function's return value, discarding the branch #766 did —(choose-loop false 1 2)in the fixture below returned2instead of1. Excluding every cycle-derived result instead locks the ordinary loop-carried closure out of the memo, and the per-param-per-edge re-walk of shared block-arg lineage is what made lowering time quadratic to begin with (gogen_ir: residual timeouts + unresolved-dep symbols in lowering #266).pkg/rt/generated.manifestandpkg/rt/generated.sumsare refreshed becauselower_go.lgis a declared generator input (#765).Verification
Two new native-entry fixtures.
test/native-entry/conditional_closure.lgcovers the acyclic disagreeing joins: closure against a different closure, closure againstnil, and a closure over aletbinding only one branch establishes.test/native-entry/loop_carried_closure.lgcovers a loop-carried closure that a conditional either replaces or retains, which is the case the memo rule gets wrong. Each lowers through the production path, matches a committed AST shape pinning the per-branch assignment, builds, and prints byte-exact output that the pre-fix lowering got wrong. Both semantic mutants die, so the assertions are not vacuous.make native-entry-gate,make test, andmake gogen-diffpass; the engine-parity gate reports 0 output divergence.make parity-fullreports thelower-goleg as diverged, but that is pre-existing:mainshows the same single:stress/timeoutfailure and the same bucket hashd88d9278. This branch passes two more assertions thanmainon that leg, and single-run wall time was slightly lower on both engines.