Lua.ex
Try it

tv-labs/lua · full-mode benchee · 2026-07-28

Lua on the BEAM: 0.4.0 → 1.0.0 → 1.0.2

3 releases of the lua Elixir library, measured on the same machine in the same sitting, with Luerl run inside every benchmark as a same-run control. 0.4.0 was a thin wrapper over Luerl; 1.0.0 introduced the native VM; 1.0.2 is the current release.

The headline: 1.0.2 is the first release faster than Luerl on most workloads — and Lua.new() is now effectively free.

Lua.new() median
0.58 μs
63× faster than 1.0.0 (36.67 μs)
Steady-state, default options, under mix run. ~100× credible in an :embedded release; ~5× when passing custom sandbox options.
Lua.new() allocation
0.88 KB
104× less than 1.0.0 (91.88 KB)
Memoized boot-time VM template; one-time ~7 ms cold build per node.
fibonacci fib(30) median
434.12 ms
1.7× faster than same-run Luerl
Was 1.09× slower on 1.0.0 (792.42 ms). Allocation: 1016.63 MB vs Luerl's 2513.67 MB.

Runtime vs Luerl, per workload

Each dot is a release's compiled-chunk median divided by the Luerl median from the same run — left of the parity line is faster than Luerl. Hover a dot for the underlying medians. Log scale.

0.4.0  (Luerl wrapper) 1.0.0 1.0.2 │ line = Luerl parity (1.0)
← faster than Luerl slower than Luerl →
fibonacci fib(30) recursive calls
string.format (literal-heavy) literal-heavy template
pcall success path (n=500) protected call, no raise
pairs over hash part (n=1000) hash-part iteration
__index 3-level chain prototype lookup
closures factory + upvalue mutation
table.sort (n=1000) reverse-ordered input
varargs + multi-return (n=500) call protocol
pcall raise + catch (n=500) does strictly more work
4× faster 2× faster parity 2× slower

Medians across releases

Compiled-chunk path (the production embedding path: compile once, run many). Ratio column is 1.0.2 ÷ same-run Luerl; green means faster than Luerl.

Workload 0.4.01.0.01.0.2 1.0.2 vs Luerl
Lua.new() — steady state 21.33 μs 36.67 μs 0.58 μs 0.04×
Lua.new() — allocation 73.78 KB 91.88 KB 0.88 KB 0.02×
fibonacci fib(30) 702.84 ms 792.42 ms 434.12 ms 0.59×
fibonacci — allocation 2.45 GB 2.90 GB 1016.63 MB 0.40×
string.format (literal-heavy) 3.91 ms 1.03 ms 716.04 μs 0.18×
pcall success path (n=500) 290.50 μs 282.13 μs 222.88 μs 0.76×
pairs over hash part (n=1000) 1.32 ms 995.70 μs 1001.12 μs 0.74×
__index 3-level chain 216.42 μs 237.13 μs 190.08 μs 0.88×
closures 368.92 μs 473.54 μs 373.71 μs 0.96×
table.sort (n=1000) 174.29 μs 227.63 μs 199.34 μs 1.11×
varargs + multi-return (n=500) 2.40 ms 4.01 ms 3.28 ms 1.36×
pcall raise + catch (n=500) 469.29 μs 877.33 μs 727.13 μs 1.55×

† 1.0.2 builds PUC-Lua-conformant position-prefixed error messages; Luerl returns the bare error value, doing less work per raise.

Where 1.0.2 is still behind Luerl

pcall raise + catch (1.55× slower). Partly apples-to-oranges: 1.0.2 builds position-prefixed error messages ("<eval>:1: negative", the PUC-Lua-conformant behavior) where Luerl returns the bare value — it does strictly more work per raise. Still, it regressed at 1.0.0 (1.81×) and has only partially recovered.

varargs + multiple returns (1.36× slower, ~1.9× the allocation). A genuine gap with no conformance excuse — the clearest optimization target for 1.1.x.

Host-boundary decode of large lists. Not in the chart (its own harness): decoding a 4,096-element integer list is ~13× slower than the 0.4.0/Luerl era; long-string lists ~25×. String-keyed maps moved the other way (1.24× faster). Worth a look before 1.1.

How to read these numbers

Full report with per-workload analysis and reproduction instructions: bench_results/versions-2026-07-28.md in tv-labs/lua. Raw Benchee outputs and parsed JSON are committed alongside, one directory per released version.