Skip to content

Commit 3fceb1d

Browse files
Khaclaude
andcommitted
perf: bound type class cache entry reuse by relative synthPending depth
Instead of a single depth-invariant class, each shared type class resolution cache entry now records the maximal `synthPendingDepth` (relative to the query) at which a `synthPending` decision was reached during synthesis, and is reused at any depth at which no such decision can reach the `maxSynthPendingDepth` give-up threshold. Only results whose synthesis actually hit the threshold remain keyed to their exact depth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9f9995c commit 3fceb1d

3 files changed

Lines changed: 107 additions & 43 deletions

File tree

src/Lean/Meta/Basic.lean

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -329,14 +329,17 @@ structure SynthInstanceCacheKey where
329329
localInsts : LocalInstances
330330
type : Expr
331331
/--
332-
Value of `synthPendingDepth` when instance was synthesized or failed to be synthesized,
333-
or `none` if the result is depth-invariant.
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.
334334
335335
`synthPendingDepth` can influence the result of a query because `synthPending` gives up
336336
when `synthPendingDepth > maxSynthPendingDepth`, so a result may not be reused at a
337337
different depth (see issue #2522). However, this is the *only* way the depth can influence
338-
a query. Thus, if no `synthPending` decision was reached while synthesizing an instance,
339-
the result is valid at every depth and is stored with `synthPendingDepth := none`.
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.
340343
-/
341344
synthPendingDepth : Option Nat
342345
deriving Hashable, BEq
@@ -351,7 +354,20 @@ structure AbstractMVarsResult where
351354
def AbstractMVarsResult.numMVars (r : AbstractMVarsResult) : Nat :=
352355
r.mvars.size
353356

354-
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
355371

356372
-- Key for `InferType` and `WHNF` caches
357373
structure ExprConfigCacheKey where
@@ -466,6 +482,17 @@ register_builtin_option maxSynthPendingDepth : Nat := {
466482
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."
467483
}
468484

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+
469496
/--
470497
Contextual information for the `MetaM` monad.
471498
-/
@@ -507,13 +534,14 @@ structure Context where
507534
-/
508535
synthPendingDepth : Nat := 0
509536
/--
510-
When set, the reference is set to `true` as soon as a `synthPending` decision is reached,
511-
i.e. behavior that may depend on `synthPendingDepth`. The type class resolution cache uses
512-
this to decide whether a result is depth-invariant; see `SynthInstanceCacheKey.synthPendingDepth`.
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`.
513541
The reference is intentionally not part of the backtrackable state: a `synthPending`
514542
invocation in a discarded search branch still influenced the search outcome.
515543
-/
516-
synthPendingActivityRef? : Option (IO.Ref Bool) := none
544+
synthPendingActivityRef? : Option (IO.Ref SynthPendingActivity) := none
517545
/--
518546
A predicate to control whether a constant can be unfolded or not at `whnf`.
519547
Note that we do not cache results at `whnf` when `canUnfold?` is not `none`. -/

src/Lean/Meta/SynthInstance.lean

Lines changed: 57 additions & 28 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
@@ -885,23 +888,46 @@ def synthInstanceCore? (type : Expr) (maxResultSize? : Option Nat := none) : Met
885888
let synthPendingDepth := (← read).synthPendingDepth
886889
let depthSuffix : MessageData :=
887890
if synthPendingDepth == 0 then m!"" else m!" (synthPendingDepth := {synthPendingDepth})"
888-
let applyCached (abstResult? : Option AbstractMVarsResult) : MetaM (Option Expr) := do
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
889902
trace[Meta.synthInstance.cache] "cached{depthSuffix}: {type}"
890-
let result? ← applyCachedAbstractResult? type abstResult?
903+
let result? ← applyCachedAbstractResult? type entry.result
891904
trace[Meta.synthInstance] "result {result?} (cached)"
892905
return result?
893-
let invariantKey : SynthInstanceCacheKey := { localInsts, type := cacheKeyType, synthPendingDepth := none }
894-
let depthKey := { invariantKey with synthPendingDepth := some synthPendingDepth }
895-
if let some abstResult? := (← get).cache.synthInstance.find? invariantKey then
896-
applyCached abstResult?
897-
else if let some abstResult? := (← get).cache.synthInstance.find? depthKey then
898-
-- Reusing a depth-sensitive entry makes the enclosing query depth-sensitive as well.
899-
if let some ref := (← read).synthPendingActivityRef? then
900-
ref.set true
901-
applyCached abstResult?
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
902928
else
903929
trace[Meta.synthInstance.cache] "new{depthSuffix}: {type}"
904-
let activityRef ← IO.mkRef false
930+
let activityRef ← IO.mkRef {}
905931
let abstResult? ← withReader (fun ctx => { ctx with synthPendingActivityRef? := some activityRef }) do
906932
withNewMCtxDepth (allowLevelAssignments := true) do
907933
match kind with
@@ -930,12 +956,12 @@ def synthInstanceCore? (type : Expr) (maxResultSize? : Option Nat := none) : Met
930956
| .mvarsOutputParams => SynthInstance.main (← preprocessOutParam type) maxResultSize
931957
let result? ← applyAbstractResult? type abstResult?
932958
trace[Meta.synthInstance] "result {result?}"
933-
let depthSensitive ← activityRef.get
934-
if depthSensitive then
935-
-- Propagate depth sensitivity to the enclosing query, if any.
936-
if let some ref := (← read).synthPendingActivityRef? then
937-
ref.set true
938-
cacheResult (if depthSensitive then depthKey else invariantKey) 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?
939965
return result?
940966

941967
def synthInstance? (type : Expr) (maxResultSize? : Option Nat := none) : MetaM (Option Expr) := do profileitM Exception "typeclass inference" (← getOptions) (decl := type.getAppFn.constName?.getD .anonymous) do
@@ -974,12 +1000,15 @@ private def synthPendingImp (mvarId : MVarId) : MetaM Bool := withIncRecDepth <|
9741000
| none =>
9751001
return false
9761002
| some _ =>
977-
-- From here on, behavior depends on `synthPendingDepth`; mark the enclosing type
978-
-- class query (if any) as depth-sensitive for caching purposes.
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.
9791006
if let some ref := (← read).synthPendingActivityRef? then
980-
ref.set true
1007+
ref.modify fun a => { a with maxDepth := some ((a.maxDepth.getD 0).max depth) }
9811008
let max := maxSynthPendingDepth.get (← getOptions)
982-
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 }
9831012
trace[Meta.synthPending] "too many nested synthPending invocations"
9841013
recordSynthPendingFailure mvarDecl.type
9851014
return false

tests/elab/tc_cache_synth_pending_depth.lean

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ Fine-grained `synthPendingDepth` handling in the type-class resolution cache
55
(`SynthInstanceCacheKey.synthPendingDepth : Option Nat`).
66
77
Cache entries whose synthesis never reached a `synthPending` decision are depth-invariant
8-
and shared across `synthPendingDepth` levels. Entries whose synthesis did reach one
9-
(including giving up at `maxSynthPendingDepth`) remain keyed by their exact depth,
10-
preserving the fix for issue #2522.
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.
1113
1214
`Meta.synthPending` fires when unification is stuck on a pending instance metavariable,
1315
e.g. a class projection applied to an unassigned instance mvar. The tests below construct
@@ -82,8 +84,11 @@ run_meta withTCTrace do
8284

8385
/-!
8486
A local instance of type `Root (LeafT.T (Wr Nat) ?hP)` forces `synthPending ?hP` *inside*
85-
the search for the rigid goal `Root Nat`, so the entry is depth-sensitive: the depth-0
86-
entry is reused at depth 0 but not at depth 1.
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.)
8792
-/
8893
/--
8994
trace: [Meta.synthInstance.cache] new: Root Nat
@@ -92,7 +97,8 @@ trace: [Meta.synthInstance.cache] new: Root Nat
9297
[Meta.synthPending] synthPending ?m.1
9398
[Meta.synthInstance.cache] cached (synthPendingDepth := 1): LeafT (Wr Nat)
9499
[Meta.synthInstance.cache] cached: Root Nat
95-
[Meta.synthInstance.cache] new (synthPendingDepth := 1): Root Nat
100+
[Meta.synthInstance.cache] cached (synthPendingDepth := 1): Root Nat
101+
[Meta.synthInstance.cache] new (synthPendingDepth := 2): Root Nat
96102
-/
97103
#guard_msgs in
98104
run_meta do
@@ -101,6 +107,7 @@ run_meta do
101107
discard <| synthInstance? rootNat
102108
discard <| synthInstance? rootNat
103109
discard <| atDepth 1 do synthInstance? rootNat
110+
discard <| atDepth 2 do synthInstance? rootNat
104111

105112
/-!
106113
Issue #2522: at depth 2, `synthPending` gives up (`synthPendingDepth > maxSynthPendingDepth`)

0 commit comments

Comments
 (0)