Skip to content

feat(ir): fold branches with a known test; infer self-recursive returns - #1087

Merged
nooga merged 4 commits into
mainfrom
feat/ir-branch-fold
Oct 22, 2026
Merged

nooga merged 4 commits into
mainfrom
feat/ir-branch-fold

Conversation

@nooga

@nooga nooga commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Why

Niva compiles fib to Clojure as a cond with an :else arm. Lowered to Go with lg compile, it ran at 2.02× the time of Niva's own direct-to-Go output. The lowered fn had an unboxed int64 param but returned a boxed vm.Value, and added with rt.AddValue. Two things caused that:

  1. :else is never folded. cond expands its last arm to (if :else x nil). build-if emits a :branch-if for it, and neither the IR passes nor the bytecode ifCompiler drops the arm that can't run. So a constant nil keeps feeding the join, and the fn's return type becomes int-or-nil.
  2. Self-recursive returns are never inferred. A self-call types as :unknown unless the fn has a return hint (AOT lowering: returns stay boxed (vm.Value + rt.AddValue) even for recursive int fns — ~3× off pure Go #599/feat(ir): seed self-recursive calls from a declared return type (#599) #662), so (+ (fib …) (fib …)) stays boxed either way.

The ^long hint was also broken in this exact shape. The self-calls were typed int64 from the hint, while the fn's real Go return type was vm.Value (because of the nil arm), and the generated Go did not compile:

cannot use 1st function result (value of interface type vm.Value) as int64 value in multiple assignment

What changes

  • ir.passes.branchfold (new). A :branch-if whose test is a constant, or whose inferred type is always truthy or always falsey, becomes a :branch. A type counts only when it doesn't rest on an argument's: tests that trace back to a :load-arg (through block params, type-copying calls like aset, or self-calls) keep both arms, because infer-arg-types types a param :int just because it feeds arithmetic. The changes:

    • Blocks the entry no longer reaches are detached.
    • Single-predecessor chains are spliced.
    • The test inst stays, so an effectful test still runs; DCE drops it only if it is pure.

    It runs after constfold-1 and again after constfold-3. When it changes something, it resets types and runs a typeinfra epoch straight away. That has to happen because typeinfer's facts only widen from their seed, so a join that lost an edge would otherwise keep its old, wider type. uniform-pass-list (lambda-lifted siblings) folds without re-inferring, which keeps their uniform ABI.

  • Self-call return types are a guess the body has to confirm. The guess is the declared return type if there is one. With ir.passes.pipeline/*infer-self-returns* bound true, an unhinted fn also tries :int, then :float. A guess is kept only if every return value then lowers to that Go type (lower-go/returns-as?). That's sound by induction on call depth: the fn really returns T, which is what each self-call site assumed. A contradicted guess is dropped and the self-calls stay boxed. This fixes the ^long miscompile above.

    • A self-call must load the same var (compared by resolving it, so core/conj inside an (ns foo) (defn conj …) wrapper doesn't count) and land on the same arity (one arity of a multi-arity defn calling another doesn't count; variadic fns are left out).
    • The check runs the same way whether or not the ns declares any return hints. That matters because collect-ns-exports records cross-package signatures without the declared-types binding.
  • pkg/compiler. An if whose test is a scalar literal (keyword, number, string, char, boolean, nil) skips the test and the BRANCH_FALSE. The arm that can't run is still compiled, behind a JUMP, so its compile errors surface exactly as before.

Return inference stays opt-in

An earlier revision made the unhinted guesses the default, which reversed #662's opt-in. Review showed that a guessed int64 return moves unhinted recursive arithmetic from checked rt.AddValue onto Go's wrapping +, so it would silently overflow where it used to throw. The guesses are now behind *infer-self-returns* until checked integer lowering lands. TestUnhintedRecursionStillBoxes is back, and the inference tests opt in. Declared return types are still checked against the body, which fixes the ^long miscompile.

Position on the three paths

  • Bytecode compiler: literal-test fold only. The core .lgb shrinks from 321,667 to 320,991 bytes.
  • *ir-compile*: gets branchfold and the return guess through optimize-fn.
  • -tags gogen_ir / lg compile: same pipeline. This is where the boxing goes away for declared returns, or unhinted ones with *infer-self-returns* on.

Numbers

Niva's fib(36) benchmark program, Apple M1 Pro, hyperfine. Both builds are make generate && make build with #1085 stacked on top, because Niva's runtime needs those shims. They differ only in this PR.

main + #1085 this PR + #1085 Niva → Go
AOT (lg compile), 20 runs 155.6 ms ± 1.4 90.0 ms ± 1.4 77.0 ms ± 1.1
interpreter, 5 runs 5.052 s ± 0.015 4.857 s ± 0.037

These numbers were measured with the unhinted guesses on by default. With them opt-in, unhinted fib gets the :else fold but keeps a boxed return unless it declares ^long or opts in. These are same-machine runs, so read them as the ratio and the mechanism, not as ratchet claims. make bench-ratchet passes its deterministic gate (allocs and bytes within 2%, IR compile included). There is no timing baseline for this machine profile, so CI's runners will have to do the timing check.

Verification

  • New test/ir_branchfold_test.lg, which checks both the folded shape and that the IR-compiled fn returns the same as the VM-compiled one. Cases:
    • cond … :else, with and without recursion;
    • falsey and truthy literals;
    • a test folded by type;
    • a nested dead arm;
    • a fold inside a loop;
    • an effect in a type-folded test, which still runs once;
    • bytecode literal tests, including compile errors in the dead arm.
  • pkg/ir/lisp_declared_return_test.go: unhinted inference, cond … :else (no vm.Keyword("else") survives), and a contradicted ^long that stays boxed.
  • Ran locally, all green:
    • make test
    • go test -short ./...
    • go test ./test/ -run TestClojureTestSuite
    • go test -tags gogen_ir ./pkg/ir
    • go test -tags bootstrap -short ./...
    • the CI e2e lists
    • make lowered && make test SHORT=false PKG=./test/e2e RUN="TestEngineParityGate|TestGogenAOTDiff"
    • make check-selfhost
    • make check-generated
    • make check-generated-manifest

Not in this PR

Typed int64 +/-/* lower to raw Go operators, which wrap on overflow, while the VM throws integer overflow. That predates this PR (an unhinted (- n 2) on an int param already lowers raw), but return inference means more code takes that path. It should get its own fix.

🤖 Generated with Claude Code

@nooga nooga left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review: blocking before merge.

The branch fold trusts param types that come from how a param is used. known-truth in pkg/rt/core/ir/passes/branchfold.lg folds a :branch-if whenever the test's inferred type is always truthy. But infer-arg-types gives a param :int just because the param is used in +/* or a comparison. That is the same fact #1076 runs into. So the else arm gets deleted and nil/false arguments throw:

(require 'ir.passes.pipeline)
(binding [*ir-compile* true]
  (eval '(defn g1 [n] (if n (+ n 1) 0)))
  (eval '(defn g2 [x] (when x (* x 2)))))
(g1 nil) (g1 false) (g2 nil)

This PR: cannot add nil and Int, cannot add Boolean and Int, cannot multiply nil and Int. Bytecode and main+#1076: 0 0 nil.

Fix: fold by type only on facts that hold (constants, results of typed ops, the nil/false types). Don't fold on types inferred for args, including tests derived from a LoadArg. Add g1/g2 as regressions in test/ir_branchfold_test.lg.

Other notes:

  • truthy-type? and falsey-type? both return true for an empty [:union] (every? over an empty seq). Harmless today because truthy is checked first, but fragile now that the answer decides CFG deletion.
  • Turning return inference on by default reverses #662's opt-in. The body flags that it needs owner sign-off, so record that sign-off explicitly in this thread once the fold is fixed.
  • The literal-test fold in ifCompiler and the self-recursive guess-and-confirm look sound. fib, float/string/nil-returning recursions, a str-wrapped recursion and cond+:else all match bytecode.

@mparrett mparrett added the perf Run the perf A/B benchmark check label Oct 21, 2026

@mparrett mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend fixing both findings below before merging. Branch folding changes which arm executes for a valid nil input, and enabling recursive return inference by default introduces silent integer overflow in previously checked code.

Reviewed c98e7cf against base b825882. Build and smoke checks passed. The new branchfold suite passed 9 tests / 39 assertions, and the four focused recursive return inference tests passed. Both regressions were reproduced against the PR and its base.

Comment thread pkg/rt/core/ir/passes/branchfold.lg Outdated
(let [o (ir/op nid f)]
(cond
(= :load-arg o) true
(or (not= :block-arg o) (contains? seen nid)) false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Trace argument-derived types through value-preserving calls

The argument-origin guard stops at any op other than load-arg/block-arg, but type inference also propagates an argument's type through aset, which returns its value argument unchanged. For (defn f [a x] (if (aset a 0 x) (+ x 1) :no)), arithmetic use infers x as :int, then aset inherits :int and its branch is folded to true. Calling (f (object-array 1) nil) returns :no through ordinary bytecode and through the base revision's IR compiler, but throws "cannot add nil and let-go.lang.Int" through this revision's strict IR compiler. Please preserve the branch when a result's inferred type depends on an unproven argument type, including through aset, and add this case to the VM/IR parity tests.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b829168. arg-derived? now follows the calls typeinfer copies a type through. That set comes from the new typeinfer/type-source (aset → its value argument, plus the transient ops, aclone and aget), so the two can't drift apart. It also treats a self-call's result as unproven, since that type was only confirmed against the same arg-inferred param types. Your case (f (object-array 1) nil) now returns :no under strict IR, and it's in the VM/IR parity tests as bf-arg-aset.

(lower-go/returns-as? f decl)
(or (not (has-self-call? f))
(some (fn [t] (lower-go/returns-as? f t)) self-return-guesses)))
(or (some (fn [t] (infer-with-self-return! f fname t))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve checked arithmetic before enabling return inference by default

The new default guess moves previously boxed recursive arithmetic onto unchecked Go operators. A concrete regression is (defn step [n] (if (zero? n) 9223372036854775807 (+ (step (dec n)) 1))): native compilation on the base revision keeps the recursive result boxed and calls rt.AddValue, so (step 1) raises overflow and can be caught. On this PR, Step returns int64 and the generated addition is a raw arg__2 + 1; the native binary silently returns -9223372036854775808. I reproduced both native binaries. Although unchecked typed arithmetic predates this PR, this unhinted program only starts taking that path because of the new default inference. Please land checked integer lowering first, or keep the affected inference opt-in until arithmetic semantics are preserved.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. In b829168 the unhinted :int/:float guesses are behind ir.passes.pipeline/*infer-self-returns*, off by default, until checked integer lowering lands. A declared return type is still checked against the body, so the ^long miscompile stays fixed. TestUnhintedRecursionStillBoxes is back, and the inference tests opt in. Your step shape lowers boxed through rt.AddValue again. make check-selfhost holds.

@mparrett mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up re-review: both blockers from my previous review are addressed at b829168.

  • The strict IR-compiled aset reproduction now returns :no for nil, matching ordinary bytecode. Argument-derived type tracking now follows the value through the call.
  • The original unhinted recursive overflow reproduction, compiled to a native binary at this revision, catches overflow again. Unhinted self-return inference is opt-in, so the default path retains checked rt.AddValue.

Re-ran the original reproductions and focused tests: the branchfold suite passed 9 tests / 42 assertions, and all five selected recursive return inference tests passed. The earlier build and smoke checks also passed at this same head. No remaining blockers from my review; clearing my previous request for changes.

nooga and others added 3 commits October 22, 2026 16:48
cond's :else arm compiles to (if :else x nil). Nothing folded it, so the
IR kept a dead nil arm feeding the join, and a recursive fn like
Niva's fib lowered with a boxed vm.Value return and rt.AddValue.

- ir.passes.branchfold: a branch-if whose test is a constant, or whose
  inferred type is always truthy or always falsey, becomes a branch;
  blocks left unreachable are detached and single-predecessor chains
  spliced. Runs after constfold-1 and constfold-3, then re-infers
  types from scratch when it changed anything.
- A self-call's return type is now a guess the body must confirm: the
  declared ^long/^double when present, else :int, then :float. A
  contradicted guess is dropped. This fixes the ^long miscompile where
  the self-call was typed int64 while the fn returned vm.Value, and it
  types unhinted int recursion. A self-call must hit the same var
  (compared by resolution) and the same arity.
- pkg/compiler: an if with a scalar literal test skips the test and
  branch; the dead arm is still compiled so its errors surface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
infer-arg-types types a param :int because it feeds arithmetic, not because
the caller passes an Int, so (if n (+ n 1) 0) lost its else arm under
*ir-compile* and threw on nil/false. known-truth now skips type-based folding
for a :load-arg or a block param an argument flows into; constants and the
results of typed ops still fold. truthy-type?/falsey-type? no longer answer
true for an empty union.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ses opt-in

aset returns its value argument and typeinfer copies that operand's type, so
(if (aset a 0 x) (+ x 1) :no) still folded on x's arg-inferred :int and threw
on a nil x. arg-derived? now follows the calls typeinfer copies a type
through (typeinfer/type-source) and treats self-call results as unproven.

The unhinted :int/:float self-return guesses move behind
*infer-self-returns*: a guessed int64 return puts recursive arithmetic on
Go's wrapping + where rt.AddValue throws on overflow, so they wait for checked
integer lowering. A declared return type is still checked against the body,
which keeps the ^long miscompile fixed. TestUnhintedRecursionStillBoxes is
back; the inference tests opt in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nooga
nooga force-pushed the feat/ir-branch-fold branch from b829168 to d2d6a0c Compare October 22, 2026 15:50
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Perf (pkg/vm micros) — base vs head, same runner

Base 88a86e1c61b5 vs head 54377003515e. Anchor-normalized; informational, not a gate.

bench-ratchet — 16 regression(s) > 12.0% budget, 0 missing, 0 new

  • baseline: AMD EPYC 9V45 96-Core Processor / go1.27.1 / linux-amd64
  • current: AMD EPYC 9V45 96-Core Processor / go1.27.1 / linux-amd64
  • anchor: baseline 0.933 ns/op, current 0.945 ns/op (+1.3%)
Benchmark Baseline× Current× Δ% Wall Best since Status
github.com/nooga/let-go/pkg/vm.BenchmarkVectorConj/ArrayVector/100 9819.974 13236.352 +34.8% 12.5 µs 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkFrameAlloc/3Args 62.098 81.020 +30.5% 76.6 ns 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkVectorConj/PersistentVector/10 688.706 888.278 +29.0% 840 ns 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkVectorConj/PersistentVector/1000 118756.965 152665.044 +28.6% 144 µs 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkVectorConj/PersistentVector/100 10738.320 13793.906 +28.5% 13 µs 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkVectorConj/ArrayVector/1000 121213.030 154476.301 +27.4% 146 µs 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkVectorCreation/PersistentVector/10000 50030.004 63696.572 +27.3% 60.2 µs 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkVectorCreation/ArrayVector/10000 36400.557 46292.848 +27.2% 43.8 µs 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkSeqIteration/List/10 20.853 25.434 +22.0% 24 ns 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkMapAssoc/HAMT-Assoc/100 213.030 257.406 +20.8% 243 ns 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkVectorConj/ArrayVector/10 425.311 513.331 +20.7% 485 ns 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkFrameAlloc/NoArgs 61.777 72.175 +16.8% 68.2 ns 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkMapAssoc/HAMT-Assoc/10 97.321 111.722 +14.8% 106 ns 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkVariadicInvoke/5Args 135.448 152.878 +12.9% 144 ns 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkSeqIteration/ArrayVector/01000 15012.859 16878.967 +12.4% 16 µs 88a86e1c61b5 REGRESSION
github.com/nooga/let-go/pkg/vm.BenchmarkMapAssoc/HAMT-Dissoc/10 88.448 99.090 +12.0% 93.7 ns 88a86e1c61b5 REGRESSION

Showing 16 of 53 benchmarks (past budget). Full table in the run summary →

@nooga
nooga merged commit 0e1714c into main Oct 22, 2026
21 checks passed
@nooga
nooga deleted the feat/ir-branch-fold branch October 22, 2026 16:05
@mparrett mparrett added the perf-repeat Run the repeat A/B (variance-reduced) perf check label Oct 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Repeat A/B (variance-reduced) — informational shadow check


=== interleaved repeat A/B — N=7, profile=pr-fast (INFORMATIONAL, never gates) ===
runner: AMD EPYC 7763 64-Core Processor  4 CPU  go1.27.1
base 88a86e1c61b5  head 54377003515e  order BH HB BH HB BH HB BH
anchor stability per cycle (base/head ns/op, should stay flat — it measures compute, not memory):
  cycle 1: base 1.245  head 1.246  Δ+0.1%
  cycle 2: base 1.246  head 1.245  Δ-0.1%
  cycle 3: base 1.245  head 1.246  Δ+0.1%
  cycle 4: base 1.246  head 1.246  Δ+0.0%
  cycle 5: base 1.246  head 1.246  Δ+0.0%
  cycle 6: base 1.245  head 1.245  Δ+0.0%
  cycle 7: base 1.245  head 1.245  Δ+0.0%

family                                    median%  worst% >prim
----------------------------------------------------------------
MapAssoc/HAMT-Dissoc/01000                  +1.71   +3.42     0
MapAssoc/HAMT-Assoc/100                     +1.44   -4.63     0
VectorCreation/ArrayVector/100              +1.26   -4.45     0
MapAssoc/HAMT-Assoc/01000                   +0.54   -3.27     0
FrameAlloc/3Args                            +0.50   +3.42     0
VectorCreation/PersistentVector/10          +0.46   +1.54     0
MapAssoc/HAMT-Lookup/01000                  +0.33   +2.60     0
StructToRecord                              +0.19   -2.75     0
MapAssoc/HAMT-Lookup/100                    +0.14   +0.32     0
SeqIteration/PersistentVector/10            +0.12   -1.86     0
MultiArity/2Args                            +0.11   +1.30     0
RecordToStructFastPath                      +0.11  +32.81     1
... 41 more (full table in aggregate.json)

would-gate (any comparable family's median > budget):
  budget    6%:  clean  
  budget    8%:  clean  
  budget   10%:  clean  
confirm K=2/7 @ 10% (secondary): clean

(no-op PR: every would-gate above is a false positive; each is one sample toward P(any-gate).)

Per-cycle logs are in the job summary. Per-snapshot evidence — raw samples, cycle order, provenance — is in the perf-pr-repeat artifact on this run.

Informational only. This check never gates the PR — the workflow header says why.

@mparrett

Copy link
Copy Markdown
Collaborator

@nooga looks like a false positive on the perf regression.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

perf Run the perf A/B benchmark check perf-repeat Run the repeat A/B (variance-reduced) perf check

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants