22. 性能工程
本章目标:学会在 Lean 4 中诊断和解决常见的证明性能问题。
当 decide 超时、simp 跑了几秒钟、或整个文件编译卡住时,
你需要的不是换一个 tactic,而是找到瓶颈在哪里。
本章提供从普通用户到 tactic 作者的分层诊断方法。
不要过早优化。大部分证明不需要性能调优。 只有当编译确实变慢(heartbeats 超限、IDE 卡顿)时才需要本章的工具。 先写对,再写快。
本章结构——三层递进:
-
第一层(21.1–21.4):普通用户排障——profiler 定位、
simp?→simp only、避免暴力decide。大部分读者只需要这一层。 -
第二层(21.5):常见瓶颈模式——typeclass 爆炸、coercion 链、unification 超时。遇到深层性能问题时再来查阅。
-
第三层(21.6):tactic 作者专属——MetaM 缓存、
isDefEq控制、proof term 体积。仅面向编写自定义 tactic 的开发者。
22.1. 第一层:普通用户排障
22.2. 21.1 性能模型:时间花在哪里
一个 by ... 证明从输入到内核接受,经历三个阶段:
源码 ──→ ❶ elaboration ──→ ❷ tactic 执行 ──→ ❸ 内核验证 ──→ 通过
(语法 → Expr) (Expr → 证明项) (检查证明项)
-
阶段:❶ elaboration —— 主要开销:type class synthesis、unification —— 典型瓶颈:深层 typeclass 层级、高阶 unification
-
阶段:❷ tactic 执行 —— 主要开销:
simp重写、aesop搜索 —— 典型瓶颈:引理集过大、搜索空间爆炸 -
阶段:❸ 内核验证 —— 主要开销:检查最终证明项 —— 典型瓶颈:证明项过大(
decide展开、simp冗长项)
关键认知:大部分"慢证明"的瓶颈在 ❷, 但 ❸ 也可能成为瓶颈——尤其是反射证明生成的巨大证明项。
22.3. 21.2 Heartbeats:平台无关的计算量度量
Lean 用 heartbeats 度量计算量——每次内核归约或 MetaM 操作消耗若干 heartbeats, 与 CPU 速度无关,保证同一证明在不同机器上给出一致的度量。
-- [可运行] 查看实际消耗 set_option profiler true in theorem ex1 : (List.range 100).length = 100 := by decide -- ▸ profiler 输出中包含 heartbeats 消耗 -- [示意] 加倍预算——应急手段,不是长期方案 set_option maxHeartbeats 400000 in -- 默认 200000 theorem slow_thm : ... := by ... -- [示意] 无限制——**仅限短暂调试,绝对不能提交到代码库** set_option maxHeartbeats 0 in -- ▸ maxHeartbeats 0 关闭了所有超时保护 -- ▸ 在 CI 中可能导致编译永远不结束 -- ▸ 调试完毕后必须删除此行,然后优化证明本身
Mathlib 的 heartbeats 纪律:默认 200000(约 1-2 秒), PR 不应需要提高预算。如果 tactic 太慢,优化证明而非加预算。 heartbeats 预算是质量门禁——迫使开发者写高效的证明。
注意:maxHeartbeats 0 不是"让证明跑完",而是"允许无限循环"。
它只能用于调试时短暂开启,帮你看到完整的 profiler/trace 输出。
找到瓶颈后,必须删除它并优化证明——它不是修复方案。
22.4. 21.3 诊断工具箱
22.4.1. profiler:整体耗时
-- [可运行] 开启 profiler set_option profiler true in theorem my_thm : ∀ n : ℕ, n + 0 = n := by intro n; simp -- ▸ 输出形如: -- elaboration of my_thm took 12.3ms -- typecheck of my_thm took 0.8ms
第一步永远是 profiler——先定位是哪个声明慢,再深入分析。
22.4.2. trace:细粒度追踪
-- [示意] 追踪 simp 的每一步重写 set_option trace.Meta.Tactic.simp.rewrite true in theorem ex2 : 0 + n = n := by simp -- ▸ [Meta.Tactic.simp.rewrite] Nat.zero_add:1000, 0 + n ==> n -- [示意] 追踪 type class synthesis set_option trace.Meta.synthInstance true in -- ▸ 显示 typeclass 搜索树,包括尝试和回溯 -- [示意] 追踪 unification set_option trace.Meta.isDefEq true in -- ▸ 显示每次 definitional equality 检查
常用 trace 选项速查:
-
trace.Meta.Tactic.simp.rewrite:simp 应用了哪些引理 -
trace.Meta.Tactic.simp.discharge:simp 的 discharge 尝试 -
trace.Meta.synthInstance:typeclass synthesis 过程 -
trace.Meta.isDefEq:unification 过程(输出极大,慎用) -
trace.profiler.threshold:只显示超过阈值的步骤
二分定位:profiler → 哪个 theorem 慢 → 逐行 sorry → 哪行 tactic 慢 → trace 深入。
22.5. 21.4 优化 simp
simp 是最常见的性能瓶颈来源——功能强大,也最容易被滥用。
22.5.1. 全局 simp vs simp only
-- [示意] ❌ 全局 simp——加载所有 @[simp] 引理 simp -- ▸ Mathlib 环境下 @[simp] 引理有数千条 -- ▸ 复杂度 ≈ O(子表达式数 × 引理数 × 重写轮数) -- [示意] ✅ simp only——只用必要的引理 simp only [Nat.add_zero, Nat.mul_one] -- ▸ 引理集从数千条缩减到 2 条
22.5.2. simp? 工作流
-- [示意] 开发时的最佳实践 -- ❶ 先用 simp 探索(开发阶段) -- ❷ 用 simp? 获取实际使用的引理列表 -- ❸ 替换为 simp only [...](提交前) example : 0 + n + 0 = n := by simp? -- ▸ 建议: simp only [Nat.zero_add, Nat.add_zero]
22.5.3. 其他 simp 调优
-- [示意] 关闭 discharge(simp 不尝试放电侧条件)
simp (config := { decide := false })
-- ❌ @[simp] 同时标记 a = b 和 b = a → 无限循环
-- ✓ @[simp] 引理应保证重写方向一致(左边"更复杂")
22.6. 第二层:常见瓶颈模式
以下内容面向遇到深层性能问题的用户。如果第一层的 simp only 和 profiler 已经解决了你的问题,可以跳过本节。
22.7. 21.5 优化 Unification 和 Elaboration
22.7.1. 高阶 unification
-- [示意] ❌ 让 Lean 推断太多类型参数 exact some_very_polymorphic_lemma -- ▸ 最坏情况下 unification 是不可判定的 -- [示意] ✅ 显式提供关键类型参数 exact @some_lemma ℝ _ _ a b -- ▸ @ 关闭隐式参数,_ 让 Lean 推断简单的 typeclass 参数
22.7.2. typeclass synthesis 爆炸
-- [示意] -- 深层实例链:Ring ℝ → AddCommMonoid ℝ → AddMonoid ℝ → ... -- ▸ 搜索树可能指数级增长 -- ✅ 策略 1:提供局部实例 have inst : Ring ℝ := inferInstance -- ▸ 后续 tactic 直接用 inst,不再搜索 -- ✅ 策略 2:控制搜索深度 set_option synthInstance.maxSize 256 in -- 默认 128
coercion 链:ℕ → ℤ → ℚ → ℝ 的隐式转换触发多层 synthesis。
用 (↑n : ℝ) 显式标注最终类型,或在证明开头 push_cast。
22.8. 第三层:tactic 作者专属
以下内容仅面向编写自定义 tactic 的开发者。普通证明用户可以跳过 21.6。
22.9. 21.6 优化 MetaM 端代码
编写自定义 tactic 时,MetaM 端代码质量直接影响性能。
22.9.1. 缓存与避免重复
-- [示意] ❌ 重复调用 let t1 ← goal.getType doSomething t1 let t2 ← goal.getType -- 浪费 -- [示意] ✅ 缓存 let target ← goal.getType doSomething target doSomethingElse target
22.9.2. 避免不必要的 whnf
-- [示意] ❌ whnf 可能展开大量定义 let t ← whnf (← goal.getType) -- ▸ whnf 递归展开直到头部是构造子——代价可能很高 -- [示意] ✅ 用 matchApp? 等结构化匹配代替 let target ← goal.getType if let some (f, args) ← matchApp? target then ... -- ▸ 只检查头部函数,不做深层展开
22.9.3. 控制 isDefEq
-- [示意] isDefEq 是 MetaM 中最昂贵的操作之一
-- ❌ 盲目尝试
for lemma in lemmas do
if ← isDefEq lemmaConcl target then return lemma
-- ✅ 廉价预筛选 + 精确匹配
for lemma in lemmas do
if quickCheck lemma target then -- 头符号匹配
if ← isDefEq lemmaConcl target then return lemma
注意:isDefEq 有副作用——它会给 metavariable 赋值。
即使最终不用该引理,赋值已经发生。
用 withoutModifyingState 隔离探索性调用。
22.10. 21.7 编译时间 vs 运行时间
Lean 同时承担证明检查器和编程语言双重角色, 有两种完全不同的性能关注维度。
-
关注对象 —— 编译时间:tactic 执行速度 —— 运行时间:编译后程序速度
-
优化手段 —— 编译时间:
simp only、类型标注、缓存 —— 运行时间:算法选择、内联、尾递归 -
度量工具 —— 编译时间:heartbeats、profiler —— 运行时间:基准测试、
timeit -
主要场景 —— 编译时间:数学证明库(Mathlib) —— 运行时间:程序验证、可执行代码
-- [示意] #eval vs #reduce 的区别 #eval (List.range 10000).length -- 快:编译后执行 #reduce (List.range 10000).length -- 极慢:内核归约(展开每一步) -- ▸ 除非需要看内核归约的中间步骤,否则永远用 #eval
22.11. 21.8 证明项大小与增量编译
22.11.1. 证明项膨胀
-- [示意] simp 生成的证明项可能非常大 -- 每一步重写记录:应用的引理、类型参数实例化、局部变量 -- 100 步重写可能生成数万个 Expr 节点 -- decide 更严重:证明项包含完整的归约过程
缓解策略:
-
native_decide生成更紧凑的证明项(不记录归约过程) -
拆分大证明为多个中间引理(每段 simp 链较短)
-
用引理推理代替暴力计算(
simp [List.length_range]优于decide)
证明项大小影响 .olean 文件大小,
进而影响所有下游文件的加载时间。
22.11.2. 增量编译与文件组织
-- [示意] ❌ 粗粒度 import import Mathlib -- 加载整个 Mathlib -- [示意] ✅ 细粒度 import import Mathlib.Tactic.Ring -- 只加载需要的模块 import Mathlib.Data.Nat.Basic
文件依赖图:
A.lean
/ \
B C ← B 和 C 可以并行编译
\ /
D.lean ← D 必须等 B 和 C 都编译完
优化原则:减少文件间依赖;把慢定义和快定义分开文件;
用 lake build +A +B 并行编译无依赖模块。
Mathlib 在 CI 中对每个 PR 测量编译时间差异, 显著增加编译时间的 PR 需要优化后再合并。
22.12. 21.9 实战:优化一个慢证明
22.12.1. 原始版本(慢)
-- [示意]
set_option maxHeartbeats 800000 in -- 需要 4 倍预算
theorem slow_example (a b c : ℕ) :
(a + b) * c + 0 = c * a + c * b := by
simp [Nat.add_mul, Nat.mul_comm, Nat.add_comm, Nat.add_zero]
ring
22.12.2. 诊断过程
-- [示意] -- ❶ profiler → simp 占 90% 时间 -- ❷ trace.simp → Nat.mul_comm 导致循环(a*b → b*a → a*b) -- ❸ 分析 → 只需 Nat.add_zero 消除 + 0,剩下交给 ring
22.12.3. 优化版本(快)
-- [示意] 不需要额外 heartbeats
theorem fast_example (a b c : ℕ) :
(a + b) * c + 0 = c * a + c * b := by
simp only [Nat.add_zero] -- 只消除 + 0
ring -- ring 处理交换律和分配律
heartbeats 从 ~600000 降到 ~5000(100x 改善)。
优化检查清单:
□ profiler 定位瓶颈(simp?unification?typeclass?) □ simp 瓶颈 → simp? → simp only □ unification 瓶颈 → 添加类型标注 □ typeclass 瓶颈 → 缓存实例或限制搜索 □ decide 瓶颈 → native_decide 或引理推理 □ 优化后 heartbeats ≤ 200000?
22.13. 21.10 失败模式汇总
22.13.1. 失败 1:maxHeartbeats 军备竞赛
-- [示意] -- ❌ 每次超时就加 maxHeartbeats set_option maxHeartbeats 400000 in -- 第一次 set_option maxHeartbeats 800000 in -- Lean 升级后又超时 set_option maxHeartbeats 1600000 in -- 加了一条引理又超时 -- ✓ 正确做法:优化证明本身
22.13.2. 失败 2:trace 输出导致 IDE 卡死
-- [示意] -- ❌ 在大文件上开 trace.Meta.isDefEq → 输出几十万行 -- ✓ 先用 sorry 缩小范围,只对单个 tactic 开 trace
22.13.3. 失败 3:#reduce 代替 #eval
-- [示意] #reduce (List.range 1000).length -- ❌ 极慢:内核归约 #eval (List.range 1000).length -- ✓ 瞬间:编译后执行
22.13.4. 失败 4:无数据支撑的优化
-- [示意] -- ❌ "simp 可能慢,我全换成 simp only" -- ❌ "加类型标注总没错" -- ▸ 没有数据支撑的优化可能无效,还降低可读性 -- ✓ 先 profiler,再 trace,最后优化——data-driven
22.14. 21.11 练习
22.14.1. 练习 1(诊断):读 profiler 输出
以下 profiler 输出说明了什么?应该优先优化什么?
elaboration of slow_lemma took 3.2s
tactic execution took 2.8s
simp took 2.6s
typecheck took 0.3s
提示:simp 占 81%——应优化 simp(simp? → simp only)。
typecheck 0.3s 也较高——可能是证明项过大,但优先处理 simp。
22.14.2. 练习 2(设计):分析性能权衡
对以下场景,分析瓶颈在哪个阶段(elaboration / tactic / typecheck), 并建议优化方向:
(a) 用 decide 证明 100 以内所有素数的 Goldbach 猜想 (b) 在深层 typeclass 层级上使用 15 步 simp 链 (c) 自定义 tactic 在 MetaM 中对 200 个候选引理逐一尝试 isDefEq
提示:
(a) tactic(decide 穷举)+ typecheck(证明项大)→ native_decide 或拆引理。
(b) elaboration(typeclass)+ tactic(simp)→ 缓存实例,simp only。
(c) tactic(isDefEq 昂贵)→ 头符号预筛选,withoutModifyingState 隔离。
22.15. 21.12 小结
-
性能三阶段:elaboration → tactic 执行 → 内核验证 -
heartbeats:平台无关的计算量度量;默认 200000 -
profiler:第一步永远是 profiler——先定位再优化 -
trace:细粒度追踪 simp、typeclass、unification -
simp only:最常见的优化——限制引理集 -
类型标注:减少 unification 和 typeclass 搜索 -
证明项大小:影响 typecheck 和 .olean 文件大小 -
编译 vs 运行:证明库关注编译时间,程序验证两者都关注 -
增量编译:细粒度 import、合理的文件依赖图 -
优化原则:data-driven:先度量,再优化 -
主要陷阱:heartbeats 军备竞赛、trace 卡 IDE、盲目优化
一句话总结:性能优化的第一原则是先度量,再行动—— profiler 告诉你在哪里花时间,trace 告诉你为什么,然后才是对症下药。
落地行动清单——按层级递进:
-
普通用户(80% 的情况在这里解决):
-
证明慢了?开
set_option profiler true,找到最慢的那行。 -
simp慢?用simp?拿到引理列表,替换为simp only [...]。 -
decide超时?换native_decide,或用引理推理代替暴力穷举。 -
maxHeartbeats只加不减?停下来优化证明本身。
-
-
进阶用户(typeclass / unification 瓶颈):
-
开
trace.Meta.synthInstance看 typeclass 搜索树。 -
给多态引理加
@和显式类型参数。 -
coercion 链用
push_cast或显式标注。
-
-
tactic 作者:
-
缓存
goal.getType结果、避免盲目whnf。 -
isDefEq前做头符号预筛选,探索性调用用withoutModifyingState隔离。
-
下一章讨论对接外部工具——当 Lean 内部 tactic 不够用时, 如何调用 SAT/SMT 求解器并安全地集成结果。