Skip to content

feat(vm): add a generational garbage collector behind Lua.gc/2 - #432

Open
dbernheisel wants to merge 3 commits into
mainfrom
feat/vm-gc
Open

dbernheisel wants to merge 3 commits into
mainfrom
feat/vm-gc

Conversation

@dbernheisel

@dbernheisel dbernheisel commented Oct 19, 2026 •

Copy link
Copy Markdown

Summary

  • Add Lua.gc/2. It frees the tables, closure cells and userdata that Lua cannot reach. Call it between evaluations.
  • Add Lua.VM.GC, a generational mark-and-sweep collector. A minor collection traces only the objects that are new since the last collection. A major collection traces the full heap.
  • Add a write barrier in Lua.VM.State.put_table/3. All writes to existing tables use it, including the dispatcher and executor fast paths. It records old tables that change between collections.
  • Pace mode: :auto by allocation. It does nothing until 1,000 objects are new. It runs a major collection when the heap doubles.
  • Add an entry_depth count to the state. Lua.VM.execute/3 and Lua.call_function/3 increase it. Collection raises an error when Lua code is running, because the collector cannot see live registers.
  • Trace Elixir callbacks through their captured values. Do not trace a captured %Lua{} or %Lua.VM.State{}.
  • Keep the string.gmatch cursor in an upvalue cell, not in private storage. The collector can then free it.
  • Move the Bootstrap.reset/0 tests to a synchronous module. They check global :persistent_term state.
  • Add Lua.gc_reentrant/2 for a host that calls back into Lua while Lua waits in an Elixir callback. It fences the heap, runs the call, and then minor-collects only what the call left behind. The waiting frames hold only older objects, so they survive.

Evidence

A retained callback makes 101 temporary tables in each call. The state is kept between calls.

iex
# main (3f2aead), no collector
iex(1)> {_, lua} = Lua.eval!(Lua.new(), "local n = 0 function step() n = n + 1 for i = 1, 100 do local t = {i} end local r = {body = string.rep('x', 8192)} return n end")
iex(2)> run = fn lua, calls -> Enum.reduce(1..calls, lua, fn _, lua -> {:ok, _, lua} = Lua.call_function(lua, [:step], []); lua end) end
iex(3)> lua = run.(lua, 1_000); map_size(lua.state.tables)
101012
iex(4)> lua = run.(lua, 1_000); map_size(lua.state.tables)
202012

# this branch, Lua.gc/1 after each call
iex(1)> {_, lua} = Lua.eval!(Lua.new(), "local n = 0 function step() n = n + 1 for i = 1, 100 do local t = {i} end local r = {body = string.rep('x', 8192)} return n end")
iex(2)> run = fn lua, calls -> Enum.reduce(1..calls, lua, fn _, lua -> {:ok, _, lua} = Lua.call_function(lua, [:step], []); Lua.gc(lua) end) end
iex(3)> lua = run.(lua, 1_000); map_size(lua.state.tables)
918
iex(4)> lua = run.(lua, 1_000); map_size(lua.state.tables)
918
iex(5)> Lua.call_function(lua, [:step], []) |> elem(1)
[2001]

Collection time with 101 new tables:

Live tables Major Minor
1,000 0.21 ms 0.04 ms
10,000 3.0 ms 0.11 ms
100,000 62 ms 0.92 ms

A callback serves 1,000 calls to the same step while its caller waits. Lua.gc/2 cannot run there.

iex
iex(1)> {_, lua} = Lua.eval!(Lua.new(), "function step() for i = 1, 100 do local t = {i} end local r = {body = string.rep('x', 8192)} return 1 end")

# 8906fd1, nothing collects until the outer evaluation returns
iex(2)> lua = Lua.set!(lua, [:serve], fn _, lua ->
...(2)>   lua = Enum.reduce(1..1_000, lua, fn _, lua -> {:ok, _, lua} = Lua.call_function(lua, [:step], []); lua end)
...(2)>   {[map_size(lua.state.tables)], lua}
...(2)> end)
iex(3)> Lua.eval!(lua, "local mine = {value = 7} local n = serve() return n, mine.value") |> elem(0)
[101010, 7]

# this branch, each call wrapped in Lua.gc_reentrant/2
iex(2)> lua = Lua.set!(lua, [:serve], fn _, lua ->
...(2)>   lua = Enum.reduce(1..1_000, lua, fn _, lua ->
...(2)>     {_, lua} = Lua.gc_reentrant(lua, fn lua -> {:ok, r, lua} = Lua.call_function(lua, [:step], []); {r, lua} end)
...(2)>     lua
...(2)>   end)
...(2)>   {[map_size(lua.state.tables)], lua}
...(2)> end)
iex(3)> Lua.eval!(lua, "local mine = {value = 7} local n = serve() return n, mine.value") |> elem(0)
[10, 7]

The write barrier costs time on table writes. A loop of 200,000 field writes takes 82 ms. On main it takes 73 ms. An arithmetic loop has no change.

The VM never removed tables, closure cells or userdata from its state.
A long-lived state that kept calling a retained callback grew without
bound.

Lua.gc/2 runs a mark-and-sweep between evaluations. Minor collections
trace only objects created since the last collection; a write barrier
in State.put_table/3 records older tables written in between. :auto
paces collections by allocation and runs a major collection when the
heap doubles. Collection refuses to run while Lua code is running,
tracked by a new entry_depth counter.

string.gmatch now keeps its cursor in an upvalue cell instead of
private storage, so the collector can free it.

The Bootstrap reset/0 tests move to a synchronous module because they
assert that global :persistent_term state is empty.
… module

Lua.VM.* modules are filtered out of the published docs, so the link to Lua.VM.GC made ExDoc warn.
@dbernheisel
dbernheisel marked this pull request as ready for review October 19, 2026 17:06
A host that serves another call while Lua waits in an Elixir callback
runs that call on the suspended state. Lua.gc/2 refuses to run there,
so every reentrant call's garbage stayed until the outer evaluation
returned, and a long wait could grow the heap without bound.

Lua.gc_reentrant/2 fences the heap by promoting every object to the old
generation, runs the call, and then minor-collects only what the call
allocated and left unreachable. The suspended frames can hold only
objects that predate the fence, so they survive. The call must return
at the entry depth it started from.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant