From c83f527dc06b55ed71e18f5c5322593b45ee9281 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 29 Jan 2026 15:16:30 +0000 Subject: [PATCH 01/45] perf: a non-updating single-subst instantiateMVar (TODO) --- src/Lean/Meta/Injective.lean | 6 +- src/Lean/Meta/InstMVarsNU.lean | 151 +++++++++++++++++++++++++++++++ src/Lean/Meta/Tactic/Native.lean | 14 +++ 3 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 src/Lean/Meta/InstMVarsNU.lean create mode 100644 src/Lean/Meta/Tactic/Native.lean diff --git a/src/Lean/Meta/Injective.lean b/src/Lean/Meta/Injective.lean index 6b6cc7b1c824..3cb213d9060c 100644 --- a/src/Lean/Meta/Injective.lean +++ b/src/Lean/Meta/Injective.lean @@ -11,6 +11,7 @@ import Lean.Meta.Tactic.Cases import Lean.Meta.Tactic.Assumption import Lean.Meta.Tactic.Simp.Main import Lean.Meta.SameCtorUtils +import Lean.Meta.InstMVarsNU public section namespace Lean.Meta @@ -167,13 +168,14 @@ private def mkInjectiveEqTheorem (ctorVal : ConstructorVal) : MetaM Unit := do withTraceNode `Meta.injective (msg := (return m!"{exceptEmoji ·} generating `{name}`")) do let some type ← mkInjectiveEqTheoremType? ctorVal | return () + let type ← instantiateMVars type trace[Meta.injective] "type: {type}" let value ← mkInjectiveEqTheoremValue ctorVal type addDecl <| Declaration.thmDecl { name levelParams := ctorVal.levelParams - type := (← instantiateMVars type) - value := (← instantiateMVars value) + type := type + value := (← instantiateMVarsNoUpdate value) } addSimpTheorem (ext := simpExtension) name (post := true) (inv := false) AttributeKind.global (prio := eval_prio default) diff --git a/src/Lean/Meta/InstMVarsNU.lean b/src/Lean/Meta/InstMVarsNU.lean new file mode 100644 index 000000000000..fc367afd1308 --- /dev/null +++ b/src/Lean/Meta/InstMVarsNU.lean @@ -0,0 +1,151 @@ + +module +prelude + +public import Lean.Meta.Basic + +open Lean Meta + +namespace Lean.Meta + +namespace IntantiateMVars + +structure DelayedLift where + originalDepth : Nat + expr : Expr -- Unshifted expression + -- shifted : Thunk Expr -- Shifted expression + +def DelayedLift.ofExpr (e : Expr) : DelayedLift where + originalDepth := 0 + expr := e + -- shifted := Thunk.pure e + +/- +def DelayedLift.lift (n : Nat) (ds : DelayedLift) : DelayedLift := + if ds.expr.hasLooseBVars then + let newLift := ds.currentLift + n + { ds with + currentLift := newLift + -- shifted := Thunk.mk fun _ => + -- ds.expr.liftLooseBVars (s := 0) (d := newLift) + } + else + ds +-/ + +structure Context where + depth : Nat := 0 + fvarSubst : PersistentHashMap FVarId DelayedLift := {} + +structure State where + cache : Std.HashMap ExprStructEq Expr := {} + +abbrev M := ReaderT Context (StateRefT State MetaM) + +/- +When going under binders, we +* we record the increased depth +* empty the cache locally +-/ +def withLift (n : Nat) (k : M α) : M α := do + let cache ← modifyGet fun s => (s.cache, { s with cache := {} }) + let x ← withTheReader Context (fun ctx => + { ctx with depth := ctx.depth + n + -- fvarSubst := ctx.fvarSubst.mapVal (fun ds => ds.lift n) -- TODO: avoid mapping eagerly + }) do + k + modify fun s => { s with cache := cache } + pure x + +/-- +When traversing a delayed-assigned metavariable assignment, extend the substitution +-/ +def withSubst (fvarIds : Array Expr) (args : Array Expr) : M α → M α := + withTheReader Context fun ctx => Id.run do + let mut mvarSubst := ctx.fvarSubst + for fvarId in fvarIds, arg in args do + mvarSubst := mvarSubst.erase fvarId.fvarId! |>.insert fvarId.fvarId! ⟨ctx.depth, arg⟩ + { ctx with fvarSubst := mvarSubst } + +def lookup? (fvarId : FVarId) : M (Option Expr) := do + let ctx ← read + match ctx.fvarSubst.find? fvarId with + | some ds => return ds.expr.liftLooseBVars (s := 0) (d := ctx.depth - ds.originalDepth) + | none => return none + +partial def go (e : Expr) : M Expr := do + unless e.hasMVar || e.hasFVar do + return e + -- logInfo m!"Instantiating MVars in:{indentExpr e}\nfvarSubst: {(← read).fvarSubst.toList.map (fun (k, v) => (mkFVar k, v.expr, v.currentLift))}" + if let some e' := (← get).cache[ExprStructEq.mk e]? then return e' + + let goApp (e : Expr) := e.withApp fun f args => do + let args ← args.mapM go + let instArgs (f' : Expr) : M Expr := do + pure (mkAppN f' args) + let instApp : M Expr := do + let wasMVar := f.isMVar + let f' ← go f + if wasMVar && f'.isLambda then + /- Some of the arguments in `args` are irrelevant after we beta + reduce. Also, it may be a bug to not instantiate them, since they + may depend on free variables that are not in the context (see + issue #4375). So we pass `useZeta := true` to ensure that they are + instantiated. -/ + go (f'.betaRev args.reverse (useZeta := true)) + else + instArgs f' + if let .mvar mvarId := f then + let some {fvars, mvarIdPending} ← getDelayedMVarAssignment? mvarId | return ← instApp + if fvars.size > args.size then + /- We don't have sufficient arguments for instantiating the free variables `fvars`. + This can only happen if a tactic or elaboration function is not implemented correctly. + We decided to not use `panic!` here and report it as an error in the frontend + when we are checking for unassigned metavariables in an elaborated term. -/ + return ← instArgs f + let substArgs := args[:fvars.size].copy + let extraArgs := args[fvars.size:].copy + /- + Example: suppose we have + `?m t1 t2 t3` + That is, `f := ?m` and `substArgs := #[t1, t2]` and `extraArgs := #[t3]` + Moreover, `?m` is delayed assigned + `?m #[x, y] := e` + + We want to instantiate `e` with a substitution `[x ↦ t1, y ↦ t2]`, and then apply `t3` + -/ + let newVal ← withSubst fvars substArgs <| + go (mkMVar mvarIdPending) + -- logInfo m!"Resolved delayed mvar assignment to {newVal}" + let result := mkAppN newVal extraArgs + return result + instApp + + let e' ← match e with + | .bvar .. | .lit .. => unreachable! + | .proj _ _ s => return e.updateProj! (← go s) + | .forallE _ d b _ => return e.updateForallE! (← go d) (← withLift 1 <| go b) + | .lam _ d b _ => return e.updateLambdaE! (← go d) (← withLift 1 <| go b) + | .letE _ t v b nd => return e.updateLet! (← go t) (← go v) (← withLift 1 <| go b) nd + | .const _ lvls => return e.updateConst! (← lvls.mapM (instantiateLevelMVars ·)) + | .sort lvl => return e.updateSort! (← instantiateLevelMVars lvl) + | .mdata _ b => return e.updateMData! (← go b) + | .fvar fvarId => return (← lookup? fvarId).getD e + | .app .. => goApp e + | .mvar mvarId => -- Not in function position, cannot be a delayed mvar assignment + match (← getExprMVarAssignment? mvarId) with + | some newE => + -- logInfo m!"Resolved {mkMVar mvarId} mvar assignment to {newE}" + let newE ← go newE + -- logInfo m!"Instantiated {mkMVar mvarId} mvar assignment to {newE}" + return newE + | none => pure e + modify fun s => { s with cache := s.cache.insert (ExprStructEq.mk e) e' } + return e' + +end IntantiateMVars + +public def instantiateMVarsNoUpdate (e : Expr) : MetaM Expr := do + (IntantiateMVars.go e).run {} |>.run' {} + +end Lean.Meta diff --git a/src/Lean/Meta/Tactic/Native.lean b/src/Lean/Meta/Tactic/Native.lean new file mode 100644 index 000000000000..4297ccb609d6 --- /dev/null +++ b/src/Lean/Meta/Tactic/Native.lean @@ -0,0 +1,14 @@ +/- +Copyright (c) 2023 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Authors: Joachim Breitner +-/ + +module +prelude + +/-! +This module contains infrastructure for proofs by native evaluation (`native decide`, `bv_decide`). +Such proofs involve a native computation using the Lean kernel, and then asserting the result +of that computation as an axiom towards the logic. +-/ From 946773f0da2edd06c543e5fbc70e3c7a1c55ead3 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 29 Jan 2026 15:28:12 +0000 Subject: [PATCH 02/45] Please CI --- src/Lean/Meta/InstMVarsNU.lean | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Lean/Meta/InstMVarsNU.lean b/src/Lean/Meta/InstMVarsNU.lean index fc367afd1308..5375464ead43 100644 --- a/src/Lean/Meta/InstMVarsNU.lean +++ b/src/Lean/Meta/InstMVarsNU.lean @@ -1,11 +1,14 @@ +/- +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Authors: Joachim Breitner +-/ module prelude public import Lean.Meta.Basic -open Lean Meta - namespace Lean.Meta namespace IntantiateMVars From 3bbb6939a08b492be1127729f2a6cccae33a4663 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 29 Jan 2026 15:43:50 +0000 Subject: [PATCH 03/45] Docstring --- src/Lean/Meta/InstMVarsNU.lean | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Lean/Meta/InstMVarsNU.lean b/src/Lean/Meta/InstMVarsNU.lean index 5375464ead43..1623a0c407e7 100644 --- a/src/Lean/Meta/InstMVarsNU.lean +++ b/src/Lean/Meta/InstMVarsNU.lean @@ -6,9 +6,32 @@ Authors: Joachim Breitner module prelude - public import Lean.Meta.Basic +/-! +This alternative to `instantiateMVars` does *not* update the assignments of the meta variables +it visits. The benefit is that it carries a substitution of free variables as it traverses +the metavariables (in particular the delayed-assigned metavariabes), avoiding some of the overhead of +repeated substitution. This can make a big difference in terms with many metavarialbes under +`mkLambdaFVars`/`mkForallFVars`, or terms produced by tactics with lots of uses of `intro`. + +Implementation notes: + +* We traverse open terms (with loose bvars). +* The reader context carries the current binder depth. This is bumped when going under a binder. +* The fvar substitution values are not lifted when we go under binder. + Instead, we remember at which depth we inserted the value into the map, and do invoke + `liftLooseBVars` with the difference when we lookup the value. +* We cache the main instantiation loop. + This also means that the lifting done during substitution, and metavariable lookup, is cached. +* However, we use a fresh cache when we go under a binder. + Rationale: we'd have to lift the values in the cache when going under binder or when using a value. + That is already linear in the size of the expression, so we might as well just re-read the whole + value from the metavariable graph. + + +-/ + namespace Lean.Meta namespace IntantiateMVars @@ -148,6 +171,13 @@ partial def go (e : Expr) : M Expr := do end IntantiateMVars +/-- +This alternative to `instantiateMVars` does *not* update the assignments of the meta variables +it visits. The benefit is that it carries a substitution of free variables as it traverses +the metavariables (in particular the delayed-assigned metavariabes), avoiding some of the overhead of +repeated substitution. This can make a big difference in terms with many metavarialbes under +`mkLambdaFVars`/`mkForallFVars`, or terms produced by tactics with lots of uses of `intro`. +-/ public def instantiateMVarsNoUpdate (e : Expr) : MetaM Expr := do (IntantiateMVars.go e).run {} |>.run' {} From b133b76a616b92ef364e1c208cce9f60674dbc17 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 29 Jan 2026 16:01:26 +0000 Subject: [PATCH 04/45] File from wrong branch --- src/Lean/Meta/Tactic/Native.lean | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 src/Lean/Meta/Tactic/Native.lean diff --git a/src/Lean/Meta/Tactic/Native.lean b/src/Lean/Meta/Tactic/Native.lean deleted file mode 100644 index 4297ccb609d6..000000000000 --- a/src/Lean/Meta/Tactic/Native.lean +++ /dev/null @@ -1,14 +0,0 @@ -/- -Copyright (c) 2023 Lean FRO, LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. -Authors: Joachim Breitner --/ - -module -prelude - -/-! -This module contains infrastructure for proofs by native evaluation (`native decide`, `bv_decide`). -Such proofs involve a native computation using the Lean kernel, and then asserting the result -of that computation as an axiom towards the logic. --/ From 293e9807c728aed18a898edf6bb828c3cb30c09f Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 29 Jan 2026 16:08:10 +0000 Subject: [PATCH 05/45] Empty lines --- src/Lean/Meta/InstMVarsNU.lean | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Lean/Meta/InstMVarsNU.lean b/src/Lean/Meta/InstMVarsNU.lean index 1623a0c407e7..3262c3c4eb9c 100644 --- a/src/Lean/Meta/InstMVarsNU.lean +++ b/src/Lean/Meta/InstMVarsNU.lean @@ -28,8 +28,6 @@ Implementation notes: Rationale: we'd have to lift the values in the cache when going under binder or when using a value. That is already linear in the size of the expression, so we might as well just re-read the whole value from the metavariable graph. - - -/ namespace Lean.Meta From 5afae98168de0621ed1227dac1a26611e5fbd274 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Sat, 21 Feb 2026 10:52:57 +0000 Subject: [PATCH 06/45] test: benchmark bvar-blind cross-depth cache for instantiateMVarsNoUpdate This adds an experimental `instantiateMVarsNoUpdate2` variant (inline in the benchmark) that keeps its expression cache across binder depths using bvar-blind keying, rather than clearing the cache at each depth. Bench2 shows it avoids the O(depth * bodySize) scaling of the old approach when the same closed sub-expression appears at many depths (crossover ~depth=50). However, the ~6ms constant per-node overhead makes it 3-5x slower on the bench1 workload (the realistic delayed-assignment case), where both variants are already sub-millisecond. Not worth adopting. Co-Authored-By: Claude Opus 4.6 --- tests/bench/delayed_assign_nu2.lean | 292 ++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 tests/bench/delayed_assign_nu2.lean diff --git a/tests/bench/delayed_assign_nu2.lean b/tests/bench/delayed_assign_nu2.lean new file mode 100644 index 000000000000..fafc9b1ac05d --- /dev/null +++ b/tests/bench/delayed_assign_nu2.lean @@ -0,0 +1,292 @@ +import Lean + +open Lean Meta + +def mkLE (i : Nat) : Expr := + mkNatLE (mkNatLit 0) (mkBVar i) + +partial def solve (mvarId : MVarId) : MetaM Unit := do + let type ← instantiateMVars (← mvarId.getType) + if type.isForall then + let (_, mvarId) ← mvarId.intro1 + solve mvarId + else if type.isAppOf ``And then + let [mvarId₁, mvarId₂] ← mvarId.applyConst ``And.intro | failure + solve mvarId₁ + solve mvarId₂ + else if type.isAppOf ``LE.le then + let [] ← mvarId.applyConst ``Nat.zero_le | failure + else + let [] ← mvarId.applyConst ``True.intro | failure + +/-! ## instantiateMVarsNoUpdate2: bvar-blind cache variant + +Experimental variant of `instantiateMVarsNoUpdate` that keeps its cache across binder depths +using bvar-blind keying: expressions are hashed and compared while treating all `.bvar` indices +as equal. When a cache hit is found at a different depth, we verify the stored key shifted by the +depth difference equals the current expression, then lift the cached result accordingly. + +### Analysis + +This trades constant per-node overhead (BVarBlindKey allocation, double cache lookup for the +shouldInsert check, richer cache entries) for better depth scaling: the old approach clears the +cache at each binder depth, giving O(depth * bodySize) when the same closed sub-expression +appears at many depths. This variant caches it once and reuses, giving O(bodySize + depth). + +Benchmark results (bench2, body=500): + +| depth | instantiateMVars | NoUpdate (old) | NoUpdate2 (new) | +|-------|-----------------|----------------|-----------------| +| 1 | 0.15 ms | 0.52 ms | 7.2 ms | +| 10 | 0.12 ms | 1.80 ms | 5.8 ms | +| 50 | 0.11 ms | 8.39 ms | 5.9 ms | +| 100 | 0.12 ms | 17.63 ms | 6.3 ms | +| 500 | 0.18 ms | 103.23 ms | 7.8 ms | + +On the bench1 workload (delayed assignments from tactics, the realistic case), NoUpdate2 is +3-5x slower than NoUpdate, and both are already sub-millisecond. The ~6ms constant overhead makes +NoUpdate2 worse for shallow depths (crossover ~depth=50). The optimization only pays off for +pathological cases with many nested binders and large shared closed sub-expressions. + +**Conclusion:** Not worth adopting as a replacement for the current cache-clearing approach. +-/ + +namespace InstMVarsNU2 + +structure DelayedLift where + originalDepth : Nat + expr : Expr + +private partial def bvarBlindEq (e₁ e₂ : Expr) : Bool := + if e₁.looseBVarRange == 0 && e₂.looseBVarRange == 0 then Expr.equal e₁ e₂ + else match e₁, e₂ with + | .bvar _, .bvar _ => true + | .app f₁ a₁, .app f₂ a₂ => bvarBlindEq f₁ f₂ && bvarBlindEq a₁ a₂ + | .forallE _ d₁ b₁ bi₁, .forallE _ d₂ b₂ bi₂ => + bi₁ == bi₂ && bvarBlindEq d₁ d₂ && bvarBlindEq b₁ b₂ + | .lam _ d₁ b₁ bi₁, .lam _ d₂ b₂ bi₂ => + bi₁ == bi₂ && bvarBlindEq d₁ d₂ && bvarBlindEq b₁ b₂ + | .letE _ t₁ v₁ b₁ nd₁, .letE _ t₂ v₂ b₂ nd₂ => + nd₁ == nd₂ && bvarBlindEq t₁ t₂ && bvarBlindEq v₁ v₂ && bvarBlindEq b₁ b₂ + | .mdata d₁ b₁, .mdata d₂ b₂ => d₁ == d₂ && bvarBlindEq b₁ b₂ + | .proj n₁ i₁ b₁, .proj n₂ i₂ b₂ => n₁ == n₂ && i₁ == i₂ && bvarBlindEq b₁ b₂ + | _, _ => false + +structure BVarBlindKey where + expr : Expr + bbHash : UInt64 + +instance : Hashable BVarBlindKey where + hash k := k.bbHash + +instance : BEq BVarBlindKey where + beq k₁ k₂ := k₁.bbHash == k₂.bbHash && bvarBlindEq k₁.expr k₂.expr + +structure CacheEntry where + depth : Nat + key : Expr + result : Expr + +structure Context where + depth : Nat := 0 + fvarSubst : PersistentHashMap FVarId DelayedLift := {} + +structure State where + cache : Std.HashMap BVarBlindKey CacheEntry := {} + +abbrev M := ReaderT Context (StateRefT State MetaM) + +/-- Compute bvar-blind hash. For closed sub-expressions, uses precomputed hash. -/ +private def bbHash (e : Expr) : UInt64 := + -- For this benchmark, all cross-depth expressions are closed, so just use regular hash. + -- A full implementation would ignore bvar indices. + e.hash + +def withLift (n : Nat) (k : M α) : M α := + withTheReader Context (fun ctx => + { ctx with depth := ctx.depth + n }) k + +def withSubst (fvarIds : Array Expr) (args : Array Expr) : M α → M α := + withTheReader Context fun ctx => Id.run do + let mut mvarSubst := ctx.fvarSubst + for fvarId in fvarIds, arg in args do + mvarSubst := mvarSubst.erase fvarId.fvarId! |>.insert fvarId.fvarId! ⟨ctx.depth, arg⟩ + { ctx with fvarSubst := mvarSubst } + +def lookup? (fvarId : FVarId) : M (Option Expr) := do + let ctx ← read + match ctx.fvarSubst.find? fvarId with + | some ds => return ds.expr.liftLooseBVars (s := 0) (d := ctx.depth - ds.originalDepth) + | none => return none + +partial def go (e : Expr) : M Expr := do + unless e.hasMVar || e.hasFVar do + return e + + let depth := (← read).depth + let key := BVarBlindKey.mk e (bbHash e) + if let some entry := (← get).cache[key]? then + if entry.depth ≤ depth then + let k := depth - entry.depth + let rangeOk := if entry.key.looseBVarRange == 0 then + e.looseBVarRange == 0 + else + e.looseBVarRange == entry.key.looseBVarRange + k + if rangeOk then + if k == 0 then + if Expr.equal entry.key e then return entry.result + else + if entry.key.liftLooseBVars 0 k == e then + return entry.result.liftLooseBVars 0 k + + let goApp (e : Expr) := e.withApp fun f args => do + let args ← args.mapM go + let instArgs (f' : Expr) : M Expr := do + pure (mkAppN f' args) + let instApp : M Expr := do + let wasMVar := f.isMVar + let f' ← go f + if wasMVar && f'.isLambda then + go (f'.betaRev args.reverse (useZeta := true)) + else + instArgs f' + if let .mvar mvarId := f then + let some {fvars, mvarIdPending} ← getDelayedMVarAssignment? mvarId | return ← instApp + if fvars.size > args.size then + return ← instArgs f + let substArgs := args[:fvars.size].copy + let extraArgs := args[fvars.size:].copy + let newVal ← withSubst fvars substArgs <| + go (mkMVar mvarIdPending) + let result := mkAppN newVal extraArgs + return result + instApp + + let e' ← match e with + | .bvar .. | .lit .. => unreachable! + | .proj _ _ s => return e.updateProj! (← go s) + | .forallE _ d b _ => return e.updateForallE! (← go d) (← withLift 1 <| go b) + | .lam _ d b _ => return e.updateLambdaE! (← go d) (← withLift 1 <| go b) + | .letE _ t v b nd => return e.updateLet! (← go t) (← go v) (← withLift 1 <| go b) nd + | .const _ lvls => return e.updateConst! (← lvls.mapM (instantiateLevelMVars ·)) + | .sort lvl => return e.updateSort! (← instantiateLevelMVars lvl) + | .mdata _ b => return e.updateMData! (← go b) + | .fvar fvarId => return (← lookup? fvarId).getD e + | .app .. => goApp e + | .mvar mvarId => + match (← getExprMVarAssignment? mvarId) with + | some newE => + let newE ← go newE + return newE + | none => pure e + let shouldInsert := match (← get).cache[key]? with + | none => true + | some entry => depth ≤ entry.depth + if shouldInsert then + modify fun s => { s with cache := s.cache.insert key ⟨depth, e, e'⟩ } + return e' + +end InstMVarsNU2 + +def _root_.Lean.Meta.instantiateMVarsNoUpdate2 (e : Expr) : MetaM Expr := do + (InstMVarsNU2.go e).run {} |>.run' {} + +/-! ## Benchmark 1: delayed assignments (from original benchmark) -/ + +partial def runBench1 (name : String) (n : Nat) (mk : Nat → MetaM MVarId) : MetaM Unit := do + let mvarId ← mk n + let startTime ← IO.monoNanosNow + solve mvarId + let endTime ← IO.monoNanosNow + let ms := (endTime - startTime).toFloat / 1000000.0 + let startTime ← IO.monoNanosNow + discard <| instantiateMVars (mkMVar mvarId) + let endTime ← IO.monoNanosNow + let instMs := (endTime - startTime).toFloat / 1000000.0 + IO.println s!"{name}_{n}: {ms} ms, instantiateMVars: {instMs} ms" + +def mkBench1 (n : Nat) : MetaM MVarId := do + let type := mkType n + return (← mkFreshExprSyntheticOpaqueMVar type).mvarId! +where + mkResultType (i : Nat) : Expr := + match i with + | 0 => mkConst ``True + | i+1 => mkAnd (mkLE i) (mkResultType i) + + mkType (i : Nat) : Expr := + match i with + | 0 => mkResultType n + | i+1 => .forallE `x Nat.mkType (mkAnd (mkType i) (mkLE (n - i - 1))) .default + +partial def bench1 (n : Nat) : MetaM Unit := do + runBench1 "bench1" n mkBench1 + +/-! ## Benchmark 2: shared closed sub-expression across binder depths + +A closed sub-expression containing mvars appears as the domain of nested foralls. +The old NoUpdate clears its cache at each depth, recomputing the sub-expression each time. +The new NoUpdate2 caches it once and reuses across depths. + +Structure: `∀ _ : bigExpr, ∀ _ : bigExpr, ... bigExpr` +where `bigExpr = (?m₁ ≤ 1) ∧ (?m₂ ≤ 1) ∧ ... ∧ True` with each `?mᵢ := 0`. +-/ + +/-- Create a conjunction of `size` mvar lookups. -/ +def mkMVarConj (size : Nat) : MetaM Expr := do + let mut expr := mkConst ``True + for _ in [:size] do + let mvar ← mkFreshExprMVar (mkConst ``Nat) + mvar.mvarId!.assign (mkNatLit 0) + expr := mkAnd (mkNatLE mvar (mkNatLit 1)) expr + return expr + +/-- Nest `bigExpr` under `depth` forall binders (also used as each domain). -/ +def mkNestedForall (bigExpr : Expr) (depth : Nat) : Expr := Id.run do + let mut term := bigExpr + for _ in [:depth] do + term := .forallE `x bigExpr term .default + return term + +def bench2 (depth : Nat) (bodySize : Nat) : MetaM Unit := do + -- Each test gets fresh mvars to avoid cross-contamination from instantiateMVars updating assignments + let bigExpr1 ← mkMVarConj bodySize + let term1 := mkNestedForall bigExpr1 depth + let t0 ← IO.monoNanosNow + let _r1 ← instantiateMVarsNoUpdate term1 + let t1 ← IO.monoNanosNow + + let bigExpr2 ← mkMVarConj bodySize + let term2 := mkNestedForall bigExpr2 depth + let t2 ← IO.monoNanosNow + let _r2 ← instantiateMVarsNoUpdate2 term2 + let t3 ← IO.monoNanosNow + + let bigExpr3 ← mkMVarConj bodySize + let term3 := mkNestedForall bigExpr3 depth + let t4 ← IO.monoNanosNow + let _r3 ← instantiateMVars term3 + let t5 ← IO.monoNanosNow + + let nuMs := (t1 - t0).toFloat / 1000000.0 + let nu2Ms := (t3 - t2).toFloat / 1000000.0 + let instMs := (t5 - t4).toFloat / 1000000.0 + + IO.println s!"bench2 depth={depth} body={bodySize}: instantiateMVars {instMs} ms, NoUpdate {nuMs} ms, NoUpdate2 {nu2Ms} ms" + +/-! ## Run benchmarks -/ + +run_meta do + IO.println "=== Bench 1: delayed assignments ===" + for i in [10, 20, 40, 80, 100, 200, 300, 400, 500] do + bench1 i + + IO.println "" + IO.println "=== Bench 2: shared closed sub-expr across depths (body=500, varying depth) ===" + for d in [1, 10, 50, 100, 200, 500] do + bench2 d 500 + + IO.println "" + IO.println "=== Bench 2: shared closed sub-expr across depths (depth=100, varying body) ===" + for b in [100, 500, 1000, 2000] do + bench2 100 b From 860b8fdd3a38afd181d32a6aee666914e2d8cd0f Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Fri, 27 Feb 2026 10:50:22 +0000 Subject: [PATCH 07/45] perf: C++ implementation of instantiateMVarsNoUpdate This PR adds a C++ implementation of `instantiateMVarsNoUpdate`, following the pattern of the existing `instantiate_mvars.cpp`. Key features: - FVar substitution via hash map instead of `replace_fvars`, avoiding a separate traversal pass for delayed assignments - Depth tracking with lazy lifting of substitution values - Two-level cache: a depth-dependent cache (cleared under binders) and a global cache for fvar-free expressions that persists across binder depths - When the fvar substitution is empty, updates mvar assignments with their normalized values so subsequent `instantiateMVars` calls are free Benchmark (delayed-assignment workload): - C++ NoUpdate is ~2x faster than the Lean NoUpdate implementation - C++ NoUpdate is 10-26x faster than standard instantiateMVars - The global cache eliminates O(depth) scaling for fvar-free expressions Co-Authored-By: Claude Opus 4.6 --- src/Lean/Meta/InstMVarsNU.lean | 11 + src/kernel/CMakeLists.txt | 2 +- src/kernel/instantiate_mvars_no_update.cpp | 444 +++++++++++++++++++++ tests/bench/delayed_assign.lean | 47 ++- 4 files changed, 490 insertions(+), 14 deletions(-) create mode 100644 src/kernel/instantiate_mvars_no_update.cpp diff --git a/src/Lean/Meta/InstMVarsNU.lean b/src/Lean/Meta/InstMVarsNU.lean index 3262c3c4eb9c..ea84a55bf650 100644 --- a/src/Lean/Meta/InstMVarsNU.lean +++ b/src/Lean/Meta/InstMVarsNU.lean @@ -169,6 +169,9 @@ partial def go (e : Expr) : M Expr := do end IntantiateMVars +@[extern "lean_instantiate_expr_mvars_no_update"] +private opaque instantiateMVarsNoUpdateImp (mctx : MetavarContext) (e : Expr) : MetavarContext × Expr + /-- This alternative to `instantiateMVars` does *not* update the assignments of the meta variables it visits. The benefit is that it carries a substitution of free variables as it traverses @@ -177,6 +180,14 @@ repeated substitution. This can make a big difference in terms with many metavar `mkLambdaFVars`/`mkForallFVars`, or terms produced by tactics with lots of uses of `intro`. -/ public def instantiateMVarsNoUpdate (e : Expr) : MetaM Expr := do + if !e.hasMVar then + return e + let (mctx, eNew) := instantiateMVarsNoUpdateImp (← getMCtx) e + modifyMCtx fun _ => mctx + return eNew + +/-- Lean implementation of `instantiateMVarsNoUpdate`, exposed for benchmarking. -/ +public def instantiateMVarsNoUpdateLean (e : Expr) : MetaM Expr := do (IntantiateMVars.go e).run {} |>.run' {} end Lean.Meta diff --git a/src/kernel/CMakeLists.txt b/src/kernel/CMakeLists.txt index 2d21c3af4968..117ed635e21a 100644 --- a/src/kernel/CMakeLists.txt +++ b/src/kernel/CMakeLists.txt @@ -2,4 +2,4 @@ add_library(kernel OBJECT level.cpp expr.cpp expr_eq_fn.cpp for_each_fn.cpp replace_fn.cpp abstract.cpp instantiate.cpp local_ctx.cpp declaration.cpp environment.cpp type_checker.cpp init_module.cpp expr_cache.cpp equiv_manager.cpp quot.cpp -inductive.cpp trace.cpp instantiate_mvars.cpp) +inductive.cpp trace.cpp instantiate_mvars.cpp instantiate_mvars_no_update.cpp) diff --git a/src/kernel/instantiate_mvars_no_update.cpp b/src/kernel/instantiate_mvars_no_update.cpp new file mode 100644 index 000000000000..0cbe61ad7cc3 --- /dev/null +++ b/src/kernel/instantiate_mvars_no_update.cpp @@ -0,0 +1,444 @@ +/* +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. + +Authors: Joachim Breitner +*/ +#include +#include +#include "util/name_set.h" +#include "runtime/option_ref.h" +#include "runtime/array_ref.h" +#include "kernel/instantiate.h" +#include "kernel/replace_fn.h" +#include "kernel/expr.h" + +/* +This module provides an efficient C++ implementation of `instantiateMVarsNoUpdate`. + +Unlike `instantiateExprMVars`, this variant does NOT update the assignments of the +metavariables it visits. Instead, it carries a substitution of free variables as it +traverses delayed-assigned metavariables, avoiding repeated substitution overhead. + +Key design differences from instantiate_mvars.cpp: +1. No `assign_mvar` calls -- the mctx is not mutated (in particular, the result does + not include an updated mctx). +2. FVar substitution: when encountering a delayed assignment `?m [x,y] := ?pending`, + instead of doing `replace_fvars`, we extend an fvar substitution map and continue + traversing. This avoids a second traversal. +3. Depth tracking: we track the current binder depth. Fvar substitution values are + stored with the depth at which they were inserted; on lookup, we lift by the + difference. +4. Cache clearing: we clear the expression cache when going under binders, because + cache entries depend on the current depth (fvar substitutions may contain loose bvars). + +When the fvar substitution is empty, we update metavar assignments with their normalized +values (like the standard instantiateMVars), since this is sound and helps future lookups. +*/ + +namespace lean { +extern "C" object * lean_get_lmvar_assignment(obj_arg mctx, obj_arg mid); +extern "C" object * lean_assign_lmvar(obj_arg mctx, obj_arg mid, obj_arg val); +extern "C" object * lean_get_mvar_assignment(obj_arg mctx, obj_arg mid); +extern "C" object * lean_get_delayed_mvar_assignment(obj_arg mctx, obj_arg mid); +extern "C" object * lean_assign_mvar(obj_arg mctx, obj_arg mid, obj_arg val); + +typedef object_ref metavar_ctx; +typedef object_ref delayed_assignment; + +static void assign_lmvar(metavar_ctx & mctx, name const & mid, level const & l) { + object * r = lean_assign_lmvar(mctx.steal(), mid.to_obj_arg(), l.to_obj_arg()); + mctx.set_box(r); +} + +static option_ref get_lmvar_assignment(metavar_ctx & mctx, name const & mid) { + return option_ref(lean_get_lmvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); +} + +static void assign_mvar(metavar_ctx & mctx, name const & mid, expr const & e) { + object * r = lean_assign_mvar(mctx.steal(), mid.to_obj_arg(), e.to_obj_arg()); + mctx.set_box(r); +} + +static option_ref get_mvar_assignment(metavar_ctx & mctx, name const & mid) { + return option_ref(lean_get_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); +} + +static option_ref get_delayed_mvar_assignment(metavar_ctx & mctx, name const & mid) { + return option_ref(lean_get_delayed_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); +} + +/* Level metavariable instantiation -- same as in instantiate_mvars.cpp, + but we DO update level mvar assignments (levels don't involve fvars). */ +class instantiate_lmvars_nu_fn { + metavar_ctx & m_mctx; + lean::unordered_map m_cache; + std::vector m_saved; + + inline level cache(level const & l, level r, bool shared) { + if (shared) { + m_cache.insert(mk_pair(l.raw(), r)); + } + return r; + } +public: + instantiate_lmvars_nu_fn(metavar_ctx & mctx):m_mctx(mctx) {} + level visit(level const & l) { + if (!has_mvar(l)) + return l; + bool shared = false; + if (is_shared(l)) { + auto it = m_cache.find(l.raw()); + if (it != m_cache.end()) { + return it->second; + } + shared = true; + } + switch (l.kind()) { + case level_kind::Succ: + return cache(l, update_succ(l, visit(succ_of(l))), shared); + case level_kind::Max: case level_kind::IMax: + return cache(l, update_max(l, visit(level_lhs(l)), visit(level_rhs(l))), shared); + case level_kind::Zero: case level_kind::Param: + lean_unreachable(); + case level_kind::MVar: { + option_ref r = get_lmvar_assignment(m_mctx, mvar_id(l)); + if (!r) { + return l; + } else { + level a(r.get_val()); + if (!has_mvar(a)) { + return a; + } else { + level a_new = visit(a); + if (!is_eqp(a, a_new)) { + m_saved.push_back(a); + assign_lmvar(m_mctx, mvar_id(l), a_new); + } + return a_new; + } + } + }} + } + level operator()(level const & l) { return visit(l); } +}; + +struct fvar_subst_entry { + unsigned depth; + expr value; +}; + +class instantiate_mvars_no_update_fn { + metavar_ctx & m_mctx; + instantiate_lmvars_nu_fn m_level_fn; + /* The fvar substitution, mapping fvar names to (depth, value) pairs. */ + lean::unordered_map m_fvar_subst; + /* Current binder depth. */ + unsigned m_depth; + /* Expression cache. Cleared when going under binders. */ + lean::unordered_map m_cache; + /* Global cache: persists across binder depths. Used for expressions that have + no FVars, so their result is depth-independent. */ + lean::unordered_map m_global_cache; + /* Prevent GC of values whose subterms may be referenced by the cache. */ + std::vector m_saved; + /* Track which mvars have already been normalized (for the update-when-empty optimization). */ + name_set m_already_normalized; + + level visit_level(level const & l) { + return m_level_fn(l); + } + + levels visit_levels(levels const & ls) { + buffer lsNew; + for (auto const & l : ls) + lsNew.push_back(visit_level(l)); + return levels(lsNew); + } + + bool fvar_subst_empty() const { + return m_fvar_subst.empty(); + } + + /* Look up an fvar in our substitution. Returns none if not found. */ + optional lookup_fvar(name const & fid) { + auto it = m_fvar_subst.find(fid.raw()); + if (it == m_fvar_subst.end()) + return optional(); + unsigned d = m_depth - it->second.depth; + if (d == 0) + return optional(it->second.value); + return optional(lift_loose_bvars(it->second.value, d)); + } + + /* + Get mvar assignment. Unlike the updating version, we do NOT normalize + and write back by default -- unless the fvar substitution is empty, + in which case the normalization result is identical to what instantiateMVars + would compute, and updating is sound and beneficial. + */ + optional get_assignment(name const & mid) { + option_ref r = get_mvar_assignment(m_mctx, mid); + if (!r) + return optional(); + expr a(r.get_val()); + if (!has_mvar(a) && !has_fvar(a)) { + return optional(a); + } + if (fvar_subst_empty()) { + /* Sound to update: no fvar substitution active, so the result + is identical to what instantiateMVars would compute. */ + if (m_already_normalized.contains(mid)) { + return optional(a); + } + m_already_normalized.insert(mid); + expr a_new = visit(a); + if (!is_eqp(a, a_new)) { + m_saved.push_back(a); + assign_mvar(m_mctx, mid, a_new); + } + return optional(a_new); + } else { + /* Cannot update: fvar substitution is active. Just visit. */ + return optional(visit(a)); + } + } + + /* Given `e` of the form `f a_1 ... a_n` where `f` is not a metavariable, + instantiate metavariables in all positions. */ + expr visit_app_default(expr const & e) { + buffer args; + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + lean_assert(!is_mvar(*curr)); + expr f = visit(*curr); + return mk_rev_app(f, args.size(), args.data()); + } + + /* Given `e` of the form `?m a_1 ... a_n`, visit the args but keep ?m as-is. */ + expr visit_mvar_app_args(expr const & e) { + buffer args; + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + lean_assert(is_mvar(*curr)); + return mk_rev_app(*curr, args.size(), args.data()); + } + + /* Beta-reduce `f_new` applied to the args of `e`. */ + expr visit_args_and_beta(expr const & f_new, expr const & e, buffer & args) { + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + bool preserve_data = false; + bool zeta = true; + return apply_beta(f_new, args.size(), args.data(), preserve_data, zeta); + } + + /* + Handle delayed assignment: `?m [x,y] := ?pending` applied to args. + Instead of replace_fvars, we extend the fvar substitution and recursively visit ?pending. + */ + expr visit_delayed(array_ref const & fvars, name const & mid_pending, + expr const & e, buffer & args) { + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + + size_t fvar_count = fvars.size(); + size_t extra_count = args.size() - fvar_count; + + /* Save and extend the fvar substitution. + The substitution values are the (already visited) args at indices + [args.size()-1 .. args.size()-fvar_count] (reversed, since args is built in reverse). */ + struct saved_entry { lean_object * key; bool had_old; fvar_subst_entry old; }; + std::vector saved_entries; + saved_entries.reserve(fvar_count); + for (size_t i = 0; i < fvar_count; i++) { + name const & fid = fvar_name(fvars[i]); + auto old_it = m_fvar_subst.find(fid.raw()); + if (old_it != m_fvar_subst.end()) { + saved_entries.push_back({fid.raw(), true, old_it->second}); + } else { + saved_entries.push_back({fid.raw(), false, {0, expr()}}); + } + /* args is reversed: args[args.size()-1] corresponds to the first argument. + fvars[i] should be mapped to the i-th argument, which is + args[args.size() - 1 - i]. */ + m_fvar_subst[fid.raw()] = {m_depth, args[args.size() - 1 - i]}; + } + + /* Visit ?pending with the extended substitution */ + expr val_new = visit(mk_mvar(mid_pending)); + + /* Restore the fvar substitution */ + for (size_t i = 0; i < saved_entries.size(); i++) { + if (!saved_entries[i].had_old) { + m_fvar_subst.erase(saved_entries[i].key); + } else { + m_fvar_subst[saved_entries[i].key] = saved_entries[i].old; + } + } + + /* Apply the extra arguments */ + return mk_rev_app(val_new, extra_count, args.data()); + } + + expr visit_app(expr const & e) { + expr const & f = get_app_fn(e); + if (!is_mvar(f)) { + return visit_app_default(e); + } else { + name const & mid = mvar_name(f); + /* Regular assignments take precedence over delayed ones. */ + if (auto f_new = get_assignment(mid)) { + buffer args; + if (is_lambda(*f_new)) { + return visit_args_and_beta(*f_new, e, args); + } else { + /* Visit args and rebuild */ + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + return mk_rev_app(*f_new, args.size(), args.data()); + } + } + option_ref d = get_delayed_mvar_assignment(m_mctx, mid); + if (!d) { + return visit_mvar_app_args(e); + } + array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + if (fvars.size() > get_app_num_args(e)) { + return visit_mvar_app_args(e); + } + /* Unlike the standard version, we always attempt to instantiate + the delayed assignment by extending our fvar substitution. */ + buffer args; + return visit_delayed(fvars, mid_pending, e, args); + } + } + + expr visit_mvar(expr const & e) { + name const & mid = mvar_name(e); + if (auto r = get_assignment(mid)) { + return *r; + } else { + return e; + } + } + + expr visit_fvar(expr const & e) { + name const & fid = fvar_name(e); + if (auto r = lookup_fvar(fid)) { + return *r; + } + return e; + } + +public: + instantiate_mvars_no_update_fn(metavar_ctx & mctx) + : m_mctx(mctx), m_level_fn(mctx), m_depth(0) {} + + expr visit(expr const & e) { + if (!has_mvar(e) && !has_fvar(e)) + return e; + + /* If the expression has no FVars, the result is independent of the current + binder depth and fvar substitution, so we can use the global cache. */ + bool use_global = !has_fvar(e); + bool shared = false; + if (is_shared(e)) { + if (use_global) { + auto it = m_global_cache.find(e.raw()); + if (it != m_global_cache.end()) + return it->second; + } else { + auto it = m_cache.find(e.raw()); + if (it != m_cache.end()) + return it->second; + } + shared = true; + } + + expr r; + switch (e.kind()) { + case expr_kind::BVar: + case expr_kind::Lit: + lean_unreachable(); + case expr_kind::FVar: + return visit_fvar(e); + case expr_kind::Sort: + r = update_sort(e, visit_level(sort_level(e))); + break; + case expr_kind::Const: + r = update_const(e, visit_levels(const_levels(e))); + break; + case expr_kind::MVar: + return visit_mvar(e); + case expr_kind::MData: + r = update_mdata(e, visit(mdata_expr(e))); + break; + case expr_kind::Proj: + r = update_proj(e, visit(proj_expr(e))); + break; + case expr_kind::App: + r = visit_app(e); + break; + case expr_kind::Pi: case expr_kind::Lambda: { + expr d = visit(binding_domain(e)); + /* Clear depth-dependent cache and bump depth for the body */ + lean::unordered_map saved_cache; + saved_cache.swap(m_cache); + m_depth++; + expr b = visit(binding_body(e)); + m_depth--; + saved_cache.swap(m_cache); + r = update_binding(e, d, b); + break; + } + case expr_kind::Let: { + expr t = visit(let_type(e)); + expr v = visit(let_value(e)); + lean::unordered_map saved_cache; + saved_cache.swap(m_cache); + m_depth++; + expr b = visit(let_body(e)); + m_depth--; + saved_cache.swap(m_cache); + r = update_let(e, t, v, b); + break; + } + } + if (shared) { + if (use_global) + m_global_cache.insert(mk_pair(e.raw(), r)); + else + m_cache.insert(mk_pair(e.raw(), r)); + } + return r; + } + + expr operator()(expr const & e) { return visit(e); } +}; + +extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_no_update(object * m, object * e) { + metavar_ctx mctx(m); + expr e_new = instantiate_mvars_no_update_fn(mctx)(expr(e)); + object * r = alloc_cnstr(0, 2, 0); + cnstr_set(r, 0, mctx.steal()); + cnstr_set(r, 1, e_new.steal()); + return r; +} +} diff --git a/tests/bench/delayed_assign.lean b/tests/bench/delayed_assign.lean index 7417272c8252..8013b28e3c36 100644 --- a/tests/bench/delayed_assign.lean +++ b/tests/bench/delayed_assign.lean @@ -1,5 +1,7 @@ import Lean +set_option maxHeartbeats 800000 + open Lean Meta def mkLE (i : Nat) : Expr := @@ -19,18 +21,6 @@ partial def solve (mvarId : MVarId) : MetaM Unit := do else let [] ← mvarId.applyConst ``True.intro | failure -partial def runBench (name : String) (n : Nat) (mk : Nat → MetaM MVarId) : MetaM Unit := do - let mvarId ← mk n - let startTime ← IO.monoNanosNow - solve mvarId - let endTime ← IO.monoNanosNow - let ms := (endTime - startTime).toFloat / 1000000.0 - let startTime ← IO.monoNanosNow - discard <| instantiateMVars (mkMVar mvarId) - let endTime ← IO.monoNanosNow - let instMs := (endTime - startTime).toFloat / 1000000.0 - IO.println s!"{name}_{n}: {ms} ms, instantiateMVars: {instMs} ms" - def mkBench1 (n : Nat) : MetaM MVarId := do let type := mkType n return (← mkFreshExprSyntheticOpaqueMVar type).mvarId! @@ -46,11 +36,42 @@ where | i+1 => .forallE `x Nat.mkType (mkAnd (mkType i) (mkLE (n - i - 1))) .default partial def bench1 (n : Nat) : MetaM Unit := do - runBench "bench1" n mkBench1 + -- Test instantiateMVars (C++) + let mvarId1 ← mkBench1 n + solve mvarId1 + let t0 ← IO.monoNanosNow + let r1 ← instantiateMVars (mkMVar mvarId1) + let t1 ← IO.monoNanosNow + let instMs := (t1 - t0).toFloat / 1000000.0 + + -- Test instantiateMVarsNoUpdate (C++) with fresh mvars + let mvarId2 ← mkBench1 n + solve mvarId2 + let t2 ← IO.monoNanosNow + let r2 ← instantiateMVarsNoUpdate (mkMVar mvarId2) + let t3 ← IO.monoNanosNow + let nuCppMs := (t3 - t2).toFloat / 1000000.0 + + -- Test instantiateMVarsNoUpdateLean with fresh mvars + let mvarId3 ← mkBench1 n + solve mvarId3 + let t4 ← IO.monoNanosNow + let r3 ← instantiateMVarsNoUpdateLean (mkMVar mvarId3) + let t5 ← IO.monoNanosNow + let nuLeanMs := (t5 - t4).toFloat / 1000000.0 + + -- Verify correctness + unless Expr.eqv r1 r2 do + IO.println s!"ERROR: instantiateMVars vs NoUpdate(C++) differ for n={n}" + unless Expr.eqv r1 r3 do + IO.println s!"ERROR: instantiateMVars vs NoUpdate(Lean) differ for n={n}" + + IO.println s!"bench1_{n}: instantiateMVars {instMs} ms, NoUpdate(C++) {nuCppMs} ms, NoUpdate(Lean) {nuLeanMs} ms" run_meta do IO.println "Example (n = 5):" let ex ← (← mkBench1 5).getType IO.println s!"{← ppExpr ex}" + IO.println "" for i in [10, 20, 40, 80, 100, 200, 300, 400, 500] do bench1 i From c5d6267cb8f6f80ba608630c6de04711c437949f Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Fri, 27 Feb 2026 23:04:34 +0000 Subject: [PATCH 08/45] perf: use instantiateMVarsNoUpdate as drop-in replacement This PR makes instantiateMVarsNoUpdate the default implementation behind lean_instantiate_expr_mvars. The NoUpdate variant carries a free variable substitution during traversal instead of doing repeated replace_fvars, avoiding quadratic behavior in terms with many delayed-assigned metavariables. Key fixes for drop-in compatibility: - Use name_hash_map for fvar substitution (structural name equality, not pointer comparison) - Always call apply_beta with preserve_data=false for regular mvar assignments to strip mdata wrappers like _recApp - Clear depth-dependent cache when extending fvar substitution in visit_delayed - Handle unassigned mvars with active fvar substitution by creating delayed assignments via elimMVarDeps - Match standard instantiateMVars guards for delayed assignment resolution Co-Authored-By: Claude Opus 4.6 --- src/Lean/Meta/InstMVarsNU.lean | 28 ++++- src/Lean/MetavarContext.lean | 18 +++ src/kernel/instantiate_mvars.cpp | 9 +- src/kernel/instantiate_mvars_no_update.cpp | 125 +++++++++++++------ tests/lean/run/instMVarsNUDelayed.lean | 133 +++++++++++++++++++++ 5 files changed, 269 insertions(+), 44 deletions(-) create mode 100644 tests/lean/run/instMVarsNUDelayed.lean diff --git a/src/Lean/Meta/InstMVarsNU.lean b/src/Lean/Meta/InstMVarsNU.lean index ea84a55bf650..03294224bd3c 100644 --- a/src/Lean/Meta/InstMVarsNU.lean +++ b/src/Lean/Meta/InstMVarsNU.lean @@ -163,7 +163,33 @@ partial def go (e : Expr) : M Expr := do let newE ← go newE -- logInfo m!"Instantiated {mkMVar mvarId} mvar assignment to {newE}" return newE - | none => pure e + | none => + let ctx ← read + if ctx.fvarSubst.isEmpty then + pure e + else + /- The mvar is unassigned and we have an active fvar substitution. The mvar's + eventual value might reference fvars from our substitution. We use + `elimMVarDeps` to create a delayed assignment that captures the substitution, + just like `mkForallFVars`/`mkLambdaFVars` would do when abstracting fvars + over an expression containing unassigned mvars. -/ + let mvarDecl ← mvarId.getDecl + let (fvars, values) := ctx.fvarSubst.foldl (init := (#[], #[])) + fun (fvars, values) fvarId dl => + if mvarDecl.lctx.contains fvarId then + (fvars.push (mkFVar fvarId), + values.push (dl.expr.liftLooseBVars (s := 0) (d := ctx.depth - dl.originalDepth))) + else + (fvars, values) + if fvars.isEmpty then + pure e + else + -- Abstract the fvars via elimMVarDeps (creates delayed assignment) + let e' ← (do + mvarId.withContext do + elimMVarDeps fvars (mkMVar mvarId) : MetaM Expr) + -- Replace fvars with their substitution values + pure (Expr.replaceFVars e' fvars values) modify fun s => { s with cache := s.cache.insert (ExprStructEq.mk e) e' } return e' diff --git a/src/Lean/MetavarContext.lean b/src/Lean/MetavarContext.lean index cc600d2480e5..f32e96af3253 100644 --- a/src/Lean/MetavarContext.lean +++ b/src/Lean/MetavarContext.lean @@ -1480,6 +1480,24 @@ def levelMVarToParam (mctx : MetavarContext) (alreadyUsedPred : Name → Bool) ( def getExprAssignmentDomain (mctx : MetavarContext) : Array MVarId := mctx.eAssignment.foldl (init := #[]) fun a mvarId _ => Array.push a mvarId +/-- +Abstract the given fvars from an unassigned mvar by creating a delayed-assigned mvar. +Returns `(mctx', result)` where `result` has the fvars replaced by `values`. +This is used by `instantiateMVarsNoUpdate` when encountering an unassigned mvar +with an active fvar substitution. +-/ +@[export lean_abstract_mvar_fvars] +def abstractMVarFVars (mctx : MetavarContext) (mvarId : MVarId) (fvars : Array Expr) (values : Array Expr) : MetavarContext × Expr := + match mctx.decls.find? mvarId with + | none => (mctx, mkMVar mvarId) + | some _mvarDecl => + let ngen : NameGenerator := { namePrefix := `_noUpdate, idx := mctx.mvarCounter } + let ctx : MkBinding.Context := { quotContext := `_root_, preserveOrder := false } + let state : MkBinding.State := { mctx, nextMacroScope := 0, ngen } + match (MkBinding.elimMVarDeps fvars (mkMVar mvarId) ctx).run state with + | .ok e s => (s.mctx, Expr.replaceFVars e fvars values) + | .error _ s => (s.mctx, mkMVar mvarId) + end MetavarContext namespace MVarId diff --git a/src/kernel/instantiate_mvars.cpp b/src/kernel/instantiate_mvars.cpp index b49d74aa5cb5..b5a6aafb57b4 100644 --- a/src/kernel/instantiate_mvars.cpp +++ b/src/kernel/instantiate_mvars.cpp @@ -362,12 +362,9 @@ class instantiate_mvars_fn { expr operator()(expr const & e) { return visit(e); } }; +extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_no_update(object *, object *); + extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars(object * m, object * e) { - metavar_ctx mctx(m); - expr e_new = instantiate_mvars_fn(mctx)(expr(e)); - object * r = alloc_cnstr(0, 2, 0); - cnstr_set(r, 0, mctx.steal()); - cnstr_set(r, 1, e_new.steal()); - return r; + return lean_instantiate_expr_mvars_no_update(m, e); } } diff --git a/src/kernel/instantiate_mvars_no_update.cpp b/src/kernel/instantiate_mvars_no_update.cpp index 0cbe61ad7cc3..60971360cf2a 100644 --- a/src/kernel/instantiate_mvars_no_update.cpp +++ b/src/kernel/instantiate_mvars_no_update.cpp @@ -7,6 +7,7 @@ Authors: Joachim Breitner #include #include #include "util/name_set.h" +#include "util/name_hash_map.h" #include "runtime/option_ref.h" #include "runtime/array_ref.h" #include "kernel/instantiate.h" @@ -42,6 +43,7 @@ extern "C" object * lean_assign_lmvar(obj_arg mctx, obj_arg mid, obj_arg val); extern "C" object * lean_get_mvar_assignment(obj_arg mctx, obj_arg mid); extern "C" object * lean_get_delayed_mvar_assignment(obj_arg mctx, obj_arg mid); extern "C" object * lean_assign_mvar(obj_arg mctx, obj_arg mid, obj_arg val); +extern "C" object * lean_abstract_mvar_fvars(obj_arg mctx, obj_arg mid, obj_arg fvars, obj_arg values); typedef object_ref metavar_ctx; typedef object_ref delayed_assignment; @@ -68,6 +70,20 @@ static option_ref get_delayed_mvar_assignment(metavar_ctx & return option_ref(lean_get_delayed_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); } +/* Abstract fvars from an unassigned mvar by creating a delayed-assigned mvar. + Returns a pair (mctx', result) where result has the fvars replaced by values. + Uses the Lean-implemented elimMVarDeps machinery. */ +static object_ref abstract_mvar_fvars(metavar_ctx & mctx, name const & mid, + array_ref const & fvars, array_ref const & values) { + object * r = lean_abstract_mvar_fvars(mctx.steal(), mid.to_obj_arg(), fvars.to_obj_arg(), values.to_obj_arg()); + /* result is a pair (MetavarContext, Expr) */ + mctx.set_box(cnstr_get(r, 0)); + lean_inc(cnstr_get(r, 0)); + expr result(cnstr_get(r, 1), true); + lean_dec(r); + return object_ref(result.steal()); +} + /* Level metavariable instantiation -- same as in instantiate_mvars.cpp, but we DO update level mvar assignments (levels don't involve fvars). */ class instantiate_lmvars_nu_fn { @@ -131,8 +147,9 @@ struct fvar_subst_entry { class instantiate_mvars_no_update_fn { metavar_ctx & m_mctx; instantiate_lmvars_nu_fn m_level_fn; - /* The fvar substitution, mapping fvar names to (depth, value) pairs. */ - lean::unordered_map m_fvar_subst; + /* The fvar substitution, mapping fvar names to (depth, value) pairs. + Uses name_hash_map for structural name equality (not pointer equality). */ + name_hash_map m_fvar_subst; /* Current binder depth. */ unsigned m_depth; /* Expression cache. Cleared when going under binders. */ @@ -162,7 +179,7 @@ class instantiate_mvars_no_update_fn { /* Look up an fvar in our substitution. Returns none if not found. */ optional lookup_fvar(name const & fid) { - auto it = m_fvar_subst.find(fid.raw()); + auto it = m_fvar_subst.find(fid); if (it == m_fvar_subst.end()) return optional(); unsigned d = m_depth - it->second.depth; @@ -182,13 +199,10 @@ class instantiate_mvars_no_update_fn { if (!r) return optional(); expr a(r.get_val()); - if (!has_mvar(a) && !has_fvar(a)) { - return optional(a); - } if (fvar_subst_empty()) { - /* Sound to update: no fvar substitution active, so the result - is identical to what instantiateMVars would compute. */ - if (m_already_normalized.contains(mid)) { + /* Match standard instantiateMVars behavior exactly: + return immediately if no mvars or already normalized. */ + if (!has_mvar(a) || m_already_normalized.contains(mid)) { return optional(a); } m_already_normalized.insert(mid); @@ -199,7 +213,11 @@ class instantiate_mvars_no_update_fn { } return optional(a_new); } else { - /* Cannot update: fvar substitution is active. Just visit. */ + /* Fvar substitution is active. Cannot update assignments. + Return immediately if nothing to process. */ + if (!has_mvar(a) && !has_fvar(a)) { + return optional(a); + } return optional(visit(a)); } } @@ -260,32 +278,39 @@ class instantiate_mvars_no_update_fn { /* Save and extend the fvar substitution. The substitution values are the (already visited) args at indices [args.size()-1 .. args.size()-fvar_count] (reversed, since args is built in reverse). */ - struct saved_entry { lean_object * key; bool had_old; fvar_subst_entry old; }; + struct saved_entry { name key; bool had_old; fvar_subst_entry old; }; std::vector saved_entries; saved_entries.reserve(fvar_count); for (size_t i = 0; i < fvar_count; i++) { name const & fid = fvar_name(fvars[i]); - auto old_it = m_fvar_subst.find(fid.raw()); + auto old_it = m_fvar_subst.find(fid); if (old_it != m_fvar_subst.end()) { - saved_entries.push_back({fid.raw(), true, old_it->second}); + saved_entries.push_back({fid, true, old_it->second}); } else { - saved_entries.push_back({fid.raw(), false, {0, expr()}}); + saved_entries.push_back({fid, false, {0, expr()}}); } /* args is reversed: args[args.size()-1] corresponds to the first argument. fvars[i] should be mapped to the i-th argument, which is args[args.size() - 1 - i]. */ - m_fvar_subst[fid.raw()] = {m_depth, args[args.size() - 1 - i]}; + m_fvar_subst[fid] = {m_depth, args[args.size() - 1 - i]}; } - /* Visit ?pending with the extended substitution */ + /* Visit ?pending with the extended substitution. + We must use a fresh depth-dependent cache because the fvar substitution + has changed: existing cache entries were computed with the old substitution + and would produce incorrect results. (The global cache is fine since it + only contains entries for fvar-free expressions.) */ + lean::unordered_map saved_cache; + saved_cache.swap(m_cache); expr val_new = visit(mk_mvar(mid_pending)); + saved_cache.swap(m_cache); /* Restore the fvar substitution */ - for (size_t i = 0; i < saved_entries.size(); i++) { - if (!saved_entries[i].had_old) { - m_fvar_subst.erase(saved_entries[i].key); + for (auto & se : saved_entries) { + if (!se.had_old) { + m_fvar_subst.erase(se.key); } else { - m_fvar_subst[saved_entries[i].key] = saved_entries[i].old; + m_fvar_subst[se.key] = se.old; } } @@ -299,20 +324,13 @@ class instantiate_mvars_no_update_fn { return visit_app_default(e); } else { name const & mid = mvar_name(f); - /* Regular assignments take precedence over delayed ones. */ + /* Regular assignments take precedence over delayed ones. + Always use visit_args_and_beta (with preserve_data=false) to match + the standard instantiateMVars behavior: apply_beta strips mdata + wrappers like _recApp even for non-lambda assignments. */ if (auto f_new = get_assignment(mid)) { buffer args; - if (is_lambda(*f_new)) { - return visit_args_and_beta(*f_new, e, args); - } else { - /* Visit args and rebuild */ - expr const * curr = &e; - while (is_app(*curr)) { - args.push_back(visit(app_arg(*curr))); - curr = &app_fn(*curr); - } - return mk_rev_app(*f_new, args.size(), args.data()); - } + return visit_args_and_beta(*f_new, e, args); } option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (!d) { @@ -323,8 +341,15 @@ class instantiate_mvars_no_update_fn { if (fvars.size() > get_app_num_args(e)) { return visit_mvar_app_args(e); } - /* Unlike the standard version, we always attempt to instantiate - the delayed assignment by extending our fvar substitution. */ + /* When fvar subst is empty, use the same guards as the standard version: + only resolve when pending is assigned and its normalized value has no mvars. + When fvar subst is non-empty, always resolve (using delayed mvar creation + for unassigned mvars). */ + if (fvar_subst_empty()) { + optional pending_val = get_assignment(mid_pending); + if (!pending_val || has_expr_mvar(*pending_val)) + return visit_mvar_app_args(e); + } buffer args; return visit_delayed(fvars, mid_pending, e, args); } @@ -334,8 +359,26 @@ class instantiate_mvars_no_update_fn { name const & mid = mvar_name(e); if (auto r = get_assignment(mid)) { return *r; - } else { + } else if (fvar_subst_empty()) { return e; + } else { + /* The mvar is unassigned and we have an active fvar substitution. + Create a delayed assignment that captures the substitution, + so that when the mvar is eventually assigned, the substitution + is correctly applied. */ + lean_object * fvars_arr = lean_mk_empty_array(); + lean_object * values_arr = lean_mk_empty_array(); + for (auto & entry : m_fvar_subst) { + name const & fid = entry.first; + expr fv = mk_fvar(fid); + fvars_arr = lean_array_push(fvars_arr, fv.steal()); + unsigned d = m_depth - entry.second.depth; + expr val = (d == 0) ? entry.second.value : lift_loose_bvars(entry.second.value, d); + values_arr = lean_array_push(values_arr, val.steal()); + } + object_ref result(abstract_mvar_fvars(m_mctx, + mid, array_ref(fvars_arr), array_ref(values_arr))); + return expr(result.steal()); } } @@ -352,8 +395,16 @@ class instantiate_mvars_no_update_fn { : m_mctx(mctx), m_level_fn(mctx), m_depth(0) {} expr visit(expr const & e) { - if (!has_mvar(e) && !has_fvar(e)) - return e; + /* When fvar_subst is empty, match the standard instantiateMVars behavior: + only process expressions that have mvars. When fvar_subst is active, + also process expressions that have fvars (for substitution). */ + if (fvar_subst_empty()) { + if (!has_mvar(e)) + return e; + } else { + if (!has_mvar(e) && !has_fvar(e)) + return e; + } /* If the expression has no FVars, the result is independent of the current binder depth and fvar substitution, so we can use the global cache. */ diff --git a/tests/lean/run/instMVarsNUDelayed.lean b/tests/lean/run/instMVarsNUDelayed.lean new file mode 100644 index 000000000000..e118593355de --- /dev/null +++ b/tests/lean/run/instMVarsNUDelayed.lean @@ -0,0 +1,133 @@ +import Lean +/-! +# Tests for `instantiateMVarsNoUpdate` handling of unassigned metavariables + +These tests verify that `instantiateMVarsNoUpdate` correctly creates delayed-assigned +metavariables when encountering unassigned mvars with an active fvar substitution. +This is essential for using `instantiateMVarsNoUpdate` as a drop-in replacement +for `instantiateMVars` in contexts like `mkForallFVars`/`mkLambdaFVars`. +-/ + +open Lean Meta + +/-- Test 1: Basic delayed assignment with unassigned pending mvar. + +When `?m [x] := ?pending` and `?pending` is unassigned, +`instantiateMVarsNoUpdate` should create a new delayed mvar +that captures the fvar substitution. -/ +def test1 : MetaM Unit := do + withLocalDecl `x .default (mkConst ``Nat) fun x => do + let pending ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) + let m ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) + assignDelayedMVar m.mvarId! #[x] pending.mvarId! + + -- Apply ?m to a concrete value (not x) + let e := mkApp m (mkNatLit 7) + + -- instantiateMVarsNoUpdate should produce an expression without fvar x + let e_nu ← instantiateMVarsNoUpdate e + unless !e_nu.hasFVar do + throwError "test1: result should not have free variables, got {← ppExpr e_nu}" + + -- Assign pending to x + 1 + pending.mvarId!.assign (mkApp2 (mkConst ``Nat.add) x (mkNatLit 1)) + + -- After assignment, both should resolve to the same thing + let e_nu_final ← instantiateMVars e_nu + let e_inst_final ← instantiateMVars e + unless Expr.eqv e_nu_final e_inst_final do + throwError "test1: results differ:\n NoUpdate: {← ppExpr e_nu_final}\n instantiate: {← ppExpr e_inst_final}" + +/-- Test 2: Nested delayed assignments. + +`?outer [x, y] := ?inner`, `?inner` assigned to expression containing `?leaf`, +where `?leaf` is unassigned. -/ +def test2 : MetaM Unit := do + withLocalDecl `x .default (mkConst ``Nat) fun x => do + withLocalDecl `y .default (mkConst ``Nat) fun y => do + let leaf ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) + let inner ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) + -- inner := leaf (a simple assignment) + inner.mvarId!.assign leaf + + let outer ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) + assignDelayedMVar outer.mvarId! #[x, y] inner.mvarId! + + -- Apply outer to concrete values + let e := mkApp (mkApp outer (mkNatLit 3)) (mkNatLit 5) + + let e_nu ← instantiateMVarsNoUpdate e + unless !e_nu.hasFVar do + throwError "test2: result should not have free variables, got {e_nu}" + + -- Assign leaf to x + y + leaf.mvarId!.assign (mkApp2 (mkConst ``Nat.add) x y) + + let e_nu_final ← instantiateMVars e_nu + let e_inst_final ← instantiateMVars e + unless Expr.eqv e_nu_final e_inst_final do + throwError "test2: results differ:\n NoUpdate: {← ppExpr e_nu_final}\n instantiate: {← ppExpr e_inst_final}" + +/-- Test 3: Multiple unassigned mvars under a single delayed assignment. -/ +def test3 : MetaM Unit := do + withLocalDecl `x .default (mkConst ``Nat) fun x => do + let m1 ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) + let m2 ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) + + -- inner is assigned to And.intro m1 m2 + let innerType := mkApp2 (mkConst ``And) (mkConst ``Nat) (mkConst ``Nat) + let inner ← mkFreshExprSyntheticOpaqueMVar innerType + inner.mvarId!.assign (mkApp4 (mkConst ``And.intro [.zero, .zero]) (mkConst ``Nat) (mkConst ``Nat) m1 m2) + + let outer ← mkFreshExprSyntheticOpaqueMVar innerType + assignDelayedMVar outer.mvarId! #[x] inner.mvarId! + + let e := mkApp outer (mkNatLit 10) + + let e_nu ← instantiateMVarsNoUpdate e + unless !e_nu.hasFVar do + throwError "test3: result should not have free variables, got {e_nu}" + + -- Assign both mvars + m1.mvarId!.assign x + m2.mvarId!.assign (mkApp2 (mkConst ``Nat.add) x (mkNatLit 1)) + + let e_nu_final ← instantiateMVars e_nu + let e_inst_final ← instantiateMVars e + unless Expr.eqv e_nu_final e_inst_final do + throwError "test3: results differ:\n NoUpdate: {← ppExpr e_nu_final}\n instantiate: {← ppExpr e_inst_final}" + +/-- Test 4: Unassigned mvar whose context does NOT include the substituted fvar. -/ +def test4 : MetaM Unit := do + withLocalDecl `x .default (mkConst ``Nat) fun x => do + withLocalDecl `y .default (mkConst ``Nat) fun y => do + -- Create pending mvar in full context (has both x and y) + let pending ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) + + -- Delayed assignment only over x + let outer ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) + assignDelayedMVar outer.mvarId! #[x] pending.mvarId! + + let e := mkApp outer (mkNatLit 42) + + let e_nu ← instantiateMVarsNoUpdate e + + -- Assign pending to y (doesn't use x, the substituted fvar) + pending.mvarId!.assign y + + let e_nu_final ← instantiateMVars e_nu + let e_inst_final ← instantiateMVars e + unless Expr.eqv e_nu_final e_inst_final do + throwError "test4: results differ:\n NoUpdate: {← ppExpr e_nu_final}\n instantiate: {← ppExpr e_inst_final}" + +-- Run all tests +run_meta do + test1 + IO.println "test1 passed" + test2 + IO.println "test2 passed" + test3 + IO.println "test3 passed" + test4 + IO.println "test4 passed" + IO.println "All tests passed!" From 0cc92df9ade8913c689b7479048d02d7ec0f83b1 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Fri, 27 Feb 2026 23:13:17 +0000 Subject: [PATCH 09/45] Undo changes to Injective --- src/Lean/Meta/Injective.lean | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Lean/Meta/Injective.lean b/src/Lean/Meta/Injective.lean index 3cb213d9060c..6b6cc7b1c824 100644 --- a/src/Lean/Meta/Injective.lean +++ b/src/Lean/Meta/Injective.lean @@ -11,7 +11,6 @@ import Lean.Meta.Tactic.Cases import Lean.Meta.Tactic.Assumption import Lean.Meta.Tactic.Simp.Main import Lean.Meta.SameCtorUtils -import Lean.Meta.InstMVarsNU public section namespace Lean.Meta @@ -168,14 +167,13 @@ private def mkInjectiveEqTheorem (ctorVal : ConstructorVal) : MetaM Unit := do withTraceNode `Meta.injective (msg := (return m!"{exceptEmoji ·} generating `{name}`")) do let some type ← mkInjectiveEqTheoremType? ctorVal | return () - let type ← instantiateMVars type trace[Meta.injective] "type: {type}" let value ← mkInjectiveEqTheoremValue ctorVal type addDecl <| Declaration.thmDecl { name levelParams := ctorVal.levelParams - type := type - value := (← instantiateMVarsNoUpdate value) + type := (← instantiateMVars type) + value := (← instantiateMVars value) } addSimpTheorem (ext := simpExtension) name (post := true) (inv := false) AttributeKind.global (prio := eval_prio default) From 2c6ee17548ef8141ec2094212121e35e6f422943 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Sat, 28 Feb 2026 09:54:53 +0000 Subject: [PATCH 10/45] perf: add instantiateAllMVars for call sites where all mvars are assigned This PR adds `instantiateAllMVars`, a variant of `instantiateMVars` that fails fast when encountering an unassigned mvar under an active fvar substitution, instead of calling the expensive `elimMVarDeps` machinery. Use it at call sites where all mvars are known to be assigned. Reverts the drop-in replacement of `instantiateMVars` with `instantiateMVarsNoUpdate` and instead targets `instantiateMVarsAtPreDecls` as the primary call site. Co-Authored-By: Claude Opus 4.6 --- src/Lean/Elab/PreDefinition/Basic.lean | 4 +- src/Lean/Meta/InstMVarsAll.lean | 27 ++ src/kernel/CMakeLists.txt | 1 + src/kernel/instantiate_mvars.cpp | 9 +- src/kernel/instantiate_mvars_all.cpp | 423 +++++++++++++++++++++++++ 5 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 src/Lean/Meta/InstMVarsAll.lean create mode 100644 src/kernel/instantiate_mvars_all.cpp diff --git a/src/Lean/Elab/PreDefinition/Basic.lean b/src/Lean/Elab/PreDefinition/Basic.lean index 812e56198b0a..a5c2487e52d5 100644 --- a/src/Lean/Elab/PreDefinition/Basic.lean +++ b/src/Lean/Elab/PreDefinition/Basic.lean @@ -11,6 +11,8 @@ public import Lean.Util.NumApps public import Lean.Meta.Eqns public import Lean.Elab.RecAppSyntax public import Lean.Elab.DefView +import Lean.Meta.InstMVarsAll +import Lean.Meta.InstMVarsNU public section @@ -48,7 +50,7 @@ Applies `Lean.instantiateMVars` to the types of values of each predefinition. -/ def instantiateMVarsAtPreDecls (preDefs : Array PreDefinition) : TermElabM (Array PreDefinition) := preDefs.mapM fun preDef => do - pure { preDef with type := (← instantiateMVars preDef.type), value := (← instantiateMVars preDef.value) } + pure { preDef with type := (← instantiateAllMVars preDef.type), value := (← instantiateAllMVars preDef.value) } /-- Applies `Lean.Elab.Term.levelMVarToParam` to the types of each predefinition. diff --git a/src/Lean/Meta/InstMVarsAll.lean b/src/Lean/Meta/InstMVarsAll.lean new file mode 100644 index 000000000000..3fd4f1cadaaf --- /dev/null +++ b/src/Lean/Meta/InstMVarsAll.lean @@ -0,0 +1,27 @@ +/- +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Authors: Joachim Breitner +-/ + +module + +prelude +public import Lean.Meta.Basic + +namespace Lean.Meta + +@[extern "lean_instantiate_expr_mvars_all"] +private opaque instantiateAllMVarsImp (mctx : MetavarContext) (e : Expr) : + Option (MetavarContext × Expr) + +/-- Like `instantiateMVars` but throws if an unassigned mvar is encountered + under an active fvar substitution. Use at call sites where all mvars are + known to be assigned — a throw indicates a wrong assumption. -/ +public def instantiateAllMVars (e : Expr) : MetaM Expr := do + if !e.hasMVar then return e + match instantiateAllMVarsImp (← getMCtx) e with + | some (mctx, eNew) => modifyMCtx fun _ => mctx; return eNew + | none => throwError "instantiateAllMVars: unexpected unassigned mvar in {e}" + +end Lean.Meta diff --git a/src/kernel/CMakeLists.txt b/src/kernel/CMakeLists.txt index 30b35ea2be18..dd2023574c10 100644 --- a/src/kernel/CMakeLists.txt +++ b/src/kernel/CMakeLists.txt @@ -20,4 +20,5 @@ add_library( trace.cpp instantiate_mvars.cpp instantiate_mvars_no_update.cpp + instantiate_mvars_all.cpp ) diff --git a/src/kernel/instantiate_mvars.cpp b/src/kernel/instantiate_mvars.cpp index b5a6aafb57b4..b49d74aa5cb5 100644 --- a/src/kernel/instantiate_mvars.cpp +++ b/src/kernel/instantiate_mvars.cpp @@ -362,9 +362,12 @@ class instantiate_mvars_fn { expr operator()(expr const & e) { return visit(e); } }; -extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_no_update(object *, object *); - extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars(object * m, object * e) { - return lean_instantiate_expr_mvars_no_update(m, e); + metavar_ctx mctx(m); + expr e_new = instantiate_mvars_fn(mctx)(expr(e)); + object * r = alloc_cnstr(0, 2, 0); + cnstr_set(r, 0, mctx.steal()); + cnstr_set(r, 1, e_new.steal()); + return r; } } diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp new file mode 100644 index 000000000000..df3e233f9823 --- /dev/null +++ b/src/kernel/instantiate_mvars_all.cpp @@ -0,0 +1,423 @@ +/* +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. + +Authors: Joachim Breitner +*/ +#include +#include +#include "util/name_set.h" +#include "util/name_hash_map.h" +#include "runtime/option_ref.h" +#include "runtime/array_ref.h" +#include "kernel/instantiate.h" +#include "kernel/replace_fn.h" +#include "kernel/expr.h" + +/* +This module provides an efficient C++ implementation of `instantiateAllMVars`. + +Like `instantiateMVarsNoUpdate`, this variant carries a substitution of free +variables as it traverses delayed-assigned metavariables. However, unlike that +variant, when it encounters an unassigned metavariable under an active fvar +substitution, it simply fails (returning `none`) instead of calling the +expensive `elimMVarDeps` / `abstract_mvar_fvars` machinery. + +Use this at call sites where all mvars are known to be assigned. A failure +indicates a wrong assumption about the call site. +*/ + +namespace lean { +extern "C" object * lean_get_lmvar_assignment(obj_arg mctx, obj_arg mid); +extern "C" object * lean_assign_lmvar(obj_arg mctx, obj_arg mid, obj_arg val); +extern "C" object * lean_get_mvar_assignment(obj_arg mctx, obj_arg mid); +extern "C" object * lean_get_delayed_mvar_assignment(obj_arg mctx, obj_arg mid); +extern "C" object * lean_assign_mvar(obj_arg mctx, obj_arg mid, obj_arg val); + +typedef object_ref metavar_ctx; +typedef object_ref delayed_assignment; + +static void assign_lmvar(metavar_ctx & mctx, name const & mid, level const & l) { + object * r = lean_assign_lmvar(mctx.steal(), mid.to_obj_arg(), l.to_obj_arg()); + mctx.set_box(r); +} + +static option_ref get_lmvar_assignment(metavar_ctx & mctx, name const & mid) { + return option_ref(lean_get_lmvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); +} + +static void assign_mvar(metavar_ctx & mctx, name const & mid, expr const & e) { + object * r = lean_assign_mvar(mctx.steal(), mid.to_obj_arg(), e.to_obj_arg()); + mctx.set_box(r); +} + +static option_ref get_mvar_assignment(metavar_ctx & mctx, name const & mid) { + return option_ref(lean_get_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); +} + +static option_ref get_delayed_mvar_assignment(metavar_ctx & mctx, name const & mid) { + return option_ref(lean_get_delayed_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); +} + +/* Level metavariable instantiation -- same as in instantiate_mvars_no_update.cpp. */ +class instantiate_lmvars_all_fn { + metavar_ctx & m_mctx; + lean::unordered_map m_cache; + std::vector m_saved; + + inline level cache(level const & l, level r, bool shared) { + if (shared) { + m_cache.insert(mk_pair(l.raw(), r)); + } + return r; + } +public: + instantiate_lmvars_all_fn(metavar_ctx & mctx):m_mctx(mctx) {} + level visit(level const & l) { + if (!has_mvar(l)) + return l; + bool shared = false; + if (is_shared(l)) { + auto it = m_cache.find(l.raw()); + if (it != m_cache.end()) { + return it->second; + } + shared = true; + } + switch (l.kind()) { + case level_kind::Succ: + return cache(l, update_succ(l, visit(succ_of(l))), shared); + case level_kind::Max: case level_kind::IMax: + return cache(l, update_max(l, visit(level_lhs(l)), visit(level_rhs(l))), shared); + case level_kind::Zero: case level_kind::Param: + lean_unreachable(); + case level_kind::MVar: { + option_ref r = get_lmvar_assignment(m_mctx, mvar_id(l)); + if (!r) { + return l; + } else { + level a(r.get_val()); + if (!has_mvar(a)) { + return a; + } else { + level a_new = visit(a); + if (!is_eqp(a, a_new)) { + m_saved.push_back(a); + assign_lmvar(m_mctx, mvar_id(l), a_new); + } + return a_new; + } + } + }} + } + level operator()(level const & l) { return visit(l); } +}; + +struct fvar_subst_entry { + unsigned depth; + expr value; +}; + +class instantiate_mvars_all_fn { + metavar_ctx & m_mctx; + instantiate_lmvars_all_fn m_level_fn; + name_hash_map m_fvar_subst; + unsigned m_depth; + lean::unordered_map m_cache; + lean::unordered_map m_global_cache; + std::vector m_saved; + name_set m_already_normalized; + bool m_failed = false; + + level visit_level(level const & l) { + return m_level_fn(l); + } + + levels visit_levels(levels const & ls) { + buffer lsNew; + for (auto const & l : ls) + lsNew.push_back(visit_level(l)); + return levels(lsNew); + } + + bool fvar_subst_empty() const { + return m_fvar_subst.empty(); + } + + optional lookup_fvar(name const & fid) { + auto it = m_fvar_subst.find(fid); + if (it == m_fvar_subst.end()) + return optional(); + unsigned d = m_depth - it->second.depth; + if (d == 0) + return optional(it->second.value); + return optional(lift_loose_bvars(it->second.value, d)); + } + + optional get_assignment(name const & mid) { + option_ref r = get_mvar_assignment(m_mctx, mid); + if (!r) + return optional(); + expr a(r.get_val()); + if (fvar_subst_empty()) { + if (!has_mvar(a) || m_already_normalized.contains(mid)) { + return optional(a); + } + m_already_normalized.insert(mid); + expr a_new = visit(a); + if (!is_eqp(a, a_new)) { + m_saved.push_back(a); + assign_mvar(m_mctx, mid, a_new); + } + return optional(a_new); + } else { + if (!has_mvar(a) && !has_fvar(a)) { + return optional(a); + } + return optional(visit(a)); + } + } + + expr visit_app_default(expr const & e) { + buffer args; + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + lean_assert(!is_mvar(*curr)); + expr f = visit(*curr); + return mk_rev_app(f, args.size(), args.data()); + } + + expr visit_mvar_app_args(expr const & e) { + buffer args; + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + lean_assert(is_mvar(*curr)); + return mk_rev_app(*curr, args.size(), args.data()); + } + + expr visit_args_and_beta(expr const & f_new, expr const & e, buffer & args) { + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + bool preserve_data = false; + bool zeta = true; + return apply_beta(f_new, args.size(), args.data(), preserve_data, zeta); + } + + expr visit_delayed(array_ref const & fvars, name const & mid_pending, + expr const & e, buffer & args) { + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + + size_t fvar_count = fvars.size(); + size_t extra_count = args.size() - fvar_count; + + struct saved_entry { name key; bool had_old; fvar_subst_entry old; }; + std::vector saved_entries; + saved_entries.reserve(fvar_count); + for (size_t i = 0; i < fvar_count; i++) { + name const & fid = fvar_name(fvars[i]); + auto old_it = m_fvar_subst.find(fid); + if (old_it != m_fvar_subst.end()) { + saved_entries.push_back({fid, true, old_it->second}); + } else { + saved_entries.push_back({fid, false, {0, expr()}}); + } + m_fvar_subst[fid] = {m_depth, args[args.size() - 1 - i]}; + } + + lean::unordered_map saved_cache; + saved_cache.swap(m_cache); + expr val_new = visit(mk_mvar(mid_pending)); + saved_cache.swap(m_cache); + + for (auto & se : saved_entries) { + if (!se.had_old) { + m_fvar_subst.erase(se.key); + } else { + m_fvar_subst[se.key] = se.old; + } + } + + return mk_rev_app(val_new, extra_count, args.data()); + } + + expr visit_app(expr const & e) { + expr const & f = get_app_fn(e); + if (!is_mvar(f)) { + return visit_app_default(e); + } else { + name const & mid = mvar_name(f); + if (auto f_new = get_assignment(mid)) { + buffer args; + return visit_args_and_beta(*f_new, e, args); + } + option_ref d = get_delayed_mvar_assignment(m_mctx, mid); + if (!d) { + if (!fvar_subst_empty()) { + /* Unassigned mvar under active fvar substitution -- fail */ + m_failed = true; + return e; + } + return visit_mvar_app_args(e); + } + array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + if (fvars.size() > get_app_num_args(e)) { + if (!fvar_subst_empty()) { + m_failed = true; + return e; + } + return visit_mvar_app_args(e); + } + if (fvar_subst_empty()) { + optional pending_val = get_assignment(mid_pending); + if (!pending_val || has_expr_mvar(*pending_val)) + return visit_mvar_app_args(e); + } + buffer args; + return visit_delayed(fvars, mid_pending, e, args); + } + } + + expr visit_mvar(expr const & e) { + name const & mid = mvar_name(e); + if (auto r = get_assignment(mid)) { + return *r; + } else if (fvar_subst_empty()) { + return e; + } else { + /* Unassigned mvar under active fvar substitution -- fail */ + m_failed = true; + return e; + } + } + + expr visit_fvar(expr const & e) { + name const & fid = fvar_name(e); + if (auto r = lookup_fvar(fid)) { + return *r; + } + return e; + } + +public: + instantiate_mvars_all_fn(metavar_ctx & mctx) + : m_mctx(mctx), m_level_fn(mctx), m_depth(0) {} + + bool failed() const { return m_failed; } + + expr visit(expr const & e) { + if (m_failed) return e; + + if (fvar_subst_empty()) { + if (!has_mvar(e)) + return e; + } else { + if (!has_mvar(e) && !has_fvar(e)) + return e; + } + + bool use_global = !has_fvar(e); + bool shared = false; + if (is_shared(e)) { + if (use_global) { + auto it = m_global_cache.find(e.raw()); + if (it != m_global_cache.end()) + return it->second; + } else { + auto it = m_cache.find(e.raw()); + if (it != m_cache.end()) + return it->second; + } + shared = true; + } + + expr r; + switch (e.kind()) { + case expr_kind::BVar: + case expr_kind::Lit: + lean_unreachable(); + case expr_kind::FVar: + return visit_fvar(e); + case expr_kind::Sort: + r = update_sort(e, visit_level(sort_level(e))); + break; + case expr_kind::Const: + r = update_const(e, visit_levels(const_levels(e))); + break; + case expr_kind::MVar: + return visit_mvar(e); + case expr_kind::MData: + r = update_mdata(e, visit(mdata_expr(e))); + break; + case expr_kind::Proj: + r = update_proj(e, visit(proj_expr(e))); + break; + case expr_kind::App: + r = visit_app(e); + break; + case expr_kind::Pi: case expr_kind::Lambda: { + expr d = visit(binding_domain(e)); + lean::unordered_map saved_cache; + saved_cache.swap(m_cache); + m_depth++; + expr b = visit(binding_body(e)); + m_depth--; + saved_cache.swap(m_cache); + r = update_binding(e, d, b); + break; + } + case expr_kind::Let: { + expr t = visit(let_type(e)); + expr v = visit(let_value(e)); + lean::unordered_map saved_cache; + saved_cache.swap(m_cache); + m_depth++; + expr b = visit(let_body(e)); + m_depth--; + saved_cache.swap(m_cache); + r = update_let(e, t, v, b); + break; + } + } + if (m_failed) return e; + if (shared) { + if (use_global) + m_global_cache.insert(mk_pair(e.raw(), r)); + else + m_cache.insert(mk_pair(e.raw(), r)); + } + return r; + } + + expr operator()(expr const & e) { return visit(e); } +}; + +extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all(object * m, object * e) { + metavar_ctx mctx(m); + instantiate_mvars_all_fn fn(mctx); + expr e_new = fn(expr(e)); + if (fn.failed()) { + return box(0); /* none */ + } + /* some (mctx, expr) */ + object * pair = alloc_cnstr(0, 2, 0); + cnstr_set(pair, 0, mctx.steal()); + cnstr_set(pair, 1, e_new.steal()); + object * r = alloc_cnstr(1, 1, 0); + cnstr_set(r, 0, pair); + return r; +} +} From 85c15561dd1f1d67c2bc0ce48ab13972152efb9e Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Mon, 2 Mar 2026 10:13:47 +0000 Subject: [PATCH 11/45] perf: two-pass architecture and scope-tracked sharing for instantiateAllMVars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite instantiateAllMVars as a two-pass algorithm: - Pass 1 resolves direct mvar assignments with write-back caching - Pass 2 resolves delayed assignments with fused fvar substitution Add instantiateAllMVarsSharing variant that uses a stack of (ptr, depth) caches with scope tracking to preserve sharing across visit_delayed boundaries. When lookup_fvar substitutes a fvar, it records which scope added it. Results are cached at the outermost valid scope level, so they persist when inner scopes are popped. This achieves O(n²) output sharing (matching standard instantiateMVars) vs O(n³) without sharing preservation. Co-Authored-By: Claude Opus 4.6 --- src/Lean/Meta/InstMVarsAll.lean | 21 +- src/kernel/instantiate_mvars_all.cpp | 480 ++++++++++++++---- .../lean/run/instantiateAllMVarsSharing.lean | 125 +++++ 3 files changed, 529 insertions(+), 97 deletions(-) create mode 100644 tests/lean/run/instantiateAllMVarsSharing.lean diff --git a/src/Lean/Meta/InstMVarsAll.lean b/src/Lean/Meta/InstMVarsAll.lean index 3fd4f1cadaaf..9322ff45914b 100644 --- a/src/Lean/Meta/InstMVarsAll.lean +++ b/src/Lean/Meta/InstMVarsAll.lean @@ -15,13 +15,32 @@ namespace Lean.Meta private opaque instantiateAllMVarsImp (mctx : MetavarContext) (e : Expr) : Option (MetavarContext × Expr) +@[extern "lean_instantiate_expr_mvars_all_sharing"] +private opaque instantiateAllMVarsSharingImp (mctx : MetavarContext) (e : Expr) : + Option (MetavarContext × Expr) + /-- Like `instantiateMVars` but throws if an unassigned mvar is encountered under an active fvar substitution. Use at call sites where all mvars are - known to be assigned — a throw indicates a wrong assumption. -/ + known to be assigned — a throw indicates a wrong assumption. + + Uses a fused single-pass approach (carries fvar substitution during traversal). -/ public def instantiateAllMVars (e : Expr) : MetaM Expr := do if !e.hasMVar then return e match instantiateAllMVarsImp (← getMCtx) e with | some (mctx, eNew) => modifyMCtx fun _ => mctx; return eNew | none => throwError "instantiateAllMVars: unexpected unassigned mvar in {e}" +/-- Like `instantiateAllMVars` but preserves sharing across delayed-mvar boundaries + using a scope-tracked cache stack. Results that only depend on outer-scope fvar + substitutions are cached at the outermost valid scope, so they are reused when + the same subexpression appears in sibling delayed-mvar resolutions. + + This produces nearly optimal output sharing (matching `instantiateMVars`) at the + cost of O(scope_depth) cache lookups instead of O(1). -/ +public def instantiateAllMVarsSharing (e : Expr) : MetaM Expr := do + if !e.hasMVar then return e + match instantiateAllMVarsSharingImp (← getMCtx) e with + | some (mctx, eNew) => modifyMCtx fun _ => mctx; return eNew + | none => throwError "instantiateAllMVarsSharing: unexpected unassigned mvar in {e}" + end Lean.Meta diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index df3e233f9823..17da2c8e4547 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -11,20 +11,26 @@ Authors: Joachim Breitner #include "runtime/option_ref.h" #include "runtime/array_ref.h" #include "kernel/instantiate.h" -#include "kernel/replace_fn.h" #include "kernel/expr.h" /* -This module provides an efficient C++ implementation of `instantiateAllMVars`. - -Like `instantiateMVarsNoUpdate`, this variant carries a substitution of free -variables as it traverses delayed-assigned metavariables. However, unlike that -variant, when it encounters an unassigned metavariable under an active fvar -substitution, it simply fails (returning `none`) instead of calling the -expensive `elimMVarDeps` / `abstract_mvar_fvars` machinery. - -Use this at call sites where all mvars are known to be assigned. A failure -indicates a wrong assumption about the call site. +This module provides `instantiateAllMVars`, a two-pass variant of `instantiateMVars` +that fails on unassigned metavariables instead of leaving them in place. + +Pass 1 (`instantiate_direct_fn`): + Standard `instantiateMVars`-like traversal that resolves direct mvar assignments + with write-back and a single persistent cache. For delayed assignments, it + pre-normalizes the pending value (resolving its direct chain) but leaves the + delayed mvar application in the expression. + +Pass 2 (`instantiate_delayed_fn`): + Fused traversal (like `instantiateMVarsNoUpdate`) that resolves delayed assignments + by carrying a fvar substitution. Since pass 1 has pre-normalized all direct chains, + each pending value is compact and visited once, avoiding the O(n³) sharing loss + that occurs when the fused approach must also chase direct chains. + +The combination preserves sharing (O(n²) output for the pathological nested-delayed +case) while avoiding the separate `replace_fvars` calls of the standard approach. */ namespace lean { @@ -59,7 +65,7 @@ static option_ref get_delayed_mvar_assignment(metavar_ctx & return option_ref(lean_get_delayed_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); } -/* Level metavariable instantiation -- same as in instantiate_mvars_no_update.cpp. */ +/* Level metavariable instantiation. */ class instantiate_lmvars_all_fn { metavar_ctx & m_mctx; lean::unordered_map m_cache; @@ -113,20 +119,18 @@ class instantiate_lmvars_all_fn { level operator()(level const & l) { return visit(l); } }; -struct fvar_subst_entry { - unsigned depth; - expr value; -}; +/* ============================================================================ + Pass 1: Resolve direct mvar assignments with write-back. + For delayed assignments, pre-normalize the pending value but leave the + delayed mvar application in the expression. + ============================================================================ */ -class instantiate_mvars_all_fn { +class instantiate_direct_fn { metavar_ctx & m_mctx; instantiate_lmvars_all_fn m_level_fn; - name_hash_map m_fvar_subst; - unsigned m_depth; + name_set m_already_normalized; lean::unordered_map m_cache; - lean::unordered_map m_global_cache; std::vector m_saved; - name_set m_already_normalized; bool m_failed = false; level visit_level(level const & l) { @@ -140,6 +144,200 @@ class instantiate_mvars_all_fn { return levels(lsNew); } + inline expr cache(expr const & e, expr r, bool shared) { + if (shared) { + m_cache.insert(mk_pair(e.raw(), r)); + } + return r; + } + + /* Get and normalize a direct mvar assignment. Write back the normalized value. */ + optional get_assignment(name const & mid) { + option_ref r = get_mvar_assignment(m_mctx, mid); + if (!r) { + return optional(); + } + expr a(r.get_val()); + if (!has_mvar(a) || m_already_normalized.contains(mid)) { + return optional(a); + } + m_already_normalized.insert(mid); + expr a_new = visit(a); + if (!is_eqp(a, a_new)) { + m_saved.push_back(a); + assign_mvar(m_mctx, mid, a_new); + } + return optional(a_new); + } + + expr visit_app_default(expr const & e) { + buffer args; + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + lean_assert(!is_mvar(*curr)); + expr f = visit(*curr); + return mk_rev_app(f, args.size(), args.data()); + } + + expr visit_mvar_app_args(expr const & e) { + buffer args; + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + lean_assert(is_mvar(*curr)); + return mk_rev_app(*curr, args.size(), args.data()); + } + + expr visit_args_and_beta(expr const & f_new, expr const & e, buffer & args) { + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + bool preserve_data = false; + bool zeta = true; + return apply_beta(f_new, args.size(), args.data(), preserve_data, zeta); + } + + expr visit_app(expr const & e) { + expr const & f = get_app_fn(e); + if (!is_mvar(f)) { + return visit_app_default(e); + } + name const & mid = mvar_name(f); + /* Direct assignment takes precedence. */ + if (auto f_new = get_assignment(mid)) { + buffer args; + return visit_args_and_beta(*f_new, e, args); + } + /* Check delayed assignment. */ + option_ref d = get_delayed_mvar_assignment(m_mctx, mid); + if (!d) { + /* Not assigned at all — fail. */ + m_failed = true; + return e; + } + /* Pre-normalize the pending value (triggers recursive normalization + of its direct chain via get_assignment + visit + write-back). */ + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + get_assignment(mid_pending); + /* Leave the delayed mvar in place, just visit args. */ + return visit_mvar_app_args(e); + } + + expr visit_mvar(expr const & e) { + name const & mid = mvar_name(e); + if (auto r = get_assignment(mid)) { + return *r; + } + /* Not directly assigned. Check if delayed-assigned and pre-normalize. */ + option_ref d = get_delayed_mvar_assignment(m_mctx, mid); + if (d) { + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + get_assignment(mid_pending); + return e; /* leave delayed mvar in place */ + } + /* Not assigned at all — fail. */ + m_failed = true; + return e; + } + +public: + instantiate_direct_fn(metavar_ctx & mctx):m_mctx(mctx), m_level_fn(mctx) {} + + bool failed() const { return m_failed; } + + expr visit(expr const & e) { + if (m_failed) return e; + if (!has_mvar(e)) + return e; + bool shared = false; + if (is_shared(e)) { + auto it = m_cache.find(e.raw()); + if (it != m_cache.end()) { + return it->second; + } + shared = true; + } + + switch (e.kind()) { + case expr_kind::BVar: + case expr_kind::Lit: case expr_kind::FVar: + lean_unreachable(); + case expr_kind::Sort: + return cache(e, update_sort(e, visit_level(sort_level(e))), shared); + case expr_kind::Const: + return cache(e, update_const(e, visit_levels(const_levels(e))), shared); + case expr_kind::MVar: + return visit_mvar(e); + case expr_kind::MData: + return cache(e, update_mdata(e, visit(mdata_expr(e))), shared); + case expr_kind::Proj: + return cache(e, update_proj(e, visit(proj_expr(e))), shared); + case expr_kind::App: + return cache(e, visit_app(e), shared); + case expr_kind::Pi: case expr_kind::Lambda: + return cache(e, update_binding(e, visit(binding_domain(e)), visit(binding_body(e))), shared); + case expr_kind::Let: + return cache(e, update_let(e, visit(let_type(e)), visit(let_value(e)), visit(let_body(e))), shared); + } + } + + expr operator()(expr const & e) { return visit(e); } +}; + +/* ============================================================================ + Pass 2: Resolve delayed assignments with fused fvar substitution. + Direct mvar chains have been pre-resolved by pass 1. + + Two modes controlled by `m_preserve_sharing`: + - false: simple cache swap on visit_delayed boundaries (fast, may lose sharing) + - true: stack of (ptr, depth) caches with scope tracking; results are cached + at the outermost valid scope so they persist across visit_delayed + boundaries, preserving sharing at near-zero extra cost. + ============================================================================ */ + +struct fvar_subst_entry { + unsigned depth; + unsigned scope; + expr value; +}; + +class instantiate_delayed_fn { + struct key_hasher { + std::size_t operator()(std::pair const & p) const { + return hash((size_t)p.first >> 3, p.second); + } + }; + + typedef lean::unordered_map, expr, key_hasher> depth_cache; + + metavar_ctx & m_mctx; + name_hash_map m_fvar_subst; + unsigned m_depth; + bool m_preserve_sharing; + + /* Stack of (ptr, depth)-keyed caches, one per visit_delayed scope. + Level 0 is the outermost scope (before any visit_delayed). + In non-sharing mode, only level m_scope is used (others are empty). */ + std::vector m_cache_stack; + unsigned m_scope; + + /* [sharing mode] After visit() returns, this holds the maximum fvar-substitution + scope that contributed to the result — i.e., the outermost scope at which the + result is valid and can be cached. Updated monotonically (via max) through + the save/reset/restore pattern in visit(). */ + unsigned m_result_scope; + + /* Global cache for fvar-free expressions — scope-independent. */ + lean::unordered_map m_global_cache; + bool m_failed = false; + bool fvar_subst_empty() const { return m_fvar_subst.empty(); } @@ -148,34 +346,61 @@ class instantiate_mvars_all_fn { auto it = m_fvar_subst.find(fid); if (it == m_fvar_subst.end()) return optional(); + if (m_preserve_sharing) { + m_result_scope = std::max(m_result_scope, it->second.scope); + } unsigned d = m_depth - it->second.depth; if (d == 0) return optional(it->second.value); return optional(lift_loose_bvars(it->second.value, d)); } + /* Cache lookup. + Non-sharing mode: check only the current (innermost) scope level. + Sharing mode: scan from outermost to innermost; first hit wins. + (Under the assumption that fvar names are unique across scopes, + each (ptr, depth) appears in at most one level, so direction + doesn't matter — but outermost-first is efficient since most + entries live at low scope levels.) */ + optional cache_lookup(lean_object * ptr) { + auto key = mk_pair(ptr, m_depth); + if (m_preserve_sharing) { + for (unsigned s = 0; s <= m_scope; s++) { + auto it = m_cache_stack[s].find(key); + if (it != m_cache_stack[s].end()) { + m_result_scope = std::max(m_result_scope, s); + return optional(it->second); + } + } + } else { + auto it = m_cache_stack[m_scope].find(key); + if (it != m_cache_stack[m_scope].end()) + return optional(it->second); + } + return optional(); + } + + void cache_insert(lean_object * ptr, expr const & result) { + auto key = mk_pair(ptr, m_depth); + if (m_preserve_sharing) { + m_cache_stack[m_result_scope].insert(mk_pair(key, result)); + } else { + m_cache_stack[m_scope].insert(mk_pair(key, result)); + } + } + + /* Get a direct mvar assignment. Since pass 1 has pre-normalized, + we just read the value without further normalization. */ optional get_assignment(name const & mid) { option_ref r = get_mvar_assignment(m_mctx, mid); if (!r) return optional(); expr a(r.get_val()); - if (fvar_subst_empty()) { - if (!has_mvar(a) || m_already_normalized.contains(mid)) { - return optional(a); - } - m_already_normalized.insert(mid); - expr a_new = visit(a); - if (!is_eqp(a, a_new)) { - m_saved.push_back(a); - assign_mvar(m_mctx, mid, a_new); - } - return optional(a_new); - } else { - if (!has_mvar(a) && !has_fvar(a)) { - return optional(a); - } - return optional(visit(a)); + if (!has_mvar(a) && !has_fvar(a)) { + return optional(a); } + /* Visit to apply fvar substitution and resolve delayed mvars. */ + return optional(visit(a)); } expr visit_app_default(expr const & e) { @@ -223,25 +448,39 @@ class instantiate_mvars_all_fn { size_t fvar_count = fvars.size(); size_t extra_count = args.size() - fvar_count; + /* Save and extend the fvar substitution. */ struct saved_entry { name key; bool had_old; fvar_subst_entry old; }; std::vector saved_entries; saved_entries.reserve(fvar_count); + m_scope++; for (size_t i = 0; i < fvar_count; i++) { name const & fid = fvar_name(fvars[i]); auto old_it = m_fvar_subst.find(fid); if (old_it != m_fvar_subst.end()) { saved_entries.push_back({fid, true, old_it->second}); } else { - saved_entries.push_back({fid, false, {0, expr()}}); + saved_entries.push_back({fid, false, {0, 0, expr()}}); } - m_fvar_subst[fid] = {m_depth, args[args.size() - 1 - i]}; + m_fvar_subst[fid] = {m_depth, m_scope, args[args.size() - 1 - i]}; + } + + /* Push a new cache scope for the extended substitution. */ + if (m_scope >= m_cache_stack.size()) { + m_cache_stack.emplace_back(); + } else { + m_cache_stack[m_scope].clear(); } - lean::unordered_map saved_cache; - saved_cache.swap(m_cache); expr val_new = visit(mk_mvar(mid_pending)); - saved_cache.swap(m_cache); + /* Pop the cache scope. In sharing mode, entries that belong to outer + scopes were already inserted there by cache_insert. Only scope-specific + entries (those using fvars from this scope) live at m_scope and are + discarded. In non-sharing mode, all entries at m_scope are discarded. */ + m_cache_stack[m_scope].clear(); + m_scope--; + + /* Restore the fvar substitution. */ for (auto & se : saved_entries) { if (!se.had_old) { m_fvar_subst.erase(se.key); @@ -257,51 +496,50 @@ class instantiate_mvars_all_fn { expr const & f = get_app_fn(e); if (!is_mvar(f)) { return visit_app_default(e); - } else { - name const & mid = mvar_name(f); - if (auto f_new = get_assignment(mid)) { - buffer args; - return visit_args_and_beta(*f_new, e, args); - } - option_ref d = get_delayed_mvar_assignment(m_mctx, mid); - if (!d) { - if (!fvar_subst_empty()) { - /* Unassigned mvar under active fvar substitution -- fail */ - m_failed = true; - return e; - } - return visit_mvar_app_args(e); - } - array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - if (fvars.size() > get_app_num_args(e)) { - if (!fvar_subst_empty()) { - m_failed = true; - return e; - } - return visit_mvar_app_args(e); - } - if (fvar_subst_empty()) { - optional pending_val = get_assignment(mid_pending); - if (!pending_val || has_expr_mvar(*pending_val)) - return visit_mvar_app_args(e); - } + } + name const & mid = mvar_name(f); + /* Direct assignment takes precedence. */ + if (auto f_new = get_assignment(mid)) { buffer args; - return visit_delayed(fvars, mid_pending, e, args); + return visit_args_and_beta(*f_new, e, args); } + /* Check delayed assignment. */ + option_ref d = get_delayed_mvar_assignment(m_mctx, mid); + if (!d) { + m_failed = true; + return e; + } + array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + if (fvars.size() > get_app_num_args(e)) { + m_failed = true; + return e; + } + /* Always resolve delayed assignments — pass 1 has pre-normalized + the pending values, so we can proceed with the fused substitution. */ + buffer args; + return visit_delayed(fvars, mid_pending, e, args); } expr visit_mvar(expr const & e) { name const & mid = mvar_name(e); if (auto r = get_assignment(mid)) { return *r; - } else if (fvar_subst_empty()) { - return e; - } else { - /* Unassigned mvar under active fvar substitution -- fail */ + } + /* Check if delayed-assigned (bare mvar without args). */ + option_ref d = get_delayed_mvar_assignment(m_mctx, mid); + if (d) { + array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); + if (fvars.size() == 0) { + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + return visit(mk_mvar(mid_pending)); + } + /* Delayed with fvars but no args — can't resolve. */ m_failed = true; return e; } + m_failed = true; + return e; } expr visit_fvar(expr const & e) { @@ -313,8 +551,11 @@ class instantiate_mvars_all_fn { } public: - instantiate_mvars_all_fn(metavar_ctx & mctx) - : m_mctx(mctx), m_level_fn(mctx), m_depth(0) {} + instantiate_delayed_fn(metavar_ctx & mctx, bool preserve_sharing) + : m_mctx(mctx), m_depth(0), m_preserve_sharing(preserve_sharing), + m_scope(0), m_result_scope(0) { + m_cache_stack.emplace_back(); /* scope 0 */ + } bool failed() const { return m_failed; } @@ -337,20 +578,30 @@ class instantiate_mvars_all_fn { if (it != m_global_cache.end()) return it->second; } else { - auto it = m_cache.find(e.raw()); - if (it != m_cache.end()) - return it->second; + if (auto r = cache_lookup(e.raw())) + return *r; } shared = true; } + /* In sharing mode, save and reset the result scope for this subtree. + After computing, cache_insert uses m_result_scope to place the entry + at the outermost valid scope level. Then we restore the parent's + watermark, taking the max with our contribution. */ + unsigned saved_result_scope = 0; + if (m_preserve_sharing) { + saved_result_scope = m_result_scope; + m_result_scope = 0; + } + expr r; switch (e.kind()) { case expr_kind::BVar: case expr_kind::Lit: lean_unreachable(); case expr_kind::FVar: - return visit_fvar(e); + r = visit_fvar(e); + goto done; /* skip caching for fvars */ case expr_kind::Sort: r = update_sort(e, visit_level(sort_level(e))); break; @@ -358,7 +609,8 @@ class instantiate_mvars_all_fn { r = update_const(e, visit_levels(const_levels(e))); break; case expr_kind::MVar: - return visit_mvar(e); + r = visit_mvar(e); + goto done; /* mvar results are not (ptr, depth)-cacheable */ case expr_kind::MData: r = update_mdata(e, visit(mdata_expr(e))); break; @@ -370,54 +622,90 @@ class instantiate_mvars_all_fn { break; case expr_kind::Pi: case expr_kind::Lambda: { expr d = visit(binding_domain(e)); - lean::unordered_map saved_cache; - saved_cache.swap(m_cache); m_depth++; expr b = visit(binding_body(e)); m_depth--; - saved_cache.swap(m_cache); r = update_binding(e, d, b); break; } case expr_kind::Let: { expr t = visit(let_type(e)); expr v = visit(let_value(e)); - lean::unordered_map saved_cache; - saved_cache.swap(m_cache); m_depth++; expr b = visit(let_body(e)); m_depth--; - saved_cache.swap(m_cache); r = update_let(e, t, v, b); break; } } - if (m_failed) return e; + if (m_failed) { r = e; goto done; } if (shared) { if (use_global) m_global_cache.insert(mk_pair(e.raw(), r)); else - m_cache.insert(mk_pair(e.raw(), r)); + cache_insert(e.raw(), r); + } + + done: + if (m_preserve_sharing) { + m_result_scope = std::max(saved_result_scope, m_result_scope); } return r; } + level visit_level(level const & l) { + /* Pass 2 does not handle level mvars — pass 1 already resolved them. + But we still need this for the visit_levels call in update_sort/update_const. + Since levels have no fvars, we can just return them as-is. */ + return l; + } + + levels visit_levels(levels const & ls) { + return ls; + } + expr operator()(expr const & e) { return visit(e); } }; -extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all(object * m, object * e) { +/* ============================================================================ + Entry points: run pass 1 then pass 2. + ============================================================================ */ + +static object * run_instantiate_all(object * m, object * e, bool preserve_sharing) { metavar_ctx mctx(m); - instantiate_mvars_all_fn fn(mctx); - expr e_new = fn(expr(e)); - if (fn.failed()) { + + /* Pass 1: resolve direct mvar assignments, pre-normalize pending values. */ + instantiate_direct_fn pass1(mctx); + expr e1 = pass1(expr(e)); + if (pass1.failed()) { + return box(0); /* none */ + } + + /* Pass 2: resolve delayed assignments with fused fvar substitution. */ + instantiate_delayed_fn pass2(mctx, preserve_sharing); + expr e2 = pass2(e1); + if (pass2.failed()) { return box(0); /* none */ } + /* some (mctx, expr) */ object * pair = alloc_cnstr(0, 2, 0); cnstr_set(pair, 0, mctx.steal()); - cnstr_set(pair, 1, e_new.steal()); + cnstr_set(pair, 1, e2.steal()); object * r = alloc_cnstr(1, 1, 0); cnstr_set(r, 0, pair); return r; } + +/* Fast variant: may lose sharing across delayed-mvar boundaries. */ +extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all(object * m, object * e) { + return run_instantiate_all(m, e, false); +} + +/* Sharing-preserving variant: uses scope-tracked cache stack to preserve + cross-scope sharing. Nearly optimal output size at the cost of + O(scope_depth) cache lookups instead of O(1). */ +extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all_sharing(object * m, object * e) { + return run_instantiate_all(m, e, true); +} } diff --git a/tests/lean/run/instantiateAllMVarsSharing.lean b/tests/lean/run/instantiateAllMVarsSharing.lean new file mode 100644 index 000000000000..d16fbaef4bb3 --- /dev/null +++ b/tests/lean/run/instantiateAllMVarsSharing.lean @@ -0,0 +1,125 @@ +import Lean +import Lean.Meta.InstMVarsAll + +open Lean Meta + +/- +Minimal test for sharing in `instantiateAllMVars` and `instantiateAllMVarsSharing`. + +We construct the metavariable assignment graph for the goal +`∀ s, (s = s → (s = s) ∧ (s = s)) ∧ (s = s)`: + + ?root := fun (s : Nat) => ?rootAux #0 + ?rootAux delayed [s_fvar] := ?body + ?body := @And.intro leftTy rightTy ?left right + ?left := fun (h : eq_ss) => ?leftAux #0 + ?leftAux delayed [h_fvar] := ?inner + ?inner := @And.intro eq_ss eq_ss h_fvar h_fvar + +where + + eq_ss := @Eq Nat s_fvar s_fvar ← single shared object + andTy := And eq_ss eq_ss ← contains eq_ss + leftTy := eq_ss → andTy ← forallE body contains eq_ss + rightTy := eq_ss + right := @Eq.refl Nat s_fvar + +`eq_ss` appears at depth 2 in two places: + (a) in `leftTy`'s forallE body (the `andTy` subexpression) + (b) in `?inner`'s value (the And.intro type arguments) + +With `instantiateMVars` (standard), a single `replace_fvars` call +processes the whole expression. Both (a) and (b) see `eq_ss` at offset 1 +and share a single `@Eq Nat #1 #1` result via the `(ptr, offset)` cache. + +With `instantiateAllMVars` (fused pass-2, no sharing preservation), (a) and (b) +are in different cache scopes that are swapped on `visit_delayed` boundaries. +This loses sharing for `eq_ss` across the two scopes. + +With `instantiateAllMVarsSharing` (scope-tracked cache stack), results that only +depend on outer-scope fvars are cached at the outermost valid scope level, so +the `eq_ss → @Eq Nat #1 #1` entry at depth 2 IS shared across scopes. The +remaining 2 extra objects come from `mk_rev_app` always allocating fresh app +nodes (vs `update_app` in the standard approach). +-/ + +private def mkTestRoot : MetaM Expr := do + let nat := mkConst ``Nat + withLocalDeclD `s nat fun s_fvar => do + let eq_ss ← mkEq s_fvar s_fvar -- shared object + + let andTy := mkApp2 (mkConst ``And) eq_ss eq_ss -- (s=s) ∧ (s=s) + let leftTy ← mkArrow eq_ss andTy -- s=s → (s=s) ∧ (s=s) + let rightTy := eq_ss -- s=s + let bodyTy := mkApp2 (mkConst ``And) leftTy rightTy + + let body ← mkFreshExprMVar bodyTy + let left ← mkFreshExprMVar leftTy + + withLocalDeclD `h eq_ss fun h_fvar => do + -- ?inner : (s=s) ∧ (s=s), proved by And.intro eq_ss eq_ss h h + let inner ← mkFreshExprMVar andTy + let leftDecl ← left.mvarId!.getDecl + let leftAux ← mkFreshExprMVarAt leftDecl.lctx leftDecl.localInstances + leftDecl.type .syntheticOpaque + assignDelayedMVar leftAux.mvarId! #[h_fvar] inner.mvarId! + left.mvarId!.assign (Lean.mkLambda `h .default eq_ss (mkApp leftAux (.bvar 0))) + inner.mvarId!.assign (mkApp4 (mkConst ``And.intro) eq_ss eq_ss h_fvar h_fvar) + + let right := mkApp2 (mkConst ``Eq.refl [1]) nat s_fvar + body.mvarId!.assign (mkApp4 (mkConst ``And.intro) leftTy rightTy left right) + + let rootTy ← mkForallFVars #[s_fvar] bodyTy + let root ← mkFreshExprMVar rootTy + let rootDecl ← root.mvarId!.getDecl + let rootAux ← mkFreshExprMVarAt rootDecl.lctx rootDecl.localInstances + rootDecl.type .syntheticOpaque + assignDelayedMVar rootAux.mvarId! #[s_fvar] body.mvarId! + root.mvarId!.assign (Lean.mkLambda `s .default nat (mkApp rootAux (.bvar 0))) + return root + +-- instantiateAllMVars: loses sharing across visit_delayed boundaries (34 vs 27 objects) +/-- +error: sharing regression: instantiateMVars 27 objs, instantiateAllMVars 34 objs +-/ +#guard_msgs (error) in +run_meta do + let root ← mkTestRoot + + let saved ← saveState + let eStd ← instantiateMVars root + let nStd ← eStd.numObjs + saved.restore + + let saved ← saveState + let eAll ← instantiateAllMVars root + let nAll ← eAll.numObjs + saved.restore + + guard (eStd == eAll) + + if nAll > nStd then + throwError "sharing regression: instantiateMVars {nStd} objs, instantiateAllMVars {nAll} objs" + +-- instantiateAllMVarsSharing: preserves cross-scope sharing (29 vs 27, residual from mk_rev_app) +/-- +error: sharing regression: instantiateMVars 27 objs, instantiateAllMVarsSharing 29 objs +-/ +#guard_msgs (error) in +run_meta do + let root ← mkTestRoot + + let saved ← saveState + let eStd ← instantiateMVars root + let nStd ← eStd.numObjs + saved.restore + + let saved ← saveState + let eAll ← instantiateAllMVarsSharing root + let nAll ← eAll.numObjs + saved.restore + + guard (eStd == eAll) + + if nAll > nStd then + throwError "sharing regression: instantiateMVars {nStd} objs, instantiateAllMVarsSharing {nAll} objs" From 54aae0a320d9c73cc0a6015bf60441e0d85fdb7c Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Mon, 2 Mar 2026 11:32:31 +0000 Subject: [PATCH 12/45] perf: make instantiateAllMVars universal and expose instantiateMVarsOriginal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the two-pass instantiateAllMVars/instantiateAllMVarsSharing handle unassigned mvars (via abstract_mvar_fvars/elimMVarDeps) instead of failing. This makes them usable as a drop-in replacement at call sites where mvars may not all be assigned, though the unassigned-mvar behavior differs from the standard implementation (lambda-wrapping vs beta-reduction). Also expose instantiateMVarsOriginal for benchmarking independently of which implementation is the default. Note: NOT yet wired as default instantiateMVars — the behavioral difference for unassigned mvars causes test failures (e.g. meta7.lean). The redirect is reverted; these remain as separate API functions. Co-Authored-By: Claude Opus 4.6 --- src/Lean/Meta/InstMVarsAll.lean | 34 ++-- src/kernel/instantiate_mvars.cpp | 10 ++ src/kernel/instantiate_mvars_all.cpp | 167 +++++++++++------- .../lean/run/instantiateAllMVarsSharing.lean | 47 ++--- 4 files changed, 142 insertions(+), 116 deletions(-) diff --git a/src/Lean/Meta/InstMVarsAll.lean b/src/Lean/Meta/InstMVarsAll.lean index 9322ff45914b..132f5839cffb 100644 --- a/src/Lean/Meta/InstMVarsAll.lean +++ b/src/Lean/Meta/InstMVarsAll.lean @@ -11,24 +11,35 @@ public import Lean.Meta.Basic namespace Lean.Meta +@[extern "lean_instantiate_expr_mvars_original"] +private opaque instantiateMVarsOriginalImp (mctx : MetavarContext) (e : Expr) : + MetavarContext × Expr + @[extern "lean_instantiate_expr_mvars_all"] private opaque instantiateAllMVarsImp (mctx : MetavarContext) (e : Expr) : - Option (MetavarContext × Expr) + MetavarContext × Expr @[extern "lean_instantiate_expr_mvars_all_sharing"] private opaque instantiateAllMVarsSharingImp (mctx : MetavarContext) (e : Expr) : - Option (MetavarContext × Expr) + MetavarContext × Expr + +/-- The original single-pass `instantiateMVars` implementation, exposed for benchmarking + independently of which implementation is the default. -/ +public def instantiateMVarsOriginal (e : Expr) : MetaM Expr := do + if !e.hasMVar then return e + let (mctx, eNew) := instantiateMVarsOriginalImp (← getMCtx) e + modifyMCtx fun _ => mctx; return eNew -/-- Like `instantiateMVars` but throws if an unassigned mvar is encountered - under an active fvar substitution. Use at call sites where all mvars are - known to be assigned — a throw indicates a wrong assumption. +/-- Like `instantiateMVars` but uses a fused two-pass approach. + Pass 1 resolves direct mvar assignments with write-back. + Pass 2 resolves delayed assignments with a fused fvar substitution, + avoiding separate `replace_fvars` calls. - Uses a fused single-pass approach (carries fvar substitution during traversal). -/ + This variant may lose sharing across delayed-mvar boundaries. -/ public def instantiateAllMVars (e : Expr) : MetaM Expr := do if !e.hasMVar then return e - match instantiateAllMVarsImp (← getMCtx) e with - | some (mctx, eNew) => modifyMCtx fun _ => mctx; return eNew - | none => throwError "instantiateAllMVars: unexpected unassigned mvar in {e}" + let (mctx, eNew) := instantiateAllMVarsImp (← getMCtx) e + modifyMCtx fun _ => mctx; return eNew /-- Like `instantiateAllMVars` but preserves sharing across delayed-mvar boundaries using a scope-tracked cache stack. Results that only depend on outer-scope fvar @@ -39,8 +50,7 @@ public def instantiateAllMVars (e : Expr) : MetaM Expr := do cost of O(scope_depth) cache lookups instead of O(1). -/ public def instantiateAllMVarsSharing (e : Expr) : MetaM Expr := do if !e.hasMVar then return e - match instantiateAllMVarsSharingImp (← getMCtx) e with - | some (mctx, eNew) => modifyMCtx fun _ => mctx; return eNew - | none => throwError "instantiateAllMVarsSharing: unexpected unassigned mvar in {e}" + let (mctx, eNew) := instantiateAllMVarsSharingImp (← getMCtx) e + modifyMCtx fun _ => mctx; return eNew end Lean.Meta diff --git a/src/kernel/instantiate_mvars.cpp b/src/kernel/instantiate_mvars.cpp index b49d74aa5cb5..02d8cb4dcddb 100644 --- a/src/kernel/instantiate_mvars.cpp +++ b/src/kernel/instantiate_mvars.cpp @@ -362,6 +362,16 @@ class instantiate_mvars_fn { expr operator()(expr const & e) { return visit(e); } }; +/* The original single-pass implementation, exposed for benchmarking. */ +extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_original(object * m, object * e) { + metavar_ctx mctx(m); + expr e_new = instantiate_mvars_fn(mctx)(expr(e)); + object * r = alloc_cnstr(0, 2, 0); + cnstr_set(r, 0, mctx.steal()); + cnstr_set(r, 1, e_new.steal()); + return r; +} + extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars(object * m, object * e) { metavar_ctx mctx(m); expr e_new = instantiate_mvars_fn(mctx)(expr(e)); diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 17da2c8e4547..4f2e8b60c2f4 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -14,20 +14,20 @@ Authors: Joachim Breitner #include "kernel/expr.h" /* -This module provides `instantiateAllMVars`, a two-pass variant of `instantiateMVars` -that fails on unassigned metavariables instead of leaving them in place. +This module provides a two-pass variant of `instantiateMVars` with improved sharing. Pass 1 (`instantiate_direct_fn`): Standard `instantiateMVars`-like traversal that resolves direct mvar assignments with write-back and a single persistent cache. For delayed assignments, it pre-normalizes the pending value (resolving its direct chain) but leaves the - delayed mvar application in the expression. + delayed mvar application in the expression. Unassigned mvars are left in place. Pass 2 (`instantiate_delayed_fn`): - Fused traversal (like `instantiateMVarsNoUpdate`) that resolves delayed assignments - by carrying a fvar substitution. Since pass 1 has pre-normalized all direct chains, - each pending value is compact and visited once, avoiding the O(n³) sharing loss - that occurs when the fused approach must also chase direct chains. + Fused traversal that resolves delayed assignments by carrying a fvar substitution. + Since pass 1 has pre-normalized all direct chains, each pending value is compact + and visited once, avoiding the O(n³) sharing loss that occurs when the fused + approach must also chase direct chains. Unassigned mvars under an active fvar + substitution are handled via `elimMVarDeps` (abstract_mvar_fvars). The combination preserves sharing (O(n²) output for the pathological nested-delayed case) while avoiding the separate `replace_fvars` calls of the standard approach. @@ -39,6 +39,7 @@ extern "C" object * lean_assign_lmvar(obj_arg mctx, obj_arg mid, obj_arg val); extern "C" object * lean_get_mvar_assignment(obj_arg mctx, obj_arg mid); extern "C" object * lean_get_delayed_mvar_assignment(obj_arg mctx, obj_arg mid); extern "C" object * lean_assign_mvar(obj_arg mctx, obj_arg mid, obj_arg val); +extern "C" object * lean_abstract_mvar_fvars(obj_arg mctx, obj_arg mid, obj_arg fvars, obj_arg values); typedef object_ref metavar_ctx; typedef object_ref delayed_assignment; @@ -65,6 +66,20 @@ static option_ref get_delayed_mvar_assignment(metavar_ctx & return option_ref(lean_get_delayed_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); } +/* Abstract fvars from an unassigned mvar by creating a delayed-assigned mvar. + Returns a pair (mctx', result) where result has the fvars replaced by values. + Uses the Lean-implemented elimMVarDeps machinery. */ +static object_ref abstract_mvar_fvars(metavar_ctx & mctx, name const & mid, + array_ref const & fvars, array_ref const & values) { + object * r = lean_abstract_mvar_fvars(mctx.steal(), mid.to_obj_arg(), fvars.to_obj_arg(), values.to_obj_arg()); + /* result is a pair (MetavarContext, Expr) */ + mctx.set_box(cnstr_get(r, 0)); + lean_inc(cnstr_get(r, 0)); + expr result(cnstr_get(r, 1), true); + lean_dec(r); + return object_ref(result.steal()); +} + /* Level metavariable instantiation. */ class instantiate_lmvars_all_fn { metavar_ctx & m_mctx; @@ -131,7 +146,6 @@ class instantiate_direct_fn { name_set m_already_normalized; lean::unordered_map m_cache; std::vector m_saved; - bool m_failed = false; level visit_level(level const & l) { return m_level_fn(l); @@ -215,18 +229,13 @@ class instantiate_direct_fn { buffer args; return visit_args_and_beta(*f_new, e, args); } - /* Check delayed assignment. */ + /* Check delayed assignment and pre-normalize pending. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); - if (!d) { - /* Not assigned at all — fail. */ - m_failed = true; - return e; + if (d) { + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + get_assignment(mid_pending); } - /* Pre-normalize the pending value (triggers recursive normalization - of its direct chain via get_assignment + visit + write-back). */ - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - get_assignment(mid_pending); - /* Leave the delayed mvar in place, just visit args. */ + /* Leave the (possibly delayed) mvar in place, just visit args. */ return visit_mvar_app_args(e); } @@ -240,20 +249,14 @@ class instantiate_direct_fn { if (d) { name mid_pending(cnstr_get(d.get_val().raw(), 1), true); get_assignment(mid_pending); - return e; /* leave delayed mvar in place */ } - /* Not assigned at all — fail. */ - m_failed = true; - return e; + return e; /* leave mvar in place */ } public: instantiate_direct_fn(metavar_ctx & mctx):m_mctx(mctx), m_level_fn(mctx) {} - bool failed() const { return m_failed; } - expr visit(expr const & e) { - if (m_failed) return e; if (!has_mvar(e)) return e; bool shared = false; @@ -336,7 +339,6 @@ class instantiate_delayed_fn { /* Global cache for fvar-free expressions — scope-independent. */ lean::unordered_map m_global_cache; - bool m_failed = false; bool fvar_subst_empty() const { return m_fvar_subst.empty(); @@ -389,17 +391,23 @@ class instantiate_delayed_fn { } } - /* Get a direct mvar assignment. Since pass 1 has pre-normalized, - we just read the value without further normalization. */ + /* Get a direct mvar assignment. Visit it to resolve delayed mvars + and apply the fvar substitution. Pass 1 has already pre-normalized + direct chains, so we only need to handle delayed mvars and fvars. + No write-back: values may contain fvar-substituted terms that are + not suitable for storing in the mctx. */ optional get_assignment(name const & mid) { option_ref r = get_mvar_assignment(m_mctx, mid); if (!r) return optional(); expr a(r.get_val()); - if (!has_mvar(a) && !has_fvar(a)) { - return optional(a); + if (fvar_subst_empty()) { + if (!has_mvar(a)) + return optional(a); + } else { + if (!has_mvar(a) && !has_fvar(a)) + return optional(a); } - /* Visit to apply fvar substitution and resolve delayed mvars. */ return optional(visit(a)); } @@ -426,6 +434,41 @@ class instantiate_delayed_fn { return mk_rev_app(*curr, args.size(), args.data()); } + /* Abstract an unassigned mvar under an active fvar substitution. + Creates a delayed assignment that captures the current substitution. */ + expr abstract_unassigned_mvar(name const & mid) { + lean_object * fvars_arr = lean_mk_empty_array(); + lean_object * values_arr = lean_mk_empty_array(); + for (auto & entry : m_fvar_subst) { + name const & fid = entry.first; + expr fv = mk_fvar(fid); + fvars_arr = lean_array_push(fvars_arr, fv.steal()); + if (m_preserve_sharing) { + m_result_scope = std::max(m_result_scope, entry.second.scope); + } + unsigned d = m_depth - entry.second.depth; + expr val = (d == 0) ? entry.second.value : lift_loose_bvars(entry.second.value, d); + values_arr = lean_array_push(values_arr, val.steal()); + } + object_ref result(abstract_mvar_fvars(m_mctx, + mid, array_ref(fvars_arr), array_ref(values_arr))); + return expr(result.steal()); + } + + /* Visit a mvar-headed application where the mvar is unassigned and fvar_subst is active. + Abstract the mvar (capturing the fvar substitution) and rebuild the application. */ + expr visit_mvar_app_abstract(expr const & e) { + buffer args; + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + lean_assert(is_mvar(*curr)); + expr f = abstract_unassigned_mvar(mvar_name(*curr)); + return mk_rev_app(f, args.size(), args.data()); + } + expr visit_args_and_beta(expr const & f_new, expr const & e, buffer & args) { expr const * curr = &e; while (is_app(*curr)) { @@ -506,17 +549,27 @@ class instantiate_delayed_fn { /* Check delayed assignment. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (!d) { - m_failed = true; - return e; + /* Not assigned at all. */ + if (fvar_subst_empty()) + return visit_mvar_app_args(e); + else + return visit_mvar_app_abstract(e); } array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); name mid_pending(cnstr_get(d.get_val().raw(), 1), true); if (fvars.size() > get_app_num_args(e)) { - m_failed = true; - return e; - } - /* Always resolve delayed assignments — pass 1 has pre-normalized - the pending values, so we can proceed with the fused substitution. */ + /* Insufficient args — can't resolve delayed assignment. */ + if (fvar_subst_empty()) + return visit_mvar_app_args(e); + else + return visit_mvar_app_abstract(e); + } + /* Always resolve delayed assignments. When the pending value contains + unassigned mvars, they will be left as-is (fvar_subst empty) or + handled via abstract_mvar_fvars (fvar_subst active). This is slightly + more aggressive than standard instantiateMVars (which leaves delayed + assignments unresolved when pending has unassigned mvars), but is + semantically correct and makes no difference when all mvars are assigned. */ buffer args; return visit_delayed(fvars, mid_pending, e, args); } @@ -526,20 +579,13 @@ class instantiate_delayed_fn { if (auto r = get_assignment(mid)) { return *r; } - /* Check if delayed-assigned (bare mvar without args). */ - option_ref d = get_delayed_mvar_assignment(m_mctx, mid); - if (d) { - array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); - if (fvars.size() == 0) { - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - return visit(mk_mvar(mid_pending)); - } - /* Delayed with fvars but no args — can't resolve. */ - m_failed = true; + /* Not directly assigned. Match standard instantiateMVars: + when fvar_subst is empty, just leave the mvar as-is. + When fvar_subst is active, abstract to capture the substitution. */ + if (fvar_subst_empty()) { return e; } - m_failed = true; - return e; + return abstract_unassigned_mvar(mid); } expr visit_fvar(expr const & e) { @@ -557,11 +603,7 @@ class instantiate_delayed_fn { m_cache_stack.emplace_back(); /* scope 0 */ } - bool failed() const { return m_failed; } - expr visit(expr const & e) { - if (m_failed) return e; - if (fvar_subst_empty()) { if (!has_mvar(e)) return e; @@ -638,7 +680,6 @@ class instantiate_delayed_fn { break; } } - if (m_failed) { r = e; goto done; } if (shared) { if (use_global) m_global_cache.insert(mk_pair(e.raw(), r)); @@ -677,23 +718,15 @@ static object * run_instantiate_all(object * m, object * e, bool preserve_sharin /* Pass 1: resolve direct mvar assignments, pre-normalize pending values. */ instantiate_direct_fn pass1(mctx); expr e1 = pass1(expr(e)); - if (pass1.failed()) { - return box(0); /* none */ - } /* Pass 2: resolve delayed assignments with fused fvar substitution. */ instantiate_delayed_fn pass2(mctx, preserve_sharing); expr e2 = pass2(e1); - if (pass2.failed()) { - return box(0); /* none */ - } - /* some (mctx, expr) */ - object * pair = alloc_cnstr(0, 2, 0); - cnstr_set(pair, 0, mctx.steal()); - cnstr_set(pair, 1, e2.steal()); - object * r = alloc_cnstr(1, 1, 0); - cnstr_set(r, 0, pair); + /* (mctx, expr) */ + object * r = alloc_cnstr(0, 2, 0); + cnstr_set(r, 0, mctx.steal()); + cnstr_set(r, 1, e2.steal()); return r; } diff --git a/tests/lean/run/instantiateAllMVarsSharing.lean b/tests/lean/run/instantiateAllMVarsSharing.lean index d16fbaef4bb3..0995f0794bb3 100644 --- a/tests/lean/run/instantiateAllMVarsSharing.lean +++ b/tests/lean/run/instantiateAllMVarsSharing.lean @@ -23,24 +23,6 @@ where leftTy := eq_ss → andTy ← forallE body contains eq_ss rightTy := eq_ss right := @Eq.refl Nat s_fvar - -`eq_ss` appears at depth 2 in two places: - (a) in `leftTy`'s forallE body (the `andTy` subexpression) - (b) in `?inner`'s value (the And.intro type arguments) - -With `instantiateMVars` (standard), a single `replace_fvars` call -processes the whole expression. Both (a) and (b) see `eq_ss` at offset 1 -and share a single `@Eq Nat #1 #1` result via the `(ptr, offset)` cache. - -With `instantiateAllMVars` (fused pass-2, no sharing preservation), (a) and (b) -are in different cache scopes that are swapped on `visit_delayed` boundaries. -This loses sharing for `eq_ss` across the two scopes. - -With `instantiateAllMVarsSharing` (scope-tracked cache stack), results that only -depend on outer-scope fvars are cached at the outermost valid scope level, so -the `eq_ss → @Eq Nat #1 #1` entry at depth 2 IS shared across scopes. The -remaining 2 extra objects come from `mk_rev_app` always allocating fresh app -nodes (vs `update_app` in the standard approach). -/ private def mkTestRoot : MetaM Expr := do @@ -78,17 +60,17 @@ private def mkTestRoot : MetaM Expr := do root.mvarId!.assign (Lean.mkLambda `s .default nat (mkApp rootAux (.bvar 0))) return root --- instantiateAllMVars: loses sharing across visit_delayed boundaries (34 vs 27 objects) +-- instantiateAllMVars (non-sharing) loses some sharing compared to instantiateAllMVarsSharing /-- -error: sharing regression: instantiateMVars 27 objs, instantiateAllMVars 34 objs +error: sharing regression: instantiateAllMVarsSharing 29 objs, instantiateAllMVars 34 objs -/ #guard_msgs (error) in run_meta do let root ← mkTestRoot let saved ← saveState - let eStd ← instantiateMVars root - let nStd ← eStd.numObjs + let eSharing ← instantiateAllMVarsSharing root + let nSharing ← eSharing.numObjs saved.restore let saved ← saveState @@ -96,30 +78,21 @@ run_meta do let nAll ← eAll.numObjs saved.restore - guard (eStd == eAll) + guard (eSharing == eAll) - if nAll > nStd then - throwError "sharing regression: instantiateMVars {nStd} objs, instantiateAllMVars {nAll} objs" + if nAll > nSharing then + throwError "sharing regression: instantiateAllMVarsSharing {nSharing} objs, instantiateAllMVars {nAll} objs" --- instantiateAllMVarsSharing: preserves cross-scope sharing (29 vs 27, residual from mk_rev_app) -/-- -error: sharing regression: instantiateMVars 27 objs, instantiateAllMVarsSharing 29 objs --/ -#guard_msgs (error) in +-- instantiateAllMVarsSharing produces the same result as instantiateMVars run_meta do let root ← mkTestRoot let saved ← saveState let eStd ← instantiateMVars root - let nStd ← eStd.numObjs saved.restore let saved ← saveState - let eAll ← instantiateAllMVarsSharing root - let nAll ← eAll.numObjs + let eSharing ← instantiateAllMVarsSharing root saved.restore - guard (eStd == eAll) - - if nAll > nStd then - throwError "sharing regression: instantiateMVars {nStd} objs, instantiateAllMVarsSharing {nAll} objs" + guard (eStd == eSharing) From 87a814c9866edd37d941c1c45173c929f93a042c Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Mon, 2 Mar 2026 16:40:28 +0000 Subject: [PATCH 13/45] perf: wire instantiateAllMVarsSharing as default instantiateMVars This makes the two-pass sharing-preserving instantiateMVars the default implementation. The key changes ensure functional equivalence with the original single-pass implementation: - Add resolvability guard: pass 1 builds a set of pending mvars whose normalized values are mvar-free; pass 2 only resolves delayed assignments when the pending mvar is in this set - Simplify unassigned mvar handling: leave them as-is instead of abstracting with elimMVarDeps, matching the original behavior - Fix global cache: exclude mvar-containing expressions since delayed resolution depends on fvar substitution context - Add mctx write-back in pass 2 when fvar substitution is empty, so downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) sees fully normalized stored assignments - Use apply_beta in visit_delayed to match the original's beta-reduction behavior Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars.cpp | 9 +- src/kernel/instantiate_mvars_all.cpp | 212 ++++++++++++++++----------- 2 files changed, 126 insertions(+), 95 deletions(-) diff --git a/src/kernel/instantiate_mvars.cpp b/src/kernel/instantiate_mvars.cpp index 02d8cb4dcddb..4fda6f9a76e1 100644 --- a/src/kernel/instantiate_mvars.cpp +++ b/src/kernel/instantiate_mvars.cpp @@ -372,12 +372,9 @@ extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_original(object * m, return r; } +extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all_sharing(object * m, object * e); + extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars(object * m, object * e) { - metavar_ctx mctx(m); - expr e_new = instantiate_mvars_fn(mctx)(expr(e)); - object * r = alloc_cnstr(0, 2, 0); - cnstr_set(r, 0, mctx.steal()); - cnstr_set(r, 1, e_new.steal()); - return r; + return lean_instantiate_expr_mvars_all_sharing(m, e); } } diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 4f2e8b60c2f4..cd175a80747a 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -26,8 +26,8 @@ Pass 2 (`instantiate_delayed_fn`): Fused traversal that resolves delayed assignments by carrying a fvar substitution. Since pass 1 has pre-normalized all direct chains, each pending value is compact and visited once, avoiding the O(n³) sharing loss that occurs when the fused - approach must also chase direct chains. Unassigned mvars under an active fvar - substitution are handled via `elimMVarDeps` (abstract_mvar_fvars). + approach must also chase direct chains. Unassigned mvars are left as-is (matching + the original `instantiateMVars` behavior). The combination preserves sharing (O(n²) output for the pathological nested-delayed case) while avoiding the separate `replace_fvars` calls of the standard approach. @@ -39,8 +39,6 @@ extern "C" object * lean_assign_lmvar(obj_arg mctx, obj_arg mid, obj_arg val); extern "C" object * lean_get_mvar_assignment(obj_arg mctx, obj_arg mid); extern "C" object * lean_get_delayed_mvar_assignment(obj_arg mctx, obj_arg mid); extern "C" object * lean_assign_mvar(obj_arg mctx, obj_arg mid, obj_arg val); -extern "C" object * lean_abstract_mvar_fvars(obj_arg mctx, obj_arg mid, obj_arg fvars, obj_arg values); - typedef object_ref metavar_ctx; typedef object_ref delayed_assignment; @@ -66,20 +64,6 @@ static option_ref get_delayed_mvar_assignment(metavar_ctx & return option_ref(lean_get_delayed_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); } -/* Abstract fvars from an unassigned mvar by creating a delayed-assigned mvar. - Returns a pair (mctx', result) where result has the fvars replaced by values. - Uses the Lean-implemented elimMVarDeps machinery. */ -static object_ref abstract_mvar_fvars(metavar_ctx & mctx, name const & mid, - array_ref const & fvars, array_ref const & values) { - object * r = lean_abstract_mvar_fvars(mctx.steal(), mid.to_obj_arg(), fvars.to_obj_arg(), values.to_obj_arg()); - /* result is a pair (MetavarContext, Expr) */ - mctx.set_box(cnstr_get(r, 0)); - lean_inc(cnstr_get(r, 0)); - expr result(cnstr_get(r, 1), true); - lean_dec(r); - return object_ref(result.steal()); -} - /* Level metavariable instantiation. */ class instantiate_lmvars_all_fn { metavar_ctx & m_mctx; @@ -144,6 +128,11 @@ class instantiate_direct_fn { metavar_ctx & m_mctx; instantiate_lmvars_all_fn m_level_fn; name_set m_already_normalized; + /* Set of delayed-assigned mvars whose pending value is assigned and + mvar-free after normalization. Used by pass 2 as a guard: only resolve + delayed assignments when the pending mvar is in this set, matching + the original instantiateMVars behavior. */ + name_set m_resolvable_delayed; lean::unordered_map m_cache; std::vector m_saved; @@ -218,6 +207,64 @@ class instantiate_direct_fn { return apply_beta(f_new, args.size(), args.data(), preserve_data, zeta); } + /* Check whether a normalized value would be mvar-free after full resolution. + Uses m_resolvable_delayed to check inner delayed mvars. After pass 1 + normalization, remaining mvars are either unassigned or delayed-assigned. */ + bool is_value_resolvable(expr const & e) { + if (!has_expr_mvar(e)) return true; + switch (e.kind()) { + case expr_kind::BVar: case expr_kind::Lit: case expr_kind::FVar: + case expr_kind::Sort: case expr_kind::Const: + return true; + case expr_kind::MVar: + /* Bare mvar after pass 1 normalization: not directly assigned. */ + return false; + case expr_kind::App: { + expr const & f = get_app_fn(e); + if (is_mvar(f)) { + /* Mvar app after pass 1: must be delayed-assigned or unassigned. */ + name const & mid = mvar_name(f); + option_ref d = get_delayed_mvar_assignment(m_mctx, mid); + if (!d) return false; + array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); + if (fvars.size() > get_app_num_args(e)) return false; + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + if (!m_resolvable_delayed.contains(mid_pending)) return false; + /* Also check args for unresolvable mvars. */ + expr const * curr = &e; + while (is_app(*curr)) { + if (!is_value_resolvable(app_arg(*curr))) return false; + curr = &app_fn(*curr); + } + return true; + } + return is_value_resolvable(app_fn(e)) && is_value_resolvable(app_arg(e)); + } + case expr_kind::Lambda: case expr_kind::Pi: + return is_value_resolvable(binding_domain(e)) && is_value_resolvable(binding_body(e)); + case expr_kind::Let: + return is_value_resolvable(let_type(e)) && is_value_resolvable(let_value(e)) + && is_value_resolvable(let_body(e)); + case expr_kind::MData: + return is_value_resolvable(mdata_expr(e)); + case expr_kind::Proj: + return is_value_resolvable(proj_expr(e)); + } + lean_unreachable(); + } + + /* Pre-normalize the pending value of a delayed assignment and record + whether it is resolvable (assigned and mvar-free after full resolution). + Inner delayed assignments are processed first (via recursive normalization), + so m_resolvable_delayed is already populated for them. */ + void normalize_delayed_pending(name const & mid_pending) { + if (auto val = get_assignment(mid_pending)) { + if (is_value_resolvable(*val)) { + m_resolvable_delayed.insert(mid_pending); + } + } + } + expr visit_app(expr const & e) { expr const & f = get_app_fn(e); if (!is_mvar(f)) { @@ -233,7 +280,7 @@ class instantiate_direct_fn { option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (d) { name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - get_assignment(mid_pending); + normalize_delayed_pending(mid_pending); } /* Leave the (possibly delayed) mvar in place, just visit args. */ return visit_mvar_app_args(e); @@ -248,13 +295,14 @@ class instantiate_direct_fn { option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (d) { name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - get_assignment(mid_pending); + normalize_delayed_pending(mid_pending); } return e; /* leave mvar in place */ } public: instantiate_direct_fn(metavar_ctx & mctx):m_mctx(mctx), m_level_fn(mctx) {} + name_set const & resolvable_delayed() const { return m_resolvable_delayed; } expr visit(expr const & e) { if (!has_mvar(e)) @@ -321,6 +369,7 @@ class instantiate_delayed_fn { typedef lean::unordered_map, expr, key_hasher> depth_cache; metavar_ctx & m_mctx; + name_set const & m_resolvable_delayed; name_hash_map m_fvar_subst; unsigned m_depth; bool m_preserve_sharing; @@ -340,6 +389,13 @@ class instantiate_delayed_fn { /* Global cache for fvar-free expressions — scope-independent. */ lean::unordered_map m_global_cache; + /* Write-back support: when fvar_subst is empty, normalize and write back + mvar assignments to match the original instantiateMVars mctx side effects. + Downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored + assignments and expects them to be normalized. */ + name_set m_already_normalized; + std::vector m_saved; + bool fvar_subst_empty() const { return m_fvar_subst.empty(); } @@ -392,10 +448,13 @@ class instantiate_delayed_fn { } /* Get a direct mvar assignment. Visit it to resolve delayed mvars - and apply the fvar substitution. Pass 1 has already pre-normalized - direct chains, so we only need to handle delayed mvars and fvars. - No write-back: values may contain fvar-substituted terms that are - not suitable for storing in the mctx. */ + and apply the fvar substitution. + When fvar_subst is empty, normalize and write back the result to + the mctx. This matches the original instantiateMVars behavior: + downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored + assignments and expects inner delayed assignments to be resolved. + When fvar_subst is non-empty, no write-back (values contain + fvar-substituted terms not suitable for the mctx). */ optional get_assignment(name const & mid) { option_ref r = get_mvar_assignment(m_mctx, mid); if (!r) @@ -404,11 +463,20 @@ class instantiate_delayed_fn { if (fvar_subst_empty()) { if (!has_mvar(a)) return optional(a); + if (m_already_normalized.contains(mid)) + return optional(a); + m_already_normalized.insert(mid); + expr a_new = visit(a); + if (!is_eqp(a, a_new)) { + m_saved.push_back(a); + assign_mvar(m_mctx, mid, a_new); + } + return optional(a_new); } else { if (!has_mvar(a) && !has_fvar(a)) return optional(a); + return optional(visit(a)); } - return optional(visit(a)); } expr visit_app_default(expr const & e) { @@ -434,41 +502,6 @@ class instantiate_delayed_fn { return mk_rev_app(*curr, args.size(), args.data()); } - /* Abstract an unassigned mvar under an active fvar substitution. - Creates a delayed assignment that captures the current substitution. */ - expr abstract_unassigned_mvar(name const & mid) { - lean_object * fvars_arr = lean_mk_empty_array(); - lean_object * values_arr = lean_mk_empty_array(); - for (auto & entry : m_fvar_subst) { - name const & fid = entry.first; - expr fv = mk_fvar(fid); - fvars_arr = lean_array_push(fvars_arr, fv.steal()); - if (m_preserve_sharing) { - m_result_scope = std::max(m_result_scope, entry.second.scope); - } - unsigned d = m_depth - entry.second.depth; - expr val = (d == 0) ? entry.second.value : lift_loose_bvars(entry.second.value, d); - values_arr = lean_array_push(values_arr, val.steal()); - } - object_ref result(abstract_mvar_fvars(m_mctx, - mid, array_ref(fvars_arr), array_ref(values_arr))); - return expr(result.steal()); - } - - /* Visit a mvar-headed application where the mvar is unassigned and fvar_subst is active. - Abstract the mvar (capturing the fvar substitution) and rebuild the application. */ - expr visit_mvar_app_abstract(expr const & e) { - buffer args; - expr const * curr = &e; - while (is_app(*curr)) { - args.push_back(visit(app_arg(*curr))); - curr = &app_fn(*curr); - } - lean_assert(is_mvar(*curr)); - expr f = abstract_unassigned_mvar(mvar_name(*curr)); - return mk_rev_app(f, args.size(), args.data()); - } - expr visit_args_and_beta(expr const & f_new, expr const & e, buffer & args) { expr const * curr = &e; while (is_app(*curr)) { @@ -532,7 +565,13 @@ class instantiate_delayed_fn { } } - return mk_rev_app(val_new, extra_count, args.data()); + /* Use apply_beta instead of mk_rev_app: pass 1's beta-reduction may have + changed delayed mvar arguments (e.g., substituting a bvar with a concrete + value), so the resolved pending value may be a lambda that needs beta- + reduction with the extra args, matching the original's behavior. */ + bool preserve_data = false; + bool zeta = true; + return apply_beta(val_new, extra_count, args.data(), preserve_data, zeta); } expr visit_app(expr const & e) { @@ -549,27 +588,27 @@ class instantiate_delayed_fn { /* Check delayed assignment. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (!d) { - /* Not assigned at all. */ - if (fvar_subst_empty()) - return visit_mvar_app_args(e); - else - return visit_mvar_app_abstract(e); + return visit_mvar_app_args(e); } array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); name mid_pending(cnstr_get(d.get_val().raw(), 1), true); if (fvars.size() > get_app_num_args(e)) { - /* Insufficient args — can't resolve delayed assignment. */ - if (fvar_subst_empty()) - return visit_mvar_app_args(e); - else - return visit_mvar_app_abstract(e); - } - /* Always resolve delayed assignments. When the pending value contains - unassigned mvars, they will be left as-is (fvar_subst empty) or - handled via abstract_mvar_fvars (fvar_subst active). This is slightly - more aggressive than standard instantiateMVars (which leaves delayed - assignments unresolved when pending has unassigned mvars), but is - semantically correct and makes no difference when all mvars are assigned. */ + return visit_mvar_app_args(e); + } + /* Match standard instantiateMVars: only resolve the delayed assignment + when the pending value was determined to be resolvable by pass 1 + (assigned and mvar-free after normalization). */ + if (!m_resolvable_delayed.contains(mid_pending)) { + /* Still normalize the pending value for mctx write-back side effects. + The original instantiateMVars always normalizes the pending value + (via get_assignment(mid_pending)) even when it can't resolve. + Downstream code like MutualDef.mkInitialUsedFVarsMap reads stored + assignments and relies on inner delayed assignments being resolved. */ + if (fvar_subst_empty()) { + (void)get_assignment(mid_pending); + } + return visit_mvar_app_args(e); + } buffer args; return visit_delayed(fvars, mid_pending, e, args); } @@ -579,13 +618,7 @@ class instantiate_delayed_fn { if (auto r = get_assignment(mid)) { return *r; } - /* Not directly assigned. Match standard instantiateMVars: - when fvar_subst is empty, just leave the mvar as-is. - When fvar_subst is active, abstract to capture the substitution. */ - if (fvar_subst_empty()) { - return e; - } - return abstract_unassigned_mvar(mid); + return e; } expr visit_fvar(expr const & e) { @@ -597,8 +630,9 @@ class instantiate_delayed_fn { } public: - instantiate_delayed_fn(metavar_ctx & mctx, bool preserve_sharing) - : m_mctx(mctx), m_depth(0), m_preserve_sharing(preserve_sharing), + instantiate_delayed_fn(metavar_ctx & mctx, name_set const & resolvable_delayed, bool preserve_sharing) + : m_mctx(mctx), m_resolvable_delayed(resolvable_delayed), + m_depth(0), m_preserve_sharing(preserve_sharing), m_scope(0), m_result_scope(0) { m_cache_stack.emplace_back(); /* scope 0 */ } @@ -612,7 +646,7 @@ class instantiate_delayed_fn { return e; } - bool use_global = !has_fvar(e); + bool use_global = !has_fvar(e) && !has_expr_mvar(e); bool shared = false; if (is_shared(e)) { if (use_global) { @@ -720,7 +754,7 @@ static object * run_instantiate_all(object * m, object * e, bool preserve_sharin expr e1 = pass1(expr(e)); /* Pass 2: resolve delayed assignments with fused fvar substitution. */ - instantiate_delayed_fn pass2(mctx, preserve_sharing); + instantiate_delayed_fn pass2(mctx, pass1.resolvable_delayed(), preserve_sharing); expr e2 = pass2(e1); /* (mctx, expr) */ From d4e4e7c5597e3158adf080db7c2d51a144702a04 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Mon, 2 Mar 2026 17:09:02 +0000 Subject: [PATCH 14/45] chore: suppress -Wdeprecated-copy in instantiate_mvars_no_update.cpp Add pragma to suppress the -Wdeprecated-copy warning triggered by mi_stl_allocator (from mimalloc) which declares a copy constructor but not a copy assignment operator. GCC 15 / Clang treat this as an error with -Werror. Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_no_update.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/kernel/instantiate_mvars_no_update.cpp b/src/kernel/instantiate_mvars_no_update.cpp index 60971360cf2a..b8e73fdc4d53 100644 --- a/src/kernel/instantiate_mvars_no_update.cpp +++ b/src/kernel/instantiate_mvars_no_update.cpp @@ -4,6 +4,14 @@ Released under Apache 2.0 license as described in the file LICENSE. Authors: Joachim Breitner */ +/* Suppress -Wdeprecated-copy triggered by mi_stl_allocator in lean::unordered_map. + mi_stl_allocator (from mimalloc) declares a copy constructor but not a copy + assignment operator, which GCC 15 / Clang flag as deprecated. */ +#if defined(__clang__) +#pragma clang diagnostic ignored "-Wdeprecated-copy" +#elif defined(__GNUC__) +#pragma GCC diagnostic ignored "-Wdeprecated-copy" +#endif #include #include #include "util/name_set.h" From cc31859a9398b4220c11dbc62722a510d77e2133 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Mon, 2 Mar 2026 21:50:10 +0000 Subject: [PATCH 15/45] perf: flat cache with generation-based staleness for instantiateAllMVars pass 2 Replace the per-scope cache stack (O(scope_depth) lookup) with a single flat cache using generation counters for O(1) staleness detection. Each visit_delayed scope gets a unique generation; cache entries store their scope level and generation. Validity check: level <= current_scope && scope_gens[level] == entry_gen. Stale entries from popped scopes are detected by generation mismatch without clearing. Also remove the non-sharing variant (m_preserve_sharing flag and all branching) since the sharing mode is the only one wired as default instantiateMVars. Co-Authored-By: Claude Opus 4.6 --- src/Lean/Meta/InstMVarsAll.lean | 14 +-- src/kernel/instantiate_mvars_all.cpp | 115 +++++++----------- .../lean/run/instantiateAllMVarsSharing.lean | 10 +- 3 files changed, 48 insertions(+), 91 deletions(-) diff --git a/src/Lean/Meta/InstMVarsAll.lean b/src/Lean/Meta/InstMVarsAll.lean index 132f5839cffb..1df057f2b2e2 100644 --- a/src/Lean/Meta/InstMVarsAll.lean +++ b/src/Lean/Meta/InstMVarsAll.lean @@ -33,21 +33,15 @@ public def instantiateMVarsOriginal (e : Expr) : MetaM Expr := do /-- Like `instantiateMVars` but uses a fused two-pass approach. Pass 1 resolves direct mvar assignments with write-back. Pass 2 resolves delayed assignments with a fused fvar substitution, - avoiding separate `replace_fvars` calls. - - This variant may lose sharing across delayed-mvar boundaries. -/ + avoiding separate `replace_fvars` calls. Preserves sharing using + a flat cache with generation-based staleness detection. -/ public def instantiateAllMVars (e : Expr) : MetaM Expr := do if !e.hasMVar then return e let (mctx, eNew) := instantiateAllMVarsImp (← getMCtx) e modifyMCtx fun _ => mctx; return eNew -/-- Like `instantiateAllMVars` but preserves sharing across delayed-mvar boundaries - using a scope-tracked cache stack. Results that only depend on outer-scope fvar - substitutions are cached at the outermost valid scope, so they are reused when - the same subexpression appears in sibling delayed-mvar resolutions. - - This produces nearly optimal output sharing (matching `instantiateMVars`) at the - cost of O(scope_depth) cache lookups instead of O(1). -/ +/-- Alias for `instantiateAllMVars`. Both variants now use the same + sharing-preserving implementation with O(1) cache lookups. -/ public def instantiateAllMVarsSharing (e : Expr) : MetaM Expr := do if !e.hasMVar then return e let (mctx, eNew) := instantiateAllMVarsSharingImp (← getMCtx) e diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index cd175a80747a..424fcddbc5a6 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -346,11 +346,10 @@ class instantiate_direct_fn { Pass 2: Resolve delayed assignments with fused fvar substitution. Direct mvar chains have been pre-resolved by pass 1. - Two modes controlled by `m_preserve_sharing`: - - false: simple cache swap on visit_delayed boundaries (fast, may lose sharing) - - true: stack of (ptr, depth) caches with scope tracking; results are cached - at the outermost valid scope so they persist across visit_delayed - boundaries, preserving sharing at near-zero extra cost. + Uses a flat (ptr, depth)-keyed cache with generation-based staleness. + Each visit_delayed scope gets a unique generation number; cache entries + record the scope level and generation at insertion. Validity is O(1): + entry valid iff level <= m_scope && m_scope_gens[level] == entry.scope_gen. ============================================================================ */ struct fvar_subst_entry { @@ -366,21 +365,22 @@ class instantiate_delayed_fn { } }; - typedef lean::unordered_map, expr, key_hasher> depth_cache; + struct cache_entry { expr result; unsigned scope_level; unsigned scope_gen; }; + + typedef lean::unordered_map, cache_entry, key_hasher> flat_cache; metavar_ctx & m_mctx; name_set const & m_resolvable_delayed; name_hash_map m_fvar_subst; unsigned m_depth; - bool m_preserve_sharing; - /* Stack of (ptr, depth)-keyed caches, one per visit_delayed scope. - Level 0 is the outermost scope (before any visit_delayed). - In non-sharing mode, only level m_scope is used (others are empty). */ - std::vector m_cache_stack; + /* Single flat cache with generation-based staleness detection. */ + flat_cache m_cache; + std::vector m_scope_gens; /* m_scope_gens[level] = generation */ + unsigned m_gen_counter; unsigned m_scope; - /* [sharing mode] After visit() returns, this holds the maximum fvar-substitution + /* After visit() returns, this holds the maximum fvar-substitution scope that contributed to the result — i.e., the outermost scope at which the result is valid and can be cached. Updated monotonically (via max) through the save/reset/restore pattern in visit(). */ @@ -404,47 +404,30 @@ class instantiate_delayed_fn { auto it = m_fvar_subst.find(fid); if (it == m_fvar_subst.end()) return optional(); - if (m_preserve_sharing) { - m_result_scope = std::max(m_result_scope, it->second.scope); - } + m_result_scope = std::max(m_result_scope, it->second.scope); unsigned d = m_depth - it->second.depth; if (d == 0) return optional(it->second.value); return optional(lift_loose_bvars(it->second.value, d)); } - /* Cache lookup. - Non-sharing mode: check only the current (innermost) scope level. - Sharing mode: scan from outermost to innermost; first hit wins. - (Under the assumption that fvar names are unique across scopes, - each (ptr, depth) appears in at most one level, so direction - doesn't matter — but outermost-first is efficient since most - entries live at low scope levels.) */ + /* Cache lookup — O(1) with generation-based staleness check. */ optional cache_lookup(lean_object * ptr) { auto key = mk_pair(ptr, m_depth); - if (m_preserve_sharing) { - for (unsigned s = 0; s <= m_scope; s++) { - auto it = m_cache_stack[s].find(key); - if (it != m_cache_stack[s].end()) { - m_result_scope = std::max(m_result_scope, s); - return optional(it->second); - } - } - } else { - auto it = m_cache_stack[m_scope].find(key); - if (it != m_cache_stack[m_scope].end()) - return optional(it->second); - } - return optional(); + auto it = m_cache.find(key); + if (it == m_cache.end()) return {}; + auto & entry = it->second; + if (entry.scope_level <= m_scope && + m_scope_gens[entry.scope_level] == entry.scope_gen) { + m_result_scope = std::max(m_result_scope, entry.scope_level); + return optional(entry.result); + } + return {}; } void cache_insert(lean_object * ptr, expr const & result) { auto key = mk_pair(ptr, m_depth); - if (m_preserve_sharing) { - m_cache_stack[m_result_scope].insert(mk_pair(key, result)); - } else { - m_cache_stack[m_scope].insert(mk_pair(key, result)); - } + m_cache[key] = { result, m_result_scope, m_scope_gens[m_result_scope] }; } /* Get a direct mvar assignment. Visit it to resolve delayed mvars @@ -540,20 +523,16 @@ class instantiate_delayed_fn { m_fvar_subst[fid] = {m_depth, m_scope, args[args.size() - 1 - i]}; } - /* Push a new cache scope for the extended substitution. */ - if (m_scope >= m_cache_stack.size()) { - m_cache_stack.emplace_back(); - } else { - m_cache_stack[m_scope].clear(); - } + /* Push: bump generation so stale entries at this scope level are detected. */ + m_gen_counter++; + if (m_scope >= m_scope_gens.size()) + m_scope_gens.push_back(m_gen_counter); + else + m_scope_gens[m_scope] = m_gen_counter; expr val_new = visit(mk_mvar(mid_pending)); - /* Pop the cache scope. In sharing mode, entries that belong to outer - scopes were already inserted there by cache_insert. Only scope-specific - entries (those using fvars from this scope) live at m_scope and are - discarded. In non-sharing mode, all entries at m_scope are discarded. */ - m_cache_stack[m_scope].clear(); + /* Pop: just decrement scope — stale entries are detected by generation mismatch. */ m_scope--; /* Restore the fvar substitution. */ @@ -630,11 +609,10 @@ class instantiate_delayed_fn { } public: - instantiate_delayed_fn(metavar_ctx & mctx, name_set const & resolvable_delayed, bool preserve_sharing) + instantiate_delayed_fn(metavar_ctx & mctx, name_set const & resolvable_delayed) : m_mctx(mctx), m_resolvable_delayed(resolvable_delayed), - m_depth(0), m_preserve_sharing(preserve_sharing), - m_scope(0), m_result_scope(0) { - m_cache_stack.emplace_back(); /* scope 0 */ + m_depth(0), m_gen_counter(0), m_scope(0), m_result_scope(0) { + m_scope_gens.push_back(0); /* scope 0 has generation 0 */ } expr visit(expr const & e) { @@ -660,15 +638,12 @@ class instantiate_delayed_fn { shared = true; } - /* In sharing mode, save and reset the result scope for this subtree. + /* Save and reset the result scope for this subtree. After computing, cache_insert uses m_result_scope to place the entry at the outermost valid scope level. Then we restore the parent's watermark, taking the max with our contribution. */ - unsigned saved_result_scope = 0; - if (m_preserve_sharing) { - saved_result_scope = m_result_scope; - m_result_scope = 0; - } + unsigned saved_result_scope = m_result_scope; + m_result_scope = 0; expr r; switch (e.kind()) { @@ -722,9 +697,7 @@ class instantiate_delayed_fn { } done: - if (m_preserve_sharing) { - m_result_scope = std::max(saved_result_scope, m_result_scope); - } + m_result_scope = std::max(saved_result_scope, m_result_scope); return r; } @@ -746,7 +719,7 @@ class instantiate_delayed_fn { Entry points: run pass 1 then pass 2. ============================================================================ */ -static object * run_instantiate_all(object * m, object * e, bool preserve_sharing) { +static object * run_instantiate_all(object * m, object * e) { metavar_ctx mctx(m); /* Pass 1: resolve direct mvar assignments, pre-normalize pending values. */ @@ -754,7 +727,7 @@ static object * run_instantiate_all(object * m, object * e, bool preserve_sharin expr e1 = pass1(expr(e)); /* Pass 2: resolve delayed assignments with fused fvar substitution. */ - instantiate_delayed_fn pass2(mctx, pass1.resolvable_delayed(), preserve_sharing); + instantiate_delayed_fn pass2(mctx, pass1.resolvable_delayed()); expr e2 = pass2(e1); /* (mctx, expr) */ @@ -764,15 +737,11 @@ static object * run_instantiate_all(object * m, object * e, bool preserve_sharin return r; } -/* Fast variant: may lose sharing across delayed-mvar boundaries. */ extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all(object * m, object * e) { - return run_instantiate_all(m, e, false); + return run_instantiate_all(m, e); } -/* Sharing-preserving variant: uses scope-tracked cache stack to preserve - cross-scope sharing. Nearly optimal output size at the cost of - O(scope_depth) cache lookups instead of O(1). */ extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all_sharing(object * m, object * e) { - return run_instantiate_all(m, e, true); + return run_instantiate_all(m, e); } } diff --git a/tests/lean/run/instantiateAllMVarsSharing.lean b/tests/lean/run/instantiateAllMVarsSharing.lean index 0995f0794bb3..85cadee3dbae 100644 --- a/tests/lean/run/instantiateAllMVarsSharing.lean +++ b/tests/lean/run/instantiateAllMVarsSharing.lean @@ -60,11 +60,7 @@ private def mkTestRoot : MetaM Expr := do root.mvarId!.assign (Lean.mkLambda `s .default nat (mkApp rootAux (.bvar 0))) return root --- instantiateAllMVars (non-sharing) loses some sharing compared to instantiateAllMVarsSharing -/-- -error: sharing regression: instantiateAllMVarsSharing 29 objs, instantiateAllMVars 34 objs --/ -#guard_msgs (error) in +-- Both variants now produce the same result with the same sharing run_meta do let root ← mkTestRoot @@ -79,9 +75,7 @@ run_meta do saved.restore guard (eSharing == eAll) - - if nAll > nSharing then - throwError "sharing regression: instantiateAllMVarsSharing {nSharing} objs, instantiateAllMVars {nAll} objs" + guard (nAll == nSharing) -- instantiateAllMVarsSharing produces the same result as instantiateMVars run_meta do From 5d29c6fb97f23815757f9e5e70f9f027d7e23179 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Mon, 2 Mar 2026 22:26:26 +0000 Subject: [PATCH 16/45] fix: handle fvar shadowing in instantiateAllMVars flat cache When nested delayed mvars bind the same fvar name, a cache entry from an outer scope (scope_level=S) could be incorrectly reused in an inner scope (scope_level=S+1) that shadows that fvar with a different value. Fix by restricting cache hits: entries at scope_level > 0 are only valid at exactly their scope level, not at deeper scopes where fvars may be shadowed. Scope_level 0 entries (no fvar dependency) remain valid at all scopes. Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 8 +- tests/lean/run/instantiateAllMVarsShadow.lean | 91 +++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/lean/run/instantiateAllMVarsShadow.lean diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 424fcddbc5a6..d6dc0ada4da5 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -411,13 +411,17 @@ class instantiate_delayed_fn { return optional(lift_loose_bvars(it->second.value, d)); } - /* Cache lookup — O(1) with generation-based staleness check. */ + /* Cache lookup — O(1) with generation-based staleness check. + An entry at scope_level 0 (no fvar dependency) is valid at any scope. + An entry at scope_level > 0 is only valid at exactly that scope level, + because an inner scope may shadow the fvars it depends on. */ optional cache_lookup(lean_object * ptr) { auto key = mk_pair(ptr, m_depth); auto it = m_cache.find(key); if (it == m_cache.end()) return {}; auto & entry = it->second; - if (entry.scope_level <= m_scope && + if ((entry.scope_level == 0 || entry.scope_level == m_scope) && + entry.scope_level <= m_scope && m_scope_gens[entry.scope_level] == entry.scope_gen) { m_result_scope = std::max(m_result_scope, entry.scope_level); return optional(entry.result); diff --git a/tests/lean/run/instantiateAllMVarsShadow.lean b/tests/lean/run/instantiateAllMVarsShadow.lean new file mode 100644 index 000000000000..ff3e6ec16116 --- /dev/null +++ b/tests/lean/run/instantiateAllMVarsShadow.lean @@ -0,0 +1,91 @@ +import Lean +import Lean.Meta.InstMVarsAll + +open Lean Meta + +/- +Test: fvar shadowing in nested delayed mvars. + +Two delayed mvars bind the same fvar `x_fvar` to different values. +A shared subexpression `succ_x := Nat.succ x_fvar` appears in both scopes. + + ?root := fun (a : Nat) => ?d1_aux #0 + ?d1_aux delayed [x_fvar] := ?body + ?body := Prod.mk succ_x (?d2_aux succ_x) ← succ_x is shared + ?d2_aux delayed [x_fvar] := ?inner + ?inner := succ_x ← same shared object + +Expected result: + fun (a : Nat) => (Nat.succ a, Nat.succ (Nat.succ a)) + +When resolving ?d1_aux with arg `a`: + - succ_x with x_fvar → a gives Nat.succ a (first component) + - ?d2_aux gets arg (Nat.succ a), so x_fvar → Nat.succ a + succ_x with x_fvar → Nat.succ a gives Nat.succ (Nat.succ a) (second component) + +A buggy cache could return the cached scope-1 result (Nat.succ a) for the scope-2 +visit, producing (Nat.succ a, Nat.succ a) instead. +-/ + +private def mkShadowTest : MetaM Expr := do + let nat := mkConst ``Nat + withLocalDeclD `x nat fun x_fvar => do + -- shared object referencing x_fvar + let succ_x := mkApp (mkConst ``Nat.succ) x_fvar + + -- ?inner := succ_x + let inner ← mkFreshExprMVar nat + inner.mvarId!.assign succ_x + + -- ?d2_aux delayed [x_fvar] := ?inner + let d2_ty ← mkArrow nat nat + let d2_aux ← mkFreshExprMVar d2_ty (kind := .syntheticOpaque) + assignDelayedMVar d2_aux.mvarId! #[x_fvar] inner.mvarId! + + -- ?body := ⟨succ_x, ?d2_aux succ_x⟩ + let pairTy := mkApp2 (mkConst ``Prod [.succ .zero, .succ .zero]) nat nat + let body ← mkFreshExprMVar pairTy + body.mvarId!.assign + (mkApp4 (mkConst ``Prod.mk [.succ .zero, .succ .zero]) nat nat + succ_x (mkApp d2_aux succ_x)) + + -- ?d1_aux delayed [x_fvar] := ?body + let d1_ty ← mkArrow nat pairTy + let d1_aux ← mkFreshExprMVar d1_ty (kind := .syntheticOpaque) + assignDelayedMVar d1_aux.mvarId! #[x_fvar] body.mvarId! + + -- ?root := fun (a : Nat) => ?d1_aux a + let rootTy ← mkArrow nat pairTy + let root ← mkFreshExprMVar rootTy + root.mvarId!.assign (Lean.mkLambda `a .default nat (mkApp d1_aux (.bvar 0))) + return root + +-- Expected: fun (a : Nat) => (Nat.succ a, Nat.succ (Nat.succ a)) +private def mkExpected : Expr := + let nat := mkConst ``Nat + let succ := mkConst ``Nat.succ + let pairTy := mkApp2 (mkConst ``Prod [.succ .zero, .succ .zero]) nat nat + -- #0 refers to the lambda-bound `a` + let succ_a := mkApp succ (.bvar 0) + let succ_succ_a := mkApp succ succ_a + let body := mkApp4 (mkConst ``Prod.mk [.succ .zero, .succ .zero]) nat nat succ_a succ_succ_a + Lean.mkLambda `a .default nat body + +private def check (label : String) (result : Expr) : MetaM Unit := do + let expected := mkExpected + unless result == expected do + throwError "{label}: expected {expected}, got {result}" + +-- Both implementations must produce the expected result +run_meta do + let root ← mkShadowTest + + let saved ← saveState + let eOrig ← instantiateMVarsOriginal root + saved.restore + check "instantiateMVarsOriginal" eOrig + + let saved ← saveState + let eNew ← instantiateAllMVars root + saved.restore + check "instantiateAllMVars" eNew From 2068f0a706cae421f5afd8aa4e3832a8ff37a37b Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Mon, 2 Mar 2026 22:41:10 +0000 Subject: [PATCH 17/45] perf: skip pass 2 when no delayed assignments are present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When pass 1 encounters no delayed mvar assignments, pass 2 has nothing to do — no delayed resolution, no fvar substitution, no write-back. Skip constructing and running pass 2 entirely in this case. This recovers most of the overhead for workloads without delayed mvars (e.g. simp_bubblesort_256: from +1.8% to +0.2% vs original). Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index d6dc0ada4da5..05f85ebcfda2 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -133,6 +133,9 @@ class instantiate_direct_fn { delayed assignments when the pending mvar is in this set, matching the original instantiateMVars behavior. */ name_set m_resolvable_delayed; + /* Set to true when any delayed assignment is encountered, even if not + resolvable. Pass 2 is needed for write-back normalization in that case. */ + bool m_has_delayed; lean::unordered_map m_cache; std::vector m_saved; @@ -258,6 +261,7 @@ class instantiate_direct_fn { Inner delayed assignments are processed first (via recursive normalization), so m_resolvable_delayed is already populated for them. */ void normalize_delayed_pending(name const & mid_pending) { + m_has_delayed = true; if (auto val = get_assignment(mid_pending)) { if (is_value_resolvable(*val)) { m_resolvable_delayed.insert(mid_pending); @@ -301,8 +305,9 @@ class instantiate_direct_fn { } public: - instantiate_direct_fn(metavar_ctx & mctx):m_mctx(mctx), m_level_fn(mctx) {} + instantiate_direct_fn(metavar_ctx & mctx):m_mctx(mctx), m_level_fn(mctx), m_has_delayed(false) {} name_set const & resolvable_delayed() const { return m_resolvable_delayed; } + bool has_delayed() const { return m_has_delayed; } expr visit(expr const & e) { if (!has_mvar(e)) @@ -730,9 +735,16 @@ static object * run_instantiate_all(object * m, object * e) { instantiate_direct_fn pass1(mctx); expr e1 = pass1(expr(e)); - /* Pass 2: resolve delayed assignments with fused fvar substitution. */ - instantiate_delayed_fn pass2(mctx, pass1.resolvable_delayed()); - expr e2 = pass2(e1); + /* Pass 2: resolve delayed assignments with fused fvar substitution. + Skip if pass 1 found no delayed assignments at all — the expression + has no delayed mvars that need resolution or write-back. */ + expr e2; + if (!pass1.has_delayed()) { + e2 = e1; + } else { + instantiate_delayed_fn pass2(mctx, pass1.resolvable_delayed()); + e2 = pass2(e1); + } /* (mctx, expr) */ object * r = alloc_cnstr(0, 2, 0); From e8d238b81b5b9ba1d7e98e334ce5f76ee39752e4 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Tue, 3 Mar 2026 09:37:29 +0000 Subject: [PATCH 18/45] chore: remove redundant scope_level check in cache_lookup Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 05f85ebcfda2..47de28d6387b 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -426,7 +426,6 @@ class instantiate_delayed_fn { if (it == m_cache.end()) return {}; auto & entry = it->second; if ((entry.scope_level == 0 || entry.scope_level == m_scope) && - entry.scope_level <= m_scope && m_scope_gens[entry.scope_level] == entry.scope_gen) { m_result_scope = std::max(m_result_scope, entry.scope_level); return optional(entry.result); From 35f82063a92e818af7c50675902c857c15b9d928 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Tue, 3 Mar 2026 09:52:22 +0000 Subject: [PATCH 19/45] perf: fuse direct and delayed mvar resolution into single-pass traversal This replaces the two-pass architecture (instantiate_direct_fn + instantiate_delayed_fn) with a single fused class (instantiate_fused_fn) that has two modes: - Outer mode resolves direct mvar assignments with write-back and fires delayed resolution inline when encountering resolvable delayed mvar applications. - Delayed mode carries fvar substitution for delayed assignment resolution. The shared m_cache ensures nodes not behind delayed mvars are traversed exactly once, eliminating the redundant second-pass traversal for the common case. Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 578 +++++++++++---------------- 1 file changed, 231 insertions(+), 347 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 47de28d6387b..7201a767179f 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -14,23 +14,20 @@ Authors: Joachim Breitner #include "kernel/expr.h" /* -This module provides a two-pass variant of `instantiateMVars` with improved sharing. - -Pass 1 (`instantiate_direct_fn`): - Standard `instantiateMVars`-like traversal that resolves direct mvar assignments - with write-back and a single persistent cache. For delayed assignments, it - pre-normalizes the pending value (resolving its direct chain) but leaves the - delayed mvar application in the expression. Unassigned mvars are left in place. - -Pass 2 (`instantiate_delayed_fn`): - Fused traversal that resolves delayed assignments by carrying a fvar substitution. - Since pass 1 has pre-normalized all direct chains, each pending value is compact - and visited once, avoiding the O(n³) sharing loss that occurs when the fused - approach must also chase direct chains. Unassigned mvars are left as-is (matching - the original `instantiateMVars` behavior). - -The combination preserves sharing (O(n²) output for the pathological nested-delayed -case) while avoiding the separate `replace_fvars` calls of the standard approach. +Fused single-pass instantiateMVars with improved sharing. + +One class, two modes: +- Outer mode (m_scope == 0, fvar_subst empty): resolves direct mvar assignments + with write-back. When encountering a resolvable delayed mvar application, + fires delayed resolution inline (switching to delayed mode). +- Delayed mode (m_scope > 0, fvar_subst active): carries fvar substitution, + resolves nested delayed mvars. No direct mvar resolution needed (outer mode + already normalized all direct chains via write-back). + +The shared m_cache allows nodes not behind delayed mvars to be traversed exactly +once. In delayed mode, cache reads use the `use_global` condition (!has_fvar && +!has_expr_mvar) to safely read scope-independent results from m_cache. Cache +writes go to m_cache when m_result_scope == 0, otherwise to the scoped cache. */ namespace lean { @@ -119,54 +116,83 @@ class instantiate_lmvars_all_fn { }; /* ============================================================================ - Pass 1: Resolve direct mvar assignments with write-back. - For delayed assignments, pre-normalize the pending value but leave the - delayed mvar application in the expression. + Fused instantiation: resolves direct and delayed mvar assignments in a + single traversal. Outer mode handles direct assignments with write-back; + delayed mode carries fvar substitution for delayed assignment resolution. ============================================================================ */ -class instantiate_direct_fn { +struct fvar_subst_entry { + unsigned depth; + unsigned scope; + expr value; +}; + +class instantiate_fused_fn { + struct key_hasher { + std::size_t operator()(std::pair const & p) const { + return hash((size_t)p.first >> 3, p.second); + } + }; + + struct cache_entry { expr result; unsigned scope_level; unsigned scope_gen; }; + + typedef lean::unordered_map, cache_entry, key_hasher> scoped_cache; + metavar_ctx & m_mctx; instantiate_lmvars_all_fn m_level_fn; + + /* Direct mvar normalization tracking (outer mode). */ name_set m_already_normalized; - /* Set of delayed-assigned mvars whose pending value is assigned and - mvar-free after normalization. Used by pass 2 as a guard: only resolve - delayed assignments when the pending mvar is in this set, matching - the original instantiateMVars behavior. */ + std::vector m_saved; + + /* Resolvable delayed mvars: pending value is assigned and mvar-free + after full resolution. Built during outer-mode traversal. */ name_set m_resolvable_delayed; - /* Set to true when any delayed assignment is encountered, even if not - resolvable. Pass 2 is needed for write-back normalization in that case. */ - bool m_has_delayed; + + /* Shared cache: used in outer mode (ptr → expr) and readable in + delayed mode for scope-independent expressions. */ lean::unordered_map m_cache; - std::vector m_saved; + + /* Scoped cache: (ptr, depth) → cache_entry with generation-based + staleness detection. Used in delayed mode for scope-dependent results. */ + scoped_cache m_scoped_cache; + + /* Fvar substitution state (delayed mode). */ + name_hash_map m_fvar_subst; + unsigned m_depth; + + /* Scope tracking with generation-based staleness. */ + std::vector m_scope_gens; + unsigned m_gen_counter; + unsigned m_scope; + + /* After visit() returns, holds the maximum fvar-substitution scope that + contributed to the result. Used to decide cache placement. */ + unsigned m_result_scope; + + bool in_delayed_mode() const { return m_scope > 0; } level visit_level(level const & l) { + if (in_delayed_mode()) return l; /* levels already resolved by outer mode */ return m_level_fn(l); } levels visit_levels(levels const & ls) { + if (in_delayed_mode()) return ls; buffer lsNew; for (auto const & l : ls) lsNew.push_back(visit_level(l)); return levels(lsNew); } - inline expr cache(expr const & e, expr r, bool shared) { - if (shared) { - m_cache.insert(mk_pair(e.raw(), r)); - } - return r; - } + /* ---- Direct mvar assignment (outer mode) ---- */ - /* Get and normalize a direct mvar assignment. Write back the normalized value. */ - optional get_assignment(name const & mid) { + optional get_direct_assignment(name const & mid) { option_ref r = get_mvar_assignment(m_mctx, mid); - if (!r) { - return optional(); - } + if (!r) return optional(); expr a(r.get_val()); - if (!has_mvar(a) || m_already_normalized.contains(mid)) { + if (!has_mvar(a) || m_already_normalized.contains(mid)) return optional(a); - } m_already_normalized.insert(mid); expr a_new = visit(a); if (!is_eqp(a, a_new)) { @@ -176,42 +202,24 @@ class instantiate_direct_fn { return optional(a_new); } - expr visit_app_default(expr const & e) { - buffer args; - expr const * curr = &e; - while (is_app(*curr)) { - args.push_back(visit(app_arg(*curr))); - curr = &app_fn(*curr); - } - lean_assert(!is_mvar(*curr)); - expr f = visit(*curr); - return mk_rev_app(f, args.size(), args.data()); - } + /* ---- Delayed-mode mvar assignment ---- */ - expr visit_mvar_app_args(expr const & e) { - buffer args; - expr const * curr = &e; - while (is_app(*curr)) { - args.push_back(visit(app_arg(*curr))); - curr = &app_fn(*curr); - } - lean_assert(is_mvar(*curr)); - return mk_rev_app(*curr, args.size(), args.data()); + /* In delayed mode, direct mvar chains are already resolved by outer-mode + write-back. We just need to visit the value to apply fvar substitution + and resolve nested delayed mvars. No write-back in delayed mode. */ + optional get_delayed_mode_assignment(name const & mid) { + option_ref r = get_mvar_assignment(m_mctx, mid); + if (!r) return optional(); + expr a(r.get_val()); + if (!has_mvar(a) && !has_fvar(a)) + return optional(a); + return optional(visit(a)); } - expr visit_args_and_beta(expr const & f_new, expr const & e, buffer & args) { - expr const * curr = &e; - while (is_app(*curr)) { - args.push_back(visit(app_arg(*curr))); - curr = &app_fn(*curr); - } - bool preserve_data = false; - bool zeta = true; - return apply_beta(f_new, args.size(), args.data(), preserve_data, zeta); - } + /* ---- Resolvability check ---- */ /* Check whether a normalized value would be mvar-free after full resolution. - Uses m_resolvable_delayed to check inner delayed mvars. After pass 1 + Uses m_resolvable_delayed to check inner delayed mvars. After outer-mode normalization, remaining mvars are either unassigned or delayed-assigned. */ bool is_value_resolvable(expr const & e) { if (!has_expr_mvar(e)) return true; @@ -220,12 +228,10 @@ class instantiate_direct_fn { case expr_kind::Sort: case expr_kind::Const: return true; case expr_kind::MVar: - /* Bare mvar after pass 1 normalization: not directly assigned. */ return false; case expr_kind::App: { expr const & f = get_app_fn(e); if (is_mvar(f)) { - /* Mvar app after pass 1: must be delayed-assigned or unassigned. */ name const & mid = mvar_name(f); option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (!d) return false; @@ -233,7 +239,6 @@ class instantiate_direct_fn { if (fvars.size() > get_app_num_args(e)) return false; name mid_pending(cnstr_get(d.get_val().raw(), 1), true); if (!m_resolvable_delayed.contains(mid_pending)) return false; - /* Also check args for unresolvable mvars. */ expr const * curr = &e; while (is_app(*curr)) { if (!is_value_resolvable(app_arg(*curr))) return false; @@ -256,154 +261,17 @@ class instantiate_direct_fn { lean_unreachable(); } - /* Pre-normalize the pending value of a delayed assignment and record - whether it is resolvable (assigned and mvar-free after full resolution). - Inner delayed assignments are processed first (via recursive normalization), - so m_resolvable_delayed is already populated for them. */ + /* Pre-normalize a delayed assignment's pending value and record + whether it is resolvable. Called during outer-mode traversal. */ void normalize_delayed_pending(name const & mid_pending) { - m_has_delayed = true; - if (auto val = get_assignment(mid_pending)) { + if (auto val = get_direct_assignment(mid_pending)) { if (is_value_resolvable(*val)) { m_resolvable_delayed.insert(mid_pending); } } } - expr visit_app(expr const & e) { - expr const & f = get_app_fn(e); - if (!is_mvar(f)) { - return visit_app_default(e); - } - name const & mid = mvar_name(f); - /* Direct assignment takes precedence. */ - if (auto f_new = get_assignment(mid)) { - buffer args; - return visit_args_and_beta(*f_new, e, args); - } - /* Check delayed assignment and pre-normalize pending. */ - option_ref d = get_delayed_mvar_assignment(m_mctx, mid); - if (d) { - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - normalize_delayed_pending(mid_pending); - } - /* Leave the (possibly delayed) mvar in place, just visit args. */ - return visit_mvar_app_args(e); - } - - expr visit_mvar(expr const & e) { - name const & mid = mvar_name(e); - if (auto r = get_assignment(mid)) { - return *r; - } - /* Not directly assigned. Check if delayed-assigned and pre-normalize. */ - option_ref d = get_delayed_mvar_assignment(m_mctx, mid); - if (d) { - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - normalize_delayed_pending(mid_pending); - } - return e; /* leave mvar in place */ - } - -public: - instantiate_direct_fn(metavar_ctx & mctx):m_mctx(mctx), m_level_fn(mctx), m_has_delayed(false) {} - name_set const & resolvable_delayed() const { return m_resolvable_delayed; } - bool has_delayed() const { return m_has_delayed; } - - expr visit(expr const & e) { - if (!has_mvar(e)) - return e; - bool shared = false; - if (is_shared(e)) { - auto it = m_cache.find(e.raw()); - if (it != m_cache.end()) { - return it->second; - } - shared = true; - } - - switch (e.kind()) { - case expr_kind::BVar: - case expr_kind::Lit: case expr_kind::FVar: - lean_unreachable(); - case expr_kind::Sort: - return cache(e, update_sort(e, visit_level(sort_level(e))), shared); - case expr_kind::Const: - return cache(e, update_const(e, visit_levels(const_levels(e))), shared); - case expr_kind::MVar: - return visit_mvar(e); - case expr_kind::MData: - return cache(e, update_mdata(e, visit(mdata_expr(e))), shared); - case expr_kind::Proj: - return cache(e, update_proj(e, visit(proj_expr(e))), shared); - case expr_kind::App: - return cache(e, visit_app(e), shared); - case expr_kind::Pi: case expr_kind::Lambda: - return cache(e, update_binding(e, visit(binding_domain(e)), visit(binding_body(e))), shared); - case expr_kind::Let: - return cache(e, update_let(e, visit(let_type(e)), visit(let_value(e)), visit(let_body(e))), shared); - } - } - - expr operator()(expr const & e) { return visit(e); } -}; - -/* ============================================================================ - Pass 2: Resolve delayed assignments with fused fvar substitution. - Direct mvar chains have been pre-resolved by pass 1. - - Uses a flat (ptr, depth)-keyed cache with generation-based staleness. - Each visit_delayed scope gets a unique generation number; cache entries - record the scope level and generation at insertion. Validity is O(1): - entry valid iff level <= m_scope && m_scope_gens[level] == entry.scope_gen. - ============================================================================ */ - -struct fvar_subst_entry { - unsigned depth; - unsigned scope; - expr value; -}; - -class instantiate_delayed_fn { - struct key_hasher { - std::size_t operator()(std::pair const & p) const { - return hash((size_t)p.first >> 3, p.second); - } - }; - - struct cache_entry { expr result; unsigned scope_level; unsigned scope_gen; }; - - typedef lean::unordered_map, cache_entry, key_hasher> flat_cache; - - metavar_ctx & m_mctx; - name_set const & m_resolvable_delayed; - name_hash_map m_fvar_subst; - unsigned m_depth; - - /* Single flat cache with generation-based staleness detection. */ - flat_cache m_cache; - std::vector m_scope_gens; /* m_scope_gens[level] = generation */ - unsigned m_gen_counter; - unsigned m_scope; - - /* After visit() returns, this holds the maximum fvar-substitution - scope that contributed to the result — i.e., the outermost scope at which the - result is valid and can be cached. Updated monotonically (via max) through - the save/reset/restore pattern in visit(). */ - unsigned m_result_scope; - - /* Global cache for fvar-free expressions — scope-independent. */ - lean::unordered_map m_global_cache; - - /* Write-back support: when fvar_subst is empty, normalize and write back - mvar assignments to match the original instantiateMVars mctx side effects. - Downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored - assignments and expects them to be normalized. */ - name_set m_already_normalized; - std::vector m_saved; - - bool fvar_subst_empty() const { - return m_fvar_subst.empty(); - } + /* ---- Fvar substitution (delayed mode) ---- */ optional lookup_fvar(name const & fid) { auto it = m_fvar_subst.find(fid); @@ -416,14 +284,12 @@ class instantiate_delayed_fn { return optional(lift_loose_bvars(it->second.value, d)); } - /* Cache lookup — O(1) with generation-based staleness check. - An entry at scope_level 0 (no fvar dependency) is valid at any scope. - An entry at scope_level > 0 is only valid at exactly that scope level, - because an inner scope may shadow the fvars it depends on. */ - optional cache_lookup(lean_object * ptr) { + /* ---- Scoped cache (delayed mode) ---- */ + + optional scoped_cache_lookup(lean_object * ptr) { auto key = mk_pair(ptr, m_depth); - auto it = m_cache.find(key); - if (it == m_cache.end()) return {}; + auto it = m_scoped_cache.find(key); + if (it == m_scoped_cache.end()) return {}; auto & entry = it->second; if ((entry.scope_level == 0 || entry.scope_level == m_scope) && m_scope_gens[entry.scope_level] == entry.scope_gen) { @@ -433,42 +299,12 @@ class instantiate_delayed_fn { return {}; } - void cache_insert(lean_object * ptr, expr const & result) { + void scoped_cache_insert(lean_object * ptr, expr const & result) { auto key = mk_pair(ptr, m_depth); - m_cache[key] = { result, m_result_scope, m_scope_gens[m_result_scope] }; + m_scoped_cache[key] = { result, m_result_scope, m_scope_gens[m_result_scope] }; } - /* Get a direct mvar assignment. Visit it to resolve delayed mvars - and apply the fvar substitution. - When fvar_subst is empty, normalize and write back the result to - the mctx. This matches the original instantiateMVars behavior: - downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored - assignments and expects inner delayed assignments to be resolved. - When fvar_subst is non-empty, no write-back (values contain - fvar-substituted terms not suitable for the mctx). */ - optional get_assignment(name const & mid) { - option_ref r = get_mvar_assignment(m_mctx, mid); - if (!r) - return optional(); - expr a(r.get_val()); - if (fvar_subst_empty()) { - if (!has_mvar(a)) - return optional(a); - if (m_already_normalized.contains(mid)) - return optional(a); - m_already_normalized.insert(mid); - expr a_new = visit(a); - if (!is_eqp(a, a_new)) { - m_saved.push_back(a); - assign_mvar(m_mctx, mid, a_new); - } - return optional(a_new); - } else { - if (!has_mvar(a) && !has_fvar(a)) - return optional(a); - return optional(visit(a)); - } - } + /* ---- App visitors ---- */ expr visit_app_default(expr const & e) { buffer args; @@ -504,6 +340,8 @@ class instantiate_delayed_fn { return apply_beta(f_new, args.size(), args.data(), preserve_data, zeta); } + /* Fire delayed resolution: push fvar substitution scope, visit pending + value, pop scope. Uses generation-based staleness for the scoped cache. */ expr visit_delayed(array_ref const & fvars, name const & mid_pending, expr const & e, buffer & args) { expr const * curr = &e; @@ -531,125 +369,188 @@ class instantiate_delayed_fn { m_fvar_subst[fid] = {m_depth, m_scope, args[args.size() - 1 - i]}; } - /* Push: bump generation so stale entries at this scope level are detected. */ + /* Bump generation so stale entries at this scope level are detected. */ m_gen_counter++; if (m_scope >= m_scope_gens.size()) m_scope_gens.push_back(m_gen_counter); else m_scope_gens[m_scope] = m_gen_counter; + /* Visit the pending value. In delayed mode, mk_mvar(mid_pending) will + resolve via get_delayed_mode_assignment which visits the stored + (already-normalized) value to apply fvar substitution. */ expr val_new = visit(mk_mvar(mid_pending)); - /* Pop: just decrement scope — stale entries are detected by generation mismatch. */ + /* Pop scope. */ m_scope--; /* Restore the fvar substitution. */ for (auto & se : saved_entries) { - if (!se.had_old) { + if (!se.had_old) m_fvar_subst.erase(se.key); - } else { + else m_fvar_subst[se.key] = se.old; - } } - /* Use apply_beta instead of mk_rev_app: pass 1's beta-reduction may have - changed delayed mvar arguments (e.g., substituting a bvar with a concrete - value), so the resolved pending value may be a lambda that needs beta- - reduction with the extra args, matching the original's behavior. */ bool preserve_data = false; bool zeta = true; return apply_beta(val_new, extra_count, args.data(), preserve_data, zeta); } - expr visit_app(expr const & e) { + /* ---- Outer-mode app: resolve direct mvars, fire delayed inline ---- */ + + expr visit_app_outer(expr const & e) { expr const & f = get_app_fn(e); - if (!is_mvar(f)) { + if (!is_mvar(f)) return visit_app_default(e); - } name const & mid = mvar_name(f); /* Direct assignment takes precedence. */ - if (auto f_new = get_assignment(mid)) { + if (auto f_new = get_direct_assignment(mid)) { buffer args; return visit_args_and_beta(*f_new, e, args); } /* Check delayed assignment. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); - if (!d) { + if (!d) return visit_mvar_app_args(e); - } array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - if (fvars.size() > get_app_num_args(e)) { + /* Pre-normalize the pending value and check resolvability. */ + normalize_delayed_pending(mid_pending); + if (fvars.size() > get_app_num_args(e)) return visit_mvar_app_args(e); - } - /* Match standard instantiateMVars: only resolve the delayed assignment - when the pending value was determined to be resolvable by pass 1 - (assigned and mvar-free after normalization). */ - if (!m_resolvable_delayed.contains(mid_pending)) { - /* Still normalize the pending value for mctx write-back side effects. - The original instantiateMVars always normalizes the pending value - (via get_assignment(mid_pending)) even when it can't resolve. - Downstream code like MutualDef.mkInitialUsedFVarsMap reads stored - assignments and relies on inner delayed assignments being resolved. */ - if (fvar_subst_empty()) { - (void)get_assignment(mid_pending); - } + if (!m_resolvable_delayed.contains(mid_pending)) return visit_mvar_app_args(e); + /* Fire delayed resolution inline. */ + buffer args; + return visit_delayed(fvars, mid_pending, e, args); + } + + /* ---- Delayed-mode app: apply fvar subst, resolve nested delayed ---- */ + + expr visit_app_delayed(expr const & e) { + expr const & f = get_app_fn(e); + if (!is_mvar(f)) + return visit_app_default(e); + name const & mid = mvar_name(f); + /* In delayed mode, direct chains are already resolved. Check assignment + to apply fvar substitution to the stored value. */ + if (auto f_new = get_delayed_mode_assignment(mid)) { + buffer args; + return visit_args_and_beta(*f_new, e, args); } + /* Check delayed assignment for nested resolution. */ + option_ref d = get_delayed_mvar_assignment(m_mctx, mid); + if (!d) + return visit_mvar_app_args(e); + array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + if (fvars.size() > get_app_num_args(e)) + return visit_mvar_app_args(e); + if (!m_resolvable_delayed.contains(mid_pending)) + return visit_mvar_app_args(e); buffer args; return visit_delayed(fvars, mid_pending, e, args); } - expr visit_mvar(expr const & e) { + /* ---- Mvar visitors ---- */ + + expr visit_mvar_outer(expr const & e) { name const & mid = mvar_name(e); - if (auto r = get_assignment(mid)) { + if (auto r = get_direct_assignment(mid)) return *r; + /* Not directly assigned. Check if delayed-assigned and pre-normalize. */ + option_ref d = get_delayed_mvar_assignment(m_mctx, mid); + if (d) { + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + normalize_delayed_pending(mid_pending); } return e; } + expr visit_mvar_delayed(expr const & e) { + name const & mid = mvar_name(e); + if (auto r = get_delayed_mode_assignment(mid)) + return *r; + return e; + } + expr visit_fvar(expr const & e) { name const & fid = fvar_name(e); - if (auto r = lookup_fvar(fid)) { + if (auto r = lookup_fvar(fid)) return *r; - } return e; } -public: - instantiate_delayed_fn(metavar_ctx & mctx, name_set const & resolvable_delayed) - : m_mctx(mctx), m_resolvable_delayed(resolvable_delayed), - m_depth(0), m_gen_counter(0), m_scope(0), m_result_scope(0) { - m_scope_gens.push_back(0); /* scope 0 has generation 0 */ - } + /* ---- Outer-mode visit ---- */ - expr visit(expr const & e) { - if (fvar_subst_empty()) { - if (!has_mvar(e)) - return e; - } else { - if (!has_mvar(e) && !has_fvar(e)) - return e; + expr visit_outer(expr const & e) { + if (!has_mvar(e)) + return e; + bool shared = false; + if (is_shared(e)) { + auto it = m_cache.find(e.raw()); + if (it != m_cache.end()) + return it->second; + shared = true; + } + + expr r; + switch (e.kind()) { + case expr_kind::BVar: + case expr_kind::Lit: case expr_kind::FVar: + lean_unreachable(); + case expr_kind::Sort: + r = update_sort(e, visit_level(sort_level(e))); + break; + case expr_kind::Const: + r = update_const(e, visit_levels(const_levels(e))); + break; + case expr_kind::MVar: + return visit_mvar_outer(e); /* don't cache mvar results */ + case expr_kind::MData: + r = update_mdata(e, visit(mdata_expr(e))); + break; + case expr_kind::Proj: + r = update_proj(e, visit(proj_expr(e))); + break; + case expr_kind::App: + r = visit_app_outer(e); + break; + case expr_kind::Pi: case expr_kind::Lambda: + r = update_binding(e, visit(binding_domain(e)), visit(binding_body(e))); + break; + case expr_kind::Let: + r = update_let(e, visit(let_type(e)), visit(let_value(e)), visit(let_body(e))); + break; } + if (shared) + m_cache.insert(mk_pair(e.raw(), r)); + return r; + } + /* ---- Delayed-mode visit ---- */ + + expr visit_delayed_mode(expr const & e) { + if (!has_mvar(e) && !has_fvar(e)) + return e; + + /* use_global: expression has no fvars and no expr mvars, so its result + is scope-independent and can be read from/written to the shared cache. */ bool use_global = !has_fvar(e) && !has_expr_mvar(e); bool shared = false; if (is_shared(e)) { if (use_global) { - auto it = m_global_cache.find(e.raw()); - if (it != m_global_cache.end()) + auto it = m_cache.find(e.raw()); + if (it != m_cache.end()) return it->second; } else { - if (auto r = cache_lookup(e.raw())) + if (auto r = scoped_cache_lookup(e.raw())) return *r; } shared = true; } - /* Save and reset the result scope for this subtree. - After computing, cache_insert uses m_result_scope to place the entry - at the outermost valid scope level. Then we restore the parent's - watermark, taking the max with our contribution. */ unsigned saved_result_scope = m_result_scope; m_result_scope = 0; @@ -668,7 +569,7 @@ class instantiate_delayed_fn { r = update_const(e, visit_levels(const_levels(e))); break; case expr_kind::MVar: - r = visit_mvar(e); + r = visit_mvar_delayed(e); goto done; /* mvar results are not (ptr, depth)-cacheable */ case expr_kind::MData: r = update_mdata(e, visit(mdata_expr(e))); @@ -677,7 +578,7 @@ class instantiate_delayed_fn { r = update_proj(e, visit(proj_expr(e))); break; case expr_kind::App: - r = visit_app(e); + r = visit_app_delayed(e); break; case expr_kind::Pi: case expr_kind::Lambda: { expr d = visit(binding_domain(e)); @@ -698,10 +599,10 @@ class instantiate_delayed_fn { } } if (shared) { - if (use_global) - m_global_cache.insert(mk_pair(e.raw(), r)); + if (m_result_scope == 0) + m_cache.insert(mk_pair(e.raw(), r)); else - cache_insert(e.raw(), r); + scoped_cache_insert(e.raw(), r); } done: @@ -709,54 +610,37 @@ class instantiate_delayed_fn { return r; } - level visit_level(level const & l) { - /* Pass 2 does not handle level mvars — pass 1 already resolved them. - But we still need this for the visit_levels call in update_sort/update_const. - Since levels have no fvars, we can just return them as-is. */ - return l; +public: + instantiate_fused_fn(metavar_ctx & mctx) + : m_mctx(mctx), m_level_fn(mctx), + m_depth(0), m_gen_counter(0), m_scope(0), m_result_scope(0) { + m_scope_gens.push_back(0); } - levels visit_levels(levels const & ls) { - return ls; + expr visit(expr const & e) { + if (in_delayed_mode()) + return visit_delayed_mode(e); + else + return visit_outer(e); } expr operator()(expr const & e) { return visit(e); } }; /* ============================================================================ - Entry points: run pass 1 then pass 2. + Entry points. ============================================================================ */ -static object * run_instantiate_all(object * m, object * e) { +extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all(object * m, object * e) { metavar_ctx mctx(m); - - /* Pass 1: resolve direct mvar assignments, pre-normalize pending values. */ - instantiate_direct_fn pass1(mctx); - expr e1 = pass1(expr(e)); - - /* Pass 2: resolve delayed assignments with fused fvar substitution. - Skip if pass 1 found no delayed assignments at all — the expression - has no delayed mvars that need resolution or write-back. */ - expr e2; - if (!pass1.has_delayed()) { - e2 = e1; - } else { - instantiate_delayed_fn pass2(mctx, pass1.resolvable_delayed()); - e2 = pass2(e1); - } - - /* (mctx, expr) */ + expr e_new = instantiate_fused_fn(mctx)(expr(e)); object * r = alloc_cnstr(0, 2, 0); cnstr_set(r, 0, mctx.steal()); - cnstr_set(r, 1, e2.steal()); + cnstr_set(r, 1, e_new.steal()); return r; } -extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all(object * m, object * e) { - return run_instantiate_all(m, e); -} - extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all_sharing(object * m, object * e) { - return run_instantiate_all(m, e); + return lean_instantiate_expr_mvars_all(m, e); } } From f0562fac73304bc017d665d49baa22e1cd58e2df Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Tue, 3 Mar 2026 11:19:28 +0000 Subject: [PATCH 20/45] Revert pass fusion, has perf issues --- src/kernel/instantiate_mvars_all.cpp | 578 ++++++++++++++++----------- 1 file changed, 347 insertions(+), 231 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 7201a767179f..47de28d6387b 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -14,20 +14,23 @@ Authors: Joachim Breitner #include "kernel/expr.h" /* -Fused single-pass instantiateMVars with improved sharing. - -One class, two modes: -- Outer mode (m_scope == 0, fvar_subst empty): resolves direct mvar assignments - with write-back. When encountering a resolvable delayed mvar application, - fires delayed resolution inline (switching to delayed mode). -- Delayed mode (m_scope > 0, fvar_subst active): carries fvar substitution, - resolves nested delayed mvars. No direct mvar resolution needed (outer mode - already normalized all direct chains via write-back). - -The shared m_cache allows nodes not behind delayed mvars to be traversed exactly -once. In delayed mode, cache reads use the `use_global` condition (!has_fvar && -!has_expr_mvar) to safely read scope-independent results from m_cache. Cache -writes go to m_cache when m_result_scope == 0, otherwise to the scoped cache. +This module provides a two-pass variant of `instantiateMVars` with improved sharing. + +Pass 1 (`instantiate_direct_fn`): + Standard `instantiateMVars`-like traversal that resolves direct mvar assignments + with write-back and a single persistent cache. For delayed assignments, it + pre-normalizes the pending value (resolving its direct chain) but leaves the + delayed mvar application in the expression. Unassigned mvars are left in place. + +Pass 2 (`instantiate_delayed_fn`): + Fused traversal that resolves delayed assignments by carrying a fvar substitution. + Since pass 1 has pre-normalized all direct chains, each pending value is compact + and visited once, avoiding the O(n³) sharing loss that occurs when the fused + approach must also chase direct chains. Unassigned mvars are left as-is (matching + the original `instantiateMVars` behavior). + +The combination preserves sharing (O(n²) output for the pathological nested-delayed +case) while avoiding the separate `replace_fvars` calls of the standard approach. */ namespace lean { @@ -116,83 +119,54 @@ class instantiate_lmvars_all_fn { }; /* ============================================================================ - Fused instantiation: resolves direct and delayed mvar assignments in a - single traversal. Outer mode handles direct assignments with write-back; - delayed mode carries fvar substitution for delayed assignment resolution. + Pass 1: Resolve direct mvar assignments with write-back. + For delayed assignments, pre-normalize the pending value but leave the + delayed mvar application in the expression. ============================================================================ */ -struct fvar_subst_entry { - unsigned depth; - unsigned scope; - expr value; -}; - -class instantiate_fused_fn { - struct key_hasher { - std::size_t operator()(std::pair const & p) const { - return hash((size_t)p.first >> 3, p.second); - } - }; - - struct cache_entry { expr result; unsigned scope_level; unsigned scope_gen; }; - - typedef lean::unordered_map, cache_entry, key_hasher> scoped_cache; - +class instantiate_direct_fn { metavar_ctx & m_mctx; instantiate_lmvars_all_fn m_level_fn; - - /* Direct mvar normalization tracking (outer mode). */ name_set m_already_normalized; - std::vector m_saved; - - /* Resolvable delayed mvars: pending value is assigned and mvar-free - after full resolution. Built during outer-mode traversal. */ + /* Set of delayed-assigned mvars whose pending value is assigned and + mvar-free after normalization. Used by pass 2 as a guard: only resolve + delayed assignments when the pending mvar is in this set, matching + the original instantiateMVars behavior. */ name_set m_resolvable_delayed; - - /* Shared cache: used in outer mode (ptr → expr) and readable in - delayed mode for scope-independent expressions. */ + /* Set to true when any delayed assignment is encountered, even if not + resolvable. Pass 2 is needed for write-back normalization in that case. */ + bool m_has_delayed; lean::unordered_map m_cache; - - /* Scoped cache: (ptr, depth) → cache_entry with generation-based - staleness detection. Used in delayed mode for scope-dependent results. */ - scoped_cache m_scoped_cache; - - /* Fvar substitution state (delayed mode). */ - name_hash_map m_fvar_subst; - unsigned m_depth; - - /* Scope tracking with generation-based staleness. */ - std::vector m_scope_gens; - unsigned m_gen_counter; - unsigned m_scope; - - /* After visit() returns, holds the maximum fvar-substitution scope that - contributed to the result. Used to decide cache placement. */ - unsigned m_result_scope; - - bool in_delayed_mode() const { return m_scope > 0; } + std::vector m_saved; level visit_level(level const & l) { - if (in_delayed_mode()) return l; /* levels already resolved by outer mode */ return m_level_fn(l); } levels visit_levels(levels const & ls) { - if (in_delayed_mode()) return ls; buffer lsNew; for (auto const & l : ls) lsNew.push_back(visit_level(l)); return levels(lsNew); } - /* ---- Direct mvar assignment (outer mode) ---- */ + inline expr cache(expr const & e, expr r, bool shared) { + if (shared) { + m_cache.insert(mk_pair(e.raw(), r)); + } + return r; + } - optional get_direct_assignment(name const & mid) { + /* Get and normalize a direct mvar assignment. Write back the normalized value. */ + optional get_assignment(name const & mid) { option_ref r = get_mvar_assignment(m_mctx, mid); - if (!r) return optional(); + if (!r) { + return optional(); + } expr a(r.get_val()); - if (!has_mvar(a) || m_already_normalized.contains(mid)) + if (!has_mvar(a) || m_already_normalized.contains(mid)) { return optional(a); + } m_already_normalized.insert(mid); expr a_new = visit(a); if (!is_eqp(a, a_new)) { @@ -202,24 +176,42 @@ class instantiate_fused_fn { return optional(a_new); } - /* ---- Delayed-mode mvar assignment ---- */ + expr visit_app_default(expr const & e) { + buffer args; + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + lean_assert(!is_mvar(*curr)); + expr f = visit(*curr); + return mk_rev_app(f, args.size(), args.data()); + } - /* In delayed mode, direct mvar chains are already resolved by outer-mode - write-back. We just need to visit the value to apply fvar substitution - and resolve nested delayed mvars. No write-back in delayed mode. */ - optional get_delayed_mode_assignment(name const & mid) { - option_ref r = get_mvar_assignment(m_mctx, mid); - if (!r) return optional(); - expr a(r.get_val()); - if (!has_mvar(a) && !has_fvar(a)) - return optional(a); - return optional(visit(a)); + expr visit_mvar_app_args(expr const & e) { + buffer args; + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + lean_assert(is_mvar(*curr)); + return mk_rev_app(*curr, args.size(), args.data()); } - /* ---- Resolvability check ---- */ + expr visit_args_and_beta(expr const & f_new, expr const & e, buffer & args) { + expr const * curr = &e; + while (is_app(*curr)) { + args.push_back(visit(app_arg(*curr))); + curr = &app_fn(*curr); + } + bool preserve_data = false; + bool zeta = true; + return apply_beta(f_new, args.size(), args.data(), preserve_data, zeta); + } /* Check whether a normalized value would be mvar-free after full resolution. - Uses m_resolvable_delayed to check inner delayed mvars. After outer-mode + Uses m_resolvable_delayed to check inner delayed mvars. After pass 1 normalization, remaining mvars are either unassigned or delayed-assigned. */ bool is_value_resolvable(expr const & e) { if (!has_expr_mvar(e)) return true; @@ -228,10 +220,12 @@ class instantiate_fused_fn { case expr_kind::Sort: case expr_kind::Const: return true; case expr_kind::MVar: + /* Bare mvar after pass 1 normalization: not directly assigned. */ return false; case expr_kind::App: { expr const & f = get_app_fn(e); if (is_mvar(f)) { + /* Mvar app after pass 1: must be delayed-assigned or unassigned. */ name const & mid = mvar_name(f); option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (!d) return false; @@ -239,6 +233,7 @@ class instantiate_fused_fn { if (fvars.size() > get_app_num_args(e)) return false; name mid_pending(cnstr_get(d.get_val().raw(), 1), true); if (!m_resolvable_delayed.contains(mid_pending)) return false; + /* Also check args for unresolvable mvars. */ expr const * curr = &e; while (is_app(*curr)) { if (!is_value_resolvable(app_arg(*curr))) return false; @@ -261,17 +256,154 @@ class instantiate_fused_fn { lean_unreachable(); } - /* Pre-normalize a delayed assignment's pending value and record - whether it is resolvable. Called during outer-mode traversal. */ + /* Pre-normalize the pending value of a delayed assignment and record + whether it is resolvable (assigned and mvar-free after full resolution). + Inner delayed assignments are processed first (via recursive normalization), + so m_resolvable_delayed is already populated for them. */ void normalize_delayed_pending(name const & mid_pending) { - if (auto val = get_direct_assignment(mid_pending)) { + m_has_delayed = true; + if (auto val = get_assignment(mid_pending)) { if (is_value_resolvable(*val)) { m_resolvable_delayed.insert(mid_pending); } } } - /* ---- Fvar substitution (delayed mode) ---- */ + expr visit_app(expr const & e) { + expr const & f = get_app_fn(e); + if (!is_mvar(f)) { + return visit_app_default(e); + } + name const & mid = mvar_name(f); + /* Direct assignment takes precedence. */ + if (auto f_new = get_assignment(mid)) { + buffer args; + return visit_args_and_beta(*f_new, e, args); + } + /* Check delayed assignment and pre-normalize pending. */ + option_ref d = get_delayed_mvar_assignment(m_mctx, mid); + if (d) { + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + normalize_delayed_pending(mid_pending); + } + /* Leave the (possibly delayed) mvar in place, just visit args. */ + return visit_mvar_app_args(e); + } + + expr visit_mvar(expr const & e) { + name const & mid = mvar_name(e); + if (auto r = get_assignment(mid)) { + return *r; + } + /* Not directly assigned. Check if delayed-assigned and pre-normalize. */ + option_ref d = get_delayed_mvar_assignment(m_mctx, mid); + if (d) { + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + normalize_delayed_pending(mid_pending); + } + return e; /* leave mvar in place */ + } + +public: + instantiate_direct_fn(metavar_ctx & mctx):m_mctx(mctx), m_level_fn(mctx), m_has_delayed(false) {} + name_set const & resolvable_delayed() const { return m_resolvable_delayed; } + bool has_delayed() const { return m_has_delayed; } + + expr visit(expr const & e) { + if (!has_mvar(e)) + return e; + bool shared = false; + if (is_shared(e)) { + auto it = m_cache.find(e.raw()); + if (it != m_cache.end()) { + return it->second; + } + shared = true; + } + + switch (e.kind()) { + case expr_kind::BVar: + case expr_kind::Lit: case expr_kind::FVar: + lean_unreachable(); + case expr_kind::Sort: + return cache(e, update_sort(e, visit_level(sort_level(e))), shared); + case expr_kind::Const: + return cache(e, update_const(e, visit_levels(const_levels(e))), shared); + case expr_kind::MVar: + return visit_mvar(e); + case expr_kind::MData: + return cache(e, update_mdata(e, visit(mdata_expr(e))), shared); + case expr_kind::Proj: + return cache(e, update_proj(e, visit(proj_expr(e))), shared); + case expr_kind::App: + return cache(e, visit_app(e), shared); + case expr_kind::Pi: case expr_kind::Lambda: + return cache(e, update_binding(e, visit(binding_domain(e)), visit(binding_body(e))), shared); + case expr_kind::Let: + return cache(e, update_let(e, visit(let_type(e)), visit(let_value(e)), visit(let_body(e))), shared); + } + } + + expr operator()(expr const & e) { return visit(e); } +}; + +/* ============================================================================ + Pass 2: Resolve delayed assignments with fused fvar substitution. + Direct mvar chains have been pre-resolved by pass 1. + + Uses a flat (ptr, depth)-keyed cache with generation-based staleness. + Each visit_delayed scope gets a unique generation number; cache entries + record the scope level and generation at insertion. Validity is O(1): + entry valid iff level <= m_scope && m_scope_gens[level] == entry.scope_gen. + ============================================================================ */ + +struct fvar_subst_entry { + unsigned depth; + unsigned scope; + expr value; +}; + +class instantiate_delayed_fn { + struct key_hasher { + std::size_t operator()(std::pair const & p) const { + return hash((size_t)p.first >> 3, p.second); + } + }; + + struct cache_entry { expr result; unsigned scope_level; unsigned scope_gen; }; + + typedef lean::unordered_map, cache_entry, key_hasher> flat_cache; + + metavar_ctx & m_mctx; + name_set const & m_resolvable_delayed; + name_hash_map m_fvar_subst; + unsigned m_depth; + + /* Single flat cache with generation-based staleness detection. */ + flat_cache m_cache; + std::vector m_scope_gens; /* m_scope_gens[level] = generation */ + unsigned m_gen_counter; + unsigned m_scope; + + /* After visit() returns, this holds the maximum fvar-substitution + scope that contributed to the result — i.e., the outermost scope at which the + result is valid and can be cached. Updated monotonically (via max) through + the save/reset/restore pattern in visit(). */ + unsigned m_result_scope; + + /* Global cache for fvar-free expressions — scope-independent. */ + lean::unordered_map m_global_cache; + + /* Write-back support: when fvar_subst is empty, normalize and write back + mvar assignments to match the original instantiateMVars mctx side effects. + Downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored + assignments and expects them to be normalized. */ + name_set m_already_normalized; + std::vector m_saved; + + bool fvar_subst_empty() const { + return m_fvar_subst.empty(); + } optional lookup_fvar(name const & fid) { auto it = m_fvar_subst.find(fid); @@ -284,12 +416,14 @@ class instantiate_fused_fn { return optional(lift_loose_bvars(it->second.value, d)); } - /* ---- Scoped cache (delayed mode) ---- */ - - optional scoped_cache_lookup(lean_object * ptr) { + /* Cache lookup — O(1) with generation-based staleness check. + An entry at scope_level 0 (no fvar dependency) is valid at any scope. + An entry at scope_level > 0 is only valid at exactly that scope level, + because an inner scope may shadow the fvars it depends on. */ + optional cache_lookup(lean_object * ptr) { auto key = mk_pair(ptr, m_depth); - auto it = m_scoped_cache.find(key); - if (it == m_scoped_cache.end()) return {}; + auto it = m_cache.find(key); + if (it == m_cache.end()) return {}; auto & entry = it->second; if ((entry.scope_level == 0 || entry.scope_level == m_scope) && m_scope_gens[entry.scope_level] == entry.scope_gen) { @@ -299,12 +433,42 @@ class instantiate_fused_fn { return {}; } - void scoped_cache_insert(lean_object * ptr, expr const & result) { + void cache_insert(lean_object * ptr, expr const & result) { auto key = mk_pair(ptr, m_depth); - m_scoped_cache[key] = { result, m_result_scope, m_scope_gens[m_result_scope] }; + m_cache[key] = { result, m_result_scope, m_scope_gens[m_result_scope] }; } - /* ---- App visitors ---- */ + /* Get a direct mvar assignment. Visit it to resolve delayed mvars + and apply the fvar substitution. + When fvar_subst is empty, normalize and write back the result to + the mctx. This matches the original instantiateMVars behavior: + downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored + assignments and expects inner delayed assignments to be resolved. + When fvar_subst is non-empty, no write-back (values contain + fvar-substituted terms not suitable for the mctx). */ + optional get_assignment(name const & mid) { + option_ref r = get_mvar_assignment(m_mctx, mid); + if (!r) + return optional(); + expr a(r.get_val()); + if (fvar_subst_empty()) { + if (!has_mvar(a)) + return optional(a); + if (m_already_normalized.contains(mid)) + return optional(a); + m_already_normalized.insert(mid); + expr a_new = visit(a); + if (!is_eqp(a, a_new)) { + m_saved.push_back(a); + assign_mvar(m_mctx, mid, a_new); + } + return optional(a_new); + } else { + if (!has_mvar(a) && !has_fvar(a)) + return optional(a); + return optional(visit(a)); + } + } expr visit_app_default(expr const & e) { buffer args; @@ -340,8 +504,6 @@ class instantiate_fused_fn { return apply_beta(f_new, args.size(), args.data(), preserve_data, zeta); } - /* Fire delayed resolution: push fvar substitution scope, visit pending - value, pop scope. Uses generation-based staleness for the scoped cache. */ expr visit_delayed(array_ref const & fvars, name const & mid_pending, expr const & e, buffer & args) { expr const * curr = &e; @@ -369,188 +531,125 @@ class instantiate_fused_fn { m_fvar_subst[fid] = {m_depth, m_scope, args[args.size() - 1 - i]}; } - /* Bump generation so stale entries at this scope level are detected. */ + /* Push: bump generation so stale entries at this scope level are detected. */ m_gen_counter++; if (m_scope >= m_scope_gens.size()) m_scope_gens.push_back(m_gen_counter); else m_scope_gens[m_scope] = m_gen_counter; - /* Visit the pending value. In delayed mode, mk_mvar(mid_pending) will - resolve via get_delayed_mode_assignment which visits the stored - (already-normalized) value to apply fvar substitution. */ expr val_new = visit(mk_mvar(mid_pending)); - /* Pop scope. */ + /* Pop: just decrement scope — stale entries are detected by generation mismatch. */ m_scope--; /* Restore the fvar substitution. */ for (auto & se : saved_entries) { - if (!se.had_old) + if (!se.had_old) { m_fvar_subst.erase(se.key); - else + } else { m_fvar_subst[se.key] = se.old; + } } + /* Use apply_beta instead of mk_rev_app: pass 1's beta-reduction may have + changed delayed mvar arguments (e.g., substituting a bvar with a concrete + value), so the resolved pending value may be a lambda that needs beta- + reduction with the extra args, matching the original's behavior. */ bool preserve_data = false; bool zeta = true; return apply_beta(val_new, extra_count, args.data(), preserve_data, zeta); } - /* ---- Outer-mode app: resolve direct mvars, fire delayed inline ---- */ - - expr visit_app_outer(expr const & e) { + expr visit_app(expr const & e) { expr const & f = get_app_fn(e); - if (!is_mvar(f)) + if (!is_mvar(f)) { return visit_app_default(e); + } name const & mid = mvar_name(f); /* Direct assignment takes precedence. */ - if (auto f_new = get_direct_assignment(mid)) { + if (auto f_new = get_assignment(mid)) { buffer args; return visit_args_and_beta(*f_new, e, args); } /* Check delayed assignment. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); - if (!d) - return visit_mvar_app_args(e); - array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - /* Pre-normalize the pending value and check resolvability. */ - normalize_delayed_pending(mid_pending); - if (fvars.size() > get_app_num_args(e)) - return visit_mvar_app_args(e); - if (!m_resolvable_delayed.contains(mid_pending)) + if (!d) { return visit_mvar_app_args(e); - /* Fire delayed resolution inline. */ - buffer args; - return visit_delayed(fvars, mid_pending, e, args); - } - - /* ---- Delayed-mode app: apply fvar subst, resolve nested delayed ---- */ - - expr visit_app_delayed(expr const & e) { - expr const & f = get_app_fn(e); - if (!is_mvar(f)) - return visit_app_default(e); - name const & mid = mvar_name(f); - /* In delayed mode, direct chains are already resolved. Check assignment - to apply fvar substitution to the stored value. */ - if (auto f_new = get_delayed_mode_assignment(mid)) { - buffer args; - return visit_args_and_beta(*f_new, e, args); } - /* Check delayed assignment for nested resolution. */ - option_ref d = get_delayed_mvar_assignment(m_mctx, mid); - if (!d) - return visit_mvar_app_args(e); array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - if (fvars.size() > get_app_num_args(e)) + if (fvars.size() > get_app_num_args(e)) { return visit_mvar_app_args(e); - if (!m_resolvable_delayed.contains(mid_pending)) + } + /* Match standard instantiateMVars: only resolve the delayed assignment + when the pending value was determined to be resolvable by pass 1 + (assigned and mvar-free after normalization). */ + if (!m_resolvable_delayed.contains(mid_pending)) { + /* Still normalize the pending value for mctx write-back side effects. + The original instantiateMVars always normalizes the pending value + (via get_assignment(mid_pending)) even when it can't resolve. + Downstream code like MutualDef.mkInitialUsedFVarsMap reads stored + assignments and relies on inner delayed assignments being resolved. */ + if (fvar_subst_empty()) { + (void)get_assignment(mid_pending); + } return visit_mvar_app_args(e); + } buffer args; return visit_delayed(fvars, mid_pending, e, args); } - /* ---- Mvar visitors ---- */ - - expr visit_mvar_outer(expr const & e) { + expr visit_mvar(expr const & e) { name const & mid = mvar_name(e); - if (auto r = get_direct_assignment(mid)) + if (auto r = get_assignment(mid)) { return *r; - /* Not directly assigned. Check if delayed-assigned and pre-normalize. */ - option_ref d = get_delayed_mvar_assignment(m_mctx, mid); - if (d) { - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - normalize_delayed_pending(mid_pending); } return e; } - expr visit_mvar_delayed(expr const & e) { - name const & mid = mvar_name(e); - if (auto r = get_delayed_mode_assignment(mid)) - return *r; - return e; - } - expr visit_fvar(expr const & e) { name const & fid = fvar_name(e); - if (auto r = lookup_fvar(fid)) + if (auto r = lookup_fvar(fid)) { return *r; + } return e; } - /* ---- Outer-mode visit ---- */ - - expr visit_outer(expr const & e) { - if (!has_mvar(e)) - return e; - bool shared = false; - if (is_shared(e)) { - auto it = m_cache.find(e.raw()); - if (it != m_cache.end()) - return it->second; - shared = true; - } - - expr r; - switch (e.kind()) { - case expr_kind::BVar: - case expr_kind::Lit: case expr_kind::FVar: - lean_unreachable(); - case expr_kind::Sort: - r = update_sort(e, visit_level(sort_level(e))); - break; - case expr_kind::Const: - r = update_const(e, visit_levels(const_levels(e))); - break; - case expr_kind::MVar: - return visit_mvar_outer(e); /* don't cache mvar results */ - case expr_kind::MData: - r = update_mdata(e, visit(mdata_expr(e))); - break; - case expr_kind::Proj: - r = update_proj(e, visit(proj_expr(e))); - break; - case expr_kind::App: - r = visit_app_outer(e); - break; - case expr_kind::Pi: case expr_kind::Lambda: - r = update_binding(e, visit(binding_domain(e)), visit(binding_body(e))); - break; - case expr_kind::Let: - r = update_let(e, visit(let_type(e)), visit(let_value(e)), visit(let_body(e))); - break; - } - if (shared) - m_cache.insert(mk_pair(e.raw(), r)); - return r; +public: + instantiate_delayed_fn(metavar_ctx & mctx, name_set const & resolvable_delayed) + : m_mctx(mctx), m_resolvable_delayed(resolvable_delayed), + m_depth(0), m_gen_counter(0), m_scope(0), m_result_scope(0) { + m_scope_gens.push_back(0); /* scope 0 has generation 0 */ } - /* ---- Delayed-mode visit ---- */ - - expr visit_delayed_mode(expr const & e) { - if (!has_mvar(e) && !has_fvar(e)) - return e; + expr visit(expr const & e) { + if (fvar_subst_empty()) { + if (!has_mvar(e)) + return e; + } else { + if (!has_mvar(e) && !has_fvar(e)) + return e; + } - /* use_global: expression has no fvars and no expr mvars, so its result - is scope-independent and can be read from/written to the shared cache. */ bool use_global = !has_fvar(e) && !has_expr_mvar(e); bool shared = false; if (is_shared(e)) { if (use_global) { - auto it = m_cache.find(e.raw()); - if (it != m_cache.end()) + auto it = m_global_cache.find(e.raw()); + if (it != m_global_cache.end()) return it->second; } else { - if (auto r = scoped_cache_lookup(e.raw())) + if (auto r = cache_lookup(e.raw())) return *r; } shared = true; } + /* Save and reset the result scope for this subtree. + After computing, cache_insert uses m_result_scope to place the entry + at the outermost valid scope level. Then we restore the parent's + watermark, taking the max with our contribution. */ unsigned saved_result_scope = m_result_scope; m_result_scope = 0; @@ -569,7 +668,7 @@ class instantiate_fused_fn { r = update_const(e, visit_levels(const_levels(e))); break; case expr_kind::MVar: - r = visit_mvar_delayed(e); + r = visit_mvar(e); goto done; /* mvar results are not (ptr, depth)-cacheable */ case expr_kind::MData: r = update_mdata(e, visit(mdata_expr(e))); @@ -578,7 +677,7 @@ class instantiate_fused_fn { r = update_proj(e, visit(proj_expr(e))); break; case expr_kind::App: - r = visit_app_delayed(e); + r = visit_app(e); break; case expr_kind::Pi: case expr_kind::Lambda: { expr d = visit(binding_domain(e)); @@ -599,10 +698,10 @@ class instantiate_fused_fn { } } if (shared) { - if (m_result_scope == 0) - m_cache.insert(mk_pair(e.raw(), r)); + if (use_global) + m_global_cache.insert(mk_pair(e.raw(), r)); else - scoped_cache_insert(e.raw(), r); + cache_insert(e.raw(), r); } done: @@ -610,37 +709,54 @@ class instantiate_fused_fn { return r; } -public: - instantiate_fused_fn(metavar_ctx & mctx) - : m_mctx(mctx), m_level_fn(mctx), - m_depth(0), m_gen_counter(0), m_scope(0), m_result_scope(0) { - m_scope_gens.push_back(0); + level visit_level(level const & l) { + /* Pass 2 does not handle level mvars — pass 1 already resolved them. + But we still need this for the visit_levels call in update_sort/update_const. + Since levels have no fvars, we can just return them as-is. */ + return l; } - expr visit(expr const & e) { - if (in_delayed_mode()) - return visit_delayed_mode(e); - else - return visit_outer(e); + levels visit_levels(levels const & ls) { + return ls; } expr operator()(expr const & e) { return visit(e); } }; /* ============================================================================ - Entry points. + Entry points: run pass 1 then pass 2. ============================================================================ */ -extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all(object * m, object * e) { +static object * run_instantiate_all(object * m, object * e) { metavar_ctx mctx(m); - expr e_new = instantiate_fused_fn(mctx)(expr(e)); + + /* Pass 1: resolve direct mvar assignments, pre-normalize pending values. */ + instantiate_direct_fn pass1(mctx); + expr e1 = pass1(expr(e)); + + /* Pass 2: resolve delayed assignments with fused fvar substitution. + Skip if pass 1 found no delayed assignments at all — the expression + has no delayed mvars that need resolution or write-back. */ + expr e2; + if (!pass1.has_delayed()) { + e2 = e1; + } else { + instantiate_delayed_fn pass2(mctx, pass1.resolvable_delayed()); + e2 = pass2(e1); + } + + /* (mctx, expr) */ object * r = alloc_cnstr(0, 2, 0); cnstr_set(r, 0, mctx.steal()); - cnstr_set(r, 1, e_new.steal()); + cnstr_set(r, 1, e2.steal()); return r; } +extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all(object * m, object * e) { + return run_instantiate_all(m, e); +} + extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_all_sharing(object * m, object * e) { - return lean_instantiate_expr_mvars_all(m, e); + return run_instantiate_all(m, e); } } From ff6cd50b8d33dbcaa77414e40c48439d0c25a008 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Tue, 3 Mar 2026 13:55:41 +0000 Subject: [PATCH 21/45] perf: integrate resolvability tracking into pass 1 traversal cache Replace the uncached `is_value_resolvable` tree walk with integrated resolvability tracking in pass 1's traversal. Each cache entry now stores both the result expression and a resolvability flag, eliminating redundant O(n) walks that lost sharing on large expressions. Key changes: - `m_result_resolvable` flag propagated via AND through save/restore pattern, cached alongside expression results - `m_normalized_resolvable` name_set tracks per-mvar resolvability for the `m_already_normalized` early-return path in `get_assignment` - Cache hits and mvar-free early returns AND into (not overwrite) the current resolvability state to preserve sibling contributions - Pass 2 write-back for non-resolvable delayed assignments restored (needed by MutualDef.mkInitialUsedFVarsMap) Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 226 ++++++++++++++++----------- 1 file changed, 134 insertions(+), 92 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 47de28d6387b..cd3f962ae692 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -121,7 +121,10 @@ class instantiate_lmvars_all_fn { /* ============================================================================ Pass 1: Resolve direct mvar assignments with write-back. For delayed assignments, pre-normalize the pending value but leave the - delayed mvar application in the expression. + delayed mvar application in the expression. Tracks resolvability of + each subexpression (whether all remaining mvars are delayed-assigned + with resolvable pending values) as a side product of the traversal, + cached alongside the result to avoid redundant tree walks. ============================================================================ */ class instantiate_direct_fn { @@ -129,14 +132,26 @@ class instantiate_direct_fn { instantiate_lmvars_all_fn m_level_fn; name_set m_already_normalized; /* Set of delayed-assigned mvars whose pending value is assigned and - mvar-free after normalization. Used by pass 2 as a guard: only resolve - delayed assignments when the pending mvar is in this set, matching - the original instantiateMVars behavior. */ + fully resolvable after pass 1 normalization. Used by pass 2 as a + guard: only resolve delayed assignments when the pending mvar is + in this set, matching the original instantiateMVars behavior. */ name_set m_resolvable_delayed; + /* Tracks which normalized mvars have resolvable values, so that + when get_assignment returns early via m_already_normalized, we + can correctly set m_result_resolvable. */ + name_set m_normalized_resolvable; /* Set to true when any delayed assignment is encountered, even if not resolvable. Pass 2 is needed for write-back normalization in that case. */ bool m_has_delayed; - lean::unordered_map m_cache; + + /* After visit() returns, indicates whether the result would be mvar-free + after full delayed-assignment resolution (pass 2). Updated via AND + through the save/restore pattern: if any child is not resolvable, + the parent is not resolvable. Cached alongside the result expression. */ + bool m_result_resolvable; + + struct cache_entry { expr result; bool resolvable; }; + lean::unordered_map m_cache; std::vector m_saved; level visit_level(level const & l) { @@ -152,23 +167,35 @@ class instantiate_direct_fn { inline expr cache(expr const & e, expr r, bool shared) { if (shared) { - m_cache.insert(mk_pair(e.raw(), r)); + m_cache.insert(mk_pair(e.raw(), cache_entry{r, m_result_resolvable})); } return r; } - /* Get and normalize a direct mvar assignment. Write back the normalized value. */ + /* Get and normalize a direct mvar assignment. Write back the normalized value. + Sets m_result_resolvable as a side effect: true if the normalized value + would be mvar-free after full delayed-assignment resolution. */ optional get_assignment(name const & mid) { option_ref r = get_mvar_assignment(m_mctx, mid); if (!r) { return optional(); } expr a(r.get_val()); - if (!has_mvar(a) || m_already_normalized.contains(mid)) { + if (!has_mvar(a)) { + /* No mvars at all — trivially resolvable. m_result_resolvable stays true. */ + return optional(a); + } + if (m_already_normalized.contains(mid)) { + /* Use cached resolvability from the first normalization visit. */ + if (!m_normalized_resolvable.contains(mid)) + m_result_resolvable = false; return optional(a); } m_already_normalized.insert(mid); expr a_new = visit(a); + /* m_result_resolvable was set by visit(a). Cache it. */ + if (m_result_resolvable) + m_normalized_resolvable.insert(mid); if (!is_eqp(a, a_new)) { m_saved.push_back(a); assign_mvar(m_mctx, mid, a_new); @@ -210,65 +237,6 @@ class instantiate_direct_fn { return apply_beta(f_new, args.size(), args.data(), preserve_data, zeta); } - /* Check whether a normalized value would be mvar-free after full resolution. - Uses m_resolvable_delayed to check inner delayed mvars. After pass 1 - normalization, remaining mvars are either unassigned or delayed-assigned. */ - bool is_value_resolvable(expr const & e) { - if (!has_expr_mvar(e)) return true; - switch (e.kind()) { - case expr_kind::BVar: case expr_kind::Lit: case expr_kind::FVar: - case expr_kind::Sort: case expr_kind::Const: - return true; - case expr_kind::MVar: - /* Bare mvar after pass 1 normalization: not directly assigned. */ - return false; - case expr_kind::App: { - expr const & f = get_app_fn(e); - if (is_mvar(f)) { - /* Mvar app after pass 1: must be delayed-assigned or unassigned. */ - name const & mid = mvar_name(f); - option_ref d = get_delayed_mvar_assignment(m_mctx, mid); - if (!d) return false; - array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); - if (fvars.size() > get_app_num_args(e)) return false; - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - if (!m_resolvable_delayed.contains(mid_pending)) return false; - /* Also check args for unresolvable mvars. */ - expr const * curr = &e; - while (is_app(*curr)) { - if (!is_value_resolvable(app_arg(*curr))) return false; - curr = &app_fn(*curr); - } - return true; - } - return is_value_resolvable(app_fn(e)) && is_value_resolvable(app_arg(e)); - } - case expr_kind::Lambda: case expr_kind::Pi: - return is_value_resolvable(binding_domain(e)) && is_value_resolvable(binding_body(e)); - case expr_kind::Let: - return is_value_resolvable(let_type(e)) && is_value_resolvable(let_value(e)) - && is_value_resolvable(let_body(e)); - case expr_kind::MData: - return is_value_resolvable(mdata_expr(e)); - case expr_kind::Proj: - return is_value_resolvable(proj_expr(e)); - } - lean_unreachable(); - } - - /* Pre-normalize the pending value of a delayed assignment and record - whether it is resolvable (assigned and mvar-free after full resolution). - Inner delayed assignments are processed first (via recursive normalization), - so m_resolvable_delayed is already populated for them. */ - void normalize_delayed_pending(name const & mid_pending) { - m_has_delayed = true; - if (auto val = get_assignment(mid_pending)) { - if (is_value_resolvable(*val)) { - m_resolvable_delayed.insert(mid_pending); - } - } - } - expr visit_app(expr const & e) { expr const & f = get_app_fn(e); if (!is_mvar(f)) { @@ -280,13 +248,45 @@ class instantiate_direct_fn { buffer args; return visit_args_and_beta(*f_new, e, args); } - /* Check delayed assignment and pre-normalize pending. */ + /* Check for delayed assignment. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (d) { + m_has_delayed = true; + array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - normalize_delayed_pending(mid_pending); + + /* Pre-normalize the pending value and check resolvability. + Save/restore m_result_resolvable so the pending check + doesn't pollute the outer resolvability tracking. + m_result_resolvable (set by get_assignment's visit) correctly + tracks whether all delayed mvars in the value are themselves + resolvable — no has_expr_mvar check needed. */ + bool saved = m_result_resolvable; + m_result_resolvable = true; + bool pending_resolvable = false; + if (auto val = get_assignment(mid_pending)) { + pending_resolvable = m_result_resolvable; + } + m_result_resolvable = saved; + + if (pending_resolvable) { + m_resolvable_delayed.insert(mid_pending); + } + + /* Visit args — their resolvability contributes to the parent. */ + expr result = visit_mvar_app_args(e); + + /* The delayed mvar app is resolvable only if: pending is resolvable, + sufficient args for fvar substitution, and all args are resolvable. */ + if (!pending_resolvable || fvars.size() > get_app_num_args(e)) { + m_result_resolvable = false; + } + /* Otherwise m_result_resolvable was set by visit_mvar_app_args + (which visits and ANDs the args' resolvability). */ + return result; } - /* Leave the (possibly delayed) mvar in place, just visit args. */ + /* Not delayed: unassigned mvar — not resolvable. */ + m_result_resolvable = false; return visit_mvar_app_args(e); } @@ -295,53 +295,95 @@ class instantiate_direct_fn { if (auto r = get_assignment(mid)) { return *r; } - /* Not directly assigned. Check if delayed-assigned and pre-normalize. */ + /* Not directly assigned. Check if delayed-assigned. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (d) { + m_has_delayed = true; name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - normalize_delayed_pending(mid_pending); + + /* Pre-normalize the pending value. Even though a bare mvar + can't resolve (no args for fvar subst), the same mid_pending + may appear in a delayed mvar app elsewhere that does have + enough args, so we must record its resolvability. */ + bool saved = m_result_resolvable; + m_result_resolvable = true; + if (auto val = get_assignment(mid_pending)) { + if (m_result_resolvable) + m_resolvable_delayed.insert(mid_pending); + } + m_result_resolvable = saved; } - return e; /* leave mvar in place */ + /* Bare mvar after pass 1: not resolvable (no args for fvar subst). */ + m_result_resolvable = false; + return e; } public: - instantiate_direct_fn(metavar_ctx & mctx):m_mctx(mctx), m_level_fn(mctx), m_has_delayed(false) {} + instantiate_direct_fn(metavar_ctx & mctx) + : m_mctx(mctx), m_level_fn(mctx), m_has_delayed(false), m_result_resolvable(true) {} name_set const & resolvable_delayed() const { return m_resolvable_delayed; } bool has_delayed() const { return m_has_delayed; } expr visit(expr const & e) { - if (!has_mvar(e)) + if (!has_mvar(e)) { + /* No mvars: trivially resolvable. AND with true is identity, + so don't touch m_result_resolvable — preserves sibling contributions. */ return e; + } bool shared = false; if (is_shared(e)) { auto it = m_cache.find(e.raw()); if (it != m_cache.end()) { - return it->second; + /* AND cached resolvability with current state to preserve + sibling contributions (e.g., domain's resolvability when + visiting body of a Pi). */ + m_result_resolvable = m_result_resolvable && it->second.resolvable; + return it->second.result; } shared = true; } + /* Save and reset m_result_resolvable. Each subtree computes its own + resolvability independently. After all children are visited, + m_result_resolvable reflects the combined resolvability. + We restore the parent's flag via AND at the end. */ + bool saved_resolvable = m_result_resolvable; + m_result_resolvable = true; + + expr r; switch (e.kind()) { case expr_kind::BVar: case expr_kind::Lit: case expr_kind::FVar: lean_unreachable(); case expr_kind::Sort: - return cache(e, update_sort(e, visit_level(sort_level(e))), shared); + r = cache(e, update_sort(e, visit_level(sort_level(e))), shared); + break; case expr_kind::Const: - return cache(e, update_const(e, visit_levels(const_levels(e))), shared); + r = cache(e, update_const(e, visit_levels(const_levels(e))), shared); + break; case expr_kind::MVar: - return visit_mvar(e); + r = visit_mvar(e); + goto done; /* mvar results depend on mctx state, skip caching */ case expr_kind::MData: - return cache(e, update_mdata(e, visit(mdata_expr(e))), shared); + r = cache(e, update_mdata(e, visit(mdata_expr(e))), shared); + break; case expr_kind::Proj: - return cache(e, update_proj(e, visit(proj_expr(e))), shared); + r = cache(e, update_proj(e, visit(proj_expr(e))), shared); + break; case expr_kind::App: - return cache(e, visit_app(e), shared); + r = cache(e, visit_app(e), shared); + break; case expr_kind::Pi: case expr_kind::Lambda: - return cache(e, update_binding(e, visit(binding_domain(e)), visit(binding_body(e))), shared); + r = cache(e, update_binding(e, visit(binding_domain(e)), visit(binding_body(e))), shared); + break; case expr_kind::Let: - return cache(e, update_let(e, visit(let_type(e)), visit(let_value(e)), visit(let_body(e))), shared); + r = cache(e, update_let(e, visit(let_type(e)), visit(let_value(e)), visit(let_body(e))), shared); + break; } + done: + /* Propagate: parent is resolvable only if this subtree is too. */ + m_result_resolvable = saved_resolvable && m_result_resolvable; + return r; } expr operator()(expr const & e) { return visit(e); } @@ -582,15 +624,15 @@ class instantiate_delayed_fn { if (fvars.size() > get_app_num_args(e)) { return visit_mvar_app_args(e); } - /* Match standard instantiateMVars: only resolve the delayed assignment - when the pending value was determined to be resolvable by pass 1 - (assigned and mvar-free after normalization). */ + /* Only resolve the delayed assignment when pass 1 determined + the pending value is fully resolvable (assigned and all nested + delayed mvars also resolvable). This matches the original + instantiateMVars behavior. */ if (!m_resolvable_delayed.contains(mid_pending)) { - /* Still normalize the pending value for mctx write-back side effects. - The original instantiateMVars always normalizes the pending value - (via get_assignment(mid_pending)) even when it can't resolve. - Downstream code like MutualDef.mkInitialUsedFVarsMap reads stored - assignments and relies on inner delayed assignments being resolved. */ + /* Still normalize the pending value for mctx write-back when + fvar_subst is empty. Downstream code (MutualDef.mkInitialUsedFVarsMap) + reads stored assignments and relies on inner delayed assignments + being resolved even when the outer one cannot be. */ if (fvar_subst_empty()) { (void)get_assignment(mid_pending); } From 4a5773e7f632e44ffce0121cea8a0e709fa69f3e Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Tue, 3 Mar 2026 21:49:25 +0000 Subject: [PATCH 22/45] perf: compute delayed assignment resolvability via post-pass fixpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move resolvability computation from pass 1's inline tracking (which stores a bool alongside each cache entry, increasing entry size from 16 to 24 bytes) to a separate fixpoint step between pass 1 and pass 2. Pass 1 now only collects a lightweight head→pending mapping for delayed assignments encountered during traversal. After pass 1 completes, the fixpoint analyzes each pending value's normalized form to determine which delayed assignments are fully resolvable. This keeps pass 1's cache entries at their minimal size, avoiding the memory pressure that caused bv_decide regressions. Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 335 +++++++++++++++++---------- 1 file changed, 207 insertions(+), 128 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index cd3f962ae692..8f4fcf8e4ed6 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -120,38 +120,24 @@ class instantiate_lmvars_all_fn { /* ============================================================================ Pass 1: Resolve direct mvar assignments with write-back. - For delayed assignments, pre-normalize the pending value but leave the - delayed mvar application in the expression. Tracks resolvability of - each subexpression (whether all remaining mvars are delayed-assigned - with resolvable pending values) as a side product of the traversal, - cached alongside the result to avoid redundant tree walks. + For delayed assignments, pre-normalize the pending value (resolving its + direct chains) but leave the delayed mvar application in the expression. + Unassigned mvars are left in place. ============================================================================ */ class instantiate_direct_fn { metavar_ctx & m_mctx; instantiate_lmvars_all_fn m_level_fn; name_set m_already_normalized; - /* Set of delayed-assigned mvars whose pending value is assigned and - fully resolvable after pass 1 normalization. Used by pass 2 as a - guard: only resolve delayed assignments when the pending mvar is - in this set, matching the original instantiateMVars behavior. */ - name_set m_resolvable_delayed; - /* Tracks which normalized mvars have resolvable values, so that - when get_assignment returns early via m_already_normalized, we - can correctly set m_result_resolvable. */ - name_set m_normalized_resolvable; /* Set to true when any delayed assignment is encountered, even if not resolvable. Pass 2 is needed for write-back normalization in that case. */ bool m_has_delayed; + /* Mapping from delayed-assigned mvar head to its pending mvar name. + Collected during traversal; used after pass 1 to compute the set of + resolvable delayed assignments without adding per-entry overhead. */ + name_hash_map m_delayed_head_to_pending; - /* After visit() returns, indicates whether the result would be mvar-free - after full delayed-assignment resolution (pass 2). Updated via AND - through the save/restore pattern: if any child is not resolvable, - the parent is not resolvable. Cached alongside the result expression. */ - bool m_result_resolvable; - - struct cache_entry { expr result; bool resolvable; }; - lean::unordered_map m_cache; + lean::unordered_map m_cache; std::vector m_saved; level visit_level(level const & l) { @@ -167,35 +153,23 @@ class instantiate_direct_fn { inline expr cache(expr const & e, expr r, bool shared) { if (shared) { - m_cache.insert(mk_pair(e.raw(), cache_entry{r, m_result_resolvable})); + m_cache.insert(mk_pair(e.raw(), r)); } return r; } - /* Get and normalize a direct mvar assignment. Write back the normalized value. - Sets m_result_resolvable as a side effect: true if the normalized value - would be mvar-free after full delayed-assignment resolution. */ + /* Get and normalize a direct mvar assignment. Write back the normalized value. */ optional get_assignment(name const & mid) { option_ref r = get_mvar_assignment(m_mctx, mid); if (!r) { return optional(); } expr a(r.get_val()); - if (!has_mvar(a)) { - /* No mvars at all — trivially resolvable. m_result_resolvable stays true. */ - return optional(a); - } - if (m_already_normalized.contains(mid)) { - /* Use cached resolvability from the first normalization visit. */ - if (!m_normalized_resolvable.contains(mid)) - m_result_resolvable = false; + if (!has_mvar(a) || m_already_normalized.contains(mid)) { return optional(a); } m_already_normalized.insert(mid); expr a_new = visit(a); - /* m_result_resolvable was set by visit(a). Cache it. */ - if (m_result_resolvable) - m_normalized_resolvable.insert(mid); if (!is_eqp(a, a_new)) { m_saved.push_back(a); assign_mvar(m_mctx, mid, a_new); @@ -252,41 +226,13 @@ class instantiate_direct_fn { option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (d) { m_has_delayed = true; - array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - - /* Pre-normalize the pending value and check resolvability. - Save/restore m_result_resolvable so the pending check - doesn't pollute the outer resolvability tracking. - m_result_resolvable (set by get_assignment's visit) correctly - tracks whether all delayed mvars in the value are themselves - resolvable — no has_expr_mvar check needed. */ - bool saved = m_result_resolvable; - m_result_resolvable = true; - bool pending_resolvable = false; - if (auto val = get_assignment(mid_pending)) { - pending_resolvable = m_result_resolvable; - } - m_result_resolvable = saved; - - if (pending_resolvable) { - m_resolvable_delayed.insert(mid_pending); - } - - /* Visit args — their resolvability contributes to the parent. */ - expr result = visit_mvar_app_args(e); - - /* The delayed mvar app is resolvable only if: pending is resolvable, - sufficient args for fvar substitution, and all args are resolvable. */ - if (!pending_resolvable || fvars.size() > get_app_num_args(e)) { - m_result_resolvable = false; - } - /* Otherwise m_result_resolvable was set by visit_mvar_app_args - (which visits and ANDs the args' resolvability). */ - return result; + m_delayed_head_to_pending.insert(mk_pair(mid, mid_pending)); + /* Pre-normalize the pending value so pass 2 finds it ready. */ + (void)get_assignment(mid_pending); + return visit_mvar_app_args(e); } - /* Not delayed: unassigned mvar — not resolvable. */ - m_result_resolvable = false; + /* Not delayed: unassigned mvar. */ return visit_mvar_app_args(e); } @@ -300,95 +246,227 @@ class instantiate_direct_fn { if (d) { m_has_delayed = true; name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - - /* Pre-normalize the pending value. Even though a bare mvar - can't resolve (no args for fvar subst), the same mid_pending - may appear in a delayed mvar app elsewhere that does have - enough args, so we must record its resolvability. */ - bool saved = m_result_resolvable; - m_result_resolvable = true; - if (auto val = get_assignment(mid_pending)) { - if (m_result_resolvable) - m_resolvable_delayed.insert(mid_pending); - } - m_result_resolvable = saved; + m_delayed_head_to_pending.insert(mk_pair(mid, mid_pending)); + /* Pre-normalize the pending value so pass 2 finds it ready. */ + (void)get_assignment(mid_pending); } - /* Bare mvar after pass 1: not resolvable (no args for fvar subst). */ - m_result_resolvable = false; return e; } public: instantiate_direct_fn(metavar_ctx & mctx) - : m_mctx(mctx), m_level_fn(mctx), m_has_delayed(false), m_result_resolvable(true) {} - name_set const & resolvable_delayed() const { return m_resolvable_delayed; } + : m_mctx(mctx), m_level_fn(mctx), m_has_delayed(false) {} bool has_delayed() const { return m_has_delayed; } + name_hash_map const & head_to_pending() const { return m_delayed_head_to_pending; } expr visit(expr const & e) { - if (!has_mvar(e)) { - /* No mvars: trivially resolvable. AND with true is identity, - so don't touch m_result_resolvable — preserves sibling contributions. */ + if (!has_mvar(e)) return e; - } bool shared = false; if (is_shared(e)) { auto it = m_cache.find(e.raw()); if (it != m_cache.end()) { - /* AND cached resolvability with current state to preserve - sibling contributions (e.g., domain's resolvability when - visiting body of a Pi). */ - m_result_resolvable = m_result_resolvable && it->second.resolvable; - return it->second.result; + return it->second; } shared = true; } - /* Save and reset m_result_resolvable. Each subtree computes its own - resolvability independently. After all children are visited, - m_result_resolvable reflects the combined resolvability. - We restore the parent's flag via AND at the end. */ - bool saved_resolvable = m_result_resolvable; - m_result_resolvable = true; - - expr r; switch (e.kind()) { case expr_kind::BVar: case expr_kind::Lit: case expr_kind::FVar: lean_unreachable(); case expr_kind::Sort: - r = cache(e, update_sort(e, visit_level(sort_level(e))), shared); - break; + return cache(e, update_sort(e, visit_level(sort_level(e))), shared); case expr_kind::Const: - r = cache(e, update_const(e, visit_levels(const_levels(e))), shared); - break; + return cache(e, update_const(e, visit_levels(const_levels(e))), shared); case expr_kind::MVar: - r = visit_mvar(e); - goto done; /* mvar results depend on mctx state, skip caching */ + return visit_mvar(e); case expr_kind::MData: - r = cache(e, update_mdata(e, visit(mdata_expr(e))), shared); - break; + return cache(e, update_mdata(e, visit(mdata_expr(e))), shared); case expr_kind::Proj: - r = cache(e, update_proj(e, visit(proj_expr(e))), shared); - break; + return cache(e, update_proj(e, visit(proj_expr(e))), shared); case expr_kind::App: - r = cache(e, visit_app(e), shared); - break; + return cache(e, visit_app(e), shared); case expr_kind::Pi: case expr_kind::Lambda: - r = cache(e, update_binding(e, visit(binding_domain(e)), visit(binding_body(e))), shared); - break; + return cache(e, update_binding(e, visit(binding_domain(e)), visit(binding_body(e))), shared); case expr_kind::Let: - r = cache(e, update_let(e, visit(let_type(e)), visit(let_value(e)), visit(let_body(e))), shared); - break; + return cache(e, update_let(e, visit(let_type(e)), visit(let_value(e)), visit(let_body(e))), shared); } - done: - /* Propagate: parent is resolvable only if this subtree is too. */ - m_result_resolvable = saved_resolvable && m_result_resolvable; - return r; } expr operator()(expr const & e) { return visit(e); } }; +/* ============================================================================ + Resolvability computation (between pass 1 and pass 2). + Determines which delayed assignments can be fully resolved by pass 2. + A pending mvar is resolvable if: + 1. It is directly assigned, AND + 2. Its assigned value (normalized by pass 1) has no remaining mvars, + OR all remaining mvars are delayed-assigned heads appearing in app + position with enough arguments, whose own pending values are resolvable. + Uses fixpoint iteration over the dependency graph. + ============================================================================ */ + +struct pending_info { + bool assigned; + bool trivially_resolvable; /* assigned and has_expr_mvar is false */ + bool has_unresolvable_mvar; /* has a mvar that can never be resolved by pass 2 */ + name_set delayed_deps; /* delayed head names appearing with enough args */ +}; + +/* Analyze an expression for mvar occurrences that affect resolvability. + Sets info.has_unresolvable_mvar if any mvar is found that pass 2 cannot resolve. + Adds delayed head names to info.delayed_deps for mvars in app position with + enough arguments (these might be resolvable depending on the fixpoint). */ +static void analyze_pending_mvars( + expr const & e, + name_hash_map const & head_to_pending, + metavar_ctx & mctx, + pending_info & info) +{ + if (!has_expr_mvar(e) || info.has_unresolvable_mvar) return; + + switch (e.kind()) { + case expr_kind::MVar: + /* Bare mvar — pass 2's visit_mvar only checks direct assignments, + which pass 1 already resolved. So this mvar is stuck. */ + info.has_unresolvable_mvar = true; + return; + case expr_kind::App: { + expr const & f = get_app_fn(e); + if (is_mvar(f)) { + name const & mid = mvar_name(f); + /* Check if this is a known delayed head. */ + auto it = head_to_pending.find(mid); + if (it == head_to_pending.end()) { + /* Not a delayed head — unassigned mvar left by pass 1. */ + info.has_unresolvable_mvar = true; + return; + } + /* Check arg count against fvar count. */ + option_ref d = get_delayed_mvar_assignment(mctx, mid); + if (!d) { + info.has_unresolvable_mvar = true; + return; + } + array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); + if (fvars.size() > get_app_num_args(e)) { + /* Not enough args — pass 2 can't resolve this. */ + info.has_unresolvable_mvar = true; + return; + } + /* Record this as a dependency on the delayed head. */ + info.delayed_deps.insert(mid); + /* Also check the arguments for unresolvable mvars. */ + expr const * curr = &e; + while (is_app(*curr)) { + analyze_pending_mvars(app_arg(*curr), head_to_pending, mctx, info); + if (info.has_unresolvable_mvar) return; + curr = &app_fn(*curr); + } + return; + } + /* Non-mvar app head — recurse normally. */ + analyze_pending_mvars(app_fn(e), head_to_pending, mctx, info); + if (info.has_unresolvable_mvar) return; + analyze_pending_mvars(app_arg(e), head_to_pending, mctx, info); + return; + } + case expr_kind::Lambda: case expr_kind::Pi: + analyze_pending_mvars(binding_domain(e), head_to_pending, mctx, info); + if (info.has_unresolvable_mvar) return; + analyze_pending_mvars(binding_body(e), head_to_pending, mctx, info); + return; + case expr_kind::Let: + analyze_pending_mvars(let_type(e), head_to_pending, mctx, info); + if (info.has_unresolvable_mvar) return; + analyze_pending_mvars(let_value(e), head_to_pending, mctx, info); + if (info.has_unresolvable_mvar) return; + analyze_pending_mvars(let_body(e), head_to_pending, mctx, info); + return; + case expr_kind::MData: + analyze_pending_mvars(mdata_expr(e), head_to_pending, mctx, info); + return; + case expr_kind::Proj: + analyze_pending_mvars(proj_expr(e), head_to_pending, mctx, info); + return; + default: + return; + } +} + +static name_set compute_resolvable_delayed( + metavar_ctx & mctx, + name_hash_map const & head_to_pending) +{ + if (head_to_pending.empty()) + return name_set(); + + /* Step 1: For each unique pending mvar, check its assignment and + analyze the structure for mvar dependencies. */ + name_hash_map infos; + for (auto & kv : head_to_pending) { + name const & pending = kv.second; + if (infos.find(pending) != infos.end()) + continue; + option_ref r = get_mvar_assignment(mctx, pending); + if (!r) { + infos[pending] = {false, false, false, {}}; + continue; + } + expr val(r.get_val()); + if (!has_expr_mvar(val)) { + infos[pending] = {true, true, false, {}}; + continue; + } + pending_info info = {true, false, false, {}}; + analyze_pending_mvars(val, head_to_pending, mctx, info); + infos[pending] = std::move(info); + } + + /* Step 2: Fixpoint iteration. + A pending is resolvable if assigned, has no unresolvable mvars, + and all delayed_deps have resolvable pending values. */ + name_set resolvable; + /* Seed with trivially resolvable (no mvars at all). */ + for (auto & kv : infos) { + if (kv.second.trivially_resolvable) + resolvable.insert(kv.first); + } + + bool changed = true; + while (changed) { + changed = false; + for (auto & kv : head_to_pending) { + name const & pending = kv.second; + if (resolvable.contains(pending)) continue; + auto it = infos.find(pending); + if (it == infos.end()) continue; + auto & info = it->second; + if (!info.assigned || info.has_unresolvable_mvar) continue; + + bool all_ok = true; + info.delayed_deps.for_each([&](name const & dep_head) { + if (!all_ok) return; + auto ht = head_to_pending.find(dep_head); + if (ht == head_to_pending.end()) { + all_ok = false; + return; + } + if (!resolvable.contains(ht->second)) { + all_ok = false; + } + }); + if (all_ok) { + resolvable.insert(pending); + changed = true; + } + } + } + return resolvable; +} + /* ============================================================================ Pass 2: Resolve delayed assignments with fused fvar substitution. Direct mvar chains have been pre-resolved by pass 1. @@ -624,10 +702,10 @@ class instantiate_delayed_fn { if (fvars.size() > get_app_num_args(e)) { return visit_mvar_app_args(e); } - /* Only resolve the delayed assignment when pass 1 determined - the pending value is fully resolvable (assigned and all nested - delayed mvars also resolvable). This matches the original - instantiateMVars behavior. */ + /* Only resolve the delayed assignment when the resolvability + computation determined the pending value is fully resolvable + (assigned and all nested delayed mvars also resolvable). This + matches the original instantiateMVars behavior. */ if (!m_resolvable_delayed.contains(mid_pending)) { /* Still normalize the pending value for mctx write-back when fvar_subst is empty. Downstream code (MutualDef.mkInitialUsedFVarsMap) @@ -783,7 +861,8 @@ static object * run_instantiate_all(object * m, object * e) { if (!pass1.has_delayed()) { e2 = e1; } else { - instantiate_delayed_fn pass2(mctx, pass1.resolvable_delayed()); + name_set resolvable = compute_resolvable_delayed(mctx, pass1.head_to_pending()); + instantiate_delayed_fn pass2(mctx, resolvable); e2 = pass2(e1); } From 93f75deaf3a817a673ee8a08da81c47ec0832576 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Tue, 3 Mar 2026 22:40:36 +0000 Subject: [PATCH 23/45] perf: use replace_fvars in pass 2 to preserve sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace pass 2's fused fvar substitution with the original `replace_fvars` approach. The fused approach visited pending values inside each fvar_subst context, preventing cache sharing across different delayed resolution scopes. This caused OOM on bv_decide_rewriter because shared subexpressions containing fvars produced different (non-shareable) results per context. The new approach: visit pending values once (globally cached/written back), then apply `replace_fvars` mechanically per-site. This preserves sharing and reduces bv_decide_rewriter from timeout to 0.8s. Also replaces the uncached fixpoint-based resolvability computation with a cached bottom-up `resolvability_checker` class, and simplifies pass 2 by removing all fvar_subst/scope/generation machinery — the cache is now a simple `lean_object* → expr` map. Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 583 ++++++++------------------- 1 file changed, 175 insertions(+), 408 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 8f4fcf8e4ed6..d7cbe3eef628 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -22,15 +22,15 @@ Pass 1 (`instantiate_direct_fn`): pre-normalizes the pending value (resolving its direct chain) but leaves the delayed mvar application in the expression. Unassigned mvars are left in place. +Between passes, a `resolvability_checker` determines which delayed assignments can +be fully resolved (assigned, mvar-free after resolution, sufficient arguments). + Pass 2 (`instantiate_delayed_fn`): - Fused traversal that resolves delayed assignments by carrying a fvar substitution. - Since pass 1 has pre-normalized all direct chains, each pending value is compact - and visited once, avoiding the O(n³) sharing loss that occurs when the fused - approach must also chase direct chains. Unassigned mvars are left as-is (matching - the original `instantiateMVars` behavior). - -The combination preserves sharing (O(n²) output for the pathological nested-delayed -case) while avoiding the separate `replace_fvars` calls of the standard approach. + Resolves delayed assignments using `replace_fvars`, matching the original + `instantiateMVars` approach. Pending values are visited once (cached/written + back), then fvar substitution is applied mechanically per-site. This preserves + sharing of the visited pending value across multiple occurrences of the same + delayed mvar. */ namespace lean { @@ -42,6 +42,9 @@ extern "C" object * lean_assign_mvar(obj_arg mctx, obj_arg mid, obj_arg val); typedef object_ref metavar_ctx; typedef object_ref delayed_assignment; +/* Forward declaration — defined in instantiate_mvars.cpp */ +expr replace_fvars(expr const & e, array_ref const & fvars, expr const * rev_args); + static void assign_lmvar(metavar_ctx & mctx, name const & mid, level const & l) { object * r = lean_assign_lmvar(mctx.steal(), mid.to_obj_arg(), l.to_obj_arg()); mctx.set_box(r); @@ -302,292 +305,158 @@ class instantiate_direct_fn { Determines which delayed assignments can be fully resolved by pass 2. A pending mvar is resolvable if: 1. It is directly assigned, AND - 2. Its assigned value (normalized by pass 1) has no remaining mvars, - OR all remaining mvars are delayed-assigned heads appearing in app - position with enough arguments, whose own pending values are resolvable. - Uses fixpoint iteration over the dependency graph. + 2. Its assigned value (normalized by pass 1) would become mvar-free + after pass 2 resolution — i.e., all remaining mvars are delayed- + assigned heads in app position with enough arguments, whose own + pending values are also resolvable. + Uses a cached bottom-up traversal (no fixpoint needed since the mvar + dependency graph is acyclic). ============================================================================ */ -struct pending_info { - bool assigned; - bool trivially_resolvable; /* assigned and has_expr_mvar is false */ - bool has_unresolvable_mvar; /* has a mvar that can never be resolved by pass 2 */ - name_set delayed_deps; /* delayed head names appearing with enough args */ -}; - -/* Analyze an expression for mvar occurrences that affect resolvability. - Sets info.has_unresolvable_mvar if any mvar is found that pass 2 cannot resolve. - Adds delayed head names to info.delayed_deps for mvars in app position with - enough arguments (these might be resolvable depending on the fixpoint). */ -static void analyze_pending_mvars( - expr const & e, - name_hash_map const & head_to_pending, - metavar_ctx & mctx, - pending_info & info) -{ - if (!has_expr_mvar(e) || info.has_unresolvable_mvar) return; - - switch (e.kind()) { - case expr_kind::MVar: - /* Bare mvar — pass 2's visit_mvar only checks direct assignments, - which pass 1 already resolved. So this mvar is stuck. */ - info.has_unresolvable_mvar = true; - return; - case expr_kind::App: { - expr const & f = get_app_fn(e); - if (is_mvar(f)) { - name const & mid = mvar_name(f); - /* Check if this is a known delayed head. */ - auto it = head_to_pending.find(mid); - if (it == head_to_pending.end()) { - /* Not a delayed head — unassigned mvar left by pass 1. */ - info.has_unresolvable_mvar = true; - return; - } - /* Check arg count against fvar count. */ - option_ref d = get_delayed_mvar_assignment(mctx, mid); - if (!d) { - info.has_unresolvable_mvar = true; - return; - } - array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); - if (fvars.size() > get_app_num_args(e)) { - /* Not enough args — pass 2 can't resolve this. */ - info.has_unresolvable_mvar = true; - return; - } - /* Record this as a dependency on the delayed head. */ - info.delayed_deps.insert(mid); - /* Also check the arguments for unresolvable mvars. */ - expr const * curr = &e; - while (is_app(*curr)) { - analyze_pending_mvars(app_arg(*curr), head_to_pending, mctx, info); - if (info.has_unresolvable_mvar) return; - curr = &app_fn(*curr); - } - return; +class resolvability_checker { + metavar_ctx & m_mctx; + name_hash_map const & m_head_to_pending; + + /* Expression cache: is this subexpression fully resolvable? + Keyed by raw pointer — safe because we only traverse normalized + values from mctx that outlive this checker. */ + lean::unordered_map m_expr_cache; + + /* Pending mvar cache: 0 = in-progress, 1 = resolvable, 2 = not. */ + name_hash_map m_pending_cache; + + bool is_pending_resolvable(name const & pending) { + auto it = m_pending_cache.find(pending); + if (it != m_pending_cache.end()) + return it->second == 1; + /* Mark in-progress (cycle guard — shouldn't happen). */ + m_pending_cache[pending] = 0; + option_ref r = get_mvar_assignment(m_mctx, pending); + if (!r) { + m_pending_cache[pending] = 2; + return false; } - /* Non-mvar app head — recurse normally. */ - analyze_pending_mvars(app_fn(e), head_to_pending, mctx, info); - if (info.has_unresolvable_mvar) return; - analyze_pending_mvars(app_arg(e), head_to_pending, mctx, info); - return; + bool ok = is_resolvable(expr(r.get_val())); + m_pending_cache[pending] = ok ? 1 : 2; + return ok; } - case expr_kind::Lambda: case expr_kind::Pi: - analyze_pending_mvars(binding_domain(e), head_to_pending, mctx, info); - if (info.has_unresolvable_mvar) return; - analyze_pending_mvars(binding_body(e), head_to_pending, mctx, info); - return; - case expr_kind::Let: - analyze_pending_mvars(let_type(e), head_to_pending, mctx, info); - if (info.has_unresolvable_mvar) return; - analyze_pending_mvars(let_value(e), head_to_pending, mctx, info); - if (info.has_unresolvable_mvar) return; - analyze_pending_mvars(let_body(e), head_to_pending, mctx, info); - return; - case expr_kind::MData: - analyze_pending_mvars(mdata_expr(e), head_to_pending, mctx, info); - return; - case expr_kind::Proj: - analyze_pending_mvars(proj_expr(e), head_to_pending, mctx, info); - return; - default: - return; + + bool is_resolvable(expr const & e) { + if (!has_expr_mvar(e)) return true; + if (is_shared(e)) { + auto it = m_expr_cache.find(e.raw()); + if (it != m_expr_cache.end()) + return it->second; + } + bool r = is_resolvable_core(e); + if (is_shared(e)) + m_expr_cache[e.raw()] = r; + return r; } -} -static name_set compute_resolvable_delayed( - metavar_ctx & mctx, - name_hash_map const & head_to_pending) -{ - if (head_to_pending.empty()) - return name_set(); - - /* Step 1: For each unique pending mvar, check its assignment and - analyze the structure for mvar dependencies. */ - name_hash_map infos; - for (auto & kv : head_to_pending) { - name const & pending = kv.second; - if (infos.find(pending) != infos.end()) - continue; - option_ref r = get_mvar_assignment(mctx, pending); - if (!r) { - infos[pending] = {false, false, false, {}}; - continue; + bool is_resolvable_core(expr const & e) { + switch (e.kind()) { + case expr_kind::MVar: + /* Bare mvar — pass 2's visit_mvar only checks direct + assignments, which pass 1 already resolved. Stuck. */ + return false; + case expr_kind::App: { + expr const & f = get_app_fn(e); + if (is_mvar(f)) { + name const & mid = mvar_name(f); + auto it = m_head_to_pending.find(mid); + if (it == m_head_to_pending.end()) + return false; /* not a delayed head */ + option_ref d = + get_delayed_mvar_assignment(m_mctx, mid); + if (!d) return false; + array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); + if (fvars.size() > get_app_num_args(e)) + return false; /* not enough args */ + if (!is_pending_resolvable(it->second)) + return false; + /* Check args too. */ + expr const * curr = &e; + while (is_app(*curr)) { + if (!is_resolvable(app_arg(*curr))) + return false; + curr = &app_fn(*curr); + } + return true; + } + return is_resolvable(app_fn(e)) && is_resolvable(app_arg(e)); } - expr val(r.get_val()); - if (!has_expr_mvar(val)) { - infos[pending] = {true, true, false, {}}; - continue; + case expr_kind::Lambda: case expr_kind::Pi: + return is_resolvable(binding_domain(e)) && + is_resolvable(binding_body(e)); + case expr_kind::Let: + return is_resolvable(let_type(e)) && + is_resolvable(let_value(e)) && + is_resolvable(let_body(e)); + case expr_kind::MData: + return is_resolvable(mdata_expr(e)); + case expr_kind::Proj: + return is_resolvable(proj_expr(e)); + default: + return true; } - pending_info info = {true, false, false, {}}; - analyze_pending_mvars(val, head_to_pending, mctx, info); - infos[pending] = std::move(info); } - /* Step 2: Fixpoint iteration. - A pending is resolvable if assigned, has no unresolvable mvars, - and all delayed_deps have resolvable pending values. */ - name_set resolvable; - /* Seed with trivially resolvable (no mvars at all). */ - for (auto & kv : infos) { - if (kv.second.trivially_resolvable) - resolvable.insert(kv.first); - } +public: + resolvability_checker(metavar_ctx & mctx, + name_hash_map const & head_to_pending) + : m_mctx(mctx), m_head_to_pending(head_to_pending) {} - bool changed = true; - while (changed) { - changed = false; - for (auto & kv : head_to_pending) { - name const & pending = kv.second; - if (resolvable.contains(pending)) continue; - auto it = infos.find(pending); - if (it == infos.end()) continue; - auto & info = it->second; - if (!info.assigned || info.has_unresolvable_mvar) continue; - - bool all_ok = true; - info.delayed_deps.for_each([&](name const & dep_head) { - if (!all_ok) return; - auto ht = head_to_pending.find(dep_head); - if (ht == head_to_pending.end()) { - all_ok = false; - return; - } - if (!resolvable.contains(ht->second)) { - all_ok = false; - } - }); - if (all_ok) { - resolvable.insert(pending); - changed = true; - } + name_set compute() { + name_set resolvable; + for (auto & kv : m_head_to_pending) { + if (is_pending_resolvable(kv.second)) + resolvable.insert(kv.second); } + return resolvable; } - return resolvable; -} +}; /* ============================================================================ - Pass 2: Resolve delayed assignments with fused fvar substitution. - Direct mvar chains have been pre-resolved by pass 1. - - Uses a flat (ptr, depth)-keyed cache with generation-based staleness. - Each visit_delayed scope gets a unique generation number; cache entries - record the scope level and generation at insertion. Validity is O(1): - entry valid iff level <= m_scope && m_scope_gens[level] == entry.scope_gen. + Pass 2: Resolve delayed assignments using replace_fvars. + Direct mvar chains have been pre-resolved by pass 1, and the resolvability + checker has determined which delayed assignments can be fully resolved. + + Uses the same replace_fvars approach as the original instantiateMVars: + pending values are visited once (cached/written back), then fvar substitution + is applied mechanically per-site via replace_fvars. This preserves sharing + of the visited pending value across multiple occurrences of the same delayed + mvar. ============================================================================ */ -struct fvar_subst_entry { - unsigned depth; - unsigned scope; - expr value; -}; - class instantiate_delayed_fn { - struct key_hasher { - std::size_t operator()(std::pair const & p) const { - return hash((size_t)p.first >> 3, p.second); - } - }; - - struct cache_entry { expr result; unsigned scope_level; unsigned scope_gen; }; - - typedef lean::unordered_map, cache_entry, key_hasher> flat_cache; - metavar_ctx & m_mctx; name_set const & m_resolvable_delayed; - name_hash_map m_fvar_subst; - unsigned m_depth; - - /* Single flat cache with generation-based staleness detection. */ - flat_cache m_cache; - std::vector m_scope_gens; /* m_scope_gens[level] = generation */ - unsigned m_gen_counter; - unsigned m_scope; - - /* After visit() returns, this holds the maximum fvar-substitution - scope that contributed to the result — i.e., the outermost scope at which the - result is valid and can be cached. Updated monotonically (via max) through - the save/reset/restore pattern in visit(). */ - unsigned m_result_scope; - - /* Global cache for fvar-free expressions — scope-independent. */ - lean::unordered_map m_global_cache; - - /* Write-back support: when fvar_subst is empty, normalize and write back - mvar assignments to match the original instantiateMVars mctx side effects. - Downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored - assignments and expects them to be normalized. */ + lean::unordered_map m_cache; name_set m_already_normalized; std::vector m_saved; - bool fvar_subst_empty() const { - return m_fvar_subst.empty(); - } - - optional lookup_fvar(name const & fid) { - auto it = m_fvar_subst.find(fid); - if (it == m_fvar_subst.end()) - return optional(); - m_result_scope = std::max(m_result_scope, it->second.scope); - unsigned d = m_depth - it->second.depth; - if (d == 0) - return optional(it->second.value); - return optional(lift_loose_bvars(it->second.value, d)); - } - - /* Cache lookup — O(1) with generation-based staleness check. - An entry at scope_level 0 (no fvar dependency) is valid at any scope. - An entry at scope_level > 0 is only valid at exactly that scope level, - because an inner scope may shadow the fvars it depends on. */ - optional cache_lookup(lean_object * ptr) { - auto key = mk_pair(ptr, m_depth); - auto it = m_cache.find(key); - if (it == m_cache.end()) return {}; - auto & entry = it->second; - if ((entry.scope_level == 0 || entry.scope_level == m_scope) && - m_scope_gens[entry.scope_level] == entry.scope_gen) { - m_result_scope = std::max(m_result_scope, entry.scope_level); - return optional(entry.result); - } - return {}; - } - - void cache_insert(lean_object * ptr, expr const & result) { - auto key = mk_pair(ptr, m_depth); - m_cache[key] = { result, m_result_scope, m_scope_gens[m_result_scope] }; - } - - /* Get a direct mvar assignment. Visit it to resolve delayed mvars - and apply the fvar substitution. - When fvar_subst is empty, normalize and write back the result to - the mctx. This matches the original instantiateMVars behavior: - downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored - assignments and expects inner delayed assignments to be resolved. - When fvar_subst is non-empty, no write-back (values contain - fvar-substituted terms not suitable for the mctx). */ + /* Get a direct mvar assignment. Visit it to resolve inner delayed mvars. + Normalize and write back the result to the mctx. This matches the + original instantiateMVars behavior: downstream code (e.g. + MutualDef.mkInitialUsedFVarsMap) reads stored assignments and expects + inner delayed assignments to be resolved. */ optional get_assignment(name const & mid) { option_ref r = get_mvar_assignment(m_mctx, mid); if (!r) return optional(); expr a(r.get_val()); - if (fvar_subst_empty()) { - if (!has_mvar(a)) - return optional(a); - if (m_already_normalized.contains(mid)) - return optional(a); - m_already_normalized.insert(mid); - expr a_new = visit(a); - if (!is_eqp(a, a_new)) { - m_saved.push_back(a); - assign_mvar(m_mctx, mid, a_new); - } - return optional(a_new); - } else { - if (!has_mvar(a) && !has_fvar(a)) - return optional(a); - return optional(visit(a)); + if (!has_mvar(a)) + return optional(a); + if (m_already_normalized.contains(mid)) + return optional(a); + m_already_normalized.insert(mid); + expr a_new = visit(a); + if (!is_eqp(a, a_new)) { + m_saved.push_back(a); + assign_mvar(m_mctx, mid, a_new); } + return optional(a_new); } expr visit_app_default(expr const & e) { @@ -631,54 +500,20 @@ class instantiate_delayed_fn { args.push_back(visit(app_arg(*curr))); curr = &app_fn(*curr); } - + /* Get and visit the pending value (resolving inner delayed assignments). + Uses get_assignment for write-back normalization. */ + optional val = get_assignment(mid_pending); + lean_assert(val); /* resolvability checker verified this is assigned */ + /* Replace the delayed assignment's fvars with the visited arguments, + matching the original instantiateMVars approach. */ size_t fvar_count = fvars.size(); - size_t extra_count = args.size() - fvar_count; - - /* Save and extend the fvar substitution. */ - struct saved_entry { name key; bool had_old; fvar_subst_entry old; }; - std::vector saved_entries; - saved_entries.reserve(fvar_count); - m_scope++; - for (size_t i = 0; i < fvar_count; i++) { - name const & fid = fvar_name(fvars[i]); - auto old_it = m_fvar_subst.find(fid); - if (old_it != m_fvar_subst.end()) { - saved_entries.push_back({fid, true, old_it->second}); - } else { - saved_entries.push_back({fid, false, {0, 0, expr()}}); - } - m_fvar_subst[fid] = {m_depth, m_scope, args[args.size() - 1 - i]}; - } - - /* Push: bump generation so stale entries at this scope level are detected. */ - m_gen_counter++; - if (m_scope >= m_scope_gens.size()) - m_scope_gens.push_back(m_gen_counter); - else - m_scope_gens[m_scope] = m_gen_counter; - - expr val_new = visit(mk_mvar(mid_pending)); - - /* Pop: just decrement scope — stale entries are detected by generation mismatch. */ - m_scope--; - - /* Restore the fvar substitution. */ - for (auto & se : saved_entries) { - if (!se.had_old) { - m_fvar_subst.erase(se.key); - } else { - m_fvar_subst[se.key] = se.old; - } - } - - /* Use apply_beta instead of mk_rev_app: pass 1's beta-reduction may have - changed delayed mvar arguments (e.g., substituting a bvar with a concrete - value), so the resolved pending value may be a lambda that needs beta- - reduction with the extra args, matching the original's behavior. */ + expr val_new = replace_fvars(*val, fvars, args.data() + (args.size() - fvar_count)); + /* Use apply_beta for extra args: the fvar-substituted pending value may + be a lambda (e.g. from assertAfter), and extra args should be beta- + reduced into it rather than left as a redex. */ bool preserve_data = false; bool zeta = true; - return apply_beta(val_new, extra_count, args.data(), preserve_data, zeta); + return apply_beta(val_new, args.size() - fvar_count, args.data(), preserve_data, zeta); } expr visit_app(expr const & e) { @@ -707,13 +542,11 @@ class instantiate_delayed_fn { (assigned and all nested delayed mvars also resolvable). This matches the original instantiateMVars behavior. */ if (!m_resolvable_delayed.contains(mid_pending)) { - /* Still normalize the pending value for mctx write-back when - fvar_subst is empty. Downstream code (MutualDef.mkInitialUsedFVarsMap) - reads stored assignments and relies on inner delayed assignments - being resolved even when the outer one cannot be. */ - if (fvar_subst_empty()) { - (void)get_assignment(mid_pending); - } + /* Normalize the pending value for mctx write-back. + Downstream code (MutualDef.mkInitialUsedFVarsMap) reads stored + assignments and relies on inner delayed assignments being + resolved even when the outer one cannot be. */ + (void)get_assignment(mid_pending); return visit_mvar_app_args(e); } buffer args; @@ -728,116 +561,49 @@ class instantiate_delayed_fn { return e; } - expr visit_fvar(expr const & e) { - name const & fid = fvar_name(e); - if (auto r = lookup_fvar(fid)) { - return *r; + inline expr cache(expr const & e, expr r, bool shared) { + if (shared) { + m_cache.insert(mk_pair(e.raw(), r)); } - return e; + return r; } public: instantiate_delayed_fn(metavar_ctx & mctx, name_set const & resolvable_delayed) - : m_mctx(mctx), m_resolvable_delayed(resolvable_delayed), - m_depth(0), m_gen_counter(0), m_scope(0), m_result_scope(0) { - m_scope_gens.push_back(0); /* scope 0 has generation 0 */ - } + : m_mctx(mctx), m_resolvable_delayed(resolvable_delayed) {} expr visit(expr const & e) { - if (fvar_subst_empty()) { - if (!has_mvar(e)) - return e; - } else { - if (!has_mvar(e) && !has_fvar(e)) - return e; - } - - bool use_global = !has_fvar(e) && !has_expr_mvar(e); + if (!has_mvar(e)) + return e; bool shared = false; if (is_shared(e)) { - if (use_global) { - auto it = m_global_cache.find(e.raw()); - if (it != m_global_cache.end()) - return it->second; - } else { - if (auto r = cache_lookup(e.raw())) - return *r; + auto it = m_cache.find(e.raw()); + if (it != m_cache.end()) { + return it->second; } shared = true; } - /* Save and reset the result scope for this subtree. - After computing, cache_insert uses m_result_scope to place the entry - at the outermost valid scope level. Then we restore the parent's - watermark, taking the max with our contribution. */ - unsigned saved_result_scope = m_result_scope; - m_result_scope = 0; - - expr r; switch (e.kind()) { case expr_kind::BVar: - case expr_kind::Lit: + case expr_kind::Lit: case expr_kind::FVar: lean_unreachable(); - case expr_kind::FVar: - r = visit_fvar(e); - goto done; /* skip caching for fvars */ - case expr_kind::Sort: - r = update_sort(e, visit_level(sort_level(e))); - break; - case expr_kind::Const: - r = update_const(e, visit_levels(const_levels(e))); - break; + case expr_kind::Sort: case expr_kind::Const: + /* Levels already resolved by pass 1. */ + return e; case expr_kind::MVar: - r = visit_mvar(e); - goto done; /* mvar results are not (ptr, depth)-cacheable */ + return visit_mvar(e); case expr_kind::MData: - r = update_mdata(e, visit(mdata_expr(e))); - break; + return cache(e, update_mdata(e, visit(mdata_expr(e))), shared); case expr_kind::Proj: - r = update_proj(e, visit(proj_expr(e))); - break; + return cache(e, update_proj(e, visit(proj_expr(e))), shared); case expr_kind::App: - r = visit_app(e); - break; - case expr_kind::Pi: case expr_kind::Lambda: { - expr d = visit(binding_domain(e)); - m_depth++; - expr b = visit(binding_body(e)); - m_depth--; - r = update_binding(e, d, b); - break; - } - case expr_kind::Let: { - expr t = visit(let_type(e)); - expr v = visit(let_value(e)); - m_depth++; - expr b = visit(let_body(e)); - m_depth--; - r = update_let(e, t, v, b); - break; - } - } - if (shared) { - if (use_global) - m_global_cache.insert(mk_pair(e.raw(), r)); - else - cache_insert(e.raw(), r); + return cache(e, visit_app(e), shared); + case expr_kind::Pi: case expr_kind::Lambda: + return cache(e, update_binding(e, visit(binding_domain(e)), visit(binding_body(e))), shared); + case expr_kind::Let: + return cache(e, update_let(e, visit(let_type(e)), visit(let_value(e)), visit(let_body(e))), shared); } - - done: - m_result_scope = std::max(saved_result_scope, m_result_scope); - return r; - } - - level visit_level(level const & l) { - /* Pass 2 does not handle level mvars — pass 1 already resolved them. - But we still need this for the visit_levels call in update_sort/update_const. - Since levels have no fvars, we can just return them as-is. */ - return l; - } - - levels visit_levels(levels const & ls) { - return ls; } expr operator()(expr const & e) { return visit(e); } @@ -854,14 +620,15 @@ static object * run_instantiate_all(object * m, object * e) { instantiate_direct_fn pass1(mctx); expr e1 = pass1(expr(e)); - /* Pass 2: resolve delayed assignments with fused fvar substitution. + /* Pass 2: resolve delayed assignments using replace_fvars. Skip if pass 1 found no delayed assignments at all — the expression has no delayed mvars that need resolution or write-back. */ expr e2; if (!pass1.has_delayed()) { e2 = e1; } else { - name_set resolvable = compute_resolvable_delayed(mctx, pass1.head_to_pending()); + resolvability_checker checker(mctx, pass1.head_to_pending()); + name_set resolvable = checker.compute(); instantiate_delayed_fn pass2(mctx, resolvable); e2 = pass2(e1); } From d72b0d5d02b033e18f16b3d53c95474e27c13e65 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Wed, 4 Mar 2026 06:54:37 +0000 Subject: [PATCH 24/45] test: benchmark all instantiateMVars implementations Co-Authored-By: Claude Opus 4.6 --- tests/bench/delayed_assign.lean | 48 ++++++++--------- tests/bench/delayed_assign_nu2.lean | 83 ++++++++++++++++++----------- 2 files changed, 76 insertions(+), 55 deletions(-) diff --git a/tests/bench/delayed_assign.lean b/tests/bench/delayed_assign.lean index 8013b28e3c36..d04ba40cc571 100644 --- a/tests/bench/delayed_assign.lean +++ b/tests/bench/delayed_assign.lean @@ -1,6 +1,8 @@ import Lean +import Lean.Meta.InstMVarsAll +import Lean.Meta.InstMVarsNU -set_option maxHeartbeats 800000 +set_option maxHeartbeats 4000000 open Lean Meta @@ -35,38 +37,34 @@ where | 0 => mkResultType n | i+1 => .forallE `x Nat.mkType (mkAnd (mkType i) (mkLE (n - i - 1))) .default -partial def bench1 (n : Nat) : MetaM Unit := do - -- Test instantiateMVars (C++) - let mvarId1 ← mkBench1 n - solve mvarId1 +/-- Run a single implementation on a fresh copy of the benchmark, return (result, time_ms). -/ +def runImpl (n : Nat) (f : Expr → MetaM Expr) : MetaM (Expr × Float) := do + let mvarId ← mkBench1 n + solve mvarId let t0 ← IO.monoNanosNow - let r1 ← instantiateMVars (mkMVar mvarId1) + let r ← f (mkMVar mvarId) let t1 ← IO.monoNanosNow - let instMs := (t1 - t0).toFloat / 1000000.0 - - -- Test instantiateMVarsNoUpdate (C++) with fresh mvars - let mvarId2 ← mkBench1 n - solve mvarId2 - let t2 ← IO.monoNanosNow - let r2 ← instantiateMVarsNoUpdate (mkMVar mvarId2) - let t3 ← IO.monoNanosNow - let nuCppMs := (t3 - t2).toFloat / 1000000.0 + let ms := (t1 - t0).toFloat / 1000000.0 + return (r, ms) - -- Test instantiateMVarsNoUpdateLean with fresh mvars - let mvarId3 ← mkBench1 n - solve mvarId3 - let t4 ← IO.monoNanosNow - let r3 ← instantiateMVarsNoUpdateLean (mkMVar mvarId3) - let t5 ← IO.monoNanosNow - let nuLeanMs := (t5 - t4).toFloat / 1000000.0 +partial def bench1 (n : Nat) : MetaM Unit := do + let (rDefault, msDefault) ← runImpl n instantiateMVars + let (rOriginal, msOriginal) ← runImpl n instantiateMVarsOriginal + let (rAll, msAll) ← runImpl n instantiateAllMVars + let (rNUCpp, msNUCpp) ← runImpl n instantiateMVarsNoUpdate + let (rNULean, msNULean) ← runImpl n instantiateMVarsNoUpdateLean -- Verify correctness - unless Expr.eqv r1 r2 do + unless Expr.eqv rDefault rOriginal do + IO.println s!"ERROR: instantiateMVars vs Original differ for n={n}" + unless Expr.eqv rDefault rAll do + IO.println s!"ERROR: instantiateMVars vs AllMVars differ for n={n}" + unless Expr.eqv rDefault rNUCpp do IO.println s!"ERROR: instantiateMVars vs NoUpdate(C++) differ for n={n}" - unless Expr.eqv r1 r3 do + unless Expr.eqv rDefault rNULean do IO.println s!"ERROR: instantiateMVars vs NoUpdate(Lean) differ for n={n}" - IO.println s!"bench1_{n}: instantiateMVars {instMs} ms, NoUpdate(C++) {nuCppMs} ms, NoUpdate(Lean) {nuLeanMs} ms" + IO.println s!"bench1_{n}: instantiateMVars {msDefault} ms, Original {msOriginal} ms, AllMVars {msAll} ms, NoUpdate(C++) {msNUCpp} ms, NoUpdate(Lean) {msNULean} ms" run_meta do IO.println "Example (n = 5):" diff --git a/tests/bench/delayed_assign_nu2.lean b/tests/bench/delayed_assign_nu2.lean index fafc9b1ac05d..7de0e6d5da20 100644 --- a/tests/bench/delayed_assign_nu2.lean +++ b/tests/bench/delayed_assign_nu2.lean @@ -1,4 +1,8 @@ import Lean +import Lean.Meta.InstMVarsAll +import Lean.Meta.InstMVarsNU + +set_option maxHeartbeats 4000000 open Lean Meta @@ -193,18 +197,6 @@ def _root_.Lean.Meta.instantiateMVarsNoUpdate2 (e : Expr) : MetaM Expr := do /-! ## Benchmark 1: delayed assignments (from original benchmark) -/ -partial def runBench1 (name : String) (n : Nat) (mk : Nat → MetaM MVarId) : MetaM Unit := do - let mvarId ← mk n - let startTime ← IO.monoNanosNow - solve mvarId - let endTime ← IO.monoNanosNow - let ms := (endTime - startTime).toFloat / 1000000.0 - let startTime ← IO.monoNanosNow - discard <| instantiateMVars (mkMVar mvarId) - let endTime ← IO.monoNanosNow - let instMs := (endTime - startTime).toFloat / 1000000.0 - IO.println s!"{name}_{n}: {ms} ms, instantiateMVars: {instMs} ms" - def mkBench1 (n : Nat) : MetaM MVarId := do let type := mkType n return (← mkFreshExprSyntheticOpaqueMVar type).mvarId! @@ -219,8 +211,37 @@ where | 0 => mkResultType n | i+1 => .forallE `x Nat.mkType (mkAnd (mkType i) (mkLE (n - i - 1))) .default +/-- Run a single implementation on a fresh copy of the benchmark, return (result, time_ms). -/ +def runImpl (n : Nat) (f : Expr → MetaM Expr) : MetaM (Expr × Float) := do + let mvarId ← mkBench1 n + solve mvarId + let t0 ← IO.monoNanosNow + let r ← f (mkMVar mvarId) + let t1 ← IO.monoNanosNow + let ms := (t1 - t0).toFloat / 1000000.0 + return (r, ms) + partial def bench1 (n : Nat) : MetaM Unit := do - runBench1 "bench1" n mkBench1 + let (rDefault, msDefault) ← runImpl n instantiateMVars + let (rOriginal, msOriginal) ← runImpl n instantiateMVarsOriginal + let (rAll, msAll) ← runImpl n instantiateAllMVars + let (rNUCpp, msNUCpp) ← runImpl n instantiateMVarsNoUpdate + let (rNULean, msNULean) ← runImpl n instantiateMVarsNoUpdateLean + let (rNU2, msNU2) ← runImpl n instantiateMVarsNoUpdate2 + + -- Verify correctness + unless Expr.eqv rDefault rOriginal do + IO.println s!"ERROR: instantiateMVars vs Original differ for n={n}" + unless Expr.eqv rDefault rAll do + IO.println s!"ERROR: instantiateMVars vs AllMVars differ for n={n}" + unless Expr.eqv rDefault rNUCpp do + IO.println s!"ERROR: instantiateMVars vs NoUpdate(C++) differ for n={n}" + unless Expr.eqv rDefault rNULean do + IO.println s!"ERROR: instantiateMVars vs NoUpdate(Lean) differ for n={n}" + unless Expr.eqv rDefault rNU2 do + IO.println s!"ERROR: instantiateMVars vs NoUpdate2 differ for n={n}" + + IO.println s!"bench1_{n}: instantiateMVars {msDefault} ms, Original {msOriginal} ms, AllMVars {msAll} ms, NoUpdate(C++) {msNUCpp} ms, NoUpdate(Lean) {msNULean} ms, NoUpdate2 {msNU2} ms" /-! ## Benchmark 2: shared closed sub-expression across binder depths @@ -248,31 +269,33 @@ def mkNestedForall (bigExpr : Expr) (depth : Nat) : Expr := Id.run do term := .forallE `x bigExpr term .default return term +def timeMs (f : MetaM α) : MetaM (α × Float) := do + let t0 ← IO.monoNanosNow + let r ← f + let t1 ← IO.monoNanosNow + return (r, (t1 - t0).toFloat / 1000000.0) + def bench2 (depth : Nat) (bodySize : Nat) : MetaM Unit := do -- Each test gets fresh mvars to avoid cross-contamination from instantiateMVars updating assignments let bigExpr1 ← mkMVarConj bodySize - let term1 := mkNestedForall bigExpr1 depth - let t0 ← IO.monoNanosNow - let _r1 ← instantiateMVarsNoUpdate term1 - let t1 ← IO.monoNanosNow + let (_, instMs) ← timeMs (instantiateMVars (mkNestedForall bigExpr1 depth)) + + let bigExpr1b ← mkMVarConj bodySize + let (_, origMs) ← timeMs (instantiateMVarsOriginal (mkNestedForall bigExpr1b depth)) + + let bigExpr1c ← mkMVarConj bodySize + let (_, allMs) ← timeMs (instantiateAllMVars (mkNestedForall bigExpr1c depth)) let bigExpr2 ← mkMVarConj bodySize - let term2 := mkNestedForall bigExpr2 depth - let t2 ← IO.monoNanosNow - let _r2 ← instantiateMVarsNoUpdate2 term2 - let t3 ← IO.monoNanosNow + let (_, nuMs) ← timeMs (instantiateMVarsNoUpdate (mkNestedForall bigExpr2 depth)) - let bigExpr3 ← mkMVarConj bodySize - let term3 := mkNestedForall bigExpr3 depth - let t4 ← IO.monoNanosNow - let _r3 ← instantiateMVars term3 - let t5 ← IO.monoNanosNow + let bigExpr2b ← mkMVarConj bodySize + let (_, nuLeanMs) ← timeMs (instantiateMVarsNoUpdateLean (mkNestedForall bigExpr2b depth)) - let nuMs := (t1 - t0).toFloat / 1000000.0 - let nu2Ms := (t3 - t2).toFloat / 1000000.0 - let instMs := (t5 - t4).toFloat / 1000000.0 + let bigExpr3 ← mkMVarConj bodySize + let (_, nu2Ms) ← timeMs (instantiateMVarsNoUpdate2 (mkNestedForall bigExpr3 depth)) - IO.println s!"bench2 depth={depth} body={bodySize}: instantiateMVars {instMs} ms, NoUpdate {nuMs} ms, NoUpdate2 {nu2Ms} ms" + IO.println s!"bench2 depth={depth} body={bodySize}: instantiateMVars {instMs} ms, Original {origMs} ms, AllMVars {allMs} ms, NoUpdate(C++) {nuMs} ms, NoUpdate(Lean) {nuLeanMs} ms, NoUpdate2 {nu2Ms} ms" /-! ## Run benchmarks -/ From f0b69dc84161d0e777bc8c7d474cf6725bb104d6 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Wed, 4 Mar 2026 07:14:25 +0000 Subject: [PATCH 25/45] perf: revert to fused pass 2, keep cached resolvability_checker This reverts commit 93f75deaf3's pass 2 changes (replace_fvars approach) while keeping its cached resolvability_checker. The fused fvar substitution in pass 2 is subquadratic on the delayed assignment benchmark (74ms vs 2116ms at n=500), but causes OOM on the larger elab_bench/bv_decide_rewriter test due to sharing loss when the same subexpression appears in different fvar_subst contexts. Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 325 +++++++++++++++++++++------ 1 file changed, 253 insertions(+), 72 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index d7cbe3eef628..9ad952e6f563 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -26,11 +26,11 @@ Between passes, a `resolvability_checker` determines which delayed assignments c be fully resolved (assigned, mvar-free after resolution, sufficient arguments). Pass 2 (`instantiate_delayed_fn`): - Resolves delayed assignments using `replace_fvars`, matching the original - `instantiateMVars` approach. Pending values are visited once (cached/written - back), then fvar substitution is applied mechanically per-site. This preserves - sharing of the visited pending value across multiple occurrences of the same - delayed mvar. + Fused traversal that resolves delayed assignments by carrying a fvar substitution. + Since pass 1 has pre-normalized all direct chains, each pending value is compact + and visited once, avoiding the O(n³) sharing loss that occurs when the fused + approach must also chase direct chains. Unassigned mvars are left as-is (matching + the original `instantiateMVars` behavior). */ namespace lean { @@ -42,9 +42,6 @@ extern "C" object * lean_assign_mvar(obj_arg mctx, obj_arg mid, obj_arg val); typedef object_ref metavar_ctx; typedef object_ref delayed_assignment; -/* Forward declaration — defined in instantiate_mvars.cpp */ -expr replace_fvars(expr const & e, array_ref const & fvars, expr const * rev_args); - static void assign_lmvar(metavar_ctx & mctx, name const & mid, level const & l) { object * r = lean_assign_lmvar(mctx.steal(), mid.to_obj_arg(), l.to_obj_arg()); mctx.set_box(r); @@ -418,45 +415,126 @@ class resolvability_checker { }; /* ============================================================================ - Pass 2: Resolve delayed assignments using replace_fvars. - Direct mvar chains have been pre-resolved by pass 1, and the resolvability - checker has determined which delayed assignments can be fully resolved. - - Uses the same replace_fvars approach as the original instantiateMVars: - pending values are visited once (cached/written back), then fvar substitution - is applied mechanically per-site via replace_fvars. This preserves sharing - of the visited pending value across multiple occurrences of the same delayed - mvar. + Pass 2: Resolve delayed assignments with fused fvar substitution. + Direct mvar chains have been pre-resolved by pass 1. + + Uses a flat (ptr, depth)-keyed cache with generation-based staleness. + Each visit_delayed scope gets a unique generation number; cache entries + record the scope level and generation at insertion. Validity is O(1): + entry valid iff level <= m_scope && m_scope_gens[level] == entry.scope_gen. ============================================================================ */ +struct fvar_subst_entry { + unsigned depth; + unsigned scope; + expr value; +}; + class instantiate_delayed_fn { + struct key_hasher { + std::size_t operator()(std::pair const & p) const { + return hash((size_t)p.first >> 3, p.second); + } + }; + + struct cache_entry { expr result; unsigned scope_level; unsigned scope_gen; }; + + typedef lean::unordered_map, cache_entry, key_hasher> flat_cache; + metavar_ctx & m_mctx; name_set const & m_resolvable_delayed; - lean::unordered_map m_cache; + name_hash_map m_fvar_subst; + unsigned m_depth; + + /* Single flat cache with generation-based staleness detection. */ + flat_cache m_cache; + std::vector m_scope_gens; /* m_scope_gens[level] = generation */ + unsigned m_gen_counter; + unsigned m_scope; + + /* After visit() returns, this holds the maximum fvar-substitution + scope that contributed to the result — i.e., the outermost scope at which the + result is valid and can be cached. Updated monotonically (via max) through + the save/reset/restore pattern in visit(). */ + unsigned m_result_scope; + + /* Global cache for fvar-free expressions — scope-independent. */ + lean::unordered_map m_global_cache; + + /* Write-back support: when fvar_subst is empty, normalize and write back + mvar assignments to match the original instantiateMVars mctx side effects. + Downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored + assignments and expects them to be normalized. */ name_set m_already_normalized; std::vector m_saved; - /* Get a direct mvar assignment. Visit it to resolve inner delayed mvars. - Normalize and write back the result to the mctx. This matches the - original instantiateMVars behavior: downstream code (e.g. - MutualDef.mkInitialUsedFVarsMap) reads stored assignments and expects - inner delayed assignments to be resolved. */ + bool fvar_subst_empty() const { + return m_fvar_subst.empty(); + } + + optional lookup_fvar(name const & fid) { + auto it = m_fvar_subst.find(fid); + if (it == m_fvar_subst.end()) + return optional(); + m_result_scope = std::max(m_result_scope, it->second.scope); + unsigned d = m_depth - it->second.depth; + if (d == 0) + return optional(it->second.value); + return optional(lift_loose_bvars(it->second.value, d)); + } + + /* Cache lookup — O(1) with generation-based staleness check. + An entry at scope_level 0 (no fvar dependency) is valid at any scope. + An entry at scope_level > 0 is only valid at exactly that scope level, + because an inner scope may shadow the fvars it depends on. */ + optional cache_lookup(lean_object * ptr) { + auto key = mk_pair(ptr, m_depth); + auto it = m_cache.find(key); + if (it == m_cache.end()) return {}; + auto & entry = it->second; + if ((entry.scope_level == 0 || entry.scope_level == m_scope) && + m_scope_gens[entry.scope_level] == entry.scope_gen) { + m_result_scope = std::max(m_result_scope, entry.scope_level); + return optional(entry.result); + } + return {}; + } + + void cache_insert(lean_object * ptr, expr const & result) { + auto key = mk_pair(ptr, m_depth); + m_cache[key] = { result, m_result_scope, m_scope_gens[m_result_scope] }; + } + + /* Get a direct mvar assignment. Visit it to resolve delayed mvars + and apply the fvar substitution. + When fvar_subst is empty, normalize and write back the result to + the mctx. This matches the original instantiateMVars behavior: + downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored + assignments and expects inner delayed assignments to be resolved. + When fvar_subst is non-empty, no write-back (values contain + fvar-substituted terms not suitable for the mctx). */ optional get_assignment(name const & mid) { option_ref r = get_mvar_assignment(m_mctx, mid); if (!r) return optional(); expr a(r.get_val()); - if (!has_mvar(a)) - return optional(a); - if (m_already_normalized.contains(mid)) - return optional(a); - m_already_normalized.insert(mid); - expr a_new = visit(a); - if (!is_eqp(a, a_new)) { - m_saved.push_back(a); - assign_mvar(m_mctx, mid, a_new); + if (fvar_subst_empty()) { + if (!has_mvar(a)) + return optional(a); + if (m_already_normalized.contains(mid)) + return optional(a); + m_already_normalized.insert(mid); + expr a_new = visit(a); + if (!is_eqp(a, a_new)) { + m_saved.push_back(a); + assign_mvar(m_mctx, mid, a_new); + } + return optional(a_new); + } else { + if (!has_mvar(a) && !has_fvar(a)) + return optional(a); + return optional(visit(a)); } - return optional(a_new); } expr visit_app_default(expr const & e) { @@ -500,20 +578,54 @@ class instantiate_delayed_fn { args.push_back(visit(app_arg(*curr))); curr = &app_fn(*curr); } - /* Get and visit the pending value (resolving inner delayed assignments). - Uses get_assignment for write-back normalization. */ - optional val = get_assignment(mid_pending); - lean_assert(val); /* resolvability checker verified this is assigned */ - /* Replace the delayed assignment's fvars with the visited arguments, - matching the original instantiateMVars approach. */ + size_t fvar_count = fvars.size(); - expr val_new = replace_fvars(*val, fvars, args.data() + (args.size() - fvar_count)); - /* Use apply_beta for extra args: the fvar-substituted pending value may - be a lambda (e.g. from assertAfter), and extra args should be beta- - reduced into it rather than left as a redex. */ + size_t extra_count = args.size() - fvar_count; + + /* Save and extend the fvar substitution. */ + struct saved_entry { name key; bool had_old; fvar_subst_entry old; }; + std::vector saved_entries; + saved_entries.reserve(fvar_count); + m_scope++; + for (size_t i = 0; i < fvar_count; i++) { + name const & fid = fvar_name(fvars[i]); + auto old_it = m_fvar_subst.find(fid); + if (old_it != m_fvar_subst.end()) { + saved_entries.push_back({fid, true, old_it->second}); + } else { + saved_entries.push_back({fid, false, {0, 0, expr()}}); + } + m_fvar_subst[fid] = {m_depth, m_scope, args[args.size() - 1 - i]}; + } + + /* Push: bump generation so stale entries at this scope level are detected. */ + m_gen_counter++; + if (m_scope >= m_scope_gens.size()) + m_scope_gens.push_back(m_gen_counter); + else + m_scope_gens[m_scope] = m_gen_counter; + + expr val_new = visit(mk_mvar(mid_pending)); + + /* Pop: just decrement scope — stale entries are detected by generation mismatch. */ + m_scope--; + + /* Restore the fvar substitution. */ + for (auto & se : saved_entries) { + if (!se.had_old) { + m_fvar_subst.erase(se.key); + } else { + m_fvar_subst[se.key] = se.old; + } + } + + /* Use apply_beta instead of mk_rev_app: pass 1's beta-reduction may have + changed delayed mvar arguments (e.g., substituting a bvar with a concrete + value), so the resolved pending value may be a lambda that needs beta- + reduction with the extra args, matching the original's behavior. */ bool preserve_data = false; bool zeta = true; - return apply_beta(val_new, args.size() - fvar_count, args.data(), preserve_data, zeta); + return apply_beta(val_new, extra_count, args.data(), preserve_data, zeta); } expr visit_app(expr const & e) { @@ -542,11 +654,13 @@ class instantiate_delayed_fn { (assigned and all nested delayed mvars also resolvable). This matches the original instantiateMVars behavior. */ if (!m_resolvable_delayed.contains(mid_pending)) { - /* Normalize the pending value for mctx write-back. - Downstream code (MutualDef.mkInitialUsedFVarsMap) reads stored - assignments and relies on inner delayed assignments being - resolved even when the outer one cannot be. */ - (void)get_assignment(mid_pending); + /* Still normalize the pending value for mctx write-back when + fvar_subst is empty. Downstream code (MutualDef.mkInitialUsedFVarsMap) + reads stored assignments and relies on inner delayed assignments + being resolved even when the outer one cannot be. */ + if (fvar_subst_empty()) { + (void)get_assignment(mid_pending); + } return visit_mvar_app_args(e); } buffer args; @@ -561,49 +675,116 @@ class instantiate_delayed_fn { return e; } - inline expr cache(expr const & e, expr r, bool shared) { - if (shared) { - m_cache.insert(mk_pair(e.raw(), r)); + expr visit_fvar(expr const & e) { + name const & fid = fvar_name(e); + if (auto r = lookup_fvar(fid)) { + return *r; } - return r; + return e; } public: instantiate_delayed_fn(metavar_ctx & mctx, name_set const & resolvable_delayed) - : m_mctx(mctx), m_resolvable_delayed(resolvable_delayed) {} + : m_mctx(mctx), m_resolvable_delayed(resolvable_delayed), + m_depth(0), m_gen_counter(0), m_scope(0), m_result_scope(0) { + m_scope_gens.push_back(0); /* scope 0 has generation 0 */ + } expr visit(expr const & e) { - if (!has_mvar(e)) - return e; + if (fvar_subst_empty()) { + if (!has_mvar(e)) + return e; + } else { + if (!has_mvar(e) && !has_fvar(e)) + return e; + } + + bool use_global = !has_fvar(e) && !has_expr_mvar(e); bool shared = false; if (is_shared(e)) { - auto it = m_cache.find(e.raw()); - if (it != m_cache.end()) { - return it->second; + if (use_global) { + auto it = m_global_cache.find(e.raw()); + if (it != m_global_cache.end()) + return it->second; + } else { + if (auto r = cache_lookup(e.raw())) + return *r; } shared = true; } + /* Save and reset the result scope for this subtree. + After computing, cache_insert uses m_result_scope to place the entry + at the outermost valid scope level. Then we restore the parent's + watermark, taking the max with our contribution. */ + unsigned saved_result_scope = m_result_scope; + m_result_scope = 0; + + expr r; switch (e.kind()) { case expr_kind::BVar: - case expr_kind::Lit: case expr_kind::FVar: + case expr_kind::Lit: lean_unreachable(); - case expr_kind::Sort: case expr_kind::Const: - /* Levels already resolved by pass 1. */ - return e; + case expr_kind::FVar: + r = visit_fvar(e); + goto done; /* skip caching for fvars */ + case expr_kind::Sort: + r = update_sort(e, visit_level(sort_level(e))); + break; + case expr_kind::Const: + r = update_const(e, visit_levels(const_levels(e))); + break; case expr_kind::MVar: - return visit_mvar(e); + r = visit_mvar(e); + goto done; /* mvar results are not (ptr, depth)-cacheable */ case expr_kind::MData: - return cache(e, update_mdata(e, visit(mdata_expr(e))), shared); + r = update_mdata(e, visit(mdata_expr(e))); + break; case expr_kind::Proj: - return cache(e, update_proj(e, visit(proj_expr(e))), shared); + r = update_proj(e, visit(proj_expr(e))); + break; case expr_kind::App: - return cache(e, visit_app(e), shared); - case expr_kind::Pi: case expr_kind::Lambda: - return cache(e, update_binding(e, visit(binding_domain(e)), visit(binding_body(e))), shared); - case expr_kind::Let: - return cache(e, update_let(e, visit(let_type(e)), visit(let_value(e)), visit(let_body(e))), shared); + r = visit_app(e); + break; + case expr_kind::Pi: case expr_kind::Lambda: { + expr d = visit(binding_domain(e)); + m_depth++; + expr b = visit(binding_body(e)); + m_depth--; + r = update_binding(e, d, b); + break; + } + case expr_kind::Let: { + expr t = visit(let_type(e)); + expr v = visit(let_value(e)); + m_depth++; + expr b = visit(let_body(e)); + m_depth--; + r = update_let(e, t, v, b); + break; + } } + if (shared) { + if (use_global) + m_global_cache.insert(mk_pair(e.raw(), r)); + else + cache_insert(e.raw(), r); + } + + done: + m_result_scope = std::max(saved_result_scope, m_result_scope); + return r; + } + + level visit_level(level const & l) { + /* Pass 2 does not handle level mvars — pass 1 already resolved them. + But we still need this for the visit_levels call in update_sort/update_const. + Since levels have no fvars, we can just return them as-is. */ + return l; + } + + levels visit_levels(levels const & ls) { + return ls; } expr operator()(expr const & e) { return visit(e); } @@ -620,7 +801,7 @@ static object * run_instantiate_all(object * m, object * e) { instantiate_direct_fn pass1(mctx); expr e1 = pass1(expr(e)); - /* Pass 2: resolve delayed assignments using replace_fvars. + /* Pass 2: resolve delayed assignments with fused fvar substitution. Skip if pass 1 found no delayed assignments at all — the expression has no delayed mvars that need resolution or write-back. */ expr e2; From bc0324b38124baa49234d06dfa2e3dbfb86ae316 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Wed, 4 Mar 2026 08:07:14 +0000 Subject: [PATCH 26/45] fix: stack-based scope cache for pass 2 fvar substitution The flat cache in pass 2's `instantiate_delayed_fn` used a scope validity check (`entry.scope_level == 0 || entry.scope_level == m_scope`) that rejected cache entries at intermediate scope levels, causing 0 cache hits and 236M+ redundant visits on bv_decide_rewriter. Replace the single-entry cache with a stack of entries per key, ordered by scope level (innermost at back). Lookup accepts only exact scope matches. Insert stores the result at each scope in [result_scope, current_scope]. This handles fvar shadowing and late-binding correctly without special-case logic, and allows cross-scope cache reuse when safe. Add a test for the late-bind scenario (fvar first seen unsubstituted, then substituted at a higher scope). Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 58 +++++++++--- tests/lean/run/instantiateAllMVarsShadow.lean | 91 ++++++++++++++++++- 2 files changed, 133 insertions(+), 16 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 9ad952e6f563..f409b6e29f25 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -437,16 +437,26 @@ class instantiate_delayed_fn { } }; - struct cache_entry { expr result; unsigned scope_level; unsigned scope_gen; }; + struct cache_entry { + expr result; + unsigned scope_level; /* scope at which this entry was stored */ + unsigned scope_gen; /* generation of scope_level at store time */ + unsigned result_scope; /* original m_result_scope for propagation */ + }; - typedef lean::unordered_map, cache_entry, key_hasher> flat_cache; + typedef lean::unordered_map, + std::vector, key_hasher> flat_cache; metavar_ctx & m_mctx; name_set const & m_resolvable_delayed; name_hash_map m_fvar_subst; unsigned m_depth; - /* Single flat cache with generation-based staleness detection. */ + /* Flat cache mapping (ptr, depth) to a stack of entries ordered by scope + (innermost/highest scope at the back). The stack-based approach handles + both fvar shadowing and late-binding of fvars without special-case logic: + - Lookup: drop stale entries from the back, accept only exact scope match. + - Insert: store entries for each scope in [result_scope, current_scope]. */ flat_cache m_cache; std::vector m_scope_gens; /* m_scope_gens[level] = generation */ unsigned m_gen_counter; @@ -483,26 +493,48 @@ class instantiate_delayed_fn { return optional(lift_loose_bvars(it->second.value, d)); } - /* Cache lookup — O(1) with generation-based staleness check. - An entry at scope_level 0 (no fvar dependency) is valid at any scope. - An entry at scope_level > 0 is only valid at exactly that scope level, - because an inner scope may shadow the fvars it depends on. */ + /* Cache lookup: scan the entry stack from the back (innermost scope first), + drop stale entries, and accept only an exact scope match. + This is always correct: an entry at scope S is only returned when + m_scope == S, so shadowed or late-bound fvars cannot produce stale hits. */ optional cache_lookup(lean_object * ptr) { auto key = mk_pair(ptr, m_depth); auto it = m_cache.find(key); if (it == m_cache.end()) return {}; - auto & entry = it->second; - if ((entry.scope_level == 0 || entry.scope_level == m_scope) && - m_scope_gens[entry.scope_level] == entry.scope_gen) { - m_result_scope = std::max(m_result_scope, entry.scope_level); - return optional(entry.result); + auto & stack = it->second; + /* Drop stale entries from the back. In a LIFO scope structure, + if scope S is stale, all scopes > S were popped earlier. */ + while (!stack.empty()) { + auto & top = stack.back(); + if (top.scope_level <= m_scope && + m_scope_gens[top.scope_level] == top.scope_gen) { + /* First valid entry — accept only if at current scope. */ + if (top.scope_level == m_scope) { + m_result_scope = std::max(m_result_scope, top.result_scope); + return optional(top.result); + } + return {}; /* valid but at a lower scope — miss */ + } + stack.pop_back(); } return {}; } + /* Cache insert: store the result at each scope level in + [m_result_scope, m_scope], so that lookups at any of those scopes + will find an entry with an exact scope match. */ void cache_insert(lean_object * ptr, expr const & result) { auto key = mk_pair(ptr, m_depth); - m_cache[key] = { result, m_result_scope, m_scope_gens[m_result_scope] }; + auto & stack = m_cache[key]; + /* Drop entries at scope_level >= m_result_scope — they are + superseded by the new result (or stale from a popped scope). */ + while (!stack.empty() && stack.back().scope_level >= m_result_scope) { + stack.pop_back(); + } + /* Push entries for each scope in [m_result_scope, m_scope]. */ + for (unsigned s = m_result_scope; s <= m_scope; s++) { + stack.push_back({result, s, m_scope_gens[s], m_result_scope}); + } } /* Get a direct mvar assignment. Visit it to resolve delayed mvars diff --git a/tests/lean/run/instantiateAllMVarsShadow.lean b/tests/lean/run/instantiateAllMVarsShadow.lean index ff3e6ec16116..575b4f9b6f0b 100644 --- a/tests/lean/run/instantiateAllMVarsShadow.lean +++ b/tests/lean/run/instantiateAllMVarsShadow.lean @@ -64,7 +64,6 @@ private def mkShadowTest : MetaM Expr := do private def mkExpected : Expr := let nat := mkConst ``Nat let succ := mkConst ``Nat.succ - let pairTy := mkApp2 (mkConst ``Prod [.succ .zero, .succ .zero]) nat nat -- #0 refers to the lambda-bound `a` let succ_a := mkApp succ (.bvar 0) let succ_succ_a := mkApp succ succ_a @@ -83,9 +82,95 @@ run_meta do let saved ← saveState let eOrig ← instantiateMVarsOriginal root saved.restore - check "instantiateMVarsOriginal" eOrig + check "instantiateMVarsOriginal (shadow)" eOrig let saved ← saveState let eNew ← instantiateAllMVars root saved.restore - check "instantiateAllMVars" eNew + check "instantiateAllMVars (shadow)" eNew + +/- +Test: an fvar first seen unsubstituted, then substituted at a higher scope. + +A shared subexpression `succ_y := Nat.succ y_fvar` is used both: + - directly in the body of d1 (where y is NOT bound), and + - inside d2's pending value (where y IS bound). + + ?root := fun (a : Nat) => ?d1 a + ?d1 delayed [x] := ?body + ?body := Prod.mk succ_y (?d2 succ_y) ← succ_y shared + ?d2 delayed [y] := ?inner ← y is NOW bound + ?inner := succ_y ← same shared object + +Expected result: + fun (a : Nat) => (Nat.succ y_fvar, Nat.succ (Nat.succ y_fvar)) + +At scope 1 (d1), x → a. Visit body: + - succ_y: y is NOT in fvar_subst. Result is succ_y unchanged. + - ?d2 succ_y: arg succ_y visited → succ_y. Then d2 at scope 2 with y → succ_y. + - Visit ?inner = succ_y. y IS in fvar_subst → Nat.succ succ_y = Nat.succ (Nat.succ y_fvar). + +A buggy cache would return the scope-1 result (succ_y unchanged) at scope 2, +producing (Nat.succ y_fvar, Nat.succ y_fvar) instead. +-/ + +private def mkLateBindTest : MetaM (Expr × Expr) := do + let nat := mkConst ``Nat + withLocalDeclD `x nat fun x_fvar => + withLocalDeclD `y nat fun y_fvar => do + -- shared object referencing y_fvar (NOT x_fvar) + let succ_y := mkApp (mkConst ``Nat.succ) y_fvar + + -- ?inner := succ_y + let inner ← mkFreshExprMVar nat + inner.mvarId!.assign succ_y + + -- ?d2 delayed [y_fvar] := ?inner + let d2_ty ← mkArrow nat nat + let d2 ← mkFreshExprMVar d2_ty (kind := .syntheticOpaque) + assignDelayedMVar d2.mvarId! #[y_fvar] inner.mvarId! + + -- ?body := ⟨succ_y, ?d2 succ_y⟩ + let pairTy := mkApp2 (mkConst ``Prod [.succ .zero, .succ .zero]) nat nat + let body ← mkFreshExprMVar pairTy + body.mvarId!.assign + (mkApp4 (mkConst ``Prod.mk [.succ .zero, .succ .zero]) nat nat + succ_y (mkApp d2 succ_y)) + + -- ?d1 delayed [x_fvar] := ?body + let d1_ty ← mkArrow nat pairTy + let d1 ← mkFreshExprMVar d1_ty (kind := .syntheticOpaque) + assignDelayedMVar d1.mvarId! #[x_fvar] body.mvarId! + + -- ?root := fun (a : Nat) => ?d1 a + let rootTy ← mkArrow nat pairTy + let root ← mkFreshExprMVar rootTy + root.mvarId!.assign (Lean.mkLambda `a .default nat (mkApp d1 (.bvar 0))) + return (root, y_fvar) + +-- Expected: fun (a : Nat) => (Nat.succ y_fvar, Nat.succ (Nat.succ y_fvar)) +private def mkExpectedLateBind (y_fvar : Expr) : Expr := + let nat := mkConst ``Nat + let succ := mkConst ``Nat.succ + let succ_y := mkApp succ y_fvar + let succ_succ_y := mkApp succ succ_y + let body := mkApp4 (mkConst ``Prod.mk [.succ .zero, .succ .zero]) nat nat succ_y succ_succ_y + Lean.mkLambda `a .default nat body + +private def checkLateBind (label : String) (result : Expr) (y_fvar : Expr) : MetaM Unit := do + let expected := mkExpectedLateBind y_fvar + unless result == expected do + throwError "{label}: expected {expected}, got {result}" + +run_meta do + let (root, y_fvar) ← mkLateBindTest + + let saved ← saveState + let eOrig ← instantiateMVarsOriginal root + saved.restore + checkLateBind "instantiateMVarsOriginal (late-bind)" eOrig y_fvar + + let saved ← saveState + let eNew ← instantiateAllMVars root + saved.restore + checkLateBind "instantiateAllMVars (late-bind)" eNew y_fvar From 81deb0a36973ea52a749662f0eacf35cfcfd645e Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Wed, 4 Mar 2026 08:39:12 +0000 Subject: [PATCH 27/45] test: update expected output for beta-reduced delayed assignments instantiateAllMVars resolves delayed assignments by substituting fvars inline rather than constructing (fun x => body) arg beta-redexes, producing more beta-reduced (but definitionally equal) terms. Co-Authored-By: Claude Opus 4.6 --- tests/elab/1179b.lean.out.expected | 22 ++++++------- tests/elab/depElim1.lean.out.expected | 45 +++++++++++++-------------- 2 files changed, 32 insertions(+), 35 deletions(-) diff --git a/tests/elab/1179b.lean.out.expected b/tests/elab/1179b.lean.out.expected index d9985ac3a81f..ba4c34d35dc4 100644 --- a/tests/elab/1179b.lean.out.expected +++ b/tests/elab/1179b.lean.out.expected @@ -2,15 +2,13 @@ def Foo.bar.match_1.{u_1} : {l₂ : Nat} → (motive : Foo l₂ → Sort u_1) → (t₂ : Foo l₂) → ((s₁ : Foo l₂) → motive s₁.cons) → ((x : Foo l₂) → motive x) → motive t₂ := fun {l₂} motive t₂ h_1 h_2 => - (fun t₂_1 => - Foo.bar._sparseCasesOn_1 (motive := fun a x => l₂ = a → t₂ ≍ x → motive t₂) t₂_1 - (fun {l} t h => - Eq.ndrec (motive := fun {l} => (t : Foo l) → t₂ ≍ t.cons → motive t₂) - (fun t h => Eq.symm (eq_of_heq h) ▸ h_1 t) h t) - fun h h_3 => - Eq.ndrec (motive := fun a => (t₂_2 : Foo a) → Nat.hasNotBit 2 t₂_2.ctorIdx → t₂ ≍ t₂_2 → motive t₂) - (fun t₂_2 h h_4 => - Eq.ndrec (motive := fun t₂_3 => Nat.hasNotBit 2 t₂_3.ctorIdx → motive t₂) (fun h => h_2 t₂) (eq_of_heq h_4) - h) - h_3 t₂_1 h) - t₂ (Eq.refl l₂) (HEq.refl t₂) + Foo.bar._sparseCasesOn_1 (motive := fun a x => l₂ = a → t₂ ≍ x → motive t₂) t₂ + (fun {l} t h => + Eq.ndrec (motive := fun {l} => (t : Foo l) → t₂ ≍ t.cons → motive t₂) (fun t h => Eq.symm (eq_of_heq h) ▸ h_1 t) h + t) + (fun h h_3 => + Eq.ndrec (motive := fun a => (t₂_1 : Foo a) → Nat.hasNotBit 2 t₂_1.ctorIdx → t₂ ≍ t₂_1 → motive t₂) + (fun t₂_1 h h_4 => + Eq.ndrec (motive := fun t₂_2 => Nat.hasNotBit 2 t₂_2.ctorIdx → motive t₂) (fun h => h_2 t₂) (eq_of_heq h_4) h) + h_3 t₂ h) + (Eq.refl l₂) (HEq.refl t₂) diff --git a/tests/elab/depElim1.lean.out.expected b/tests/elab/depElim1.lean.out.expected index 9a84846e5bfe..6104b68b92d8 100644 --- a/tests/elab/depElim1.lean.out.expected +++ b/tests/elab/depElim1.lean.out.expected @@ -24,29 +24,28 @@ def elimTest2.{u_1, u_2} : (α : Type u_1) → (x : α) → (xs : Vec α n) → (y : α) → (ys : Vec α n) → motive (n + 1) (Vec.cons x xs) (Vec.cons y ys)) → motive n xs ys := fun α motive n xs ys h_1 h_2 => - (fun xs_1 => - Vec.casesOn (motive := fun a x => n = a → xs ≍ x → motive n xs ys) xs_1 - (fun h => - Eq.ndrec (motive := fun n => (xs ys : Vec α n) → xs ≍ Vec.nil → motive n xs ys) - (fun xs ys h => - ⋯ ▸ - Vec.casesOn (motive := fun a x => 0 = a → ys ≍ x → motive 0 Vec.nil ys) ys (fun h h_3 => ⋯ ▸ h_1 ()) - (fun {n} a a_1 h => False.elim ⋯) ⋯ ⋯) - ⋯ xs ys) - fun {n_1} a a_1 h => - Eq.ndrec (motive := fun n => (xs ys : Vec α n) → xs ≍ Vec.cons a a_1 → motive n xs ys) - (fun xs ys h => - ⋯ ▸ - Vec.casesOn (motive := fun a_2 x => n_1 + 1 = a_2 → ys ≍ x → motive (n_1 + 1) (Vec.cons a a_1) ys) ys - (fun h => False.elim ⋯) - (fun {n} a_2 a_3 h => - n_1.elimOffset n 1 h fun x => - Eq.ndrec (motive := fun {n} => - (a_4 : Vec α n) → ys ≍ Vec.cons a_2 a_4 → motive (n_1 + 1) (Vec.cons a a_1) ys) - (fun a_4 h => ⋯ ▸ h_2 n_1 a a_1 a_2 a_4) x a_3) - ⋯ ⋯) - ⋯ xs ys) - xs ⋯ ⋯ + Vec.casesOn (motive := fun a x => n = a → xs ≍ x → motive n xs ys) xs + (fun h => + Eq.ndrec (motive := fun n => (xs ys : Vec α n) → xs ≍ Vec.nil → motive n xs ys) + (fun xs ys h => + ⋯ ▸ + Vec.casesOn (motive := fun a x => 0 = a → ys ≍ x → motive 0 Vec.nil ys) ys (fun h h_3 => ⋯ ▸ h_1 ()) + (fun {n} a a_1 h => False.elim ⋯) ⋯ ⋯) + ⋯ xs ys) + (fun {n_1} a a_1 h => + Eq.ndrec (motive := fun n => (xs ys : Vec α n) → xs ≍ Vec.cons a a_1 → motive n xs ys) + (fun xs ys h => + ⋯ ▸ + Vec.casesOn (motive := fun a_2 x => n_1 + 1 = a_2 → ys ≍ x → motive (n_1 + 1) (Vec.cons a a_1) ys) ys + (fun h => False.elim ⋯) + (fun {n} a_2 a_3 h => + n_1.elimOffset n 1 h fun x => + Eq.ndrec (motive := fun {n} => + (a_4 : Vec α n) → ys ≍ Vec.cons a_2 a_4 → motive (n_1 + 1) (Vec.cons a a_1) ys) + (fun a_4 h => ⋯ ▸ h_2 n_1 a a_1 a_2 a_4) x a_3) + ⋯ ⋯) + ⋯ xs ys) + ⋯ ⋯ elimTest3 : forall (α : Type.{u_1}) (β : Type.{u_2}) (motive : (List.{u_1} α) -> (List.{u_2} β) -> Sort.{u_3}) (x : List.{u_1} α) (y : List.{u_2} β), (Unit -> (motive (List.nil.{u_1} α) (List.nil.{u_2} β))) -> (forall (a : α) (b : β), motive (List.cons.{u_1} α a (List.nil.{u_1} α)) (List.cons.{u_2} β b (List.nil.{u_2} β))) -> (forall (a₁ : α) (a₂ : α) (as : List.{u_1} α) (b₁ : β) (b₂ : β) (bs : List.{u_2} β), motive (List.cons.{u_1} α a₁ (List.cons.{u_1} α a₂ as)) (List.cons.{u_2} β b₁ (List.cons.{u_2} β b₂ bs))) -> (forall (as : List.{u_1} α) (bs : List.{u_2} β), motive as bs) -> (motive x y) def elimTest3.{u_1, u_2, u_3} : (α : Type u_1) → (β : Type u_2) → From 937b0fc92e57aff6625a049095dbce67d688522e Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Wed, 4 Mar 2026 23:06:16 +0000 Subject: [PATCH 28/45] perf: lazy persistent-list cache for pass 2 scope tracking This replaces the eager multi-scope cache insert strategy with a lazy single-insert approach using persistent linked-list snapshots of scope generations. On insert, a single cache entry is stored with an O(1) pointer copy of the current scope_gens list. On lookup, entries are lazily degraded by walking the snapshot and current lists in lockstep, evicting entries whose result depends on popped scopes. This is ~10-20% faster than the previous eager approach which stored separate entries for each scope in [result_scope, current_scope]. Co-Authored-By: Claude Opus 4.6 --- src/Lean/Meta/InstMVarsAll.lean | 6 +- src/kernel/instantiate_mvars_all.cpp | 117 ++++++++++++++++++--------- tests/bench/delayed_assign.lean | 2 +- 3 files changed, 81 insertions(+), 44 deletions(-) diff --git a/src/Lean/Meta/InstMVarsAll.lean b/src/Lean/Meta/InstMVarsAll.lean index 1df057f2b2e2..02b8efb92668 100644 --- a/src/Lean/Meta/InstMVarsAll.lean +++ b/src/Lean/Meta/InstMVarsAll.lean @@ -34,14 +34,14 @@ public def instantiateMVarsOriginal (e : Expr) : MetaM Expr := do Pass 1 resolves direct mvar assignments with write-back. Pass 2 resolves delayed assignments with a fused fvar substitution, avoiding separate `replace_fvars` calls. Preserves sharing using - a flat cache with generation-based staleness detection. -/ + a flat cache with lazy staleness detection via persistent scope + generation snapshots. -/ public def instantiateAllMVars (e : Expr) : MetaM Expr := do if !e.hasMVar then return e let (mctx, eNew) := instantiateAllMVarsImp (← getMCtx) e modifyMCtx fun _ => mctx; return eNew -/-- Alias for `instantiateAllMVars`. Both variants now use the same - sharing-preserving implementation with O(1) cache lookups. -/ +/-- Alias for `instantiateAllMVars`. -/ public def instantiateAllMVarsSharing (e : Expr) : MetaM Expr := do if !e.hasMVar then return e let (mctx, eNew) := instantiateAllMVarsSharingImp (← getMCtx) e diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index f409b6e29f25..cefe7baebef6 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -5,6 +5,7 @@ Released under Apache 2.0 license as described in the file LICENSE. Authors: Joachim Breitner */ #include +#include #include #include "util/name_set.h" #include "util/name_hash_map.h" @@ -418,10 +419,13 @@ class resolvability_checker { Pass 2: Resolve delayed assignments with fused fvar substitution. Direct mvar chains have been pre-resolved by pass 1. - Uses a flat (ptr, depth)-keyed cache with generation-based staleness. - Each visit_delayed scope gets a unique generation number; cache entries - record the scope level and generation at insertion. Validity is O(1): - entry valid iff level <= m_scope && m_scope_gens[level] == entry.scope_gen. + Uses a flat (ptr, depth)-keyed cache with lazy staleness detection. + Each visit_delayed scope gets a unique generation number. Scope + generations are stored in a persistent linked list (arena-allocated + for pointer stability). Cache entries store a single snapshot of the + scope_gens list at insert time. On lookup, entries are lazily degraded + by walking the snapshot and current lists in lockstep until a valid + scope level is found or the entry is evicted. ============================================================================ */ struct fvar_subst_entry { @@ -430,6 +434,13 @@ struct fvar_subst_entry { expr value; }; +/* Persistent linked-list node for scope generations. + Allocated from an arena (std::deque) for pointer stability. */ +struct scope_gen_node { + unsigned gen; + scope_gen_node * tail; /* parent scope, or nullptr for scope -1 */ +}; + class instantiate_delayed_fn { struct key_hasher { std::size_t operator()(std::pair const & p) const { @@ -439,8 +450,8 @@ class instantiate_delayed_fn { struct cache_entry { expr result; - unsigned scope_level; /* scope at which this entry was stored */ - unsigned scope_gen; /* generation of scope_level at store time */ + unsigned scope_level; /* scope at which this entry is (currently) valid */ + scope_gen_node * scope_gens; /* snapshot of scope_gens list at store time */ unsigned result_scope; /* original m_result_scope for propagation */ }; @@ -453,12 +464,12 @@ class instantiate_delayed_fn { unsigned m_depth; /* Flat cache mapping (ptr, depth) to a stack of entries ordered by scope - (innermost/highest scope at the back). The stack-based approach handles - both fvar shadowing and late-binding of fvars without special-case logic: - - Lookup: drop stale entries from the back, accept only exact scope match. - - Insert: store entries for each scope in [result_scope, current_scope]. */ + (innermost/highest scope at the back). */ flat_cache m_cache; - std::vector m_scope_gens; /* m_scope_gens[level] = generation */ + /* Persistent linked list of scope generations for O(1) snapshot copies. + Arena (deque) provides pointer stability across push_back. */ + std::deque m_gen_arena; + scope_gen_node * m_scope_gens_list; unsigned m_gen_counter; unsigned m_scope; @@ -493,48 +504,71 @@ class instantiate_delayed_fn { return optional(lift_loose_bvars(it->second.value, d)); } - /* Cache lookup: scan the entry stack from the back (innermost scope first), - drop stale entries, and accept only an exact scope match. - This is always correct: an entry at scope S is only returned when - m_scope == S, so shadowed or late-bound fvars cannot produce stale hits. */ optional cache_lookup(lean_object * ptr) { auto key = mk_pair(ptr, m_depth); auto it = m_cache.find(key); if (it == m_cache.end()) return {}; auto & stack = it->second; - /* Drop stale entries from the back. In a LIFO scope structure, - if scope S is stale, all scopes > S were popped earlier. */ + /* Entries store a persistent scope_gens snapshot. On lookup, degrade + entries whose top scopes have been popped or re-entered, walking + the snapshot and current lists in lockstep. */ while (!stack.empty()) { auto & top = stack.back(); - if (top.scope_level <= m_scope && - m_scope_gens[top.scope_level] == top.scope_gen) { - /* First valid entry — accept only if at current scope. */ - if (top.scope_level == m_scope) { + /* Degrade: if scope_level > m_scope, those scopes were popped. */ + while (top.scope_level > m_scope) { + top.scope_gens = top.scope_gens->tail; + top.scope_level--; + } + /* If the result depends on a scope that has been popped, the + entry is invalid — discard it. */ + if (top.result_scope > m_scope) { + stack.pop_back(); + continue; + } + /* Now scope_level <= m_scope. */ + if (top.scope_level == m_scope) { + /* Compare generation at this level. */ + if (top.scope_gens->gen == m_scope_gens_list->gen) { m_result_scope = std::max(m_result_scope, top.result_scope); return optional(top.result); } - return {}; /* valid but at a lower scope — miss */ + /* Generation mismatch: scope was re-entered. Walk both + lists down in lockstep until we find a valid level or + exhaust to result_scope. */ + scope_gen_node * entry_node = top.scope_gens; + scope_gen_node * current_node = m_scope_gens_list; + unsigned level = top.scope_level; + while (level > top.result_scope) { + entry_node = entry_node->tail; + current_node = current_node->tail; + level--; + if (entry_node->gen == current_node->gen) { + /* Valid at this level. Update entry in place. */ + top.scope_level = level; + top.scope_gens = entry_node; + /* level < m_scope → valid but at lower scope → miss */ + return {}; + } + } + /* Reached result_scope without finding valid level → delete. */ + stack.pop_back(); + continue; } - stack.pop_back(); + /* scope_level < m_scope: valid but at a lower scope — miss */ + return {}; } return {}; } - /* Cache insert: store the result at each scope level in - [m_result_scope, m_scope], so that lookups at any of those scopes - will find an entry with an exact scope match. */ void cache_insert(lean_object * ptr, expr const & result) { auto key = mk_pair(ptr, m_depth); auto & stack = m_cache[key]; - /* Drop entries at scope_level >= m_result_scope — they are - superseded by the new result (or stale from a popped scope). */ + /* Single entry with scope_gens snapshot. On later lookup, the + entry is lazily degraded by walking the snapshot list. */ while (!stack.empty() && stack.back().scope_level >= m_result_scope) { stack.pop_back(); } - /* Push entries for each scope in [m_result_scope, m_scope]. */ - for (unsigned s = m_result_scope; s <= m_scope; s++) { - stack.push_back({result, s, m_scope_gens[s], m_result_scope}); - } + stack.push_back({result, m_scope, m_scope_gens_list, m_result_scope}); } /* Get a direct mvar assignment. Visit it to resolve delayed mvars @@ -630,17 +664,18 @@ class instantiate_delayed_fn { m_fvar_subst[fid] = {m_depth, m_scope, args[args.size() - 1 - i]}; } - /* Push: bump generation so stale entries at this scope level are detected. */ + /* Push: bump generation so stale entries at this scope level are detected. + Prepend a new node to the persistent scope_gens list. */ m_gen_counter++; - if (m_scope >= m_scope_gens.size()) - m_scope_gens.push_back(m_gen_counter); - else - m_scope_gens[m_scope] = m_gen_counter; + m_gen_arena.push_back({m_gen_counter, m_scope_gens_list}); + m_scope_gens_list = &m_gen_arena.back(); expr val_new = visit(mk_mvar(mid_pending)); - /* Pop: just decrement scope — stale entries are detected by generation mismatch. */ + /* Pop: follow the tail of the persistent list back to the parent scope. + Stale entries are detected by generation mismatch on lookup. */ m_scope--; + m_scope_gens_list = m_scope_gens_list->tail; /* Restore the fvar substitution. */ for (auto & se : saved_entries) { @@ -718,8 +753,10 @@ class instantiate_delayed_fn { public: instantiate_delayed_fn(metavar_ctx & mctx, name_set const & resolvable_delayed) : m_mctx(mctx), m_resolvable_delayed(resolvable_delayed), - m_depth(0), m_gen_counter(0), m_scope(0), m_result_scope(0) { - m_scope_gens.push_back(0); /* scope 0 has generation 0 */ + m_depth(0), m_scope_gens_list(nullptr), + m_gen_counter(0), m_scope(0), m_result_scope(0) { + m_gen_arena.push_back({0, nullptr}); + m_scope_gens_list = &m_gen_arena.back(); } expr visit(expr const & e) { diff --git a/tests/bench/delayed_assign.lean b/tests/bench/delayed_assign.lean index d04ba40cc571..e53d91425e46 100644 --- a/tests/bench/delayed_assign.lean +++ b/tests/bench/delayed_assign.lean @@ -64,7 +64,7 @@ partial def bench1 (n : Nat) : MetaM Unit := do unless Expr.eqv rDefault rNULean do IO.println s!"ERROR: instantiateMVars vs NoUpdate(Lean) differ for n={n}" - IO.println s!"bench1_{n}: instantiateMVars {msDefault} ms, Original {msOriginal} ms, AllMVars {msAll} ms, NoUpdate(C++) {msNUCpp} ms, NoUpdate(Lean) {msNULean} ms" + IO.println s!"bench1_{n}: Default {msDefault} ms, Original {msOriginal} ms, AllMVars {msAll} ms, NoUpdate(C++) {msNUCpp} ms, NoUpdate(Lean) {msNULean} ms" run_meta do IO.println "Example (n = 5):" From cead4665d0772f3370d6790f86f57385837fc68e Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 09:02:09 +0000 Subject: [PATCH 29/45] chore: remove instantiateMVarsNoUpdate variant This removes the no-update instantiateMVars variant, keeping only the original single-pass implementation and the new two-pass implementation. Co-Authored-By: Claude Opus 4.6 --- src/Lean/Elab/PreDefinition/Basic.lean | 1 - src/Lean/Meta/InstMVarsNU.lean | 219 --------- src/Lean/MetavarContext.lean | 4 +- src/kernel/CMakeLists.txt | 1 - src/kernel/instantiate_mvars_no_update.cpp | 503 --------------------- tests/bench/delayed_assign.lean | 9 +- tests/bench/delayed_assign_nu2.lean | 315 ------------- tests/lean/run/instMVarsNUDelayed.lean | 133 ------ 8 files changed, 3 insertions(+), 1182 deletions(-) delete mode 100644 src/Lean/Meta/InstMVarsNU.lean delete mode 100644 src/kernel/instantiate_mvars_no_update.cpp delete mode 100644 tests/bench/delayed_assign_nu2.lean delete mode 100644 tests/lean/run/instMVarsNUDelayed.lean diff --git a/src/Lean/Elab/PreDefinition/Basic.lean b/src/Lean/Elab/PreDefinition/Basic.lean index a5c2487e52d5..f8c39a9bfe5d 100644 --- a/src/Lean/Elab/PreDefinition/Basic.lean +++ b/src/Lean/Elab/PreDefinition/Basic.lean @@ -12,7 +12,6 @@ public import Lean.Meta.Eqns public import Lean.Elab.RecAppSyntax public import Lean.Elab.DefView import Lean.Meta.InstMVarsAll -import Lean.Meta.InstMVarsNU public section diff --git a/src/Lean/Meta/InstMVarsNU.lean b/src/Lean/Meta/InstMVarsNU.lean deleted file mode 100644 index 03294224bd3c..000000000000 --- a/src/Lean/Meta/InstMVarsNU.lean +++ /dev/null @@ -1,219 +0,0 @@ -/- -Copyright (c) 2026 Lean FRO, LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. -Authors: Joachim Breitner --/ - -module -prelude -public import Lean.Meta.Basic - -/-! -This alternative to `instantiateMVars` does *not* update the assignments of the meta variables -it visits. The benefit is that it carries a substitution of free variables as it traverses -the metavariables (in particular the delayed-assigned metavariabes), avoiding some of the overhead of -repeated substitution. This can make a big difference in terms with many metavarialbes under -`mkLambdaFVars`/`mkForallFVars`, or terms produced by tactics with lots of uses of `intro`. - -Implementation notes: - -* We traverse open terms (with loose bvars). -* The reader context carries the current binder depth. This is bumped when going under a binder. -* The fvar substitution values are not lifted when we go under binder. - Instead, we remember at which depth we inserted the value into the map, and do invoke - `liftLooseBVars` with the difference when we lookup the value. -* We cache the main instantiation loop. - This also means that the lifting done during substitution, and metavariable lookup, is cached. -* However, we use a fresh cache when we go under a binder. - Rationale: we'd have to lift the values in the cache when going under binder or when using a value. - That is already linear in the size of the expression, so we might as well just re-read the whole - value from the metavariable graph. --/ - -namespace Lean.Meta - -namespace IntantiateMVars - -structure DelayedLift where - originalDepth : Nat - expr : Expr -- Unshifted expression - -- shifted : Thunk Expr -- Shifted expression - -def DelayedLift.ofExpr (e : Expr) : DelayedLift where - originalDepth := 0 - expr := e - -- shifted := Thunk.pure e - -/- -def DelayedLift.lift (n : Nat) (ds : DelayedLift) : DelayedLift := - if ds.expr.hasLooseBVars then - let newLift := ds.currentLift + n - { ds with - currentLift := newLift - -- shifted := Thunk.mk fun _ => - -- ds.expr.liftLooseBVars (s := 0) (d := newLift) - } - else - ds --/ - -structure Context where - depth : Nat := 0 - fvarSubst : PersistentHashMap FVarId DelayedLift := {} - -structure State where - cache : Std.HashMap ExprStructEq Expr := {} - -abbrev M := ReaderT Context (StateRefT State MetaM) - -/- -When going under binders, we -* we record the increased depth -* empty the cache locally --/ -def withLift (n : Nat) (k : M α) : M α := do - let cache ← modifyGet fun s => (s.cache, { s with cache := {} }) - let x ← withTheReader Context (fun ctx => - { ctx with depth := ctx.depth + n - -- fvarSubst := ctx.fvarSubst.mapVal (fun ds => ds.lift n) -- TODO: avoid mapping eagerly - }) do - k - modify fun s => { s with cache := cache } - pure x - -/-- -When traversing a delayed-assigned metavariable assignment, extend the substitution --/ -def withSubst (fvarIds : Array Expr) (args : Array Expr) : M α → M α := - withTheReader Context fun ctx => Id.run do - let mut mvarSubst := ctx.fvarSubst - for fvarId in fvarIds, arg in args do - mvarSubst := mvarSubst.erase fvarId.fvarId! |>.insert fvarId.fvarId! ⟨ctx.depth, arg⟩ - { ctx with fvarSubst := mvarSubst } - -def lookup? (fvarId : FVarId) : M (Option Expr) := do - let ctx ← read - match ctx.fvarSubst.find? fvarId with - | some ds => return ds.expr.liftLooseBVars (s := 0) (d := ctx.depth - ds.originalDepth) - | none => return none - -partial def go (e : Expr) : M Expr := do - unless e.hasMVar || e.hasFVar do - return e - -- logInfo m!"Instantiating MVars in:{indentExpr e}\nfvarSubst: {(← read).fvarSubst.toList.map (fun (k, v) => (mkFVar k, v.expr, v.currentLift))}" - if let some e' := (← get).cache[ExprStructEq.mk e]? then return e' - - let goApp (e : Expr) := e.withApp fun f args => do - let args ← args.mapM go - let instArgs (f' : Expr) : M Expr := do - pure (mkAppN f' args) - let instApp : M Expr := do - let wasMVar := f.isMVar - let f' ← go f - if wasMVar && f'.isLambda then - /- Some of the arguments in `args` are irrelevant after we beta - reduce. Also, it may be a bug to not instantiate them, since they - may depend on free variables that are not in the context (see - issue #4375). So we pass `useZeta := true` to ensure that they are - instantiated. -/ - go (f'.betaRev args.reverse (useZeta := true)) - else - instArgs f' - if let .mvar mvarId := f then - let some {fvars, mvarIdPending} ← getDelayedMVarAssignment? mvarId | return ← instApp - if fvars.size > args.size then - /- We don't have sufficient arguments for instantiating the free variables `fvars`. - This can only happen if a tactic or elaboration function is not implemented correctly. - We decided to not use `panic!` here and report it as an error in the frontend - when we are checking for unassigned metavariables in an elaborated term. -/ - return ← instArgs f - let substArgs := args[:fvars.size].copy - let extraArgs := args[fvars.size:].copy - /- - Example: suppose we have - `?m t1 t2 t3` - That is, `f := ?m` and `substArgs := #[t1, t2]` and `extraArgs := #[t3]` - Moreover, `?m` is delayed assigned - `?m #[x, y] := e` - - We want to instantiate `e` with a substitution `[x ↦ t1, y ↦ t2]`, and then apply `t3` - -/ - let newVal ← withSubst fvars substArgs <| - go (mkMVar mvarIdPending) - -- logInfo m!"Resolved delayed mvar assignment to {newVal}" - let result := mkAppN newVal extraArgs - return result - instApp - - let e' ← match e with - | .bvar .. | .lit .. => unreachable! - | .proj _ _ s => return e.updateProj! (← go s) - | .forallE _ d b _ => return e.updateForallE! (← go d) (← withLift 1 <| go b) - | .lam _ d b _ => return e.updateLambdaE! (← go d) (← withLift 1 <| go b) - | .letE _ t v b nd => return e.updateLet! (← go t) (← go v) (← withLift 1 <| go b) nd - | .const _ lvls => return e.updateConst! (← lvls.mapM (instantiateLevelMVars ·)) - | .sort lvl => return e.updateSort! (← instantiateLevelMVars lvl) - | .mdata _ b => return e.updateMData! (← go b) - | .fvar fvarId => return (← lookup? fvarId).getD e - | .app .. => goApp e - | .mvar mvarId => -- Not in function position, cannot be a delayed mvar assignment - match (← getExprMVarAssignment? mvarId) with - | some newE => - -- logInfo m!"Resolved {mkMVar mvarId} mvar assignment to {newE}" - let newE ← go newE - -- logInfo m!"Instantiated {mkMVar mvarId} mvar assignment to {newE}" - return newE - | none => - let ctx ← read - if ctx.fvarSubst.isEmpty then - pure e - else - /- The mvar is unassigned and we have an active fvar substitution. The mvar's - eventual value might reference fvars from our substitution. We use - `elimMVarDeps` to create a delayed assignment that captures the substitution, - just like `mkForallFVars`/`mkLambdaFVars` would do when abstracting fvars - over an expression containing unassigned mvars. -/ - let mvarDecl ← mvarId.getDecl - let (fvars, values) := ctx.fvarSubst.foldl (init := (#[], #[])) - fun (fvars, values) fvarId dl => - if mvarDecl.lctx.contains fvarId then - (fvars.push (mkFVar fvarId), - values.push (dl.expr.liftLooseBVars (s := 0) (d := ctx.depth - dl.originalDepth))) - else - (fvars, values) - if fvars.isEmpty then - pure e - else - -- Abstract the fvars via elimMVarDeps (creates delayed assignment) - let e' ← (do - mvarId.withContext do - elimMVarDeps fvars (mkMVar mvarId) : MetaM Expr) - -- Replace fvars with their substitution values - pure (Expr.replaceFVars e' fvars values) - modify fun s => { s with cache := s.cache.insert (ExprStructEq.mk e) e' } - return e' - -end IntantiateMVars - -@[extern "lean_instantiate_expr_mvars_no_update"] -private opaque instantiateMVarsNoUpdateImp (mctx : MetavarContext) (e : Expr) : MetavarContext × Expr - -/-- -This alternative to `instantiateMVars` does *not* update the assignments of the meta variables -it visits. The benefit is that it carries a substitution of free variables as it traverses -the metavariables (in particular the delayed-assigned metavariabes), avoiding some of the overhead of -repeated substitution. This can make a big difference in terms with many metavarialbes under -`mkLambdaFVars`/`mkForallFVars`, or terms produced by tactics with lots of uses of `intro`. --/ -public def instantiateMVarsNoUpdate (e : Expr) : MetaM Expr := do - if !e.hasMVar then - return e - let (mctx, eNew) := instantiateMVarsNoUpdateImp (← getMCtx) e - modifyMCtx fun _ => mctx - return eNew - -/-- Lean implementation of `instantiateMVarsNoUpdate`, exposed for benchmarking. -/ -public def instantiateMVarsNoUpdateLean (e : Expr) : MetaM Expr := do - (IntantiateMVars.go e).run {} |>.run' {} - -end Lean.Meta diff --git a/src/Lean/MetavarContext.lean b/src/Lean/MetavarContext.lean index 7ecb05a18106..7cbbc68af21d 100644 --- a/src/Lean/MetavarContext.lean +++ b/src/Lean/MetavarContext.lean @@ -1486,8 +1486,8 @@ def getExprAssignmentDomain (mctx : MetavarContext) : Array MVarId := /-- Abstract the given fvars from an unassigned mvar by creating a delayed-assigned mvar. Returns `(mctx', result)` where `result` has the fvars replaced by `values`. -This is used by `instantiateMVarsNoUpdate` when encountering an unassigned mvar -with an active fvar substitution. +This can be used when encountering an unassigned mvar with an active fvar +substitution. -/ @[export lean_abstract_mvar_fvars] def abstractMVarFVars (mctx : MetavarContext) (mvarId : MVarId) (fvars : Array Expr) (values : Array Expr) : MetavarContext × Expr := diff --git a/src/kernel/CMakeLists.txt b/src/kernel/CMakeLists.txt index dd2023574c10..d2dd7dc03940 100644 --- a/src/kernel/CMakeLists.txt +++ b/src/kernel/CMakeLists.txt @@ -19,6 +19,5 @@ add_library( inductive.cpp trace.cpp instantiate_mvars.cpp - instantiate_mvars_no_update.cpp instantiate_mvars_all.cpp ) diff --git a/src/kernel/instantiate_mvars_no_update.cpp b/src/kernel/instantiate_mvars_no_update.cpp deleted file mode 100644 index b8e73fdc4d53..000000000000 --- a/src/kernel/instantiate_mvars_no_update.cpp +++ /dev/null @@ -1,503 +0,0 @@ -/* -Copyright (c) 2026 Lean FRO, LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. - -Authors: Joachim Breitner -*/ -/* Suppress -Wdeprecated-copy triggered by mi_stl_allocator in lean::unordered_map. - mi_stl_allocator (from mimalloc) declares a copy constructor but not a copy - assignment operator, which GCC 15 / Clang flag as deprecated. */ -#if defined(__clang__) -#pragma clang diagnostic ignored "-Wdeprecated-copy" -#elif defined(__GNUC__) -#pragma GCC diagnostic ignored "-Wdeprecated-copy" -#endif -#include -#include -#include "util/name_set.h" -#include "util/name_hash_map.h" -#include "runtime/option_ref.h" -#include "runtime/array_ref.h" -#include "kernel/instantiate.h" -#include "kernel/replace_fn.h" -#include "kernel/expr.h" - -/* -This module provides an efficient C++ implementation of `instantiateMVarsNoUpdate`. - -Unlike `instantiateExprMVars`, this variant does NOT update the assignments of the -metavariables it visits. Instead, it carries a substitution of free variables as it -traverses delayed-assigned metavariables, avoiding repeated substitution overhead. - -Key design differences from instantiate_mvars.cpp: -1. No `assign_mvar` calls -- the mctx is not mutated (in particular, the result does - not include an updated mctx). -2. FVar substitution: when encountering a delayed assignment `?m [x,y] := ?pending`, - instead of doing `replace_fvars`, we extend an fvar substitution map and continue - traversing. This avoids a second traversal. -3. Depth tracking: we track the current binder depth. Fvar substitution values are - stored with the depth at which they were inserted; on lookup, we lift by the - difference. -4. Cache clearing: we clear the expression cache when going under binders, because - cache entries depend on the current depth (fvar substitutions may contain loose bvars). - -When the fvar substitution is empty, we update metavar assignments with their normalized -values (like the standard instantiateMVars), since this is sound and helps future lookups. -*/ - -namespace lean { -extern "C" object * lean_get_lmvar_assignment(obj_arg mctx, obj_arg mid); -extern "C" object * lean_assign_lmvar(obj_arg mctx, obj_arg mid, obj_arg val); -extern "C" object * lean_get_mvar_assignment(obj_arg mctx, obj_arg mid); -extern "C" object * lean_get_delayed_mvar_assignment(obj_arg mctx, obj_arg mid); -extern "C" object * lean_assign_mvar(obj_arg mctx, obj_arg mid, obj_arg val); -extern "C" object * lean_abstract_mvar_fvars(obj_arg mctx, obj_arg mid, obj_arg fvars, obj_arg values); - -typedef object_ref metavar_ctx; -typedef object_ref delayed_assignment; - -static void assign_lmvar(metavar_ctx & mctx, name const & mid, level const & l) { - object * r = lean_assign_lmvar(mctx.steal(), mid.to_obj_arg(), l.to_obj_arg()); - mctx.set_box(r); -} - -static option_ref get_lmvar_assignment(metavar_ctx & mctx, name const & mid) { - return option_ref(lean_get_lmvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); -} - -static void assign_mvar(metavar_ctx & mctx, name const & mid, expr const & e) { - object * r = lean_assign_mvar(mctx.steal(), mid.to_obj_arg(), e.to_obj_arg()); - mctx.set_box(r); -} - -static option_ref get_mvar_assignment(metavar_ctx & mctx, name const & mid) { - return option_ref(lean_get_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); -} - -static option_ref get_delayed_mvar_assignment(metavar_ctx & mctx, name const & mid) { - return option_ref(lean_get_delayed_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); -} - -/* Abstract fvars from an unassigned mvar by creating a delayed-assigned mvar. - Returns a pair (mctx', result) where result has the fvars replaced by values. - Uses the Lean-implemented elimMVarDeps machinery. */ -static object_ref abstract_mvar_fvars(metavar_ctx & mctx, name const & mid, - array_ref const & fvars, array_ref const & values) { - object * r = lean_abstract_mvar_fvars(mctx.steal(), mid.to_obj_arg(), fvars.to_obj_arg(), values.to_obj_arg()); - /* result is a pair (MetavarContext, Expr) */ - mctx.set_box(cnstr_get(r, 0)); - lean_inc(cnstr_get(r, 0)); - expr result(cnstr_get(r, 1), true); - lean_dec(r); - return object_ref(result.steal()); -} - -/* Level metavariable instantiation -- same as in instantiate_mvars.cpp, - but we DO update level mvar assignments (levels don't involve fvars). */ -class instantiate_lmvars_nu_fn { - metavar_ctx & m_mctx; - lean::unordered_map m_cache; - std::vector m_saved; - - inline level cache(level const & l, level r, bool shared) { - if (shared) { - m_cache.insert(mk_pair(l.raw(), r)); - } - return r; - } -public: - instantiate_lmvars_nu_fn(metavar_ctx & mctx):m_mctx(mctx) {} - level visit(level const & l) { - if (!has_mvar(l)) - return l; - bool shared = false; - if (is_shared(l)) { - auto it = m_cache.find(l.raw()); - if (it != m_cache.end()) { - return it->second; - } - shared = true; - } - switch (l.kind()) { - case level_kind::Succ: - return cache(l, update_succ(l, visit(succ_of(l))), shared); - case level_kind::Max: case level_kind::IMax: - return cache(l, update_max(l, visit(level_lhs(l)), visit(level_rhs(l))), shared); - case level_kind::Zero: case level_kind::Param: - lean_unreachable(); - case level_kind::MVar: { - option_ref r = get_lmvar_assignment(m_mctx, mvar_id(l)); - if (!r) { - return l; - } else { - level a(r.get_val()); - if (!has_mvar(a)) { - return a; - } else { - level a_new = visit(a); - if (!is_eqp(a, a_new)) { - m_saved.push_back(a); - assign_lmvar(m_mctx, mvar_id(l), a_new); - } - return a_new; - } - } - }} - } - level operator()(level const & l) { return visit(l); } -}; - -struct fvar_subst_entry { - unsigned depth; - expr value; -}; - -class instantiate_mvars_no_update_fn { - metavar_ctx & m_mctx; - instantiate_lmvars_nu_fn m_level_fn; - /* The fvar substitution, mapping fvar names to (depth, value) pairs. - Uses name_hash_map for structural name equality (not pointer equality). */ - name_hash_map m_fvar_subst; - /* Current binder depth. */ - unsigned m_depth; - /* Expression cache. Cleared when going under binders. */ - lean::unordered_map m_cache; - /* Global cache: persists across binder depths. Used for expressions that have - no FVars, so their result is depth-independent. */ - lean::unordered_map m_global_cache; - /* Prevent GC of values whose subterms may be referenced by the cache. */ - std::vector m_saved; - /* Track which mvars have already been normalized (for the update-when-empty optimization). */ - name_set m_already_normalized; - - level visit_level(level const & l) { - return m_level_fn(l); - } - - levels visit_levels(levels const & ls) { - buffer lsNew; - for (auto const & l : ls) - lsNew.push_back(visit_level(l)); - return levels(lsNew); - } - - bool fvar_subst_empty() const { - return m_fvar_subst.empty(); - } - - /* Look up an fvar in our substitution. Returns none if not found. */ - optional lookup_fvar(name const & fid) { - auto it = m_fvar_subst.find(fid); - if (it == m_fvar_subst.end()) - return optional(); - unsigned d = m_depth - it->second.depth; - if (d == 0) - return optional(it->second.value); - return optional(lift_loose_bvars(it->second.value, d)); - } - - /* - Get mvar assignment. Unlike the updating version, we do NOT normalize - and write back by default -- unless the fvar substitution is empty, - in which case the normalization result is identical to what instantiateMVars - would compute, and updating is sound and beneficial. - */ - optional get_assignment(name const & mid) { - option_ref r = get_mvar_assignment(m_mctx, mid); - if (!r) - return optional(); - expr a(r.get_val()); - if (fvar_subst_empty()) { - /* Match standard instantiateMVars behavior exactly: - return immediately if no mvars or already normalized. */ - if (!has_mvar(a) || m_already_normalized.contains(mid)) { - return optional(a); - } - m_already_normalized.insert(mid); - expr a_new = visit(a); - if (!is_eqp(a, a_new)) { - m_saved.push_back(a); - assign_mvar(m_mctx, mid, a_new); - } - return optional(a_new); - } else { - /* Fvar substitution is active. Cannot update assignments. - Return immediately if nothing to process. */ - if (!has_mvar(a) && !has_fvar(a)) { - return optional(a); - } - return optional(visit(a)); - } - } - - /* Given `e` of the form `f a_1 ... a_n` where `f` is not a metavariable, - instantiate metavariables in all positions. */ - expr visit_app_default(expr const & e) { - buffer args; - expr const * curr = &e; - while (is_app(*curr)) { - args.push_back(visit(app_arg(*curr))); - curr = &app_fn(*curr); - } - lean_assert(!is_mvar(*curr)); - expr f = visit(*curr); - return mk_rev_app(f, args.size(), args.data()); - } - - /* Given `e` of the form `?m a_1 ... a_n`, visit the args but keep ?m as-is. */ - expr visit_mvar_app_args(expr const & e) { - buffer args; - expr const * curr = &e; - while (is_app(*curr)) { - args.push_back(visit(app_arg(*curr))); - curr = &app_fn(*curr); - } - lean_assert(is_mvar(*curr)); - return mk_rev_app(*curr, args.size(), args.data()); - } - - /* Beta-reduce `f_new` applied to the args of `e`. */ - expr visit_args_and_beta(expr const & f_new, expr const & e, buffer & args) { - expr const * curr = &e; - while (is_app(*curr)) { - args.push_back(visit(app_arg(*curr))); - curr = &app_fn(*curr); - } - bool preserve_data = false; - bool zeta = true; - return apply_beta(f_new, args.size(), args.data(), preserve_data, zeta); - } - - /* - Handle delayed assignment: `?m [x,y] := ?pending` applied to args. - Instead of replace_fvars, we extend the fvar substitution and recursively visit ?pending. - */ - expr visit_delayed(array_ref const & fvars, name const & mid_pending, - expr const & e, buffer & args) { - expr const * curr = &e; - while (is_app(*curr)) { - args.push_back(visit(app_arg(*curr))); - curr = &app_fn(*curr); - } - - size_t fvar_count = fvars.size(); - size_t extra_count = args.size() - fvar_count; - - /* Save and extend the fvar substitution. - The substitution values are the (already visited) args at indices - [args.size()-1 .. args.size()-fvar_count] (reversed, since args is built in reverse). */ - struct saved_entry { name key; bool had_old; fvar_subst_entry old; }; - std::vector saved_entries; - saved_entries.reserve(fvar_count); - for (size_t i = 0; i < fvar_count; i++) { - name const & fid = fvar_name(fvars[i]); - auto old_it = m_fvar_subst.find(fid); - if (old_it != m_fvar_subst.end()) { - saved_entries.push_back({fid, true, old_it->second}); - } else { - saved_entries.push_back({fid, false, {0, expr()}}); - } - /* args is reversed: args[args.size()-1] corresponds to the first argument. - fvars[i] should be mapped to the i-th argument, which is - args[args.size() - 1 - i]. */ - m_fvar_subst[fid] = {m_depth, args[args.size() - 1 - i]}; - } - - /* Visit ?pending with the extended substitution. - We must use a fresh depth-dependent cache because the fvar substitution - has changed: existing cache entries were computed with the old substitution - and would produce incorrect results. (The global cache is fine since it - only contains entries for fvar-free expressions.) */ - lean::unordered_map saved_cache; - saved_cache.swap(m_cache); - expr val_new = visit(mk_mvar(mid_pending)); - saved_cache.swap(m_cache); - - /* Restore the fvar substitution */ - for (auto & se : saved_entries) { - if (!se.had_old) { - m_fvar_subst.erase(se.key); - } else { - m_fvar_subst[se.key] = se.old; - } - } - - /* Apply the extra arguments */ - return mk_rev_app(val_new, extra_count, args.data()); - } - - expr visit_app(expr const & e) { - expr const & f = get_app_fn(e); - if (!is_mvar(f)) { - return visit_app_default(e); - } else { - name const & mid = mvar_name(f); - /* Regular assignments take precedence over delayed ones. - Always use visit_args_and_beta (with preserve_data=false) to match - the standard instantiateMVars behavior: apply_beta strips mdata - wrappers like _recApp even for non-lambda assignments. */ - if (auto f_new = get_assignment(mid)) { - buffer args; - return visit_args_and_beta(*f_new, e, args); - } - option_ref d = get_delayed_mvar_assignment(m_mctx, mid); - if (!d) { - return visit_mvar_app_args(e); - } - array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - if (fvars.size() > get_app_num_args(e)) { - return visit_mvar_app_args(e); - } - /* When fvar subst is empty, use the same guards as the standard version: - only resolve when pending is assigned and its normalized value has no mvars. - When fvar subst is non-empty, always resolve (using delayed mvar creation - for unassigned mvars). */ - if (fvar_subst_empty()) { - optional pending_val = get_assignment(mid_pending); - if (!pending_val || has_expr_mvar(*pending_val)) - return visit_mvar_app_args(e); - } - buffer args; - return visit_delayed(fvars, mid_pending, e, args); - } - } - - expr visit_mvar(expr const & e) { - name const & mid = mvar_name(e); - if (auto r = get_assignment(mid)) { - return *r; - } else if (fvar_subst_empty()) { - return e; - } else { - /* The mvar is unassigned and we have an active fvar substitution. - Create a delayed assignment that captures the substitution, - so that when the mvar is eventually assigned, the substitution - is correctly applied. */ - lean_object * fvars_arr = lean_mk_empty_array(); - lean_object * values_arr = lean_mk_empty_array(); - for (auto & entry : m_fvar_subst) { - name const & fid = entry.first; - expr fv = mk_fvar(fid); - fvars_arr = lean_array_push(fvars_arr, fv.steal()); - unsigned d = m_depth - entry.second.depth; - expr val = (d == 0) ? entry.second.value : lift_loose_bvars(entry.second.value, d); - values_arr = lean_array_push(values_arr, val.steal()); - } - object_ref result(abstract_mvar_fvars(m_mctx, - mid, array_ref(fvars_arr), array_ref(values_arr))); - return expr(result.steal()); - } - } - - expr visit_fvar(expr const & e) { - name const & fid = fvar_name(e); - if (auto r = lookup_fvar(fid)) { - return *r; - } - return e; - } - -public: - instantiate_mvars_no_update_fn(metavar_ctx & mctx) - : m_mctx(mctx), m_level_fn(mctx), m_depth(0) {} - - expr visit(expr const & e) { - /* When fvar_subst is empty, match the standard instantiateMVars behavior: - only process expressions that have mvars. When fvar_subst is active, - also process expressions that have fvars (for substitution). */ - if (fvar_subst_empty()) { - if (!has_mvar(e)) - return e; - } else { - if (!has_mvar(e) && !has_fvar(e)) - return e; - } - - /* If the expression has no FVars, the result is independent of the current - binder depth and fvar substitution, so we can use the global cache. */ - bool use_global = !has_fvar(e); - bool shared = false; - if (is_shared(e)) { - if (use_global) { - auto it = m_global_cache.find(e.raw()); - if (it != m_global_cache.end()) - return it->second; - } else { - auto it = m_cache.find(e.raw()); - if (it != m_cache.end()) - return it->second; - } - shared = true; - } - - expr r; - switch (e.kind()) { - case expr_kind::BVar: - case expr_kind::Lit: - lean_unreachable(); - case expr_kind::FVar: - return visit_fvar(e); - case expr_kind::Sort: - r = update_sort(e, visit_level(sort_level(e))); - break; - case expr_kind::Const: - r = update_const(e, visit_levels(const_levels(e))); - break; - case expr_kind::MVar: - return visit_mvar(e); - case expr_kind::MData: - r = update_mdata(e, visit(mdata_expr(e))); - break; - case expr_kind::Proj: - r = update_proj(e, visit(proj_expr(e))); - break; - case expr_kind::App: - r = visit_app(e); - break; - case expr_kind::Pi: case expr_kind::Lambda: { - expr d = visit(binding_domain(e)); - /* Clear depth-dependent cache and bump depth for the body */ - lean::unordered_map saved_cache; - saved_cache.swap(m_cache); - m_depth++; - expr b = visit(binding_body(e)); - m_depth--; - saved_cache.swap(m_cache); - r = update_binding(e, d, b); - break; - } - case expr_kind::Let: { - expr t = visit(let_type(e)); - expr v = visit(let_value(e)); - lean::unordered_map saved_cache; - saved_cache.swap(m_cache); - m_depth++; - expr b = visit(let_body(e)); - m_depth--; - saved_cache.swap(m_cache); - r = update_let(e, t, v, b); - break; - } - } - if (shared) { - if (use_global) - m_global_cache.insert(mk_pair(e.raw(), r)); - else - m_cache.insert(mk_pair(e.raw(), r)); - } - return r; - } - - expr operator()(expr const & e) { return visit(e); } -}; - -extern "C" LEAN_EXPORT object * lean_instantiate_expr_mvars_no_update(object * m, object * e) { - metavar_ctx mctx(m); - expr e_new = instantiate_mvars_no_update_fn(mctx)(expr(e)); - object * r = alloc_cnstr(0, 2, 0); - cnstr_set(r, 0, mctx.steal()); - cnstr_set(r, 1, e_new.steal()); - return r; -} -} diff --git a/tests/bench/delayed_assign.lean b/tests/bench/delayed_assign.lean index e53d91425e46..b9ec12a34ff0 100644 --- a/tests/bench/delayed_assign.lean +++ b/tests/bench/delayed_assign.lean @@ -1,6 +1,5 @@ import Lean import Lean.Meta.InstMVarsAll -import Lean.Meta.InstMVarsNU set_option maxHeartbeats 4000000 @@ -51,20 +50,14 @@ partial def bench1 (n : Nat) : MetaM Unit := do let (rDefault, msDefault) ← runImpl n instantiateMVars let (rOriginal, msOriginal) ← runImpl n instantiateMVarsOriginal let (rAll, msAll) ← runImpl n instantiateAllMVars - let (rNUCpp, msNUCpp) ← runImpl n instantiateMVarsNoUpdate - let (rNULean, msNULean) ← runImpl n instantiateMVarsNoUpdateLean -- Verify correctness unless Expr.eqv rDefault rOriginal do IO.println s!"ERROR: instantiateMVars vs Original differ for n={n}" unless Expr.eqv rDefault rAll do IO.println s!"ERROR: instantiateMVars vs AllMVars differ for n={n}" - unless Expr.eqv rDefault rNUCpp do - IO.println s!"ERROR: instantiateMVars vs NoUpdate(C++) differ for n={n}" - unless Expr.eqv rDefault rNULean do - IO.println s!"ERROR: instantiateMVars vs NoUpdate(Lean) differ for n={n}" - IO.println s!"bench1_{n}: Default {msDefault} ms, Original {msOriginal} ms, AllMVars {msAll} ms, NoUpdate(C++) {msNUCpp} ms, NoUpdate(Lean) {msNULean} ms" + IO.println s!"bench1_{n}: Default {msDefault} ms, Original {msOriginal} ms, AllMVars {msAll} ms" run_meta do IO.println "Example (n = 5):" diff --git a/tests/bench/delayed_assign_nu2.lean b/tests/bench/delayed_assign_nu2.lean deleted file mode 100644 index 7de0e6d5da20..000000000000 --- a/tests/bench/delayed_assign_nu2.lean +++ /dev/null @@ -1,315 +0,0 @@ -import Lean -import Lean.Meta.InstMVarsAll -import Lean.Meta.InstMVarsNU - -set_option maxHeartbeats 4000000 - -open Lean Meta - -def mkLE (i : Nat) : Expr := - mkNatLE (mkNatLit 0) (mkBVar i) - -partial def solve (mvarId : MVarId) : MetaM Unit := do - let type ← instantiateMVars (← mvarId.getType) - if type.isForall then - let (_, mvarId) ← mvarId.intro1 - solve mvarId - else if type.isAppOf ``And then - let [mvarId₁, mvarId₂] ← mvarId.applyConst ``And.intro | failure - solve mvarId₁ - solve mvarId₂ - else if type.isAppOf ``LE.le then - let [] ← mvarId.applyConst ``Nat.zero_le | failure - else - let [] ← mvarId.applyConst ``True.intro | failure - -/-! ## instantiateMVarsNoUpdate2: bvar-blind cache variant - -Experimental variant of `instantiateMVarsNoUpdate` that keeps its cache across binder depths -using bvar-blind keying: expressions are hashed and compared while treating all `.bvar` indices -as equal. When a cache hit is found at a different depth, we verify the stored key shifted by the -depth difference equals the current expression, then lift the cached result accordingly. - -### Analysis - -This trades constant per-node overhead (BVarBlindKey allocation, double cache lookup for the -shouldInsert check, richer cache entries) for better depth scaling: the old approach clears the -cache at each binder depth, giving O(depth * bodySize) when the same closed sub-expression -appears at many depths. This variant caches it once and reuses, giving O(bodySize + depth). - -Benchmark results (bench2, body=500): - -| depth | instantiateMVars | NoUpdate (old) | NoUpdate2 (new) | -|-------|-----------------|----------------|-----------------| -| 1 | 0.15 ms | 0.52 ms | 7.2 ms | -| 10 | 0.12 ms | 1.80 ms | 5.8 ms | -| 50 | 0.11 ms | 8.39 ms | 5.9 ms | -| 100 | 0.12 ms | 17.63 ms | 6.3 ms | -| 500 | 0.18 ms | 103.23 ms | 7.8 ms | - -On the bench1 workload (delayed assignments from tactics, the realistic case), NoUpdate2 is -3-5x slower than NoUpdate, and both are already sub-millisecond. The ~6ms constant overhead makes -NoUpdate2 worse for shallow depths (crossover ~depth=50). The optimization only pays off for -pathological cases with many nested binders and large shared closed sub-expressions. - -**Conclusion:** Not worth adopting as a replacement for the current cache-clearing approach. --/ - -namespace InstMVarsNU2 - -structure DelayedLift where - originalDepth : Nat - expr : Expr - -private partial def bvarBlindEq (e₁ e₂ : Expr) : Bool := - if e₁.looseBVarRange == 0 && e₂.looseBVarRange == 0 then Expr.equal e₁ e₂ - else match e₁, e₂ with - | .bvar _, .bvar _ => true - | .app f₁ a₁, .app f₂ a₂ => bvarBlindEq f₁ f₂ && bvarBlindEq a₁ a₂ - | .forallE _ d₁ b₁ bi₁, .forallE _ d₂ b₂ bi₂ => - bi₁ == bi₂ && bvarBlindEq d₁ d₂ && bvarBlindEq b₁ b₂ - | .lam _ d₁ b₁ bi₁, .lam _ d₂ b₂ bi₂ => - bi₁ == bi₂ && bvarBlindEq d₁ d₂ && bvarBlindEq b₁ b₂ - | .letE _ t₁ v₁ b₁ nd₁, .letE _ t₂ v₂ b₂ nd₂ => - nd₁ == nd₂ && bvarBlindEq t₁ t₂ && bvarBlindEq v₁ v₂ && bvarBlindEq b₁ b₂ - | .mdata d₁ b₁, .mdata d₂ b₂ => d₁ == d₂ && bvarBlindEq b₁ b₂ - | .proj n₁ i₁ b₁, .proj n₂ i₂ b₂ => n₁ == n₂ && i₁ == i₂ && bvarBlindEq b₁ b₂ - | _, _ => false - -structure BVarBlindKey where - expr : Expr - bbHash : UInt64 - -instance : Hashable BVarBlindKey where - hash k := k.bbHash - -instance : BEq BVarBlindKey where - beq k₁ k₂ := k₁.bbHash == k₂.bbHash && bvarBlindEq k₁.expr k₂.expr - -structure CacheEntry where - depth : Nat - key : Expr - result : Expr - -structure Context where - depth : Nat := 0 - fvarSubst : PersistentHashMap FVarId DelayedLift := {} - -structure State where - cache : Std.HashMap BVarBlindKey CacheEntry := {} - -abbrev M := ReaderT Context (StateRefT State MetaM) - -/-- Compute bvar-blind hash. For closed sub-expressions, uses precomputed hash. -/ -private def bbHash (e : Expr) : UInt64 := - -- For this benchmark, all cross-depth expressions are closed, so just use regular hash. - -- A full implementation would ignore bvar indices. - e.hash - -def withLift (n : Nat) (k : M α) : M α := - withTheReader Context (fun ctx => - { ctx with depth := ctx.depth + n }) k - -def withSubst (fvarIds : Array Expr) (args : Array Expr) : M α → M α := - withTheReader Context fun ctx => Id.run do - let mut mvarSubst := ctx.fvarSubst - for fvarId in fvarIds, arg in args do - mvarSubst := mvarSubst.erase fvarId.fvarId! |>.insert fvarId.fvarId! ⟨ctx.depth, arg⟩ - { ctx with fvarSubst := mvarSubst } - -def lookup? (fvarId : FVarId) : M (Option Expr) := do - let ctx ← read - match ctx.fvarSubst.find? fvarId with - | some ds => return ds.expr.liftLooseBVars (s := 0) (d := ctx.depth - ds.originalDepth) - | none => return none - -partial def go (e : Expr) : M Expr := do - unless e.hasMVar || e.hasFVar do - return e - - let depth := (← read).depth - let key := BVarBlindKey.mk e (bbHash e) - if let some entry := (← get).cache[key]? then - if entry.depth ≤ depth then - let k := depth - entry.depth - let rangeOk := if entry.key.looseBVarRange == 0 then - e.looseBVarRange == 0 - else - e.looseBVarRange == entry.key.looseBVarRange + k - if rangeOk then - if k == 0 then - if Expr.equal entry.key e then return entry.result - else - if entry.key.liftLooseBVars 0 k == e then - return entry.result.liftLooseBVars 0 k - - let goApp (e : Expr) := e.withApp fun f args => do - let args ← args.mapM go - let instArgs (f' : Expr) : M Expr := do - pure (mkAppN f' args) - let instApp : M Expr := do - let wasMVar := f.isMVar - let f' ← go f - if wasMVar && f'.isLambda then - go (f'.betaRev args.reverse (useZeta := true)) - else - instArgs f' - if let .mvar mvarId := f then - let some {fvars, mvarIdPending} ← getDelayedMVarAssignment? mvarId | return ← instApp - if fvars.size > args.size then - return ← instArgs f - let substArgs := args[:fvars.size].copy - let extraArgs := args[fvars.size:].copy - let newVal ← withSubst fvars substArgs <| - go (mkMVar mvarIdPending) - let result := mkAppN newVal extraArgs - return result - instApp - - let e' ← match e with - | .bvar .. | .lit .. => unreachable! - | .proj _ _ s => return e.updateProj! (← go s) - | .forallE _ d b _ => return e.updateForallE! (← go d) (← withLift 1 <| go b) - | .lam _ d b _ => return e.updateLambdaE! (← go d) (← withLift 1 <| go b) - | .letE _ t v b nd => return e.updateLet! (← go t) (← go v) (← withLift 1 <| go b) nd - | .const _ lvls => return e.updateConst! (← lvls.mapM (instantiateLevelMVars ·)) - | .sort lvl => return e.updateSort! (← instantiateLevelMVars lvl) - | .mdata _ b => return e.updateMData! (← go b) - | .fvar fvarId => return (← lookup? fvarId).getD e - | .app .. => goApp e - | .mvar mvarId => - match (← getExprMVarAssignment? mvarId) with - | some newE => - let newE ← go newE - return newE - | none => pure e - let shouldInsert := match (← get).cache[key]? with - | none => true - | some entry => depth ≤ entry.depth - if shouldInsert then - modify fun s => { s with cache := s.cache.insert key ⟨depth, e, e'⟩ } - return e' - -end InstMVarsNU2 - -def _root_.Lean.Meta.instantiateMVarsNoUpdate2 (e : Expr) : MetaM Expr := do - (InstMVarsNU2.go e).run {} |>.run' {} - -/-! ## Benchmark 1: delayed assignments (from original benchmark) -/ - -def mkBench1 (n : Nat) : MetaM MVarId := do - let type := mkType n - return (← mkFreshExprSyntheticOpaqueMVar type).mvarId! -where - mkResultType (i : Nat) : Expr := - match i with - | 0 => mkConst ``True - | i+1 => mkAnd (mkLE i) (mkResultType i) - - mkType (i : Nat) : Expr := - match i with - | 0 => mkResultType n - | i+1 => .forallE `x Nat.mkType (mkAnd (mkType i) (mkLE (n - i - 1))) .default - -/-- Run a single implementation on a fresh copy of the benchmark, return (result, time_ms). -/ -def runImpl (n : Nat) (f : Expr → MetaM Expr) : MetaM (Expr × Float) := do - let mvarId ← mkBench1 n - solve mvarId - let t0 ← IO.monoNanosNow - let r ← f (mkMVar mvarId) - let t1 ← IO.monoNanosNow - let ms := (t1 - t0).toFloat / 1000000.0 - return (r, ms) - -partial def bench1 (n : Nat) : MetaM Unit := do - let (rDefault, msDefault) ← runImpl n instantiateMVars - let (rOriginal, msOriginal) ← runImpl n instantiateMVarsOriginal - let (rAll, msAll) ← runImpl n instantiateAllMVars - let (rNUCpp, msNUCpp) ← runImpl n instantiateMVarsNoUpdate - let (rNULean, msNULean) ← runImpl n instantiateMVarsNoUpdateLean - let (rNU2, msNU2) ← runImpl n instantiateMVarsNoUpdate2 - - -- Verify correctness - unless Expr.eqv rDefault rOriginal do - IO.println s!"ERROR: instantiateMVars vs Original differ for n={n}" - unless Expr.eqv rDefault rAll do - IO.println s!"ERROR: instantiateMVars vs AllMVars differ for n={n}" - unless Expr.eqv rDefault rNUCpp do - IO.println s!"ERROR: instantiateMVars vs NoUpdate(C++) differ for n={n}" - unless Expr.eqv rDefault rNULean do - IO.println s!"ERROR: instantiateMVars vs NoUpdate(Lean) differ for n={n}" - unless Expr.eqv rDefault rNU2 do - IO.println s!"ERROR: instantiateMVars vs NoUpdate2 differ for n={n}" - - IO.println s!"bench1_{n}: instantiateMVars {msDefault} ms, Original {msOriginal} ms, AllMVars {msAll} ms, NoUpdate(C++) {msNUCpp} ms, NoUpdate(Lean) {msNULean} ms, NoUpdate2 {msNU2} ms" - -/-! ## Benchmark 2: shared closed sub-expression across binder depths - -A closed sub-expression containing mvars appears as the domain of nested foralls. -The old NoUpdate clears its cache at each depth, recomputing the sub-expression each time. -The new NoUpdate2 caches it once and reuses across depths. - -Structure: `∀ _ : bigExpr, ∀ _ : bigExpr, ... bigExpr` -where `bigExpr = (?m₁ ≤ 1) ∧ (?m₂ ≤ 1) ∧ ... ∧ True` with each `?mᵢ := 0`. --/ - -/-- Create a conjunction of `size` mvar lookups. -/ -def mkMVarConj (size : Nat) : MetaM Expr := do - let mut expr := mkConst ``True - for _ in [:size] do - let mvar ← mkFreshExprMVar (mkConst ``Nat) - mvar.mvarId!.assign (mkNatLit 0) - expr := mkAnd (mkNatLE mvar (mkNatLit 1)) expr - return expr - -/-- Nest `bigExpr` under `depth` forall binders (also used as each domain). -/ -def mkNestedForall (bigExpr : Expr) (depth : Nat) : Expr := Id.run do - let mut term := bigExpr - for _ in [:depth] do - term := .forallE `x bigExpr term .default - return term - -def timeMs (f : MetaM α) : MetaM (α × Float) := do - let t0 ← IO.monoNanosNow - let r ← f - let t1 ← IO.monoNanosNow - return (r, (t1 - t0).toFloat / 1000000.0) - -def bench2 (depth : Nat) (bodySize : Nat) : MetaM Unit := do - -- Each test gets fresh mvars to avoid cross-contamination from instantiateMVars updating assignments - let bigExpr1 ← mkMVarConj bodySize - let (_, instMs) ← timeMs (instantiateMVars (mkNestedForall bigExpr1 depth)) - - let bigExpr1b ← mkMVarConj bodySize - let (_, origMs) ← timeMs (instantiateMVarsOriginal (mkNestedForall bigExpr1b depth)) - - let bigExpr1c ← mkMVarConj bodySize - let (_, allMs) ← timeMs (instantiateAllMVars (mkNestedForall bigExpr1c depth)) - - let bigExpr2 ← mkMVarConj bodySize - let (_, nuMs) ← timeMs (instantiateMVarsNoUpdate (mkNestedForall bigExpr2 depth)) - - let bigExpr2b ← mkMVarConj bodySize - let (_, nuLeanMs) ← timeMs (instantiateMVarsNoUpdateLean (mkNestedForall bigExpr2b depth)) - - let bigExpr3 ← mkMVarConj bodySize - let (_, nu2Ms) ← timeMs (instantiateMVarsNoUpdate2 (mkNestedForall bigExpr3 depth)) - - IO.println s!"bench2 depth={depth} body={bodySize}: instantiateMVars {instMs} ms, Original {origMs} ms, AllMVars {allMs} ms, NoUpdate(C++) {nuMs} ms, NoUpdate(Lean) {nuLeanMs} ms, NoUpdate2 {nu2Ms} ms" - -/-! ## Run benchmarks -/ - -run_meta do - IO.println "=== Bench 1: delayed assignments ===" - for i in [10, 20, 40, 80, 100, 200, 300, 400, 500] do - bench1 i - - IO.println "" - IO.println "=== Bench 2: shared closed sub-expr across depths (body=500, varying depth) ===" - for d in [1, 10, 50, 100, 200, 500] do - bench2 d 500 - - IO.println "" - IO.println "=== Bench 2: shared closed sub-expr across depths (depth=100, varying body) ===" - for b in [100, 500, 1000, 2000] do - bench2 100 b diff --git a/tests/lean/run/instMVarsNUDelayed.lean b/tests/lean/run/instMVarsNUDelayed.lean deleted file mode 100644 index e118593355de..000000000000 --- a/tests/lean/run/instMVarsNUDelayed.lean +++ /dev/null @@ -1,133 +0,0 @@ -import Lean -/-! -# Tests for `instantiateMVarsNoUpdate` handling of unassigned metavariables - -These tests verify that `instantiateMVarsNoUpdate` correctly creates delayed-assigned -metavariables when encountering unassigned mvars with an active fvar substitution. -This is essential for using `instantiateMVarsNoUpdate` as a drop-in replacement -for `instantiateMVars` in contexts like `mkForallFVars`/`mkLambdaFVars`. --/ - -open Lean Meta - -/-- Test 1: Basic delayed assignment with unassigned pending mvar. - -When `?m [x] := ?pending` and `?pending` is unassigned, -`instantiateMVarsNoUpdate` should create a new delayed mvar -that captures the fvar substitution. -/ -def test1 : MetaM Unit := do - withLocalDecl `x .default (mkConst ``Nat) fun x => do - let pending ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) - let m ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) - assignDelayedMVar m.mvarId! #[x] pending.mvarId! - - -- Apply ?m to a concrete value (not x) - let e := mkApp m (mkNatLit 7) - - -- instantiateMVarsNoUpdate should produce an expression without fvar x - let e_nu ← instantiateMVarsNoUpdate e - unless !e_nu.hasFVar do - throwError "test1: result should not have free variables, got {← ppExpr e_nu}" - - -- Assign pending to x + 1 - pending.mvarId!.assign (mkApp2 (mkConst ``Nat.add) x (mkNatLit 1)) - - -- After assignment, both should resolve to the same thing - let e_nu_final ← instantiateMVars e_nu - let e_inst_final ← instantiateMVars e - unless Expr.eqv e_nu_final e_inst_final do - throwError "test1: results differ:\n NoUpdate: {← ppExpr e_nu_final}\n instantiate: {← ppExpr e_inst_final}" - -/-- Test 2: Nested delayed assignments. - -`?outer [x, y] := ?inner`, `?inner` assigned to expression containing `?leaf`, -where `?leaf` is unassigned. -/ -def test2 : MetaM Unit := do - withLocalDecl `x .default (mkConst ``Nat) fun x => do - withLocalDecl `y .default (mkConst ``Nat) fun y => do - let leaf ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) - let inner ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) - -- inner := leaf (a simple assignment) - inner.mvarId!.assign leaf - - let outer ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) - assignDelayedMVar outer.mvarId! #[x, y] inner.mvarId! - - -- Apply outer to concrete values - let e := mkApp (mkApp outer (mkNatLit 3)) (mkNatLit 5) - - let e_nu ← instantiateMVarsNoUpdate e - unless !e_nu.hasFVar do - throwError "test2: result should not have free variables, got {e_nu}" - - -- Assign leaf to x + y - leaf.mvarId!.assign (mkApp2 (mkConst ``Nat.add) x y) - - let e_nu_final ← instantiateMVars e_nu - let e_inst_final ← instantiateMVars e - unless Expr.eqv e_nu_final e_inst_final do - throwError "test2: results differ:\n NoUpdate: {← ppExpr e_nu_final}\n instantiate: {← ppExpr e_inst_final}" - -/-- Test 3: Multiple unassigned mvars under a single delayed assignment. -/ -def test3 : MetaM Unit := do - withLocalDecl `x .default (mkConst ``Nat) fun x => do - let m1 ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) - let m2 ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) - - -- inner is assigned to And.intro m1 m2 - let innerType := mkApp2 (mkConst ``And) (mkConst ``Nat) (mkConst ``Nat) - let inner ← mkFreshExprSyntheticOpaqueMVar innerType - inner.mvarId!.assign (mkApp4 (mkConst ``And.intro [.zero, .zero]) (mkConst ``Nat) (mkConst ``Nat) m1 m2) - - let outer ← mkFreshExprSyntheticOpaqueMVar innerType - assignDelayedMVar outer.mvarId! #[x] inner.mvarId! - - let e := mkApp outer (mkNatLit 10) - - let e_nu ← instantiateMVarsNoUpdate e - unless !e_nu.hasFVar do - throwError "test3: result should not have free variables, got {e_nu}" - - -- Assign both mvars - m1.mvarId!.assign x - m2.mvarId!.assign (mkApp2 (mkConst ``Nat.add) x (mkNatLit 1)) - - let e_nu_final ← instantiateMVars e_nu - let e_inst_final ← instantiateMVars e - unless Expr.eqv e_nu_final e_inst_final do - throwError "test3: results differ:\n NoUpdate: {← ppExpr e_nu_final}\n instantiate: {← ppExpr e_inst_final}" - -/-- Test 4: Unassigned mvar whose context does NOT include the substituted fvar. -/ -def test4 : MetaM Unit := do - withLocalDecl `x .default (mkConst ``Nat) fun x => do - withLocalDecl `y .default (mkConst ``Nat) fun y => do - -- Create pending mvar in full context (has both x and y) - let pending ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) - - -- Delayed assignment only over x - let outer ← mkFreshExprSyntheticOpaqueMVar (mkConst ``Nat) - assignDelayedMVar outer.mvarId! #[x] pending.mvarId! - - let e := mkApp outer (mkNatLit 42) - - let e_nu ← instantiateMVarsNoUpdate e - - -- Assign pending to y (doesn't use x, the substituted fvar) - pending.mvarId!.assign y - - let e_nu_final ← instantiateMVars e_nu - let e_inst_final ← instantiateMVars e - unless Expr.eqv e_nu_final e_inst_final do - throwError "test4: results differ:\n NoUpdate: {← ppExpr e_nu_final}\n instantiate: {← ppExpr e_inst_final}" - --- Run all tests -run_meta do - test1 - IO.println "test1 passed" - test2 - IO.println "test2 passed" - test3 - IO.println "test3 passed" - test4 - IO.println "test4 passed" - IO.println "All tests passed!" From 56aa64e15c75ce5d281cf3f966769f2cf71d5c73 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 09:07:41 +0000 Subject: [PATCH 30/45] refactor: extract scope-aware cache into scope_cache.h This extracts the pass-2 scope-aware expression cache from instantiate_delayed_fn into a self-contained scope_cache class in scope_cache.h with push/pop/lookup/insert operations. The caller provides the result_scope when inserting. --- src/kernel/instantiate_mvars_all.cpp | 145 ++++---------------------- src/kernel/scope_cache.h | 146 +++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 128 deletions(-) create mode 100644 src/kernel/scope_cache.h diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index cefe7baebef6..cfa44073fd54 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -5,7 +5,6 @@ Released under Apache 2.0 license as described in the file LICENSE. Authors: Joachim Breitner */ #include -#include #include #include "util/name_set.h" #include "util/name_hash_map.h" @@ -13,6 +12,7 @@ Authors: Joachim Breitner #include "runtime/array_ref.h" #include "kernel/instantiate.h" #include "kernel/expr.h" +#include "kernel/scope_cache.h" /* This module provides a two-pass variant of `instantiateMVars` with improved sharing. @@ -418,14 +418,6 @@ class resolvability_checker { /* ============================================================================ Pass 2: Resolve delayed assignments with fused fvar substitution. Direct mvar chains have been pre-resolved by pass 1. - - Uses a flat (ptr, depth)-keyed cache with lazy staleness detection. - Each visit_delayed scope gets a unique generation number. Scope - generations are stored in a persistent linked list (arena-allocated - for pointer stability). Cache entries store a single snapshot of the - scope_gens list at insert time. On lookup, entries are lazily degraded - by walking the snapshot and current lists in lockstep until a valid - scope level is found or the entry is evicted. ============================================================================ */ struct fvar_subst_entry { @@ -434,44 +426,20 @@ struct fvar_subst_entry { expr value; }; -/* Persistent linked-list node for scope generations. - Allocated from an arena (std::deque) for pointer stability. */ -struct scope_gen_node { - unsigned gen; - scope_gen_node * tail; /* parent scope, or nullptr for scope -1 */ -}; - class instantiate_delayed_fn { - struct key_hasher { - std::size_t operator()(std::pair const & p) const { - return hash((size_t)p.first >> 3, p.second); - } - }; - - struct cache_entry { - expr result; - unsigned scope_level; /* scope at which this entry is (currently) valid */ - scope_gen_node * scope_gens; /* snapshot of scope_gens list at store time */ - unsigned result_scope; /* original m_result_scope for propagation */ - }; - - typedef lean::unordered_map, - std::vector, key_hasher> flat_cache; - metavar_ctx & m_mctx; name_set const & m_resolvable_delayed; name_hash_map m_fvar_subst; unsigned m_depth; - /* Flat cache mapping (ptr, depth) to a stack of entries ordered by scope - (innermost/highest scope at the back). */ - flat_cache m_cache; - /* Persistent linked list of scope generations for O(1) snapshot copies. - Arena (deque) provides pointer stability across push_back. */ - std::deque m_gen_arena; - scope_gen_node * m_scope_gens_list; - unsigned m_gen_counter; - unsigned m_scope; + /* Scope-aware cache for (ptr, depth) → expr with lazy staleness detection. */ + struct key_hasher { + std::size_t operator()(std::pair const & p) const { + return hash((size_t)p.first >> 3, p.second); + } + }; + typedef std::pair cache_key; + scope_cache m_cache; /* After visit() returns, this holds the maximum fvar-substitution scope that contributed to the result — i.e., the outermost scope at which the @@ -504,73 +472,6 @@ class instantiate_delayed_fn { return optional(lift_loose_bvars(it->second.value, d)); } - optional cache_lookup(lean_object * ptr) { - auto key = mk_pair(ptr, m_depth); - auto it = m_cache.find(key); - if (it == m_cache.end()) return {}; - auto & stack = it->second; - /* Entries store a persistent scope_gens snapshot. On lookup, degrade - entries whose top scopes have been popped or re-entered, walking - the snapshot and current lists in lockstep. */ - while (!stack.empty()) { - auto & top = stack.back(); - /* Degrade: if scope_level > m_scope, those scopes were popped. */ - while (top.scope_level > m_scope) { - top.scope_gens = top.scope_gens->tail; - top.scope_level--; - } - /* If the result depends on a scope that has been popped, the - entry is invalid — discard it. */ - if (top.result_scope > m_scope) { - stack.pop_back(); - continue; - } - /* Now scope_level <= m_scope. */ - if (top.scope_level == m_scope) { - /* Compare generation at this level. */ - if (top.scope_gens->gen == m_scope_gens_list->gen) { - m_result_scope = std::max(m_result_scope, top.result_scope); - return optional(top.result); - } - /* Generation mismatch: scope was re-entered. Walk both - lists down in lockstep until we find a valid level or - exhaust to result_scope. */ - scope_gen_node * entry_node = top.scope_gens; - scope_gen_node * current_node = m_scope_gens_list; - unsigned level = top.scope_level; - while (level > top.result_scope) { - entry_node = entry_node->tail; - current_node = current_node->tail; - level--; - if (entry_node->gen == current_node->gen) { - /* Valid at this level. Update entry in place. */ - top.scope_level = level; - top.scope_gens = entry_node; - /* level < m_scope → valid but at lower scope → miss */ - return {}; - } - } - /* Reached result_scope without finding valid level → delete. */ - stack.pop_back(); - continue; - } - /* scope_level < m_scope: valid but at a lower scope — miss */ - return {}; - } - return {}; - } - - void cache_insert(lean_object * ptr, expr const & result) { - auto key = mk_pair(ptr, m_depth); - auto & stack = m_cache[key]; - /* Single entry with scope_gens snapshot. On later lookup, the - entry is lazily degraded by walking the snapshot list. */ - while (!stack.empty() && stack.back().scope_level >= m_result_scope) { - stack.pop_back(); - } - stack.push_back({result, m_scope, m_scope_gens_list, m_result_scope}); - } - /* Get a direct mvar assignment. Visit it to resolve delayed mvars and apply the fvar substitution. When fvar_subst is empty, normalize and write back the result to @@ -648,11 +549,11 @@ class instantiate_delayed_fn { size_t fvar_count = fvars.size(); size_t extra_count = args.size() - fvar_count; - /* Save and extend the fvar substitution. */ + /* Push a new scope and extend the fvar substitution. */ + m_cache.push(); struct saved_entry { name key; bool had_old; fvar_subst_entry old; }; std::vector saved_entries; saved_entries.reserve(fvar_count); - m_scope++; for (size_t i = 0; i < fvar_count; i++) { name const & fid = fvar_name(fvars[i]); auto old_it = m_fvar_subst.find(fid); @@ -661,21 +562,13 @@ class instantiate_delayed_fn { } else { saved_entries.push_back({fid, false, {0, 0, expr()}}); } - m_fvar_subst[fid] = {m_depth, m_scope, args[args.size() - 1 - i]}; + m_fvar_subst[fid] = {m_depth, m_cache.scope(), args[args.size() - 1 - i]}; } - /* Push: bump generation so stale entries at this scope level are detected. - Prepend a new node to the persistent scope_gens list. */ - m_gen_counter++; - m_gen_arena.push_back({m_gen_counter, m_scope_gens_list}); - m_scope_gens_list = &m_gen_arena.back(); - expr val_new = visit(mk_mvar(mid_pending)); - /* Pop: follow the tail of the persistent list back to the parent scope. - Stale entries are detected by generation mismatch on lookup. */ - m_scope--; - m_scope_gens_list = m_scope_gens_list->tail; + /* Pop scope; stale entries are detected by generation mismatch on lookup. */ + m_cache.pop(); /* Restore the fvar substitution. */ for (auto & se : saved_entries) { @@ -753,11 +646,7 @@ class instantiate_delayed_fn { public: instantiate_delayed_fn(metavar_ctx & mctx, name_set const & resolvable_delayed) : m_mctx(mctx), m_resolvable_delayed(resolvable_delayed), - m_depth(0), m_scope_gens_list(nullptr), - m_gen_counter(0), m_scope(0), m_result_scope(0) { - m_gen_arena.push_back({0, nullptr}); - m_scope_gens_list = &m_gen_arena.back(); - } + m_depth(0), m_result_scope(0) {} expr visit(expr const & e) { if (fvar_subst_empty()) { @@ -776,7 +665,7 @@ class instantiate_delayed_fn { if (it != m_global_cache.end()) return it->second; } else { - if (auto r = cache_lookup(e.raw())) + if (auto r = m_cache.lookup(cache_key(e.raw(), m_depth), m_result_scope)) return *r; } shared = true; @@ -837,7 +726,7 @@ class instantiate_delayed_fn { if (use_global) m_global_cache.insert(mk_pair(e.raw(), r)); else - cache_insert(e.raw(), r); + m_cache.insert(cache_key(e.raw(), m_depth), r, m_result_scope); } done: diff --git a/src/kernel/scope_cache.h b/src/kernel/scope_cache.h new file mode 100644 index 000000000000..85749ca411ff --- /dev/null +++ b/src/kernel/scope_cache.h @@ -0,0 +1,146 @@ +/* +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. + +Authors: Joachim Breitner +*/ +#pragma once +#include +#include +#include "util/alloc.h" +#include "runtime/optional.h" + +namespace lean { + +/* +A scope-aware cache for use in traversals that carry a substitution +through nested scopes (e.g., delayed mvar resolution). + +Supports push/pop of scopes and caches Key → Value results. +Each scope gets a unique generation number. Cache entries store a +single snapshot of the scope generation list (a persistent linked +list) at insert time. On lookup, entries are lazily degraded by +walking the snapshot and current lists in lockstep, evicting entries +whose result depends on popped or re-entered scopes. + +The caller maintains a `result_scope` watermark: the maximum scope +level that contributed to a cached result. On insert, the caller +provides its `result_scope`; on lookup hit, the stored `result_scope` +is propagated back via a reference parameter. +*/ +template> +class scope_cache { + struct scope_gen_node { + unsigned gen; + scope_gen_node * tail; /* parent scope, or nullptr for scope -1 */ + }; + + struct cache_entry { + Value result; + unsigned scope_level; /* scope at which this entry is (currently) valid */ + scope_gen_node * scope_gens; /* snapshot of scope_gens list at store time */ + unsigned result_scope; /* maximum scope that contributed to the result */ + }; + + typedef lean::unordered_map, Hash> cache_map; + + cache_map m_cache; + std::deque m_gen_arena; + scope_gen_node * m_scope_gens_list; + unsigned m_gen_counter; + unsigned m_scope; + +public: + scope_cache() : m_scope_gens_list(nullptr), m_gen_counter(0), m_scope(0) { + m_gen_arena.push_back({0, nullptr}); + m_scope_gens_list = &m_gen_arena.back(); + } + + unsigned scope() const { return m_scope; } + + /* Enter a new scope. Bumps the generation counter so that stale + entries at the new scope level are detected on lookup. */ + void push() { + m_scope++; + m_gen_counter++; + m_gen_arena.push_back({m_gen_counter, m_scope_gens_list}); + m_scope_gens_list = &m_gen_arena.back(); + } + + /* Leave the current scope. Follows the tail of the persistent + generation list back to the parent scope. */ + void pop() { + m_scope--; + m_scope_gens_list = m_scope_gens_list->tail; + } + + /* Lazily clean up the top of a per-key entry stack: degrade entries + whose scopes were popped and evict entries that are stale due to + popped result scopes or scope re-entry. After rewind, either the + stack is empty or its top entry satisfies scope_level <= m_scope + with a matching scope generation. */ + void rewind(std::vector & stack) { + while (!stack.empty()) { + auto & top = stack.back(); + /* Discard entries whose result depends on popped scopes. */ + if (top.result_scope > m_scope) { + stack.pop_back(); + continue; + } + /* Degrade: follow tail pointers for scopes that were popped. */ + while (top.scope_level > m_scope) { + top.scope_gens = top.scope_gens->tail; + top.scope_level--; + } + /* scope_level < m_scope: entry is at a lower scope, stop. */ + if (top.scope_level < m_scope) return; + /* scope_level == m_scope: check generation. */ + if (top.scope_gens->gen == m_scope_gens_list->gen) return; + /* Generation mismatch: scope was re-entered. Walk both lists + in lockstep to find a valid level or exhaust to result_scope. */ + scope_gen_node * entry_node = top.scope_gens; + scope_gen_node * current_node = m_scope_gens_list; + unsigned level = top.scope_level; + while (level > top.result_scope) { + entry_node = entry_node->tail; + current_node = current_node->tail; + level--; + if (entry_node->gen == current_node->gen) { + top.scope_level = level; + top.scope_gens = entry_node; + return; /* now scope_level < m_scope */ + } + } + /* No valid level found → discard. */ + stack.pop_back(); + } + } + + /* Look up a cached result for the given key at the current scope. + On hit, updates `result_scope = max(result_scope, entry.result_scope)` + and returns the cached result. On miss, returns none. */ + optional lookup(Key const & key, unsigned & result_scope) { + auto it = m_cache.find(key); + if (it == m_cache.end()) return {}; + auto & stack = it->second; + rewind(stack); + if (stack.empty()) return {}; + auto & top = stack.back(); + if (top.scope_level != m_scope) return {}; + result_scope = std::max(result_scope, top.result_scope); + return optional(top.result); + } + + /* Insert a result for the given key at the current scope. + `result_scope` is the maximum scope that contributed to the result; + the entry is only valid when all scopes up to result_scope are unchanged. */ + void insert(Key const & key, Value const & result, unsigned result_scope) { + auto & stack = m_cache[key]; + while (!stack.empty() && stack.back().scope_level >= result_scope) { + stack.pop_back(); + } + stack.push_back({result, m_scope, m_scope_gens_list, result_scope}); + } +}; + +} From 3c17c8c1b0792b708f59ab283f03bbfddba30cd5 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 10:32:00 +0000 Subject: [PATCH 31/45] perf: reuse cached values across scopes in scope_cache insert On insert, rewind stale entries and check if the top entry has the same result_scope. If so, reuse its value instead of storing a fresh copy, improving expression sharing across scope re-entries. The caller uses the returned reference to benefit from the sharing. Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 2 +- src/kernel/scope_cache.h | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index cfa44073fd54..489c4a2ce611 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -726,7 +726,7 @@ class instantiate_delayed_fn { if (use_global) m_global_cache.insert(mk_pair(e.raw(), r)); else - m_cache.insert(cache_key(e.raw(), m_depth), r, m_result_scope); + r = m_cache.insert(cache_key(e.raw(), m_depth), r, m_result_scope); } done: diff --git a/src/kernel/scope_cache.h b/src/kernel/scope_cache.h index 85749ca411ff..b9216d4a3f6c 100644 --- a/src/kernel/scope_cache.h +++ b/src/kernel/scope_cache.h @@ -133,13 +133,21 @@ class scope_cache { /* Insert a result for the given key at the current scope. `result_scope` is the maximum scope that contributed to the result; - the entry is only valid when all scopes up to result_scope are unchanged. */ - void insert(Key const & key, Value const & result, unsigned result_scope) { + the entry is only valid when all scopes up to result_scope are unchanged. + If a valid entry with the same `result_scope` already exists, its value + is reused for sharing; the returned reference is the stored value. */ + Value const & insert(Key const & key, Value const & result, unsigned result_scope) { auto & stack = m_cache[key]; + rewind(stack); + Value shared = result; + if (!stack.empty() && stack.back().result_scope == result_scope) { + shared = stack.back().result; + } while (!stack.empty() && stack.back().scope_level >= result_scope) { stack.pop_back(); } - stack.push_back({result, m_scope, m_scope_gens_list, result_scope}); + stack.push_back({std::move(shared), m_scope, m_scope_gens_list, result_scope}); + return stack.back().result; } }; From 336c060a5b2a57797131814271d6cd741e3c4ab2 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 10:47:53 +0000 Subject: [PATCH 32/45] test: add cross-scope sharing test for scope_cache insert Test that the scope_cache insert optimization reuses cached values across scopes when the result_scope matches. A shared expression `Nat.succ x_fvar` is visited at scope 1 and then at scope 2 (inside a nested delayed mvar). With the optimization, both visits return the same object (ptrEq). Verified that disabling the optimization makes the test fail with ptrEq = false. Co-Authored-By: Claude Opus 4.6 --- .../run/instantiateAllMVarsCrossScope.lean | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/lean/run/instantiateAllMVarsCrossScope.lean diff --git a/tests/lean/run/instantiateAllMVarsCrossScope.lean b/tests/lean/run/instantiateAllMVarsCrossScope.lean new file mode 100644 index 000000000000..64245f6049af --- /dev/null +++ b/tests/lean/run/instantiateAllMVarsCrossScope.lean @@ -0,0 +1,99 @@ +import Lean +import Lean.Meta.InstMVarsAll + +open Lean Meta + +/- +Test: cross-scope sharing in scope_cache insert. + +A shared expression `succ_x := Nat.succ x_fvar` is visited at scope 1 +(as d2's argument, before scope 2 is pushed) and then at scope 2 +(inside d2's pending value). The insert optimization should reuse the +scope-1 result when inserting at scope 2, since result_scope=1 and +scope 1 hasn't changed. + + ?root := fun (a : Nat) => ?d1 a + ?d1 delayed [x] := ?body + ?body := ?d2 succ_x ← succ_x visited at scope 1 as d2's arg + ?d2 delayed [z] := ?inner + ?inner := Prod.mk z succ_x ← z = R1, succ_x visited at scope 2 + +The ordering guarantee comes from visit_delayed's control flow: args +are visited before pushing the new scope, the pending value is visited +after. This does not depend on the order in which application arguments +are traversed. + +Expected result: fun (a : Nat) => (Nat.succ a, Nat.succ a) + +With insert sharing, both Nat.succ a subexpressions in the result are +the same object (ptrEq). Without it, they are structurally equal but +distinct objects. +-/ + +private def mkCrossScopeTest : MetaM Expr := do + let nat := mkConst ``Nat + withLocalDeclD `x nat fun x_fvar => + withLocalDeclD `z nat fun z_fvar => do + let succ_x := mkApp (mkConst ``Nat.succ) x_fvar + + -- ?inner := Prod.mk z succ_x + let pairTy := mkApp2 (mkConst ``Prod [.succ .zero, .succ .zero]) nat nat + let inner ← mkFreshExprMVar pairTy + inner.mvarId!.assign + (mkApp4 (mkConst ``Prod.mk [.succ .zero, .succ .zero]) nat nat z_fvar succ_x) + + -- ?d2 delayed [z] := ?inner, takes one Nat arg + let d2_ty ← mkArrow nat pairTy + let d2 ← mkFreshExprMVar d2_ty (kind := .syntheticOpaque) + assignDelayedMVar d2.mvarId! #[z_fvar] inner.mvarId! + + -- ?body := ?d2 succ_x + let body ← mkFreshExprMVar pairTy + body.mvarId!.assign (mkApp d2 succ_x) + + -- ?d1 delayed [x] := ?body + let d1_ty ← mkArrow nat pairTy + let d1 ← mkFreshExprMVar d1_ty (kind := .syntheticOpaque) + assignDelayedMVar d1.mvarId! #[x_fvar] body.mvarId! + + -- ?root := fun (a : Nat) => ?d1 a + let rootTy ← mkArrow nat pairTy + let root ← mkFreshExprMVar rootTy + root.mvarId!.assign (Lean.mkLambda `a .default nat (mkApp d1 (.bvar 0))) + return root + +-- Expected: fun (a : Nat) => (Nat.succ a, Nat.succ a) +private def mkExpected : Expr := + let nat := mkConst ``Nat + let succ_a := mkApp (mkConst ``Nat.succ) (.bvar 0) + let body := mkApp4 (mkConst ``Prod.mk [.succ .zero, .succ .zero]) nat nat succ_a succ_a + Lean.mkLambda `a .default nat body + +-- Extract the two components from the result +-- Result shape: fun (a : Nat) => @Prod.mk Nat Nat fst snd +private def extractComponents (e : Expr) : Expr × Expr := + let body := e.bindingBody! + let snd := body.appArg! + let fst := body.appFn!.appArg! + (fst, snd) + +unsafe def checkImpl (label : String) (f : Expr → MetaM Expr) : MetaM Bool := do + let root ← mkCrossScopeTest + let expected := mkExpected + let saved ← saveState + let result ← f root + saved.restore + unless result == expected do + throwError "{label}: wrong result, got {result}" + let (fst, snd) := extractComponents result + let shared := ptrEq fst snd + IO.println s!"{label}: cross-scope sharing = {shared}" + return shared + +run_meta do + let _ ← unsafe checkImpl "instantiateMVarsOriginal" instantiateMVarsOriginal + let sharingShared ← unsafe checkImpl "instantiateAllMVarsSharing" instantiateAllMVarsSharing + let defaultShared ← unsafe checkImpl "instantiateMVars" instantiateMVars + -- instantiateAllMVarsSharing (= instantiateMVars) should have cross-scope sharing + guard sharingShared + guard defaultShared From 5be03078fa4503d37f9ef86ecfe402dab5c2047b Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 13:37:21 +0000 Subject: [PATCH 33/45] fix: check scope generation at all levels in scope_cache rewind The rewind function previously skipped generation checking when scope_level < m_scope, assuming entries at lower scopes were always valid. This was safe for lookup (which rejects entries at scope_level != m_scope), but the insert sharing optimization compared against entries at scope_level < m_scope without verifying their scope generations. When a scope was popped and re-entered at a different depth, the stale entry's result had incorrect BVar indices from lift_loose_bvars, causing kernel application type mismatch errors. Co-Authored-By: Claude Opus 4.6 --- src/kernel/scope_cache.h | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/kernel/scope_cache.h b/src/kernel/scope_cache.h index b9216d4a3f6c..44364229cda6 100644 --- a/src/kernel/scope_cache.h +++ b/src/kernel/scope_cache.h @@ -92,14 +92,15 @@ class scope_cache { top.scope_gens = top.scope_gens->tail; top.scope_level--; } - /* scope_level < m_scope: entry is at a lower scope, stop. */ - if (top.scope_level < m_scope) return; - /* scope_level == m_scope: check generation. */ - if (top.scope_gens->gen == m_scope_gens_list->gen) return; + /* Check generation at scope_level. When scope_level < m_scope, + walk the current list down to scope_level first. */ + scope_gen_node * current_node = m_scope_gens_list; + for (unsigned i = m_scope; i > top.scope_level; i--) + current_node = current_node->tail; + if (top.scope_gens->gen == current_node->gen) return; /* Generation mismatch: scope was re-entered. Walk both lists in lockstep to find a valid level or exhaust to result_scope. */ scope_gen_node * entry_node = top.scope_gens; - scope_gen_node * current_node = m_scope_gens_list; unsigned level = top.scope_level; while (level > top.result_scope) { entry_node = entry_node->tail; From e2bbd746efdf7368db0fbcfbec11bd81e415a820 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 16:31:40 +0000 Subject: [PATCH 34/45] refactor: remove m_global_cache from pass 2, use has_expr_mvar Pass 2 of instantiateAllMVars does not handle level metavariables (pass 1 already resolves them), so has_mvar checks are unnecessarily broad. Replace with has_expr_mvar throughout pass 2. This makes the global cache redundant: expressions with no fvars and no expr mvars are returned unchanged by the early-return check, so there is nothing to cache. Remove m_global_cache and simplify the visit logic. Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 32 ++++++---------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 489c4a2ce611..0aa6e80eb98e 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -447,9 +447,6 @@ class instantiate_delayed_fn { the save/reset/restore pattern in visit(). */ unsigned m_result_scope; - /* Global cache for fvar-free expressions — scope-independent. */ - lean::unordered_map m_global_cache; - /* Write-back support: when fvar_subst is empty, normalize and write back mvar assignments to match the original instantiateMVars mctx side effects. Downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored @@ -486,7 +483,7 @@ class instantiate_delayed_fn { return optional(); expr a(r.get_val()); if (fvar_subst_empty()) { - if (!has_mvar(a)) + if (!has_expr_mvar(a)) return optional(a); if (m_already_normalized.contains(mid)) return optional(a); @@ -498,7 +495,7 @@ class instantiate_delayed_fn { } return optional(a_new); } else { - if (!has_mvar(a) && !has_fvar(a)) + if (!has_expr_mvar(a) && !has_fvar(a)) return optional(a); return optional(visit(a)); } @@ -649,25 +646,13 @@ class instantiate_delayed_fn { m_depth(0), m_result_scope(0) {} expr visit(expr const & e) { - if (fvar_subst_empty()) { - if (!has_mvar(e)) - return e; - } else { - if (!has_mvar(e) && !has_fvar(e)) - return e; - } + if ((!has_fvar(e) || fvar_subst_empty()) && !has_expr_mvar(e)) + return e; - bool use_global = !has_fvar(e) && !has_expr_mvar(e); bool shared = false; if (is_shared(e)) { - if (use_global) { - auto it = m_global_cache.find(e.raw()); - if (it != m_global_cache.end()) - return it->second; - } else { - if (auto r = m_cache.lookup(cache_key(e.raw(), m_depth), m_result_scope)) - return *r; - } + if (auto r = m_cache.lookup(cache_key(e.raw(), m_depth), m_result_scope)) + return *r; shared = true; } @@ -723,10 +708,7 @@ class instantiate_delayed_fn { } } if (shared) { - if (use_global) - m_global_cache.insert(mk_pair(e.raw(), r)); - else - r = m_cache.insert(cache_key(e.raw(), m_depth), r, m_result_scope); + r = m_cache.insert(cache_key(e.raw(), m_depth), r, m_result_scope); } done: From f42e6dd589b94f4b7ad0e3b524a9da13cddbb5a9 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 16:59:01 +0000 Subject: [PATCH 35/45] refactor: clarify pass 2 invariants for mvar and delayed assignment handling In pass 2, direct mvar assignments have already been resolved by pass 1. Assert this invariant in visit_app and for bare mvars instead of handling the impossible case. Inline the pending value lookup in visit_delayed instead of going through visit(mk_mvar(mid_pending)). Remove the now unnecessary visit_mvar and visit_args_and_beta methods from pass 2. Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 42 ++++++++++------------------ 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 0aa6e80eb98e..d40a0defd25b 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -524,17 +524,6 @@ class instantiate_delayed_fn { return mk_rev_app(*curr, args.size(), args.data()); } - expr visit_args_and_beta(expr const & f_new, expr const & e, buffer & args) { - expr const * curr = &e; - while (is_app(*curr)) { - args.push_back(visit(app_arg(*curr))); - curr = &app_fn(*curr); - } - bool preserve_data = false; - bool zeta = true; - return apply_beta(f_new, args.size(), args.data(), preserve_data, zeta); - } - expr visit_delayed(array_ref const & fvars, name const & mid_pending, expr const & e, buffer & args) { expr const * curr = &e; @@ -562,7 +551,11 @@ class instantiate_delayed_fn { m_fvar_subst[fid] = {m_depth, m_cache.scope(), args[args.size() - 1 - i]}; } - expr val_new = visit(mk_mvar(mid_pending)); + /* Get the pending value directly — it must be assigned (pass 1 + pre-normalized it). No write-back needed since fvar_subst is non-empty. */ + option_ref pending_val = get_mvar_assignment(m_mctx, mid_pending); + lean_assert(!!pending_val); + expr val_new = visit(expr(pending_val.get_val())); /* Pop scope; stale entries are detected by generation mismatch on lookup. */ m_cache.pop(); @@ -591,11 +584,8 @@ class instantiate_delayed_fn { return visit_app_default(e); } name const & mid = mvar_name(f); - /* Direct assignment takes precedence. */ - if (auto f_new = get_assignment(mid)) { - buffer args; - return visit_args_and_beta(*f_new, e, args); - } + /* Direct assignments were resolved by pass 1. */ + lean_assert(!get_mvar_assignment(m_mctx, mid)); /* Check delayed assignment. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (!d) { @@ -624,14 +614,6 @@ class instantiate_delayed_fn { return visit_delayed(fvars, mid_pending, e, args); } - expr visit_mvar(expr const & e) { - name const & mid = mvar_name(e); - if (auto r = get_assignment(mid)) { - return *r; - } - return e; - } - expr visit_fvar(expr const & e) { name const & fid = fvar_name(e); if (auto r = lookup_fvar(fid)) { @@ -678,8 +660,14 @@ class instantiate_delayed_fn { r = update_const(e, visit_levels(const_levels(e))); break; case expr_kind::MVar: - r = visit_mvar(e); - goto done; /* mvar results are not (ptr, depth)-cacheable */ + /* Bare mvars in pass 2 are unassigned: direct assignments were + resolved by pass 1, and resolvable pending values contain no + bare unassigned mvars. They only appear at the top level or + during write-back normalization (both with empty fvar_subst). */ + lean_assert(fvar_subst_empty()); + lean_assert(!get_mvar_assignment(m_mctx, mvar_name(e))); + r = e; + goto done; case expr_kind::MData: r = update_mdata(e, visit(mdata_expr(e))); break; From 5ce31b8036a6755073d1b1ab1d8cded14f6e31c0 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 17:08:49 +0000 Subject: [PATCH 36/45] Manual comment tweaks --- src/kernel/instantiate_mvars_all.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index d40a0defd25b..15cf0c83ca78 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -15,23 +15,26 @@ Authors: Joachim Breitner #include "kernel/scope_cache.h" /* -This module provides a two-pass variant of `instantiateMVars` with improved sharing. +This module provides a two-pass implementation of `instantiateMVars` with +linear complexity in the presence of nested delayed-assigned metavariables and +improved sharing. Pass 1 (`instantiate_direct_fn`): - Standard `instantiateMVars`-like traversal that resolves direct mvar assignments - with write-back and a single persistent cache. For delayed assignments, it - pre-normalizes the pending value (resolving its direct chain) but leaves the - delayed mvar application in the expression. Unassigned mvars are left in place. + First traversal that resolves **direct** mvar assignments with write-back. + For delayed assignments, it pre-normalizes the pending value (resolving its + direct chain) but leaves the delayed mvar application in the expression. + + Also assigns level metavariables. Between passes, a `resolvability_checker` determines which delayed assignments can be fully resolved (assigned, mvar-free after resolution, sufficient arguments). Pass 2 (`instantiate_delayed_fn`): - Fused traversal that resolves delayed assignments by carrying a fvar substitution. + Second traversal that resolves delayed assignments in one go, by carrying a + fvar substitution. Since pass 1 has pre-normalized all direct chains, each pending value is compact and visited once, avoiding the O(n³) sharing loss that occurs when the fused - approach must also chase direct chains. Unassigned mvars are left as-is (matching - the original `instantiateMVars` behavior). + approach must also chase direct chains. */ namespace lean { From a61d5b61eec546d9a49aac98c8c4599b885c7260 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 17:35:25 +0000 Subject: [PATCH 37/45] refactor: merge resolvability checker into pass 2, simplify pass 1 This moves the resolvability checking from a separate pre-computation phase into pass 2 itself, where it is performed on-demand when each delayed mvar is first encountered. Caches are kept across invocations for efficiency. This eliminates the `resolvability_checker` class and the `head_to_pending` map from pass 1. Pass 1 now only tracks a `m_has_delayed` bool, and only sets it when a delayed mvar's pending value is actually assigned (unassigned pending values will clearly fail the resolvability check, so pass 2 can be skipped entirely in that case). Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 218 ++++++++++----------------- 1 file changed, 83 insertions(+), 135 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 15cf0c83ca78..a8f918d1af7c 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -23,18 +23,15 @@ Pass 1 (`instantiate_direct_fn`): First traversal that resolves **direct** mvar assignments with write-back. For delayed assignments, it pre-normalizes the pending value (resolving its direct chain) but leaves the delayed mvar application in the expression. - Also assigns level metavariables. -Between passes, a `resolvability_checker` determines which delayed assignments can -be fully resolved (assigned, mvar-free after resolution, sufficient arguments). - Pass 2 (`instantiate_delayed_fn`): - Second traversal that resolves delayed assignments in one go, by carrying a - fvar substitution. - Since pass 1 has pre-normalized all direct chains, each pending value is compact - and visited once, avoiding the O(n³) sharing loss that occurs when the fused - approach must also chase direct chains. + Second traversal that resolves delayed assignments by carrying a fvar + substitution. Checks resolvability on-demand when encountering each delayed + mvar (caching results across invocations). Since pass 1 has pre-normalized + all direct chains, each pending value is compact and visited once, avoiding + the O(n³) sharing loss that occurs when the fused approach must also chase + direct chains. */ namespace lean { @@ -136,10 +133,6 @@ class instantiate_direct_fn { /* Set to true when any delayed assignment is encountered, even if not resolvable. Pass 2 is needed for write-back normalization in that case. */ bool m_has_delayed; - /* Mapping from delayed-assigned mvar head to its pending mvar name. - Collected during traversal; used after pass 1 to compute the set of - resolvable delayed assignments without adding per-entry overhead. */ - name_hash_map m_delayed_head_to_pending; lean::unordered_map m_cache; std::vector m_saved; @@ -229,14 +222,13 @@ class instantiate_direct_fn { /* Check for delayed assignment. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (d) { - m_has_delayed = true; + /* Pre-normalize the pending value so pass 2 finds it ready. + Only trigger pass 2 if the pending mvar is actually assigned; + unassigned pending values will clearly fail the resolvability check. */ name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - m_delayed_head_to_pending.insert(mk_pair(mid, mid_pending)); - /* Pre-normalize the pending value so pass 2 finds it ready. */ - (void)get_assignment(mid_pending); - return visit_mvar_app_args(e); + if (get_assignment(mid_pending)) + m_has_delayed = true; } - /* Not delayed: unassigned mvar. */ return visit_mvar_app_args(e); } @@ -248,11 +240,9 @@ class instantiate_direct_fn { /* Not directly assigned. Check if delayed-assigned. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (d) { - m_has_delayed = true; name mid_pending(cnstr_get(d.get_val().raw(), 1), true); - m_delayed_head_to_pending.insert(mk_pair(mid, mid_pending)); - /* Pre-normalize the pending value so pass 2 finds it ready. */ - (void)get_assignment(mid_pending); + if (get_assignment(mid_pending)) + m_has_delayed = true; } return e; } @@ -261,7 +251,6 @@ class instantiate_direct_fn { instantiate_direct_fn(metavar_ctx & mctx) : m_mctx(mctx), m_level_fn(mctx), m_has_delayed(false) {} bool has_delayed() const { return m_has_delayed; } - name_hash_map const & head_to_pending() const { return m_delayed_head_to_pending; } expr visit(expr const & e) { if (!has_mvar(e)) @@ -302,161 +291,125 @@ class instantiate_direct_fn { }; /* ============================================================================ - Resolvability computation (between pass 1 and pass 2). - Determines which delayed assignments can be fully resolved by pass 2. - A pending mvar is resolvable if: - 1. It is directly assigned, AND - 2. Its assigned value (normalized by pass 1) would become mvar-free - after pass 2 resolution — i.e., all remaining mvars are delayed- - assigned heads in app position with enough arguments, whose own - pending values are also resolvable. - Uses a cached bottom-up traversal (no fixpoint needed since the mvar - dependency graph is acyclic). + Pass 2: Resolve delayed assignments with fused fvar substitution. + Direct mvar chains have been pre-resolved by pass 1. ============================================================================ */ -class resolvability_checker { +struct fvar_subst_entry { + unsigned depth; + unsigned scope; + expr value; +}; + +class instantiate_delayed_fn { metavar_ctx & m_mctx; - name_hash_map const & m_head_to_pending; + name_hash_map m_fvar_subst; + unsigned m_depth; - /* Expression cache: is this subexpression fully resolvable? - Keyed by raw pointer — safe because we only traverse normalized - values from mctx that outlive this checker. */ - lean::unordered_map m_expr_cache; + /* Scope-aware cache for (ptr, depth) → expr with lazy staleness detection. */ + struct key_hasher { + std::size_t operator()(std::pair const & p) const { + return hash((size_t)p.first >> 3, p.second); + } + }; + typedef std::pair cache_key; + scope_cache m_cache; - /* Pending mvar cache: 0 = in-progress, 1 = resolvable, 2 = not. */ - name_hash_map m_pending_cache; + /* After visit() returns, this holds the maximum fvar-substitution + scope that contributed to the result — i.e., the outermost scope at which the + result is valid and can be cached. Updated monotonically (via max) through + the save/reset/restore pattern in visit(). */ + unsigned m_result_scope; - bool is_pending_resolvable(name const & pending) { - auto it = m_pending_cache.find(pending); - if (it != m_pending_cache.end()) + /* Write-back support: when fvar_subst is empty, normalize and write back + mvar assignments to match the original instantiateMVars mctx side effects. + Downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored + assignments and expects them to be normalized. */ + name_set m_already_normalized; + std::vector m_saved; + + /* Resolvability caches — persistent across all delayed mvar resolutions. + A pending mvar is resolvable if its assigned value (normalized by pass 1) + would become mvar-free after resolution: all remaining mvars must be + delayed-assigned heads in app position with enough arguments, whose own + pending values are also resolvable. */ + lean::unordered_map m_resolvable_expr_cache; + name_hash_map m_resolvable_pending_cache; /* 0 = in-progress, 1 = yes, 2 = no */ + + bool is_resolvable_pending(name const & pending) { + auto it = m_resolvable_pending_cache.find(pending); + if (it != m_resolvable_pending_cache.end()) return it->second == 1; /* Mark in-progress (cycle guard — shouldn't happen). */ - m_pending_cache[pending] = 0; + m_resolvable_pending_cache[pending] = 0; option_ref r = get_mvar_assignment(m_mctx, pending); if (!r) { - m_pending_cache[pending] = 2; + m_resolvable_pending_cache[pending] = 2; return false; } - bool ok = is_resolvable(expr(r.get_val())); - m_pending_cache[pending] = ok ? 1 : 2; + bool ok = is_resolvable_expr(expr(r.get_val())); + m_resolvable_pending_cache[pending] = ok ? 1 : 2; return ok; } - bool is_resolvable(expr const & e) { + bool is_resolvable_expr(expr const & e) { if (!has_expr_mvar(e)) return true; if (is_shared(e)) { - auto it = m_expr_cache.find(e.raw()); - if (it != m_expr_cache.end()) + auto it = m_resolvable_expr_cache.find(e.raw()); + if (it != m_resolvable_expr_cache.end()) return it->second; } - bool r = is_resolvable_core(e); + bool r = is_resolvable_expr_core(e); if (is_shared(e)) - m_expr_cache[e.raw()] = r; + m_resolvable_expr_cache[e.raw()] = r; return r; } - bool is_resolvable_core(expr const & e) { + bool is_resolvable_expr_core(expr const & e) { switch (e.kind()) { case expr_kind::MVar: - /* Bare mvar — pass 2's visit_mvar only checks direct - assignments, which pass 1 already resolved. Stuck. */ + /* Bare mvar — direct assignments were resolved by pass 1. Stuck. */ return false; case expr_kind::App: { expr const & f = get_app_fn(e); if (is_mvar(f)) { name const & mid = mvar_name(f); - auto it = m_head_to_pending.find(mid); - if (it == m_head_to_pending.end()) - return false; /* not a delayed head */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (!d) return false; array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); if (fvars.size() > get_app_num_args(e)) return false; /* not enough args */ - if (!is_pending_resolvable(it->second)) + name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + if (!is_resolvable_pending(mid_pending)) return false; /* Check args too. */ expr const * curr = &e; while (is_app(*curr)) { - if (!is_resolvable(app_arg(*curr))) + if (!is_resolvable_expr(app_arg(*curr))) return false; curr = &app_fn(*curr); } return true; } - return is_resolvable(app_fn(e)) && is_resolvable(app_arg(e)); + return is_resolvable_expr(app_fn(e)) && is_resolvable_expr(app_arg(e)); } case expr_kind::Lambda: case expr_kind::Pi: - return is_resolvable(binding_domain(e)) && - is_resolvable(binding_body(e)); + return is_resolvable_expr(binding_domain(e)) && + is_resolvable_expr(binding_body(e)); case expr_kind::Let: - return is_resolvable(let_type(e)) && - is_resolvable(let_value(e)) && - is_resolvable(let_body(e)); + return is_resolvable_expr(let_type(e)) && + is_resolvable_expr(let_value(e)) && + is_resolvable_expr(let_body(e)); case expr_kind::MData: - return is_resolvable(mdata_expr(e)); + return is_resolvable_expr(mdata_expr(e)); case expr_kind::Proj: - return is_resolvable(proj_expr(e)); + return is_resolvable_expr(proj_expr(e)); default: return true; } } -public: - resolvability_checker(metavar_ctx & mctx, - name_hash_map const & head_to_pending) - : m_mctx(mctx), m_head_to_pending(head_to_pending) {} - - name_set compute() { - name_set resolvable; - for (auto & kv : m_head_to_pending) { - if (is_pending_resolvable(kv.second)) - resolvable.insert(kv.second); - } - return resolvable; - } -}; - -/* ============================================================================ - Pass 2: Resolve delayed assignments with fused fvar substitution. - Direct mvar chains have been pre-resolved by pass 1. - ============================================================================ */ - -struct fvar_subst_entry { - unsigned depth; - unsigned scope; - expr value; -}; - -class instantiate_delayed_fn { - metavar_ctx & m_mctx; - name_set const & m_resolvable_delayed; - name_hash_map m_fvar_subst; - unsigned m_depth; - - /* Scope-aware cache for (ptr, depth) → expr with lazy staleness detection. */ - struct key_hasher { - std::size_t operator()(std::pair const & p) const { - return hash((size_t)p.first >> 3, p.second); - } - }; - typedef std::pair cache_key; - scope_cache m_cache; - - /* After visit() returns, this holds the maximum fvar-substitution - scope that contributed to the result — i.e., the outermost scope at which the - result is valid and can be cached. Updated monotonically (via max) through - the save/reset/restore pattern in visit(). */ - unsigned m_result_scope; - - /* Write-back support: when fvar_subst is empty, normalize and write back - mvar assignments to match the original instantiateMVars mctx side effects. - Downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored - assignments and expects them to be normalized. */ - name_set m_already_normalized; - std::vector m_saved; - bool fvar_subst_empty() const { return m_fvar_subst.empty(); } @@ -599,11 +552,9 @@ class instantiate_delayed_fn { if (fvars.size() > get_app_num_args(e)) { return visit_mvar_app_args(e); } - /* Only resolve the delayed assignment when the resolvability - computation determined the pending value is fully resolvable - (assigned and all nested delayed mvars also resolvable). This - matches the original instantiateMVars behavior. */ - if (!m_resolvable_delayed.contains(mid_pending)) { + /* Only resolve when the pending value is fully resolvable + (assigned and all nested delayed mvars also resolvable). */ + if (!is_resolvable_pending(mid_pending)) { /* Still normalize the pending value for mctx write-back when fvar_subst is empty. Downstream code (MutualDef.mkInitialUsedFVarsMap) reads stored assignments and relies on inner delayed assignments @@ -626,9 +577,8 @@ class instantiate_delayed_fn { } public: - instantiate_delayed_fn(metavar_ctx & mctx, name_set const & resolvable_delayed) - : m_mctx(mctx), m_resolvable_delayed(resolvable_delayed), - m_depth(0), m_result_scope(0) {} + instantiate_delayed_fn(metavar_ctx & mctx) + : m_mctx(mctx), m_depth(0), m_result_scope(0) {} expr visit(expr const & e) { if ((!has_fvar(e) || fvar_subst_empty()) && !has_expr_mvar(e)) @@ -739,9 +689,7 @@ static object * run_instantiate_all(object * m, object * e) { if (!pass1.has_delayed()) { e2 = e1; } else { - resolvability_checker checker(mctx, pass1.head_to_pending()); - name_set resolvable = checker.compute(); - instantiate_delayed_fn pass2(mctx, resolvable); + instantiate_delayed_fn pass2(mctx); e2 = pass2(e1); } From bcf308b6bef8f1ed66262606e94276b48c7a9e93 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 17:43:04 +0000 Subject: [PATCH 38/45] Redundant checks --- src/kernel/instantiate_mvars_all.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index a8f918d1af7c..83f000595917 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -439,8 +439,6 @@ class instantiate_delayed_fn { return optional(); expr a(r.get_val()); if (fvar_subst_empty()) { - if (!has_expr_mvar(a)) - return optional(a); if (m_already_normalized.contains(mid)) return optional(a); m_already_normalized.insert(mid); @@ -451,8 +449,6 @@ class instantiate_delayed_fn { } return optional(a_new); } else { - if (!has_expr_mvar(a) && !has_fvar(a)) - return optional(a); return optional(visit(a)); } } From ad9be0f985e4e14c1eb6b5e0c8cbb7aa7c8fc358 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 17:49:42 +0000 Subject: [PATCH 39/45] refactor: add proper accessors for DelayedMetavarAssignment fields This exports `DelayedMetavarAssignment.fvars` and `DelayedMetavarAssignment.mvarIdPending` as C functions and uses them in both `instantiate_mvars.cpp` and `instantiate_mvars_all.cpp`, replacing raw `cnstr_get` access. Also simplifies `get_assignment` in pass 2 by removing redundant `has_expr_mvar`/`has_fvar` checks that `visit` already performs. Pass 1 now only sets `m_has_delayed` when the pending mvar is actually assigned. Co-Authored-By: Claude Opus 4.6 --- src/Lean/MetavarContext.lean | 6 ++++++ src/kernel/instantiate_mvars.cpp | 14 ++++++++++++-- src/kernel/instantiate_mvars_all.cpp | 22 ++++++++++++++++------ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/Lean/MetavarContext.lean b/src/Lean/MetavarContext.lean index 7cbbc68af21d..2d33ec3d0992 100644 --- a/src/Lean/MetavarContext.lean +++ b/src/Lean/MetavarContext.lean @@ -400,6 +400,12 @@ def MetavarContext.getDelayedMVarAssignmentCore? (mctx : MetavarContext) (mvarId def MetavarContext.getDelayedMVarAssignmentExp (mctx : MetavarContext) (mvarId : MVarId) : Option DelayedMetavarAssignment := mctx.dAssignment.find? mvarId +@[export lean_delayed_mvar_assignment_fvars] +def DelayedMetavarAssignment.fvarsExp (d : DelayedMetavarAssignment) : Array Expr := d.fvars + +@[export lean_delayed_mvar_assignment_mvar_id_pending] +def DelayedMetavarAssignment.mvarIdPendingExp (d : DelayedMetavarAssignment) : MVarId := d.mvarIdPending + def getDelayedMVarAssignment? [Monad m] [MonadMCtx m] (mvarId : MVarId) : m (Option DelayedMetavarAssignment) := return (← getMCtx).getDelayedMVarAssignmentCore? mvarId diff --git a/src/kernel/instantiate_mvars.cpp b/src/kernel/instantiate_mvars.cpp index 4fda6f9a76e1..d923561f0637 100644 --- a/src/kernel/instantiate_mvars.cpp +++ b/src/kernel/instantiate_mvars.cpp @@ -100,6 +100,8 @@ extern "C" LEAN_EXPORT object * lean_instantiate_level_mvars(object * m, object extern "C" object * lean_get_mvar_assignment(obj_arg mctx, obj_arg mid); extern "C" object * lean_get_delayed_mvar_assignment(obj_arg mctx, obj_arg mid); +extern "C" object * lean_delayed_mvar_assignment_fvars(obj_arg d); +extern "C" object * lean_delayed_mvar_assignment_mvar_id_pending(obj_arg d); extern "C" object * lean_assign_mvar(obj_arg mctx, obj_arg mid, obj_arg val); typedef object_ref delayed_assignment; @@ -116,6 +118,14 @@ option_ref get_delayed_mvar_assignment(metavar_ctx & mctx, n return option_ref(lean_get_delayed_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); } +array_ref delayed_assignment_fvars(delayed_assignment const & d) { + return array_ref(lean_delayed_mvar_assignment_fvars(d.to_obj_arg())); +} + +name delayed_assignment_mvar_id_pending(delayed_assignment const & d) { + return name(lean_delayed_mvar_assignment_mvar_id_pending(d.to_obj_arg())); +} + expr replace_fvars(expr const & e, array_ref const & fvars, expr const * rev_args) { size_t sz = fvars.size(); if (sz == 0) @@ -290,8 +300,8 @@ class instantiate_mvars_fn { metavariables, we replace the free variables `fvars` in `newVal` with the first `fvars.size` elements of `args`. */ - array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + array_ref fvars = delayed_assignment_fvars(d.get_val()); + name mid_pending = delayed_assignment_mvar_id_pending(d.get_val()); if (fvars.size() > get_app_num_args(e)) { /* We don't have sufficient arguments for instantiating the free variables `fvars`. diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 83f000595917..97b22228ec8a 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -39,6 +39,8 @@ extern "C" object * lean_get_lmvar_assignment(obj_arg mctx, obj_arg mid); extern "C" object * lean_assign_lmvar(obj_arg mctx, obj_arg mid, obj_arg val); extern "C" object * lean_get_mvar_assignment(obj_arg mctx, obj_arg mid); extern "C" object * lean_get_delayed_mvar_assignment(obj_arg mctx, obj_arg mid); +extern "C" object * lean_delayed_mvar_assignment_fvars(obj_arg d); +extern "C" object * lean_delayed_mvar_assignment_mvar_id_pending(obj_arg d); extern "C" object * lean_assign_mvar(obj_arg mctx, obj_arg mid, obj_arg val); typedef object_ref metavar_ctx; typedef object_ref delayed_assignment; @@ -65,6 +67,14 @@ static option_ref get_delayed_mvar_assignment(metavar_ctx & return option_ref(lean_get_delayed_mvar_assignment(mctx.to_obj_arg(), mid.to_obj_arg())); } +static array_ref delayed_assignment_fvars(delayed_assignment const & d) { + return array_ref(lean_delayed_mvar_assignment_fvars(d.to_obj_arg())); +} + +static name delayed_assignment_mvar_id_pending(delayed_assignment const & d) { + return name(lean_delayed_mvar_assignment_mvar_id_pending(d.to_obj_arg())); +} + /* Level metavariable instantiation. */ class instantiate_lmvars_all_fn { metavar_ctx & m_mctx; @@ -225,7 +235,7 @@ class instantiate_direct_fn { /* Pre-normalize the pending value so pass 2 finds it ready. Only trigger pass 2 if the pending mvar is actually assigned; unassigned pending values will clearly fail the resolvability check. */ - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + name mid_pending = delayed_assignment_mvar_id_pending(d.get_val()); if (get_assignment(mid_pending)) m_has_delayed = true; } @@ -240,7 +250,7 @@ class instantiate_direct_fn { /* Not directly assigned. Check if delayed-assigned. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (d) { - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + name mid_pending = delayed_assignment_mvar_id_pending(d.get_val()); if (get_assignment(mid_pending)) m_has_delayed = true; } @@ -377,10 +387,10 @@ class instantiate_delayed_fn { option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (!d) return false; - array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); + array_ref fvars = delayed_assignment_fvars(d.get_val()); if (fvars.size() > get_app_num_args(e)) return false; /* not enough args */ - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + name mid_pending = delayed_assignment_mvar_id_pending(d.get_val()); if (!is_resolvable_pending(mid_pending)) return false; /* Check args too. */ @@ -543,8 +553,8 @@ class instantiate_delayed_fn { if (!d) { return visit_mvar_app_args(e); } - array_ref fvars(cnstr_get(d.get_val().raw(), 0), true); - name mid_pending(cnstr_get(d.get_val().raw(), 1), true); + array_ref fvars = delayed_assignment_fvars(d.get_val()); + name mid_pending = delayed_assignment_mvar_id_pending(d.get_val()); if (fvars.size() > get_app_num_args(e)) { return visit_mvar_app_args(e); } From 99e9da1a7df6f8b6a77f6df0bd481cc5f8849f20 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 17:58:08 +0000 Subject: [PATCH 40/45] doc: explain write-back behavior of pass 2 delayed mvar resolution Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 97b22228ec8a..2a4f0bfd594f 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -303,6 +303,26 @@ class instantiate_direct_fn { /* ============================================================================ Pass 2: Resolve delayed assignments with fused fvar substitution. Direct mvar chains have been pre-resolved by pass 1. + + Write-back behavior: + + Delayed-assigned mvars (d-a-mvars) form a dependency tree: each d-a-mvar's + pending value may reference other d-a-mvars. Some subtrees of this tree are + fully resolvable (all d-a-mvars within have assigned, mvar-free pending + values with sufficient arguments), while others are not. + + Pass 2 fully resolves every maximal resolvable subtree. The roots of these + subtrees — d-a-mvars that are themselves resolvable but whose parent in the + tree is not — form a horizontal cut through the dependency tree. Above the + cut sit non-resolvable d-a-mvars; below the cut, everything is resolved. + + Pass 2 writes back the normalized pending values of d-a-mvars above the cut + (the non-resolvable ones whose children may have been resolved). This is + exactly the right set: these d-a-mvars are visited with an empty fvar + substitution, so their normalized values are suitable for storing in the + mctx. D-a-mvars below the cut are visited with a non-empty substitution + (fvars replaced by arguments), so their intermediate values cannot be + written back. ============================================================================ */ struct fvar_subst_entry { From 32948acdd9ead433d324f83ec145391e24a7d372 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 18:00:21 +0000 Subject: [PATCH 41/45] refactor: make args buffer local to visit_delayed Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 2a4f0bfd594f..2330d91b72de 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -507,7 +507,8 @@ class instantiate_delayed_fn { } expr visit_delayed(array_ref const & fvars, name const & mid_pending, - expr const & e, buffer & args) { + expr const & e) { + buffer args; expr const * curr = &e; while (is_app(*curr)) { args.push_back(visit(app_arg(*curr))); @@ -590,8 +591,7 @@ class instantiate_delayed_fn { } return visit_mvar_app_args(e); } - buffer args; - return visit_delayed(fvars, mid_pending, e, args); + return visit_delayed(fvars, mid_pending, e); } expr visit_fvar(expr const & e) { From 07f526d035de77385c60812f2b78cc96ff115b64 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 18:00:55 +0000 Subject: [PATCH 42/45] style: flip is_resolvable_pending branch to avoid negation Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 2330d91b72de..7ea84407da4e 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -579,19 +579,16 @@ class instantiate_delayed_fn { if (fvars.size() > get_app_num_args(e)) { return visit_mvar_app_args(e); } - /* Only resolve when the pending value is fully resolvable - (assigned and all nested delayed mvars also resolvable). */ - if (!is_resolvable_pending(mid_pending)) { - /* Still normalize the pending value for mctx write-back when - fvar_subst is empty. Downstream code (MutualDef.mkInitialUsedFVarsMap) - reads stored assignments and relies on inner delayed assignments - being resolved even when the outer one cannot be. */ + if (is_resolvable_pending(mid_pending)) { + return visit_delayed(fvars, mid_pending, e); + } else { + /* Normalize the pending value for mctx write-back when + fvar_subst is empty (see write-back comment above). */ if (fvar_subst_empty()) { (void)get_assignment(mid_pending); } return visit_mvar_app_args(e); } - return visit_delayed(fvars, mid_pending, e); } expr visit_fvar(expr const & e) { From f67de6649f7889a88b0db8cd343c1ede8ca241d1 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 18:02:01 +0000 Subject: [PATCH 43/45] refactor: assert fvar_subst_empty in non-resolvable delayed mvar path Inside a resolvable subtree, all nested delayed mvars are also resolvable, so the non-resolvable branch is only reachable in outer mode (empty fvar substitution). Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 7ea84407da4e..7e24de534899 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -582,11 +582,12 @@ class instantiate_delayed_fn { if (is_resolvable_pending(mid_pending)) { return visit_delayed(fvars, mid_pending, e); } else { - /* Normalize the pending value for mctx write-back when - fvar_subst is empty (see write-back comment above). */ - if (fvar_subst_empty()) { - (void)get_assignment(mid_pending); - } + /* Non-resolvable d-a-mvars only appear in outer mode: inside a + resolvable subtree all nested d-a-mvars are resolvable too. */ + lean_assert(fvar_subst_empty()); + /* Normalize the pending value for mctx write-back + (see write-back comment above). */ + (void)get_assignment(mid_pending); return visit_mvar_app_args(e); } } From 9284c3c2d53f5c68bd62eb40337e47874b85dd99 Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Thu, 5 Mar 2026 22:59:29 +0000 Subject: [PATCH 44/45] refactor: overhaul terminology in instantiate_mvars_all.cpp This commit introduces consistent terminology throughout instantiate_mvars_all.cpp: direct MVar, pending MVar, assigned MVar, MVar DAG, resolvable MVar, updateable MVar, outer/inner mode, and the updateable-MVar cut. Renames fvar_subst_empty() to in_outer_mode() and m_has_delayed to m_has_updateable_delayed for alignment. Co-Authored-By: Claude Opus 4.6 --- src/kernel/instantiate_mvars_all.cpp | 226 ++++++++++++++++----------- 1 file changed, 135 insertions(+), 91 deletions(-) diff --git a/src/kernel/instantiate_mvars_all.cpp b/src/kernel/instantiate_mvars_all.cpp index 7e24de534899..17a4bfdbfd5c 100644 --- a/src/kernel/instantiate_mvars_all.cpp +++ b/src/kernel/instantiate_mvars_all.cpp @@ -15,23 +15,62 @@ Authors: Joachim Breitner #include "kernel/scope_cache.h" /* -This module provides a two-pass implementation of `instantiateMVars` with -linear complexity in the presence of nested delayed-assigned metavariables and -improved sharing. +This module provides an implementation of `instantiateMVars` with linear +complexity in the presence of nested delayed-assigned metavariables and +improved sharing. It proceeds in two passes. + +Terminology (for this file): + +* Direct MVar: an MVar that is not delayed-assigned. +* Pending MVar: the direct MVar stored in a `DelayedMetavarAssignment`. +* Assigned MVar: a direct MVar with an assignment, or a delayed-assigned MVar + with an assigned pending MVar. +* MVar DAG: the directed acyclic graph of MVars reachable from the expression. +* Resolvable MVar: an MVar where all MVars reachable from it (including itself) + are assigned. +* Updateable MVar: an assigned direct MVar, or a delayed-assigned MVar that is + resolvable but not reachable from any other resolvable delayed-assigned MVar. + +In the MVar DAG, the updateable delayed-assigned MVars form a cut with only +assigned MVars behind it and no resolvable delayed-assigned MVars before it. Pass 1 (`instantiate_direct_fn`): - First traversal that resolves **direct** mvar assignments with write-back. - For delayed assignments, it pre-normalizes the pending value (resolving its - direct chain) but leaves the delayed mvar application in the expression. - Also assigns level metavariables. + Traverses all MVars and expressions reachable from the initial expression and + * instantiates all updateable direct MVars, updating their assignment with + its instantiation, + * instantiates all level MVars, + * determines if there are any updateable delayed-assigned MVars. Pass 2 (`instantiate_delayed_fn`): - Second traversal that resolves delayed assignments by carrying a fvar - substitution. Checks resolvability on-demand when encountering each delayed - mvar (caching results across invocations). Since pass 1 has pre-normalized - all direct chains, each pending value is compact and visited once, avoiding - the O(n³) sharing loss that occurs when the fused approach must also chase - direct chains. + Only run if there are updateable delayed-assigned MVars. Has an "outer" and + an "inner" mode, depending on whether it has crossed the updateable-MVar cut. + + In outer mode (empty substitution), all MVars are either unassigned direct + MVars (left alone), non-updateable delayed-assigned MVars (pending MVar + traversed in outer mode and updated with the result), or updateable + delayed-assigned MVars. + + When a delayed-assigned MVar is encountered, its MVar DAG is explored (via + `is_resolvable_pending`) to determine if it is resolvable (and thus + updateable). Results are cached across invocations. + + If it is updateable, the substitution is initialized from its arguments and + traversal continues with the value of its pending MVar in inner mode. + + In inner mode (non-empty substitution), all encountered delayed-assigned + MVars are, by construction, resolvable but not updateable. The substitution + is carried along and extended as we cross such MVars. Pending MVars of these + delayed-assigned MVars are NOT updated with the result (as the result is + valid only for this substitution, not in general). + + Applying the substitution in one go, rather than instantiating each + delayed-assigned MVar on its own from inside out, avoids the quadratic + overhead of that approach when there are long chains of delayed-assigned + MVars. + + A special-crafted caching data structure, the `scope_cache`, ensures that + sharing is preserved even across different delayed-assigned MVars (and hence + with different substitutions), when possible. */ namespace lean { @@ -130,19 +169,20 @@ class instantiate_lmvars_all_fn { }; /* ============================================================================ - Pass 1: Resolve direct mvar assignments with write-back. - For delayed assignments, pre-normalize the pending value (resolving its - direct chains) but leave the delayed mvar application in the expression. - Unassigned mvars are left in place. + Pass 1: Instantiate updateable direct MVars with write-back. + For delayed-assigned MVars, pre-normalize the pending MVar's value + (resolving its direct MVar chains) but leave the delayed-assigned MVar + application in the expression. Also instantiates level MVars. + Unassigned MVars are left in place. ============================================================================ */ class instantiate_direct_fn { metavar_ctx & m_mctx; instantiate_lmvars_all_fn m_level_fn; name_set m_already_normalized; - /* Set to true when any delayed assignment is encountered, even if not - resolvable. Pass 2 is needed for write-back normalization in that case. */ - bool m_has_delayed; + /* Set to true when a delayed-assigned MVar with an assigned pending MVar + is encountered. Pass 2 is needed to resolve or write back such MVars. */ + bool m_has_updateable_delayed; lean::unordered_map m_cache; std::vector m_saved; @@ -165,7 +205,8 @@ class instantiate_direct_fn { return r; } - /* Get and normalize a direct mvar assignment. Write back the normalized value. */ + /* Get and normalize an updateable direct MVar's assignment. Write back the + normalized value. */ optional get_assignment(name const & mid) { option_ref r = get_mvar_assignment(m_mctx, mid); if (!r) { @@ -224,20 +265,20 @@ class instantiate_direct_fn { return visit_app_default(e); } name const & mid = mvar_name(f); - /* Direct assignment takes precedence. */ + /* Direct MVar assignment takes precedence. */ if (auto f_new = get_assignment(mid)) { buffer args; return visit_args_and_beta(*f_new, e, args); } - /* Check for delayed assignment. */ + /* Check for delayed-assigned MVar. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (d) { - /* Pre-normalize the pending value so pass 2 finds it ready. - Only trigger pass 2 if the pending mvar is actually assigned; - unassigned pending values will clearly fail the resolvability check. */ + /* Pre-normalize the pending MVar's value so pass 2 finds it ready. + Only trigger pass 2 if the pending MVar is actually assigned; + unassigned pending MVars will clearly fail the resolvability check. */ name mid_pending = delayed_assignment_mvar_id_pending(d.get_val()); if (get_assignment(mid_pending)) - m_has_delayed = true; + m_has_updateable_delayed = true; } return visit_mvar_app_args(e); } @@ -247,20 +288,20 @@ class instantiate_direct_fn { if (auto r = get_assignment(mid)) { return *r; } - /* Not directly assigned. Check if delayed-assigned. */ + /* Not a direct MVar with assignment. Check if delayed-assigned. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (d) { name mid_pending = delayed_assignment_mvar_id_pending(d.get_val()); if (get_assignment(mid_pending)) - m_has_delayed = true; + m_has_updateable_delayed = true; } return e; } public: instantiate_direct_fn(metavar_ctx & mctx) - : m_mctx(mctx), m_level_fn(mctx), m_has_delayed(false) {} - bool has_delayed() const { return m_has_delayed; } + : m_mctx(mctx), m_level_fn(mctx), m_has_updateable_delayed(false) {} + bool has_updateable_delayed() const { return m_has_updateable_delayed; } expr visit(expr const & e) { if (!has_mvar(e)) @@ -301,28 +342,29 @@ class instantiate_direct_fn { }; /* ============================================================================ - Pass 2: Resolve delayed assignments with fused fvar substitution. - Direct mvar chains have been pre-resolved by pass 1. + Pass 2: Resolve delayed-assigned MVars with fused fvar substitution. + Direct MVar chains have been pre-resolved by pass 1. Write-back behavior: - Delayed-assigned mvars (d-a-mvars) form a dependency tree: each d-a-mvar's - pending value may reference other d-a-mvars. Some subtrees of this tree are - fully resolvable (all d-a-mvars within have assigned, mvar-free pending - values with sufficient arguments), while others are not. + Delayed-assigned MVars form a dependency tree: each delayed-assigned MVar's + pending MVar value may reference other delayed-assigned MVars. Some subtrees + of this tree are fully resolvable (all delayed-assigned MVars within are + resolvable), while others are not. Pass 2 fully resolves every maximal resolvable subtree. The roots of these - subtrees — d-a-mvars that are themselves resolvable but whose parent in the - tree is not — form a horizontal cut through the dependency tree. Above the - cut sit non-resolvable d-a-mvars; below the cut, everything is resolved. - - Pass 2 writes back the normalized pending values of d-a-mvars above the cut - (the non-resolvable ones whose children may have been resolved). This is - exactly the right set: these d-a-mvars are visited with an empty fvar - substitution, so their normalized values are suitable for storing in the - mctx. D-a-mvars below the cut are visited with a non-empty substitution - (fvars replaced by arguments), so their intermediate values cannot be - written back. + subtrees — updateable delayed-assigned MVars that are resolvable but whose + parent in the tree is not — form the updateable-MVar cut through the + dependency tree. Above the cut sit non-resolvable delayed-assigned MVars; + below the cut, everything is resolved. + + Pass 2 writes back the normalized pending MVar values of delayed-assigned + MVars above the cut (the non-resolvable ones whose children may have been + resolved). This is exactly the right set: these MVars are visited in outer + mode (empty fvar substitution), so their normalized values are suitable for + storing in the mctx. MVars below the cut are visited in inner mode + (non-empty substitution, fvars replaced by arguments), so their intermediate + values cannot be written back. ============================================================================ */ struct fvar_subst_entry { @@ -351,18 +393,17 @@ class instantiate_delayed_fn { the save/reset/restore pattern in visit(). */ unsigned m_result_scope; - /* Write-back support: when fvar_subst is empty, normalize and write back - mvar assignments to match the original instantiateMVars mctx side effects. - Downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored - assignments and expects them to be normalized. */ + /* Write-back support: in outer mode, normalize and write back direct MVar + assignments. Downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads + stored assignments and expects inner delayed-assigned MVars to be resolved. */ name_set m_already_normalized; std::vector m_saved; - /* Resolvability caches — persistent across all delayed mvar resolutions. - A pending mvar is resolvable if its assigned value (normalized by pass 1) - would become mvar-free after resolution: all remaining mvars must be - delayed-assigned heads in app position with enough arguments, whose own - pending values are also resolvable. */ + /* Resolvability caches — persistent across all delayed-assigned MVar + resolutions. A pending MVar is resolvable if its assigned value + (normalized by pass 1) would become MVar-free after resolution: all + remaining MVars must be delayed-assigned MVars in app position with + enough arguments, whose own pending MVars are also resolvable. */ lean::unordered_map m_resolvable_expr_cache; name_hash_map m_resolvable_pending_cache; /* 0 = in-progress, 1 = yes, 2 = no */ @@ -398,7 +439,7 @@ class instantiate_delayed_fn { bool is_resolvable_expr_core(expr const & e) { switch (e.kind()) { case expr_kind::MVar: - /* Bare mvar — direct assignments were resolved by pass 1. Stuck. */ + /* Bare MVar — direct MVar assignments were resolved by pass 1. Stuck. */ return false; case expr_kind::App: { expr const & f = get_app_fn(e); @@ -440,7 +481,9 @@ class instantiate_delayed_fn { } } - bool fvar_subst_empty() const { + /* Outer mode: no fvar substitution active; inner mode: inside a + resolvable delayed-assigned MVar with fvars mapped to arguments. */ + bool in_outer_mode() const { return m_fvar_subst.empty(); } @@ -455,20 +498,19 @@ class instantiate_delayed_fn { return optional(lift_loose_bvars(it->second.value, d)); } - /* Get a direct mvar assignment. Visit it to resolve delayed mvars - and apply the fvar substitution. - When fvar_subst is empty, normalize and write back the result to - the mctx. This matches the original instantiateMVars behavior: - downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored - assignments and expects inner delayed assignments to be resolved. - When fvar_subst is non-empty, no write-back (values contain - fvar-substituted terms not suitable for the mctx). */ + /* Get a direct MVar assignment. Visit it to resolve delayed-assigned + MVars and apply the fvar substitution. + In outer mode, normalize and write back the result to the mctx. + Downstream code (e.g. MutualDef.mkInitialUsedFVarsMap) reads stored + assignments and expects inner delayed-assigned MVars to be resolved. + In inner mode, no write-back: the result contains fvar-substituted + terms not suitable for the mctx. */ optional get_assignment(name const & mid) { option_ref r = get_mvar_assignment(m_mctx, mid); if (!r) return optional(); expr a(r.get_val()); - if (fvar_subst_empty()) { + if (in_outer_mode()) { if (m_already_normalized.contains(mid)) return optional(a); m_already_normalized.insert(mid); @@ -534,8 +576,8 @@ class instantiate_delayed_fn { m_fvar_subst[fid] = {m_depth, m_cache.scope(), args[args.size() - 1 - i]}; } - /* Get the pending value directly — it must be assigned (pass 1 - pre-normalized it). No write-back needed since fvar_subst is non-empty. */ + /* Get the pending MVar's value directly — it must be assigned (pass 1 + pre-normalized it). No write-back: we are in inner mode. */ option_ref pending_val = get_mvar_assignment(m_mctx, mid_pending); lean_assert(!!pending_val); expr val_new = visit(expr(pending_val.get_val())); @@ -553,9 +595,9 @@ class instantiate_delayed_fn { } /* Use apply_beta instead of mk_rev_app: pass 1's beta-reduction may have - changed delayed mvar arguments (e.g., substituting a bvar with a concrete - value), so the resolved pending value may be a lambda that needs beta- - reduction with the extra args, matching the original's behavior. */ + changed delayed-assigned MVar arguments (e.g., substituting a bvar with a + concrete value), so the resolved pending MVar value may be a lambda that + needs beta-reduction with the extra args. */ bool preserve_data = false; bool zeta = true; return apply_beta(val_new, extra_count, args.data(), preserve_data, zeta); @@ -567,9 +609,9 @@ class instantiate_delayed_fn { return visit_app_default(e); } name const & mid = mvar_name(f); - /* Direct assignments were resolved by pass 1. */ + /* Direct MVar assignments were resolved by pass 1. */ lean_assert(!get_mvar_assignment(m_mctx, mid)); - /* Check delayed assignment. */ + /* Check for delayed-assigned MVar. */ option_ref d = get_delayed_mvar_assignment(m_mctx, mid); if (!d) { return visit_mvar_app_args(e); @@ -580,12 +622,14 @@ class instantiate_delayed_fn { return visit_mvar_app_args(e); } if (is_resolvable_pending(mid_pending)) { + /* Updateable delayed-assigned MVar: cross the cut into inner mode. */ return visit_delayed(fvars, mid_pending, e); } else { - /* Non-resolvable d-a-mvars only appear in outer mode: inside a - resolvable subtree all nested d-a-mvars are resolvable too. */ - lean_assert(fvar_subst_empty()); - /* Normalize the pending value for mctx write-back + /* Non-resolvable delayed-assigned MVars only appear in outer mode: + inside a resolvable subtree all nested delayed-assigned MVars are + resolvable too. */ + lean_assert(in_outer_mode()); + /* Normalize the pending MVar's value for mctx write-back (see write-back comment above). */ (void)get_assignment(mid_pending); return visit_mvar_app_args(e); @@ -605,7 +649,7 @@ class instantiate_delayed_fn { : m_mctx(mctx), m_depth(0), m_result_scope(0) {} expr visit(expr const & e) { - if ((!has_fvar(e) || fvar_subst_empty()) && !has_expr_mvar(e)) + if ((!has_fvar(e) || in_outer_mode()) && !has_expr_mvar(e)) return e; bool shared = false; @@ -637,11 +681,11 @@ class instantiate_delayed_fn { r = update_const(e, visit_levels(const_levels(e))); break; case expr_kind::MVar: - /* Bare mvars in pass 2 are unassigned: direct assignments were - resolved by pass 1, and resolvable pending values contain no - bare unassigned mvars. They only appear at the top level or - during write-back normalization (both with empty fvar_subst). */ - lean_assert(fvar_subst_empty()); + /* Bare MVars in pass 2 are unassigned direct MVars: direct MVar + assignments were resolved by pass 1, and resolvable pending MVar + values contain no bare unassigned MVars. They only appear in + outer mode (at the top level or during write-back normalization). */ + lean_assert(in_outer_mode()); lean_assert(!get_mvar_assignment(m_mctx, mvar_name(e))); r = e; goto done; @@ -682,7 +726,7 @@ class instantiate_delayed_fn { } level visit_level(level const & l) { - /* Pass 2 does not handle level mvars — pass 1 already resolved them. + /* Pass 2 does not handle level MVars — pass 1 already resolved them. But we still need this for the visit_levels call in update_sort/update_const. Since levels have no fvars, we can just return them as-is. */ return l; @@ -702,15 +746,15 @@ class instantiate_delayed_fn { static object * run_instantiate_all(object * m, object * e) { metavar_ctx mctx(m); - /* Pass 1: resolve direct mvar assignments, pre-normalize pending values. */ + /* Pass 1: instantiate updateable direct MVars, pre-normalize pending MVar values. */ instantiate_direct_fn pass1(mctx); expr e1 = pass1(expr(e)); - /* Pass 2: resolve delayed assignments with fused fvar substitution. - Skip if pass 1 found no delayed assignments at all — the expression - has no delayed mvars that need resolution or write-back. */ + /* Pass 2: resolve delayed-assigned MVars with fused fvar substitution. + Skip if pass 1 found no delayed-assigned MVars with assigned pending + MVars — none need resolution or write-back. */ expr e2; - if (!pass1.has_delayed()) { + if (!pass1.has_updateable_delayed()) { e2 = e1; } else { instantiate_delayed_fn pass2(mctx); From 88b30864442a31e1bb9202fb09180c9e0ce1b92d Mon Sep 17 00:00:00 2001 From: Joachim Breitner Date: Fri, 6 Mar 2026 06:44:51 +0000 Subject: [PATCH 45/45] doc: improve scope_cache documentation, make rewind private This commit replaces the scope_cache comment with a clearer description of the conceptual model (stack of hashmaps) and how the implementation inverts this for efficiency. Co-Authored-By: Claude Opus 4.6 --- src/kernel/scope_cache.h | 49 ++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/src/kernel/scope_cache.h b/src/kernel/scope_cache.h index 44364229cda6..3b0d6114bd95 100644 --- a/src/kernel/scope_cache.h +++ b/src/kernel/scope_cache.h @@ -13,20 +13,39 @@ Authors: Joachim Breitner namespace lean { /* -A scope-aware cache for use in traversals that carry a substitution -through nested scopes (e.g., delayed mvar resolution). - -Supports push/pop of scopes and caches Key → Value results. -Each scope gets a unique generation number. Cache entries store a -single snapshot of the scope generation list (a persistent linked -list) at insert time. On lookup, entries are lazily degraded by -walking the snapshot and current lists in lockstep, evicting entries -whose result depends on popped or re-entered scopes. - -The caller maintains a `result_scope` watermark: the maximum scope -level that contributed to a cached result. On insert, the caller -provides its `result_scope`; on lookup hit, the stored `result_scope` -is propagated back via a reference parameter. +Conceptually, the scope cache is a stack of `Key → (Value × Scope)` hashmaps. +The `Scope` is a counter indicating the lowest position in the stack for which +the entry is valid. + +Its purpose is to provide caching for an operation that: + * maintains scopes (e.g. local contexts, substitutions). Higher stack + positions correspond to inner, more local scopes. + * For a given key, the result may depend on all or part of that scope. + * At lookup time, it is not known whether the value for a key will depend on + all or part of the scope, so only entries for the current innermost scope + are considered. + * At insert time, it is known which outermost scope the result depends on + (the "result scope"), and the result is valid for all scopes between that + and the innermost scope. + +The operations are: + * push(): push a new (empty) hashmap onto the stack. + * pop(): pop the top hashmap from the stack. + * scope(): current size of the stack (i.e. the index of the innermost scope). + * lookup(key, result_scope): look up in the top hashmap, returning the value + and propagating its result scope into `result_scope` via max. + * insert(key, value, result_scope): insert a key-value pair into the hashmaps + in the stack at depths in the range from `result_scope` to `scope()`. If it + encounters an existing value along the way, uses and returns that value for + improved sharing. + +The implementation inverts the data structure to a hashmap of stacks for +efficiency. It uses a generation counter to assign a unique identifier to each +scope, and maintains a persistent linked list of these to represent the current +scope stack. Cache entries are not touched upon pop(); instead they are lazily +cleaned up when accessed (the `rewind` operation). Upon insert, instead of +duplicating the entry for all valid scopes, it stores one entry with the range +of scopes it is valid for. */ template> class scope_cache { @@ -74,6 +93,7 @@ class scope_cache { m_scope_gens_list = m_scope_gens_list->tail; } +private: /* Lazily clean up the top of a per-key entry stack: degrade entries whose scopes were popped and evict entries that are stale due to popped result scopes or scope re-entry. After rewind, either the @@ -117,6 +137,7 @@ class scope_cache { } } +public: /* Look up a cached result for the given key at the current scope. On hit, updates `result_scope = max(result_scope, entry.result_scope)` and returns the cached result. On miss, returns none. */