fix: garbage-collect the Lua VM before snapshotting - #1062
Conversation
|
Great find! Thanks @jim-toth . The docs for Luerl claim that we should be able to trigger this from inside the Lua env, but I think the point that it would be nice for this to be automatic(!) is sound. The question is how best to avoid OOM, maintain performance, while ensuring that we don't encode the GC mechanics of Luerl into There are a couple of different paths we could go from here:
5 seems cleanest, but open to ideas! |
|
Thanks @samcamwilliams. I implemented option 5 (counter in Luerl gc is mark-and-sweep with the mark phase being the heavy part, and it scales by number of objects rather than data size, so what a process keeps live matters far more than what it allocates.
Rows three and six hold the same 12,000 entries: reshaping them into 4,000 small tables is what takes it from 8% to 95%, and turning GC on there makes the process 16.5x slower. Cost and benefit run opposite, which is the useful part: the shapes cheapest to collect have the most to reclaim. Deferring converts the cost to memory rather than avoiding it. At N=25 the second shape carries up to 41.6 MB between collects instead of a flat 17 KB. So I'd argue for N=1 as the default, with the counter kept as a knob. No single frequency is right for every shape, but the safe one should be what you get without asking for it. I would put 0 meaning never is worth having as an escape hatch. Defaulting to 0 would keep existing processes bit-identical, but it leaves the leak as the default and only fixes it for people who already know about it. Either shape replaces the snapshot patch in this PR, since collecting in Would you rather this PR is repurposed or a fresh one opened with this new proposed change? Re: GC from LuaOne correction to what I posted earlier: |
Summary
dev_lua:snapshot/3serializes the whole luerl VM withterm_to_binary(luerl:externalize(State))and never garbage-collects it. Luerl reclaims table-store slots only insideluerl_heap:gc/1, and nothing in HyperBEAM calls it, so the snapshot retains every table the process has ever allocated and grows without bound, even when the Lua-visible state is a fixed size. It is written on every snapshot slot, so per-message cost grows with accumulated slots.Measurements
Reproduced on
edge7135fdbausingtest/test.luaas the fixture, with the process driven through its defaultcompute, which writes the same result on every message. Sampling the serialized snapshot as slots accumulate:edgeluerl:gcStock grows ~19 KB per slot and is 69× larger by slot 100, for a workload whose Lua-visible state does not grow. That growth is dead tables the VM has never been asked to collect. Exact byte counts move by ~0.1% between runs, since each run spawns a process with a fresh identity and those bytes sit inside the serialized state; the ratios are stable.
Collected, the snapshot tracks live state rather than slot count. It is not byte-identical across those samples, and should not be: the state handed to the script carries per-slot process fields, so successive snapshots differ legitimately. Byte-identity is only meaningful where the Lua state between two snapshots is genuinely unchanged and that case is measured below.
Restore
snapshot/3does not write the collected state back intopriv, collects a copy purely for serialization, and the running VM continues on the uncollected state. The only behavioral surface is restore which is measured directly: drive a process 40 messages, snapshot, restore throughnormalize/3into a base with noprivstate, then re-snapshot and continue.edgeluerl:gcnormalize/3count40 → 41The restored VM re-serializes to byte-identical bytes and continues to the same result as the process that never restarted. This is also the unchanged-state case referred to above: between those two snapshots the Lua state really is identical, and the bytes match exactly.
Why this cannot be fixed at the application layer
Per CONTRIBUTING rule 2, the application-layer fix was tried first: calling
collectgarbage()from inside the Lua contract. It does not suffice: it is unsound, and it fails inside HyperBEAM.In luerl 1.3.0, the version pinned in
rebar.lockhere,collectgarbage("collect")is not a stub:luerl_lib_basic:collectgarbage/3callsluerl_heap:gc/1on the in-flight state.luerl_heap:gc/1derives its root set from the state handed to it, so it is only correct at a settled point, which is whatsnapshot/3is. With a frame on the dynamic stack it cannot see the in-flight continuation, and objects still reachable from that frame are freed.Run through
lua@5.3a, a contract callingcollectgarbage()inside apcall:The Lua
pcalldoes not catch it: the error is raised below Lua, in the Erlang heap code, and the whole computation fails. Identical on unmodifiededge, so this is a property of luerl rather than anything this PR changes.No Lua program can safely collect its own VM, so the collection point has to be one the application layer cannot reach.
Note on luerl's
externalize/1Misleadingly named:
luerl_lib_math:externalize/1converts only the RNG state so it survivesterm_to_binary. It does not touch the table store, so it is not a substitute for collecting.Testing
rebar3 device test --with-core(the full suite: every device plus the core modules) againstedgeat7135fdba, and against this branch: 3,497 passed, 5 failed, identical both ways. The same 5 fail on unmodifiededge: fourscheduler@1.0http_get_legacy_*tests andpush@1.0: test_push_prompts_encoding_change, so this PR introduces no new failures or flakes (rule 1).rebar3 device test --devices dev_luaon its own: 38/38, includingpure_lua_restore_test.For completeness: the teardown printed
Segmentation faultafterAll 38 tests passedon this branch and on unmodifiededge. It is unrelated to this change and I have not investigated it further.