Zulip Chat Archive
Stream: lean4
Topic: Run tactics in parallel (asynchronously)
Son Ho (Jun 30 2025 at 08:59):
Is there a way of running tactics in parallel? For instance, given a list of MVarIds and a list of tactics to solve those subgoals, I would like to run the tactics on those subgoals in an asynchronous manner. As a side note: I actually tried implementing something which goes in that direction, but it doesn't work extremely well (in particular I have random failures on some of the subgoals because the asynchronous computations seem to mess up with the heartbeats count - I can share the code if people are interested to look at it).
Sebastian Ullrich (Jun 30 2025 at 09:34):
For a robust solution you will want to build on Lean.Core.wrapAsync, extending that to TacticM. Depending on how you want to handle messages created by the async tactics, you might also want to consider the AsSnapshot variants.
Sebastian Ullrich (Jun 30 2025 at 09:34):
Needless to say, I am very interested in the outcome, so please let me know if you get stuck :)
Son Ho (Jun 30 2025 at 11:51):
Thanks! I had attempted to mimic what Lean.Core.wrapAsync does, but building my tactics directly on top of this function solves the issue with the heart beats (I don't know why). Here is what I came up with :)
import Lean
open Lean Elab Term Meta Tactic
/-!
# Asynchronous execution
-/
/--
One issue with tactics like `omega` is that they wrap proofs in auxiliary theorems.
As a temporary workaround I'm using this helper to inline the proof terms.
-/
def inlineFreshProofs (env0 : Environment) (e : Expr) : MetaM Expr := do
let rec inline (e : Expr) : MetaM Expr := do
match e with
| .bvar _ | .fvar _ | .mvar _ | .sort _ | .lit _ => pure e
| .const declName us =>
if env0.constants.contains declName then pure e
else
-- We need to inline this constant
let const ← pure ((← getEnv).constants.find! declName)
let some body := const.value? (allowOpaque := true)
| throwError "Could not inline constant: {e}"
-- Replace the levels in the body
let levels := const.levelParams
let levels := Std.HashMap.ofList (List.zip (List.map Level.param levels) us)
let body := body.replaceLevel (Std.HashMap.get? levels)
pure body
| .app fn arg => pure (.app (← inline fn) (← inline arg))
| .lam name ty body info =>
pure (.lam name (← inline ty) (← inline body) info)
| .forallE name ty body info =>
pure (.forallE name (← inline ty) (← inline body) info)
| .letE name ty value body nonDep =>
pure (.letE name (← inline ty) (← inline value) (← inline body) nonDep)
| .mdata data e => pure (.mdata data (← inline e))
| .proj name idx struct =>
pure (.proj name idx (← inline struct))
inline (← instantiateMVars e)
def wrapTactic {α : Type} (tactic : α → TacticM Unit) (cancelTk? : Option IO.CancelToken) :
TacticM (α → BaseIO (Except Exception (Option Expr))) := do
let env0 ← getEnv
-- The code below is adapted from `Lean.Elab.Tactic.tacticToDischarge` --
let ref ← IO.mkRef (← getThe Term.State)
let ctx ← readThe Term.Context
let mvar ← mkFreshExprSyntheticOpaqueMVar (← getMainTarget) `simp.discharger
let s ← ref.get
let runTac? (x : α) : TermElabM (Option Expr) :=
try
--Term.withoutModifyingElabMetaStateWithInfo do
let ngoals ← --Term.withSynthesize (postpone := .no) do
Tactic.run mvar.mvarId! (tactic x)
if ngoals.isEmpty then
let result ← instantiateMVars mvar
if result.hasExprMVar then
return none
else
let result ← inlineFreshProofs env0 result
return some result
else return none
catch _ =>
return none
--------------------------------------------------------------------------
let metaCtx ← readThe Meta.Context
let metaState ← getThe Meta.State
let tac (x : α) := do
MetaM.run' (ctx := metaCtx) (s := metaState)
<| Term.TermElabM.run' (runTac? x) ctx s
let tac ← Lean.Core.wrapAsync tac cancelTk?
pure (fun x => (tac x).toBaseIO)
/- Run `tac` on the current goals in parallel -/
def allGoalsAsync' (tac : TacticM Unit) : TacticM Unit := do
let mvarIds ← getGoals
let mut tasks := #[]
-- Create tasks
for mvarId in mvarIds do
unless (← mvarId.isAssigned) do
setGoals [mvarId]
-- TODO: how to check for task cancellation?
let tac ← pure ((← wrapTactic (fun () => tac) none) ())
tasks := tasks.push (← (BaseIO.asTask tac))
-- Wait for the tasks
let mut unsolved := #[]
for (i, (mvarId, task)) in (List.zip mvarIds tasks.toList).mapIdx (fun id x => (id, x)) do
match task.get with
| .error e =>
logInfo m!"Task {i}: exception: {e.toMessageData}"
unsolved := unsolved.push mvarId
| .ok x =>
-- TODO: how to propagate the information from the `Core.State`? (in particular, I
-- want to preserve the log)
match x with
| .none =>
logInfo m!"Task {i}: tactic failed"
unsolved := unsolved.push mvarId
| .some x =>
/- Successfully generated a proof! Assign the meta-variable -/
logInfo m!"Task {i}: solved!"
/- TODO: would it be interesting to introduce theorems for all the proofs
of the subgoals, so that they get type-checked in parallel? -/
mvarId.assign x
setGoals unsolved.toList
/-!
# Test
-/
/-!
## Small helpers, unrelated to asynchronous executions
-/
-- Copy/pasted from Mathlib to make this file more self-containted
def allGoals (tac : TacticM Unit) : TacticM Unit := do
let mvarIds ← getGoals
let mut mvarIdsNew := #[]
for mvarId in mvarIds do
unless (← mvarId.isAssigned) do
setGoals [mvarId]
try
tac
mvarIdsNew := mvarIdsNew ++ (← getUnsolvedGoals)
catch ex =>
if (← read).recover then
logException ex
mvarIdsNew := mvarIdsNew.push mvarId
else
throw ex
setGoals mvarIdsNew.toList
-- Repeatedly apply a tactic
partial def repeatTac (tac : TacticM Unit) : TacticM Unit := do
try
tac
allGoals (focus (repeatTac tac))
-- TODO: does this restore the state?
catch _ => pure ()
def optSplitConj (e : Expr) : Expr × Option Expr :=
e.consumeMData.withApp fun f args =>
if f.isConstOf ``And ∧ args.size = 2 then (args[0]!, some (args[1]!))
else (e, none)
-- Split the goal if it is a conjunction
def splitConjTarget : TacticM Unit := do
withMainContext do
let g ← getMainTarget
-- The tactic was initially implemened with `_root_.Lean.MVarId.apply`
-- but it tended to mess the goal by unfolding terms, even when it failed
let (l, r) := optSplitConj g
match r with
| none => do throwError "The goal is not a conjunction"
| some r => do
let lmvar ← mkFreshExprSyntheticOpaqueMVar l
let rmvar ← mkFreshExprSyntheticOpaqueMVar r
let and_intro ← mkAppM ``And.intro #[lmvar, rmvar]
let g ← getMainGoal
g.assign and_intro
let goals ← getUnsolvedGoals
setGoals (lmvar.mvarId! :: rmvar.mvarId! :: goals)
/-!
## The test
-/
/- Solve the goal by splitting the conjunctions -/
def trySync : TacticM Unit := do
repeatTac splitConjTarget
allGoals (do
--IO.sleep 100
Tactic.Omega.omegaTactic {})
syntax "sync_solve" : tactic
elab "sync_solve" : tactic => do trySync
def tryAsync : TacticM Unit := do
repeatTac splitConjTarget
allGoalsAsync' (do
--IO.sleep 100
Tactic.Omega.omegaTactic {})
syntax "async_solve" : tactic
elab "async_solve" : tactic => do tryAsync
abbrev goal (x y : Nat) : Prop :=
x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧
x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧
x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧
x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧
x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧
x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧
x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧
x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧
x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧
x + y < 30 ∧ x + y < 30 ∧ x + y < 30 ∧ x + y < 30
set_option trace.profiler true in
def test (x y : Nat) (h : x < 10) (h : y < 20) : goal x y
:= by
/- The async execution is actually slower than the synchronous one (I get
~0.30s with a quite high variance versus 0.10s), but when using a more
expensive tactic on a more difficult goal (I'm not doing this here for
the purpose of keeping the file self-contained) I do have a speedup
(I need to do more benchmarks).
-/
unfold goal
async_solve -- compare with `sync_solve`
Son Ho (Jun 30 2025 at 12:02):
I do have a few questions though:
- as you can see from the code, I have an issue with the fact that tactics like
omegaintroduce auxiliary theorems. What would be the best way of handling this? I'm not sure about how to retrieve the list of fresh theorems, and how to add them to the main environment. (I just need pointers) following what is being done inI managed to make this workMutualDef.lean, I believe I should use promises to turnwrapAsyncAsSnapshotinto something which outputs arbitrary values. Also, in order to preserve the log all I need to do is to give the snapshot tree toCore.logSnapshotTask. Is this correct?- finally, I would like to introduce auxiliary theorems for the proofs generated by the tactics. Do you have some pointers to do this in an asynchronous manner (this may be redundant with my first question)?
𝚠𝚘𝚓𝚌𝚒𝚎𝚌𝚑 𝚗𝚊𝚠𝚛𝚘𝚌𝚔𝚒 (Jul 06 2025 at 03:19):
I'm not sure about how to retrieve the list of fresh theorems,
I am guessing you mean the list of theorems introduced by a tactic that you ran via Tactic.run or a similar mechanism? You could look at the implementation of whatsnew for that. If you were doing everything synchronously, then I think you wouldn't have to do this because omega would just modify the Environment and the TacticM state would pass the new Environment through. With async elaboration, you may have to commit the async environment branch containing all those new theorems.
I would like to introduce auxiliary theorems for the proofs generated by the tactics. Do you have some pointers to do this in an asynchronous manner
Afaict the mechanism for adding new constants to the environment asynchronously is docs#Lean.Environment.addConstAsync. The AddConstAsyncResult structure contains two Environments: the 'main' one which will ultimately contain all the constants you add, and an 'async branch' which is what the other thread should add its constants into. Then you can you use commitConst and commitCheckEnv (I think the difference between these two is that the latter commits a kernel-checked environment, and the former just the (possibly malformed) Lean-userspace-environment) to send results from async construction back to the main thread. addConstAsync can only add one constant, it seems like, but you can nest calls to it to commit multiple constants.
As far as adding new lemmas itself is concerned (sync or async), I would look at as_aux_lemma. I started writing an async version of this tactic (in the sense that it would commit the lemma's signature to the main thread immediately, but then continue checking the proof asynchronously) but ran into issues with interactive feedback as in this thread.
𝚠𝚘𝚓𝚌𝚒𝚎𝚌𝚑 𝚗𝚊𝚠𝚛𝚘𝚌𝚔𝚒 (Jul 06 2025 at 03:42):
One think I'm not sure how to deal with is that addConstAsync expects you to give it the name of the async constant before you launch the async task. But if you want to asyncly add a bunch of theorems dumped by a tactic, you will not know their names (and possibly their kinds) a priori. Then maybe you have to do write some more complex, manual Promise-based code.
This is speculative, so I welcome corrections from the core devs.
Sebastian Ullrich (Jul 06 2025 at 08:28):
The given name is used to collect any asynchronously added constants, you don't need to know those a priori. You can use an auxiliary decl for addConstAsync but if we're already in an asynchronous context, it might be possible to extend the API so that this intermediary constant is not needed.
Son Ho (Jul 07 2025 at 07:45):
𝚠𝚘𝚓𝚌𝚒𝚎𝚌𝚑 𝚗𝚊𝚠𝚛𝚘𝚌𝚔𝚒 said:
You could look at the implementation of
whatsnewfor that
Yes, that's what I need to do, but I hoped there would be a way of achieving it without performing a linear scan of the environment? This seems to be what I need, thanks! If I understand correctly, this function ignores the imported definitions, it only goes through the ones local to the file (because of this line)?
Son Ho (Jul 07 2025 at 07:47):
𝚠𝚘𝚓𝚌𝚒𝚎𝚌𝚑 𝚗𝚊𝚠𝚛𝚘𝚌𝚔𝚒 said:
If you were doing everything synchronously, then I think you wouldn't have to do this because
omegawould just modify theEnvironmentand theTacticMstate would pass the newEnvironmentthrough. With async elaboration, you may have to commit the async environment branch containing all those new theorems.
Yes indeed, the problem only arises in an asynchronous setting.
Son Ho (Jul 07 2025 at 07:54):
𝚠𝚘𝚓𝚌𝚒𝚎𝚌𝚑 𝚗𝚊𝚠𝚛𝚘𝚌𝚔𝚒 said:
As far as adding new lemmas itself is concerned (sync or async), I would look at
as_aux_lemma. I started writing an async version of this tactic (in the sense that it would commit the lemma's signature to the main thread immediately, but then continue checking the proof asynchronously) but ran into issues with interactive feedback as in this thread.
Thanks for the pointer! I actually ended up using mkAuxTheorem which I believe is asynchronous (it calls this function under the hood)?
𝚠𝚘𝚓𝚌𝚒𝚎𝚌𝚑 𝚗𝚊𝚠𝚛𝚘𝚌𝚔𝚒 (Jul 07 2025 at 17:13):
this function ignores the imported definitions, it only goes through the ones local to the file
Yeah; that's fine for you, right? You can only import definitions at the top of a module in the import block anyway, not after that.
𝚠𝚘𝚓𝚌𝚒𝚎𝚌𝚑 𝚗𝚊𝚠𝚛𝚘𝚌𝚔𝚒 (Jul 07 2025 at 17:15):
Son Ho said:
Thanks for the pointer! I actually ended up using
mkAuxTheoremwhich I believe is asynchronous (it calls this function under the hood)?
I don't think mkAuxTheorem on its own is async, in the sense that if you just call it on the main thread it would run there and block. It would be async if you fork off an async environment branch, set that to be your MetaM env in a new task, and call mkAuxTheorem then.
Son Ho (Jul 07 2025 at 17:15):
𝚠𝚘𝚓𝚌𝚒𝚎𝚌𝚑 𝚗𝚊𝚠𝚛𝚘𝚌𝚔𝚒 said:
this function ignores the imported definitions, it only goes through the ones local to the file
Yeah; that's fine for you, right? You can only import definitions at the top of a module in the
importblock anyway, not after that.
Yes, it's exactly what I need: I was afraid that it would go through the whole environment :)
Son Ho (Jul 07 2025 at 17:16):
𝚠𝚘𝚓𝚌𝚒𝚎𝚌𝚑 𝚗𝚊𝚠𝚛𝚘𝚌𝚔𝚒 said:
I don't think
mkAuxTheoremon its own is async
Ok, I'll use addConstAsync then
Last updated: Dec 20 2025 at 21:32 UTC