Repository navigation
feat(vm): add a generational garbage collector behind Lua.gc/2 - #432
Open
dbernheisel wants to merge 3 commits into
Open
dbernheisel wants to merge 3 commits into
dbernheisel wants to merge 3 commits into
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lua.gc/2. It frees the tables, closure cells and userdata that Lua cannot reach. Call it between evaluations.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.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.mode: :autoby allocation. It does nothing until 1,000 objects are new. It runs a major collection when the heap doubles.entry_depthcount to the state.Lua.VM.execute/3andLua.call_function/3increase it. Collection raises an error when Lua code is running, because the collector cannot see live registers.%Lua{}or%Lua.VM.State{}.string.gmatchcursor in an upvalue cell, not in private storage. The collector can then free it.Bootstrap.reset/0tests to a synchronous module. They check global:persistent_termstate.Lua.gc_reentrant/2for 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
Collection time with 101 new tables:
A callback serves 1,000 calls to the same
stepwhile its caller waits.Lua.gc/2cannot run there.iex
The write barrier costs time on table writes. A loop of 200,000 field writes takes 82 ms. On
mainit takes 73 ms. An arithmetic loop has no change.