Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/perf/ir-stress-bytecode-baseline.edn
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{:failed 719
:total 2466
:passed 1747
:captured "2026-07-22"
{:failed 496
:total 2468
:passed 1972
:captured "2026-08-04"
:how "LG_STRESS_PASSES=1 make ir-stress-bytecode-gate (corpus = every shipped/test/example .lg, LG_STRESS_BACKEND=lower)"
:note
"BYTECODE-path lowering coverage — the backend `(set! *ir-compile* true)`\n actually drives at runtime. Sibling of docs/perf/ir-stress-baseline.edn,\n which measures lower-go (IR->Go) and is NOT a proxy for this one: the two\n backends fail on different things, so a form can lower natively to Go and\n still fail to bytecode. First capture of a path that had no census.\n\n The gap is large: 713/2432 fail here (70% coverage) against 11 failures\n (99.5%) on lower-go. The buckets say why, and :lower/not-on-stack/* (535)\n is self-inflicted rather than inherent.\n\n The IR is indexed RPN. :insts is a vector indexed by InstId, and every\n operand in :refs is already an InstId — the index is in hand at the moment\n of lowering. lower.lg discards it and reconstructs a STACK DEPTH instead\n (value-stack-pos, DUP_NTH n). Depth is fragile where the index is stable:\n it shifts as the stack moves, and a value defined in another block or\n displaced by an earlier operand's materialization has no computable depth\n at all. That is the failure. The VM offers no alternative today — its\n entire addressing vocabulary is LOAD_ARG / LOAD_CLOSEDOVER (read-only,\n indexed) and DUP_NTH (depth-addressed), and Frame carries no writable\n slots — so lowering has nowhere to spill what it cannot keep in position.\n\n Give the frame indexed slots and the class dissolves: InstId maps straight\n to a slot, block-args become 'both predecessors write the same slot', and\n the DUP_NTH copies (with the junk-below they strand and the RECUR that\n cleans it up) stop being needed. EPIC-016 is that unblock; EPIC-017 (#574)\n and EPIC-018 (#575) build on it.\n\n :lower/unsupported-op/:try (144) is a separate gap — the bytecode lowerer\n has no emission case for the form at all.\n\n Same ratchet rule as its sibling: only tightens. A rise is a real coverage\n regression on the runtime path; never loosen without investigating."
:buckets
{":lower/not-on-stack/block-arg" 20, ":lower/not-on-stack/call" 428, ":unresolved/a" 1, ":lower/not-on-stack/gt" 2, ":lower/not-on-stack/try" 3, ":lower/not-on-stack/eq" 68, ":lower/unsupported-op/try" 145, ":lower/unsupported-op/div" 21, ":lower/not-on-stack/lte" 2, ":lower/not-on-stack/lt" 6, ":lower/not-on-stack/gte" 14, ":build/unrecognized-form" 1, ":unresolved/n" 7, ":unresolved/read-json" 1}}
{":lower/not-on-stack/block-arg" 99, ":lower/not-on-stack/call" 69, ":unresolved/a" 1, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 1); unsupported shape" 6, ":other | ir/lower: junk-below mismatch for block 22 (0 vs 2); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (0 vs 2); unsupported shape" 4, ":other | ir/lower: junk-below mismatch for block 12 (0 vs 2); unsupported shape" 6, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 3); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 2); unsupported shape" 8, ":other | ir/lower: junk-below mismatch for block 18 (0 vs 2); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 6 (1 vs 0); unsupported shape" 4, ":other | ir/lower: junk-below mismatch for block 6 (0 vs 5); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 9 (3 vs 0); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 12 (3 vs 0); unsupported shape" 5, ":other | ir/lower: junk-below mismatch for block 3 (1 vs 0); unsupported shape" 10, ":other | ir/lower: junk-below mismatch for block 3 (3 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (1 vs 0); unsupported shape" 3, ":other | ir/lower: junk-below mismatch for block 12 (1 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (2 vs 0); unsupported shape" 4, ":lower/unsupported-op/try" 157, ":lower/unsupported-op/div" 21, ":other | ir/lower: junk-below mismatch for block 15 (0 vs 5); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (0 vs 6); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 3 (9 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 6 (2 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (4 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 12 (4 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 21 (0 vs 2); unsupported shape" 5, ":other | ir/lower: junk-below mismatch for block 39 (0 vs 1); unsupported shape" 1, ":lower/not-on-stack/inc" 1, ":other | ir/lower: junk-below mismatch for block 6 (0 vs 2); unsupported shape" 9, ":other | ir/lower: junk-below mismatch for block 45 (0 vs 26); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 12 (0 vs 7); unsupported shape" 1, ":build/unrecognized-form" 1, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 4); unsupported shape" 4, ":other | ir/lower: junk-below mismatch for block 4 (1 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (0 vs 5); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 15 (2 vs 0); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 24 (0 vs 2); unsupported shape" 1, ":unresolved/n" 7, ":other | ir/lower: junk-below mismatch for block 12 (0 vs 3); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 6 (0 vs 1); unsupported shape" 12, ":unresolved/read-json" 1, ":other | ir/lower: junk-below mismatch for block 9 (0 vs 1); unsupported shape" 10, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 29); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 12 (0 vs 1); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 15 (0 vs 2); unsupported shape" 3, ":other | ir/lower: junk-below mismatch for block 3 (2 vs 0); unsupported shape" 14, ":other | ir/lower: junk-below mismatch for block 15 (0 vs 3); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 15 (4 vs 0); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 5); unsupported shape" 1}}
140 changes: 115 additions & 25 deletions pkg/rt/core/ir/lower.lg
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@
;; minus 1 below the target's params; OP_RECUR/clean BRANCH
;; leave 0). Used to compute true runtime SP for max-stack.
:block-junk (vec (repeat n-blocks 0))
;; Exact junk each pred reported per target (nil = none yet);
;; disagreement aborts lowering — see record-block-junk!.
:block-junk-seen {}
:current-block 0})))

;; Field accessors that read the atom — keep call sites tidy.
Expand Down Expand Up @@ -126,11 +129,26 @@
(get (:block-junk @l) bid 0))

(defn- record-block-junk! [l bid junk]
"Record (or raise) junk-below for block bid. Multiple predecessors may
set this; take the max."
(let [cur (block-junk-of l bid)]
(when (> junk cur)
(swap! l update :block-junk assoc bid junk))))
"Record junk-below for block bid. Every fall-through predecessor must
agree: junk is baked into the target's OP_RECUR drop counts, so a
path entering with different junk over- or under-drops the runtime
stack (observed as `slice bounds out of range` underflows). The old
take-the-max rule was harmless only while mismatching shapes could
not lower at all; the deferral/RPO fixes let them lower, so a
disagreement now aborts this fn's lowering (the defn hybrid falls
back to the plain bytecode compiler, exactly as before). RECUR-edge
preds report zero junk: they rebuild the target's stack from scratch,
and registering that zero is recorded so disagreement with another
pred's nonzero junk is caught (→ fallback) instead of silently accepted."
(let [seen (:block-junk-seen @l)
cur (get seen bid)]
(if (nil? cur)
(do (swap! l update :block-junk-seen assoc bid junk)
(when (pos? junk)
(swap! l update :block-junk assoc bid junk)))
(when (not= cur junk)
(throw (str "ir/lower: junk-below mismatch for block " bid
" (" cur " vs " junk "); unsupported shape"))))))

(defn- bump-max-stack! [l]
"If current runtime SP (= lower-sp + current block's junk-below)
Expand Down Expand Up @@ -571,6 +589,11 @@
;; branch-if's leftovers) leaks `junk` slots per
;; iteration until the frame stack overflows.
ignore (+ (- drop-count argc) cur-junk)
;; RECUR rebuilds the target's stack from scratch: junk 0.
;; Register that as this pred's exact junk report so a
;; mismatch with another pred's nonzero junk is caught
;; (→ fallback) instead of silently over-dropping.
_ (record-block-junk! l target 0)
off-ip (ir/chunk-emit-recur (chunk-of l) cur-sp argc ignore)]
(record-source-info! l nid)
(add-patch! l {:src-ip (dec off-ip)
Expand Down Expand Up @@ -615,9 +638,10 @@
:offset-slot 1
:negate? false})
(bump-stack-sp! l -1) ; cond popped
;; If true-target is the immediately-following block, fall through.
;; If true-target is the next block in EMISSION order (RPO, not
;; id order), fall through; otherwise jump explicitly.
(let [my-block (ir/block-of nid f)
next-block-id (inc my-block)]
next-block-id (get (:next-of @l) my-block -1)]
(when (not= tt-target next-block-id)
(let [arg-ip2 (emit-placeholder! l nid :branch)]
(add-patch! l {:src-ip (dec arg-ip2)
Expand Down Expand Up @@ -724,20 +748,38 @@

(defn- deferrable-branch-if-cond? [l term cond-ref]
"True iff cond-ref is safe to defer past the body walk: it has
exactly one use (use-count==1) and that user is the branch-if
terminator itself. build-if always produces such conds; only
hand-built IR could violate this."
exactly one use (use-count==1), that user is the branch-if
terminator itself, AND it is not also one of the terminator's
branch-target args. The last condition matters because the use
bitset counts USERS, not uses: a value that is both the cond and
a branch arg (e.g. `(let [v (f x)] (when v ...) v)` threads v as
the join's block-arg) still reads as one user, but the terminator
materializes the branch args BEFORE the deferred cond — asking for
a value whose emission was skipped. For non-cheap ops (:call) that
is an ir/lower throw and the whole fn silently falls back to the
plain bytecode compiler."
;; cond-ref is typed as native int (InstId) by gogen; :branch-if
;; always has exactly one ref so cond-ref is never nil here.
(and (= 1 (use-count-of l cond-ref))
;; use-count==1 → bitset has exactly one user-id; read via
;; ir/uses-first (bitsets are vec-of-int64-words, so raw
;; `first`/`count` would return a word and the word-count).
(let [uses (:uses @l)
us (when (< cond-ref (count uses)) (nth uses cond-ref))]
(and us
(not (ir/uses-empty? us))
(= term (ir/uses-first us))))))
(and
;; A :block-arg cond must NOT defer: deferral means "emit at the
;; terminator", but emitting a :block-arg emits nothing (its value
;; sits at a param slot) — BRANCH_F would then pop whatever is on
;; top (usually a just-materialized branch arg) as the cond. The
;; normal path DUPs it from its slot correctly.
(not= :block-arg (ir/op cond-ref (f-of l)))
(= 1 (use-count-of l cond-ref))
;; use-count==1 → bitset has exactly one user-id; read via
;; ir/uses-first (bitsets are vec-of-int64-words, so raw
;; `first`/`count` would return a word and the word-count).
(let [uses (:uses @l)
us (when (< cond-ref (count uses)) (nth uses cond-ref))]
(and us
(not (ir/uses-empty? us))
(= term (ir/uses-first us))))
;; Not a branch arg of its own terminator (targets are symmetric
;; by validate-fn!, so checking the true side suffices).
(let [tt (ir/cond-target-true (ir/aux term (f-of l)))]
(not (some (fn [a] (= a cond-ref)) (ir/branch-target-args tt))))))

(defn- lower-block! [l bid]
(swap! l assoc :current-block bid)
Expand Down Expand Up @@ -848,6 +890,50 @@
(+ src-ip (:offset-slot p))
offset))))

;; --- emission order --------------------------------------------------

(defn- block-succs [f bid]
(let [term (ir/block-term bid f)]
(if (or (nil? term) (zero? term))
[]
(let [op (ir/op term f)
aux (ir/aux term f)]
(cond
(= op :branch)
[(ir/branch-target-target aux)]
(= op :branch-if)
[(ir/branch-target-target (ir/cond-target-true aux))
(ir/branch-target-target (ir/cond-target-false aux))]
:else [])))))

(defn- rpo-block-order [f]
"Reverse-postorder over the CFG from the entry block. Emission MUST
follow an order where every non-back edge points forward: a block's
junk-below (values a pred's BRANCH_F leaves beneath its params) is
recorded by the pred's lowering, so lowering a block before one of
its preds bakes in a stale junk count — its RECUR under-drops and
every later slot reference shifts (miscompile, e.g. a loop whose
cond is an `and`: build-loop gives the body a LOWER id than the
cond-chain blocks). Raw id order only worked for CFGs where preds
happen to have lower ids. Back-edge preds are exempt: OP_RECUR
rebuilds the stack, junk 0. Successors are visited highest-id-first
so the order coincides with id order whenever id order was already
valid (keeps existing output byte-stable). Unreachable blocks are
appended in id order so block-ips stays total."
(let [n (count (ir/blocks f))
state (atom {:visited #{} :post []})]
((fn dfs [b]
(when-not (contains? (:visited @state) b)
(swap! state update :visited conj b)
(doseq [s (reverse (sort (distinct (block-succs f b))))]
(dfs s))
(swap! state update :post conj b)))
0)
(let [order (vec (reverse (:post @state)))
seen (set order)]
(vec (concat order
(filter (fn [b] (not (contains? seen b))) (range n)))))))

;; --- entry point -----------------------------------------------------

(defn lower [f]
Expand All @@ -858,12 +944,16 @@
(throw "ir/lower: nil function"))
(let [l (new-lowerer f)]
(check-cross-block! f (:uses @l))
(let [n-blocks (count (ir/blocks f))]
(loop [bid 0]
(when (< bid n-blocks)
(set-block-ip! l bid (ir/chunk-length (chunk-of l)))
(lower-block! l bid)
(recur (inc bid)))))
(let [order (rpo-block-order f)
next-of (loop [i 0 acc {}]
(if (>= i (dec (count order)))
acc
(recur (inc i)
(assoc acc (nth order i) (nth order (inc i))))))]
(swap! l assoc :next-of next-of)
(doseq [bid order]
(set-block-ip! l bid (ir/chunk-length (chunk-of l)))
(lower-block! l bid)))
(patch-branches! l)
;; Conservative max-stack guard: bump by the max number of params
;; across all blocks. The block-junk tracking above handles the
Expand Down
2 changes: 1 addition & 1 deletion pkg/rt/generated.sums
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# Content digest of all .lg + lgbgen sources that feed the .lgb
# bundle and the lowered Go tree. The genmanifest staleness test
# fails if this no longer matches the sources on disk.
88ee4dbce3dd12ab8a3e9fac6e2b9dfcae175e1ff7602b99cae4f8c6295c6ee3
6e713ff2c9b821fed54494b2a429d24319ac79664bc5b623c08c7dbe9874dee1
92 changes: 92 additions & 0 deletions test/ir_and_cond_rpo.lg
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
;; Test: and-cond loop RPO fix
;;
;; The RPO (reverse-postorder) block-order fix ensures blocks are emitted in an
;; order where every non-back edge points forward. Loops with (and ...) conditions
;; create a CFG where the old id-order emission was wrong: the (and ...) creates
;; multiple conditions that must be evaluated in the correct order, and the short-circuit
;; logic creates a specific CFG structure that requires RPO ordering.
;;
;; The test constructs loops with (and ...) and (or ...) conditions that expose
;; the block-ordering issue.
(ns test.ir-and-cond-rpo-test
(:require
[ir.passes.pipeline]
[test :refer :all]))

;; Bytecode path: loop with (and ...) condition
(defn and-loop-bytecode [n]
(loop [i 0 s 0]
(if (and (< i n) (>= s 0) (not (neg? i)))
(recur (inc i) (+ s i))
s)))

;; IR-compile path: should match bytecode
(set! *ir-compile* true)

(defn and-loop-ir [n]
(loop [i 0 s 0]
(if (and (< i n) (>= s 0) (not (neg? i)))
(recur (inc i) (+ s i))
s)))

(set! *ir-compile* false)

;; Additional shape: loop with (or ...) condition for short-circuit testing
(defn or-loop-bytecode [n]
(loop [i 0 s 0]
(if (or (>= i n) (< s 0))
s
(recur (inc i) (+ s i)))))

(set! *ir-compile* true)

(defn or-loop-ir [n]
(loop [i 0 s 0]
(if (or (>= i n) (< s 0))
s
(recur (inc i) (+ s i)))))

(set! *ir-compile* false)

;; Complex nested conditionals creating complex CFG
(defn nested-cond-bytecode [n]
(loop [i 0 s 0]
(if (< i n)
(if (even? i)
(if (< s 100)
(recur (inc i) (+ s i))
s)
(recur (+ i 2) s))
s)))

(set! *ir-compile* true)

(defn nested-cond-ir [n]
(loop [i 0 s 0]
(if (< i n)
(if (even? i)
(if (< s 100)
(recur (inc i) (+ s i))
s)
(recur (+ i 2) s))
s)))

(set! *ir-compile* false)

(deftest ir-and-cond-rpo-parity
(testing "and-cond loop under *ir-compile* with multiple guards"
;; The IR path should emit blocks in RPO order, producing the same result as bytecode
(is (= (and-loop-bytecode 5) (and-loop-ir 5)))
(is (= 10 (and-loop-ir 5))) ; 0+1+2+3+4
(is (= (and-loop-bytecode 100) (and-loop-ir 100)))
(is (= 4950 (and-loop-ir 100))))
(testing "or-cond loop with short-circuit"
;; Exit condition using (or ...)
(is (= (or-loop-bytecode 10) (or-loop-ir 10)))
(is (= 45 (or-loop-ir 10))) ; 0+1+2+...+9
(is (= (or-loop-bytecode 100) (or-loop-ir 100))))
(testing "nested conditionals creating complex CFG"
;; Multiple nested ifs create a more complex block structure
(is (= (nested-cond-bytecode 20) (nested-cond-ir 20)))
;; Accumulates even numbers until sum >= 100
(is (= (nested-cond-bytecode 100) (nested-cond-ir 100)))))
Loading
Loading