Skip to content

runtime: memoize the default VM template; build stdlib tables one-shot - #398

Merged
davydog187 merged 3 commits into
mainfrom
perf/lua-new-fast-path
Jul 27, 2026
Merged

runtime: memoize the default VM template; build stdlib tables one-shot#398
davydog187 merged 3 commits into
mainfrom
perf/lua-new-fast-path

Conversation

@davydog187

Copy link
Copy Markdown
Contributor

What

Lua.new() goes from ~40 µs to ~0.4 µs by memoizing the built VM, and
Lua.VM.Stdlib.install/1 — which still runs once per node, and on every
direct caller — goes from ~33 µs to ~13 µs by building the tables it
installs in one shot instead of key by key.

Why

Profiling Lua.new() put ~80% of its cost in Stdlib.install(State.new()):
2,581 function calls, 154 Table.put/3 calls, every standard-library symbol
written into _G one key at a time. Embedders that build a VM per request
paid that on every single call, for a value that never differs.

It never differs because it can't: the boot VM is a pure function of the
sandbox options. No refs, no pids, no timestamps — Lua.new() and
Lua.new() serialize to identical binaries. And every downstream mutation is
copy-on-write, so one template can seed unlimited independent VMs.

How

Lua.VM.Bootstrap.fetch/2 memoizes a template in :persistent_term,
written exactly once per node (a put over a live key forces a global scan of
every process, so the key is never refreshed on the happy path). Two keys:

  • {Lua, :default_lua} — the fully default-sandboxed %Lua{}. Hit when
    :sandboxed is the default deny-list and :exclude is empty. Both come
    from the same module literal, so the match is a pointer compare.
  • {Lua, :base_state} — the pre-sandbox install, shared by every custom
    :sandboxed / :exclude call, which then pays only for its own sandbox
    pass.

:max_call_depth, :max_string_bytes, :max_instructions and :debug are
patched onto the finished struct, exactly as before, so they stay per-instance
and never enter the cache.

Install itself got the follow-on cleanups the profile pointed at:

  • Table.from_data/1 builds an all-string-keyed map (every stdlib library
    table) directly instead of Enum.sort + N × put/3.
  • State.set_globals/2 seeds the base globals in a single _G update.
  • preload_stdlib_modules/1 re-cached five modules that install_library/2
    had already cached — pure duplicate work, deleted.
  • package.loaded is resolved once and threaded through the library installs
    rather than re-walked from the package global twelve times.

Correctness hazards addressed

Module-reload staleness. A template holds ~116 closures into the
Lua.VM.Stdlib* modules. Reload those twice in a shell and the first version
is purged, turning every captured fun into a badfun. At build time, in
:interactive mode only, the term is walked for closures and fingerprinted by
each reachable module's module_info(:md5); a cache hit re-checks the
fingerprint (~0.2 µs of the 0.42 µs) and rebuilds on a mismatch. Under
:embedded mode — releases — code never reloads, no fingerprint is taken, and
a hit is the bare get.

Mutation isolation. Terms are immutable and %Lua{} mutation is
copy-on-write, so VMs derived from one template cannot see each other. Pinned
by tests: Lua.set! (flat and nested), a global assigned from Lua, and a
stdlib table extended from Lua all fail to appear in the next Lua.new(), and
table_next_id is still 12.

pairs order. The one-shot table build produces the struct the fold
produced, field for field — order_tail in descending key order, border
untouched — so nothing observable moves. A new property test asserts
from_data/1 == the sorted put/3 fold over generated maps mixing dense,
sparse and string keys. Verified directly, too: pairs order over _G,
package, package.loaded, string, math, table, os, utf8 and
debug is unchanged.

Boot invariants. g_ref is {:tref, 0}, table_next_id is 12, 12
tables, 37 globals, and the term is still exactly 3106 words — the same size
as before this PR.

Measured

M-series laptop, MIX_ENV=prod, median of 5000 iterations (machine was
otherwise idle; ±10% run to run):

before after
Lua.new() 40.0 µs 0.42 µs
Lua.new(sandboxed: []) 30.8 µs 0.29 µs
Lua.new(sandboxed: [[:os, :exit]]) 31.8 µs 0.50 µs
Lua.new(exclude: [[:require], [:package]]) 39.3 µs 6.5 µs
Stdlib.install(State.new()) 33.2 µs 13.1 µs
Lua.eval!(Lua.new(), "return 1+1") 44.6 µs 3.1 µs

The exclude: row is the level-1 path: it skips the install but still runs its
own 25-path sandbox pass, which is now what it costs.

Validation

  • mix test --include lua53 — 2653 passed, 16 skipped (was 2638 before the
    new tests).
  • mix format --check-formatted, mix compile --warnings-as-errors,
    mix dialyzer (0 errors), mix docs --warnings-as-errors.
  • Lua 5.3 full-suite survey: 9/28 files clean, matching the documented
    baseline in ROADMAP.md.

`Lua.new()` cost ~40us, and ~80% of that was `Stdlib.install/1`: every
standard-library symbol written into `_G` one key at a time, and every
library table folded key-by-key through the generic mutation path. Callers
that build a VM per request paid the whole install every time, for a value
that never differs.

The built VM is pure and deterministic — no refs, no pids, byte-identical
across calls — and every later mutation is copy-on-write, so a single
template can seed unlimited independent VMs. `Lua.VM.Bootstrap.fetch/2`
memoizes one in `:persistent_term`, written exactly once per node (a `put`
over a live key forces a global process scan). Two keys: the fully
default-sandboxed `%Lua{}` for the default arguments, and the pre-sandbox
install behind custom `:sandboxed` / `:exclude`. Limits and `:debug` are
patched onto the finished struct, so they stay per-instance.

A template holds closures into the stdlib modules; reload those twice in a
shell and the captured funs become badfuns. In `:interactive` mode the
template is fingerprinted by the `module_info(:md5)` of every module
reachable through a captured fun, and a cache hit re-checks it (~0.2us).
`:embedded` mode — releases — never reloads code, so no fingerprint is
taken and a hit is the bare `get`.

Also cuts the install itself, which still runs once per node and on every
`Stdlib.install/1` caller:

  * `Table.from_data/1` builds an all-string-keyed map in one shot instead
    of sorting and folding `put/3`. The resulting struct is field-for-field
    what the fold produced, so iteration order is untouched — pinned by a
    new property test.
  * `State.set_globals/2` seeds the base globals in one `_G` update.
  * `preload_stdlib_modules/1` re-cached five modules `install_library/2`
    had already cached; deleted.
  * `package.loaded` is resolved once and threaded, not re-walked from the
    `package` global per library.

Measured on an M-series laptop, MIX_ENV=prod, median of 5000:

    Lua.new()                     40.0us -> 0.42us   (95x)
    Lua.new(sandboxed: [])        30.8us -> 0.29us
    Lua.new(sandboxed: [[..]])    31.8us -> 0.50us
    Lua.new(exclude: [[..]])      39.3us -> 6.5us    (own sandbox pass)
    Stdlib.install(State.new())   33.2us -> 13.1us   (2.5x, once per node)
    Lua.eval!(Lua.new(), "1+1")   44.6us -> 3.1us

The boot VM is unchanged: 3106 words, 12 tables, 37 globals, `g_ref`
`{:tref, 0}`, `table_next_id` 12, and identical `pairs` order over `_G`,
`package`, `package.loaded` and all six library tables.
@davydog187

Copy link
Copy Markdown
Contributor Author

🤖 Automated review — Opus + Sonnet consolidated findings

Two independent AI reviewers (Opus, Sonnet) reviewed this PR. The core is solid: boot state is bit-identical to main (flat_size, full pairs(_G) order, per-table order_tails, package.loaded order), Table.from_data/replace_data fast paths are exactly equivalent to the fold (11 shapes + the PR's own property test), deleting preload_stdlib_modules/1 is safe, the perf claims reproduce (80 → 0.44 µs on the reviewer's machine), and :persistent_term for an immutable boot template is consistent with existing precedent (os.ex:300) and the "state threads through %Lua{}" rule in substance. Three MEDIUMs on the staleness/concurrency story, all in bootstrap.ex:

🟠 MEDIUM — fingerprint misses modules that shape the template without leaving a closure in it

lib/lua/vm/bootstrap.ex:140-151 (fingerprint/1) + :155-170 (fun_modules/1)

The staleness guard fingerprints only modules reachable via captured funs — Lua.VM.Table, Lua.VM.State, Lua.VM.Limits shape the template but never invalidate it. Staged live in iex: reload a patched Lua.VM.Table → direct calls use the new code while Lua.new() keeps serving the old template. Worse case: adding a field to %Table{}/%State{} yields cached structs missing the field → KeyError on update syntax. Long-lived shells only (mix test gets a fresh node), but that's exactly the :interactive case the fingerprint exists for. Fix: fingerprint an explicit module list (the closure walk plus the known shaping modules).

🟠 MEDIUM — :embedded mode has no invalidation path at all

lib/lua/vm/bootstrap.ex:133 (current?(:static), do: true)

Releases run embedded (RELEASE_MODE=embedded), where explicit :code.load_file/1 / remote-console r/1 still swaps code. Two loads of a changed stdlib module → every Lua.new() returns a template of badfun closures for the life of the node, with no public escape hatch (no Bootstrap.reset/0; only reaching into private :persistent_term keys). A small public reset/invalidate function closes this.

🟠 MEDIUM — no mutual exclusion on cold-start build (both reviewers)

lib/lua/vm/bootstrap.ex:117-131 — the moduledoc's "written exactly once per node" isn't guaranteed: N concurrent first callers each build and each :persistent_term.put, and every put over a live key forces a global GC scan of all processes — at exactly the first-traffic moment. Verified with 64 concurrent Lua.new(): all terms equal (no correctness bug), nothing serializes. At minimum the moduledoc claim should be corrected; serializing the first build is optional hardening.

🟡 LOW

  • bootstrap.ex:136module.module_info(:md5) raises UndefinedFunctionError if a fingerprinted module was deleted (:code.delete/1); current?/1 is the one place where "rebuild" is always the right answer.
  • bootstrap.ex:155-158 — closure environments aren't walked, so a fun nested inside another fun's env is invisible to the fingerprint. Not exploitable today (everything relevant lives in module Lua), but a latent trap the moment sandbox/2 delegates to a helper module.
  • bootstrap.ex:160fun_modules/1 crashes on improper lists though fetch/2 is doc-public with a term() spec.
  • Perf nit: in :interactive mode the fingerprint re-check is 52% of what's left of Lua.new() (0.229 of 0.442 µs) — paid by every non-release deployment (mix phx.server, escripts) where code never reloads. A compile-time gate is worth considering; will be filed as a follow-up.
  • Perf nit: table.ex:386split_from_map/2 now always materializes :maps.to_list(data) for the probe, and the non-string path materializes again via Enum.sort(data) — a small constant regression on integer-keyed from_data/replace_data (table.pack, Lua.encode!).
  • No CHANGELOG entry despite a user-facing perf change (Lua.new/1 40 µs → 0.4 µs); recent behavior/perf PRs all update it.

Test-coverage notes

replace_data/2 (the riskier split_from_map caller) isn't covered by the new property test, whose key_gen also never generates nil values (the nil-bail-out guard is untested); the :embedded/:static branches, reload-invalidation, and concurrent cold-start have no tests; require("utf8")/require("os") are unpinned in package_test.exs now that the redundant preload net is gone.

Fixes for the MEDIUMs and the cheap LOWs will be pushed to this branch shortly.

Hardens the memoized-template staleness story:

- The interactive-mode fingerprint now covers an explicit list of
  template-shaping modules (Lua, Bootstrap, Limits, State, Stdlib,
  Table) in addition to modules reachable through captured funs, so a
  struct-layout or default change invalidates the cached template even
  when the changed module leaves no closure in it.
- Bootstrap.reset/0 erases every memoized template, giving embedded-mode
  hosts that hot-load new code an invalidation path; stored keys are
  tracked in a small persistent_term registry.
- A fingerprinted module that can no longer be loaded now reads as stale
  and triggers a rebuild instead of raising UndefinedFunctionError.
- The moduledoc no longer claims the key is written exactly once per
  node; it documents the actual best-effort cold-start semantics
  (concurrent first callers may each build and put equal terms).
- Table.from_data/replace_data property coverage: replace_data/2 is now
  exercised over lived-in tables (array/hash split, dead keys, order
  memo, metatable), and generated map values include nil so the
  all-string one-shot build's nil bail-out is hit.
- CHANGELOG entry for the Lua.new/1 template memoization speedup.

Claude-Session: https://claude.ai/code/session_01BYHGFLoHBJAsUrTzjVp5nC
@davydog187

Copy link
Copy Markdown
Contributor Author

Pushed cd6f129 addressing the confirmed review findings. The interactive-mode fingerprint now also covers an explicit list of template-shaping modules (Lua, Bootstrap, Limits, State, Stdlib, Table) so a struct-layout or default change invalidates the cached template even when it leaves no closure in the term; Lua.VM.Bootstrap.reset/0 gives embedded-mode hosts that hot-load code an escape hatch (keys tracked in a small persistent_term registry, module stays out of the published docs); an unloadable fingerprinted module now reads as stale and rebuilds rather than raising UndefinedFunctionError; and the moduledoc now describes the actual best-effort cold-start semantics instead of claiming a write-once key (no locking added). Also extended the Table property tests to drive replace_data/2 over lived-in tables (split, dead keys, order memo, metatable) with nil-valued entries, and added a CHANGELOG entry. Full suite green (2638 passed), mix compile --warnings-as-errors and mix format --check-formatted clean.

@davydog187

Copy link
Copy Markdown
Contributor Author

🤖 Automated risk assessment — :persistent_term memory safety

Follow-up to the review above, prompted by a specific concern: could the :persistent_term memoization cache things indefinitely and cause memory problems? Assessed by measurement in throwaway worktrees on this branch (cd6f129) and on main.

Bottom line: no — and memory is arguably the strongest argument for this change. The cache is a fixed 44 KB in two literal keys, written twice per node lifetime, and because persistent-term reads are shared rather than copied it moves ~14–31 KB per live VM off every caller's heap. One small real defect surfaced, unrelated to memory (reset/0's key registry — fixed in a follow-up commit).

Why the usual :persistent_term hazard doesn't apply

The dangerous pattern is a key space fed by input — no eviction means it grows forever. That isn't present here. The only two fetch/2 call sites are lib/lua.ex:176 ({Lua, :default_lua}) and :190 ({Lua, :base_state}) — literal atom tuples with argument-less builders, so no user value can reach the key or the stored term. Lua.new/1 only reaches the memo via defp template(@default_sandbox, []), a literal pattern match; every other option shape falls through and builds on the caller's heap.

Hammered to confirm: 500 iterations each of sandboxed: [["os","exit"]], exclude: [["require"]], varying max_call_depth, and combinations → key count stayed at 3, zero rebuilds (traced on build_and_store/2).

Measurements

:persistent_term.info() across the first Lua.new(): count: 37 → 41, memory: 107,976 → 152,768 (+44,792 B). Stored sizes: :default_lua 25,736 B, :base_state 19,256 B, registry 80 B. For scale, OTP/Elixir itself already holds 108 KB there before Lua loads.

Branch vs main, same script:

main this PR
Lua.new() warm 65.3 µs 1.14 µs
two new() pointer-identical (:erts_debug.same/2) false true
heap cost of 1,000 live VMs 13,817 B/VM 31.6 B/VM
100 VMs after Lua.eval! 15,713 B/VM 858 B/VM
32 procs × 500 (new+eval) 8.44 µs/op 0.46 µs/op
:erlang.memory(:total) end of run 97.3 MB 71.3 MB

With default options the struct update in new/1 writes values identical to what's already there, so BEAM returns the map unchanged and callers share the persistent term itself. A process that mutates its VM copies only the path it touches (858 B for a trivial script); its worst case is copying the full 25 KB — which is what main paid unconditionally on every new/1.

Retention check (PR #399's sub-binary hazard)

Structural walk of both stored terms: 0 refs, 0 pids, 0 ports; 335 binaries totalling 1,849 B, every one under 64 bytes (heap binaries, which cannot reference an off-heap refc binary). byte_size vs :binary.referenced_byte_size on every binary: no sub-binary retains more than it shows. Structurally impossible here anyway — the builders take no input, so no lexer/parser output is in scope.

Write cost and staleness

Writes, not storage, are what make :persistent_term expensive (a put over a live key eventually forces referencing processes to major-GC). Traced: cold Lua.new() → exactly 2 builds; next 5,000 warm calls → 0; 64 concurrent tasks × 200 new+eval → 0. So the cost is paid twice at boot, before anything holds a reference.

The highest-value check was whether a fingerprint mismatch could rebuild on every call, turning a one-time cost into a per-call global scan. It can't: module_info(:md5) only changes when code is actually replaced (recompiling unchanged source yields the same md5, so an IEx recompile of untouched files invalidates nothing). Forcing a genuine mismatch by recompiling Lua.VM.Table with an extra function produced one rebuild, then 0 builds across 2,000 subsequent Lua.new() calls.

Findings

  • 🟡 LOW/MED (fixed in follow-up): reset/0 depends on a racy {Lua.VM.Bootstrap, :keys} registry — register/1 is an unsynchronized read-modify-write. 64 concurrent first-fetches of distinct keys stored all 64 but registered only 10; reset/0 would have missed 54. Not a leak or corruption, but reset/0 is the escape hatch for embedded releases after :code.load_file/1, and a missed key leaves a stale template serving closures into replaced code. Since the key set is fixed by construction, a module attribute removes the race and one put per build.
  • 🟡 LOW (docs): reset/0 de-shares the template for anything currently holding a VM. Quantified: 10,000 holders → reset/0 returned in 80 µs but process memory went 43 MB → 364 MB (~33 KB/holder) as each copied the old literal to its own heap. Bounded, one-time, lands near main's baseline; versions don't accumulate across repeated resets. Worth a doc line so nobody calls it per-request.
  • 🟡 LOW (theoretical): if a fingerprinted module were made unloadable, :code.ensure_loaded/1 pays a code-path scan every fetch (133 µs measured for a nonexistent module) and nil === nil compares equal so it wouldn't even rebuild. Requires someone to :code.delete/1 one of Lua's own modules.
  • 🟡 Drift guardrail: fetch/2 takes a caller-supplied key. Nothing today derives one from input, but if a future call site did, both store and registry grow unbounded — 2,000 dynamic keys reached 1.36 MB and 312 µs/key. The follow-up commit makes the compile-time-literal invariant explicit in the docs.

Not re-listed: #415 (interactive recheck cost — measured 1.41 µs for the 12-module recheck against a 1.14 µs warm new(), consistent with the ~52% figure), and the already-fixed fingerprint coverage, reset/0 addition, cold-start-race documentation, and deleted-module tolerance.

`reset/0` used to erase whatever keys a `{Lua.VM.Bootstrap, :keys}`
persistent_term entry listed, and `register/1` maintained that entry with
an unsynchronized read-modify-write. Concurrent first fetches therefore
lost updates: each racing builder read the list, added its key, and put
the result back, so a put built on a stale read dropped keys another
process had already recorded. A truncated registry silently narrows what
`reset/0` erases, and a template that survives the reset keeps handing
out closures into replaced code — the exact failure the `:embedded`-mode
escape hatch exists to prevent.

No bookkeeping is needed: the key set is fixed by construction. Both
`fetch/2` call sites pass compile-time literal tuples with argument-less
builders, so the keys now live in a module attribute and `reset/0` erases
exactly those (plus the obsolete registry entry, for releases upgraded
from a build that wrote one). That invariant is what keeps the store
bounded — `:persistent_term` has no eviction — so it is now stated on
`fetch/2` rather than left implicit, and it drops one `put` per build.

Claude-Session: https://claude.ai/code/session_01BYHGFLoHBJAsUrTzjVp5nC
@davydog187

Copy link
Copy Markdown
Contributor Author

Fixed a lost-update race in the reset path (3bee144). reset/0 was erasing only the keys listed in a {Lua.VM.Bootstrap, :keys} persistent_term entry, and register/1 maintained that entry with an unsynchronized read-modify-write — concurrent cold starts could drop keys from the list, and a template that survives reset/0 keeps serving closures into replaced code, which is exactly what the :embedded-mode escape hatch is for. The registry is gone: both fetch/2 call sites pass compile-time literal keys, so the set now lives in a module attribute that reset/0 erases wholesale (plus the obsolete registry entry, so an upgraded release doesn't leave it behind), which also removes one :persistent_term.put per build. fetch/2's signature and behavior are unchanged; the fixed-key constraint that bounds the store is now documented on it. Tests cover erasure of every memoized key after 64 racing cold starts, and deterministically pin that reset/0 does not consult runtime bookkeeping — both fail against the previous implementation. Full suite green (2640 passed).

@davydog187
davydog187 merged commit b8d59ae into main Jul 27, 2026
5 checks passed
@davydog187
davydog187 deleted the perf/lua-new-fast-path branch July 27, 2026 19:52
@davydog187 davydog187 mentioned this pull request Jul 28, 2026
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