Skip to content

Commit 898391e

Browse files
Khaclaude
andcommitted
perf: share depth-invariant type class cache entries across synthPendingDepth
The type class resolution cache is keyed by `synthPendingDepth` (issue #2522), which partitions the whole cache by depth even though the depth can only influence a query through the `synthPending` give-up check. Track whether a query ever reached a `synthPending` decision (via a non-backtrackable `IO.Ref` in `Meta.Context`, so discarded search branches still count); results of queries that never did are now cached with `synthPendingDepth := none` and shared across all depths. Depth-sensitive results remain keyed by their exact depth as before. The `Meta.synthInstance.cache` trace now includes the depth when it is nonzero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 41b2fe8 commit 898391e

3 files changed

Lines changed: 253 additions & 20 deletions

File tree

src/Lean/Meta/Basic.lean

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -329,10 +329,19 @@ structure SynthInstanceCacheKey where
329329
localInsts : LocalInstances
330330
type : Expr
331331
/--
332-
Value of `synthPendingDepth` when instance was synthesized or failed to be synthesized.
333-
See issue #2522.
332+
Value of `synthPendingDepth` when the instance was synthesized or failed to be
333+
synthesized, or `none` if the result can be reused at other depths.
334+
335+
`synthPendingDepth` can influence the result of a query because `synthPending` gives up
336+
when `synthPendingDepth > maxSynthPendingDepth`, so a result may not be reused at a
337+
different depth (see issue #2522). However, this is the *only* way the depth can influence
338+
a query. Thus, if no `synthPending` invocation gave up while synthesizing an instance, the
339+
result is valid at every depth at which the guard still cannot trigger, and is stored with
340+
`synthPendingDepth := none` together with a validity bound in the cache entry (see
341+
`SynthInstanceCacheEntry.relSynthPendingDepth`). Only results whose synthesis hit the
342+
give-up threshold remain keyed by their exact depth.
334343
-/
335-
synthPendingDepth : Nat
344+
synthPendingDepth : Option Nat
336345
deriving Hashable, BEq
337346

338347
/-- Resulting type for `abstractMVars` -/
@@ -345,7 +354,20 @@ structure AbstractMVarsResult where
345354
def AbstractMVarsResult.numMVars (r : AbstractMVarsResult) : Nat :=
346355
r.mvars.size
347356

348-
abbrev SynthInstanceCache := PersistentHashMap SynthInstanceCacheKey (Option AbstractMVarsResult)
357+
/-- Entry of the type class resolution cache. -/
358+
structure SynthInstanceCacheEntry where
359+
/--
360+
Maximum `synthPendingDepth` at which a `synthPending` decision was reached during the
361+
synthesis, relative to the query's own `synthPendingDepth`, or `none` if no such decision
362+
was reached. An entry stored under `synthPendingDepth := none` may be reused by a query at
363+
depth `d` iff `relSynthPendingDepth` is `none` or `d + relSynthPendingDepth` does not
364+
exceed `maxSynthPendingDepth`: under that bound no `synthPending` invocation can reach the
365+
give-up threshold, so the synthesis behaves identically at depth `d`.
366+
-/
367+
relSynthPendingDepth : Option Nat := none
368+
result : Option AbstractMVarsResult
369+
370+
abbrev SynthInstanceCache := PersistentHashMap SynthInstanceCacheKey SynthInstanceCacheEntry
349371

350372
-- Key for `InferType` and `WHNF` caches
351373
structure ExprConfigCacheKey where
@@ -460,6 +482,17 @@ register_builtin_option maxSynthPendingDepth : Nat := {
460482
descr := "maximum number of nested `synthPending` invocations. When resolving unification constraints, pending type class problems may need to be synthesized. These type class problems may create new unification constraints that again require solving new type class problems. This option puts a threshold on how many nested problems are created."
461483
}
462484

485+
/--
486+
Accumulated `synthPending` decisions of a type class query, used by the type class
487+
resolution cache to bound the `synthPendingDepth` values at which the result may be reused.
488+
See `SynthInstanceCacheEntry.relSynthPendingDepth`.
489+
-/
490+
structure SynthPendingActivity where
491+
/-- Maximum `synthPendingDepth` at which a `synthPending` decision was reached. -/
492+
maxDepth : Option Nat := none
493+
/-- Whether some `synthPending` invocation gave up because of `maxSynthPendingDepth`. -/
494+
guardHit : Bool := false
495+
463496
/--
464497
Contextual information for the `MetaM` monad.
465498
-/
@@ -500,6 +533,15 @@ structure Context where
500533
Remark: `synthPending` fails if `synthPendingDepth > maxSynthPendingDepth`.
501534
-/
502535
synthPendingDepth : Nat := 0
536+
/--
537+
When set, the reference accumulates the `synthPending` decisions reached during the
538+
current type class query, i.e. behavior that may depend on `synthPendingDepth`. The type
539+
class resolution cache uses this to decide at which depths a result may be reused; see
540+
`SynthInstanceCacheKey.synthPendingDepth` and `SynthInstanceCacheEntry.relSynthPendingDepth`.
541+
The reference is intentionally not part of the backtrackable state: a `synthPending`
542+
invocation in a discarded search branch still influenced the search outcome.
543+
-/
544+
synthPendingActivityRef? : Option (IO.Ref SynthPendingActivity) := none
503545
/--
504546
A predicate to control whether a constant can be unfolded or not at `whnf`.
505547
Note that we do not cache results at `whnf` when `canUnfold?` is not `none`. -/

src/Lean/Meta/SynthInstance.lean

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -856,20 +856,23 @@ private def applyCachedAbstractResult? (type : Expr) (abstResult? : Option Abstr
856856
applyAbstractResult? type abstResult?
857857

858858
/-- Helper function for caching synthesized type class instances. -/
859-
private def cacheResult (cacheKey : SynthInstanceCacheKey) (kind : PreprocessKind) (abstResult? : Option AbstractMVarsResult) (result? : Option Expr) : MetaM Unit := do
860-
-- **TODO**: simplify this function.
859+
private def cacheResult (cacheKey : SynthInstanceCacheKey) (relSynthPendingDepth : Option Nat)
860+
(kind : PreprocessKind) (abstResult? : Option AbstractMVarsResult) (result? : Option Expr) : MetaM Unit := do
861+
let insert (result : Option AbstractMVarsResult) : MetaM Unit :=
862+
modify fun s => { s with
863+
cache.synthInstance := s.cache.synthInstance.insert cacheKey { relSynthPendingDepth, result } }
861864
match abstResult? with
862-
| none => modify fun s => { s with cache.synthInstance := s.cache.synthInstance.insert cacheKey none }
865+
| none => insert none
863866
| some abstResult =>
864867
if abstResult.numMVars == 0 && abstResult.paramNames.isEmpty && kind matches .noMVars | .mvarsNoOutputParams then
865868
match result? with
866-
| none => modify fun s => { s with cache.synthInstance := s.cache.synthInstance.insert cacheKey none }
869+
| none => insert none
867870
| some result =>
868871
-- See `applyCachedAbstractResult?` If new metavariables have **not** been introduced,
869872
-- we don't need to perform extra checks again when reusing result.
870-
modify fun s => { s with cache.synthInstance := s.cache.synthInstance.insert cacheKey (some { expr := result, paramNames := #[], mvars := #[] }) }
873+
insert (some { expr := result, paramNames := #[], mvars := #[] })
871874
else
872-
modify fun s => { s with cache.synthInstance := s.cache.synthInstance.insert cacheKey (some abstResult) }
875+
insert (some abstResult)
873876

874877
def synthInstanceCore? (type : Expr) (maxResultSize? : Option Nat := none) : MetaM (Option Expr) := do
875878
let opts ← getOptions
@@ -882,16 +885,51 @@ def synthInstanceCore? (type : Expr) (maxResultSize? : Option Nat := none) : Met
882885
let localInsts ← getLocalInstances
883886
let type ← instantiateMVars type
884887
let { type, cacheKeyType, kind } ← preprocess type
885-
let cacheKey := { localInsts, type := cacheKeyType, synthPendingDepth := (← read).synthPendingDepth }
886-
match (← get).cache.synthInstance.find? cacheKey with
887-
| some abstResult? =>
888-
trace[Meta.synthInstance.cache] "cached: {type}"
889-
let result? ← applyCachedAbstractResult? type abstResult?
888+
let synthPendingDepth := (← read).synthPendingDepth
889+
let depthSuffix : MessageData :=
890+
if synthPendingDepth == 0 then m!"" else m!" (synthPendingDepth := {synthPendingDepth})"
891+
-- Fold `synthPending` activity into the enclosing query's accumulator, if any.
892+
let foldActivity (activity : SynthPendingActivity) : MetaM Unit := do
893+
if activity.maxDepth.isSome || activity.guardHit then
894+
if let some ref := (← read).synthPendingActivityRef? then
895+
ref.modify fun a => {
896+
maxDepth := match a.maxDepth, activity.maxDepth with
897+
| some d₁, some d₂ => some (d₁.max d₂)
898+
| some d, none | none, some d => some d
899+
| none, none => none
900+
guardHit := a.guardHit || activity.guardHit }
901+
let applyCached (entry : SynthInstanceCacheEntry) : MetaM (Option Expr) := do
902+
trace[Meta.synthInstance.cache] "cached{depthSuffix}: {type}"
903+
let result? ← applyCachedAbstractResult? type entry.result
890904
trace[Meta.synthInstance] "result {result?} (cached)"
891905
return result?
892-
| none =>
893-
trace[Meta.synthInstance.cache] "new: {type}"
894-
let abstResult? ← withNewMCtxDepth (allowLevelAssignments := true) do
906+
let sharedKey : SynthInstanceCacheKey := { localInsts, type := cacheKeyType, synthPendingDepth := none }
907+
let depthKey := { sharedKey with synthPendingDepth := some synthPendingDepth }
908+
let maxSynthPending := maxSynthPendingDepth.get (← getOptions)
909+
let sharedEntry? :=
910+
match (← get).cache.synthInstance.find? sharedKey with
911+
| some entry =>
912+
match entry.relSynthPendingDepth with
913+
| none => some entry
914+
| some rel =>
915+
-- Valid iff no `synthPending` invocation can reach the give-up threshold at the
916+
-- current depth; see `SynthInstanceCacheEntry.relSynthPendingDepth`.
917+
if synthPendingDepth + rel ≤ maxSynthPending then some entry else none
918+
| none => none
919+
if let some entry := sharedEntry? then
920+
-- Reusing the entry re-enacts its `synthPending` decisions at the current depth.
921+
foldActivity { maxDepth := entry.relSynthPendingDepth.map (synthPendingDepth + ·) }
922+
applyCached entry
923+
else if let some entry := (← get).cache.synthInstance.find? depthKey then
924+
-- The entry's synthesis hit the `maxSynthPendingDepth` give-up, so reusing it keeps
925+
-- the enclosing query depth-exact as well.
926+
foldActivity { maxDepth := some synthPendingDepth, guardHit := true }
927+
applyCached entry
928+
else
929+
trace[Meta.synthInstance.cache] "new{depthSuffix}: {type}"
930+
let activityRef ← IO.mkRef {}
931+
let abstResult? ← withReader (fun ctx => { ctx with synthPendingActivityRef? := some activityRef }) do
932+
withNewMCtxDepth (allowLevelAssignments := true) do
895933
match kind with
896934
| .noMVars =>
897935
/-
@@ -918,7 +956,12 @@ def synthInstanceCore? (type : Expr) (maxResultSize? : Option Nat := none) : Met
918956
| .mvarsOutputParams => SynthInstance.main (← preprocessOutParam type) maxResultSize
919957
let result? ← applyAbstractResult? type abstResult?
920958
trace[Meta.synthInstance] "result {result?}"
921-
cacheResult cacheKey kind abstResult? result?
959+
let activity ← activityRef.get
960+
foldActivity activity
961+
-- Results whose synthesis hit the `maxSynthPendingDepth` give-up are only valid at
962+
-- the exact depth; all others are shared, bounded by their relative activity depth.
963+
let key := if activity.guardHit then depthKey else sharedKey
964+
cacheResult key (activity.maxDepth.map (· - synthPendingDepth)) kind abstResult? result?
922965
return result?
923966

924967
def synthInstance? (type : Expr) (maxResultSize? : Option Nat := none) : MetaM (Option Expr) := do profileitM Exception "typeclass inference" (← getOptions) (decl := type.getAppFn.constName?.getD .anonymous) do
@@ -957,8 +1000,15 @@ private def synthPendingImp (mvarId : MVarId) : MetaM Bool := withIncRecDepth <|
9571000
| none =>
9581001
return false
9591002
| some _ =>
1003+
let depth := (← read).synthPendingDepth
1004+
-- Record the `synthPending` decision reached at `depth`; the enclosing type class
1005+
-- query's cache entry (if any) is only valid at depths where it comes out the same.
1006+
if let some ref := (← read).synthPendingActivityRef? then
1007+
ref.modify fun a => { a with maxDepth := some ((a.maxDepth.getD 0).max depth) }
9601008
let max := maxSynthPendingDepth.get (← getOptions)
961-
if (← read).synthPendingDepth > max then
1009+
if depth > max then
1010+
if let some ref := (← read).synthPendingActivityRef? then
1011+
ref.modify fun a => { a with guardHit := true }
9621012
trace[Meta.synthPending] "too many nested synthPending invocations"
9631013
recordSynthPendingFailure mvarDecl.type
9641014
return false
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import Lean
2+
3+
/-!
4+
Fine-grained `synthPendingDepth` handling in the type-class resolution cache
5+
(`SynthInstanceCacheKey.synthPendingDepth : Option Nat`).
6+
7+
Cache entries whose synthesis never reached a `synthPending` decision are depth-invariant
8+
and shared across `synthPendingDepth` levels. Entries whose synthesis reached decisions up
9+
to relative depth `r` (without any giving up) are shared with queries at depth `d` as long
10+
as `d + r ≤ maxSynthPendingDepth`, i.e. as long as no decision can come out differently.
11+
Entries whose synthesis gave up at `maxSynthPendingDepth` remain keyed by their exact
12+
depth, preserving the fix for issue #2522.
13+
14+
`Meta.synthPending` fires when unification is stuck on a pending instance metavariable,
15+
e.g. a class projection applied to an unassigned instance mvar. The tests below construct
16+
pending mvars by hand and simulate nested queries by setting `synthPendingDepth` directly.
17+
The taint tests use a *local* instance because local instances are candidates regardless
18+
of discrimination-tree keys, so a stuck projection in its type meets the rigid goal during
19+
`tryResolve`.
20+
-/
21+
22+
open Lean Meta
23+
24+
structure Wr (α : Type) : Type
25+
26+
class LeafT (α : Type) where T : Type
27+
instance iLeafT : LeafT (Wr Nat) := ⟨Nat⟩
28+
29+
class Out (α : Type) (β : outParam Type) where
30+
instance iOut : Out (Wr Nat) Nat := ⟨⟩
31+
32+
class Root (α : Type) where
33+
34+
def wrNat : Expr := mkApp (mkConst ``Wr) (mkConst ``Nat)
35+
def leafTWr : Expr := mkApp (mkConst ``LeafT) wrNat
36+
def projT (h : Expr) : Expr := mkApp2 (mkConst ``LeafT.T) wrNat h
37+
def rootNat : Expr := mkApp (mkConst ``Root) (mkConst ``Nat)
38+
39+
def withTCTrace (x : MetaM α) : MetaM α :=
40+
withOptions (fun o =>
41+
(o.setBool `trace.Meta.synthInstance.cache true).setBool `trace.Meta.synthPending true) x
42+
43+
def atDepth (d : Nat) (x : MetaM α) : MetaM α :=
44+
withReader (fun ctx : Meta.Context => { ctx with synthPendingDepth := d }) x
45+
46+
set_option maxSynthPendingDepth 1
47+
48+
/-!
49+
`LeafT (Wr Nat)` synthesis never reaches a `synthPending` decision, so its cache entry is
50+
depth-invariant: computed at depth 0, it is reused at depths 1 and 2.
51+
-/
52+
/--
53+
trace: [Meta.synthInstance.cache] new: LeafT (Wr Nat)
54+
[Meta.synthInstance.cache] cached (synthPendingDepth := 1): LeafT (Wr Nat)
55+
[Meta.synthInstance.cache] cached (synthPendingDepth := 2): LeafT (Wr Nat)
56+
-/
57+
#guard_msgs in
58+
run_meta withTCTrace do
59+
discard <| synthInstance? leafTWr
60+
discard <| atDepth 1 do synthInstance? leafTWr
61+
discard <| atDepth 2 do synthInstance? leafTWr
62+
63+
/-!
64+
A query with a stuck projection in an `outParam` position: the search itself is
65+
depth-invariant (the out-param is replaced by a fresh mvar before the search), and the
66+
depth-sensitive `synthPending` happens while assigning the out-params to the caller's
67+
arguments, which is re-run on every cache hit. Hence the `Out` entry is shared with the
68+
depth-1 query, and the nested `LeafT (Wr Nat)` query (depth 1 first, then depth 2 on the
69+
cache hit) reuses the depth-invariant entry as well.
70+
-/
71+
/--
72+
trace: [Meta.synthInstance.cache] new: Out (Wr Nat) (LeafT.T (Wr Nat))
73+
[Meta.synthPending] synthPending ?m.1
74+
[Meta.synthInstance.cache] new (synthPendingDepth := 1): LeafT (Wr Nat)
75+
[Meta.synthInstance.cache] cached (synthPendingDepth := 1): Out (Wr Nat) (LeafT.T (Wr Nat))
76+
[Meta.synthPending] synthPending ?m.2
77+
[Meta.synthInstance.cache] cached (synthPendingDepth := 2): LeafT (Wr Nat)
78+
-/
79+
#guard_msgs in
80+
run_meta withTCTrace do
81+
discard <| synthInstance? (mkApp2 (mkConst ``Out) wrNat (projT (← mkFreshExprMVar leafTWr)))
82+
discard <| atDepth 1 do
83+
synthInstance? (mkApp2 (mkConst ``Out) wrNat (projT (← mkFreshExprMVar leafTWr)))
84+
85+
/-!
86+
A local instance of type `Root (LeafT.T (Wr Nat) ?hP)` forces `synthPending ?hP` *inside*
87+
the search for the rigid goal `Root Nat`. The `synthPending` decision is reached at
88+
relative depth 0 and does not give up, so the depth-0 entry is valid for depths `d` with
89+
`d + 0 ≤ maxSynthPendingDepth`: it is reused at depths 0 and 1, but not at depth 2. (The
90+
depth-2 recomputation finds `?hP` already assigned by the first query, so it performs no
91+
`synthPending` at all and its entry is unbounded.)
92+
-/
93+
/--
94+
trace: [Meta.synthInstance.cache] new: Root Nat
95+
[Meta.synthPending] synthPending ?m.1
96+
[Meta.synthInstance.cache] new (synthPendingDepth := 1): LeafT (Wr Nat)
97+
[Meta.synthPending] synthPending ?m.1
98+
[Meta.synthInstance.cache] cached (synthPendingDepth := 1): LeafT (Wr Nat)
99+
[Meta.synthInstance.cache] cached: Root Nat
100+
[Meta.synthInstance.cache] cached (synthPendingDepth := 1): Root Nat
101+
[Meta.synthInstance.cache] new (synthPendingDepth := 2): Root Nat
102+
-/
103+
#guard_msgs in
104+
run_meta do
105+
let hP ← mkFreshExprMVar leafTWr
106+
withLocalDecl `linst .instImplicit (mkApp (mkConst ``Root) (projT hP)) fun _ => withTCTrace do
107+
discard <| synthInstance? rootNat
108+
discard <| synthInstance? rootNat
109+
discard <| atDepth 1 do synthInstance? rootNat
110+
discard <| atDepth 2 do synthInstance? rootNat
111+
112+
/-!
113+
Issue #2522: at depth 2, `synthPending` gives up (`synthPendingDepth > maxSynthPendingDepth`)
114+
and the query *fails*. Here the failure aborts the search with an `isDefEqStuck` exception,
115+
so nothing is cached at all (both depth-2 queries recompute), and in particular the depth-0
116+
query is unaffected by the depth-2 failures and succeeds.
117+
-/
118+
/--
119+
info: depth 2: failed
120+
---
121+
info: depth 0: synthesized
122+
---
123+
trace: [Meta.synthInstance.cache] new (synthPendingDepth := 2): Root Nat
124+
[Meta.synthPending] too many nested synthPending invocations
125+
[Meta.synthInstance.cache] new (synthPendingDepth := 2): Root Nat
126+
[Meta.synthPending] too many nested synthPending invocations
127+
[Meta.synthInstance.cache] new: Root Nat
128+
[Meta.synthPending] synthPending ?m.1
129+
[Meta.synthInstance.cache] new (synthPendingDepth := 1): LeafT (Wr Nat)
130+
[Meta.synthPending] synthPending ?m.1
131+
[Meta.synthInstance.cache] cached (synthPendingDepth := 1): LeafT (Wr Nat)
132+
-/
133+
#guard_msgs in
134+
run_meta do
135+
let hP ← mkFreshExprMVar leafTWr
136+
withLocalDecl `linst .instImplicit (mkApp (mkConst ``Root) (projT hP)) fun _ => withTCTrace do
137+
let r2 ← atDepth 2 do trySynthInstance rootNat
138+
let r2' ← atDepth 2 do trySynthInstance rootNat
139+
logInfo m!"depth 2: {if r2.toOption.isSome || r2'.toOption.isSome then "synthesized" else "failed"}"
140+
let r0 ← synthInstance? rootNat
141+
logInfo m!"depth 0: {if r0.isSome then "synthesized" else "failed"}"

0 commit comments

Comments
 (0)