LeanElpi

· Volodymyr Pavlyshyn

Every ELPI performance program now runs on LeanElpi

The first benchmark could only measure the ELPI programs that LeanElpi could read: 11 of the 21 performance programs were rejected at load time, with an error on their first unsupported line. The second article fixed the speed of the ones that ran. This one is about the rest: what was missing, what it took, what the benchmark says now, and what we traded to get there.

What was missing

None of the rejections were engine limits. Each was a feature of ELPI 1.18 that LeanElpi did not have yet:

ProgramsFirst errorThe gapWhat we did
zebraundeclared constant 'nil'ELPI only warns about an undeclared constant; LeanElpi made it an errorIn a program that declares no types, an undeclared constant is a warning, as in ELPI (typed programs keep the error)
map, map_list, set'macro' is unsupportedmacro @name :- body.Macros are expanded on the token stream before parsing
map_ho, set_houndeclared constant 'std.int.map.empty'the integer-keyed maps and sets, and std.map K V with ELPI's typesA balanced (AVL) std.map, std.set and the std.int.* versions, written from the textbook algorithm
index2, dtexpected '.' but found ':':index (…) before a declarationParsed, and used (below)
eta, restriction4/5 and morepi is applied to 2 argumentspi x y z\ body binds several namesRewritten to nested binders
dtterm expected, found ']'a trailing comma in a listAccepted

Two more things came out of the same work: std.time (with a gettimeofday builtin, because the programs time themselves), and a build fix. Lake did not know that lib/elpi/*.elpi are inputs of the library, so editing the standard library left a stale one until we declared the directory as an input.

On the upstream suite, 52 of the 114 applicable tests now pass, up from 43; the conformance corpus is unchanged at 38 of 40. Both suites ran after every step, and nine entries came off the register of known failures.

Results

Median wall time of three runs in seconds, on the same Apple M2 Max, against ELPI 1.18.2; Teyjus where a program is also a Teyjus module. The last column is peak memory, ELPI then LeanElpi, in MB. None of the 24 programs is rejected now.

ProgramELPILeanElpiLeanElpi / ELPITeyjusPeak MB
church50.080.091.1×–16 / 60
rev0.170.171.0×–45 / 74
rev140.160.171.1×0.0644 / 74
map (rbtree)1.391.320.9×–16 / 124
set1.161.341.2×–16 / 126
fib230.130.181.4×–15 / 68
zebra0.611.562.6×1.3818 / 54
nrev4000.150.312.0×–15 / 110
cbn0.240.682.8×–17 / 203
queens0.872.673.1×1.31133 / 191
dt_off6.5620.453.1×–15 / 109
lambda30.271.274.6×–16 / 188
fast_mu0.873.413.9×1.8317 / 54
mu1.215.194.3×3.2817 / 103
crypt2.139.524.5×4.1038 / 76
dt0.904.024.5×–15 / 109
index21.056.045.8×–16 / 140
set_ho0.321.946.2×–47 / 414
map (list)5.4314.832.7×–16 / 1398
map (list, builtin)4.9014.793.0×–16 / 1398
cbv1.600.420.3×–18 / 103
map_ho0.070.040.6×–15 / 52
deep_index0.070.040.5×–15 / 52
ackermann0.080.040.5×–16 / 52
start-up (empty program)0.070.040.6×–15 / 52

Over the 20 programs where ELPI needs more than 0.1 s, the geometric-mean slowdown is 2.4× and the worst case is 6.2× (set_ho). Over all 25 timings, start-up included, it is 1.8×. LeanElpi starts faster than ELPI (40 ms against 70) and is faster than it on cbv (0.42 s against 1.6 s, with identical results: both engines compute a result of 6,253 nodes) and on the three tiny programs that mostly measure start-up. It is 1.1 to 2.6 times Teyjus's time on the programs they share.

How the last gaps were closed

:index is used, not only parsed. :index (12) before pred f i:list int. says to index the first argument 12 levels deep; ELPI keeps a discrimination tree. A predicate with such a declaration now offers a call only the clauses (and hypotheses) whose indexed arguments do not clash rigidly with the call's within that depth; a variable or a λ never clashes, so the answers and their order cannot change. Each clause stores a hash of its indexed arguments, and the common case is one comparison. index2 went from 47 s to 6 s and dt from 18 s to 4 s (ELPI: about 1 s). It is still a scan over the candidates, not a tree.

Integer maps and sets are native. std.int.set took 72 s on set_ho (ELPI: 0.3 s) because ELPI's is OCaml code and ours was interpreted balancing, a few hundred resolution steps per key. The insert, find and remove for integer keys now run in Lean on the same tree terms the library code builds, so the two interoperate; set_ho is 1.9 s.

What we traded

What is still slow

Reproduce it

lake build
python3 tests/bench/run.py --runs 3 --json out.json   # add TEYJUS_BIN=... for Teyjus
tests/upstream/run.sh lean && tests/conformance/run.sh && lake test

The raw results are in tests/bench/results/2026-10-15-all-programs.json. The changes are the OpenSpec changes close-compat-gaps and add-deep-indexing, with tests in tests/Core/Compat.lean and tests/Core/Indexing.lean.