From d0cf58f931a168b53a4034d0dd410842d919cf5a Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 12:30:20 -0400 Subject: [PATCH 01/13] website: parse playground snippets once per run (#402) --- website/lib/website/lua_sandbox.ex | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/website/lib/website/lua_sandbox.ex b/website/lib/website/lua_sandbox.ex index 27e4cfed..a39f1438 100644 --- a/website/lib/website/lua_sandbox.ex +++ b/website/lib/website/lua_sandbox.ex @@ -212,13 +212,16 @@ defmodule Website.LuaSandbox do end) try do - bytecode = + # Parse once and reuse the chunk for both the bytecode pane and the + # evaluation itself. A parse failure falls through to eval!/2 on the + # source so the error path still raises the usual CompilerException. + {bytecode, runnable} = case Lua.parse_chunk(source) do - {:ok, %Lua.Chunk{prototype: proto}} -> disassemble(proto) - _ -> [] + {:ok, %Lua.Chunk{prototype: proto} = chunk} -> {disassemble(proto), chunk} + _ -> {[], source} end - {results, _lua} = Lua.eval!(lua, source) + {results, _lua} = Lua.eval!(lua, runnable) %{ status: :ok, From d178ef3f5c120e8fc5b60b38f9231638e4b09073 Mon Sep 17 00:00:00 2001 From: Federico Meini Date: Mon, 27 Jul 2026 18:39:42 +0200 Subject: [PATCH 02/13] Fix unbounded recursion on cyclic tables at the eval boundary (#407) Co-authored-by: Dave Lucia --- lib/lua/vm/display.ex | 37 ++++++--- lib/lua/vm/display/table.ex | 19 ++++- lib/lua/vm/value.ex | 44 ++++++++--- test/lua/vm/cyclic_table_test.exs | 125 ++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 23 deletions(-) create mode 100644 test/lua/vm/cyclic_table_test.exs diff --git a/lib/lua/vm/display.ex b/lib/lua/vm/display.ex index 69595e79..f190a9af 100644 --- a/lib/lua/vm/display.ex +++ b/lib/lua/vm/display.ex @@ -87,37 +87,49 @@ defmodule Lua.VM.Display do Wraps a single eval-result value for display. See `wrap_results/3` for the decode-mode matrix. + + Tables that (transitively) contain themselves get a `:circular` + peek at the point of recurrence rather than recursing forever — + see `Lua.VM.Display.Table`. """ @spec wrap_value(term(), State.t(), boolean()) :: term() - def wrap_value(value, state, decode?) + def wrap_value(value, state, decode?), do: wrap_value(value, state, decode?, %{}) # decode: true — only wrap closures/native; tables and userdata # have already been decoded and are passed through unchanged. - def wrap_value({:lua_closure, _, _} = ref, _state, _decode?) do + defp wrap_value({:lua_closure, _, _} = ref, _state, _decode?, _ancestors) do wrap_closure(ref) end - def wrap_value({:compiled_closure, _, _} = ref, _state, _decode?) do + defp wrap_value({:compiled_closure, _, _} = ref, _state, _decode?, _ancestors) do wrap_closure(ref) end - def wrap_value({:native_func, fun} = ref, _state, _decode?) do + defp wrap_value({:native_func, fun} = ref, _state, _decode?, _ancestors) do %NativeFunc{fun: fun, ref: ref} end # decode: false — wrap tref/udref too, and recurse into table peek. - def wrap_value({:tref, id} = ref, state, false) do - peek = peek_table(state, id, false) + # `ancestors` holds the tref ids currently being peeked higher up + # this walk; revisiting one means the table contains itself. + defp wrap_value({:tref, id} = ref, state, false, ancestors) do + peek = + if Map.has_key?(ancestors, id) do + :circular + else + peek_table(state, id, false, Map.put(ancestors, id, true)) + end + %DTable{id: id, peek: peek, ref: ref} end - def wrap_value({:udref, id} = ref, state, false) do + defp wrap_value({:udref, id} = ref, state, false, _ancestors) do term = State.get_userdata(state, ref) %Userdata{id: id, term: term, ref: ref} end # decode: true catch-all (already-decoded values pass through) - def wrap_value(value, _state, _decode?), do: value + defp wrap_value(value, _state, _decode?, _ancestors), do: value # ---- internal helpers ---- @@ -138,15 +150,18 @@ defmodule Lua.VM.Display do # (1..N keys) render as a list; mixed-key tables render as a map. # Nested tables/closures are recursively wrapped so `Inspect` does # not have to know about live VM state. - defp peek_table(state, id, decode?) do + defp peek_table(state, id, decode?, ancestors) do case Map.fetch(state.tables, id) do {:ok, table} -> data = Lua.VM.Table.to_map(table) if sequence_like?(data) do - Enum.map(1..map_size(data), &wrap_value(Map.fetch!(data, &1), state, decode?)) + Enum.map( + 1..map_size(data), + &wrap_value(Map.fetch!(data, &1), state, decode?, ancestors) + ) else - Map.new(data, fn {k, v} -> {k, wrap_value(v, state, decode?)} end) + Map.new(data, fn {k, v} -> {k, wrap_value(v, state, decode?, ancestors)} end) end :error -> diff --git a/lib/lua/vm/display/table.ex b/lib/lua/vm/display/table.ex index 56732034..c2085c1f 100644 --- a/lib/lua/vm/display/table.ex +++ b/lib/lua/vm/display/table.ex @@ -15,7 +15,10 @@ defmodule Lua.VM.Display.Table do - `:peek` — a snapshot of the table's data as it was at the time the eval boundary was crossed, suitable for human display. May be a list (sequence-like tables) or a map (mixed-key tables). - Truncated to `Inspect.Opts.limit` entries when rendered. + Truncated to `Inspect.Opts.limit` entries when rendered. When a + table (transitively) contains itself — e.g. the `T.__index = T` + OOP idiom — the recurring occurrence carries `:circular` instead + of a snapshot, bounding an otherwise infinite walk. - `:ref` — the original `{:tref, id}` tuple so callers can round-trip the value back into the VM (via `Lua.set!/3`, `Lua.encode!/2`, etc.). @@ -23,9 +26,11 @@ defmodule Lua.VM.Display.Table do See `Lua.eval!/3` and the `decode:` option. """ + alias Lua.VM.Display.Table + @type t :: %__MODULE__{ id: non_neg_integer(), - peek: list() | map(), + peek: list() | map() | :circular, ref: tuple() } @@ -34,7 +39,15 @@ defmodule Lua.VM.Display.Table do defimpl Inspect do import Inspect.Algebra - def inspect(%Lua.VM.Display.Table{id: id, peek: peek}, opts) do + def inspect(%Table{id: id, peek: :circular}, _opts) do + concat([ + "#Lua.Table" + ]) + end + + def inspect(%Table{id: id, peek: peek}, opts) do concat([ "#Lua.Table + "cyclic table that decoding left in place rather than walking forever. " <> + "Evaluate with `decode: false` to get a table reference that round-trips " <> + "back into the VM." + end + # Structs are maps, so without this clause a bare `%MyStruct{}` would encode # to a Lua table carrying a `"__struct__"` key — a silent, lossy conversion. # Refuse it explicitly: the caller must decide how the struct maps to a Lua @@ -323,25 +335,39 @@ defmodule Lua.VM.Value do Tables are returned as lists of `{key, decoded_value}` tuples. Functions (closures, native) pass through as-is. + + Cyclic tables (e.g. the common `T.__index = T` idiom) cannot be + represented as acyclic Elixir data, so the walk terminates at the + point of recurrence and leaves the table's `{:tref, id}` reference + there — mirroring how functions already pass through as opaque + references. Shared references that do not form a cycle decode + normally. """ @spec decode(term(), State.t()) :: term() - def decode(nil, _state), do: nil - def decode(value, _state) when is_boolean(value), do: value - def decode(value, _state) when is_number(value), do: value - def decode(value, _state) when is_binary(value), do: value + def decode(value, state), do: decode(value, state, %{}) - def decode({:udref, _} = ref, state) do + defp decode(nil, _state, _ancestors), do: nil + defp decode(value, _state, _ancestors) when is_boolean(value), do: value + defp decode(value, _state, _ancestors) when is_number(value), do: value + defp decode(value, _state, _ancestors) when is_binary(value), do: value + + defp decode({:udref, _} = ref, state, _ancestors) do value = State.get_userdata(state, ref) {:userdata, value} end - def decode({:tref, id}, state) do - table = Map.fetch!(state.tables, id) + defp decode({:tref, id} = ref, state, ancestors) do + if Map.has_key?(ancestors, id) do + ref + else + table = Map.fetch!(state.tables, id) + ancestors = Map.put(ancestors, id, true) - Enum.map(Lua.VM.Table.to_map(table), fn {k, v} -> {k, decode(v, state)} end) + Enum.map(Lua.VM.Table.to_map(table), fn {k, v} -> {k, decode(v, state, ancestors)} end) + end end - def decode(value, _state), do: value + defp decode(value, _state, _ancestors), do: value @doc """ Decodes a list of Lua VM values. diff --git a/test/lua/vm/cyclic_table_test.exs b/test/lua/vm/cyclic_table_test.exs new file mode 100644 index 00000000..919f999f --- /dev/null +++ b/test/lua/vm/cyclic_table_test.exs @@ -0,0 +1,125 @@ +defmodule Lua.VM.CyclicTableTest do + @moduledoc """ + Cyclic tables crossing the eval boundary must terminate. + + The common Lua OOP idiom `T.__index = T` creates a table that + contains itself. Both boundary walks — `decode: true` (Value.decode) + and `decode: false` (Display peek) — previously recursed forever on + such values, growing memory without bound until the VM's + `max_heap_size` (when set) killed the process. + """ + + use ExUnit.Case, async: true + + alias Lua.VM.Display.Table, as: DTable + + @self_cycle """ + local T = {} + T.__index = T + return T + """ + + @mutual_cycle """ + local a = {} + local b = {a = a} + a.b = b + return a + """ + + describe "decode: false (Display peek)" do + test "self-referential table peeks as :circular at the recurrence" do + {[t], _} = Lua.eval!(Lua.new(), @self_cycle, decode: false) + + assert %DTable{id: id, peek: %{"__index" => inner}} = t + assert %DTable{id: ^id, peek: :circular} = inner + assert inspect(inner) == "#Lua.Table" + end + + test "mutually recursive tables terminate and render" do + {[t], _} = Lua.eval!(Lua.new(), @mutual_cycle, decode: false) + + assert %DTable{id: a_id, peek: %{"b" => %DTable{peek: %{"a" => inner_a}}}} = t + assert %DTable{id: ^a_id, peek: :circular} = inner_a + assert inspect(t) =~ "circular" + end + + test "a cycle through a sequence element peeks as :circular" do + code = """ + local t = {} + t[1] = t + return t + """ + + {[t], _} = Lua.eval!(Lua.new(), code, decode: false) + + assert %DTable{id: id, peek: [inner]} = t + assert %DTable{id: ^id, peek: :circular} = inner + end + + test "shared non-cyclic references still peek fully" do + code = """ + local shared = {x = 1} + return {a = shared, b = shared} + """ + + {[t], _} = Lua.eval!(Lua.new(), code, decode: false) + + assert %DTable{ + peek: %{"a" => %DTable{peek: %{"x" => 1}}, "b" => %DTable{peek: %{"x" => 1}}} + } = + t + end + end + + describe "decode: true (Value.decode)" do + test "self-referential table terminates with the table's reference at the recurrence" do + {[decoded], _} = Lua.eval!(Lua.new(), @self_cycle) + + assert [{"__index", {:tref, id}}] = decoded + assert is_integer(id) + end + + test "mutually recursive tables terminate with a reference" do + {[decoded], _} = Lua.eval!(Lua.new(), @mutual_cycle) + + assert [{"b", [{"a", {:tref, _}}]}] = decoded + end + + test "shared non-cyclic references decode normally" do + code = """ + local shared = {x = 1} + return {a = shared, b = shared} + """ + + {[decoded], _} = Lua.eval!(Lua.new(), code) + + assert Enum.sort(decoded) == [{"a", [{"x", 1}]}, {"b", [{"x", 1}]}] + end + + test "a table appearing under multiple sibling keys is not a false-positive cycle" do + code = """ + local leaf = {v = 1} + local mid = {l = leaf, r = leaf} + return {left = mid, right = mid} + """ + + {[decoded], _} = Lua.eval!(Lua.new(), code) + + branch = [{"l", [{"v", 1}]}, {"r", [{"v", 1}]}] + + assert [{"left", left}, {"right", right}] = Enum.sort(decoded) + assert Enum.sort(left) == branch + assert Enum.sort(right) == branch + end + end + + describe "re-encoding a decoded cycle" do + test "the leftover tref is refused with an actionable error" do + {[decoded], lua} = Lua.eval!(Lua.new(), @self_cycle) + + assert_raise Lua.RuntimeException, ~r/marks a cyclic table/, fn -> + Lua.set!(lua, [:x], Map.new(decoded)) + end + end + end +end From 70b3f96afeb944d8722020f5572d2a5cad8683f7 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 14:09:43 -0400 Subject: [PATCH 03/13] lexer: slice tokens from the source binary; drop per-char position maps (#399) --- lib/lua/lexer.ex | 985 +++++++++++++++++++++++----------------- test/lua/lexer_test.exs | 99 ++++ 2 files changed, 656 insertions(+), 428 deletions(-) diff --git a/lib/lua/lexer.ex b/lib/lua/lexer.ex index b2338a6d..9440ddd8 100644 --- a/lib/lua/lexer.ex +++ b/lib/lua/lexer.ex @@ -3,6 +3,19 @@ defmodule Lua.Lexer do Hand-written lexer for Lua 5.3 using Elixir binary pattern matching. Tokenizes Lua source code into a list of tokens with position tracking. + + Position is threaded as three bare integers instead of a map: the current + line, the byte offset the current line's columns are measured from, and the + current byte offset. A position map is materialized only when a token or an + error is emitted. `line_start` absorbs the continuation bytes of multibyte + codepoints, so `column` counts codepoints while `byte_offset` counts bytes. + + Token text is sliced out of the source binary with `binary_part/3` and then + copied with `:binary.copy/1`, so a retained token never keeps the whole + source binary alive. String escapes and long-string end-of-line + normalization are the only places where a token's text differs from its + source bytes; those fall back to collecting chunks of iodata around the + rewritten spans. """ import Bitwise @@ -18,10 +31,12 @@ defmodule Lua.Lexer do | {:comment, :single | :multi, String.t(), position()} | {:eof, position()} - @keywords ~w( - and break do else elseif end false for function goto if in - local nil not or repeat return then true until while - ) + # Signed 64-bit wrap-around constants for integer literals. + @uint64_mask 0xFFFFFFFFFFFFFFFF + @uint64_modulus 0x10000000000000000 + @sign_bit 0x8000000000000000 + + @compile {:inline, position: 3, utf8_width: 1, chunked_text: 4} @doc """ Tokenizes Lua source code into a list of tokens. @@ -40,9 +55,8 @@ defmodule Lua.Lexer do @spec tokenize(String.t()) :: {:ok, [token()]} | {:error, term()} def tokenize(code) when is_binary(code) do # Handle shebang on first line (Unix convention: #! means interpreter directive) - code = strip_shebang(code) - pos = %{line: 1, column: 1, byte_offset: 0} - do_tokenize(code, [], pos) + src = strip_shebang(code) + do_tokenize(src, [], src, 1, 0, 0) end # Strip the first line if it looks like a shebang/header directive. Lua's @@ -62,409 +76,496 @@ defmodule Lua.Lexer do end end + # Build the public position map for the given cursor. + defp position(line, line_start, offset) do + %{line: line, column: offset - line_start + 1, byte_offset: offset} + end + + # Byte width of a codepoint's UTF-8 encoding (only called for cp > 127). + defp utf8_width(cp) when cp < 0x800, do: 2 + defp utf8_width(cp) when cp < 0x10000, do: 3 + defp utf8_width(_cp), do: 4 + + # Assemble token text from the trailing raw span plus any earlier chunks. + # The single-slice path copies the sub-binary so a token doesn't keep the + # whole source binary alive; the chunked path copies via iodata already. + defp chunked_text(src, start, offset, []), do: :binary.copy(binary_part(src, start, offset - start)) + + defp chunked_text(src, start, offset, chunks) do + IO.iodata_to_binary(:lists.reverse([binary_part(src, start, offset - start) | chunks])) + end + # End of input - defp do_tokenize(<<>>, acc, pos) do - {:ok, Enum.reverse([{:eof, pos} | acc])} + defp do_tokenize(<<>>, acc, _src, line, line_start, offset) do + {:ok, Enum.reverse([{:eof, position(line, line_start, offset)} | acc])} end # Whitespace (space, horizontal tab, vertical tab, form feed). # Per Lua 5.3 reference manual §3.1, whitespace is space, tab, newline, # carriage return, vertical tab, and form feed. Newline and CR advance # the line counter and are handled below. - defp do_tokenize(<>, acc, pos) when c in [?\s, ?\t, ?\v, ?\f] do - new_pos = advance_column(pos, 1) - do_tokenize(rest, acc, new_pos) + defp do_tokenize(<>, acc, src, line, line_start, offset) when c in [?\s, ?\t, ?\v, ?\f] do + do_tokenize(rest, acc, src, line, line_start, offset + 1) end # Newline (LF) - defp do_tokenize(<>, acc, pos) do - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 1} - do_tokenize(rest, acc, new_pos) + defp do_tokenize(<>, acc, src, line, _line_start, offset) do + do_tokenize(rest, acc, src, line + 1, offset + 1, offset + 1) end # Carriage return (CR, or CRLF) - defp do_tokenize(<>, acc, pos) do - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 2} - do_tokenize(rest, acc, new_pos) + defp do_tokenize(<>, acc, src, line, _line_start, offset) do + do_tokenize(rest, acc, src, line + 1, offset + 2, offset + 2) end - defp do_tokenize(<>, acc, pos) do - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 1} - do_tokenize(rest, acc, new_pos) + defp do_tokenize(<>, acc, src, line, _line_start, offset) do + do_tokenize(rest, acc, src, line + 1, offset + 1, offset + 1) end # Comments: single-line (--) or multi-line (--[[ ... ]] or --[=[ ... ]=] etc.) - defp do_tokenize(<<"--[", rest::binary>>, acc, pos) do + defp do_tokenize(<<"--[", rest::binary>>, acc, src, line, line_start, offset) do # scan_long_bracket eats `=` characters then requires a closing `[`, # so it correctly detects --[[ (level 0), --[=[ (level 1), --[==[ (level 2), etc. case scan_long_bracket(rest, 0) do {:ok, equals, after_bracket} -> - # Multi-line comment of the given level - scan_multiline_comment_text( + # Multi-line comment of the given level. The opener is `--[`, the + # level's `=` signs, and the second `[`. + body = offset + 4 + equals + + scan_multiline_comment( after_bracket, - "", acc, - advance_column(pos, 3 + equals), - pos, + src, + line, + line_start, + body, + body, + position(line, line_start, offset), equals ) :error -> # Single-line comment starting with --[ - scan_single_line_comment(rest, acc, advance_column(pos, 3), pos) + start_pos = position(line, line_start, offset) + scan_single_line_comment(rest, acc, src, line, line_start, offset + 3, offset + 3, start_pos) end end - defp do_tokenize(<<"--", rest::binary>>, acc, pos) do - scan_single_line_comment(rest, acc, advance_column(pos, 2), pos) + defp do_tokenize(<<"--", rest::binary>>, acc, src, line, line_start, offset) do + start_pos = position(line, line_start, offset) + scan_single_line_comment(rest, acc, src, line, line_start, offset + 2, offset + 2, start_pos) end # Strings: double-quoted - defp do_tokenize(<>, acc, pos) do - scan_string(rest, "", acc, advance_column(pos, 1), pos, ?") + defp do_tokenize(<>, acc, src, line, line_start, offset) do + scan_string(rest, acc, src, line, line_start, offset + 1, offset + 1, [], position(line, line_start, offset), ?") end # Strings: single-quoted - defp do_tokenize(<>, acc, pos) do - scan_string(rest, "", acc, advance_column(pos, 1), pos, ?') + defp do_tokenize(<>, acc, src, line, line_start, offset) do + scan_string(rest, acc, src, line, line_start, offset + 1, offset + 1, [], position(line, line_start, offset), ?') end # Strings: multi-line [[ ... ]] or [=[ ... ]=] - defp do_tokenize(<<"[", rest::binary>>, acc, pos) do + defp do_tokenize(<<"[", rest::binary>>, acc, src, line, line_start, offset) do case scan_long_bracket(rest, 0) do {:ok, equals, after_bracket} -> - start_pos = pos - open_pos = advance_column(pos, 2 + equals) - {body_rest, body_pos} = drop_leading_newline(after_bracket, open_pos) - scan_long_string(body_rest, "", acc, body_pos, start_pos, equals) + start_pos = position(line, line_start, offset) + + {body, body_line, body_line_start, body_offset} = + drop_leading_newline(after_bracket, line, line_start, offset + 2 + equals) + + scan_long_string( + body, + acc, + src, + body_line, + body_line_start, + body_offset, + body_offset, + [], + start_pos, + equals + ) :error -> # Not a long string, treat as delimiter - token = {:delimiter, :lbracket, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 1)) + token = {:delimiter, :lbracket, position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 1) end end # Numbers: hex (0x, 0X) - defp do_tokenize(<<"0", x, rest::binary>>, acc, pos) when x in [?x, ?X] do - scan_hex_number(rest, "", acc, advance_column(pos, 2), pos) + defp do_tokenize(<<"0", x, rest::binary>>, acc, src, line, line_start, offset) when x in [?x, ?X] do + scan_hex_int(rest, acc, src, line, line_start, offset + 2, offset + 2, offset) end # Numbers: decimal or float - defp do_tokenize(<>, acc, pos) when c in ?0..?9 do - scan_number(<>, "", acc, pos, pos) + defp do_tokenize(<> = bin, acc, src, line, line_start, offset) when c in ?0..?9 do + scan_int_digits(bin, acc, src, line, line_start, offset, offset) end # Float starting with dot: .0, .5e3, etc. - defp do_tokenize(<<".", c, rest::binary>>, acc, pos) when c in ?0..?9 do - scan_float(rest, "0." <> <>, acc, advance_column(pos, 2), pos) + defp do_tokenize(<<".", c, _rest::binary>> = bin, acc, src, line, line_start, offset) when c in ?0..?9 do + scan_int_digits(bin, acc, src, line, line_start, offset, offset) end # Three-character operators - defp do_tokenize(<<"...", rest::binary>>, acc, pos) do - token = {:operator, :vararg, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 3)) + defp do_tokenize(<<"...", rest::binary>>, acc, src, line, line_start, offset) do + token = {:operator, :vararg, position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 3) end # Two-character operators - defp do_tokenize(<<"==", rest::binary>>, acc, pos) do - token = {:operator, :eq, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 2)) + defp do_tokenize(<<"==", rest::binary>>, acc, src, line, line_start, offset) do + token = {:operator, :eq, position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 2) end - defp do_tokenize(<<"~=", rest::binary>>, acc, pos) do - token = {:operator, :ne, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 2)) + defp do_tokenize(<<"~=", rest::binary>>, acc, src, line, line_start, offset) do + token = {:operator, :ne, position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 2) end - defp do_tokenize(<<"<=", rest::binary>>, acc, pos) do - token = {:operator, :le, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 2)) + defp do_tokenize(<<"<=", rest::binary>>, acc, src, line, line_start, offset) do + token = {:operator, :le, position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 2) end - defp do_tokenize(<<">=", rest::binary>>, acc, pos) do - token = {:operator, :ge, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 2)) + defp do_tokenize(<<">=", rest::binary>>, acc, src, line, line_start, offset) do + token = {:operator, :ge, position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 2) end - defp do_tokenize(<<"..", rest::binary>>, acc, pos) do - token = {:operator, :concat, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 2)) + defp do_tokenize(<<"..", rest::binary>>, acc, src, line, line_start, offset) do + token = {:operator, :concat, position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 2) end - defp do_tokenize(<<"::", rest::binary>>, acc, pos) do - token = {:delimiter, :double_colon, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 2)) + defp do_tokenize(<<"::", rest::binary>>, acc, src, line, line_start, offset) do + token = {:delimiter, :double_colon, position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 2) end - defp do_tokenize(<<"//", rest::binary>>, acc, pos) do - token = {:operator, :floordiv, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 2)) + defp do_tokenize(<<"//", rest::binary>>, acc, src, line, line_start, offset) do + token = {:operator, :floordiv, position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 2) end # Bitwise shift operators (must come before single < and >) - defp do_tokenize(<<"<<", rest::binary>>, acc, pos) do - token = {:operator, :shl, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 2)) + defp do_tokenize(<<"<<", rest::binary>>, acc, src, line, line_start, offset) do + token = {:operator, :shl, position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 2) end - defp do_tokenize(<<">>", rest::binary>>, acc, pos) do - token = {:operator, :shr, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 2)) + defp do_tokenize(<<">>", rest::binary>>, acc, src, line, line_start, offset) do + token = {:operator, :shr, position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 2) end # Single-character operators and delimiters - defp do_tokenize(<>, acc, pos) when c in [?+, ?-, ?*, ?/, ?%, ?^, ?#, ?&, ?|, ?~] do - op = - case c do - ?+ -> :add - ?- -> :sub - ?* -> :mul - ?/ -> :div - ?% -> :mod - ?^ -> :pow - ?# -> :len - ?& -> :band - ?| -> :bor - ?~ -> :bxor - end - - token = {:operator, op, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 1)) + defp do_tokenize(<>, acc, src, line, line_start, offset) + when c in [?+, ?-, ?*, ?/, ?%, ?^, ?#, ?&, ?|, ?~] do + token = {:operator, single_operator(c), position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 1) end - defp do_tokenize(<>, acc, pos) when c in [?<, ?>, ?=] do - op = - case c do - ?< -> :lt - ?> -> :gt - ?= -> :assign - end - - token = {:operator, op, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 1)) - end - - defp do_tokenize(<>, acc, pos) when c in [?(, ?), ?{, ?}, ?], ?;, ?,, ?., ?:] do - delim = - case c do - ?( -> :lparen - ?) -> :rparen - ?{ -> :lbrace - ?} -> :rbrace - ?] -> :rbracket - ?; -> :semicolon - ?, -> :comma - ?. -> :dot - ?: -> :colon - end + defp do_tokenize(<>, acc, src, line, line_start, offset) when c in [?<, ?>, ?=] do + token = {:operator, single_operator(c), position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 1) + end - token = {:delimiter, delim, pos} - do_tokenize(rest, [token | acc], advance_column(pos, 1)) + defp do_tokenize(<>, acc, src, line, line_start, offset) + when c in [?(, ?), ?{, ?}, ?], ?;, ?,, ?., ?:] do + token = {:delimiter, single_delimiter(c), position(line, line_start, offset)} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 1) end # Identifiers and keywords - defp do_tokenize(<>, acc, pos) when c in ?a..?z or c in ?A..?Z or c == ?_ do - scan_identifier(<>, "", acc, pos, pos) + defp do_tokenize(<>, acc, src, line, line_start, offset) when c in ?a..?z or c in ?A..?Z or c == ?_ do + {after_id, len} = scan_identifier(rest, 1) + text = binary_part(src, offset, len) + start_pos = position(line, line_start, offset) + + token = + case keyword_atom(text) do + {:ok, keyword} -> {:keyword, keyword, start_pos} + # Copy the slice so the identifier doesn't keep the source alive. + :error -> {:identifier, :binary.copy(text), start_pos} + end + + do_tokenize(after_id, [token | acc], src, line, line_start, offset + len) end # Unexpected character — carry the full codepoint so error messages stay # valid UTF-8 even for multibyte characters (a byte-level match would keep # only the UTF-8 lead byte). - defp do_tokenize(<>, _acc, pos) do - {:error, {:unexpected_character, cp, pos}} + defp do_tokenize(<>, _acc, _src, line, line_start, offset) do + {:error, {:unexpected_character, cp, position(line, line_start, offset)}} end # Genuinely invalid UTF-8 lead byte (no valid codepoint here). - defp do_tokenize(<>, _acc, pos) do - {:error, {:invalid_byte, byte, pos}} + defp do_tokenize(<>, _acc, _src, line, line_start, offset) do + {:error, {:invalid_byte, byte, position(line, line_start, offset)}} end - # Scan single-line comment (collect text until newline) - # pos is the current scanning position (after --), token_pos is where the comment started - defp scan_single_line_comment(rest, acc, pos, token_pos) do - scan_single_line_comment_content(rest, "", acc, pos, token_pos) + defp single_operator(?+), do: :add + defp single_operator(?-), do: :sub + defp single_operator(?*), do: :mul + defp single_operator(?/), do: :div + defp single_operator(?%), do: :mod + defp single_operator(?^), do: :pow + defp single_operator(?#), do: :len + defp single_operator(?&), do: :band + defp single_operator(?|), do: :bor + defp single_operator(?~), do: :bxor + defp single_operator(?<), do: :lt + defp single_operator(?>), do: :gt + defp single_operator(?=), do: :assign + + defp single_delimiter(?(), do: :lparen + defp single_delimiter(?)), do: :rparen + defp single_delimiter(?{), do: :lbrace + defp single_delimiter(?}), do: :rbrace + defp single_delimiter(?]), do: :rbracket + defp single_delimiter(?;), do: :semicolon + defp single_delimiter(?,), do: :comma + defp single_delimiter(?.), do: :dot + defp single_delimiter(?:), do: :colon + + # Scan single-line comment: the text runs from `text_start` to the newline + # (or end of input) and is always a verbatim slice of the source. The start + # position is built eagerly by the caller — multibyte codepoints in the body + # shift `line_start`, so it can't be reconstructed after scanning. + defp scan_single_line_comment(<>, acc, src, line, _line_start, offset, text_start, start_pos) do + token = single_comment(src, text_start, offset, start_pos) + do_tokenize(rest, [token | acc], src, line + 1, offset + 1, offset + 1) + end + + defp scan_single_line_comment(<>, acc, src, line, _line_start, offset, text_start, start_pos) do + token = single_comment(src, text_start, offset, start_pos) + do_tokenize(rest, [token | acc], src, line + 1, offset + 2, offset + 2) end - defp scan_single_line_comment_content(<>, text, acc, pos, start_pos) do - token = {:comment, :single, text, start_pos} - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 1} - do_tokenize(rest, [token | acc], new_pos) + defp scan_single_line_comment(<>, acc, src, line, _line_start, offset, text_start, start_pos) do + token = single_comment(src, text_start, offset, start_pos) + do_tokenize(rest, [token | acc], src, line + 1, offset + 1, offset + 1) end - defp scan_single_line_comment_content(<>, text, acc, pos, start_pos) do - token = {:comment, :single, text, start_pos} - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 2} - do_tokenize(rest, [token | acc], new_pos) + defp scan_single_line_comment(<<>>, acc, src, line, line_start, offset, text_start, start_pos) do + token = single_comment(src, text_start, offset, start_pos) + {:ok, Enum.reverse([{:eof, position(line, line_start, offset)}, token | acc])} end - defp scan_single_line_comment_content(<>, text, acc, pos, start_pos) do - token = {:comment, :single, text, start_pos} - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 1} - do_tokenize(rest, [token | acc], new_pos) + defp scan_single_line_comment(<>, acc, src, line, line_start, offset, text_start, start_pos) + when c < 128 do + scan_single_line_comment(rest, acc, src, line, line_start, offset + 1, text_start, start_pos) end - defp scan_single_line_comment_content(<<>>, text, acc, pos, start_pos) do - token = {:comment, :single, text, start_pos} - {:ok, Enum.reverse([{:eof, pos}, token | acc])} + defp scan_single_line_comment(<>, acc, src, line, line_start, offset, text_start, start_pos) + when cp > 127 do + width = utf8_width(cp) + + scan_single_line_comment( + rest, + acc, + src, + line, + line_start + width - 1, + offset + width, + text_start, + start_pos + ) end - defp scan_single_line_comment_content(<>, text, acc, pos, start_pos) when cp > 127 do - scan_single_line_comment_content(rest, text <> <>, acc, advance_utf8(pos, cp), start_pos) + defp scan_single_line_comment(<<_c, rest::binary>>, acc, src, line, line_start, offset, text_start, start_pos) do + scan_single_line_comment(rest, acc, src, line, line_start, offset + 1, text_start, start_pos) end - defp scan_single_line_comment_content(<>, text, acc, pos, start_pos) do - scan_single_line_comment_content(rest, text <> <>, acc, advance_column(pos, 1), start_pos) + defp single_comment(src, text_start, offset, start_pos) do + {:comment, :single, :binary.copy(binary_part(src, text_start, offset - text_start)), start_pos} end # Scan multi-line comment body. The opening bracket level was determined by - # scan_long_bracket in do_tokenize/3. `pos` is the current scanning position - # (right after the opener), `start_pos` is where the comment started. - defp scan_multiline_comment_text(<<"]", rest::binary>>, text, acc, pos, start_pos, level) do + # scan_long_bracket in do_tokenize/6. The body is a verbatim slice of the + # source: no end-of-line normalization applies to comments. + defp scan_multiline_comment(<<"]", rest::binary>>, acc, src, line, line_start, offset, text_start, start_pos, level) do case try_close_long_bracket(rest, level, 0) do {:ok, after_bracket} -> + text = :binary.copy(binary_part(src, text_start, offset - text_start)) token = {:comment, :multi, text, start_pos} - new_pos = advance_column(pos, 2 + level) - do_tokenize(after_bracket, [token | acc], new_pos) + do_tokenize(after_bracket, [token | acc], src, line, line_start, offset + 2 + level) :error -> - scan_multiline_comment_text( - rest, - text <> "]", - acc, - advance_column(pos, 1), - start_pos, - level - ) + scan_multiline_comment(rest, acc, src, line, line_start, offset + 1, text_start, start_pos, level) end end - defp scan_multiline_comment_text(<>, text, acc, pos, start_pos, level) do - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 1} - scan_multiline_comment_text(rest, text <> "\n", acc, new_pos, start_pos, level) + defp scan_multiline_comment(<>, acc, src, line, _line_start, offset, text_start, start_pos, level) do + scan_multiline_comment(rest, acc, src, line + 1, offset + 1, offset + 1, text_start, start_pos, level) end - defp scan_multiline_comment_text(<<>>, _text, _acc, pos, _start_pos, _level) do - {:error, {:unclosed_comment, pos}} + defp scan_multiline_comment(<<>>, _acc, _src, line, line_start, offset, _text_start, _start_pos, _level) do + {:error, {:unclosed_comment, position(line, line_start, offset)}} end - defp scan_multiline_comment_text(<>, text, acc, pos, start_pos, level) when cp > 127 do - scan_multiline_comment_text( - rest, - text <> <>, - acc, - advance_utf8(pos, cp), - start_pos, - level - ) + defp scan_multiline_comment(<>, acc, src, line, line_start, offset, text_start, start_pos, level) + when c < 128 do + scan_multiline_comment(rest, acc, src, line, line_start, offset + 1, text_start, start_pos, level) end - defp scan_multiline_comment_text(<>, text, acc, pos, start_pos, level) do - scan_multiline_comment_text( + defp scan_multiline_comment( + <>, + acc, + src, + line, + line_start, + offset, + text_start, + start_pos, + level + ) + when cp > 127 do + width = utf8_width(cp) + + scan_multiline_comment( rest, - text <> <>, acc, - advance_column(pos, 1), + src, + line, + line_start + width - 1, + offset + width, + text_start, start_pos, level ) end - # Scan quoted string - defp scan_string(<>, str_acc, acc, pos, start_pos, quote) do + defp scan_multiline_comment(<<_c, rest::binary>>, acc, src, line, line_start, offset, text_start, start_pos, level) do + scan_multiline_comment(rest, acc, src, line, line_start, offset + 1, text_start, start_pos, level) + end + + # Scan quoted string. `chunk` is the offset where the current verbatim span + # of the string body starts; `chunks` holds the already-rewritten prefix in + # reverse order and stays empty for the common escape-free string. + defp scan_string(<>, acc, src, line, line_start, offset, chunk, chunks, start_pos, quote) do # Closing quote - token = {:string, str_acc, start_pos} - do_tokenize(rest, [token | acc], pos) + token = {:string, chunked_text(src, chunk, offset, chunks), start_pos} + do_tokenize(rest, [token | acc], src, line, line_start, offset + 1) end # \z escape: skip all following whitespace - defp scan_string(<>, str_acc, acc, pos, start_pos, quote) do - {remaining, new_pos} = skip_whitespace_in_string(rest, advance_column(pos, 2)) - scan_string(remaining, str_acc, acc, new_pos, start_pos, quote) + defp scan_string(<>, acc, src, line, line_start, offset, chunk, chunks, start_pos, quote) do + chunks = flush_chunk(src, chunk, offset, chunks) + {remaining, line, line_start, offset} = skip_string_whitespace(rest, line, line_start, offset + 2) + scan_string(remaining, acc, src, line, line_start, offset, offset, chunks, start_pos, quote) end # \xXX hex escape: exactly two hex digits - defp scan_string(<>, str_acc, acc, pos, start_pos, quote) + defp scan_string(<>, acc, src, line, line_start, offset, chunk, chunks, start_pos, quote) when h1 in ?0..?9 or h1 in ?a..?f or h1 in ?A..?F do if hex?(h2) do byte = hex_value(h1) * 16 + hex_value(h2) - scan_string(rest, str_acc <> <>, acc, advance_column(pos, 4), start_pos, quote) + chunks = [<> | flush_chunk(src, chunk, offset, chunks)] + scan_string(rest, acc, src, line, line_start, offset + 4, offset + 4, chunks, start_pos, quote) else - {:error, {:invalid_escape, pos}} + {:error, {:invalid_escape, position(line, line_start, offset)}} end end - defp scan_string(<>, _str_acc, _acc, pos, _start_pos, _quote) do - {:error, {:invalid_escape, pos}} + defp scan_string(<>, _acc, _src, line, line_start, offset, _chunk, _chunks, _start_pos, _quote) do + {:error, {:invalid_escape, position(line, line_start, offset)}} end # \u{XXX} unicode escape (UTF-8 encoded codepoint) - defp scan_string(<>, str_acc, acc, pos, start_pos, quote) do + defp scan_string(<>, acc, src, line, line_start, offset, chunk, chunks, start_pos, quote) do case scan_unicode_escape(rest, 0, 0) do - {:ok, codepoint, digits, after_brace} when codepoint <= 0x7FFFFFFF -> - utf8 = encode_lua_utf8(codepoint) - # consumed: \u{ + digits + } - scan_string(after_brace, str_acc <> utf8, acc, advance_column(pos, 4 + digits), start_pos, quote) + {:ok, codepoint, inner_len, after_brace} when codepoint <= 0x7FFFFFFF -> + chunks = [encode_lua_utf8(codepoint) | flush_chunk(src, chunk, offset, chunks)] + # consumed: `\u{` plus the digits and the closing `}` + offset = offset + 3 + inner_len + scan_string(after_brace, acc, src, line, line_start, offset, offset, chunks, start_pos, quote) _ -> - {:error, {:invalid_escape, pos}} + {:error, {:invalid_escape, position(line, line_start, offset)}} end end - # \ddd decimal escape: 1-3 decimal digits, value must fit in a byte - defp scan_string(<>, str_acc, acc, pos, start_pos, quote) when d1 in ?0..?9 do + # \ddd decimal escape: 1-3 decimal digits. read_decimal_escape/3 stops + # before a digit would push the value past 255, so it always fits in a byte. + defp scan_string(<>, acc, src, line, line_start, offset, chunk, chunks, start_pos, quote) + when d1 in ?0..?9 do {value, digits, remaining} = read_decimal_escape(d1 - ?0, 1, rest) - - if value > 255 do - {:error, {:invalid_escape, pos}} - else - scan_string(remaining, str_acc <> <>, acc, advance_column(pos, 1 + digits), start_pos, quote) - end + chunks = [<> | flush_chunk(src, chunk, offset, chunks)] + offset = offset + 1 + digits + scan_string(remaining, acc, src, line, line_start, offset, offset, chunks, start_pos, quote) end # \ line continuation: a backslash before a real end-of-line yields a # single \n byte and advances one line. All four line endings (\n, \r, \r\n, # \n\r) collapse to one newline, matching PUC-Lua's `read_string`. - defp scan_string(<>, str_acc, acc, pos, start_pos, quote) do - scan_string(rest, str_acc <> "\n", acc, advance_string_line(pos, 3), start_pos, quote) + defp scan_string(<>, acc, src, line, _line_start, offset, chunk, chunks, start_pos, quote) do + continue_string_line(rest, acc, src, line, offset, 3, chunk, chunks, start_pos, quote) end - defp scan_string(<>, str_acc, acc, pos, start_pos, quote) do - scan_string(rest, str_acc <> "\n", acc, advance_string_line(pos, 3), start_pos, quote) + defp scan_string(<>, acc, src, line, _line_start, offset, chunk, chunks, start_pos, quote) do + continue_string_line(rest, acc, src, line, offset, 3, chunk, chunks, start_pos, quote) end - defp scan_string(<>, str_acc, acc, pos, start_pos, quote) when nl in [?\n, ?\r] do - scan_string(rest, str_acc <> "\n", acc, advance_string_line(pos, 2), start_pos, quote) + defp scan_string(<>, acc, src, line, _line_start, offset, chunk, chunks, start_pos, quote) + when nl in [?\n, ?\r] do + continue_string_line(rest, acc, src, line, offset, 2, chunk, chunks, start_pos, quote) end - defp scan_string(<>, str_acc, acc, pos, start_pos, quote) do + defp scan_string(<>, acc, src, line, line_start, offset, chunk, chunks, start_pos, quote) do # Escape sequence case escape_char(esc) do {:ok, char} -> - scan_string(rest, str_acc <> <>, acc, advance_column(pos, 2), start_pos, quote) + chunks = [<> | flush_chunk(src, chunk, offset, chunks)] + scan_string(rest, acc, src, line, line_start, offset + 2, offset + 2, chunks, start_pos, quote) :error -> - # Invalid escape, but continue scanning - scan_string(rest, str_acc <> <>, acc, advance_column(pos, 2), start_pos, quote) + # Invalid escape, but continue scanning — the backslash and the + # following byte are kept verbatim, so the span needs no rewriting. + scan_string(rest, acc, src, line, line_start, offset + 2, chunk, chunks, start_pos, quote) end end - defp scan_string(<>, _str_acc, _acc, pos, _start_pos, _quote) do - {:error, {:unclosed_string, pos}} + defp scan_string(<>, _acc, _src, line, line_start, offset, _chunk, _chunks, _start_pos, _quote) do + {:error, {:unclosed_string, position(line, line_start, offset)}} + end + + defp scan_string(<<>>, _acc, _src, line, line_start, offset, _chunk, _chunks, _start_pos, _quote) do + {:error, {:unclosed_string, position(line, line_start, offset)}} end - defp scan_string(<<>>, _str_acc, _acc, pos, _start_pos, _quote) do - {:error, {:unclosed_string, pos}} + defp scan_string(<>, acc, src, line, line_start, offset, chunk, chunks, start_pos, quote) + when c < 128 do + scan_string(rest, acc, src, line, line_start, offset + 1, chunk, chunks, start_pos, quote) end - defp scan_string(<>, str_acc, acc, pos, start_pos, quote) when cp > 127 do - scan_string(rest, str_acc <> <>, acc, advance_utf8(pos, cp), start_pos, quote) + defp scan_string(<>, acc, src, line, line_start, offset, chunk, chunks, start_pos, quote) + when cp > 127 do + width = utf8_width(cp) + scan_string(rest, acc, src, line, line_start + width - 1, offset + width, chunk, chunks, start_pos, quote) end - defp scan_string(<>, str_acc, acc, pos, start_pos, quote) do - scan_string(rest, str_acc <> <>, acc, advance_column(pos, 1), start_pos, quote) + defp scan_string(<<_c, rest::binary>>, acc, src, line, line_start, offset, chunk, chunks, start_pos, quote) do + scan_string(rest, acc, src, line, line_start, offset + 1, chunk, chunks, start_pos, quote) end + # A \ continuation contributes one "\n" byte and starts a new line + # after `consumed` raw source bytes. + defp continue_string_line(rest, acc, src, line, offset, consumed, chunk, chunks, start_pos, quote) do + chunks = ["\n" | flush_chunk(src, chunk, offset, chunks)] + offset = offset + consumed + scan_string(rest, acc, src, line + 1, offset, offset, offset, chunks, start_pos, quote) + end + + # Close the current verbatim span before a rewritten one is appended. + defp flush_chunk(_src, chunk, offset, chunks) when chunk == offset, do: chunks + defp flush_chunk(src, chunk, offset, chunks), do: [binary_part(src, chunk, offset - chunk) | chunks] + # Read up to two more decimal digits for a \ddd escape defp read_decimal_escape(value, digits, <>) when d in ?0..?9 and digits < 3 do next = value * 10 + (d - ?0) @@ -478,7 +579,8 @@ defmodule Lua.Lexer do defp read_decimal_escape(value, digits, rest), do: {value, digits, rest} - # Read hex digits inside \u{...} + # Read hex digits inside \u{...}. The reported length covers the digits and + # the closing brace, i.e. everything after the opening `\u{`. defp scan_unicode_escape(<>, value, digits) when digits > 0 do {:ok, value, digits + 1, rest} end @@ -543,39 +645,20 @@ defmodule Lua.Lexer do defp escape_char(_), do: :error # Helper for \z escape: skip all whitespace characters - defp skip_whitespace_in_string(<>, pos) do - skip_whitespace_in_string(rest, advance_column(pos, 1)) + defp skip_string_whitespace(<>, line, line_start, offset) when c in [?\s, ?\t, ?\v, ?\f] do + skip_string_whitespace(rest, line, line_start, offset + 1) end - defp skip_whitespace_in_string(<>, pos) do - skip_whitespace_in_string(rest, advance_column(pos, 1)) + defp skip_string_whitespace(<>, line, _line_start, offset) do + skip_string_whitespace(rest, line + 1, offset + 2, offset + 2) end - defp skip_whitespace_in_string(<>, pos) do - skip_whitespace_in_string(rest, advance_column(pos, 1)) + defp skip_string_whitespace(<>, line, _line_start, offset) when c in [?\n, ?\r] do + skip_string_whitespace(rest, line + 1, offset + 1, offset + 1) end - defp skip_whitespace_in_string(<>, pos) do - skip_whitespace_in_string(rest, advance_column(pos, 1)) - end - - defp skip_whitespace_in_string(<>, pos) do - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 1} - skip_whitespace_in_string(rest, new_pos) - end - - defp skip_whitespace_in_string(<>, pos) do - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 2} - skip_whitespace_in_string(rest, new_pos) - end - - defp skip_whitespace_in_string(<>, pos) do - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 1} - skip_whitespace_in_string(rest, new_pos) - end - - defp skip_whitespace_in_string(rest, pos) do - {rest, pos} + defp skip_string_whitespace(rest, line, line_start, offset) do + {rest, line, line_start, offset} end # Scan long bracket for level: [[ or [=[ or [==[ etc. @@ -614,289 +697,335 @@ defmodule Lua.Lexer do end # Scan long string [[ ... ]] or [=[ ... ]=] - defp scan_long_string(<<"]", rest::binary>>, str_acc, acc, pos, start_pos, level) do + defp scan_long_string(<<"]", rest::binary>>, acc, src, line, line_start, offset, chunk, chunks, start_pos, level) do case try_close_long_bracket(rest, level, 0) do {:ok, after_bracket} -> - token = {:string, str_acc, start_pos} - new_pos = advance_column(pos, 2 + level) - do_tokenize(after_bracket, [token | acc], new_pos) + token = {:string, chunked_text(src, chunk, offset, chunks), start_pos} + do_tokenize(after_bracket, [token | acc], src, line, line_start, offset + 2 + level) :error -> - scan_long_string(rest, str_acc <> "]", acc, advance_column(pos, 1), start_pos, level) + scan_long_string(rest, acc, src, line, line_start, offset + 1, chunk, chunks, start_pos, level) end end # Per Lua 5.3 §3.1, long strings normalize end-of-line sequences (`\r`, - # `\n`, `\r\n`, `\n\r`) to a single `\n`. - defp scan_long_string(<>, str_acc, acc, pos, start_pos, level) do - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 2} - scan_long_string(rest, str_acc <> "\n", acc, new_pos, start_pos, level) + # `\n`, `\r\n`, `\n\r`) to a single `\n`. A bare `\n` already is that + # normal form, so only the other three break the verbatim span. + defp scan_long_string(<>, acc, src, line, _line_start, offset, chunk, chunks, start_pos, level) do + long_string_newline(rest, acc, src, line, offset, 2, chunk, chunks, start_pos, level) + end + + defp scan_long_string(<>, acc, src, line, _line_start, offset, chunk, chunks, start_pos, level) do + long_string_newline(rest, acc, src, line, offset, 2, chunk, chunks, start_pos, level) + end + + defp scan_long_string(<>, acc, src, line, _line_start, offset, chunk, chunks, start_pos, level) do + scan_long_string(rest, acc, src, line + 1, offset + 1, offset + 1, chunk, chunks, start_pos, level) + end + + defp scan_long_string(<>, acc, src, line, _line_start, offset, chunk, chunks, start_pos, level) do + long_string_newline(rest, acc, src, line, offset, 1, chunk, chunks, start_pos, level) end - defp scan_long_string(<>, str_acc, acc, pos, start_pos, level) do - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 2} - scan_long_string(rest, str_acc <> "\n", acc, new_pos, start_pos, level) + defp scan_long_string(<<>>, _acc, _src, line, line_start, offset, _chunk, _chunks, _start_pos, _level) do + {:error, {:unclosed_long_string, position(line, line_start, offset)}} end - defp scan_long_string(<>, str_acc, acc, pos, start_pos, level) when c == ?\n or c == ?\r do - new_pos = %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 1} - scan_long_string(rest, str_acc <> "\n", acc, new_pos, start_pos, level) + defp scan_long_string(<>, acc, src, line, line_start, offset, chunk, chunks, start_pos, level) + when c < 128 do + scan_long_string(rest, acc, src, line, line_start, offset + 1, chunk, chunks, start_pos, level) end - defp scan_long_string(<<>>, _str_acc, _acc, pos, _start_pos, _level) do - {:error, {:unclosed_long_string, pos}} + defp scan_long_string(<>, acc, src, line, line_start, offset, chunk, chunks, start_pos, level) + when cp > 127 do + width = utf8_width(cp) + scan_long_string(rest, acc, src, line, line_start + width - 1, offset + width, chunk, chunks, start_pos, level) end - defp scan_long_string(<>, str_acc, acc, pos, start_pos, level) when cp > 127 do - scan_long_string(rest, str_acc <> <>, acc, advance_utf8(pos, cp), start_pos, level) + defp scan_long_string(<<_c, rest::binary>>, acc, src, line, line_start, offset, chunk, chunks, start_pos, level) do + scan_long_string(rest, acc, src, line, line_start, offset + 1, chunk, chunks, start_pos, level) end - defp scan_long_string(<>, str_acc, acc, pos, start_pos, level) do - scan_long_string(rest, str_acc <> <>, acc, advance_column(pos, 1), start_pos, level) + # An end-of-line sequence other than a bare `\n` becomes a single "\n". + defp long_string_newline(rest, acc, src, line, offset, consumed, chunk, chunks, start_pos, level) do + chunks = ["\n" | flush_chunk(src, chunk, offset, chunks)] + offset = offset + consumed + scan_long_string(rest, acc, src, line + 1, offset, offset, offset, chunks, start_pos, level) end # Per Lua 5.3 §3.1: "when the opening long bracket is immediately followed # by a newline, the newline is not included in the string." Applies to any # line-break sequence (`\n`, `\r`, `\r\n`, `\n\r`). - defp drop_leading_newline(<>, pos), - do: {rest, %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 2}} + defp drop_leading_newline(<>, line, _line_start, offset), + do: {rest, line + 1, offset + 2, offset + 2} - defp drop_leading_newline(<>, pos), - do: {rest, %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 2}} + defp drop_leading_newline(<>, line, _line_start, offset), + do: {rest, line + 1, offset + 2, offset + 2} - defp drop_leading_newline(<>, pos) when c == ?\n or c == ?\r, - do: {rest, %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + 1}} + defp drop_leading_newline(<>, line, _line_start, offset) when c == ?\n or c == ?\r, + do: {rest, line + 1, offset + 1, offset + 1} - defp drop_leading_newline(rest, pos), do: {rest, pos} + defp drop_leading_newline(rest, line, line_start, offset), do: {rest, line, line_start, offset} - # Scan identifier or keyword - defp scan_identifier(<>, id_acc, acc, pos, start_pos) - when c in ?a..?z or c in ?A..?Z or c in ?0..?9 or c == ?_ do - scan_identifier(rest, id_acc <> <>, acc, advance_column(pos, 1), start_pos) + # Scan identifier or keyword — only the length is collected, the text is + # then sliced out of the source binary in one go. + defp scan_identifier(<>, len) when c in ?a..?z or c in ?A..?Z or c in ?0..?9 or c == ?_ do + scan_identifier(rest, len + 1) end - defp scan_identifier(rest, id_acc, acc, pos, start_pos) do - # Check if it's a keyword - token = - if id_acc in @keywords do - {:keyword, String.to_atom(id_acc), start_pos} - else - {:identifier, id_acc, start_pos} - end + defp scan_identifier(rest, len), do: {rest, len} + + # Reserved words. Compiled into a binary decision tree, so a non-keyword + # identifier falls through without any sequential comparison or atom + # conversion. The results are wrapped because `nil` and `false` are + # themselves keyword atoms. + defp keyword_atom("and"), do: {:ok, :and} + defp keyword_atom("break"), do: {:ok, :break} + defp keyword_atom("do"), do: {:ok, :do} + defp keyword_atom("else"), do: {:ok, :else} + defp keyword_atom("elseif"), do: {:ok, :elseif} + defp keyword_atom("end"), do: {:ok, :end} + defp keyword_atom("false"), do: {:ok, false} + defp keyword_atom("for"), do: {:ok, :for} + defp keyword_atom("function"), do: {:ok, :function} + defp keyword_atom("goto"), do: {:ok, :goto} + defp keyword_atom("if"), do: {:ok, :if} + defp keyword_atom("in"), do: {:ok, :in} + defp keyword_atom("local"), do: {:ok, :local} + defp keyword_atom("nil"), do: {:ok, nil} + defp keyword_atom("not"), do: {:ok, :not} + defp keyword_atom("or"), do: {:ok, :or} + defp keyword_atom("repeat"), do: {:ok, :repeat} + defp keyword_atom("return"), do: {:ok, :return} + defp keyword_atom("then"), do: {:ok, :then} + defp keyword_atom("true"), do: {:ok, true} + defp keyword_atom("until"), do: {:ok, :until} + defp keyword_atom("while"), do: {:ok, :while} + defp keyword_atom(_), do: :error - do_tokenize(rest, [token | acc], pos) + # Scan decimal number. `token_start` is the byte offset of the first + # character of the literal; the digit runs are measured against it and the + # value is converted straight from the source bytes. + defp scan_int_digits(<>, acc, src, line, line_start, offset, token_start) when c in ?0..?9 do + scan_int_digits(rest, acc, src, line, line_start, offset + 1, token_start) end - # Scan decimal number - defp scan_number(<>, num_acc, acc, pos, start_pos) when c in ?0..?9 do - scan_number(rest, num_acc <> <>, acc, advance_column(pos, 1), start_pos) + # ".." is the concat operator, not a decimal point: 0..5 → 0 .. 5 + defp scan_int_digits(<<"..", _rest::binary>> = bin, acc, src, line, line_start, offset, token_start) do + emit_integer(bin, acc, src, line, line_start, offset, token_start) end - defp scan_number(<<".", c, rest::binary>>, num_acc, acc, pos, start_pos) when c in ?0..?9 do - # Decimal point with digit following: 0.5 - scan_float(rest, num_acc <> "." <> <>, acc, advance_column(pos, 2), start_pos) + defp scan_int_digits(<<".", rest::binary>>, acc, src, line, line_start, offset, token_start) do + scan_frac_digits(rest, acc, src, line, line_start, offset + 1, token_start, offset - token_start, offset + 1) end - defp scan_number(<<"..", _rest::binary>> = rest, num_acc, acc, pos, start_pos) do - # ".." is concat operator, not a decimal point: 0..5 → 0 .. 5 - finalize_number(num_acc, rest, acc, pos, start_pos) + # Scientific notation without a decimal point: 1e5 + defp scan_int_digits(<>, acc, src, line, line_start, offset, token_start) when c in [?e, ?E] do + mantissa = binary_part(src, token_start, offset - token_start) <> ".0" + scan_exponent(rest, acc, src, line, line_start, offset + 1, token_start, mantissa) end - defp scan_number(<<".", c, rest::binary>>, num_acc, acc, pos, start_pos) when c in [?e, ?E] do - # "0.e5" → float with exponent - scan_float(<>, num_acc <> ".", acc, advance_column(pos, 1), start_pos) + defp scan_int_digits(bin, acc, src, line, line_start, offset, token_start) do + emit_integer(bin, acc, src, line, line_start, offset, token_start) end - defp scan_number(<<".", rest::binary>>, num_acc, acc, pos, start_pos) do - # Trailing dot makes it a float: 0. → 0.0 - scan_float(rest, num_acc <> ".", acc, advance_column(pos, 1), start_pos) + # Scan the fractional digits after a decimal point. + defp scan_frac_digits(<>, acc, src, line, line_start, offset, token_start, int_len, frac_start) + when c in ?0..?9 do + scan_frac_digits(rest, acc, src, line, line_start, offset + 1, token_start, int_len, frac_start) end - defp scan_number(<>, num_acc, acc, pos, start_pos) when c in [?e, ?E] do - # Scientific notation - scan_exponent(<>, num_acc, acc, pos, start_pos) + defp scan_frac_digits(<>, acc, src, line, line_start, offset, token_start, int_len, frac_start) + when c in [?e, ?E] do + mantissa = mantissa_binary(src, token_start, int_len, frac_start, offset - frac_start) + scan_exponent(rest, acc, src, line, line_start, offset + 1, token_start, mantissa) end - defp scan_number(rest, num_acc, acc, pos, start_pos) do - finalize_number(num_acc, rest, acc, pos, start_pos) + defp scan_frac_digits(bin, acc, src, line, line_start, offset, token_start, int_len, frac_start) do + mantissa = mantissa_binary(src, token_start, int_len, frac_start, offset - frac_start) + emit_float(bin, acc, src, line, line_start, offset, token_start, mantissa) end - # Scan float part (after decimal point) - defp scan_float(<>, num_acc, acc, pos, start_pos) when c in ?0..?9 do - scan_float(rest, num_acc <> <>, acc, advance_column(pos, 1), start_pos) + # Scan the exponent of a decimal float. `mantissa` is already normalized to + # a form Erlang's binary_to_float/1 accepts (digits on both sides of a dot). + defp scan_exponent(<>, acc, src, line, line_start, offset, token_start, mantissa) + when sign in [?+, ?-] do + scan_exponent_digits(rest, acc, src, line, line_start, offset + 1, token_start, mantissa <> <>, offset + 1) end - defp scan_float(<>, num_acc, acc, pos, start_pos) when c in [?e, ?E] do - scan_exponent(<>, num_acc, acc, pos, start_pos) + defp scan_exponent(bin, acc, src, line, line_start, offset, token_start, mantissa) do + scan_exponent_digits(bin, acc, src, line, line_start, offset, token_start, mantissa <> "e", offset) end - defp scan_float(rest, num_acc, acc, pos, start_pos) do - finalize_number(num_acc, rest, acc, pos, start_pos) + defp scan_exponent_digits(<>, acc, src, line, line_start, offset, token_start, mantissa, digit_start) + when c in ?0..?9 do + scan_exponent_digits(rest, acc, src, line, line_start, offset + 1, token_start, mantissa, digit_start) end - # Scan scientific notation exponent - defp scan_exponent(<>, num_acc, acc, pos, start_pos) when c in [?e, ?E] and sign in [?+, ?-] do - scan_exponent_digits(rest, num_acc <> <>, acc, advance_column(pos, 2), start_pos) + # An exponent marker with no digits after it is a malformed number. + defp scan_exponent_digits(_bin, _acc, _src, line, line_start, offset, token_start, _mantissa, digit_start) + when offset == digit_start do + {:error, {:invalid_number, position(line, line_start, token_start)}} end - defp scan_exponent(<>, num_acc, acc, pos, start_pos) when c in [?e, ?E] do - scan_exponent_digits(rest, num_acc <> <>, acc, advance_column(pos, 1), start_pos) + defp scan_exponent_digits(bin, acc, src, line, line_start, offset, token_start, mantissa, digit_start) do + literal = mantissa <> binary_part(src, digit_start, offset - digit_start) + emit_float(bin, acc, src, line, line_start, offset, token_start, literal) + end + + # Normalize the mantissa so Erlang's strict float syntax accepts it: digits + # are required on both sides of the decimal point. + defp mantissa_binary(src, token_start, int_len, _frac_start, 0) when int_len > 0 do + binary_part(src, token_start, int_len) <> ".0" + end + + defp mantissa_binary(src, _token_start, 0, frac_start, frac_len) when frac_len > 0 do + "0." <> binary_part(src, frac_start, frac_len) + end + + defp mantissa_binary(src, token_start, int_len, _frac_start, frac_len) when int_len > 0 and frac_len > 0 do + binary_part(src, token_start, int_len + 1 + frac_len) + end + + defp mantissa_binary(_src, _token_start, _int_len, _frac_start, _frac_len), do: "0.0" + + defp emit_integer(bin, acc, src, line, line_start, offset, token_start) do + value = :erlang.binary_to_integer(binary_part(src, token_start, offset - token_start)) + + # Lua 5.3.3 §3.1: a decimal integer literal that overflows the signed + # 64-bit range converts to a float (a leading sign is a separate token, + # so `value` here is always the non-negative magnitude). Hex integer + # literals instead wrap via wrap_int64; this branch is decimal only. + # @sign_bit is 2^63, i.e. max_int + 1, so `>= @sign_bit` means overflow. + number = if value >= @sign_bit, do: value * 1.0, else: value + token = {:number, number, position(line, line_start, token_start)} + do_tokenize(bin, [token | acc], src, line, line_start, offset) end - defp scan_exponent_digits(<>, num_acc, acc, pos, start_pos) when c in ?0..?9 do - scan_exponent_digits(rest, num_acc <> <>, acc, advance_column(pos, 1), start_pos) + defp emit_float(bin, acc, src, line, line_start, offset, token_start, literal) do + case parse_float(literal) do + {:ok, value} -> + token = {:number, value, position(line, line_start, token_start)} + do_tokenize(bin, [token | acc], src, line, line_start, offset) + + :error -> + {:error, {:invalid_number, position(line, line_start, token_start)}} + end end - defp scan_exponent_digits(rest, num_acc, acc, pos, start_pos) do - finalize_number(num_acc, rest, acc, pos, start_pos) + # The literal is always well-formed by construction; the only way this can + # fail is a magnitude outside the double range, e.g. `1e400`. + defp parse_float(literal) do + {:ok, :erlang.binary_to_float(literal)} + rescue + ArgumentError -> :error end # Scan hexadecimal number (0x...) — supports integers, hex floats (0xF0.0), and exponents (0xABCp-3) - defp scan_hex_number(<>, hex_acc, acc, pos, start_pos) + defp scan_hex_int(<>, acc, src, line, line_start, offset, digit_start, token_start) when c in ?0..?9 or c in ?a..?f or c in ?A..?F do - scan_hex_number(rest, hex_acc <> <>, acc, advance_column(pos, 1), start_pos) + scan_hex_int(rest, acc, src, line, line_start, offset + 1, digit_start, token_start) end # Hex float: dot followed by hex digits - defp scan_hex_number(<<".", rest::binary>>, hex_acc, acc, pos, start_pos) do - scan_hex_frac(rest, hex_acc, "", acc, advance_column(pos, 1), start_pos) + defp scan_hex_int(<<".", rest::binary>>, acc, src, line, line_start, offset, digit_start, token_start) do + int_hex = binary_part(src, digit_start, offset - digit_start) + scan_hex_frac(rest, acc, src, line, line_start, offset + 1, offset + 1, int_hex, token_start) end # Hex float: binary exponent (p/P) - defp scan_hex_number(<>, hex_acc, acc, pos, start_pos) when p in [?p, ?P] do - scan_hex_exp(rest, hex_acc, "", acc, advance_column(pos, 1), start_pos) + defp scan_hex_int(<>, acc, src, line, line_start, offset, digit_start, token_start) + when p in [?p, ?P] do + int_hex = binary_part(src, digit_start, offset - digit_start) + scan_hex_exponent(rest, acc, src, line, line_start, offset + 1, int_hex, "", token_start) end - defp scan_hex_number(rest, hex_acc, acc, pos, start_pos) do - case Integer.parse(hex_acc, 16) do - {num, ""} -> - # Per Lua 5.3 §3.1: hex integer literals overflow-wrap into the - # signed 64-bit range. e.g. 0xFFFFFFFFFFFFFFFF == -1. - token = {:number, wrap_int64(num), start_pos} - do_tokenize(rest, [token | acc], pos) + defp scan_hex_int(bin, acc, src, line, line_start, offset, digit_start, token_start) when offset > digit_start do + # Per Lua 5.3 §3.1: hex integer literals overflow-wrap into the + # signed 64-bit range. e.g. 0xFFFFFFFFFFFFFFFF == -1. + value = wrap_int64(:erlang.binary_to_integer(binary_part(src, digit_start, offset - digit_start), 16)) + token = {:number, value, position(line, line_start, token_start)} + do_tokenize(bin, [token | acc], src, line, line_start, offset) + end - _ -> - {:error, {:invalid_hex_number, start_pos}} - end + defp scan_hex_int(_bin, _acc, _src, line, line_start, _offset, _digit_start, token_start) do + {:error, {:invalid_hex_number, position(line, line_start, token_start)}} end # Wrap an unsigned hex integer to the signed 64-bit range. Inlined here so # the lexer doesn't depend on Lua.VM.Numeric (kept VM-internal). - @uint64_mask 0xFFFFFFFFFFFFFFFF - @uint64_modulus 0x10000000000000000 - @sign_bit 0x8000000000000000 - defp wrap_int64(n) when is_integer(n) do masked = band(n, @uint64_mask) if masked >= @sign_bit, do: masked - @uint64_modulus, else: masked end # Scan hex fractional digits after the dot - defp scan_hex_frac(<>, int_acc, frac_acc, acc, pos, start_pos) + defp scan_hex_frac(<>, acc, src, line, line_start, offset, frac_start, int_hex, token_start) when c in ?0..?9 or c in ?a..?f or c in ?A..?F do - scan_hex_frac(rest, int_acc, frac_acc <> <>, acc, advance_column(pos, 1), start_pos) + scan_hex_frac(rest, acc, src, line, line_start, offset + 1, frac_start, int_hex, token_start) end # Hex float fractional part followed by exponent - defp scan_hex_frac(<>, int_acc, frac_acc, acc, pos, start_pos) when p in [?p, ?P] do - scan_hex_exp(rest, int_acc, frac_acc, acc, advance_column(pos, 1), start_pos) + defp scan_hex_frac(<>, acc, src, line, line_start, offset, frac_start, int_hex, token_start) + when p in [?p, ?P] do + frac_hex = binary_part(src, frac_start, offset - frac_start) + scan_hex_exponent(rest, acc, src, line, line_start, offset + 1, int_hex, frac_hex, token_start) end # Hex float fractional part without exponent - defp scan_hex_frac(rest, int_acc, frac_acc, acc, pos, start_pos) do - num = build_hex_float(int_acc, frac_acc, 0) - token = {:number, num, start_pos} - do_tokenize(rest, [token | acc], pos) + defp scan_hex_frac(bin, acc, src, line, line_start, offset, frac_start, int_hex, token_start) do + frac_hex = binary_part(src, frac_start, offset - frac_start) + token = {:number, build_hex_float(int_hex, frac_hex, 0), position(line, line_start, token_start)} + do_tokenize(bin, [token | acc], src, line, line_start, offset) end # Scan binary exponent (p/P followed by optional sign and decimal digits) - defp scan_hex_exp(<>, int_acc, frac_acc, acc, pos, start_pos) when sign in [?+, ?-] do - scan_hex_exp_digits(rest, int_acc, frac_acc, <>, acc, advance_column(pos, 1), start_pos) + defp scan_hex_exponent(<>, acc, src, line, line_start, offset, int_hex, frac_hex, token_start) + when sign in [?+, ?-] do + scan_hex_exponent_digits(rest, acc, src, line, line_start, offset + 1, offset, int_hex, frac_hex, token_start) end - defp scan_hex_exp(rest, int_acc, frac_acc, acc, pos, start_pos) do - scan_hex_exp_digits(rest, int_acc, frac_acc, "", acc, pos, start_pos) + defp scan_hex_exponent(bin, acc, src, line, line_start, offset, int_hex, frac_hex, token_start) do + scan_hex_exponent_digits(bin, acc, src, line, line_start, offset, offset, int_hex, frac_hex, token_start) end - defp scan_hex_exp_digits(<>, int_acc, frac_acc, exp_acc, acc, pos, start_pos) when c in ?0..?9 do - scan_hex_exp_digits(rest, int_acc, frac_acc, exp_acc <> <>, acc, advance_column(pos, 1), start_pos) + defp scan_hex_exponent_digits( + <>, + acc, + src, + line, + line_start, + offset, + exp_start, + int_hex, + frac_hex, + token_start + ) + when c in ?0..?9 do + scan_hex_exponent_digits(rest, acc, src, line, line_start, offset + 1, exp_start, int_hex, frac_hex, token_start) end - defp scan_hex_exp_digits(rest, int_acc, frac_acc, exp_acc, acc, pos, start_pos) do - exp = if exp_acc == "" or exp_acc == "+" or exp_acc == "-", do: 0, else: String.to_integer(exp_acc) - num = build_hex_float(int_acc, frac_acc, exp) - token = {:number, num, start_pos} - do_tokenize(rest, [token | acc], pos) + defp scan_hex_exponent_digits(bin, acc, src, line, line_start, offset, exp_start, int_hex, frac_hex, token_start) do + exp = hex_exponent_value(binary_part(src, exp_start, offset - exp_start)) + token = {:number, build_hex_float(int_hex, frac_hex, exp), position(line, line_start, token_start)} + do_tokenize(bin, [token | acc], src, line, line_start, offset) end + defp hex_exponent_value(digits) when digits in ["", "+", "-"], do: 0 + defp hex_exponent_value(digits), do: :erlang.binary_to_integer(digits) + # Build a hex float value from integer hex digits, fractional hex digits, and binary exponent defp build_hex_float(int_hex, frac_hex, exp) do - int_val = if int_hex == "", do: 0, else: String.to_integer(int_hex, 16) + int_val = if int_hex == "", do: 0, else: :erlang.binary_to_integer(int_hex, 16) frac_val = if frac_hex == "" do 0.0 else - frac_int = String.to_integer(frac_hex, 16) - frac_int / :math.pow(16, String.length(frac_hex)) + frac_int = :erlang.binary_to_integer(frac_hex, 16) + frac_int / :math.pow(16, byte_size(frac_hex)) end (int_val + frac_val) * :math.pow(2, exp) end - - # Finalize number token - defp finalize_number(num_str, rest, acc, pos, start_pos) do - case parse_number(num_str) do - {:ok, num} -> - token = {:number, num, start_pos} - do_tokenize(rest, [token | acc], pos) - - {:error, reason} -> - {:error, {reason, start_pos}} - end - end - - # Parse number string to integer or float - defp parse_number(num_str) do - if String.contains?(num_str, ".") or String.contains?(num_str, "e") or - String.contains?(num_str, "E") do - # Normalize for Elixir's Float.parse which requires digits after dot - normalized = num_str - # "0." → "0.0" - normalized = if String.ends_with?(normalized, "."), do: normalized <> "0", else: normalized - # "2.E-1" → "2.0E-1" - normalized = String.replace(normalized, ~r/\.([eE])/, ".0\\1") - - case Float.parse(normalized) do - {num, ""} -> {:ok, num} - _ -> {:error, :invalid_number} - end - else - {num, ""} = Integer.parse(num_str) - # Lua 5.3.3 §3.1: a decimal integer literal that overflows the signed - # 64-bit range converts to a float (a leading sign is a separate token, - # so `num` here is always the non-negative magnitude). Hex integer - # literals instead wrap via wrap_int64; this branch is decimal only. - # @sign_bit is 2^63, i.e. max_int + 1, so `>= @sign_bit` means overflow. - if num >= @sign_bit, do: {:ok, num * 1.0}, else: {:ok, num} - end - end - - # Position tracking helpers - defp advance_column(pos, n) do - %{pos | column: pos.column + n, byte_offset: pos.byte_offset + n} - end - - # Advance one display column for a codepoint that occupies its full UTF-8 - # byte width in the source, so `column` stays per-codepoint while - # `byte_offset` stays per-byte. - defp advance_utf8(pos, cp) do - %{pos | column: pos.column + 1, byte_offset: pos.byte_offset + byte_size(<>)} - end - - # Advance one source line, consuming `n` raw bytes (the backslash plus the - # one- or two-byte line ending of a \ continuation). - defp advance_string_line(pos, n) do - %{line: pos.line + 1, column: 1, byte_offset: pos.byte_offset + n} - end end diff --git a/test/lua/lexer_test.exs b/test/lua/lexer_test.exs index fa1ab581..537cc30a 100644 --- a/test/lua/lexer_test.exs +++ b/test/lua/lexer_test.exs @@ -616,6 +616,47 @@ defmodule Lua.LexerTest do assert [{:string, "hello", %{line: 1, column: 1, byte_offset: 0}}, {:eof, _}] = tokens end + + test "counts the closing quote of a short string" do + code = ~s(local x = "ab" ]) + + assert {:ok, tokens} = Lexer.tokenize(code) + + assert [ + {:keyword, :local, %{column: 1, byte_offset: 0}}, + {:identifier, "x", %{column: 7, byte_offset: 6}}, + {:operator, :assign, %{column: 9, byte_offset: 8}}, + {:string, "ab", %{column: 11, byte_offset: 10}}, + {:delimiter, :rbracket, %{column: 16, byte_offset: 15}}, + {:eof, %{column: 17, byte_offset: 16}} + ] = tokens + end + + test "the eof offset is the size of the source, whatever precedes it" do + for code <- [ + ~s(local x = "ab"), + ~s(local x = 'a' .. 'b'), + ~s(x = "\\u{41}"), + "--[[ comment ]]", + "--[==[ comment ]==]", + "x = [[long]]" + ] do + assert {:ok, tokens} = Lexer.tokenize(code) + assert {:eof, pos} = List.last(tokens) + assert pos.byte_offset == byte_size(code), "wrong eof offset for #{inspect(code)}" + assert pos.column == byte_size(code) + 1, "wrong eof column for #{inspect(code)}" + end + end + + test "counts the whole opener and body of a multi-line comment" do + assert {:ok, tokens} = Lexer.tokenize("--[==[ c ]==] x") + + assert [ + {:comment, :multi, " c ", %{column: 1, byte_offset: 0}}, + {:identifier, "x", %{column: 15, byte_offset: 14}}, + {:eof, %{column: 16, byte_offset: 15}} + ] = tokens + end end describe "complex expressions" do @@ -748,6 +789,18 @@ defmodule Lua.LexerTest do assert utf8_pos.byte_offset == ascii_pos.byte_offset + 1 end + test "a multibyte character inside a single-line comment does not shift its start column" do + # The comment's start position must reflect where the `--` opener sits, + # not be skewed by multibyte codepoints scanned later in the body. + assert {:ok, [{:comment, :single, " é", pos} | _]} = Lexer.tokenize("-- é\nx") + assert pos == %{line: 1, column: 1, byte_offset: 0} + + assert {:ok, tokens} = Lexer.tokenize("local y = 1 -- é\n") + + assert {:comment, :single, " é", %{line: 1, column: 13, byte_offset: 12}} = + Enum.find(tokens, &match?({:comment, _, _, _}, &1)) + end + test "handles consecutive operators" do assert {:ok, tokens} = Lexer.tokenize("+-*/") @@ -994,4 +1047,50 @@ defmodule Lua.LexerTest do assert length(tokens) > 10 end end + + describe "token invariants" do + test "every token position has line >= 1 and column >= 1 across the Lua 5.3 suite files" do + fixtures = Path.wildcard(Path.join(__DIR__, "../lua53_tests/*.lua")) + assert fixtures != [] + + for file <- fixtures do + assert {:ok, tokens} = Lexer.tokenize(File.read!(file)) + + for token <- tokens do + pos = elem(token, tuple_size(token) - 1) + + assert pos.line >= 1 and pos.column >= 1, + "#{Path.basename(file)}: token #{inspect(token)} has an out-of-range position" + end + end + end + + test "token text does not retain the source binary" do + # Sub-binaries over 64 bytes are references into the original binary; + # token text must be copied out so retained tokens don't keep a large + # source alive. Build a >4KB source with a >64-byte escape-free string + # literal and a >64-byte comment, and check each token's text is + # backed by exactly its own bytes. + long = String.duplicate("a", 100) + padding = String.duplicate("local x = 1\n", 400) + src = ~s(local s = "#{long}" -- #{long}\n) <> padding + + assert byte_size(src) > 4096 + assert {:ok, tokens} = Lexer.tokenize(src) + + assert {:string, string_text, _} = Enum.find(tokens, &match?({:string, _, _}, &1)) + assert byte_size(string_text) > 64 + assert :binary.referenced_byte_size(string_text) == byte_size(string_text) + + assert {:comment, :single, comment_text, _} = + Enum.find(tokens, &match?({:comment, :single, _, _}, &1)) + + assert byte_size(comment_text) > 64 + assert :binary.referenced_byte_size(comment_text) == byte_size(comment_text) + + for {:identifier, text, _} <- tokens do + assert :binary.referenced_byte_size(text) == byte_size(text) + end + end + end end From 6444055382c4e16fdb3574c5b09b4b9dcf75a830 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 14:15:08 -0400 Subject: [PATCH 04/13] compiler: key scope resolution by node id and dedupe upvalue descriptors (#400) --- lib/lua/ast/block.ex | 2 +- lib/lua/ast/ids.ex | 238 ++++++++++++++++++ lib/lua/ast/meta.ex | 36 ++- lib/lua/compiler.ex | 9 + lib/lua/compiler/codegen.ex | 40 +-- lib/lua/compiler/scope.ex | 162 ++++++------ lib/lua/parser.ex | 6 +- test/lua/ast/ids_test.exs | 95 +++++++ test/lua/ast/meta_test.exs | 14 ++ test/lua/compiler/upvalue_descriptor_test.exs | 175 +++++++++++++ test/lua/vm/upvalue_test.exs | 83 ++++++ website/lib/website/lua_sandbox.ex | 35 ++- website/test/website/lua_sandbox_test.exs | 47 ++++ 13 files changed, 826 insertions(+), 116 deletions(-) create mode 100644 lib/lua/ast/ids.ex create mode 100644 test/lua/ast/ids_test.exs create mode 100644 test/lua/compiler/upvalue_descriptor_test.exs create mode 100644 website/test/website/lua_sandbox_test.exs diff --git a/lib/lua/ast/block.ex b/lib/lua/ast/block.ex index e74c9d89..2303d60c 100644 --- a/lib/lua/ast/block.ex +++ b/lib/lua/ast/block.ex @@ -25,7 +25,7 @@ defmodule Lua.AST.Block do %Lua.AST.Block{stmts: [], meta: nil} iex> Lua.AST.Block.new([], %Lua.AST.Meta{}) - %Lua.AST.Block{stmts: [], meta: %Lua.AST.Meta{start: nil, end: nil, metadata: %{}}} + %Lua.AST.Block{stmts: [], meta: %Lua.AST.Meta{start: nil, end: nil, metadata: %{}, id: nil}} """ @spec new([Statement.t()], Meta.t() | nil) :: t() def new(stmts \\ [], meta \\ nil) do diff --git a/lib/lua/ast/ids.ex b/lib/lua/ast/ids.ex new file mode 100644 index 00000000..a8bc3e6b --- /dev/null +++ b/lib/lua/ast/ids.ex @@ -0,0 +1,238 @@ +defmodule Lua.AST.Ids do + @moduledoc """ + Stamps AST nodes with chunk-unique identifiers. + + Compiler passes keep per-node tables — which registers a `local` claimed, + which upvalue a `Var` resolved to, the close-upvalue watermark of a block. + Keying those tables by the node term makes every read and write hash the + node's whole subtree, so the deepest nodes (function bodies, blocks) — the + ones that make the most useful keys — are also the most expensive ones. + + `assign/1` walks a parsed chunk once and writes a distinct integer into + each node's `meta.id`, letting those tables key on a single word instead. + Ids are unique across the whole chunk, including the bodies of nested + functions. + """ + + alias Lua.AST.Block + alias Lua.AST.Chunk + alias Lua.AST.Expr + alias Lua.AST.Meta + alias Lua.AST.Statement + + @doc """ + Returns `chunk` with every reachable node stamped with a unique `meta.id`. + + Nodes the parser built without metadata gain a `Lua.AST.Meta` carrying only + the id; nodes that already have one keep their positions and comments. + """ + @spec assign(Chunk.t()) :: Chunk.t() + def assign(%Chunk{} = chunk) do + {chunk, _next_id} = number(chunk, 0) + chunk + end + + # Children are numbered before their parent, so a node's id is always + # greater than every id inside it. Nothing relies on that ordering; it just + # falls out of numbering on the way back up. + + defp number(%Chunk{block: block} = node, next) do + {block, next} = number(block, next) + {%{node | block: block, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Block{stmts: stmts} = node, next) do + {stmts, next} = number_list(stmts, next, []) + {%{node | stmts: stmts, meta: with_id(node.meta, next)}, next + 1} + end + + # Expressions + + defp number(%Expr.Nil{} = node, next), do: {%{node | meta: with_id(node.meta, next)}, next + 1} + defp number(%Expr.Bool{} = node, next), do: {%{node | meta: with_id(node.meta, next)}, next + 1} + defp number(%Expr.Number{} = node, next), do: {%{node | meta: with_id(node.meta, next)}, next + 1} + defp number(%Expr.String{} = node, next), do: {%{node | meta: with_id(node.meta, next)}, next + 1} + defp number(%Expr.Var{} = node, next), do: {%{node | meta: with_id(node.meta, next)}, next + 1} + defp number(%Expr.Vararg{} = node, next), do: {%{node | meta: with_id(node.meta, next)}, next + 1} + + defp number(%Expr.BinOp{left: left, right: right} = node, next) do + {left, next} = number(left, next) + {right, next} = number(right, next) + {%{node | left: left, right: right, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Expr.UnOp{operand: operand} = node, next) do + {operand, next} = number(operand, next) + {%{node | operand: operand, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Expr.Table{fields: fields} = node, next) do + {fields, next} = number_table_fields(fields, next, []) + {%{node | fields: fields, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Expr.Call{func: func, args: args} = node, next) do + {func, next} = number(func, next) + {args, next} = number_list(args, next, []) + {%{node | func: func, args: args, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Expr.MethodCall{object: object, args: args} = node, next) do + {object, next} = number(object, next) + {args, next} = number_list(args, next, []) + {%{node | object: object, args: args, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Expr.Index{table: table, key: key} = node, next) do + {table, next} = number(table, next) + {key, next} = number(key, next) + {%{node | table: table, key: key, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Expr.Property{table: table} = node, next) do + {table, next} = number(table, next) + {%{node | table: table, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Expr.Function{body: body} = node, next) do + {body, next} = number(body, next) + {%{node | body: body, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Expr.Paren{inner: inner} = node, next) do + {inner, next} = number(inner, next) + {%{node | inner: inner, meta: with_id(node.meta, next)}, next + 1} + end + + # Statements + + defp number(%Statement.Break{} = node, next), do: {%{node | meta: with_id(node.meta, next)}, next + 1} + defp number(%Statement.Goto{} = node, next), do: {%{node | meta: with_id(node.meta, next)}, next + 1} + defp number(%Statement.Label{} = node, next), do: {%{node | meta: with_id(node.meta, next)}, next + 1} + + defp number(%Statement.Assign{targets: targets, values: values} = node, next) do + {targets, next} = number_list(targets, next, []) + {values, next} = number_list(values, next, []) + {%{node | targets: targets, values: values, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Statement.Local{values: values} = node, next) do + {values, next} = number_list(values, next, []) + {%{node | values: values, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Statement.LocalFunc{body: body} = node, next) do + {body, next} = number(body, next) + {%{node | body: body, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Statement.FuncDecl{body: body} = node, next) do + {body, next} = number(body, next) + {%{node | body: body, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Statement.CallStmt{call: call} = node, next) do + {call, next} = number(call, next) + {%{node | call: call, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Statement.If{} = node, next) do + %Statement.If{condition: condition, then_block: then_block, elseifs: elseifs, else_block: else_block} = node + + {condition, next} = number(condition, next) + {then_block, next} = number(then_block, next) + {elseifs, next} = number_elseifs(elseifs, next, []) + {else_block, next} = number_optional(else_block, next) + + {%{ + node + | condition: condition, + then_block: then_block, + elseifs: elseifs, + else_block: else_block, + meta: with_id(node.meta, next) + }, next + 1} + end + + defp number(%Statement.While{condition: condition, body: body} = node, next) do + {condition, next} = number(condition, next) + {body, next} = number(body, next) + {%{node | condition: condition, body: body, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Statement.Repeat{body: body, condition: condition} = node, next) do + {body, next} = number(body, next) + {condition, next} = number(condition, next) + {%{node | body: body, condition: condition, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Statement.ForNum{} = node, next) do + %Statement.ForNum{start: start, limit: limit, step: step, body: body} = node + + {start, next} = number(start, next) + {limit, next} = number(limit, next) + {step, next} = number_optional(step, next) + {body, next} = number(body, next) + + {%{node | start: start, limit: limit, step: step, body: body, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Statement.ForIn{iterators: iterators, body: body} = node, next) do + {iterators, next} = number_list(iterators, next, []) + {body, next} = number(body, next) + {%{node | iterators: iterators, body: body, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Statement.Do{body: body} = node, next) do + {body, next} = number(body, next) + {%{node | body: body, meta: with_id(node.meta, next)}, next + 1} + end + + defp number(%Statement.Return{values: values} = node, next) do + {values, next} = number_list(values, next, []) + {%{node | values: values, meta: with_id(node.meta, next)}, next + 1} + end + + # Every node shape must have a clause above. Silently skipping one would + # leave its whole subtree unnumbered, and the compiler's raw-term fallback + # key collides for structurally identical subtrees — a miscompile, not a + # slowdown — so an unrecognized node fails loudly instead. + defp number(node, _next) do + raise ArgumentError, + "Lua.AST.Ids has no number/2 clause for node: #{inspect(node, limit: 5)}" + end + + defp number_optional(nil, next), do: {nil, next} + defp number_optional(node, next), do: number(node, next) + + defp number_list([], next, acc), do: {Enum.reverse(acc), next} + + defp number_list([node | rest], next, acc) do + {node, next} = number(node, next) + number_list(rest, next, [node | acc]) + end + + defp number_table_fields([], next, acc), do: {Enum.reverse(acc), next} + + defp number_table_fields([{:list, value} | rest], next, acc) do + {value, next} = number(value, next) + number_table_fields(rest, next, [{:list, value} | acc]) + end + + defp number_table_fields([{:record, key, value} | rest], next, acc) do + {key, next} = number(key, next) + {value, next} = number(value, next) + number_table_fields(rest, next, [{:record, key, value} | acc]) + end + + defp number_elseifs([], next, acc), do: {Enum.reverse(acc), next} + + defp number_elseifs([{condition, block} | rest], next, acc) do + {condition, next} = number(condition, next) + {block, next} = number(block, next) + number_elseifs(rest, next, [{condition, block} | acc]) + end + + defp with_id(nil, id), do: %Meta{id: id} + defp with_id(meta, id), do: %{meta | id: id} +end diff --git a/lib/lua/ast/meta.ex b/lib/lua/ast/meta.ex index 125ff558..afe0f839 100644 --- a/lib/lua/ast/meta.ex +++ b/lib/lua/ast/meta.ex @@ -25,10 +25,22 @@ defmodule Lua.AST.Meta do @type t :: %__MODULE__{ start: position() | nil, end: position() | nil, - metadata: map() + metadata: map(), + id: non_neg_integer() | nil } - defstruct start: nil, end: nil, metadata: %{} + @typedoc """ + Chunk-unique identifier for the node carrying this metadata. + + `Lua.AST.Ids.assign/1` stamps every node of a parsed chunk, including the + bodies of nested functions. Compiler passes that need a per-node table key + it by this integer instead of by the node term, so a lookup hashes one word + rather than a whole subtree. Hand-built AST nodes carry `nil` until + `Lua.Compiler.compile/2` stamps the chunk they belong to. + """ + @type id :: non_neg_integer() | nil + + defstruct start: nil, end: nil, metadata: %{}, id: nil @doc """ Creates a new Meta struct with start and end positions. @@ -42,7 +54,8 @@ defmodule Lua.AST.Meta do %Lua.AST.Meta{ start: %{line: 1, column: 1, byte_offset: 0}, end: %{line: 1, column: 5, byte_offset: 4}, - metadata: %{} + metadata: %{}, + id: nil } """ @spec new(position() | nil, position() | nil, map()) :: t() @@ -53,7 +66,9 @@ defmodule Lua.AST.Meta do @doc """ Merges two Meta structs, taking the earliest start and latest end. - Useful when combining multiple nodes into a single parent node. + Useful when combining multiple nodes into a single parent node. The + `metadata` (comments) and `id` of the left operand are kept, so merging a + wider span into a node never silently drops what was already attached to it. ## Examples @@ -63,14 +78,17 @@ defmodule Lua.AST.Meta do %Lua.AST.Meta{ start: %{line: 1, column: 1, byte_offset: 0}, end: %{line: 1, column: 10, byte_offset: 9}, - metadata: %{} + metadata: %{}, + id: nil } """ @spec merge(t(), t()) :: t() - def merge(%__MODULE__{start: start1, end: end1}, %__MODULE__{start: start2, end: end2}) do - new_start = earliest_position(start1, start2) - new_end = latest_position(end1, end2) - new(new_start, new_end) + def merge(%__MODULE__{} = left, %__MODULE__{} = right) do + %{ + left + | start: earliest_position(left.start, right.start), + end: latest_position(left.end, right.end) + } end @doc """ diff --git a/lib/lua/compiler.ex b/lib/lua/compiler.ex index 728c9479..b88ffa99 100644 --- a/lib/lua/compiler.ex +++ b/lib/lua/compiler.ex @@ -6,6 +6,7 @@ defmodule Lua.Compiler do """ alias Lua.AST.Chunk + alias Lua.AST.Ids alias Lua.Compiler.Bytecode alias Lua.Compiler.Codegen alias Lua.Compiler.GotoResolution @@ -29,6 +30,14 @@ defmodule Lua.Compiler do """ @spec compile(Chunk.t(), compile_opts()) :: {:ok, Prototype.t()} | {:error, term()} def compile(%Chunk{} = chunk, opts \\ []) do + # Scope resolution and codegen key per-node tables by `meta.id` (see + # `Lua.AST.Ids`); without ids, structurally identical nodes (e.g. two + # empty loop bodies) would share one table entry and miscompile. Stamping + # here covers chunks that never went through the parser, such as those + # built with `Lua.AST.Builder`. Assignment is deterministic, so a parsed + # chunk (already stamped by `Lua.Parser`) re-stamps to the same ids. + chunk = Ids.assign(chunk) + with :ok <- GotoValidation.validate(chunk), {:ok, scope_state} <- Scope.resolve(chunk, opts), {:ok, prototype} <- Codegen.generate(chunk, scope_state, opts) do diff --git a/lib/lua/compiler/codegen.ex b/lib/lua/compiler/codegen.ex index 9c9611ec..36a70eba 100644 --- a/lib/lua/compiler/codegen.ex +++ b/lib/lua/compiler/codegen.ex @@ -450,7 +450,7 @@ defmodule Lua.Compiler.Codegen do defp gen_statement(%Statement.Local{names: names, values: values} = local_stmt, ctx) do # Get per-statement register assignments from var_map - reg_list = Map.get(ctx.scope.var_map, local_stmt, []) + reg_list = Map.get(ctx.scope.var_map, Scope.node_key(local_stmt), []) num_names = length(names) num_values = length(values) @@ -604,7 +604,7 @@ defmodule Lua.Compiler.Codegen do # captured during scope resolution so consecutive `for` loops with the # same variable name use distinct registers (issue #146). loop_var_reg = - Map.get(ctx.scope.var_map, {:for_num_var_reg, for_stmt}, ctx.scope.locals[var]) + Map.get(ctx.scope.var_map, {:for_num_var_reg, Scope.node_key(for_stmt)}, ctx.scope.locals[var]) # Allocate 3 internal registers for: counter, limit, step base = ctx.next_reg @@ -654,7 +654,11 @@ defmodule Lua.Compiler.Codegen do # bindings captured during scope resolution so consecutive `for` loops # with the same variable names use distinct registers (issue #146). var_regs = - Map.get(ctx.scope.var_map, {:for_in_var_regs, for_stmt}, Enum.map(vars, fn name -> ctx.scope.locals[name] end)) + Map.get( + ctx.scope.var_map, + {:for_in_var_regs, Scope.node_key(for_stmt)}, + Enum.map(vars, fn name -> ctx.scope.locals[name] end) + ) # Allocate 3 internal registers for: iterator function, invariant state, control variable base = ctx.next_reg @@ -757,7 +761,7 @@ defmodule Lua.Compiler.Codegen do # Per Lua 5.3: `function name(...) end` is sugar for `name = function(...) end`. # Use the var_map entry resolved by scope analysis. {store_instructions, ctx} = - case Map.get(ctx.scope.var_map, {:func_decl_target, decl}) do + case Map.get(ctx.scope.var_map, {:func_decl_target, Scope.node_key(decl)}) do {:register, local_reg} -> instrs = if local_reg == closure_reg, do: [], else: [Instruction.move(local_reg, closure_reg)] {instrs, ctx} @@ -773,7 +777,7 @@ defmodule Lua.Compiler.Codegen do nil -> # Should never happen after scope analysis (single-name FuncDecl - # always populates {:func_decl_target, decl}). + # always populates the `{:func_decl_target, _}` entry). raise "codegen: missing var_map entry for FuncDecl target #{inspect(name)}" end @@ -804,7 +808,7 @@ defmodule Lua.Compiler.Codegen do {closure_instructions, closure_reg, ctx} = gen_closure_from_node(local_func, ctx) # Get the local variable's register from var_map (per-statement, handles redefinitions) - dest_reg = Map.get(ctx.scope.var_map, {:local_func_reg, local_func}, ctx.scope.locals[name]) + dest_reg = Map.get(ctx.scope.var_map, {:local_func_reg, Scope.node_key(local_func)}, ctx.scope.locals[name]) # Move closure to the local's register move_instructions = @@ -841,7 +845,7 @@ defmodule Lua.Compiler.Codegen do # at block entry; emitting unconditionally is cheap — the executor's # close helper short-circuits when `open_upvalues` is empty, which is the # overwhelming common case. - threshold = Map.fetch!(ctx.scope.var_map, {:do_close_threshold, do_stmt}) + threshold = Map.fetch!(ctx.scope.var_map, {:do_close_threshold, Scope.node_key(do_stmt)}) {body_instructions ++ [Instruction.close_upvalues(threshold)], ctx} end @@ -857,7 +861,7 @@ defmodule Lua.Compiler.Codegen do # its own block or an enclosing one, never a nested or sibling block # (Lua 5.3 §3.3.4). defp gen_statement(%Statement.Goto{label: label} = goto, ctx) do - block_path = Map.fetch!(ctx.scope.var_map, {:goto_block, goto}) + block_path = Map.fetch!(ctx.scope.var_map, {:goto_block, Scope.node_key(goto)}) {[{:goto, label, block_path}], ctx} end @@ -870,8 +874,8 @@ defmodule Lua.Compiler.Codegen do # declared deeper in the block being re-entered or exited). See # `Lua.Compiler.GotoResolution`. defp gen_statement(%Statement.Label{name: name} = label, ctx) do - level = Map.fetch!(ctx.scope.var_map, {:label_level, label}) - block_path = Map.fetch!(ctx.scope.var_map, {:label_block, label}) + level = Map.fetch!(ctx.scope.var_map, {:label_level, Scope.node_key(label)}) + block_path = Map.fetch!(ctx.scope.var_map, {:label_block, Scope.node_key(label)}) {[{:label, name, level, block_path}], ctx} end @@ -887,11 +891,11 @@ defmodule Lua.Compiler.Codegen do # the executor's close helper short-circuits when `open_upvalues` is empty. # # If/elseif/else branches, while/repeat bodies, and for bodies all key on - # the block AST node. Loop bodies re-run this close on every iteration + # the block's node id. Loop bodies re-run this close on every iteration # boundary and on loop exit; cells therefore persist within an iteration # and close only at its tail, which is exactly the §3.4.10 contract. defp append_block_close(instructions, block, ctx) do - case Map.fetch(ctx.scope.var_map, {:block_close_threshold, block}) do + case Map.fetch(ctx.scope.var_map, {:block_close_threshold, Scope.node_key(block)}) do {:ok, threshold} -> instructions ++ [Instruction.close_upvalues(threshold)] :error -> instructions end @@ -903,7 +907,7 @@ defmodule Lua.Compiler.Codegen do # `%Expr.Var{}` — local register, captured-local cell, parent upvalue, # or free name read through `_ENV`. defp gen_func_decl_head(decl, ctx) do - case Map.get(ctx.scope.var_map, {:func_decl_head, decl}) do + case Map.get(ctx.scope.var_map, {:func_decl_head, Scope.node_key(decl)}) do {:register, reg} -> {[], reg, ctx} @@ -927,7 +931,7 @@ defmodule Lua.Compiler.Codegen do # Helpers for assignment target code generation defp gen_assign_target(%Expr.Var{} = target_var, value_reg, ctx) do - case Map.get(ctx.scope.var_map, target_var) do + case Map.get(ctx.scope.var_map, Scope.node_key(target_var)) do {:register, local_reg} -> instrs = if local_reg == value_reg, do: [], else: [Instruction.move(local_reg, value_reg)] {instrs, ctx} @@ -1157,7 +1161,7 @@ defmodule Lua.Compiler.Codegen do defp gen_expr(%Expr.Var{} = var, ctx) do # Look up variable classification from scope - case Map.get(ctx.scope.var_map, var) do + case Map.get(ctx.scope.var_map, Scope.node_key(var)) do {:register, reg} -> # Local variable - already in a register, just return it {[], reg, ctx} @@ -1664,7 +1668,7 @@ defmodule Lua.Compiler.Codegen do # Shared helper: generates a closure from a function node (Expr.Function, Statement.FuncDecl, etc.) # Returns {instructions, dest_reg, ctx} like gen_expr. defp gen_closure_from_node(node, ctx) do - func_key = Map.get(ctx.scope.var_map, node) + func_key = Map.get(ctx.scope.var_map, Scope.node_key(node)) func_scope = ctx.scope.functions[func_key] # Generate the function body in a fresh context with function-scoped locals @@ -1722,7 +1726,7 @@ defmodule Lua.Compiler.Codegen do # that sibling captures of the same name don't trigger a spurious # set_open_upvalue against a cell that doesn't exist yet. defp captures_self?(local_func, name, ctx) do - with {:ok, func_key} <- Map.fetch(ctx.scope.var_map, local_func), + with {:ok, func_key} <- Map.fetch(ctx.scope.var_map, Scope.node_key(local_func)), {:ok, func_scope} <- Map.fetch(ctx.scope.functions, func_key) do Enum.any?(func_scope.upvalue_descriptors, fn {:parent_local, _reg, captured_name} -> captured_name == name @@ -1769,7 +1773,7 @@ defmodule Lua.Compiler.Codegen do # runtime errors, mirroring PUC-Lua. `nil` means "no useful name" (e.g. # anonymous callee like `(f or g)()`). defp name_hint(%Expr.Var{} = var, ctx) do - case Map.get(ctx.scope.var_map, var) do + case Map.get(ctx.scope.var_map, Scope.node_key(var)) do {:env_field, _env_ref, name} -> {:global, name} {:upvalue, _index} -> {:upvalue, var.name} {:register, _reg} -> {:local, var.name} diff --git a/lib/lua/compiler/scope.ex b/lib/lua/compiler/scope.ex index 05a1efa5..3f1cd131 100644 --- a/lib/lua/compiler/scope.ex +++ b/lib/lua/compiler/scope.ex @@ -65,6 +65,21 @@ defmodule Lua.Compiler.Scope do } end + @doc """ + Returns the `var_map` key that stands for `node`. + + Nodes reaching the compiler carry a chunk-unique `meta.id` (see + `Lua.AST.Ids`; `Lua.Compiler.compile/2` stamps every chunk before scope + resolution), so the key is one integer and a lookup hashes one word. Nodes + without an id fall back to the node term itself — slower to hash and, unlike + an id, shared by structurally identical nodes, so two equal subtrees would + collapse onto one entry. Codegen keys its reads through this same function, + so both sides agree whichever shape a node has. + """ + @spec node_key(term()) :: term() + def node_key(%{meta: %{id: id}}) when is_integer(id), do: id + def node_key(node), do: node + @doc """ Resolves variable scopes in the AST. @@ -179,7 +194,7 @@ defmodule Lua.Compiler.Scope do end) # Store per-statement register assignments in var_map so codegen can find them - state = %{state | var_map: Map.put(state.var_map, local_stmt, reg_list)} + state = %{state | var_map: Map.put(state.var_map, node_key(local_stmt), reg_list)} # Update max_register in current function scope func_scope = state.functions[state.current_function] @@ -196,16 +211,16 @@ defmodule Lua.Compiler.Scope do # Per Lua 5.3 §3.3.4, each branch of an `if` is its own block, so locals # declared inside it do not leak past `end`. state = resolve_expr(condition, state) - state = with_block_scope(state, {:block_close_threshold, then_block}, &resolve_block(then_block, &1)) + state = with_block_scope(state, {:block_close_threshold, node_key(then_block)}, &resolve_block(then_block, &1)) state = Enum.reduce(elseifs, state, fn {elseif_cond, elseif_block}, state -> state = resolve_expr(elseif_cond, state) - with_block_scope(state, {:block_close_threshold, elseif_block}, &resolve_block(elseif_block, &1)) + with_block_scope(state, {:block_close_threshold, node_key(elseif_block)}, &resolve_block(elseif_block, &1)) end) if else_block do - with_block_scope(state, {:block_close_threshold, else_block}, &resolve_block(else_block, &1)) + with_block_scope(state, {:block_close_threshold, node_key(else_block)}, &resolve_block(else_block, &1)) else state end @@ -213,7 +228,7 @@ defmodule Lua.Compiler.Scope do defp resolve_statement(%Statement.While{condition: condition, body: body}, state) do state = resolve_expr(condition, state) - with_block_scope(state, {:block_close_threshold, body}, &resolve_block(body, &1)) + with_block_scope(state, {:block_close_threshold, node_key(body)}, &resolve_block(body, &1)) end defp resolve_statement(%Statement.Repeat{body: body, condition: condition}, state) do @@ -223,7 +238,7 @@ defmodule Lua.Compiler.Scope do saved_locals = state.locals saved_next_register = state.next_register - state = %{state | var_map: Map.put(state.var_map, {:block_close_threshold, body}, saved_next_register)} + state = %{state | var_map: Map.put(state.var_map, {:block_close_threshold, node_key(body)}, saved_next_register)} state = resolve_block(body, state) state = resolve_expr(condition, state) @@ -246,14 +261,14 @@ defmodule Lua.Compiler.Scope do state = %{state | locals: Map.put(state.locals, var, loop_var_reg)} state = %{state | next_register: loop_var_reg + 3} - state = %{state | var_map: Map.put(state.var_map, {:for_num_var_reg, for_stmt}, loop_var_reg)} + state = %{state | var_map: Map.put(state.var_map, {:for_num_var_reg, node_key(for_stmt)}, loop_var_reg)} # Stash the post-loop-variable watermark so codegen can close body-local # cells at the tail of each iteration. The loop variable's own cells are # swept by the per-iteration close in the executor's continuation handler # (keyed on `loop_var_reg`); the body-tail close handles inner-block # locals declared above this watermark. - state = %{state | var_map: Map.put(state.var_map, {:block_close_threshold, body}, state.next_register)} + state = %{state | var_map: Map.put(state.var_map, {:block_close_threshold, node_key(body)}, state.next_register)} func_scope = state.functions[state.current_function] func_scope = %{func_scope | max_register: max(func_scope.max_register, state.next_register)} @@ -277,7 +292,7 @@ defmodule Lua.Compiler.Scope do # Resolve the target name (local/upvalue/global) and store under a namespaced # key so resolve_function_scope cannot overwrite it (it always stores the # function-scope reference under the bare `decl` key). - target_key = {:func_decl_target, decl} + target_key = {:func_decl_target, node_key(decl)} # Process the function body FIRST. The body may capture this scope's # locals (including `_ENV`); processing it before tagging the assignment @@ -347,12 +362,12 @@ defmodule Lua.Compiler.Scope do {state, reg + 1, [reg | acc]} end) - state = %{state | var_map: Map.put(state.var_map, {:for_in_var_regs, for_stmt}, Enum.reverse(var_regs))} + state = %{state | var_map: Map.put(state.var_map, {:for_in_var_regs, node_key(for_stmt)}, Enum.reverse(var_regs))} # Stash the post-loop-variable watermark so codegen can close body-local # cells at the tail of each iteration. The loop variables' own cells are # swept by the per-iteration close in the executor's continuation handler. - state = %{state | var_map: Map.put(state.var_map, {:block_close_threshold, body}, state.next_register)} + state = %{state | var_map: Map.put(state.var_map, {:block_close_threshold, node_key(body)}, state.next_register)} func_scope = state.functions[state.current_function] func_scope = %{func_scope | max_register: max(func_scope.max_register, state.next_register)} @@ -371,7 +386,7 @@ defmodule Lua.Compiler.Scope do # Store the register assignment in var_map so codegen can find the correct # register even when the same name is redefined later (e.g., two `local function f`) - state = %{state | var_map: Map.put(state.var_map, {:local_func_reg, local_func}, reg)} + state = %{state | var_map: Map.put(state.var_map, {:local_func_reg, node_key(local_func)}, reg)} # Update max_register in current function scope func_scope = state.functions[state.current_function] @@ -405,7 +420,7 @@ defmodule Lua.Compiler.Scope do # cell over a register that goes out of scope here must be detached so # the next statement that reuses the slot does not read or write through # the stale cell — see locals.lua:148-154 for the symptom this prevents. - state = %{state | var_map: Map.put(state.var_map, {:do_close_threshold, do_stmt}, saved_next_register)} + state = %{state | var_map: Map.put(state.var_map, {:do_close_threshold, node_key(do_stmt)}, saved_next_register)} # Restore outer scope (inner locals don't leak out) %{state | locals: saved_locals, next_register: saved_next_register} @@ -415,13 +430,14 @@ defmodule Lua.Compiler.Scope do # label closes any open-upvalue cell at or above this register, so locals # declared deeper in the blocks it leaves or re-enters get fresh cells # (Lua 5.3 §3.3.4). `next_register` counts locals only (not codegen temps), - # which is exactly the close threshold. Keyed by the label node, which is - # unique per position, so reused names across sibling blocks stay distinct. + # which is exactly the close threshold. Keyed by the label's node id, which + # is unique per position, so reused names across sibling blocks stay + # distinct. defp resolve_statement(%Statement.Label{} = label, state) do var_map = state.var_map - |> Map.put({:label_level, label}, state.next_register) - |> Map.put({:label_block, label}, state.block_path) + |> Map.put({:label_level, node_key(label)}, state.next_register) + |> Map.put({:label_block, node_key(label)}, state.block_path) %{state | var_map: var_map} end @@ -430,7 +446,7 @@ defmodule Lua.Compiler.Scope do # emitted instruction and goto resolution can match it only against labels # visible from this block or an enclosing one (Lua 5.3 §3.3.4). defp resolve_statement(%Statement.Goto{} = goto, state) do - %{state | var_map: Map.put(state.var_map, {:goto_block, goto}, state.block_path)} + %{state | var_map: Map.put(state.var_map, {:goto_block, node_key(goto)}, state.block_path)} end # For now, stub out other statement types - we'll implement them incrementally @@ -448,19 +464,19 @@ defmodule Lua.Compiler.Scope do # Not a local — check parent scopes for upvalue case find_upvalue(name, state.parent_scopes, state) do {:ok, upvalue_index, state} -> - %{state | var_map: Map.put(state.var_map, var, {:upvalue, upvalue_index})} + %{state | var_map: Map.put(state.var_map, node_key(var), {:upvalue, upvalue_index})} :not_found -> # Free name: compile as `_ENV.name` {env_ref, state} = resolve_env_ref(state) - %{state | var_map: Map.put(state.var_map, var, {:env_field, env_ref, name})} + %{state | var_map: Map.put(state.var_map, node_key(var), {:env_field, env_ref, name})} end reg -> if MapSet.member?(state.captured_locals, name) do - %{state | var_map: Map.put(state.var_map, var, {:captured_local, reg})} + %{state | var_map: Map.put(state.var_map, node_key(var), {:captured_local, reg})} else - %{state | var_map: Map.put(state.var_map, var, {:register, reg})} + %{state | var_map: Map.put(state.var_map, node_key(var), {:register, reg})} end end end @@ -519,10 +535,10 @@ defmodule Lua.Compiler.Scope do # Resolve the head name of a multi-name FuncDecl the same way an # `Expr.Var` read is resolved. The result is stashed under - # `{:func_decl_head, decl}` so codegen can replay the lookup without + # `{:func_decl_head, node_key(decl)}` so codegen can replay the lookup without # re-reading the post-block locals snapshot. defp resolve_func_decl_head(name, decl, state) do - key = {:func_decl_head, decl} + key = {:func_decl_head, node_key(decl)} case Map.get(state.locals, name) do nil -> @@ -595,76 +611,66 @@ defmodule Lua.Compiler.Scope do case Map.get(parent.locals, name) do nil -> # Not in this parent's locals — check if the parent already has it as an upvalue - parent_func = state.functions[parent.function] - - case Enum.find_index(parent_func.upvalue_descriptors, fn - {:parent_local, _, n} -> n == name - {:parent_upvalue, _, n} -> n == name - end) do + case upvalue_index(state, parent.function, name) do nil -> # Parent doesn't have it. Recurse to ensure the parent gets it first. case ensure_upvalue(name, parent.function, rest, state) do - {:ok, _parent_uv_index, state} -> - # Parent now has an upvalue for this variable. Find its index. - parent_func = state.functions[parent.function] - - parent_uv_index = - Enum.find_index(parent_func.upvalue_descriptors, fn - {:parent_local, _, n} -> n == name - {:parent_upvalue, _, n} -> n == name - end) - - # Add to for_function referencing parent's upvalue - func = state.functions[for_function] - uv_index = length(func.upvalue_descriptors) - - func = %{ - func - | upvalue_descriptors: - func.upvalue_descriptors ++ - [{:parent_upvalue, parent_uv_index, name}] - } - - state = %{state | functions: Map.put(state.functions, for_function, func)} - {:ok, uv_index, state} + {:ok, parent_uv_index, state} -> + # Parent now has an upvalue for this variable at that index. + put_upvalue(state, for_function, {:parent_upvalue, parent_uv_index, name}) :not_found -> :not_found end parent_uv_index -> - # Parent already has this upvalue — add reference in for_function - func = state.functions[for_function] - uv_index = length(func.upvalue_descriptors) - - func = %{ - func - | upvalue_descriptors: - func.upvalue_descriptors ++ - [{:parent_upvalue, parent_uv_index, name}] - } - - state = %{state | functions: Map.put(state.functions, for_function, func)} - {:ok, uv_index, state} + # Parent already has this upvalue — reference it from for_function + put_upvalue(state, for_function, {:parent_upvalue, parent_uv_index, name}) end reg -> - # Found in parent's locals — add {:parent_local, reg, name} to for_function - func = state.functions[for_function] - uv_index = length(func.upvalue_descriptors) + # Found in parent's locals — capture it directly + put_upvalue(state, for_function, {:parent_local, reg, name}) + end + end - func = %{ - func - | upvalue_descriptors: func.upvalue_descriptors ++ [{:parent_local, reg, name}] - } + # Index of the upvalue `function` holds for `name`, or nil. A function has + # at most one, because the parent-scope snapshot a function resolves + # against is fixed for its whole body: a name always denotes the same cell. + defp upvalue_index(state, function, name) do + Enum.find_index(state.functions[function].upvalue_descriptors, fn {_kind, _index, n} -> n == name end) + end - state = %{state | functions: Map.put(state.functions, for_function, func)} - {:ok, uv_index, state} - end + # Give `for_function` an upvalue for `descriptor` and return its index. + # + # An identical descriptor provably denotes the same cell — closure creation + # resolves `{:parent_local, reg, _}` through the one open-upvalue cell for + # `reg`, and `{:parent_upvalue, i, _}` forwards the enclosing closure's + # slot `i` — so a function that mentions the same free variable twice + # shares a single slot rather than carrying the capture twice. That is what + # PUC-Lua does: one slot per upvalue, not one per reference. Deduping on + # the whole tuple keeps shadowed same-name captures apart. + defp put_upvalue(state, for_function, descriptor) do + func = state.functions[for_function] + descriptors = func.upvalue_descriptors + {index, descriptors} = insert_upvalue(descriptors, descriptor, 0, [], descriptors) + func = %{func | upvalue_descriptors: descriptors} + {:ok, index, %{state | functions: Map.put(state.functions, for_function, func)}} + end + + # One traversal for both outcomes: a hit yields the position and the + # untouched list, a miss appends off the reversed prefix already in hand. + # Neither path walks the list twice the way `length/1` plus `++` did. + defp insert_upvalue([], descriptor, index, acc, _all), do: {index, Enum.reverse([descriptor | acc])} + + defp insert_upvalue([descriptor | _rest], descriptor, index, _acc, all), do: {index, all} + + defp insert_upvalue([other | rest], descriptor, index, acc, all) do + insert_upvalue(rest, descriptor, index + 1, [other | acc], all) end # Shared helper: resolves a function body scope for Expr.Function, Statement.FuncDecl, etc. - # The `node` is used as the var_map key so codegen can look up the function scope. + # The `node`'s id is the var_map key so codegen can look up the function scope. defp resolve_function_scope(node, params, body, state) do func_key = make_ref() param_count = Enum.count(params, &(&1 != :vararg)) @@ -747,7 +753,7 @@ defmodule Lua.Compiler.Scope do state = %{state | functions: Map.put(state.functions, func_key, func_scope)} # Store the function key in var_map for this node - state = %{state | var_map: Map.put(state.var_map, node, func_key)} + state = %{state | var_map: Map.put(state.var_map, node_key(node), func_key)} # Detect which parent locals this inner function captures func_scope_final = state.functions[func_key] diff --git a/lib/lua/parser.ex b/lib/lua/parser.ex index 4c43efef..a2ba96b0 100644 --- a/lib/lua/parser.ex +++ b/lib/lua/parser.ex @@ -8,6 +8,7 @@ defmodule Lua.Parser do alias Lua.AST.Block alias Lua.AST.Chunk alias Lua.AST.Expr + alias Lua.AST.Ids alias Lua.AST.Meta alias Lua.AST.Statement alias Lua.Lexer @@ -105,6 +106,9 @@ defmodule Lua.Parser do @doc """ Parses a chunk (top-level block) from a token list. + + Every node of the returned chunk carries a chunk-unique `meta.id`; see + `Lua.AST.Ids`. """ @spec parse_chunk([token()]) :: {:ok, Chunk.t()} | {:error, term()} def parse_chunk(tokens) do @@ -112,7 +116,7 @@ defmodule Lua.Parser do {:ok, block, rest} -> case rest do [{:eof, _}] -> - {:ok, Chunk.new(block)} + {:ok, Ids.assign(Chunk.new(block))} [{type, _, pos} | _] -> {:error, {:unexpected_token, type, pos, "Expected end of input"}} diff --git a/test/lua/ast/ids_test.exs b/test/lua/ast/ids_test.exs new file mode 100644 index 00000000..0e7a2a9b --- /dev/null +++ b/test/lua/ast/ids_test.exs @@ -0,0 +1,95 @@ +defmodule Lua.AST.IdsTest do + @moduledoc """ + Pins the uniqueness contract compiler passes rely on when they key + per-node tables by `meta.id`. + """ + + use ExUnit.Case, async: true + + alias Lua.AST.Ids + alias Lua.AST.Walker + + describe "assign/1" do + test "gives every node of a parsed chunk an id" do + ids = ids_for("local x = 1\nprint(x + 2)\n") + + assert Enum.all?(ids, &is_integer/1) + end + + test "ids are unique across nested function bodies" do + source = """ + local function outer(a) + local inner = function(b) + return function(c) return a + b + c end + end + + return inner + end + + return outer(1)(2)(3) + """ + + ids = ids_for(source) + + assert length(ids) == length(Enum.uniq(ids)) + end + + test "ids are unique across structurally identical siblings" do + # The two branches parse to equal terms, so keying by the node itself + # would collapse them onto one entry. + source = """ + if flag then + local x = 1 + return x + else + local x = 1 + return x + end + """ + + ids = ids_for(source) + + assert length(ids) == length(Enum.uniq(ids)) + end + + test "keeps positions and comments already on a node" do + {:ok, chunk} = Lua.Parser.parse_raw("-- leading\nlocal x = 1\n") + [local_stmt] = chunk.block.stmts + + assert %{line: 2} = local_stmt.meta.start + assert [%{text: " leading"}] = local_stmt.meta.metadata.leading_comments + assert is_integer(local_stmt.meta.id) + end + + test "is idempotent in shape: re-assigning yields the same chunk" do + {:ok, chunk} = Lua.Parser.parse_raw("local t = {1, 2, x = 3}\nreturn t.x\n") + + assert Ids.assign(chunk) == chunk + end + + test "numbers every node of the compilable surface, uniquely" do + # `number/2` raises on a node shape it has no clause for, so any AST + # node type reachable from real programs that is missing coverage fails + # this walk loudly rather than leaving a subtree unnumbered. + sources = Path.wildcard("test/lua53_tests/*.lua") ++ Path.wildcard("test/integration/**/*.lua") + + refute sources == [] + + for path <- sources do + {:ok, chunk} = Lua.Parser.parse_raw(File.read!(path)) + + ids = Walker.reduce(Ids.assign(chunk), [], fn node, acc -> [node.meta.id | acc] end) + + refute ids == [] + assert Enum.all?(ids, &is_integer/1), "node without an integer id in #{path}" + assert length(ids) == length(Enum.uniq(ids)), "duplicate node ids in #{path}" + end + end + end + + defp ids_for(source) do + {:ok, chunk} = Lua.Parser.parse_raw(source) + + Walker.reduce(chunk, [], fn node, acc -> [node.meta.id | acc] end) + end +end diff --git a/test/lua/ast/meta_test.exs b/test/lua/ast/meta_test.exs index 09e34f1a..58b0841a 100644 --- a/test/lua/ast/meta_test.exs +++ b/test/lua/ast/meta_test.exs @@ -169,6 +169,20 @@ defmodule Lua.AST.MetaTest do assert merged.start == %{line: 1, column: 1, byte_offset: 0} assert merged.end == %{line: 1, column: 10, byte_offset: 9} end + + test "keeps the left operand's id and metadata" do + comment = %{type: :single, text: " note", position: %{line: 1, column: 1, byte_offset: 0}} + + meta1 = Meta.add_leading_comment(%{Meta.new(%{line: 1, column: 1, byte_offset: 0}, nil) | id: 7}, comment) + + meta2 = Meta.add_metadata(%{Meta.new(nil, %{line: 1, column: 10, byte_offset: 9}) | id: 99}, :other, :dropped) + + merged = Meta.merge(meta1, meta2) + + assert merged.id == 7 + assert Meta.get_leading_comments(merged) == [comment] + refute Map.has_key?(merged.metadata, :other) + end end describe "position tracking" do diff --git a/test/lua/compiler/upvalue_descriptor_test.exs b/test/lua/compiler/upvalue_descriptor_test.exs new file mode 100644 index 00000000..a70fc1a5 --- /dev/null +++ b/test/lua/compiler/upvalue_descriptor_test.exs @@ -0,0 +1,175 @@ +defmodule Lua.Compiler.UpvalueDescriptorTest do + @moduledoc """ + Pins one upvalue slot per captured variable, not one per reference. + + Lua 5.3 §3.5 gives a closure one upvalue per free variable it uses; + `debug.getupvalue` numbering follows that list. Two references to the same + free variable therefore share a slot. Closure creation already resolved + identical descriptors to the same cell — `{:parent_local, reg, _}` through + the single open-upvalue cell for `reg`, `{:parent_upvalue, i, _}` through + the enclosing closure's slot `i` — so collapsing them changes only how many + slots each closure carries, never which cell a read or write reaches. + """ + + use ExUnit.Case, async: true + + alias Lua.Compiler + alias Lua.Compiler.Prototype + alias Lua.Parser + + describe "upvalue_descriptors" do + test "a variable referenced many times gets one slot" do + [f] = + prototypes_of(""" + local x = 10 + local function f() return x + x + x + x end + return f() + """) + + assert f.upvalue_descriptors == [{:parent_local, 0, "_ENV"}, {:parent_local, 1, "x"}] + assert f.upvalue_names == ["_ENV", "x"] + end + + test "a global referenced many times gets one _ENV slot" do + [f] = + prototypes_of(""" + local function f() return print, print, print end + return f() + """) + + assert f.upvalue_descriptors == [{:parent_local, 0, "_ENV"}] + end + + test "a self-recursive local function captures itself once" do + [fib] = + prototypes_of(""" + local function fib(n) + if n < 2 then return n end + return fib(n - 1) + fib(n - 2) + end + return fib(10) + """) + + assert fib.upvalue_descriptors == [{:parent_local, 0, "_ENV"}, {:parent_local, 1, "fib"}] + end + + test "a grandparent capture is deduped at every level" do + [outer] = + prototypes_of(""" + local a = 1 + local function outer() + return function() return a + a + a end + end + return outer()() + """) + + [inner] = outer.prototypes + + assert outer.upvalue_descriptors == [{:parent_local, 0, "_ENV"}, {:parent_local, 1, "a"}] + assert inner.upvalue_descriptors == [{:parent_upvalue, 0, "_ENV"}, {:parent_upvalue, 1, "a"}] + end + + test "shadowed captures of the same name stay in their own function's list" do + # `outer` captures the chunk's `x`; `inner` captures `outer`'s own `x`. + # The two descriptors are equal tuples over different cells, so the + # dedupe has to be per-function — a chunk-wide one would fuse them. + [outer] = + prototypes_of(""" + local x = 1 + local function outer() + local seen = x + local x = 2 + return function() return seen + x + x end + end + return outer()() + """) + + [inner] = outer.prototypes + + assert outer.upvalue_descriptors == [{:parent_local, 0, "_ENV"}, {:parent_local, 1, "x"}] + + assert inner.upvalue_descriptors == [ + {:parent_upvalue, 0, "_ENV"}, + {:parent_local, 0, "seen"}, + {:parent_local, 1, "x"} + ] + + assert {[5], _} = + Lua.eval!(""" + local x = 1 + local function outer() + local seen = x + local x = 2 + return function() return seen + x + x end + end + return outer()() + """) + end + + test "no prototype in the compilable surface carries a duplicate descriptor" do + sources = Path.wildcard("test/lua53_tests/*.lua") ++ Path.wildcard("test/integration/**/*.lua") + + refute sources == [] + + for path <- sources, + {:ok, chunk} = Parser.parse_raw(File.read!(path)), + {:ok, proto} = Compiler.compile(chunk, source: path), + {sub_path, descriptors} <- descriptors_of(proto, path) do + assert descriptors == Enum.uniq(descriptors), "duplicate upvalue descriptors in #{sub_path}" + end + end + end + + describe "runtime behaviour is unchanged" do + test "writes through a repeated capture are visible to every reader" do + code = """ + local n = 0 + local function bump() n = n + 1 return n + n end + local function read() return n end + bump() + bump() + return read(), bump() + """ + + assert {[2, 6], _} = Lua.eval!(code) + end + + test "debug.getupvalue numbers the deduped slots" do + code = """ + local a, b = 1, 2 + local function f() return a + b + a end + local out = {} + local i = 1 + while true do + local name = debug.getupvalue(f, i) + if not name then break end + out[#out + 1] = name + i = i + 1 + end + return table.concat(out, ",") + """ + + # PUC-Lua prints "a,b": it only gives a closure an `_ENV` upvalue when + # the closure actually references a global. This VM currently hands + # `_ENV` to every nested function, so slot 1 is always `_ENV`. This + # assertion pins that divergence on purpose — aligning `_ENV` capture + # with PUC-Lua should update it to "a,b", not read as a regression. + assert {["_ENV,a,b"], _} = Lua.eval!(code) + end + end + + defp prototypes_of(source) do + {:ok, chunk} = Parser.parse_raw(source) + {:ok, %Prototype{prototypes: prototypes}} = Compiler.compile(chunk) + prototypes + end + + defp descriptors_of(%Prototype{} = proto, path) do + subs = + proto.prototypes + |> Enum.with_index() + |> Enum.flat_map(fn {sub, index} -> descriptors_of(sub, "#{path}/#{index}") end) + + [{path, proto.upvalue_descriptors} | subs] + end +end diff --git a/test/lua/vm/upvalue_test.exs b/test/lua/vm/upvalue_test.exs index f9825a59..72c51cf3 100644 --- a/test/lua/vm/upvalue_test.exs +++ b/test/lua/vm/upvalue_test.exs @@ -1,6 +1,7 @@ defmodule Lua.VM.UpvalueTest do use ExUnit.Case, async: true + alias Lua.AST.Builder alias Lua.Compiler alias Lua.Parser alias Lua.VM @@ -365,4 +366,86 @@ defmodule Lua.VM.UpvalueTest do assert {:ok, [600], _state} = run_lua(code) end end + + # Every empty block parses to the same term (`%Block{stmts: [], meta: nil}`), + # so while scope analysis keyed its per-block close-upvalue watermark by the + # block node, all the empty blocks in a chunk shared one entry and the last + # one resolved decided the threshold for all of them. A block that borrowed + # a lower watermark then closed cells belonging to live enclosing locals. + # Keying by node id gives each block its own entry. PUC-Lua prints `42 42` + # for the program below. + describe "empty blocks get their own close-upvalue watermark" do + test "an empty loop body does not close an enclosing captured local" do + code = """ + local t = {} + + do + local a, b, c, d = 1, 2, 3, 4 + local n = 0 + local get = function() return n end + local set = function(v) n = v end + for i = 1, 1 do end + set(42) + t[1] = get() + t[2] = n + end + + for i = 1, 1 do end + + return t[1], t[2] + """ + + assert {:ok, [42, 42], _state} = run_lua(code) + end + + test "sibling empty loop bodies compile to different thresholds" do + code = """ + do + local a, b, c, d = 1, 2, 3, 4 + for i = 1, 1 do end + end + + for i = 1, 1 do end + """ + + assert {:ok, ast} = Parser.parse(code) + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + + assert [_, _] = thresholds = close_thresholds(proto) + assert thresholds == Enum.uniq(thresholds) + end + + test "structurally identical hand-built loop bodies compile to different thresholds" do + # Same program as above, built through the public `Lua.AST.Builder` + # rather than the parser, so the nodes start without `meta.id`. The two + # `for` bodies are equal terms; only compile-time id stamping keeps + # their close-upvalue watermarks apart. + chunk = + Builder.chunk([ + Builder.do_block([ + Builder.local( + ["a", "b", "c", "d"], + [Builder.number(1), Builder.number(2), Builder.number(3), Builder.number(4)] + ), + Builder.for_num("i", Builder.number(1), Builder.number(1), []) + ]), + Builder.for_num("i", Builder.number(1), Builder.number(1), []) + ]) + + assert {:ok, proto} = Compiler.compile(chunk, source: "test.lua") + + assert [_, _] = thresholds = close_thresholds(proto) + assert thresholds == Enum.uniq(thresholds) + end + end + + # The empty loop bodies above compile to a body that is exactly the block's + # close-upvalues instruction; anything else in the body means the shape of + # the compiled output changed and the assertion should be revisited. + defp close_thresholds(proto) do + for {:numeric_for, _base, _loop_var, body} <- proto.instructions do + assert [{:close_upvalues, threshold}] = body + threshold + end + end end diff --git a/website/lib/website/lua_sandbox.ex b/website/lib/website/lua_sandbox.ex index a39f1438..08c7056b 100644 --- a/website/lib/website/lua_sandbox.ex +++ b/website/lib/website/lua_sandbox.ex @@ -129,14 +129,7 @@ defmodule Website.LuaSandbox do # big binaries it is about to drop). Unlink — and flush an exit # signal that may already be queued — so a late `:killed` can't # propagate to us once we stop trapping. - Process.unlink(worker) - - receive do - {:EXIT, ^worker, _} -> :ok - after - 0 -> :ok - end - + unlink_worker(worker) result # Worker hit the memory ceiling: max_heap_size kills it with @@ -151,13 +144,23 @@ defmodule Website.LuaSandbox do error_result(started, reason) # This task is being cancelled by the caller (the LiveView timeout). - # Stop the worker and let the cancellation proceed. + # Stop the worker and let the cancellation proceed. As on the timeout + # path below, `Process.exit/2` only *sends* the kill — if `exit/1` is + # caught upstream, the worker's `:killed` would come back after + # `trap_exit` is restored, so cut the link before exiting. {:EXIT, _other, reason} -> Process.exit(worker, :kill) + unlink_worker(worker) exit(reason) after @run_timeout_ms -> + # `Process.exit/2` only *sends* the kill; the worker's exit signal + # comes back tens of microseconds later, which is long enough to + # land after the `after` clause below has restored `trap_exit`. + # An untrapped `:killed` would then take the caller down with it, + # so cut the link before returning rather than racing it. Process.exit(worker, :kill) + unlink_worker(worker) timeout_result(started) end after @@ -165,6 +168,20 @@ defmodule Website.LuaSandbox do end end + # Drop the link to `worker` and flush an exit signal that already made it + # into the mailbox. `Process.unlink/1` guarantees no exit signal from that + # link is delivered after it returns, so once this runs the caller is safe + # to stop trapping exits. + defp unlink_worker(worker) do + Process.unlink(worker) + + receive do + {:EXIT, ^worker, _reason} -> :ok + after + 0 -> :ok + end + end + defp timeout_result(started) do %{ status: :timeout, diff --git a/website/test/website/lua_sandbox_test.exs b/website/test/website/lua_sandbox_test.exs new file mode 100644 index 00000000..da76e3f2 --- /dev/null +++ b/website/test/website/lua_sandbox_test.exs @@ -0,0 +1,47 @@ +defmodule Website.LuaSandboxTest do + @moduledoc """ + Pins that `run/1` leaves the caller alone. + + The worker is `spawn_link`ed so its death is observable, and `run/1` traps + exits only for its own duration. Any exit signal that escapes that window + lands on a caller that is no longer trapping and takes it down — so `run/1` + has to cut the link itself rather than hope the signal beats the restore. + """ + + use ExUnit.Case, async: true + + alias Website.LuaSandbox + + @timeout_source """ + local n = 0 + while true do + n = n + 1 + end + """ + + test "a snippet stopped by the wall-clock timeout does not kill a non-trapping caller" do + parent = self() + + caller = + spawn(fn -> + result = LuaSandbox.run(@timeout_source) + # Keep working after `run/1` returns: a late exit signal has to have + # somewhere to land for the race to be observable. + Process.sleep(150) + send(parent, {:survived, result.status}) + end) + + ref = Process.monitor(caller) + + assert_receive {:survived, :timeout}, 10_000 + assert_receive {:DOWN, ^ref, :process, ^caller, :normal}, 1_000 + end + + test "a snippet that finishes leaves nothing in the caller's mailbox" do + Process.flag(:trap_exit, true) + + assert %{status: :ok, returns: ["3"]} = LuaSandbox.run("return 1 + 2") + + refute_receive {:EXIT, _pid, _reason}, 100 + end +end From 983da9de67dfc18fdf63298ca44aad61a71af15a Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 15:19:44 -0400 Subject: [PATCH 05/13] vm: cut per-call and per-iteration overhead in the dispatcher (#401) --- lib/lua/vm/dispatcher.ex | 905 +++++++++++++------- lib/lua/vm/executor.ex | 61 +- lib/lua/vm/state.ex | 9 + lib/lua/vm/table.ex | 27 +- test/lua/call_function_error_value_test.exs | 56 ++ test/lua/vm/dispatcher_test.exs | 45 + test/lua/vm/require_open_upvalue_test.exs | 40 + 7 files changed, 794 insertions(+), 349 deletions(-) diff --git a/lib/lua/vm/dispatcher.ex b/lib/lua/vm/dispatcher.ex index b0d375e3..c9b5fd84 100644 --- a/lib/lua/vm/dispatcher.ex +++ b/lib/lua/vm/dispatcher.ex @@ -154,18 +154,28 @@ defmodule Lua.VM.Dispatcher do proto end - saved_open = state.open_upvalues - try do - state = %{state | open_upvalues: %{}} - - # Seed the dispatcher tally from the budget carried across the boundary - # so an alternating-engine call chain accumulates against one budget - # instead of resetting here; the terminals stamp the final tally back - # into `state.instruction_count`. - {results, state} = dispatch(proto.bytecode, 1, regs, upvalues, proto, state, [], [], state.instruction_count) + # Seed the loop-carried control parameters — instruction tally, call + # stack, call depth, open upvalues — from the state crossing the + # boundary. `open_upvalues` starts empty: cells are keyed by register + # index, so a nested evaluation must not see the caller's. The + # terminals stamp all four back into the struct on the way out. + {results, state} = + dispatch( + proto.bytecode, + 1, + regs, + upvalues, + proto, + state, + [], + [], + state.instruction_count, + state.call_stack, + state.call_depth, + %{} + ) - state = %{state | open_upvalues: saved_open} {results, state} rescue # Backstop net: any raise site missed by the per-site state @@ -215,55 +225,56 @@ defmodule Lua.VM.Dispatcher do # `Executor.call_function/3` instead, paying one Erlang stack frame # at the boundary. - defp dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count) when pc > tuple_size(code) do - finish_body(regs, upvalues, proto, state, cont, frames, instruction_count) + defp dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + when pc > tuple_size(code) do + finish_body(regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end - defp dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count) do + defp dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) do case :erlang.element(pc, code) do {@op_load_constant, dest, value} -> regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_load_boolean, dest, value} -> regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_load_nil, dest, count} -> regs = clear_nils(regs, dest, count + 1) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_move, dest, src} -> v = :erlang.element(src + 1, regs) regs = :erlang.setelement(dest + 1, regs, v) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_load_env, dest} -> env = if tuple_size(upvalues) > 0 do - Map.get(state.upvalue_cells, :erlang.element(1, upvalues)) + :maps.get(:erlang.element(1, upvalues), state.upvalue_cells, nil) else State.g_ref(state) end regs = :erlang.setelement(dest + 1, regs, env) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_get_upvalue, dest, index} -> cell_ref = :erlang.element(index + 1, upvalues) - # Mirror the interpreter's `Map.get/2` (returns nil for a dangling + # Mirror the interpreter's defaulting read (nil for a dangling # cell) rather than `:erlang.map_get/2` (which raises `:badkey`). # Compiled closures should never carry stale cell refs, but the # invariant is the interpreter's, not ours, and the error shape # has to match where it does fire. - v = Map.get(state.upvalue_cells, cell_ref) + v = :maps.get(cell_ref, state.upvalue_cells, nil) regs = :erlang.setelement(dest + 1, regs, v) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_get_global, dest, name} -> v = State.get_global(state, name) regs = :erlang.setelement(dest + 1, regs, v) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_get_field, dest, table_reg, name, name_hint} -> table_val = :erlang.element(table_reg + 1, regs) @@ -279,29 +290,29 @@ defmodule Lua.VM.Dispatcher do case data do %{^name => value} -> regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) _ -> case :erlang.map_get(:metatable, table) do nil -> regs = :erlang.setelement(dest + 1, regs, nil) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) _ -> {value, state} = - Executor.dispatcher_get_field(table_val, name, state, proto, name_hint) + Executor.dispatcher_get_field(table_val, name, sync(state, cs, cd), proto, name_hint) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end end _ -> {value, state} = - Executor.dispatcher_get_field(table_val, name, state, proto, name_hint) + Executor.dispatcher_get_field(table_val, name, sync(state, cs, cd), proto, name_hint) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end # ── Arithmetic ────────────────────────────────────────────────── @@ -320,16 +331,16 @@ defmodule Lua.VM.Dispatcher do sum = va + vb wrapped = if sum >= @min_int and sum <= @max_int, do: sum, else: Numeric.to_signed_int64(sum) regs = :erlang.setelement(dest + 1, regs, wrapped) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) is_number(va) and is_number(vb) -> regs = :erlang.setelement(dest + 1, regs, va + vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) true -> - {value, state} = Executor.dispatcher_binop(:add, va, vb, state, proto, hint_a, hint_b) + {value, state} = Executor.dispatcher_binop(:add, va, vb, sync(state, cs, cd), proto, hint_a, hint_b) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_subtract, dest, a, b, hint_a, hint_b} -> @@ -341,16 +352,16 @@ defmodule Lua.VM.Dispatcher do diff = va - vb wrapped = if diff >= @min_int and diff <= @max_int, do: diff, else: Numeric.to_signed_int64(diff) regs = :erlang.setelement(dest + 1, regs, wrapped) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) is_number(va) and is_number(vb) -> regs = :erlang.setelement(dest + 1, regs, va - vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) true -> - {value, state} = Executor.dispatcher_binop(:subtract, va, vb, state, proto, hint_a, hint_b) + {value, state} = Executor.dispatcher_binop(:subtract, va, vb, sync(state, cs, cd), proto, hint_a, hint_b) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_multiply, dest, a, b, hint_a, hint_b} -> @@ -362,16 +373,16 @@ defmodule Lua.VM.Dispatcher do prod = va * vb wrapped = if prod >= @min_int and prod <= @max_int, do: prod, else: Numeric.to_signed_int64(prod) regs = :erlang.setelement(dest + 1, regs, wrapped) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) is_number(va) and is_number(vb) -> regs = :erlang.setelement(dest + 1, regs, va * vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) true -> - {value, state} = Executor.dispatcher_binop(:multiply, va, vb, state, proto, hint_a, hint_b) + {value, state} = Executor.dispatcher_binop(:multiply, va, vb, sync(state, cs, cd), proto, hint_a, hint_b) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_divide, dest, a, b, hint_a, hint_b} -> @@ -380,14 +391,14 @@ defmodule Lua.VM.Dispatcher do :divide, :erlang.element(a + 1, regs), :erlang.element(b + 1, regs), - state, + sync(state, cs, cd), proto, hint_a, hint_b ) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_floor_divide, dest, a, b, hint_a, hint_b} -> {value, state} = @@ -395,14 +406,14 @@ defmodule Lua.VM.Dispatcher do :floor_divide, :erlang.element(a + 1, regs), :erlang.element(b + 1, regs), - state, + sync(state, cs, cd), proto, hint_a, hint_b ) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_modulo, dest, a, b, hint_a, hint_b} -> {value, state} = @@ -410,14 +421,14 @@ defmodule Lua.VM.Dispatcher do :modulo, :erlang.element(a + 1, regs), :erlang.element(b + 1, regs), - state, + sync(state, cs, cd), proto, hint_a, hint_b ) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_power, dest, a, b, hint_a, hint_b} -> {value, state} = @@ -425,21 +436,21 @@ defmodule Lua.VM.Dispatcher do :power, :erlang.element(a + 1, regs), :erlang.element(b + 1, regs), - state, + sync(state, cs, cd), proto, hint_a, hint_b ) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_negate, dest, src, hint} -> {value, state} = - Executor.dispatcher_unop(:negate, :erlang.element(src + 1, regs), state, proto, hint) + Executor.dispatcher_unop(:negate, :erlang.element(src + 1, regs), sync(state, cs, cd), proto, hint) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) # ── Bitwise ───────────────────────────────────────────────────── # @@ -463,11 +474,11 @@ defmodule Lua.VM.Dispatcher do if is_integer(va) and is_integer(vb) do regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(Bitwise.band(va, vb))) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) else - {value, state} = Executor.dispatcher_bitwise(:band, va, vb, state, proto, hint_a, hint_b) + {value, state} = Executor.dispatcher_bitwise(:band, va, vb, sync(state, cs, cd), proto, hint_a, hint_b) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_bitwise_or, dest, a, b, hint_a, hint_b} -> @@ -476,11 +487,11 @@ defmodule Lua.VM.Dispatcher do if is_integer(va) and is_integer(vb) do regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(Bitwise.bor(va, vb))) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) else - {value, state} = Executor.dispatcher_bitwise(:bor, va, vb, state, proto, hint_a, hint_b) + {value, state} = Executor.dispatcher_bitwise(:bor, va, vb, sync(state, cs, cd), proto, hint_a, hint_b) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_bitwise_xor, dest, a, b, hint_a, hint_b} -> @@ -489,32 +500,32 @@ defmodule Lua.VM.Dispatcher do if is_integer(va) and is_integer(vb) do regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(Bitwise.bxor(va, vb))) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) else - {value, state} = Executor.dispatcher_bitwise(:bxor, va, vb, state, proto, hint_a, hint_b) + {value, state} = Executor.dispatcher_bitwise(:bxor, va, vb, sync(state, cs, cd), proto, hint_a, hint_b) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_shift_left, dest, a, b, hint_a, hint_b} -> va = :erlang.element(a + 1, regs) vb = :erlang.element(b + 1, regs) - {value, state} = Executor.dispatcher_bitwise(:shl, va, vb, state, proto, hint_a, hint_b) + {value, state} = Executor.dispatcher_bitwise(:shl, va, vb, sync(state, cs, cd), proto, hint_a, hint_b) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_shift_right, dest, a, b, hint_a, hint_b} -> va = :erlang.element(a + 1, regs) vb = :erlang.element(b + 1, regs) - {value, state} = Executor.dispatcher_bitwise(:shr, va, vb, state, proto, hint_a, hint_b) + {value, state} = Executor.dispatcher_bitwise(:shr, va, vb, sync(state, cs, cd), proto, hint_a, hint_b) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_bitwise_not, dest, src, hint} -> val = :erlang.element(src + 1, regs) - {value, state} = Executor.dispatcher_bnot(val, state, proto, hint) + {value, state} = Executor.dispatcher_bnot(val, sync(state, cs, cd), proto, hint) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) # ── Comparisons ───────────────────────────────────────────────── @@ -525,16 +536,16 @@ defmodule Lua.VM.Dispatcher do cond do is_number(va) and is_number(vb) -> regs = :erlang.setelement(dest + 1, regs, va < vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) is_binary(va) and is_binary(vb) -> regs = :erlang.setelement(dest + 1, regs, va < vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) true -> - {value, state} = Executor.dispatcher_cmp(:less_than, va, vb, state, proto) + {value, state} = Executor.dispatcher_cmp(:less_than, va, vb, sync(state, cs, cd), proto) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_less_equal, dest, a, b} -> @@ -544,16 +555,16 @@ defmodule Lua.VM.Dispatcher do cond do is_number(va) and is_number(vb) -> regs = :erlang.setelement(dest + 1, regs, va <= vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) is_binary(va) and is_binary(vb) -> regs = :erlang.setelement(dest + 1, regs, va <= vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) true -> - {value, state} = Executor.dispatcher_cmp(:less_equal, va, vb, state, proto) + {value, state} = Executor.dispatcher_cmp(:less_equal, va, vb, sync(state, cs, cd), proto) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_greater_than, dest, a, b} -> @@ -563,16 +574,16 @@ defmodule Lua.VM.Dispatcher do cond do is_number(va) and is_number(vb) -> regs = :erlang.setelement(dest + 1, regs, va > vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) is_binary(va) and is_binary(vb) -> regs = :erlang.setelement(dest + 1, regs, va > vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) true -> - {value, state} = Executor.dispatcher_cmp(:greater_than, va, vb, state, proto) + {value, state} = Executor.dispatcher_cmp(:greater_than, va, vb, sync(state, cs, cd), proto) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_greater_equal, dest, a, b} -> @@ -582,16 +593,16 @@ defmodule Lua.VM.Dispatcher do cond do is_number(va) and is_number(vb) -> regs = :erlang.setelement(dest + 1, regs, va >= vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) is_binary(va) and is_binary(vb) -> regs = :erlang.setelement(dest + 1, regs, va >= vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) true -> - {value, state} = Executor.dispatcher_cmp(:greater_equal, va, vb, state, proto) + {value, state} = Executor.dispatcher_cmp(:greater_equal, va, vb, sync(state, cs, cd), proto) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_equal, dest, a, b} -> @@ -601,16 +612,16 @@ defmodule Lua.VM.Dispatcher do cond do is_number(va) and is_number(vb) -> regs = :erlang.setelement(dest + 1, regs, va == vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) is_binary(va) and is_binary(vb) -> regs = :erlang.setelement(dest + 1, regs, va == vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) true -> - {value, state} = Executor.dispatcher_cmp(:equal, va, vb, state, proto) + {value, state} = Executor.dispatcher_cmp(:equal, va, vb, sync(state, cs, cd), proto) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_not_equal, dest, a, b} -> @@ -620,16 +631,16 @@ defmodule Lua.VM.Dispatcher do cond do is_number(va) and is_number(vb) -> regs = :erlang.setelement(dest + 1, regs, va != vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) is_binary(va) and is_binary(vb) -> regs = :erlang.setelement(dest + 1, regs, va != vb) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) true -> - {value, state} = Executor.dispatcher_cmp(:not_equal, va, vb, state, proto) + {value, state} = Executor.dispatcher_cmp(:not_equal, va, vb, sync(state, cs, cd), proto) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_not, dest, src} -> @@ -638,7 +649,7 @@ defmodule Lua.VM.Dispatcher do # values. Saves a function call per `:not` opcode. result = v === nil or v === false regs = :erlang.setelement(dest + 1, regs, result) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) # ── Conditional branching ─────────────────────────────────────── # @@ -657,7 +668,7 @@ defmodule Lua.VM.Dispatcher do _ -> then_bc end - dispatch(branch, 1, regs, upvalues, proto, state, [{code, pc + 1} | cont], frames, instruction_count) + dispatch(branch, 1, regs, upvalues, proto, state, [{code, pc + 1} | cont], frames, instruction_count, cs, cd, ou) # ── Calls ─────────────────────────────────────────────────────── # @@ -679,24 +690,17 @@ defmodule Lua.VM.Dispatcher do case func_value do {:compiled_closure, callee_proto, callee_upvalues} -> callee_regs = init_callee_regs(callee_proto, regs, base + 1, arg_count) - # B5c-v2: compiled callees may now be vararg functions. The - # `is_vararg` check in `setup_vararg_proto/4` short-circuits for - # the common non-vararg case at one tuple-field read. - callee_proto = setup_vararg_proto(callee_proto, regs, base + 1, arg_count) - - frame = - {code, pc + 1, regs, upvalues, proto, cont, :discard, state.open_upvalues} - - call_info = Executor.dispatcher_call_info(proto, name_hint, 0) - instruction_count = State.tick!(state, instruction_count) - State.check_call_depth!(state) - - state = %{ - state - | call_stack: [call_info | state.call_stack], - call_depth: state.call_depth + 1, - open_upvalues: %{} - } + # Compiled callees may be vararg functions. Testing `is_vararg` + # here keeps the common non-vararg call to one field read. + callee_proto = + if callee_proto.is_vararg, + do: setup_vararg_proto(callee_proto, regs, base + 1, arg_count), + else: callee_proto + + frame = {code, pc + 1, regs, upvalues, proto, cont, :discard, ou} + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) dispatch( callee_proto.bytecode, @@ -707,38 +711,40 @@ defmodule Lua.VM.Dispatcher do state, [], [frame | frames], - instruction_count + instruction_count, + [call_info | cs], + cd + 1, + %{} ) {:lua_closure, _, _} = closure -> args = collect_args(regs, base + 1, arg_count) - call_info = Executor.dispatcher_call_info(proto, name_hint, 0) - instruction_count = State.tick!(state, instruction_count) - State.check_call_depth!(state) + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) state = %{ state - | call_stack: [call_info | state.call_stack], - call_depth: state.call_depth + 1, + | call_stack: [call_info | cs], + call_depth: cd + 1, instruction_count: instruction_count } {_results, state} = Executor.call_function(closure, args, state) instruction_count = state.instruction_count - state = %{state | call_stack: tl(state.call_stack), call_depth: state.call_depth - 1} - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) _ -> args = collect_args(regs, base + 1, arg_count) - state = %{state | instruction_count: instruction_count} + state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} {_results, state} = Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) instruction_count = state.instruction_count - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_call_one, base, arg_count, name_hint, line} -> @@ -747,24 +753,19 @@ defmodule Lua.VM.Dispatcher do case func_value do {:compiled_closure, callee_proto, callee_upvalues} -> callee_regs = init_callee_regs(callee_proto, regs, base + 1, arg_count) - callee_proto = setup_vararg_proto(callee_proto, regs, base + 1, arg_count) + + callee_proto = + if callee_proto.is_vararg, + do: setup_vararg_proto(callee_proto, regs, base + 1, arg_count), + else: callee_proto # Frame is a tuple, not a map: pattern-matching a tuple in - # `return_one/3` skips Map.fetch! lookups and lets the BEAM + # `return_one/7` skips Map.fetch! lookups and lets the BEAM # bind everything in a single `move` per slot. - frame = - {code, pc + 1, regs, upvalues, proto, cont, base, state.open_upvalues} - - call_info = Executor.dispatcher_call_info(proto, name_hint, 0) - instruction_count = State.tick!(state, instruction_count) - State.check_call_depth!(state) - - state = %{ - state - | call_stack: [call_info | state.call_stack], - call_depth: state.call_depth + 1, - open_upvalues: %{} - } + frame = {code, pc + 1, regs, upvalues, proto, cont, base, ou} + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) dispatch( callee_proto.bytecode, @@ -775,25 +776,27 @@ defmodule Lua.VM.Dispatcher do state, [], [frame | frames], - instruction_count + instruction_count, + [call_info | cs], + cd + 1, + %{} ) {:lua_closure, _, _} = closure -> args = collect_args(regs, base + 1, arg_count) - call_info = Executor.dispatcher_call_info(proto, name_hint, 0) - instruction_count = State.tick!(state, instruction_count) - State.check_call_depth!(state) + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) state = %{ state - | call_stack: [call_info | state.call_stack], - call_depth: state.call_depth + 1, + | call_stack: [call_info | cs], + call_depth: cd + 1, instruction_count: instruction_count } {results, state} = Executor.call_function(closure, args, state) instruction_count = state.instruction_count - state = %{state | call_stack: tl(state.call_stack), call_depth: state.call_depth - 1} first = case results do @@ -802,12 +805,12 @@ defmodule Lua.VM.Dispatcher do end regs = :erlang.setelement(base + 1, regs, first) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) _ -> args = collect_args(regs, base + 1, arg_count) - state = %{state | instruction_count: instruction_count} + state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} {results, state} = Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) @@ -821,7 +824,7 @@ defmodule Lua.VM.Dispatcher do end regs = :erlang.setelement(base + 1, regs, first) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end # ── Returns ───────────────────────────────────────────────────── @@ -834,10 +837,10 @@ defmodule Lua.VM.Dispatcher do # `call_function/3` contract. {@op_return_one, base} -> - return_one(:erlang.element(base + 1, regs), state, frames, instruction_count) + return_one(:erlang.element(base + 1, regs), state, frames, instruction_count, cs, cd, ou) {@op_return_zero} -> - return_one(nil, state, frames, instruction_count) + return_one(nil, state, frames, instruction_count, cs, cd, ou) # ── Table opcodes ─────────────────────────────────────────────── # @@ -849,7 +852,7 @@ defmodule Lua.VM.Dispatcher do {@op_new_table, dest} -> {tref, state} = State.alloc_table(state) regs = :erlang.setelement(dest + 1, regs, tref) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_get_table, dest, table_reg, key_reg, name_hint} -> table_val = :erlang.element(table_reg + 1, regs) @@ -864,19 +867,19 @@ defmodule Lua.VM.Dispatcher do case :erlang.map_get(:metatable, table) do nil -> regs = :erlang.setelement(dest + 1, regs, nil) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) _ -> {value, state} = - Executor.dispatcher_get_table(table_val, key, state, proto, name_hint) + Executor.dispatcher_get_table(table_val, key, sync(state, cs, cd), proto, name_hint) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end value -> regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {:tref, id} when is_integer(key) or is_binary(key) -> @@ -886,43 +889,66 @@ defmodule Lua.VM.Dispatcher do case data do %{^key => value} -> regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) _ -> case :erlang.map_get(:metatable, table) do nil -> regs = :erlang.setelement(dest + 1, regs, nil) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) _ -> {value, state} = - Executor.dispatcher_get_table(table_val, key, state, proto, name_hint) + Executor.dispatcher_get_table(table_val, key, sync(state, cs, cd), proto, name_hint) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end end _ -> {value, state} = - Executor.dispatcher_get_table(table_val, key, state, proto, name_hint) + Executor.dispatcher_get_table(table_val, key, sync(state, cs, cd), proto, name_hint) regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end {@op_set_table, table_reg, key_reg, value_reg, name_hint} -> table_val = :erlang.element(table_reg + 1, regs) key = :erlang.element(key_reg + 1, regs) value = :erlang.element(value_reg + 1, regs) - state = Executor.dispatcher_set_table(table_val, key, value, state, proto, name_hint) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) - + state = Executor.dispatcher_set_table(table_val, key, value, sync(state, cs, cd), proto, name_hint) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + # Mirrors `@op_get_field`'s shape: a tref whose table has no metatable + # has no `__newindex` to consult, so the write is a direct + # `Table.put/3` into `state.tables`. Anything else — a non-tref, or a + # table carrying a metatable — bridges so the `__newindex` chain and + # the index type errors stay the interpreter's. {@op_set_field, table_reg, name, value_reg, name_hint} -> table_val = :erlang.element(table_reg + 1, regs) value = :erlang.element(value_reg + 1, regs) - state = Executor.dispatcher_set_field(table_val, name, value, state, proto, name_hint) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + + case table_val do + {:tref, id} -> + table = :erlang.map_get(id, state.tables) + + state = + case :erlang.map_get(:metatable, table) do + nil -> + %{state | tables: :maps.put(id, Table.put(table, name, value), state.tables)} + + _ -> + Executor.dispatcher_set_field(table_val, name, value, sync(state, cs, cd), proto, name_hint) + end + + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + # Indexing a non-table always raises; the bridge owns the wording. + _ -> + Executor.dispatcher_set_field(table_val, name, value, sync(state, cs, cd), proto, name_hint) + end # `:set_list` with a positive integer count is the table-constructor # form. The `count == 0` sentinel was filtered upstream and never @@ -935,7 +961,7 @@ defmodule Lua.VM.Dispatcher do set_list_into_table(table, regs, start, count, offset, 0) end) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) # `:set_list` multi-return tail (`{f(), 1}`): fold the static prefix # `init_count` with the trailing values count the last multi-return @@ -950,7 +976,7 @@ defmodule Lua.VM.Dispatcher do set_list_into_table(table, regs, start, total, offset, 0) end) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_length, dest, source} -> value = :erlang.element(source + 1, regs) @@ -965,22 +991,22 @@ defmodule Lua.VM.Dispatcher do # without __len is the border length of the data map. len = Table.length(table) regs = :erlang.setelement(dest + 1, regs, len) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) _ -> - {len, state} = Executor.dispatcher_length(value, state, proto) + {len, state} = Executor.dispatcher_length(value, sync(state, cs, cd), proto) regs = :erlang.setelement(dest + 1, regs, len) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end v when is_binary(v) -> regs = :erlang.setelement(dest + 1, regs, byte_size(v)) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) _ -> - {len, state} = Executor.dispatcher_length(value, state, proto) + {len, state} = Executor.dispatcher_length(value, sync(state, cs, cd), proto) regs = :erlang.setelement(dest + 1, regs, len) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end # ── numeric_for ───────────────────────────────────────────────── @@ -997,7 +1023,7 @@ defmodule Lua.VM.Dispatcher do :erlang.element(base + 1, regs), :erlang.element(base + 2, regs), :erlang.element(base + 3, regs), - state + sync(state, cs, cd) ) regs = :erlang.setelement(base + 1, regs, counter) @@ -1009,12 +1035,26 @@ defmodule Lua.VM.Dispatcher do if should_continue do regs = :erlang.setelement(loop_var + 1, regs, counter) - state = Executor.dispatcher_close_open_upvalues_at_or_above(state, loop_var) + ou = close_upv(ou, loop_var) marker = {:cps_for, base, loop_var, body_bc, code, pc + 1} loop_exit = {:loop_exit, code, pc + 1} - dispatch(body_bc, 1, regs, upvalues, proto, state, [marker, loop_exit | cont], frames, instruction_count) + + dispatch( + body_bc, + 1, + regs, + upvalues, + proto, + state, + [marker, loop_exit | cont], + frames, + instruction_count, + cs, + cd, + ou + ) else - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end # ── while_loop / repeat_loop / generic_for ───────────────────── @@ -1027,12 +1067,12 @@ defmodule Lua.VM.Dispatcher do {@op_while_loop, test_reg, cond_bc, body_bc} -> cps = {:cps_while_test, test_reg, cond_bc, body_bc, code, pc + 1} loop_exit = {:loop_exit, code, pc + 1} - dispatch(cond_bc, 1, regs, upvalues, proto, state, [cps, loop_exit | cont], frames, instruction_count) + dispatch(cond_bc, 1, regs, upvalues, proto, state, [cps, loop_exit | cont], frames, instruction_count, cs, cd, ou) {@op_repeat_loop, test_reg, body_bc, cond_bc} -> cps = {:cps_repeat_body, test_reg, body_bc, cond_bc, code, pc + 1} loop_exit = {:loop_exit, code, pc + 1} - dispatch(body_bc, 1, regs, upvalues, proto, state, [cps, loop_exit | cont], frames, instruction_count) + dispatch(body_bc, 1, regs, upvalues, proto, state, [cps, loop_exit | cont], frames, instruction_count, cs, cd, ou) {@op_generic_for, base, var_regs, body_bc, line} -> # Iterator call follows the same shape as the executor: @@ -1045,7 +1085,7 @@ defmodule Lua.VM.Dispatcher do invariant_state = :erlang.element(base + 2, regs) control = :erlang.element(base + 3, regs) - state = %{state | instruction_count: instruction_count} + state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} {results, state} = Executor.dispatcher_call_value(iter_func, [invariant_state, control], proto, state, line) @@ -1054,19 +1094,33 @@ defmodule Lua.VM.Dispatcher do case results do [nil | _] -> - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) [] -> - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) [first | _] -> regs = :erlang.setelement(base + 3, regs, first) regs = assign_iter_results(regs, var_regs, results, 0) first_var_reg = :erlang.element(1, var_regs) - state = Executor.dispatcher_close_open_upvalues_at_or_above(state, first_var_reg) + ou = close_upv(ou, first_var_reg) marker = {:cps_generic_for, base, var_regs, body_bc, line, code, pc + 1} loop_exit = {:loop_exit, code, pc + 1} - dispatch(body_bc, 1, regs, upvalues, proto, state, [marker, loop_exit | cont], frames, instruction_count) + + dispatch( + body_bc, + 1, + regs, + upvalues, + proto, + state, + [marker, loop_exit | cont], + frames, + instruction_count, + cs, + cd, + ou + ) end # ── break ───────────────────────────────────────────────────── @@ -1077,7 +1131,7 @@ defmodule Lua.VM.Dispatcher do {@op_break} -> {exit_code, exit_pc, rest_cont} = find_loop_exit(cont) - dispatch(exit_code, exit_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count) + dispatch(exit_code, exit_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count, cs, cd, ou) # ── label / goto ────────────────────────────────────────────── # @@ -1089,16 +1143,16 @@ defmodule Lua.VM.Dispatcher do # enclosing `code` recorded on the unwound markers. {@op_label, _name, _level} -> - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_goto, 0, target_pc, level} -> - state = Executor.dispatcher_close_open_upvalues_at_or_above(state, level) - dispatch(code, target_pc, regs, upvalues, proto, state, cont, frames, instruction_count) + ou = close_upv(ou, level) + dispatch(code, target_pc, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_goto, depth, target_pc, level} -> - state = Executor.dispatcher_close_open_upvalues_at_or_above(state, level) + ou = close_upv(ou, level) {dest_code, rest_cont} = unwind_goto(cont, depth) - dispatch(dest_code, target_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count) + dispatch(dest_code, target_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count, cs, cd, ou) # ── Closure construction ────────────────────────────────────── # @@ -1114,7 +1168,7 @@ defmodule Lua.VM.Dispatcher do {@op_closure, dest, proto_index} -> nested_proto = Enum.at(proto.prototypes, proto_index) - {cells, state} = build_upvalues(nested_proto.upvalue_descriptors, regs, upvalues, state, []) + {cells, state, ou} = build_upvalues(nested_proto.upvalue_descriptors, regs, upvalues, state, ou, []) upvalues_tuple = List.to_tuple(:lists.reverse(cells)) closure = @@ -1124,7 +1178,7 @@ defmodule Lua.VM.Dispatcher do end regs = :erlang.setelement(dest + 1, regs, closure) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) # ── Upvalue access ──────────────────────────────────────────── # @@ -1139,21 +1193,21 @@ defmodule Lua.VM.Dispatcher do cell_ref = :erlang.element(index + 1, upvalues) value = :erlang.element(source + 1, regs) state = %{state | upvalue_cells: Map.put(state.upvalue_cells, cell_ref, value)} - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_get_open_upvalue, dest, reg} -> value = - case Map.get(state.open_upvalues, reg) do + case :maps.get(reg, ou, nil) do nil -> :erlang.element(reg + 1, regs) - cell_ref -> Map.get(state.upvalue_cells, cell_ref) + cell_ref -> :maps.get(cell_ref, state.upvalue_cells, nil) end regs = :erlang.setelement(dest + 1, regs, value) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_set_open_upvalue, reg, source} -> state = - case Map.get(state.open_upvalues, reg) do + case :maps.get(reg, ou, nil) do nil -> state @@ -1162,11 +1216,11 @@ defmodule Lua.VM.Dispatcher do %{state | upvalue_cells: Map.put(state.upvalue_cells, cell_ref, value)} end - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_close_upvalues, threshold} -> - state = Executor.dispatcher_close_open_upvalues_at_or_above(state, threshold) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + ou = close_upv(ou, threshold) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) # ── Vararg ──────────────────────────────────────────────────── # @@ -1181,25 +1235,25 @@ defmodule Lua.VM.Dispatcher do varargs = proto.varargs {regs, n} = write_varargs(regs, base, varargs, 0) state = %{state | multi_return_count: n} - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) {@op_vararg, base, count} -> regs = write_varargs_n(regs, base, proto.varargs, count) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) # ── Multi-return returns ────────────────────────────────────── {@op_return_proto_varargs} -> - return_multi(proto.varargs, state, frames, instruction_count) + return_multi(proto.varargs, state, frames, instruction_count, cs, cd, ou) {@op_return_collect, base, fixed} -> total = fixed + state.multi_return_count results = collect_args(regs, base, total) - return_multi(results, state, frames, instruction_count) + return_multi(results, state, frames, instruction_count, cs, cd, ou) {@op_return_multi, base, count} -> results = collect_args(regs, base, count) - return_multi(results, state, frames, instruction_count) + return_multi(results, state, frames, instruction_count, cs, cd, ou) # ── Multi-return calls ──────────────────────────────────────── # @@ -1227,7 +1281,12 @@ defmodule Lua.VM.Dispatcher do case func_value do {:compiled_closure, callee_proto, callee_upvalues} -> callee_regs = init_callee_regs(callee_proto, regs, base + 1, total_args) - callee_proto = setup_vararg_proto(callee_proto, regs, base + 1, total_args) + + callee_proto = + if callee_proto.is_vararg, + do: setup_vararg_proto(callee_proto, regs, base + 1, total_args), + else: callee_proto + # Reuse the fast-path frame shapes when result_count is 0 # (discard) or 1 (single integer base). Only the genuine # multi-return shapes (-1, -2, n > 1) need the tagged @@ -1239,17 +1298,10 @@ defmodule Lua.VM.Dispatcher do _ -> {:multi, base, result_count} end - frame = {code, pc + 1, regs, upvalues, proto, cont, dest, state.open_upvalues} - call_info = Executor.dispatcher_call_info(proto, name_hint, 0) - instruction_count = State.tick!(state, instruction_count) - State.check_call_depth!(state) - - state = %{ - state - | call_stack: [call_info | state.call_stack], - call_depth: state.call_depth + 1, - open_upvalues: %{} - } + frame = {code, pc + 1, regs, upvalues, proto, cont, dest, ou} + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) dispatch( callee_proto.bytecode, @@ -1260,25 +1312,27 @@ defmodule Lua.VM.Dispatcher do state, [], [frame | frames], - instruction_count + instruction_count, + [call_info | cs], + cd + 1, + %{} ) {:lua_closure, _, _} = closure -> args = collect_args(regs, base + 1, total_args) - call_info = Executor.dispatcher_call_info(proto, name_hint, 0) - instruction_count = State.tick!(state, instruction_count) - State.check_call_depth!(state) + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) state = %{ state - | call_stack: [call_info | state.call_stack], - call_depth: state.call_depth + 1, + | call_stack: [call_info | cs], + call_depth: cd + 1, instruction_count: instruction_count } {results, state} = Executor.call_function(closure, args, state) instruction_count = state.instruction_count - state = %{state | call_stack: tl(state.call_stack), call_depth: state.call_depth - 1} apply_multi_call_result( result_count, @@ -1292,13 +1346,16 @@ defmodule Lua.VM.Dispatcher do state, cont, frames, - instruction_count + instruction_count, + cs, + cd, + ou ) _ -> args = collect_args(regs, base + 1, total_args) - state = %{state | instruction_count: instruction_count} + state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} {results, state} = Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) @@ -1317,7 +1374,10 @@ defmodule Lua.VM.Dispatcher do state, cont, frames, - instruction_count + instruction_count, + cs, + cd, + ou ) end @@ -1331,10 +1391,10 @@ defmodule Lua.VM.Dispatcher do {@op_self, base, obj_reg, method_name, name_hint} -> obj = :erlang.element(obj_reg + 1, regs) - {func, state} = Executor.dispatcher_index_method_target(obj, method_name, state, proto, name_hint) + {func, state} = Executor.dispatcher_index_method_target(obj, method_name, sync(state, cs, cd), proto, name_hint) regs = :erlang.setelement(base + 2, regs, obj) regs = :erlang.setelement(base + 1, regs, func) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) # ── Concatenation ───────────────────────────────────────────── # @@ -1352,19 +1412,30 @@ defmodule Lua.VM.Dispatcher do end regs = :erlang.setelement(dest + 1, regs, left <> right) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) else - {result, state} = Executor.dispatcher_concat(left, right, state, proto) + {result, state} = Executor.dispatcher_concat(left, right, sync(state, cs, cd), proto) regs = :erlang.setelement(dest + 1, regs, result) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end end end # ── End-of-body handling ──────────────────────────────────────────────── - defp finish_body(regs, upvalues, proto, state, [{next_code, next_pc} | rest_cont], frames, instruction_count) do - dispatch(next_code, next_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count) + defp finish_body( + regs, + upvalues, + proto, + state, + [{next_code, next_pc} | rest_cont], + frames, + instruction_count, + cs, + cd, + ou + ) do + dispatch(next_code, next_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count, cs, cd, ou) end # `:numeric_for` body ran to completion. Increment the counter, re-test, @@ -1381,7 +1452,10 @@ defmodule Lua.VM.Dispatcher do state, [{:cps_for, base, loop_var, body_bc, outer_code, outer_pc} = marker, {:loop_exit, _, _} = loop_exit | rest_cont], frames, - instruction_count + instruction_count, + cs, + cd, + ou ) do counter = :erlang.element(base + 1, regs) step = :erlang.element(base + 3, regs) @@ -1391,12 +1465,26 @@ defmodule Lua.VM.Dispatcher do should_continue = if step > 0, do: new_counter <= limit, else: new_counter >= limit if should_continue do - instruction_count = State.tick!(state, instruction_count) + instruction_count = tick(state, instruction_count, cs, cd) regs = :erlang.setelement(loop_var + 1, regs, new_counter) - state = Executor.dispatcher_close_open_upvalues_at_or_above(state, loop_var) - dispatch(body_bc, 1, regs, upvalues, proto, state, [marker, loop_exit | rest_cont], frames, instruction_count) + ou = close_upv(ou, loop_var) + + dispatch( + body_bc, + 1, + regs, + upvalues, + proto, + state, + [marker, loop_exit | rest_cont], + frames, + instruction_count, + cs, + cd, + ou + ) else - dispatch(outer_code, outer_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count) + dispatch(outer_code, outer_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count, cs, cd, ou) end end @@ -1412,15 +1500,32 @@ defmodule Lua.VM.Dispatcher do {:loop_exit, _, _} = loop_exit | rest_cont ], frames, - instruction_count + instruction_count, + cs, + cd, + ou ) do case :erlang.element(test_reg + 1, regs) do v when v === nil or v === false -> - dispatch(outer_code, outer_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count) + dispatch(outer_code, outer_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count, cs, cd, ou) _ -> cps = {:cps_while_body, test_reg, cond_bc, body_bc, outer_code, outer_pc} - dispatch(body_bc, 1, regs, upvalues, proto, state, [cps, loop_exit | rest_cont], frames, instruction_count) + + dispatch( + body_bc, + 1, + regs, + upvalues, + proto, + state, + [cps, loop_exit | rest_cont], + frames, + instruction_count, + cs, + cd, + ou + ) end end @@ -1436,11 +1541,28 @@ defmodule Lua.VM.Dispatcher do {:loop_exit, _, _} = loop_exit | rest_cont ], frames, - instruction_count + instruction_count, + cs, + cd, + ou ) do - instruction_count = State.tick!(state, instruction_count) + instruction_count = tick(state, instruction_count, cs, cd) cps = {:cps_while_test, test_reg, cond_bc, body_bc, outer_code, outer_pc} - dispatch(cond_bc, 1, regs, upvalues, proto, state, [cps, loop_exit | rest_cont], frames, instruction_count) + + dispatch( + cond_bc, + 1, + regs, + upvalues, + proto, + state, + [cps, loop_exit | rest_cont], + frames, + instruction_count, + cs, + cd, + ou + ) end # `:repeat_loop`: body just finished. Run the condition next. @@ -1454,10 +1576,27 @@ defmodule Lua.VM.Dispatcher do {:loop_exit, _, _} = loop_exit | rest_cont ], frames, - instruction_count + instruction_count, + cs, + cd, + ou ) do cps = {:cps_repeat_cond, test_reg, body_bc, cond_bc, outer_code, outer_pc} - dispatch(cond_bc, 1, regs, upvalues, proto, state, [cps, loop_exit | rest_cont], frames, instruction_count) + + dispatch( + cond_bc, + 1, + regs, + upvalues, + proto, + state, + [cps, loop_exit | rest_cont], + frames, + instruction_count, + cs, + cd, + ou + ) end # `:repeat_loop`: condition just finished. test_reg truthy = exit (Lua's @@ -1472,16 +1611,33 @@ defmodule Lua.VM.Dispatcher do {:loop_exit, _, _} = loop_exit | rest_cont ], frames, - instruction_count + instruction_count, + cs, + cd, + ou ) do case :erlang.element(test_reg + 1, regs) do v when v === nil or v === false -> - instruction_count = State.tick!(state, instruction_count) + instruction_count = tick(state, instruction_count, cs, cd) cps = {:cps_repeat_body, test_reg, body_bc, cond_bc, outer_code, outer_pc} - dispatch(body_bc, 1, regs, upvalues, proto, state, [cps, loop_exit | rest_cont], frames, instruction_count) + + dispatch( + body_bc, + 1, + regs, + upvalues, + proto, + state, + [cps, loop_exit | rest_cont], + frames, + instruction_count, + cs, + cd, + ou + ) _ -> - dispatch(outer_code, outer_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count) + dispatch(outer_code, outer_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count, cs, cd, ou) end end @@ -1496,13 +1652,16 @@ defmodule Lua.VM.Dispatcher do {:loop_exit, _, _} = loop_exit | rest_cont ], frames, - instruction_count + instruction_count, + cs, + cd, + ou ) do iter_func = :erlang.element(base + 1, regs) invariant_state = :erlang.element(base + 2, regs) control = :erlang.element(base + 3, regs) - state = %{state | instruction_count: instruction_count} + state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} {results, state} = Executor.dispatcher_call_value(iter_func, [invariant_state, control], proto, state, line) @@ -1511,18 +1670,32 @@ defmodule Lua.VM.Dispatcher do case results do [nil | _] -> - dispatch(outer_code, outer_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count) + dispatch(outer_code, outer_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count, cs, cd, ou) [] -> - dispatch(outer_code, outer_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count) + dispatch(outer_code, outer_pc, regs, upvalues, proto, state, rest_cont, frames, instruction_count, cs, cd, ou) [first | _] -> - instruction_count = State.tick!(state, instruction_count) + instruction_count = tick(state, instruction_count, cs, cd) regs = :erlang.setelement(base + 3, regs, first) regs = assign_iter_results(regs, var_regs, results, 0) first_var_reg = :erlang.element(1, var_regs) - state = Executor.dispatcher_close_open_upvalues_at_or_above(state, first_var_reg) - dispatch(body_bc, 1, regs, upvalues, proto, state, [marker, loop_exit | rest_cont], frames, instruction_count) + ou = close_upv(ou, first_var_reg) + + dispatch( + body_bc, + 1, + regs, + upvalues, + proto, + state, + [marker, loop_exit | rest_cont], + frames, + instruction_count, + cs, + cd, + ou + ) end end @@ -1530,8 +1703,8 @@ defmodule Lua.VM.Dispatcher do # body ran past its last instruction with the loop_exit still on top). # Drop the loop_exit and let the next iteration of finish_body see the # cont below it. - defp finish_body(regs, upvalues, proto, state, [{:loop_exit, _, _} | rest_cont], frames, instruction_count) do - finish_body(regs, upvalues, proto, state, rest_cont, frames, instruction_count) + defp finish_body(regs, upvalues, proto, state, [{:loop_exit, _, _} | rest_cont], frames, instruction_count, cs, cd, ou) do + finish_body(regs, upvalues, proto, state, rest_cont, frames, instruction_count, cs, cd, ou) end # Body exhausted with no continuation: prototype ran off the end. Lua @@ -1539,8 +1712,8 @@ defmodule Lua.VM.Dispatcher do # values when control falls off the end, not a single `nil` — the # caller's `result_count` decides how that's projected (nil for a # single-value site, empty slot for a multi-return one). - defp finish_body(_regs, _upvalues, _proto, state, [], frames, instruction_count) do - return_multi([], state, frames, instruction_count) + defp finish_body(_regs, _upvalues, _proto, state, [], frames, instruction_count, cs, cd, ou) do + return_multi([], state, frames, instruction_count, cs, cd, ou) end # ── Return propagation through frames ─────────────────────────────────── @@ -1559,29 +1732,33 @@ defmodule Lua.VM.Dispatcher do # {:multi, B, -2} → expand all into regs[B..], set multi_return_count. # {:multi, B, n>1} → write n results into regs[B..], pad nil. - defp return_one(value, state, [], instruction_count) do - # Top of this dispatcher sub-evaluation: stamp the tally back into the - # state so a caller in the other engine can resume the same budget. - {[value], %{state | instruction_count: instruction_count}} + defp return_one(value, state, [], instruction_count, cs, cd, _ou) do + # Top of this dispatcher sub-evaluation: stamp the loop-carried control + # parameters back into the state so a caller in the other engine + # resumes the same budget and the same call stack. A bridge out of the + # loop may have left a deeper stack stamped in the struct; `cs`/`cd` + # have unwound back to their entry values, so this restores them. + {[value], %{state | instruction_count: instruction_count, call_stack: cs, call_depth: cd}} end - defp return_one(value, state, [frame | rest_frames], instruction_count) do - {code, pc, regs, upvalues, proto, cont, dest, saved_open} = frame + defp return_one(value, state, [frame | rest_frames], instruction_count, cs, cd, _ou) do + {code, pc, regs, upvalues, proto, cont, dest, ou} = frame # Every dispatcher frame corresponds to a Lua-level call that pushed a # call_stack entry. Pop it on the way out — the interpreter's # `do_frame_return/6` does the same at executor.ex:1767. - state = %{state | open_upvalues: saved_open, call_stack: tl(state.call_stack), call_depth: state.call_depth - 1} + cs = tl(cs) + cd = cd - 1 case dest do :discard -> - dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count, cs, cd, ou) n when is_integer(n) -> regs = :erlang.setelement(n + 1, regs, value) - dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count, cs, cd, ou) {:multi, _, -1} -> - return_one(value, state, rest_frames, instruction_count) + return_one(value, state, rest_frames, instruction_count, cs, cd, ou) {:multi, base, -2} -> # The expansion dest may sit past the statically reserved register @@ -1591,31 +1768,32 @@ defmodule Lua.VM.Dispatcher do regs = grow_regs(regs, base + 1) regs = :erlang.setelement(base + 1, regs, value) state = %{state | multi_return_count: 1} - dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count, cs, cd, ou) {:multi, base, n} when is_integer(n) and n > 1 -> regs = grow_regs(regs, base + n) regs = :erlang.setelement(base + 1, regs, value) regs = pad_nils(regs, base + 1, n - 1) - dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count, cs, cd, ou) end end # List-return path for `:return_multi`, `:return_collect`, # `:return_proto_varargs`, and the non-compiled-callee branch of - # `:call_multi`. Mirrors `return_one/3`'s frame-variant handling. + # `:call_multi`. Mirrors `return_one/7`'s frame-variant handling. - defp return_multi(results, state, [], instruction_count) do - {results, %{state | instruction_count: instruction_count}} + defp return_multi(results, state, [], instruction_count, cs, cd, _ou) do + {results, %{state | instruction_count: instruction_count, call_stack: cs, call_depth: cd}} end - defp return_multi(results, state, [frame | rest_frames], instruction_count) do - {code, pc, regs, upvalues, proto, cont, dest, saved_open} = frame - state = %{state | open_upvalues: saved_open, call_stack: tl(state.call_stack), call_depth: state.call_depth - 1} + defp return_multi(results, state, [frame | rest_frames], instruction_count, cs, cd, _ou) do + {code, pc, regs, upvalues, proto, cont, dest, ou} = frame + cs = tl(cs) + cd = cd - 1 case dest do :discard -> - dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count, cs, cd, ou) n when is_integer(n) -> v = @@ -1625,24 +1803,68 @@ defmodule Lua.VM.Dispatcher do end regs = :erlang.setelement(n + 1, regs, v) - dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count, cs, cd, ou) {:multi, _, -1} -> - return_multi(results, state, rest_frames, instruction_count) + return_multi(results, state, rest_frames, instruction_count, cs, cd, ou) {:multi, base, -2} -> regs = write_results(regs, base, results) state = %{state | multi_return_count: length(results)} - dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count, cs, cd, ou) {:multi, base, n} when is_integer(n) and n > 1 -> regs = write_results_n(regs, base, results, n) - dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, rest_frames, instruction_count, cs, cd, ou) end end # ── Helpers ───────────────────────────────────────────────────────────── + # Writes the loop-carried control fields back into the struct. Called + # wherever execution leaves the dispatch loop — bridges into `Executor`, + # native callbacks, the loop's own raise sites — so anything that reads + # `state.call_stack` / `state.call_depth` out there sees the live values. + # + # The first clause makes a repeat crossing free: a loop body that bridges + # every iteration without calling anything (a `t[k] = v` write, an + # `__index` read) leaves the struct already carrying this frame's stack, + # so only the first crossing rebuilds it. + @compile {:inline, sync: 3} + defp sync(%{call_stack: call_stack, call_depth: call_depth} = state, call_stack, call_depth), do: state + + defp sync(state, call_stack, call_depth) do + %{state | call_stack: call_stack, call_depth: call_depth} + end + + # Hot-path guards. Each of these fires on every call or loop iteration + # only to discover it has nothing to do under the default configuration. + # Inlining the guard here keeps the no-op case to a single function-head + # match instead of a cross-module call into `Executor`/`State`; the + # non-trivial case still routes to the canonical implementation (paying + # one `sync/3` on the way) so the behaviour and its error shapes live in + # one place. + @compile {:inline, close_upv: 2, tick: 4, ckdepth: 3} + + defp close_upv(open_upvalues, _threshold) when map_size(open_upvalues) == 0, do: open_upvalues + + defp close_upv(open_upvalues, threshold) do + :maps.filter(fn reg, _cell -> reg < threshold end, open_upvalues) + end + + defp tick(%{max_instructions: :infinity}, instruction_count, _call_stack, _call_depth), do: instruction_count + + defp tick(state, instruction_count, call_stack, call_depth) do + State.tick!(sync(state, call_stack, call_depth), instruction_count) + end + + defp ckdepth(%{max_call_depth: :infinity}, _call_stack, _call_depth), do: :ok + defp ckdepth(%{max_call_depth: max}, _call_stack, call_depth) when call_depth < max, do: :ok + + defp ckdepth(state, call_stack, call_depth) do + State.check_call_depth!(sync(state, call_stack, call_depth)) + end + defp clear_nils(regs, _dest, 0), do: regs defp clear_nils(regs, dest, n) do @@ -1735,47 +1957,42 @@ defmodule Lua.VM.Dispatcher do # Vararg setup at the call boundary. Mirrors the executor's per-call # behaviour: when calling a vararg function, regs[param_count..total_args) # become the varargs list carried on `%{proto | varargs: ...}`. + # + # Only reached when the callee is actually vararg — call sites test + # `callee_proto.is_vararg` inline so a non-vararg call pays one field + # read rather than a call into a function that rebuilds nothing. The + # vararg case still copies the whole `%Prototype{}`. defp setup_vararg_proto(callee_proto, src_regs, src_off, total_args) do - if callee_proto.is_vararg do - param_count = callee_proto.param_count - vararg_count = max(total_args - param_count, 0) - varargs = collect_args(src_regs, src_off + param_count, vararg_count) - %{callee_proto | varargs: varargs} - else - callee_proto - end + param_count = callee_proto.param_count + vararg_count = max(total_args - param_count, 0) + varargs = collect_args(src_regs, src_off + param_count, vararg_count) + %{callee_proto | varargs: varargs} end # Closure upvalue capture. `:parent_local` allocates (or reuses) an # open-upvalue cell so multiple closures over the same register share # mutation. `:parent_upvalue` forwards our own cell ref to the child. - defp build_upvalues([], _regs, _upvalues, state, acc), do: {acc, state} + defp build_upvalues([], _regs, _upvalues, state, open_upvalues, acc), do: {acc, state, open_upvalues} - defp build_upvalues([{:parent_local, reg, _name} | rest], regs, upvalues, state, acc) do - {cell_ref, state} = - case Map.get(state.open_upvalues, reg) do + defp build_upvalues([{:parent_local, reg, _name} | rest], regs, upvalues, state, open_upvalues, acc) do + {cell_ref, state, open_upvalues} = + case :maps.get(reg, open_upvalues, nil) do nil -> new_ref = make_ref() value = :erlang.element(reg + 1, regs) - - new_state = %{ - state - | upvalue_cells: Map.put(state.upvalue_cells, new_ref, value), - open_upvalues: Map.put(state.open_upvalues, reg, new_ref) - } - - {new_ref, new_state} + state = %{state | upvalue_cells: Map.put(state.upvalue_cells, new_ref, value)} + {new_ref, state, :maps.put(reg, new_ref, open_upvalues)} existing_cell -> - {existing_cell, state} + {existing_cell, state, open_upvalues} end - build_upvalues(rest, regs, upvalues, state, [cell_ref | acc]) + build_upvalues(rest, regs, upvalues, state, open_upvalues, [cell_ref | acc]) end - defp build_upvalues([{:parent_upvalue, index, _name} | rest], regs, upvalues, state, acc) do + defp build_upvalues([{:parent_upvalue, index, _name} | rest], regs, upvalues, state, open_upvalues, acc) do cell_ref = :erlang.element(index + 1, upvalues) - build_upvalues(rest, regs, upvalues, state, [cell_ref | acc]) + build_upvalues(rest, regs, upvalues, state, open_upvalues, [cell_ref | acc]) end # `:vararg` count=0 form: write every vararg into regs[base..] @@ -1891,12 +2108,31 @@ defmodule Lua.VM.Dispatcher do state, cont, frames, - instruction_count + instruction_count, + cs, + cd, + ou ) do - dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end - defp apply_multi_call_result(1, base, results, code, pc, regs, upvalues, proto, state, cont, frames, instruction_count) do + defp apply_multi_call_result( + 1, + base, + results, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) do first = case results do [v | _] -> v @@ -1904,7 +2140,7 @@ defmodule Lua.VM.Dispatcher do end regs = :erlang.setelement(base + 1, regs, first) - dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end defp apply_multi_call_result( @@ -1919,21 +2155,56 @@ defmodule Lua.VM.Dispatcher do state, _cont, frames, - instruction_count + instruction_count, + cs, + cd, + ou ) do - return_multi(results, state, frames, instruction_count) + return_multi(results, state, frames, instruction_count, cs, cd, ou) end - defp apply_multi_call_result(-2, base, results, code, pc, regs, upvalues, proto, state, cont, frames, instruction_count) do + defp apply_multi_call_result( + -2, + base, + results, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) do regs = write_results(regs, base, results) state = %{state | multi_return_count: length(results)} - dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end - defp apply_multi_call_result(n, base, results, code, pc, regs, upvalues, proto, state, cont, frames, instruction_count) + defp apply_multi_call_result( + n, + base, + results, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) when is_integer(n) and n > 1 do regs = write_results_n(regs, base, results, n) - dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count) + dispatch(code, pc, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end # Lazy regs-tuple growth. Used at the points where multi-return diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 71536bcf..4aa879f1 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -187,9 +187,10 @@ defmodule Lua.VM.Executor do def call_function({:compiled_closure, callee_proto, callee_upvalues}, args, state) do # Compiled callees route through the dispatcher. The dispatcher manages - # its own register file setup, vararg routing, and open-upvalue save/ - # restore — `Dispatcher.execute/4` mirrors the semantics of this - # function for the bytecode-encoded path. + # its own register file setup and vararg routing, and isolates open + # upvalues by threading them as a dispatch-loop parameter seeded empty + # at this boundary — `Dispatcher.execute/4` mirrors the semantics of + # this function for the bytecode-encoded path. Dispatcher.execute(callee_proto, args, callee_upvalues, state) end @@ -498,12 +499,6 @@ defmodule Lua.VM.Executor do coerce_numeric_for_controls(init, limit, step, state) end - @doc false - @spec dispatcher_close_open_upvalues_at_or_above(State.t(), non_neg_integer()) :: State.t() - def dispatcher_close_open_upvalues_at_or_above(state, threshold) do - close_open_upvalues_at_or_above(state, threshold) - end - # ── Dispatcher bridges: B5c-v2 ────────────────────────────────────────── # # `:self` method resolution. Wraps `index_value/6` so __index metamethod @@ -579,14 +574,24 @@ defmodule Lua.VM.Executor do # Publish the source line baked into the call opcode by the encoder so # raise sites reading `current_position/0` — `error()`'s §6.1 prefix, # stdlib bad-argument raises — attribute to the right call site. - # Restored after the call so nested invocations don't leak. - prev_pos = Process.get(@position_key, @unset) - set_position(line, proto.source) + # + # Every compiled-mode stdlib call lands here, so the bridge talks to the + # process dictionary through the BIFs instead of the `Process.*` + # wrappers. The restore must run on raise too — not every entry into + # this bridge sits under `execute/5`'s `after restore_position/1` net + # (e.g. `Lua.call_function/3` invoking a compiled closure), so a + # raise-skipped restore would leak a stale position into later, + # unrelated evaluations' diagnostics. + prev_pos = :erlang.get(@position_key) + :erlang.put(@position_key, {line, proto.source}) try do call_function(nf, args, state) after - restore_position(prev_pos) + case prev_pos do + :undefined -> :erlang.erase(@position_key) + pos -> :erlang.put(@position_key, pos) + end end end @@ -622,18 +627,12 @@ defmodule Lua.VM.Executor do end end - @doc false - @spec dispatcher_call_info(term(), term(), non_neg_integer()) :: call_frame() - def dispatcher_call_info(proto, name_hint, line) do - # Hot path: every Lua call pushes one of these. Keep it a flat 3-tuple - # carrying the raw `name_hint` tag, and defer the `hint_name`/ - # `hint_namewhat` decoding to the cold readers (`frame_name/1`, - # `frame_namewhat/1`) that only run during traceback formatting and - # `debug.getinfo`. A tuple is ~4 words vs ~7 for the old 4-key map, and - # skips two function calls per call frame. - {proto.source, line, name_hint} - end - + # Every Lua call pushes one call frame, so the runtime shape is a flat + # 3-tuple `{source, line, name_hint}` carrying the raw `name_hint` tag; + # the `hint_name` / `hint_namewhat` decoding is deferred to the cold + # readers (`frame_name/1`, `frame_namewhat/1`) that only run during + # traceback formatting and `debug.getinfo`. A tuple is ~4 words against + # ~7 for a 4-key map, and skips two function calls per frame. @typedoc false @type call_frame() :: {term(), non_neg_integer(), term()} | map() @@ -949,7 +948,7 @@ defmodule Lua.VM.Executor do instruction_count ) do cell_ref = elem(upvalues, index) - value = Map.get(state.upvalue_cells, cell_ref) + value = :maps.get(cell_ref, state.upvalue_cells, nil) regs = put_elem(regs, dest, value) do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) end @@ -991,9 +990,9 @@ defmodule Lua.VM.Executor do instruction_count ) do value = - case Map.get(state.open_upvalues, reg) do + case :maps.get(reg, state.open_upvalues, nil) do nil -> elem(regs, reg) - cell_ref -> Map.get(state.upvalue_cells, cell_ref) + cell_ref -> :maps.get(cell_ref, state.upvalue_cells, nil) end regs = put_elem(regs, dest, value) @@ -1043,7 +1042,7 @@ defmodule Lua.VM.Executor do instruction_count ) do state = - case Map.get(state.open_upvalues, reg) do + case :maps.get(reg, state.open_upvalues, nil) do nil -> state @@ -1287,7 +1286,7 @@ defmodule Lua.VM.Executor do {captured_upvalues_reversed, state} = Enum.reduce(nested_proto.upvalue_descriptors, {[], state}, fn {:parent_local, reg, _name}, {cells, state} -> - case Map.get(state.open_upvalues, reg) do + case :maps.get(reg, state.open_upvalues, nil) do nil -> cell_ref = make_ref() value = elem(regs, reg) @@ -3751,7 +3750,7 @@ defmodule Lua.VM.Executor do # environment lives in upvalue slot 0 when present (a chunk loaded via # `load(..., env)`), otherwise default to the global table `_G`. defp load_env_value(upvalues, state) when tuple_size(upvalues) > 0 do - Map.get(state.upvalue_cells, elem(upvalues, 0)) + :maps.get(elem(upvalues, 0), state.upvalue_cells, nil) end defp load_env_value(_upvalues, state), do: State.g_ref(state) diff --git a/lib/lua/vm/state.ex b/lib/lua/vm/state.ex index 248437f6..5a70773a 100644 --- a/lib/lua/vm/state.ex +++ b/lib/lua/vm/state.ex @@ -7,6 +7,15 @@ defmodule Lua.VM.State do alias Lua.VM.RuntimeError alias Lua.VM.Table + # `call_stack`, `call_depth` and `open_upvalues` are control state, not + # heap state. The interpreter keeps them here; the dispatcher threads + # them as loop parameters (the same discipline as `instruction_count` + # below) so an in-mode Lua call allocates no struct at all, and writes + # them back into these fields whenever execution leaves the dispatch + # loop — an `Executor` bridge, a native callback, a raise site. Anything + # outside the loop that reads them (`debug.getinfo`, `error(msg, level)`, + # traceback formatting, `check_call_depth!/1`) therefore still sees live + # values. defstruct call_stack: [], # Call depth tracked as an O(1) counter that moves in lockstep # with `call_stack` — `length(call_stack)` would be O(depth) per diff --git a/lib/lua/vm/table.ex b/lib/lua/vm/table.ex index 5116c5a1..d644f70e 100644 --- a/lib/lua/vm/table.ex +++ b/lib/lua/vm/table.ex @@ -84,6 +84,10 @@ defmodule Lua.VM.Table do encode) get split storage with no extra effort. """ @spec from_data(map()) :: t() + # Every `{}` constructor allocates through here; an empty map has nothing + # to sort and nothing to fold, so skip straight to the empty struct. + def from_data(data) when map_size(data) == 0, do: %__MODULE__{} + def from_data(data) when is_map(data) do split_from_map(%__MODULE__{}, data) end @@ -198,6 +202,27 @@ defmodule Lua.VM.Table do end end + # No dead keys means the resurrection branch cannot apply, so the write is + # either an in-place overwrite or a fresh append. This is the shape every + # `t.field = v` / `t[k] = v` on a table that has never had a key removed + # takes, so it skips the `dead` probe entirely. + defp insert_hash(%__MODULE__{data: data, order_tail: order_tail, dead: dead} = table, key, value) + when map_size(dead) == 0 do + table = if positive_int?(key), do: %{table | border: :dirty}, else: table + + if :maps.is_key(key, data) do + %{table | data: :maps.put(key, value, data)} + else + %{ + table + | data: :maps.put(key, value, data), + order_tail: [key | order_tail], + order_index: nil, + order_arr: nil + } + end + end + defp insert_hash(%__MODULE__{data: data, order: order, order_tail: order_tail, dead: dead} = table, key, value) do # A positive-integer hash key sits adjacent to the probe range and can # change #t once a contiguous fill reaches it, so invalidate the cache. @@ -205,7 +230,7 @@ defmodule Lua.VM.Table do table = if positive_int?(key), do: %{table | border: :dirty}, else: table cond do - Map.has_key?(dead, key) -> + :maps.is_key(key, dead) -> merged_order = order ++ Enum.reverse(order_tail) new_order = Enum.reject(merged_order, &(&1 === key)) diff --git a/test/lua/call_function_error_value_test.exs b/test/lua/call_function_error_value_test.exs index 2a8271a8..33a584b2 100644 --- a/test/lua/call_function_error_value_test.exs +++ b/test/lua/call_function_error_value_test.exs @@ -285,4 +285,60 @@ defmodule Lua.CallFunctionErrorValueTest do assert Lua.format_exception(error) =~ "regression.lua:2:" end end + + describe "call_function/3 position hygiene across evaluations" do + test "a raise inside a compiled closure's stdlib call does not leak its position" do + # The dispatcher publishes {line, source} around every native stdlib + # call so raise sites attribute to the right call site. That position + # must be restored even when the native call raises: `call_function/3` + # has no outer save/restore net, so a leak here surfaces in a *later*, + # unrelated evaluation whose own error carries no line info. + {[ref], lua} = + Lua.eval!( + Lua.new(), + """ + local function boom() + + + error("kaboom") + end + return boom + """, + decode: false, + source: "first.lua" + ) + + # Pin that `boom` bytecode-compiled: the leak under test lives in the + # dispatcher's native-call bridge, not the interpreter's. + assert {:compiled_closure, _, _} = ref.ref + + assert {:error, %RuntimeException{kind: :error}, %Lua{} = lua} = + Lua.call_function(lua, ref, []) + + error = + assert_raise RuntimeException, fn -> + Lua.eval!( + lua, + """ + local function g() + local t = nil + return t.x + end + return g() + """, + source: "second.lua" + ) + end + + # Indexing nil inside a compiled body raises without line info, and + # the exception falls back to `current_position/0`. A leaked position + # would attribute this error to first.lua:4 instead of no line. + assert %TypeError{error_kind: :index_non_table, line: nil, source: "second.lua"} = + error.original + + message = Lua.format_exception(error) + refute message =~ "first.lua" + refute message =~ ":4:" + end + end end diff --git a/test/lua/vm/dispatcher_test.exs b/test/lua/vm/dispatcher_test.exs index 79507ed2..038b2c1f 100644 --- a/test/lua/vm/dispatcher_test.exs +++ b/test/lua/vm/dispatcher_test.exs @@ -1072,6 +1072,51 @@ defmodule Lua.VM.DispatcherTest do assert results == [18] end + test ":set_field — nil assignment deletes the key" do + {proto, results} = + run!(""" + function f() + local t = { x = 1, y = 2 } + t.x = nil + local count = 0 + for _ in pairs(t) do count = count + 1 end + return t.x, t.y, count + end + return f() + """) + + assert first_sub(proto).bytecode + assert results == [nil, 2, 1] + end + + test ":set_field — __newindex function handler (slow-path bridge)" do + {proto, results} = + run!(""" + function f(t, v) t.x = v end + local log = {} + local t = setmetatable({}, {__newindex = function(_, k, v) log[k] = v + 1 end}) + f(t, 41) + return rawget(t, "x"), log.x + """) + + assert first_sub(proto).bytecode + assert results == [nil, 42] + end + + test ":set_field — __newindex table handler (slow-path bridge)" do + {proto, results} = + run!(""" + function f(t, v) t.x = v end + local backing = {} + local t = setmetatable({}, {__newindex = backing}) + f(t, 7) + return rawget(t, "x"), backing.x + """) + + assert first_sub(proto).bytecode + assert results == [nil, 7] + end + test ":get_table — integer-key fast path" do {proto, results} = run!(""" diff --git a/test/lua/vm/require_open_upvalue_test.exs b/test/lua/vm/require_open_upvalue_test.exs index f92ef30d..b328be6a 100644 --- a/test/lua/vm/require_open_upvalue_test.exs +++ b/test/lua/vm/require_open_upvalue_test.exs @@ -88,4 +88,44 @@ defmodule Lua.VM.RequireOpenUpvalueTest do assert {["registered:ok"], _} = eval_with_path(code, tmp_dir) end + + test "compiled caller's open upvalues survive a require in its body", %{tmp_dir: tmp_dir} do + # Same invariant, entered from the bytecode dispatcher rather than the + # interpreter. The dispatcher carries its open upvalues as a loop + # parameter, so a nested evaluation cannot reach them; this pins that + # a `require` between capturing a local and reading it back through + # the cell still yields the caller's value. + File.write!(Path.join(tmp_dir, "inner.lua"), """ + local inner_local = "inner_value" + + local function captures_it() + return inner_local + end + + return { tag = "inner", fn = captures_it } + """) + + # The body runs on the dispatcher: `outer/0` is reached by a call, and + # its own body creates the closure over `mine`. + code = ~S""" + local function outer() + local mine = "outer_value" + local function read_mine() return mine end + local m = require("inner") + mine = "reassigned" + return read_mine(), m.tag, m.fn() + end + + return outer() + """ + + # Pin that `outer` actually bytecode-compiles, so this exercises the + # dispatcher rather than silently degrading into an interpreter test. + {:ok, ast} = Lua.Parser.parse(code) + {:ok, proto} = Lua.Compiler.compile(ast, source: "test.lua") + assert %Lua.Compiler.Prototype{prototypes: [outer_proto | _]} = proto + assert outer_proto.bytecode + + assert {["reassigned", "inner", "inner_value"], _} = eval_with_path(code, tmp_dir) + end end From b8d59aefaa78ab327f3895d02e2feed62a972c8f Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 15:52:16 -0400 Subject: [PATCH 06/13] runtime: memoize the default VM template; build stdlib tables one-shot (#398) --- CHANGELOG.md | 12 ++ lib/lua.ex | 45 ++++- lib/lua/vm/bootstrap.ex | 156 +++++++++++++++ lib/lua/vm/state.ex | 13 ++ lib/lua/vm/stdlib.ex | 135 ++++++------- lib/lua/vm/table.ex | 30 +++ mix.exs | 1 + test/lua/vm/bootstrap_test.exs | 286 +++++++++++++++++++++++++++ test/lua/vm/table_iteration_test.exs | 58 ++++++ 9 files changed, 662 insertions(+), 74 deletions(-) create mode 100644 lib/lua/vm/bootstrap.ex create mode 100644 test/lua/vm/bootstrap_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7def79e6..a8432ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,18 @@ Everything else — the default sandbox, `_G`/`_ENV` semantics, metatables, and the standard-library surface — is compatible. The full breaking-change list is in the [`1.0.0-rc.0`](#100-rc0---2026-05-26) entry below. +## [Unreleased] + +### Changed +- `Lua.new/1` is ~100x faster (roughly 40µs down to 0.4µs) for the default and + fully-custom-sandbox configurations. Installing the standard library is pure + and deterministic, so the boot-time VM template is now built once per node + and memoized in `:persistent_term`; every later `Lua.new/1` starts from the + shared template copy-on-write. In `:interactive` mode (dev, IEx, tests) the + cache self-invalidates when the modules that built it are recompiled; hosts + that hot-load new code in `:embedded` mode (releases) can force a rebuild + with `Lua.VM.Bootstrap.reset/0` (#398). + ## [1.0.1] - 2026-07-16 ### Fixed diff --git a/lib/lua.ex b/lib/lua.ex index 0b47bcdb..e0553662 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -8,6 +8,7 @@ defmodule Lua do alias Lua.Util alias Lua.VM.AssertionError + alias Lua.VM.Bootstrap alias Lua.VM.Display alias Lua.VM.Executor alias Lua.VM.InternalError @@ -142,23 +143,51 @@ defmodule Lua do max_instructions: :infinity ) + sandboxed = Keyword.fetch!(opts, :sandboxed) exclude = Keyword.fetch!(opts, :exclude) debug = Keyword.fetch!(opts, :debug) max_call_depth = validate_max_call_depth!(Keyword.fetch!(opts, :max_call_depth)) max_string_bytes = validate_max_string_bytes!(Keyword.fetch!(opts, :max_string_bytes)) max_instructions = validate_max_instructions!(Keyword.fetch!(opts, :max_instructions)) - state = %{ - Lua.VM.Stdlib.install(State.new()) - | max_call_depth: max_call_depth, - max_string_bytes: max_string_bytes, - max_instructions: max_instructions + lua = template(sandboxed, exclude) + + %{ + lua + | debug: debug, + state: %{ + lua.state + | max_call_depth: max_call_depth, + max_string_bytes: max_string_bytes, + max_instructions: max_instructions + } } + end + + # A freshly built VM is a pure function of the sandbox options — the limits + # and `:debug` are patched onto the finished struct above, and every later + # mutation is copy-on-write — so the two shapes that dominate real use are + # memoized rather than rebuilt. Installing the standard library was four + # fifths of what `new/1` cost, and it produces the same value every time. + # + # The default arguments hit the fully-sandboxed template. Anything else pays + # only for its own sandbox pass, over the shared pre-sandbox install. + defp template(@default_sandbox, []) do + Bootstrap.fetch({__MODULE__, :default_lua}, fn -> sandbox_all(@default_sandbox, []) end) + end - opts - |> Keyword.fetch!(:sandboxed) + defp template(sandboxed, exclude), do: sandbox_all(sandboxed, exclude) + + defp sandbox_all(sandboxed, exclude) do + lua = %__MODULE__{state: base_state()} + + sandboxed |> Enum.reject(fn path -> path in exclude end) - |> Enum.reduce(%__MODULE__{state: state, debug: debug}, &sandbox(&2, &1)) + |> Enum.reduce(lua, &sandbox(&2, &1)) + end + + defp base_state do + Bootstrap.fetch({__MODULE__, :base_state}, fn -> Lua.VM.Stdlib.install(State.new()) end) end defp validate_max_call_depth!(:infinity), do: :infinity diff --git a/lib/lua/vm/bootstrap.ex b/lib/lua/vm/bootstrap.ex new file mode 100644 index 00000000..27bceb22 --- /dev/null +++ b/lib/lua/vm/bootstrap.ex @@ -0,0 +1,156 @@ +defmodule Lua.VM.Bootstrap do + @moduledoc """ + Memoizes boot-time VM templates in `:persistent_term`. + + Building a VM means installing the whole standard library — allocating a + dozen tables and capturing a hundred-odd native-function closures — and the + result is a pure, deterministic value: two builds from the same code produce + byte-identical terms. Nothing in it is per-instance (no refs, no pids, no + timestamps), and every downstream mutation is copy-on-write, so a single + template can seed unlimited independent VMs. + + `fetch/2` builds a template on first use and stores it under `key`. Later + calls return the stored term for the cost of one `:persistent_term.get/2`. + There is no mutual exclusion: callers racing a cold start each build and + `put`, and every `put` over a live key forces a global scan of every + process. Builders are pure, so the racing puts all store equal terms — the + race costs redundant work on first use, never an inconsistent result — and + once warm the key is effectively write-once, refreshed only by the + staleness path below. + + ## Staleness + + A template holds closures pointing into the modules that built it. Reload + those modules twice and the old code is purged, turning every captured + closure into a `badfun`. That only happens while iterating in a shell, so the + guard is priced for it: at build time, in `:interactive` mode, every module + reachable through a captured fun — plus the template-shaping modules whose + struct layouts and defaults are baked into the term without leaving a + closure in it — is fingerprinted by its `module_info(:md5)`, and a `fetch/2` + hit re-checks those hashes before handing the term back. A mismatch (or a + fingerprinted module that can no longer be loaded) rebuilds. + + Under `:embedded` mode (releases) code never reloads on its own, so no + fingerprint is taken and a hit is the bare `get`. A host that loads new + Lua-implementation code anyway — `:code.load_file/1`, a remote-console + recompile, a hot upgrade — must call `reset/0` afterwards, or `fetch/2` + keeps serving templates built against the replaced code. + """ + + @typep fingerprint :: :static | [{module(), binary() | nil}] + + # Modules whose code shapes a built template without necessarily leaving a + # captured closure inside it: struct layouts, table splitting, and default + # limits are baked into the stored term at build time, so reloading any of + # these must invalidate the template even though no fun in it points there. + @template_modules [__MODULE__, Lua, Lua.VM.Limits, Lua.VM.State, Lua.VM.Stdlib, Lua.VM.Table] + + # Every key `fetch/2` may be called with, and therefore everything `reset/0` + # has to erase. Fixed at compile time on purpose: the store stays a known, + # bounded size, and `reset/0` needs no bookkeeping that a concurrent cold + # start could lose. + @memoized_keys [{Lua, :default_lua}, {Lua, :base_state}] + + # Earlier builds tracked the keys here at runtime. Erased by `reset/0` so a + # release upgraded from one of those builds does not keep the entry forever. + @obsolete_registry {__MODULE__, :keys} + + @doc """ + Returns the memoized template for `key`, building it with `builder` on a miss. + + `key` must be one of the compile-time literals in `memoized_keys/0`. That is + what bounds the store: `:persistent_term` has no eviction, so a key derived + from user input would grow it without limit, and `reset/0` would not know to + erase it. + + `builder` must be pure: it is invoked on a cold start (possibly more than + once under concurrent first use, see the moduledoc) and again after a module + reload in `:interactive` mode; every caller shares the stored result. + """ + @spec fetch(term(), (-> term())) :: term() + def fetch(key, builder) when is_function(builder, 0) do + case :persistent_term.get(key, nil) do + nil -> + build_and_store(key, builder) + + {fingerprint, term} -> + if current?(fingerprint), do: term, else: build_and_store(key, builder) + end + end + + @doc """ + The keys `fetch/2` accepts, and the exact set `reset/0` erases. + """ + @spec memoized_keys() :: [term()] + def memoized_keys, do: @memoized_keys + + @doc """ + Erases every memoized template, so the next `fetch/2` of each key rebuilds. + + Required after explicitly loading new Lua-implementation code in `:embedded` + mode (releases), where no staleness fingerprint exists — see the moduledoc. + Safe to call at any time in any mode; concurrent `fetch/2` callers simply + rebuild. + """ + @spec reset() :: :ok + def reset do + Enum.each([@obsolete_registry | @memoized_keys], &:persistent_term.erase/1) + :ok + end + + defp build_and_store(key, builder) do + term = builder.() + :persistent_term.put(key, {fingerprint(term), term}) + term + end + + defp current?(:static), do: true + + defp current?(fingerprint) do + Enum.all?(fingerprint, fn {module, md5} -> loaded_md5(module) === md5 end) + end + + # A fingerprinted module that has since been deleted has no md5 to compare; + # returning nil makes the comparison fail, so the caller rebuilds instead of + # raising `UndefinedFunctionError` mid-fetch. + defp loaded_md5(module) do + case :code.ensure_loaded(module) do + {:module, ^module} -> module.module_info(:md5) + {:error, _reason} -> nil + end + end + + @spec fingerprint(term()) :: fingerprint() + defp fingerprint(term) do + case :code.get_mode() do + :interactive -> + term + |> fun_modules() + |> Enum.concat(@template_modules) + |> Enum.uniq() + |> Enum.map(fn module -> {module, loaded_md5(module)} end) + + _embedded -> + :static + end + end + + # Every module reachable through a closure stored anywhere in the term. Runs + # once per build, so a plain structural walk is fine. + defp fun_modules(fun) when is_function(fun) do + {:module, module} = :erlang.fun_info(fun, :module) + [module] + end + + defp fun_modules(list) when is_list(list), do: Enum.flat_map(list, &fun_modules/1) + + # `:maps.to_list/1` rather than `Enum`: structs are maps here too, and they + # are not enumerable. + defp fun_modules(map) when is_map(map), do: map |> :maps.to_list() |> fun_modules() + + defp fun_modules(tuple) when is_tuple(tuple) do + tuple |> Tuple.to_list() |> fun_modules() + end + + defp fun_modules(_other), do: [] +end diff --git a/lib/lua/vm/state.ex b/lib/lua/vm/state.ex index 5a70773a..da5e62e9 100644 --- a/lib/lua/vm/state.ex +++ b/lib/lua/vm/state.ex @@ -221,6 +221,19 @@ defmodule Lua.VM.State do update_table(state, g_ref, fn table -> Table.put(table, name, value) end) end + @doc """ + Sets a batch of global variables, left to right, in one `_G` update. + + Equivalent to folding `set_global/3` over `pairs`, but the surrounding + `%State{}` and its `tables` map are rebuilt once instead of once per name — + which is what installing the standard library's several dozen globals used + to cost. + """ + @spec set_globals(t(), [{binary(), term()}]) :: t() + def set_globals(%__MODULE__{g_ref: g_ref} = state, pairs) when is_list(pairs) and not is_nil(g_ref) do + update_table(state, g_ref, fn table -> Table.put_many(table, pairs) end) + end + @doc """ Reads a global variable from the VM state. Returns `nil` if unset. """ diff --git a/lib/lua/vm/stdlib.ex b/lib/lua/vm/stdlib.ex index a1caca26..69676929 100644 --- a/lib/lua/vm/stdlib.ex +++ b/lib/lua/vm/stdlib.ex @@ -18,54 +18,74 @@ defmodule Lua.VM.Stdlib do alias Lua.VM.TypeError alias Lua.VM.Value + # Installed in this order: it is the order `pairs(_G)` and + # `pairs(package.loaded)` report the library tables in. + @libraries [ + Lua.VM.Stdlib.String, + Lua.VM.Stdlib.Math, + Lua.VM.Stdlib.Table, + Lua.VM.Stdlib.Utf8, + Lua.VM.Stdlib.Os, + Lua.VM.Stdlib.Debug + ] + @doc """ Installs the standard library into the given VM state. """ @spec install(State.t()) :: State.t() def install(%State{} = state) do - state - |> State.register_function("type", &lua_type/2) - |> State.register_function("tostring", &lua_tostring/2) - |> State.register_function("tonumber", &lua_tonumber/2) - |> State.register_function("print", &lua_print/2) - |> State.register_function("error", &lua_error/2) - |> State.register_function("assert", &lua_assert/2) - |> State.register_function("pcall", &lua_pcall/2) - |> State.register_function("xpcall", &lua_xpcall/2) - |> State.register_function("rawget", &lua_rawget/2) - |> State.register_function("rawset", &lua_rawset/2) - |> State.register_function("rawlen", &lua_rawlen/2) - |> State.register_function("rawequal", &lua_rawequal/2) - |> State.register_function("next", &lua_next/2) - |> State.register_function("pairs", &lua_pairs/2) - |> State.register_function("ipairs", &lua_ipairs/2) - |> State.register_function("setmetatable", &lua_setmetatable/2) - |> State.register_function("getmetatable", &lua_getmetatable/2) - |> State.register_function("select", &lua_select/2) - |> State.register_function("load", &lua_load/2) - |> State.register_function("require", &lua_require/2) - |> State.register_function("collectgarbage", &lua_collectgarbage/2) - |> State.register_function("dofile", &lua_dofile/2) - |> State.set_global("_VERSION", "Lua 5.3") - |> install_package_table() - |> install_library(Lua.VM.Stdlib.String) - |> install_library(Lua.VM.Stdlib.Math) - |> install_library(Lua.VM.Stdlib.Table) - |> install_library(Lua.VM.Stdlib.Utf8) - |> install_library(Lua.VM.Stdlib.Os) - |> install_library(Lua.VM.Stdlib.Debug) - |> preload_stdlib_modules() + {state, loaded} = + state + |> State.set_globals(base_globals()) + |> install_package_table() + + @libraries + |> Enum.reduce(state, &install_library(&2, &1, loaded)) |> install_unpack_alias() |> install_global_g() end - # Install a stdlib library module and register it in package.loaded - defp install_library(state, module) do + # The base globals, in the iteration order `pairs(_G)` reports them. Seeded + # in one `_G` update rather than one per name. + defp base_globals do + [ + {"type", {:native_func, &lua_type/2}}, + {"tostring", {:native_func, &lua_tostring/2}}, + {"tonumber", {:native_func, &lua_tonumber/2}}, + {"print", {:native_func, &lua_print/2}}, + {"error", {:native_func, &lua_error/2}}, + {"assert", {:native_func, &lua_assert/2}}, + {"pcall", {:native_func, &lua_pcall/2}}, + {"xpcall", {:native_func, &lua_xpcall/2}}, + {"rawget", {:native_func, &lua_rawget/2}}, + {"rawset", {:native_func, &lua_rawset/2}}, + {"rawlen", {:native_func, &lua_rawlen/2}}, + {"rawequal", {:native_func, &lua_rawequal/2}}, + {"next", {:native_func, &lua_next/2}}, + {"pairs", {:native_func, &lua_pairs/2}}, + {"ipairs", {:native_func, &lua_ipairs/2}}, + {"setmetatable", {:native_func, &lua_setmetatable/2}}, + {"getmetatable", {:native_func, &lua_getmetatable/2}}, + {"select", {:native_func, &lua_select/2}}, + {"load", {:native_func, &lua_load/2}}, + {"require", {:native_func, &lua_require/2}}, + {"collectgarbage", {:native_func, &lua_collectgarbage/2}}, + {"dofile", {:native_func, &lua_dofile/2}}, + {"_VERSION", "Lua 5.3"} + ] + end + + # Install a stdlib library module and register it in package.loaded. + # + # `loaded` is the `package.loaded` tref, resolved once by the caller — + # rediscovering it per library means re-reading the `package` global and its + # table for every module installed. + defp install_library(state, module, loaded) do state = module.install(state) name = module.lib_name() case State.get_global(state, name) do - {:tref, _} = tref -> cache_module_result(state, name, tref) + {:tref, _} = tref -> cache_in_loaded(state, loaded, name, tref) _ -> state end end @@ -83,34 +103,12 @@ defmodule Lua.VM.Stdlib do defp install_global_g(state) do g_ref = State.g_ref(state) - # Expose _G to Lua under the name "_G" - state = State.set_global(state, "_G", g_ref) - - # _ENV is also exposed at boot for backwards compatibility with code that - # references `_ENV` as a global. The compiler binds `_ENV` as a chunk-level - # local (register 0) at execute time, so this global is mostly for - # introspection; user-level `_ENV` reassignment goes to that local, not + # `_ENV` is exposed alongside `_G` at boot for backwards compatibility with + # code that references `_ENV` as a global. The compiler binds `_ENV` as a + # chunk-level local (register 0) at execute time, so this global is mostly + # for introspection; user-level `_ENV` reassignment goes to that local, not # this global. - State.set_global(state, "_ENV", g_ref) - end - - # Pre-load any stdlib table globals into package.loaded so that - # require("string"), require("math"), etc. resolve to the existing global - # tables without triggering a filesystem search. This mirrors Lua 5.3's - # behaviour where package.loaded is pre-populated by the runtime. - # - # install_library/2 already caches the four installed modules (string, math, - # table, debug), so this pass is a safety net for any future stdlib tables - # that may be added as globals before this call. - defp preload_stdlib_modules(state) do - modules = ["string", "math", "table", "utf8", "debug"] - - Enum.reduce(modules, state, fn name, acc -> - case State.get_global(acc, name) do - {:tref, _} = tref -> cache_module_result(acc, name, tref) - _ -> acc - end - end) + State.set_globals(state, [{"_G", g_ref}, {"_ENV", g_ref}]) end # type(v) — returns the type of v as a string @@ -667,8 +665,10 @@ defmodule Lua.VM.Stdlib do state = State.set_global(state, "package", package_tref) - # Cache "package" itself in package.loaded - cache_module_result(state, "package", package_tref) + # Cache "package" itself in package.loaded, and hand the caller the + # `loaded` tref so the library installs that follow do not each re-resolve + # it through the `package` global. + {cache_in_loaded(state, loaded_tref, "package", package_tref), loaded_tref} end # require(modname) — loads a Lua module @@ -801,9 +801,12 @@ defmodule Lua.VM.Stdlib do # Cache the module result in package.loaded defp cache_module_result(state, modname, result) do - {:tref, loaded_id} = get_package_loaded_ref(state) + cache_in_loaded(state, get_package_loaded_ref(state), modname, result) + end - State.update_table(state, {:tref, loaded_id}, fn loaded_table -> + # Cache the module result in an already-resolved package.loaded table + defp cache_in_loaded(state, loaded_ref, modname, result) do + State.update_table(state, loaded_ref, fn loaded_table -> Table.put(loaded_table, modname, result) end) end diff --git a/lib/lua/vm/table.ex b/lib/lua/vm/table.ex index d644f70e..4a2b5bc0 100644 --- a/lib/lua/vm/table.ex +++ b/lib/lua/vm/table.ex @@ -116,6 +116,27 @@ defmodule Lua.VM.Table do end defp split_from_map(table, data) do + case string_keys(:maps.to_list(data), []) do + {:ok, keys} -> hash_only(table, data, keys) + :error -> sorted_fold(table, data) + end + end + + # Every key is a string, so nothing routes to the array, nothing touches the + # border, and no key can be dead in a table this function is allowed to see + # (both callers reset `dead`). That makes the whole build a rename of the + # caller's map plus the iteration order, instead of N struct-rebuilding + # `put/3` calls. + # + # `order_tail` is the descending key list because that is exactly the struct + # the fold produced: it sorted the pairs and `insert_hash` prepended each + # key. Iteration order over a stdlib table is therefore bit-for-bit what it + # has always been. + defp hash_only(table, data, keys) do + %{table | data: data, order: [], order_tail: Enum.sort(keys, :desc), order_index: nil, order_arr: nil} + end + + defp sorted_fold(table, data) do # Sorted fold: integer keys come first, ascending (term order puts numbers # before other keys), so contiguous appends hit put/3's O(1) array path # instead of parking in the hash and draining via absorb_from_hash — an @@ -124,6 +145,15 @@ defmodule Lua.VM.Table do Enum.reduce(Enum.sort(data), table, fn {k, v}, acc -> put(acc, k, v) end) end + # Collects the keys when the map is entirely string-keyed with no `nil` + # values — the shape every stdlib library table has. A `nil` value means + # "absent" in Lua, so a map carrying one has to go through `put/3`'s delete + # branch; anything other than a string key may normalize into the array. + defp string_keys([], keys), do: {:ok, keys} + defp string_keys([{_k, nil} | _rest], _keys), do: :error + defp string_keys([{k, _v} | rest], keys) when is_binary(k), do: string_keys(rest, [k | keys]) + defp string_keys(_pairs, _keys), do: :error + @doc """ Writes `value` into the table under `key`, honoring Lua semantics: diff --git a/mix.exs b/mix.exs index 36bb00c4..ff90f21e 100644 --- a/mix.exs +++ b/mix.exs @@ -60,6 +60,7 @@ defmodule Lua.MixProject do # autolinking to filtered pages, which errors under # `--warnings-as-errors`. skip_code_autolink_to: [ + "Lua.VM.Bootstrap.reset/0", "Lua.VM.Executor.current_position/0", "Lua.VM.ErrorFormatter.to_map/3", "Lua.VM.RuntimeError", diff --git a/test/lua/vm/bootstrap_test.exs b/test/lua/vm/bootstrap_test.exs new file mode 100644 index 00000000..cd9f5a2c --- /dev/null +++ b/test/lua/vm/bootstrap_test.exs @@ -0,0 +1,286 @@ +defmodule Lua.VM.BootstrapTest do + @moduledoc """ + Pins the contract of the memoized boot templates behind `Lua.new/1`: a VM + built from a shared template must be indistinguishable from one built from + scratch, and must stay isolated from every other VM built from it. + """ + + use ExUnit.Case, async: true + + alias Lua.VM.Bootstrap + alias Lua.VM.Limits + alias Lua.VM.State + + describe "Lua.new/1" do + test "the standard library works" do + lua = Lua.new() + + assert {[2], _} = Lua.eval!(lua, "return 1 + 1") + assert {["HELLO"], _} = Lua.eval!(lua, ~S[return string.upper("hello")]) + assert {[3], _} = Lua.eval!(lua, "return math.max(1, 3, 2)") + assert {["a,b"], _} = Lua.eval!(lua, ~S[return table.concat({"a", "b"}, ",")]) + assert {["Lua 5.3"], _} = Lua.eval!(lua, "return _VERSION") + assert {[true], _} = Lua.eval!(lua, "return _G.print ~= nil") + end + + test "sequential VMs are identical and evaluate identically" do + first = Lua.new() + second = Lua.new() + + assert :erlang.term_to_binary(first) === :erlang.term_to_binary(second) + + script = ~S""" + local acc = {} + for k in pairs(_G) do acc[#acc + 1] = k end + return table.concat(acc, ","), tostring(#acc) + """ + + assert {results, _} = Lua.eval!(first, script) + assert {^results, _} = Lua.eval!(second, script) + end + + test "a fresh VM starts with the boot table and global counts" do + state = Lua.new().state + + assert state.g_ref == {:tref, 0} + assert state.table_next_id == 12 + assert map_size(state.tables) == 12 + end + + test "mutating one VM does not leak into the next" do + mutated = + Lua.new() + |> Lua.set!([:planted], "leaked") + |> Lua.set!([:deeply, :nested], "leaked") + + assert {["leaked"], _} = Lua.eval!(mutated, "return planted") + + fresh = Lua.new() + + assert {[nil], _} = Lua.eval!(fresh, "return planted") + assert {[nil], _} = Lua.eval!(fresh, "return deeply") + assert fresh.state.table_next_id == 12 + end + + test "globals and stdlib tables written from Lua do not leak into the next VM" do + {_, mutated} = + Lua.eval!(Lua.new(), ~S""" + planted = "leaked" + string.shout = function(s) return s end + """) + + assert {["leaked", true], _} = Lua.eval!(mutated, "return planted, string.shout ~= nil") + + assert {[nil, nil], _} = Lua.eval!(Lua.new(), "return planted, string.shout") + end + + test "limits and :debug are per-VM, not baked into the template" do + limited = Lua.new(max_call_depth: 10, max_string_bytes: 1024, max_instructions: 5000, debug: true) + + assert limited.debug + assert limited.state.max_call_depth == 10 + assert limited.state.max_string_bytes == 1024 + assert limited.state.max_instructions == 5000 + + default = Lua.new() + + refute default.debug + assert default.state.max_call_depth == :infinity + assert default.state.max_string_bytes == Limits.max_string_bytes() + assert default.state.max_instructions == :infinity + end + end + + describe "sandbox options" do + test "the default deny-list still applies" do + lua = Lua.new() + + assert_raise Lua.RuntimeException, "Lua runtime error: os.exit(_) is sandboxed", fn -> + Lua.eval!(lua, "os.exit(1)") + end + + assert_raise Lua.RuntimeException, "Lua runtime error: require(_) is sandboxed", fn -> + Lua.eval!(lua, ~S[require("anything")]) + end + end + + test "sandboxed: [] leaves the library intact" do + lua = Lua.new(sandboxed: []) + + assert {["required file successfully"], _} = + Lua.eval!(lua, ~S""" + package.path = "./test/fixtures/?.lua" + return require("test_require") + """) + end + + test "a custom deny-list sandboxes only what it names" do + lua = Lua.new(sandboxed: [[:os, :exit]]) + + assert_raise Lua.RuntimeException, "Lua runtime error: os.exit(_) is sandboxed", fn -> + Lua.eval!(lua, "os.exit(1)") + end + + assert {[true], _} = Lua.eval!(lua, "return load ~= nil") + assert {[true], _} = Lua.eval!(lua, "return package ~= nil") + end + + test ":exclude lifts entries out of the default deny-list" do + lua = Lua.new(exclude: [[:require], [:package]]) + + assert {["required file successfully"], _} = + Lua.eval!(Lua.set_lua_paths(lua, "./test/fixtures/?.lua"), ~S[return require("test_require")]) + + assert_raise Lua.RuntimeException, "Lua runtime error: os.exit(_) is sandboxed", fn -> + Lua.eval!(lua, "os.exit(1)") + end + end + + test "custom-sandbox VMs do not disturb the default VM" do + _custom = Lua.set!(Lua.new(sandboxed: []), [:planted], "leaked") + + assert {[nil], _} = Lua.eval!(Lua.new(), "return planted") + + assert_raise Lua.RuntimeException, "Lua runtime error: os.exit(_) is sandboxed", fn -> + Lua.eval!(Lua.new(), "os.exit(1)") + end + end + end + + describe "fetch/2" do + setup do + key = {__MODULE__, System.unique_integer()} + on_exit(fn -> :persistent_term.erase(key) end) + %{key: key} + end + + test "builds once and returns the same term afterwards", %{key: key} do + test = self() + + builder = fn -> + send(test, :built) + %{value: State.new()} + end + + first = Bootstrap.fetch(key, builder) + second = Bootstrap.fetch(key, builder) + + assert first === second + assert_received :built + refute_received :built + end + + test "keys are independent", %{key: key} do + other = {__MODULE__, System.unique_integer()} + on_exit(fn -> :persistent_term.erase(other) end) + + assert Bootstrap.fetch(key, fn -> :one end) == :one + assert Bootstrap.fetch(other, fn -> :two end) == :two + assert Bootstrap.fetch(key, fn -> :three end) == :one + end + + test "a stale fingerprint rebuilds the term", %{key: key} do + :persistent_term.put(key, {[{__MODULE__, <<"not the current md5">>}], :stale}) + + assert Bootstrap.fetch(key, fn -> :rebuilt end) == :rebuilt + assert Bootstrap.fetch(key, fn -> :again end) == :rebuilt + end + + test "a fingerprinted module that no longer exists rebuilds instead of raising", %{key: key} do + :persistent_term.put(key, {[{Lua.VM.NoSuchModule, <<"md5 of deleted code">>}], :stale}) + + assert Bootstrap.fetch(key, fn -> :rebuilt end) == :rebuilt + assert Bootstrap.fetch(key, fn -> :again end) == :rebuilt + end + + test "the fingerprint covers template-shaping modules even without captured funs", %{key: key} do + # The stored term carries no closures at all, so every module below gets + # into the fingerprint only via the explicit template-module list: a + # struct-layout or default-limit change in any of them must invalidate + # the template even though no fun in it points there. + assert :code.get_mode() == :interactive + + Bootstrap.fetch(key, fn -> %{value: :no_funs_here} end) + + {fingerprint, _term} = :persistent_term.get(key) + modules = Enum.map(fingerprint, fn {module, _md5} -> module end) + + for module <- [Lua, Bootstrap, Limits, State, Lua.VM.Stdlib, Lua.VM.Table] do + assert module in modules, "fingerprint is missing template-shaping module #{inspect(module)}" + end + end + end + + describe "reset/0" do + test "erases every memoized key so the next fetch rebuilds" do + _warm = Lua.new() + + for key <- Bootstrap.memoized_keys() do + assert :persistent_term.get(key, :missing) != :missing, + "expected #{inspect(key)} to be memoized by Lua.new/1" + end + + assert Bootstrap.reset() == :ok + + for key <- Bootstrap.memoized_keys() do + assert :persistent_term.get(key, :missing) == :missing + end + + assert {[2], _} = Lua.eval!(Lua.new(), "return 1 + 1") + end + + test "erases every memoized key populated by racing cold starts" do + Bootstrap.reset() + + test = self() + + workers = + for _ <- 1..64 do + Task.async(fn -> + send(test, {:ready, self()}) + assert_receive :go + Lua.new() + end) + end + + for %Task{pid: pid} <- workers, do: assert_receive({:ready, ^pid}) + for %Task{pid: pid} <- workers, do: send(pid, :go) + + Task.await_many(workers) + + assert Bootstrap.reset() == :ok + + for key <- Bootstrap.memoized_keys() do + assert :persistent_term.get(key, :missing) == :missing, + "reset/0 left #{inspect(key)} behind after a concurrent cold start" + end + end + + test "does not consult runtime bookkeeping, and erases the obsolete entry" do + # An entry left by a build that tracked the stored keys at runtime. Such + # tracking is a read-modify-write that concurrent cold starts can + # truncate, so a truncated list must not be able to narrow what `reset/0` + # erases — and the entry itself must not survive the reset. + obsolete = {Bootstrap, :keys} + on_exit(fn -> :persistent_term.erase(obsolete) end) + + _warm = Lua.new() + :persistent_term.put(obsolete, []) + + assert Bootstrap.reset() == :ok + + for key <- [obsolete | Bootstrap.memoized_keys()] do + assert :persistent_term.get(key, :missing) == :missing, + "reset/0 left #{inspect(key)} behind" + end + end + + test "Lua.new/1 still works after a reset" do + _warm = Lua.new() + + assert Bootstrap.reset() == :ok + + assert {[2], _} = Lua.eval!(Lua.new(), "return 1 + 1") + end + end +end diff --git a/test/lua/vm/table_iteration_test.exs b/test/lua/vm/table_iteration_test.exs index 73ad9d2a..86a3c6d4 100644 --- a/test/lua/vm/table_iteration_test.exs +++ b/test/lua/vm/table_iteration_test.exs @@ -42,6 +42,15 @@ defmodule Lua.VM.TableIterationTest do defp entries_gen, do: list_of(tuple({key_gen(), integer(1..1000)}), max_length: 40) + # Values for the map-based constructors (`from_data/1`, `replace_data/2`): + # unlike `entries_gen/0` these include `nil`, which means "absent" in Lua and + # forces the constructors off the all-string one-shot build (its nil bail-out + # guard) onto the put/3 fold with its delete branch. + defp data_entries_gen do + value_gen = frequency([{4, integer(1..1000)}, {1, constant(nil)}]) + list_of(tuple({key_gen(), value_gen}), max_length: 40) + end + # Builds the table and an independent key=>value oracle from the same ops # (last write wins on both sides; generated values are always non-nil). defp build_pair(ops) do @@ -320,6 +329,55 @@ defmodule Lua.VM.TableIterationTest do end end + property "from_data equals the sorted put/3 fold it replaces, key shape regardless" do + # from_data/1 skips the fold entirely for an all-string-keyed map, + # building `data` and `order` in one shot. That shortcut has to be + # invisible: the resulting struct must be the one the fold produced, + # field for field, so iteration order and border bookkeeping are + # unchanged. Generated values include nil, so maps carrying a + # nil-valued entry must bail out of the one-shot build and take the + # fold's delete branch, matching it exactly. + check all(entries <- data_entries_gen()) do + data = Map.new(entries) + folded = Enum.reduce(Enum.sort(data), %Table{}, fn {k, v}, acc -> Table.put(acc, k, v) end) + + assert Table.from_data(data) == folded + end + end + + property "replace_data on a lived-in table equals replace_data on a fresh one" do + # replace_data/2 is the riskier split_from_map/2 caller: the struct it + # rebuilds arrives pre-populated with an array/hash split, dead keys, + # an order memo, and a metatable. Nothing from that history may leak + # into the rebuilt contents — only the metatable survives — so the + # result must be indistinguishable from replacing into a table that + # never held anything. + check all(ops <- entries_gen(), entries <- data_entries_gen()) do + {built, oracle} = build_pair(ops) + + lived_in = + oracle + |> Map.keys() + |> Enum.take(div(map_size(oracle), 2)) + |> Enum.reduce(built, fn k, acc -> Table.put(acc, k, nil) end) + |> Table.flush_order() + |> Map.put(:metatable, {:tref, 7}) + + data = Map.new(entries) + replaced = Table.replace_data(lived_in, data) + + assert replaced == Table.replace_data(%Table{metatable: {:tref, 7}}, data) + assert replaced.metatable == {:tref, 7} + assert replaced.dead == %{} + + # The walk sees exactly the non-nil entries of the new data map. + live = for {k, v} <- data, v != nil, into: %{}, do: {k, v} + walked = walk(Table.flush_order(replaced)) + + assert MapSet.new(walked) == MapSet.new(live) + end + end + property "clearing the current key mid-walk still visits every live key once (§6.1), memo and fallback" do check all(ops <- entries_gen()) do {table, oracle} = build_pair(ops) From 9431258c81ba1ee800a7deace4506c3ef8e86058 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 17:10:04 -0400 Subject: [PATCH 07/13] compiler: stamp node ids once, at compile time (#416) --- lib/lua/ast/ids.ex | 10 ++++++---- lib/lua/compiler.ex | 10 ++++++---- lib/lua/parser.ex | 7 +++---- test/lua/ast/ids_test.exs | 9 ++++++--- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/lib/lua/ast/ids.ex b/lib/lua/ast/ids.ex index a8bc3e6b..2fe0496a 100644 --- a/lib/lua/ast/ids.ex +++ b/lib/lua/ast/ids.ex @@ -8,8 +8,8 @@ defmodule Lua.AST.Ids do node's whole subtree, so the deepest nodes (function bodies, blocks) — the ones that make the most useful keys — are also the most expensive ones. - `assign/1` walks a parsed chunk once and writes a distinct integer into - each node's `meta.id`, letting those tables key on a single word instead. + `assign/1` walks a chunk once and writes a distinct integer into each + node's `meta.id`, letting those tables key on a single word instead. Ids are unique across the whole chunk, including the bodies of nested functions. """ @@ -23,8 +23,10 @@ defmodule Lua.AST.Ids do @doc """ Returns `chunk` with every reachable node stamped with a unique `meta.id`. - Nodes the parser built without metadata gain a `Lua.AST.Meta` carrying only - the id; nodes that already have one keep their positions and comments. + Nodes built without metadata gain a `Lua.AST.Meta` carrying only the id; + nodes that already have one keep their positions and comments. Ids are + reassigned from scratch on every call, so a partially stamped chunk comes + back fully and consistently numbered. """ @spec assign(Chunk.t()) :: Chunk.t() def assign(%Chunk{} = chunk) do diff --git a/lib/lua/compiler.ex b/lib/lua/compiler.ex index b88ffa99..a7f9663e 100644 --- a/lib/lua/compiler.ex +++ b/lib/lua/compiler.ex @@ -32,10 +32,12 @@ defmodule Lua.Compiler do def compile(%Chunk{} = chunk, opts \\ []) do # Scope resolution and codegen key per-node tables by `meta.id` (see # `Lua.AST.Ids`); without ids, structurally identical nodes (e.g. two - # empty loop bodies) would share one table entry and miscompile. Stamping - # here covers chunks that never went through the parser, such as those - # built with `Lua.AST.Builder`. Assignment is deterministic, so a parsed - # chunk (already stamped by `Lua.Parser`) re-stamps to the same ids. + # empty loop bodies) would share one table entry and miscompile. This is + # the single stamping point, so it covers parsed chunks and chunks built + # by hand (`Lua.AST.Builder`) alike. It cannot be skipped for an + # already-stamped chunk: a chunk carrying hand-built nodes spliced into a + # parsed one is partially stamped, and proving otherwise costs the walk + # this would save. chunk = Ids.assign(chunk) with :ok <- GotoValidation.validate(chunk), diff --git a/lib/lua/parser.ex b/lib/lua/parser.ex index a2ba96b0..8ceea8e8 100644 --- a/lib/lua/parser.ex +++ b/lib/lua/parser.ex @@ -8,7 +8,6 @@ defmodule Lua.Parser do alias Lua.AST.Block alias Lua.AST.Chunk alias Lua.AST.Expr - alias Lua.AST.Ids alias Lua.AST.Meta alias Lua.AST.Statement alias Lua.Lexer @@ -107,8 +106,8 @@ defmodule Lua.Parser do @doc """ Parses a chunk (top-level block) from a token list. - Every node of the returned chunk carries a chunk-unique `meta.id`; see - `Lua.AST.Ids`. + Node ids are not stamped here; `Lua.Compiler.compile/2` stamps every chunk + it compiles, including chunks built without the parser. See `Lua.AST.Ids`. """ @spec parse_chunk([token()]) :: {:ok, Chunk.t()} | {:error, term()} def parse_chunk(tokens) do @@ -116,7 +115,7 @@ defmodule Lua.Parser do {:ok, block, rest} -> case rest do [{:eof, _}] -> - {:ok, Ids.assign(Chunk.new(block))} + {:ok, Chunk.new(block)} [{type, _, pos} | _] -> {:error, {:unexpected_token, type, pos, "Expected end of input"}} diff --git a/test/lua/ast/ids_test.exs b/test/lua/ast/ids_test.exs index 0e7a2a9b..c72a4fdb 100644 --- a/test/lua/ast/ids_test.exs +++ b/test/lua/ast/ids_test.exs @@ -54,7 +54,7 @@ defmodule Lua.AST.IdsTest do test "keeps positions and comments already on a node" do {:ok, chunk} = Lua.Parser.parse_raw("-- leading\nlocal x = 1\n") - [local_stmt] = chunk.block.stmts + [local_stmt] = Ids.assign(chunk).block.stmts assert %{line: 2} = local_stmt.meta.start assert [%{text: " leading"}] = local_stmt.meta.metadata.leading_comments @@ -63,8 +63,9 @@ defmodule Lua.AST.IdsTest do test "is idempotent in shape: re-assigning yields the same chunk" do {:ok, chunk} = Lua.Parser.parse_raw("local t = {1, 2, x = 3}\nreturn t.x\n") + stamped = Ids.assign(chunk) - assert Ids.assign(chunk) == chunk + assert Ids.assign(stamped) == stamped end test "numbers every node of the compilable surface, uniquely" do @@ -90,6 +91,8 @@ defmodule Lua.AST.IdsTest do defp ids_for(source) do {:ok, chunk} = Lua.Parser.parse_raw(source) - Walker.reduce(chunk, [], fn node, acc -> [node.meta.id | acc] end) + chunk + |> Ids.assign() + |> Walker.reduce([], fn node, acc -> [node.meta.id | acc] end) end end From 1aa4369a768202c813b90aa7d4637a42ec1c5b11 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 17:10:17 -0400 Subject: [PATCH 08/13] compiler: peephole pass with fused and constant opcodes (#403) --- lib/lua/compiler.ex | 42 +- lib/lua/compiler/bytecode.ex | 41 + lib/lua/compiler/codegen.ex | 7 +- lib/lua/compiler/instruction.ex | 23 + lib/lua/compiler/peephole.ex | 789 ++++++++++++++++++ lib/lua/vm/dispatcher.ex | 208 +++++ lib/lua/vm/executor.ex | 314 +++++++ test/lua/compiler/instruction_size_test.exs | 3 +- .../compiler/max_registers_invariant_test.exs | 13 + test/lua/compiler/peephole_test.exs | 724 ++++++++++++++++ test/lua/vm/upvalue_test.exs | 11 +- website/lib/website/lua_sandbox.ex | 10 + website/lib/website_web/bytecode.ex | 42 +- website/lib/website_web/live/opcodes_live.ex | 16 + 14 files changed, 2226 insertions(+), 17 deletions(-) create mode 100644 lib/lua/compiler/peephole.ex create mode 100644 test/lua/compiler/peephole_test.exs diff --git a/lib/lua/compiler.ex b/lib/lua/compiler.ex index a7f9663e..32059bba 100644 --- a/lib/lua/compiler.ex +++ b/lib/lua/compiler.ex @@ -11,22 +11,31 @@ defmodule Lua.Compiler do alias Lua.Compiler.Codegen alias Lua.Compiler.GotoResolution alias Lua.Compiler.GotoValidation + alias Lua.Compiler.Peephole alias Lua.Compiler.Prototype alias Lua.Compiler.Scope @type compile_opts :: [ - source: binary() + source: binary(), + peephole: boolean() ] @doc """ Compiles a Lua AST chunk into a prototype. - After codegen, the prototype is offered to `Lua.Compiler.Bytecode` for - dense encoding. Sub-prototypes are encoded independently — the dispatcher - takes over per-prototype wherever every opcode in that prototype falls - within its coverage; anything else stays on the interpreter. The - original instruction stream is preserved either way, so error reporting - and tooling continue to work unchanged. + Codegen's output first goes through `Lua.Compiler.Peephole`, which elides + redundant moves, folds literals into `_k` opcode variants, fuses upvalue + field access, and re-derives `max_registers` from the result. Both engines + run the rewritten stream. Pass `peephole: false` to skip it — the + unoptimised stream is semantically identical and the differential tests + compare the two. + + The prototype is then offered to `Lua.Compiler.Bytecode` for dense + encoding. Sub-prototypes are encoded independently — the dispatcher takes + over per-prototype wherever every opcode in that prototype falls within + its coverage; anything else stays on the interpreter. The instruction + stream is preserved either way, so error reporting and tooling continue to + work unchanged. """ @spec compile(Chunk.t(), compile_opts()) :: {:ok, Prototype.t()} | {:error, term()} def compile(%Chunk{} = chunk, opts \\ []) do @@ -43,12 +52,15 @@ defmodule Lua.Compiler do with :ok <- GotoValidation.validate(chunk), {:ok, scope_state} <- Scope.resolve(chunk, opts), {:ok, prototype} <- Codegen.generate(chunk, scope_state, opts) do - # Encode bytecode first (it reads the raw `:goto` / `:label` stream), - # then resolve gotos for the list interpreter. The two passes are - # independent: the dispatcher runs `bytecode`, the interpreter runs the - # resolved `instructions` plus `goto_targets`. + # Peephole first — it rewrites the raw instruction stream, so both the + # bytecode encoding and the interpreter's list see the same code. Then + # encode bytecode (it reads the raw `:goto` / `:label` stream) and + # finally resolve gotos for the list interpreter. The last two passes + # are independent: the dispatcher runs `bytecode`, the interpreter runs + # the resolved `instructions` plus `goto_targets`. prototype = prototype + |> maybe_peephole(opts) |> Bytecode.compile() |> GotoResolution.resolve() @@ -56,6 +68,14 @@ defmodule Lua.Compiler do end end + defp maybe_peephole(prototype, opts) do + if Keyword.get(opts, :peephole, true) do + Peephole.optimize(prototype) + else + prototype + end + end + @doc """ Compiles a Lua AST chunk, raising on error. """ diff --git a/lib/lua/compiler/bytecode.ex b/lib/lua/compiler/bytecode.ex index bb8702be..11f92484 100644 --- a/lib/lua/compiler/bytecode.ex +++ b/lib/lua/compiler/bytecode.ex @@ -112,6 +112,19 @@ defmodule Lua.Compiler.Bytecode do @op_label 60 @op_goto 61 + # Fused opcodes produced by `Lua.Compiler.Peephole`. The `_k` family + # carries its right operand as an inline literal instead of a register; + # the upvalue-field pair folds a `get_upvalue` into the field access that + # consumes it. Codegen never emits any of them directly. + @op_add_k 62 + @op_subtract_k 63 + @op_multiply_k 64 + @op_less_than_k 65 + @op_less_equal_k 66 + @op_equal_k 67 + @op_get_field_upvalue 68 + @op_set_field_upvalue 69 + @doc """ Compile a prototype, populating its `bytecode` field on success. @@ -306,6 +319,26 @@ defmodule Lua.Compiler.Bytecode do defp encode({:shift_right, dest, a, b, hint_a, hint_b}), do: {:ok, {@op_shift_right, dest, a, b, hint_a, hint_b}} defp encode({:bitwise_not, dest, src, hint}), do: {:ok, {@op_bitwise_not, dest, src, hint}} + # Constant-folded arithmetic and comparison. Slot 4 is a literal Lua + # value, not a register index — the dispatcher and the interpreter both + # use it directly as the right operand. `hint_a` still rides along so + # `attempt to perform arithmetic` errors keep their `(local 'n')` suffix; + # the constant side never had a hint. + defp encode({:add_k, dest, a, constant, hint_a}), do: {:ok, {@op_add_k, dest, a, constant, hint_a}} + defp encode({:subtract_k, dest, a, constant, hint_a}), do: {:ok, {@op_subtract_k, dest, a, constant, hint_a}} + defp encode({:multiply_k, dest, a, constant, hint_a}), do: {:ok, {@op_multiply_k, dest, a, constant, hint_a}} + defp encode({:less_than_k, dest, a, constant}), do: {:ok, {@op_less_than_k, dest, a, constant}} + defp encode({:less_equal_k, dest, a, constant}), do: {:ok, {@op_less_equal_k, dest, a, constant}} + defp encode({:equal_k, dest, a, constant}), do: {:ok, {@op_equal_k, dest, a, constant}} + + # Field access through an upvalue-held table — the shape of every global + # read and write outside the chunk itself. + defp encode({:get_field_upvalue, dest, index, name, name_hint}), + do: {:ok, {@op_get_field_upvalue, dest, index, name, name_hint}} + + defp encode({:set_field_upvalue, index, name, value_reg, name_hint}), + do: {:ok, {@op_set_field_upvalue, index, name, value_reg, name_hint}} + defp encode({:less_than, dest, a, b}), do: {:ok, {@op_less_than, dest, a, b}} defp encode({:less_equal, dest, a, b}), do: {:ok, {@op_less_equal, dest, a, b}} defp encode({:greater_than, dest, a, b}), do: {:ok, {@op_greater_than, dest, a, b}} @@ -642,4 +675,12 @@ defmodule Lua.Compiler.Bytecode do def op_set_list_multi, do: @op_set_list_multi def op_label, do: @op_label def op_goto, do: @op_goto + def op_add_k, do: @op_add_k + def op_subtract_k, do: @op_subtract_k + def op_multiply_k, do: @op_multiply_k + def op_less_than_k, do: @op_less_than_k + def op_less_equal_k, do: @op_less_equal_k + def op_equal_k, do: @op_equal_k + def op_get_field_upvalue, do: @op_get_field_upvalue + def op_set_field_upvalue, do: @op_set_field_upvalue end diff --git a/lib/lua/compiler/codegen.ex b/lib/lua/compiler/codegen.ex index 36a70eba..c515a5d9 100644 --- a/lib/lua/compiler/codegen.ex +++ b/lib/lua/compiler/codegen.ex @@ -120,7 +120,8 @@ defmodule Lua.Compiler.Codegen do # Returns the register-slot count an instruction proves is needed (its # highest written register index + 1), recursing into nested bodies. - defp instruction_size({:load_nil, dest, count}), do: dest + count + # `load_nil` clears `count + 1` registers, `dest..dest + count`. + defp instruction_size({:load_nil, dest, count}), do: dest + count + 1 defp instruction_size({:vararg, base, count}) when is_integer(count) and count > 0, do: base + count defp instruction_size({:vararg, base, _}), do: base + 1 defp instruction_size({:self, base, _obj, _name, _hint}), do: base + 2 @@ -160,6 +161,10 @@ defmodule Lua.Compiler.Codegen do defp instruction_size({:set_upvalue, _index, _source}), do: 0 defp instruction_size({:set_open_upvalue, _reg, _source}), do: 0 + # `Lua.Compiler.Peephole` emits this: operand 1 is an *upvalue* index, not + # a register, so it must not reach the default clause below. + defp instruction_size({:set_field_upvalue, _index, _name, _value, _hint}), do: 0 + # Everything that reaches here is an ordinary value-producing opcode — # `{tag, dest, ...}` whose destination is operand 1. That is the rule for # every load / move / arithmetic / comparison / bitwise / table-read / diff --git a/lib/lua/compiler/instruction.ex b/lib/lua/compiler/instruction.ex index 04540ccd..84341717 100644 --- a/lib/lua/compiler/instruction.ex +++ b/lib/lua/compiler/instruction.ex @@ -39,6 +39,16 @@ defmodule Lua.Compiler.Instruction do def set_table(table, key, value, name_hint \\ nil), do: {:set_table, table, key, value, name_hint} def get_field(dest, table, name, name_hint \\ nil), do: {:get_field, dest, table, name, name_hint} def set_field(table, name, value, name_hint \\ nil), do: {:set_field, table, name, value, name_hint} + + # Field access through an upvalue-held table, fusing a `get_upvalue` with + # the `get_field` / `set_field` that consumes it. Every read or write of a + # global is exactly that pair (`_ENV` is an upvalue in every function but + # the chunk), so the fused form halves their instruction count. + # `Lua.Compiler.Peephole` emits these; codegen never does. + def get_field_upvalue(dest, index, name, name_hint \\ nil), do: {:get_field_upvalue, dest, index, name, name_hint} + + def set_field_upvalue(index, name, value, name_hint \\ nil), do: {:set_field_upvalue, index, name, value, name_hint} + def set_list(table, start, count, offset), do: {:set_list, table, start, count, offset} # Arithmetic. @@ -78,6 +88,19 @@ defmodule Lua.Compiler.Instruction do def less_than(dest, a, b), do: {:less_than, dest, a, b} def less_equal(dest, a, b), do: {:less_equal, dest, a, b} + # Constant-folded variants. The right operand is an inline literal rather + # than a register, so the `load_constant` that materialised it disappears + # along with the register it occupied. Only `hint_a` survives: the + # constant side never carried a name hint to begin with, so error + # rendering is unchanged. `Lua.Compiler.Peephole` emits these; codegen + # never does. + def add_k(dest, a, constant, hint_a \\ nil), do: {:add_k, dest, a, constant, hint_a} + def subtract_k(dest, a, constant, hint_a \\ nil), do: {:subtract_k, dest, a, constant, hint_a} + def multiply_k(dest, a, constant, hint_a \\ nil), do: {:multiply_k, dest, a, constant, hint_a} + def equal_k(dest, a, constant), do: {:equal_k, dest, a, constant} + def less_than_k(dest, a, constant), do: {:less_than_k, dest, a, constant} + def less_equal_k(dest, a, constant), do: {:less_equal_k, dest, a, constant} + # Unary / logical def logical_not(dest, source), do: {:not, dest, source} def length(dest, source), do: {:length, dest, source} diff --git a/lib/lua/compiler/peephole.ex b/lib/lua/compiler/peephole.ex new file mode 100644 index 00000000..396b287a --- /dev/null +++ b/lib/lua/compiler/peephole.ex @@ -0,0 +1,789 @@ +defmodule Lua.Compiler.Peephole do + @moduledoc """ + Peephole optimiser over the instruction stream `Lua.Compiler.Codegen` + emits, run before `Lua.Compiler.Bytecode.compile/1`. + + Codegen is deliberately naive: it allocates a fresh temporary for every + intermediate value and copies it into place, it re-reads `_ENV` out of the + upvalue table before every global access, and it materialises every literal + into a register before using it. That keeps codegen simple, and leaves a + small set of purely local rewrites on the table: + + 1. **Move elision.** `{op, tmp, …}` immediately followed by + `{:move, dst, tmp}` retargets the producer at `dst` when `tmp` is + neither an operand of the producer nor read again. + 2. **Constant folding.** `{:load_constant, k, value}` immediately followed + by an arithmetic or comparison op using `k` as its right operand folds + into a `_k` variant carrying the constant inline. + 3. **Upvalue-field fusion.** `{:get_upvalue, t, i}` immediately followed by + a field read or write through `t` fuses into + `:get_field_upvalue` / `:set_field_upvalue`. Every global access is + exactly this shape, so this halves their instruction count. + 4. **Unreachable-code removal** after an unconditional `return` / `break`. + 5. **Upvalue round-trip collapse.** `set_upvalue i, r` immediately + followed by `get_upvalue d, i` reads the register directly. + 6. **Redundant `close_upvalues` removal** in functions that create no + closures — nothing in such a function can open an upvalue cell over + one of its own registers, so there is never anything to close. + + Both engines run the rewritten stream: the interpreter + (`Lua.VM.Executor`) walks `instructions` directly, the dispatcher + (`Lua.VM.Dispatcher`) walks the `Lua.Compiler.Bytecode` encoding of the + same list. Every opcode introduced here therefore has a handler in both. + + ## Safety + + Every rewrite is gated on the rewritten temporary being *dead* — never + read again on any path that can follow. Liveness is answered by scanning + the instructions that may execute after the rewrite site: the rest of the + enclosing block, then the rest of each enclosing block outward. Inside a + loop body two continuations follow the rewrite site — one more trip + around the loop, and the loop exiting into the code after it — and the + register must be dead along both: a write on the back edge settles only + the back-edge path, never the exit path. Registers read through an + upvalue cell count: `:closure` reads every parent register its child + prototype captures, and the open-upvalue opcodes read theirs + syntactically. + + `reads?/3` defaults to "reads everything" for an instruction shape it does + not recognise, so an opcode added to codegen without a clause here disables + the optimisation rather than miscompiling it. + + Functions containing `goto` / `::label::` opt out entirely. A backward jump + makes "the instructions that may follow" a control-flow-graph question + rather than a lexical one, and `goto` is rare enough that the conservative + answer costs nothing. + """ + + alias Lua.Compiler.Codegen + alias Lua.Compiler.Prototype + + # Producers whose destination register is operand 1, that write exactly + # that one register, and that read every operand before writing it. Only + # these can have their destination retargeted by move elision. + @coalescible [ + :load_constant, + :load_boolean, + :load_env, + :move, + :get_upvalue, + :get_open_upvalue, + :get_global, + :new_table, + :get_table, + :get_field, + :get_field_upvalue, + :closure, + :length, + :not, + :negate, + :bitwise_not, + :concatenate, + :add, + :subtract, + :multiply, + :divide, + :floor_divide, + :modulo, + :power, + :bitwise_and, + :bitwise_or, + :bitwise_xor, + :shift_left, + :shift_right, + :add_k, + :subtract_k, + :multiply_k, + :equal, + :not_equal, + :less_than, + :less_equal, + :greater_than, + :greater_equal, + :equal_k, + :less_than_k, + :less_equal_k + ] + + # `{op, dest, a, b, hint_a, hint_b}` shapes. + @binary_ops [ + :add, + :subtract, + :multiply, + :divide, + :floor_divide, + :modulo, + :power, + :bitwise_and, + :bitwise_or, + :bitwise_xor, + :shift_left, + :shift_right + ] + + # `{op, dest, a, b}` shapes. + @compare_ops [:equal, :not_equal, :less_than, :less_equal, :greater_than, :greater_equal] + + # `{op, dest, a, constant, hint_a}` shapes produced by rule 2. + @arith_k_ops [:add_k, :subtract_k, :multiply_k] + + # `{op, dest, a, constant}` shapes produced by rule 2. + @compare_k_ops [:equal_k, :less_than_k, :less_equal_k] + + @doc """ + Optimise a prototype and every prototype nested within it. + + `max_registers` is recomputed from the rewritten stream. It only ever + shrinks: the result is clamped to the incoming value so a shape this + module does not model can never widen the register file, and bounded + below by the highest register index the rewritten stream still touches. + """ + @spec optimize(Prototype.t()) :: Prototype.t() + def optimize(%Prototype{} = proto) do + prototypes = Enum.map(proto.prototypes, &optimize/1) + instructions = optimize_instructions(proto.instructions, prototypes) + + %{ + proto + | prototypes: prototypes, + instructions: instructions, + max_registers: recompute_max_registers(proto, instructions, prototypes) + } + end + + defp optimize_instructions(instructions, prototypes) do + if contains_goto?(instructions) do + instructions + else + instructions + |> drop_redundant_closes() + |> drop_unreachable() + |> collapse_upvalue_roundtrip() + |> fuse_block([], prototypes) + end + end + + # ── Rule 6: redundant `close_upvalues` ────────────────────────────────── + # + # An open upvalue cell over one of this function's registers can only be + # created by a `:closure` opcode in this function capturing it as a + # `:parent_local`. A function that builds no closures therefore never has + # anything to close, and every `close_upvalues` it carries is a pure + # dispatch cost. The gate is the whole function, not the individual block: + # `goto` closes at explicit levels and loop bodies close at iteration + # boundaries, and neither is safe to reason about block by block. + + defp drop_redundant_closes(instructions) do + if contains_closure?(instructions) do + instructions + else + strip_closes(instructions) + end + end + + defp strip_closes(instructions) do + instructions + |> Enum.reject(&match?({:close_upvalues, _}, &1)) + |> Enum.map(fn instr -> map_bodies(instr, &strip_closes/1) end) + end + + # ── Rule 4: unreachable code ──────────────────────────────────────────── + # + # Nothing after an unconditional `return` / `break` in the same block can + # run. Codegen appends a block-exit `close_upvalues` unconditionally, so + # every `if … then return x end` carries one. + + defp drop_unreachable(instructions) do + instructions + |> Enum.map(fn instr -> map_bodies(instr, &drop_unreachable/1) end) + |> truncate_after_terminator([]) + end + + defp truncate_after_terminator([], acc), do: Enum.reverse(acc) + + defp truncate_after_terminator([instr | rest], acc) do + if terminator?(instr) do + Enum.reverse([instr | acc]) + else + truncate_after_terminator(rest, [instr | acc]) + end + end + + defp terminator?({:return, _base, _count}), do: true + defp terminator?({:return_vararg}), do: true + defp terminator?(:break), do: true + defp terminator?(_instr), do: false + + # ── Rule 5: upvalue round-trip ────────────────────────────────────────── + # + # `set_upvalue i, r` followed immediately by `get_upvalue d, i` reads back + # the value just written. Nothing can run between the two, so the register + # still holds it — read it directly and skip the cell map entirely. The + # write stays: the cell is shared state other closures observe. + + defp collapse_upvalue_roundtrip(instructions) do + instructions + |> Enum.map(fn instr -> map_bodies(instr, &collapse_upvalue_roundtrip/1) end) + |> collapse_roundtrip_pairs() + end + + defp collapse_roundtrip_pairs([]), do: [] + + defp collapse_roundtrip_pairs([{:set_upvalue, index, source} = set, {:get_upvalue, dest, index} | rest]) do + [set, {:move, dest, source} | collapse_roundtrip_pairs(rest)] + end + + defp collapse_roundtrip_pairs([instr | rest]), do: [instr | collapse_roundtrip_pairs(rest)] + + # ── Rules 1–3: fusion ─────────────────────────────────────────────────── + # + # A single left-to-right walk. Each rewrite collapses two instructions into + # one and the result is re-examined against its new successor, so chains + # (`get_upvalue` → `get_field` → `move`) collapse in one pass without a + # fixpoint loop. + # + # `future` is the list of instruction lists that may execute after the + # current position, innermost first. Scanning a not-yet-optimised tail is + # conservative: no rewrite ever adds a read, and while move elision does + # delete a write (the copy), the retargeted producer re-establishes that + # write earlier across instructions transparent to it — a kill the scan + # relied on only ever moves earlier, and the temporary whose own write + # disappears was already proven unread on every following path. + + defp fuse_block([], _future, _prototypes), do: [] + + defp fuse_block([instr], future, prototypes) do + [fuse_bodies(instr, future, prototypes)] + end + + defp fuse_block([first, second | rest] = block, future, prototypes) do + case fuse(first, second, [rest | future], prototypes) do + {:ok, fused} -> + fuse_block([fused | rest], future, prototypes) + + :error -> + case elide_move(block, future, prototypes) do + {:ok, rewritten} -> + fuse_block(rewritten, future, prototypes) + + :error -> + tail = [second | rest] + [fuse_bodies(first, [tail | future], prototypes) | fuse_block(tail, future, prototypes)] + end + end + end + + defp fuse_bodies(instr, future, prototypes) do + case bodies(instr) do + [] -> + instr + + list -> + optimised = + list + |> Enum.zip(body_futures(instr, future)) + |> Enum.map(fn {body, body_future} -> fuse_block(body, body_future, prototypes) end) + + put_bodies(instr, optimised) + end + end + + # The future of each nested body, parallel to `bodies/1`. + # + # A branch body simply continues into whatever follows the branch. A loop + # body continues into one more trip around the loop first, spelled out + # instruction by instruction so the scan can find a read on the back edge. + # The loop instruction with its bodies emptied stands in for the header's + # own register reads (the `for` control triple, the `while` test + # register). + # + # The trip is tagged `:back_edge` because it is only one of two + # continuations: the loop may equally exit into `future` without running + # it. `dead?/3` therefore treats a kill inside the trip as settling the + # back-edge path only, and still requires the register to be dead along + # the exit path — a write at the top of every iteration must not license + # deleting a write that the code after the loop reads. + defp body_futures({:test, _reg, _then_body, _else_body}, future), do: [future, future] + defp body_futures({:test_and, _dest, _source, _body}, future), do: [future] + defp body_futures({:test_or, _dest, _source, _body}, future), do: [future] + + defp body_futures({:while_loop, cond_body, _reg, body} = instr, future) do + header = [put_bodies(instr, [[], []])] + + [ + [{:back_edge, header ++ body ++ cond_body} | future], + [{:back_edge, cond_body ++ header ++ body} | future] + ] + end + + defp body_futures({:repeat_loop, body, cond_body, _reg} = instr, future) do + header = [put_bodies(instr, [[], []])] + + [ + [{:back_edge, cond_body ++ header ++ body} | future], + [{:back_edge, header ++ body ++ cond_body} | future] + ] + end + + defp body_futures({:numeric_for, _base, _loop_var, body} = instr, future) do + [[{:back_edge, [put_bodies(instr, [[]])] ++ body} | future]] + end + + defp body_futures({:generic_for, _base, _var_regs, body} = instr, future) do + [[{:back_edge, [put_bodies(instr, [[]])] ++ body} | future]] + end + + # Rule 3: `_ENV` (or any upvalue) field access. `t` holding the table is a + # scratch register the fused form no longer needs. When the field read + # writes back into `t` its own write kills the value, so no liveness check + # is needed. + defp fuse({:get_upvalue, table_reg, index}, {:get_field, dest, table_reg, name, hint}, future, prototypes) do + if dest == table_reg or dead?(table_reg, future, prototypes) do + {:ok, {:get_field_upvalue, dest, index, name, hint}} + else + :error + end + end + + defp fuse({:get_upvalue, table_reg, index}, {:set_field, table_reg, name, value_reg, hint}, future, prototypes) do + if value_reg != table_reg and dead?(table_reg, future, prototypes) do + {:ok, {:set_field_upvalue, index, name, value_reg, hint}} + else + :error + end + end + + # Rule 2: fold a literal into the operation that consumes it. Only the + # right operand folds, and only when the constant side carries no error + # hint — which is the shape codegen emits for a literal, so nothing is + # lost from `Lua.format_exception/1` output. + defp fuse({:load_constant, k_reg, value}, {op, dest, a, k_reg, hint_a, nil}, future, prototypes) do + with {:ok, fused_op} <- fetch_arith_k(op), + true <- a != k_reg, + true <- dest == k_reg or dead?(k_reg, future, prototypes) do + {:ok, {fused_op, dest, a, value, hint_a}} + else + _ -> :error + end + end + + defp fuse({:load_constant, k_reg, value}, {op, dest, a, k_reg}, future, prototypes) do + with {:ok, fused_op} <- fetch_compare_k(op), + true <- a != k_reg, + true <- dest == k_reg or dead?(k_reg, future, prototypes) do + {:ok, {fused_op, dest, a, value}} + else + _ -> :error + end + end + + defp fuse(_first, _second, _future, _prototypes), do: :error + + # ── Rule 1: destination coalescing ────────────────────────────────────── + # + # A producer writing a scratch register that is later copied into its real + # home writes the real home directly, and the copy disappears. Codegen + # emits the pair for every call argument and every `for` header, usually + # but not always adjacently — `move base+1, tmp1; move base+2, tmp2` + # separates each producer from its copy — so the copy is searched for + # within a bounded window. + # + # Instructions in the window must be transparent to both registers: they + # may not read or write `tmp` (whose write is moving later in the stream) + # and they may not read or write `dest` (whose write is moving earlier). + # Only straight-line shapes qualify, so no branch, loop, or `break` can + # observe the reordering. + + @window 16 + + defp elide_move([producer | rest], future, prototypes) do + with true <- coalescible?(producer), + tmp = :erlang.element(2, producer), + false <- reads?(producer, tmp, prototypes), + {:ok, dest, skipped, after_move} <- find_copy(rest, tmp, prototypes, @window, []), + true <- tmp != dest, + false <- reads?(producer, dest, prototypes), + true <- Enum.all?(skipped, &transparent?(&1, dest, prototypes)), + true <- dead?(tmp, [after_move | future], prototypes) do + {:ok, [:erlang.setelement(2, producer, dest) | Enum.reverse(skipped, after_move)]} + else + _ -> :error + end + end + + defp find_copy([{:move, dest, tmp} | after_move], tmp, _prototypes, _budget, skipped) do + {:ok, dest, skipped, after_move} + end + + defp find_copy([instr | rest], tmp, prototypes, budget, skipped) when budget > 0 do + if transparent?(instr, tmp, prototypes) do + find_copy(rest, tmp, prototypes, budget - 1, [instr | skipped]) + else + :error + end + end + + defp find_copy(_instructions, _tmp, _prototypes, _budget, _skipped), do: :error + + # Straight-line shapes a coalesced write may cross. Everything omitted — + # `:test`, the loops, `:return`, `:break`, `:goto`, `:vararg` (whose + # written range is a run-time value) — ends the window. + @window_safe [ + :load_constant, + :load_boolean, + :load_nil, + :load_env, + :move, + :get_upvalue, + :set_upvalue, + :get_open_upvalue, + :set_open_upvalue, + :close_upvalues, + :get_global, + :new_table, + :get_table, + :set_table, + :get_field, + :set_field, + :get_field_upvalue, + :set_field_upvalue, + :set_list, + :length, + :not, + :negate, + :bitwise_not, + :concatenate, + :self, + :call, + :closure, + :source_line, + :add, + :subtract, + :multiply, + :divide, + :floor_divide, + :modulo, + :power, + :bitwise_and, + :bitwise_or, + :bitwise_xor, + :shift_left, + :shift_right, + :add_k, + :subtract_k, + :multiply_k, + :equal, + :not_equal, + :less_than, + :less_equal, + :greater_than, + :greater_equal, + :equal_k, + :less_than_k, + :less_equal_k + ] + + defp transparent?(instr, reg, prototypes) when is_tuple(instr) do + :erlang.element(1, instr) in @window_safe and + not reads?(instr, reg, prototypes) and + not window_writes?(instr, reg) + end + + defp transparent?(_instr, _reg, _prototypes), do: false + + # A call distributes its results from `base` upward, and how far is a + # run-time property of the callee. + defp window_writes?({:call, base, _args, _results, _hint}, reg), do: reg >= base + defp window_writes?(instr, reg), do: writes?(instr, reg) + + defp fetch_arith_k(:add), do: {:ok, :add_k} + defp fetch_arith_k(:subtract), do: {:ok, :subtract_k} + defp fetch_arith_k(:multiply), do: {:ok, :multiply_k} + defp fetch_arith_k(_op), do: :error + + defp fetch_compare_k(:equal), do: {:ok, :equal_k} + defp fetch_compare_k(:less_than), do: {:ok, :less_than_k} + defp fetch_compare_k(:less_equal), do: {:ok, :less_equal_k} + defp fetch_compare_k(_op), do: :error + + defp coalescible?(instr) when is_tuple(instr) and tuple_size(instr) > 1 do + :erlang.element(1, instr) in @coalescible and is_integer(:erlang.element(2, instr)) + end + + defp coalescible?(_instr), do: false + + # ── Liveness ──────────────────────────────────────────────────────────── + + # True when nothing that can execute after the rewrite site observes the + # current contents of `reg`. + # + # `future` is the enclosing blocks' remaining instructions, innermost + # first. Each list is scanned in order: a read of `reg` settles the + # question, an unconditional straight-line write to `reg` kills the value + # and ends the search, and anything else moves on. Running off the end of + # the outermost list means the frame is gone, which is the strongest form + # of dead. + # + # A `{:back_edge, instructions}` entry is one more trip around an + # enclosing loop, and the loop may exit instead of taking it. A read + # inside the trip still settles the question, but a kill inside it only + # settles the back-edge path — the exit continuation (the lists after it) + # is scanned as well, so a register the code after the loop reads stays + # live no matter what the next iteration would do to it. + # + # Conditional writes (a write nested inside a branch or loop body) do not + # kill: the scan just keeps going, which can only under-report deadness. + # A `break` reached before the killing write does suppress it, though — + # the value would survive the block on that path and reach code the outer + # lists cover. + defp dead?(_reg, [], _prototypes), do: true + + defp dead?(reg, [{:back_edge, instructions} | outer], prototypes) do + case scan(instructions, reg, prototypes, false) do + :read -> false + _killed_or_through -> dead?(reg, outer, prototypes) + end + end + + defp dead?(reg, [instructions | outer], prototypes) do + case scan(instructions, reg, prototypes, false) do + :read -> false + :killed -> true + :through -> dead?(reg, outer, prototypes) + end + end + + defp scan([], _reg, _prototypes, _escaped), do: :through + + defp scan([instr | rest], reg, prototypes, escaped) do + cond do + reads?(instr, reg, prototypes) -> :read + writes?(instr, reg) and not escaped -> :killed + writes?(instr, reg) -> :through + true -> scan(rest, reg, prototypes, escaped or breaks?(instr)) + end + end + + # A `break` in the block (or in a nested branch of it) can leave before a + # later write kills `reg`, so the value escapes to the enclosing block. + # A `break` inside a nested *loop* leaves that loop, not this block. + defp breaks?(:break), do: true + defp breaks?({:while_loop, _cond_body, _reg, _body}), do: false + defp breaks?({:repeat_loop, _body, _cond_body, _reg}), do: false + defp breaks?({:numeric_for, _base, _loop_var, _body}), do: false + defp breaks?({:generic_for, _base, _var_regs, _body}), do: false + defp breaks?(instr), do: Enum.any?(bodies(instr), fn body -> Enum.any?(body, &breaks?/1) end) + + # True when `instr` unconditionally overwrites `reg` on every path through + # it, discarding whatever was there. Anything with a nested body, and + # anything whose written range is only known at run time (`:call`, + # `:vararg`), answers false — the scan then simply continues. + defp writes?({:load_nil, dest, count}, reg), do: reg >= dest and reg <= dest + count + defp writes?({:self, base, _object, _name, _hint}, reg), do: reg === base or reg === base + 1 + defp writes?({:load_constant, dest, _value}, reg), do: dest === reg + defp writes?({:load_boolean, dest, _value}, reg), do: dest === reg + defp writes?({:load_env, dest}, reg), do: dest === reg + defp writes?({:move, dest, _source}, reg), do: dest === reg + defp writes?({:get_upvalue, dest, _index}, reg), do: dest === reg + defp writes?({:get_open_upvalue, dest, _source}, reg), do: dest === reg + defp writes?({:get_global, dest, _name}, reg), do: dest === reg + defp writes?({:new_table, dest, _array, _hash}, reg), do: dest === reg + defp writes?({:get_table, dest, _table, _key, _hint}, reg), do: dest === reg + defp writes?({:get_field, dest, _table, _name, _hint}, reg), do: dest === reg + defp writes?({:get_field_upvalue, dest, _index, _name, _hint}, reg), do: dest === reg + defp writes?({:closure, dest, _index}, reg), do: dest === reg + defp writes?({:length, dest, _source}, reg), do: dest === reg + defp writes?({:not, dest, _source}, reg), do: dest === reg + defp writes?({:negate, dest, _source, _hint}, reg), do: dest === reg + defp writes?({:bitwise_not, dest, _source, _hint}, reg), do: dest === reg + defp writes?({:concatenate, dest, _a, _b}, reg), do: dest === reg + defp writes?({op, dest, _a, _b, _hint_a, _hint_b}, reg) when op in @binary_ops, do: dest === reg + defp writes?({op, dest, _a, _constant, _hint_a}, reg) when op in @arith_k_ops, do: dest === reg + defp writes?({op, dest, _a, _b}, reg) when op in @compare_ops, do: dest === reg + defp writes?({op, dest, _a, _constant}, reg) when op in @compare_k_ops, do: dest === reg + defp writes?(_instr, _reg), do: false + + defp any_reads?(instructions, reg, prototypes) do + Enum.any?(instructions, &reads?(&1, reg, prototypes)) + end + + # True when executing `instr` can observe the current contents of `reg`. + # + # The clauses with a literal opcode in position 1 must precede the guarded + # catch-alls for the arithmetic and comparison families, which match on + # arity alone. + defp reads?({:load_constant, _dest, _value}, _reg, _protos), do: false + defp reads?({:load_boolean, _dest, _value}, _reg, _protos), do: false + defp reads?({:load_nil, _dest, _count}, _reg, _protos), do: false + defp reads?({:load_env, _dest}, _reg, _protos), do: false + defp reads?({:move, _dest, source}, reg, _protos), do: source === reg + defp reads?({:get_upvalue, _dest, _index}, _reg, _protos), do: false + defp reads?({:set_upvalue, _index, source}, reg, _protos), do: source === reg + defp reads?({:get_open_upvalue, _dest, source}, reg, _protos), do: source === reg + defp reads?({:set_open_upvalue, cell_reg, source}, reg, _protos), do: cell_reg === reg or source === reg + defp reads?({:get_global, _dest, _name}, _reg, _protos), do: false + defp reads?({:new_table, _dest, _array, _hash}, _reg, _protos), do: false + defp reads?({:get_table, _dest, table, key, _hint}, reg, _protos), do: table === reg or key === reg + defp reads?({:set_table, table, key, value, _hint}, reg, _protos), do: table === reg or key === reg or value === reg + + defp reads?({:get_field, _dest, table, _name, _hint}, reg, _protos), do: table === reg + defp reads?({:set_field, table, _name, value, _hint}, reg, _protos), do: table === reg or value === reg + defp reads?({:get_field_upvalue, _dest, _index, _name, _hint}, _reg, _protos), do: false + defp reads?({:set_field_upvalue, _index, _name, value, _hint}, reg, _protos), do: value === reg + + defp reads?({:set_list, table, start, count, _offset}, reg, _protos) when is_integer(count), + do: table === reg or (reg >= start and reg < start + count) + + defp reads?({:set_list, table, start, _multi, _offset}, reg, _protos), do: table === reg or reg >= start + + defp reads?({:length, _dest, source}, reg, _protos), do: source === reg + defp reads?({:not, _dest, source}, reg, _protos), do: source === reg + defp reads?({:negate, _dest, source, _hint}, reg, _protos), do: source === reg + defp reads?({:bitwise_not, _dest, source, _hint}, reg, _protos), do: source === reg + defp reads?({:concatenate, _dest, a, b}, reg, _protos), do: a === reg or b === reg + defp reads?({:self, _base, object, _name, _hint}, reg, _protos), do: object === reg + defp reads?({:vararg, _base, _count}, _reg, _protos), do: false + defp reads?({:source_line, _line, _file}, _reg, _protos), do: false + defp reads?({:return_vararg}, _reg, _protos), do: false + defp reads?(:break, _reg, _protos), do: false + + # `close_upvalues` filters the frame's open-cell map by register index; it + # never touches the register file. + defp reads?({:close_upvalues, _threshold}, _reg, _protos), do: false + + # A closure reads every parent register its child prototype captures. + defp reads?({:closure, _dest, index}, reg, prototypes), do: captures?(prototypes, index, reg) + + defp reads?({:call, base, arg_count, _results, _hint}, reg, _protos) when is_integer(arg_count) and arg_count >= 0, + do: reg >= base and reg <= base + arg_count + + defp reads?({:call, base, _arg_count, _results, _hint}, reg, _protos), do: reg >= base + + defp reads?({:return, base, count}, reg, _protos) when is_integer(count) and count > 0, + do: reg >= base and reg < base + count + + defp reads?({:return, _base, 0}, _reg, _protos), do: false + defp reads?({:return, base, _count}, reg, _protos), do: reg >= base + + defp reads?({:test, test_reg, then_body, else_body}, reg, protos), + do: test_reg === reg or any_reads?(then_body, reg, protos) or any_reads?(else_body, reg, protos) + + defp reads?({:test_and, _dest, source, body}, reg, protos), do: source === reg or any_reads?(body, reg, protos) + + defp reads?({:test_or, _dest, source, body}, reg, protos), do: source === reg or any_reads?(body, reg, protos) + + defp reads?({:while_loop, cond_body, test_reg, body}, reg, protos), + do: test_reg === reg or any_reads?(cond_body, reg, protos) or any_reads?(body, reg, protos) + + defp reads?({:repeat_loop, body, cond_body, test_reg}, reg, protos), + do: test_reg === reg or any_reads?(body, reg, protos) or any_reads?(cond_body, reg, protos) + + # The numeric/generic `for` header occupies `base..base + 2` (initial + # value, limit, step / iterator, state, control). + defp reads?({:numeric_for, base, _loop_var, body}, reg, protos), + do: (reg >= base and reg <= base + 2) or any_reads?(body, reg, protos) + + defp reads?({:generic_for, base, _var_regs, body}, reg, protos), + do: (reg >= base and reg <= base + 2) or any_reads?(body, reg, protos) + + defp reads?({op, _dest, a, b, _hint_a, _hint_b}, reg, _protos) when op in @binary_ops, do: a === reg or b === reg + + defp reads?({op, _dest, a, _constant, _hint_a}, reg, _protos) when op in @arith_k_ops, do: a === reg + + defp reads?({op, _dest, a, b}, reg, _protos) when op in @compare_ops, do: a === reg or b === reg + + defp reads?({op, _dest, a, _constant}, reg, _protos) when op in @compare_k_ops, do: a === reg + + # Unrecognised shape — including `:goto` / `:label`, which only reach here + # via the register-extent scan. Assume it observes everything. + defp reads?(_instr, _reg, _protos), do: true + + defp captures?(prototypes, index, reg) do + case Enum.at(prototypes, index) do + %Prototype{upvalue_descriptors: descriptors} -> + Enum.any?(descriptors, fn + {:parent_local, parent_reg, _name} -> parent_reg === reg + _descriptor -> false + end) + + _missing -> + true + end + end + + # ── Register file ─────────────────────────────────────────────────────── + # + # Move elision removes the highest-numbered temporaries first, so the + # rewritten stream usually needs a narrower register tuple — and every + # call frame allocates and every `setelement` copies that tuple, so the + # narrowing is worth as much as the dropped dispatches. + # + # The new bound is `Codegen.instruction_peak/1` (every statically-fixed + # destination) widened to cover every register still *read*, then clamped + # to the incoming value. Probing `reads?/3` per register reuses the same + # table the rewrites are gated on rather than duplicating it, and its + # "reads everything" default makes an unmodelled opcode pin the bound at + # the incoming value instead of shrinking it. + + defp recompute_max_registers(proto, instructions, prototypes) do + peak = Codegen.instruction_peak(instructions) + reads = highest_read(instructions, prototypes, proto.max_registers) + + min(proto.max_registers, Enum.max([proto.param_count, peak, reads])) + end + + # Probe downward from the incoming bound and stop at the first hit: the + # answer is normally within a slot or two of the top, so this is a couple + # of scans rather than one per register. + defp highest_read(instructions, prototypes, limit) do + Enum.find_value((limit - 1)..0//-1, 0, fn reg -> + if any_reads?(instructions, reg, prototypes), do: reg + 1 + end) + end + + # ── Nested bodies ─────────────────────────────────────────────────────── + + defp bodies({:test, _reg, then_body, else_body}), do: [then_body, else_body] + defp bodies({:test_and, _dest, _source, body}), do: [body] + defp bodies({:test_or, _dest, _source, body}), do: [body] + defp bodies({:while_loop, cond_body, _reg, body}), do: [cond_body, body] + defp bodies({:repeat_loop, body, cond_body, _reg}), do: [body, cond_body] + defp bodies({:numeric_for, _base, _loop_var, body}), do: [body] + defp bodies({:generic_for, _base, _var_regs, body}), do: [body] + defp bodies(_instr), do: [] + + defp put_bodies({:test, reg, _then_body, _else_body}, [then_body, else_body]), do: {:test, reg, then_body, else_body} + + defp put_bodies({:test_and, dest, source, _body}, [body]), do: {:test_and, dest, source, body} + defp put_bodies({:test_or, dest, source, _body}, [body]), do: {:test_or, dest, source, body} + + defp put_bodies({:while_loop, _cond_body, reg, _body}, [cond_body, body]), do: {:while_loop, cond_body, reg, body} + + defp put_bodies({:repeat_loop, _body, _cond_body, reg}, [body, cond_body]), do: {:repeat_loop, body, cond_body, reg} + + defp put_bodies({:numeric_for, base, loop_var, _body}, [body]), do: {:numeric_for, base, loop_var, body} + + defp put_bodies({:generic_for, base, var_regs, _body}, [body]), do: {:generic_for, base, var_regs, body} + + defp put_bodies(instr, []), do: instr + + defp map_bodies(instr, fun) do + case bodies(instr) do + [] -> instr + list -> put_bodies(instr, Enum.map(list, fun)) + end + end + + # ── Whole-function predicates ─────────────────────────────────────────── + + defp contains_closure?(instructions), do: Enum.any?(instructions, &closure?/1) + + defp closure?({:closure, _dest, _index}), do: true + defp closure?(instr), do: Enum.any?(bodies(instr), &contains_closure?/1) + + defp contains_goto?(instructions), do: Enum.any?(instructions, &goto?/1) + + defp goto?({:goto, _name, _block_path}), do: true + defp goto?({:label, _name, _level, _block_path}), do: true + defp goto?(instr), do: Enum.any?(bodies(instr), &contains_goto?/1) +end diff --git a/lib/lua/vm/dispatcher.ex b/lib/lua/vm/dispatcher.ex index c9b5fd84..f75a34df 100644 --- a/lib/lua/vm/dispatcher.ex +++ b/lib/lua/vm/dispatcher.ex @@ -124,6 +124,20 @@ defmodule Lua.VM.Dispatcher do @op_label 60 @op_goto 61 + # Fused opcodes from `Lua.Compiler.Peephole`. The `_k` family carries its + # right operand inline, so the fast path skips one register read and the + # `load_constant` that fed it. `@op_get_field_upvalue` / + # `@op_set_field_upvalue` source the table straight out of `upvalues` + # instead of a scratch register. + @op_add_k 62 + @op_subtract_k 63 + @op_multiply_k 64 + @op_less_than_k 65 + @op_less_equal_k 66 + @op_equal_k 67 + @op_get_field_upvalue 68 + @op_set_field_upvalue 69 + @doc """ Execute a compiled prototype against `args` and `state`. """ @@ -315,6 +329,72 @@ defmodule Lua.VM.Dispatcher do dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end + # Same shape as `@op_get_field`, but the table comes from the + # upvalue cell rather than a register — the fused form of the + # `get_upvalue` + `get_field` pair every global read compiles to. + {@op_get_field_upvalue, dest, index, name, name_hint} -> + cell_ref = :erlang.element(index + 1, upvalues) + table_val = :maps.get(cell_ref, state.upvalue_cells, nil) + + case table_val do + {:tref, id} -> + table = :erlang.map_get(id, state.tables) + data = :erlang.map_get(:data, table) + + case data do + %{^name => value} -> + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + _ -> + case :erlang.map_get(:metatable, table) do + nil -> + regs = :erlang.setelement(dest + 1, regs, nil) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + _ -> + {value, state} = + Executor.dispatcher_get_field(table_val, name, sync(state, cs, cd), proto, name_hint) + + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + end + + _ -> + {value, state} = + Executor.dispatcher_get_field(table_val, name, sync(state, cs, cd), proto, name_hint) + + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + # Mirror of `@op_set_field` sourcing the table from an upvalue cell — + # the fused form of the pair every global write compiles to. + {@op_set_field_upvalue, index, name, value_reg, name_hint} -> + cell_ref = :erlang.element(index + 1, upvalues) + table_val = :maps.get(cell_ref, state.upvalue_cells, nil) + value = :erlang.element(value_reg + 1, regs) + + case table_val do + {:tref, id} -> + table = :erlang.map_get(id, state.tables) + + state = + case :erlang.map_get(:metatable, table) do + nil -> + %{state | tables: :maps.put(id, Table.put(table, name, value), state.tables)} + + _ -> + Executor.dispatcher_set_field(table_val, name, value, sync(state, cs, cd), proto, name_hint) + end + + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + _ -> + Executor.dispatcher_set_field(table_val, name, value, sync(state, cs, cd), proto, name_hint) + end + # ── Arithmetic ────────────────────────────────────────────────── # # Integer fast paths mirror the interpreter's. Numbers can't carry @@ -385,6 +465,73 @@ defmodule Lua.VM.Dispatcher do dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end + # ── Constant-folded arithmetic ────────────────────────────────── + # + # Same three tiers as the register forms, with `k` read straight out + # of the opcode tuple. The slow path boxes `k` and hands it to the + # shared bridge, so `__add` / `__sub` / `__mul` fidelity and the + # `(local 'n')` error suffix are unchanged. + + {@op_add_k, dest, a, k, hint_a} -> + va = :erlang.element(a + 1, regs) + + cond do + is_integer(va) and is_integer(k) -> + sum = va + k + wrapped = if sum >= @min_int and sum <= @max_int, do: sum, else: Numeric.to_signed_int64(sum) + regs = :erlang.setelement(dest + 1, regs, wrapped) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va + k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_binop(:add, va, k, sync(state, cs, cd), proto, hint_a, nil) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + {@op_subtract_k, dest, a, k, hint_a} -> + va = :erlang.element(a + 1, regs) + + cond do + is_integer(va) and is_integer(k) -> + diff = va - k + wrapped = if diff >= @min_int and diff <= @max_int, do: diff, else: Numeric.to_signed_int64(diff) + regs = :erlang.setelement(dest + 1, regs, wrapped) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va - k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_binop(:subtract, va, k, sync(state, cs, cd), proto, hint_a, nil) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + {@op_multiply_k, dest, a, k, hint_a} -> + va = :erlang.element(a + 1, regs) + + cond do + is_integer(va) and is_integer(k) -> + prod = va * k + wrapped = if prod >= @min_int and prod <= @max_int, do: prod, else: Numeric.to_signed_int64(prod) + regs = :erlang.setelement(dest + 1, regs, wrapped) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va * k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_binop(:multiply, va, k, sync(state, cs, cd), proto, hint_a, nil) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + {@op_divide, dest, a, b, hint_a, hint_b} -> {value, state} = Executor.dispatcher_binop( @@ -643,6 +790,67 @@ defmodule Lua.VM.Dispatcher do dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end + # ── Constant-folded comparisons ───────────────────────────────── + # + # `k` is a literal, so it can never carry a metatable: the fast + # paths fire whenever the register side is a number or a binary. + # Everything else still routes through the shared bridge so `__lt` + # / `__le` / `__eq` behave exactly as in the register form. + + {@op_less_than_k, dest, a, k} -> + va = :erlang.element(a + 1, regs) + + cond do + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va < k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_binary(va) and is_binary(k) -> + regs = :erlang.setelement(dest + 1, regs, va < k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_cmp(:less_than, va, k, sync(state, cs, cd), proto) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + {@op_less_equal_k, dest, a, k} -> + va = :erlang.element(a + 1, regs) + + cond do + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va <= k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_binary(va) and is_binary(k) -> + regs = :erlang.setelement(dest + 1, regs, va <= k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_cmp(:less_equal, va, k, sync(state, cs, cd), proto) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + {@op_equal_k, dest, a, k} -> + va = :erlang.element(a + 1, regs) + + cond do + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va == k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_binary(va) and is_binary(k) -> + regs = :erlang.setelement(dest + 1, regs, va == k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_cmp(:equal, va, k, sync(state, cs, cd), proto) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + {@op_not, dest, src} -> v = :erlang.element(src + 1, regs) # Inline truthiness — Lua treats nil and false as the only falsy diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 4aa879f1..e36e11c8 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -2122,6 +2122,152 @@ defmodule Lua.VM.Executor do do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) end + # ── Constant-folded arithmetic ───────────────────────────────────────────── + # + # `Lua.Compiler.Peephole` folds the `load_constant` that materialised a + # literal into the operation that consumes it, so `k` is a value rather + # than a register index. Same three tiers as the register forms; the slow + # path hands `k` to the same metamethod bridge, so `__add` / `__sub` / + # `__mul` and the `(local 'n')` error suffix behave identically. + + defp do_execute( + [{:add_k, dest, a, k, _hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) + when is_integer(:erlang.element(a + 1, regs)) and is_integer(k) do + sum = :erlang.element(a + 1, regs) + k + regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(sum)) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + end + + defp do_execute( + [{:add_k, dest, a, k, hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + val_a = elem(regs, a) + + if is_number(val_a) and is_number(k) do + regs = put_elem(regs, dest, val_a + k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + else + src = proto.source + + {result, new_state} = + try_binary_metamethod("__add", val_a, k, state, fn -> + safe_add(val_a, k, line, src, hint_a, nil, state) + end) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + + defp do_execute( + [{:subtract_k, dest, a, k, _hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) + when is_integer(:erlang.element(a + 1, regs)) and is_integer(k) do + diff = :erlang.element(a + 1, regs) - k + regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(diff)) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + end + + defp do_execute( + [{:subtract_k, dest, a, k, hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + val_a = elem(regs, a) + + if is_number(val_a) and is_number(k) do + regs = put_elem(regs, dest, val_a - k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + else + src = proto.source + + {result, new_state} = + try_binary_metamethod("__sub", val_a, k, state, fn -> + safe_subtract(val_a, k, line, src, hint_a, nil, state) + end) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + + defp do_execute( + [{:multiply_k, dest, a, k, _hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) + when is_integer(:erlang.element(a + 1, regs)) and is_integer(k) do + prod = :erlang.element(a + 1, regs) * k + regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(prod)) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + end + + defp do_execute( + [{:multiply_k, dest, a, k, hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + val_a = elem(regs, a) + + if is_number(val_a) and is_number(k) do + regs = put_elem(regs, dest, val_a * k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + else + src = proto.source + + {result, new_state} = + try_binary_metamethod("__mul", val_a, k, state, fn -> + safe_multiply(val_a, k, line, src, hint_a, nil, state) + end) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + # ── Comparison operations ────────────────────────────────────────────────── # Comparison fast paths: number-vs-number and string-vs-string skip the @@ -2194,6 +2340,95 @@ defmodule Lua.VM.Executor do end end + # ── Constant-folded comparisons ──────────────────────────────────────────── + # + # A literal can never carry a metatable, so the fast paths fire whenever + # the register side is a number or a binary. Anything else routes through + # the same metamethod helpers as the register forms. + + defp do_execute([{:equal_k, dest, a, k} | rest], regs, upvalues, proto, state, cont, frames, line, instruction_count) do + val_a = elem(regs, a) + + cond do + is_number(val_a) and is_number(k) -> + regs = put_elem(regs, dest, val_a == k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + is_binary(val_a) and is_binary(k) -> + regs = put_elem(regs, dest, val_a == k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + true -> + {result, new_state} = try_equality_metamethod(val_a, k, state, fn -> lua_equal(val_a, k) end) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + + defp do_execute( + [{:less_than_k, dest, a, k} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + val_a = elem(regs, a) + + cond do + is_number(val_a) and is_number(k) -> + regs = put_elem(regs, dest, val_a < k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + is_binary(val_a) and is_binary(k) -> + regs = put_elem(regs, dest, val_a < k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + true -> + src = proto.source + + {result, new_state} = + try_binary_metamethod("__lt", val_a, k, state, fn -> safe_compare_lt(val_a, k, line, src, state) end) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + + defp do_execute( + [{:less_equal_k, dest, a, k} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + val_a = elem(regs, a) + + cond do + is_number(val_a) and is_number(k) -> + regs = put_elem(regs, dest, val_a <= k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + is_binary(val_a) and is_binary(k) -> + regs = put_elem(regs, dest, val_a <= k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + true -> + {result, new_state} = compare_le(val_a, k, state, line, proto.source) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + defp do_execute( [{:greater_than, dest, a, b} | rest], regs, @@ -2496,6 +2731,85 @@ defmodule Lua.VM.Executor do end end + # ── get_field_upvalue ────────────────────────────────────────────────────── + # + # `Lua.Compiler.Peephole` fuses `get_upvalue` + `get_field` into this — the + # shape of every global read outside the chunk itself. Identical to + # `:get_field` except the table comes from the upvalue cell instead of a + # scratch register. + + defp do_execute( + [{:get_field_upvalue, dest, index, name, name_hint} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + cell_ref = elem(upvalues, index) + table_val = :maps.get(cell_ref, state.upvalue_cells, nil) + + case table_val do + {:tref, id} -> + table = :erlang.map_get(id, state.tables) + + case :erlang.map_get(:data, table) do + %{^name => value} -> + regs = put_elem(regs, dest, value) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + _data -> + case :erlang.map_get(:metatable, table) do + nil -> + regs = put_elem(regs, dest, nil) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + _ -> + {value, state} = index_value(table_val, name, state, line, proto.source, name_hint) + regs = put_elem(regs, dest, value) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + end + end + + _ -> + {value, state} = index_value(table_val, name, state, line, proto.source, name_hint) + regs = put_elem(regs, dest, value) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + end + end + + # ── set_field_upvalue ────────────────────────────────────────────────────── + # + # The `set_field` mirror of the fusion above — every global write. + + defp do_execute( + [{:set_field_upvalue, index, name, value_reg, name_hint} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + cell_ref = elem(upvalues, index) + table_val = :maps.get(cell_ref, state.upvalue_cells, nil) + + case table_val do + {:tref, _} -> + value = elem(regs, value_reg) + state = table_newindex(table_val, name, value, state) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + _ -> + raise_index_type_error(table_val, line, proto.source, name_hint, state) + end + end + # ── set_field ────────────────────────────────────────────────────────────── defp do_execute( diff --git a/test/lua/compiler/instruction_size_test.exs b/test/lua/compiler/instruction_size_test.exs index 4f377da6..db545169 100644 --- a/test/lua/compiler/instruction_size_test.exs +++ b/test/lua/compiler/instruction_size_test.exs @@ -71,7 +71,8 @@ defmodule Lua.Compiler.InstructionSizeTest do # Structural opcodes: write a range, a fixed offset off a base, or recurse # into nested bodies. @structural [ - {{:load_nil, 5, 3}, 8}, + # `load_nil` clears `count + 1` registers, so 5..8 needs 9 slots. + {{:load_nil, 5, 3}, 9}, {{:vararg, 5, 3}, 8}, {{:vararg, 5, 0}, 6}, {{:self, 5, 1, "m", nil}, 7}, diff --git a/test/lua/compiler/max_registers_invariant_test.exs b/test/lua/compiler/max_registers_invariant_test.exs index 1aa3d878..b5908a53 100644 --- a/test/lua/compiler/max_registers_invariant_test.exs +++ b/test/lua/compiler/max_registers_invariant_test.exs @@ -106,6 +106,19 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do # multi-return values occupy start..top at runtime, but the only # syntactic register operands are table_reg and the start slot. op == Bytecode.op_set_list_multi() -> [1, 2] + # Peephole fusions. The `_k` family's slot 3 is a literal value, not a + # register, so only dest and the left operand count. + # `get_field_upvalue`'s slot 2 is an upvalue index and + # `set_field_upvalue`'s slot 1 likewise — neither indexes the register + # file, and both would blow past `max_registers` if counted. + op == Bytecode.op_add_k() -> [1, 2] + op == Bytecode.op_subtract_k() -> [1, 2] + op == Bytecode.op_multiply_k() -> [1, 2] + op == Bytecode.op_less_than_k() -> [1, 2] + op == Bytecode.op_less_equal_k() -> [1, 2] + op == Bytecode.op_equal_k() -> [1, 2] + op == Bytecode.op_get_field_upvalue() -> [1] + op == Bytecode.op_set_field_upvalue() -> [3] true -> raise "register_positions/1 is missing a case for opcode #{inspect(op)}" end end diff --git a/test/lua/compiler/peephole_test.exs b/test/lua/compiler/peephole_test.exs new file mode 100644 index 00000000..bc1c01b6 --- /dev/null +++ b/test/lua/compiler/peephole_test.exs @@ -0,0 +1,724 @@ +defmodule Lua.Compiler.PeepholeTest do + @moduledoc """ + Pins the peephole pass: the rewrites it performs, the rewrites it must + refuse, and — the load-bearing part — that turning it on changes nothing + an observer can see. + + The differential compiles each program twice, once with `peephole: false` + and once with it on, evaluates both, and compares results, printed output, + and (for the failing battery) the rendered exception byte for byte. The + rewritten stream must also never need a wider register file or more + instruction slots than the stream it came from. + """ + + use ExUnit.Case, async: true + use ExUnitProperties + + import ExUnit.CaptureIO + + alias Lua.Compiler + alias Lua.Compiler.Bytecode + alias Lua.Compiler.Codegen + alias Lua.Compiler.Prototype + alias Lua.Parser + + defp compile!(source, opts \\ []) do + {:ok, ast} = Parser.parse_structured(source) + {:ok, proto} = Compiler.compile(ast, Keyword.merge([source: "peephole-test.lua"], opts)) + proto + end + + defp run(source, opts) do + proto = compile!(source, opts) + chunk = %Lua.Chunk{prototype: proto} + + fn -> + result = + try do + {results, _lua} = Lua.eval!(Lua.new(), chunk) + {:ok, results} + rescue + e -> {:error, Lua.format_exception(e)} + end + + send(self(), {:result, result}) + end + |> capture_io() + |> then(fn output -> + receive do + {:result, result} -> {result, output} + end + end) + end + + # Every opcode tag in a prototype tree, own instructions only. + defp opcodes(%Prototype{} = proto) do + tags(proto.instructions) ++ Enum.flat_map(proto.prototypes, &opcodes/1) + end + + defp tags(instructions) do + Enum.flat_map(instructions, fn + instr when is_tuple(instr) -> + [:erlang.element(1, instr) | Enum.flat_map(bodies(instr), &tags/1)] + + atom -> + [atom] + end) + end + + defp bodies({:test, _reg, then_body, else_body}), do: [then_body, else_body] + defp bodies({:test_and, _dest, _source, body}), do: [body] + defp bodies({:test_or, _dest, _source, body}), do: [body] + defp bodies({:while_loop, cond_body, _reg, body}), do: [cond_body, body] + defp bodies({:repeat_loop, body, cond_body, _reg}), do: [body, cond_body] + defp bodies({:numeric_for, _base, _loop_var, body}), do: [body] + defp bodies({:generic_for, _base, _var_regs, body}), do: [body] + defp bodies(_instr), do: [] + + defp count_instructions(%Prototype{} = proto) do + length(opcodes(proto)) + end + + # Walks a prototype tree pairwise, applying `fun` to each matched pair. + defp zip_protos(%Prototype{} = a, %Prototype{} = b, fun) do + fun.(a, b) + + a.prototypes + |> Enum.zip(b.prototypes) + |> Enum.each(fn {child_a, child_b} -> zip_protos(child_a, child_b, fun) end) + end + + describe "move elision" do + test "retargets an adjacent producer at the move's destination" do + proto = compile!("function f(t) local x = t.a return x end") + [f] = proto.prototypes + + # `get_field tmp, t, "a"` + `move x, tmp` collapses into a single + # `get_field x, t, "a"`. + assert Enum.count(opcodes(f), &(&1 == :move)) == 0 + assert Enum.count(opcodes(f), &(&1 == :get_field)) == 1 + end + + test "finds the copy across intervening transparent instructions" do + # The `for` header loads three temporaries and then copies all three + # into the control triple, so no producer is adjacent to its copy. + before = compile!("function f(n) for i = 1, n do end end", peephole: false) + after_pass = compile!("function f(n) for i = 1, n do end end") + + [before_f] = before.prototypes + [after_f] = after_pass.prototypes + + # Three loads and three copies become three loads; the empty body's + # block close goes too. + assert count_instructions(before_f) - count_instructions(after_f) >= 2 + end + + test "refuses to coalesce when the temporary is read again" do + # `x` is used twice, so the register holding it is live past the copy. + proto = compile!("function f(t) local x = t.a return x + x end") + [f] = proto.prototypes + + assert :get_field in opcodes(f) + end + + test "a conditional reassignment still wins" do + source = "function f(t, c) local x = t.a if c then x = 1 end return x end" + + before = compile!(source, peephole: false) + after_pass = compile!(source) + + # Whatever it rewrites, it must not widen the frame. + zip_protos(before, after_pass, fn a, b -> assert b.max_registers <= a.max_registers end) + + assert {[7, 1], _} = + Lua.eval!(source <> " return f({a = 7}, false), f({a = 7}, true)") + end + end + + describe "move elision across loop exits" do + # A local written unconditionally inside a loop body and read only + # after the loop. The back edge overwrites it every iteration, but the + # exit path reads the final iteration's value — the write to the local + # must survive. The call argument copy is the bait: with an unsound + # scan, a second elision retargets the producer back at the temporary + # and the local is never written at all. + @live_out_cases [ + {"numeric for with call argument", + """ + local function id(x) return x end + local c = 0 + for i = 1, 2 do + c = i + local y = id(c) + end + return c + """, [2]}, + {"numeric for with arithmetic producer", + """ + local function id(x) return x end + local c = 0 + for i = 1, 2 do + c = i + 1 + local y = id(c) + end + return c + """, [3]}, + {"while loop", + """ + local function id(x) return x end + local c = 0 + local i = 0 + while i < 2 do + i = i + 1 + c = i + local y = id(c) + end + return c + """, [2]}, + {"repeat loop", + """ + local function id(x) return x end + local c = 0 + local i = 0 + repeat + i = i + 1 + c = i + local y = id(c) + until i >= 2 + return c + """, [2]}, + {"generic for", + """ + local function id(x) return x end + local c = 0 + for _, v in ipairs({1, 2}) do + c = v + local y = id(c) + end + return c + """, [2]}, + {"method call argument", + """ + local o = {} + function o:m(x) return x end + local c = 0 + for i = 1, 2 do + c = i + local y = o:m(c) + end + return c + """, [2]}, + {"inside a nested function", + """ + local function id(x) return x end + local function run() + local c = 0 + for i = 1, 2 do + c = i + local y = id(c) + end + return c + end + return run() + """, [2]} + ] + + for {name, source, expected} <- @live_out_cases do + test "#{name}: the loop-exit read keeps the write alive" do + source = unquote(source) + expected = unquote(Macro.escape(expected)) + + assert {{:ok, ^expected}, ""} = run(source, peephole: false) + assert {{:ok, ^expected}, ""} = run(source, peephole: true) + end + end + end + + describe "constant folding" do + test "folds a literal right operand into the arithmetic op" do + proto = compile!("function f(n) return n - 1 end") + [f] = proto.prototypes + + assert :subtract_k in opcodes(f) + refute :subtract in opcodes(f) + refute :load_constant in opcodes(f) + end + + test "folds a literal right operand into a comparison" do + proto = compile!("function f(n) if n < 2 then return n end return 0 end") + [f] = proto.prototypes + + assert :less_than_k in opcodes(f) + refute :less_than in opcodes(f) + end + + test "leaves the register form alone when both operands are registers" do + proto = compile!("function f(a, b) return a - b end") + [f] = proto.prototypes + + assert :subtract in opcodes(f) + refute :subtract_k in opcodes(f) + end + + test "does not fold a literal on the left" do + proto = compile!("function f(n) return 1 - n end") + [f] = proto.prototypes + + assert :subtract in opcodes(f) + refute :subtract_k in opcodes(f) + end + + test "does not fold operations with no _k variant" do + proto = compile!("function f(n) return n / 2, n % 2, n ^ 2 end") + [f] = proto.prototypes + + assert :divide in opcodes(f) + assert :modulo in opcodes(f) + assert :power in opcodes(f) + end + + test "the folded form preserves the operand hint" do + proto = compile!("function f(n) return n - 1 end") + [f] = proto.prototypes + + assert [{:subtract_k, _dest, _a, 1, {:local, "n"}}] = + Enum.filter(f.instructions, &match?({:subtract_k, _, _, _, _}, &1)) + end + end + + describe "upvalue-field fusion" do + test "fuses the global read every free name compiles to" do + proto = compile!("function f() return print end") + [f] = proto.prototypes + + assert :get_field_upvalue in opcodes(f) + refute :get_upvalue in opcodes(f) + refute :get_field in opcodes(f) + end + + test "fuses the global write" do + proto = compile!("function f() x = 1 end") + [f] = proto.prototypes + + assert :set_field_upvalue in opcodes(f) + refute :get_upvalue in opcodes(f) + end + + test "leaves the chunk's own _ENV alone — it lives in a register, not an upvalue" do + proto = compile!("x = 1 return x") + + refute :get_field_upvalue in tags(proto.instructions) + refute :set_field_upvalue in tags(proto.instructions) + end + end + + describe "unreachable code" do + test "drops the block close codegen appends after a return" do + proto = compile!("function f(n) if n < 2 then return n end return 0 end") + [f] = proto.prototypes + + refute :close_upvalues in opcodes(f) + end + end + + describe "redundant close_upvalues" do + test "a closure-free function keeps none" do + proto = compile!("function f(n) local s = 0 for i = 1, n do local t = i * 2 s = s + t end return s end") + [f] = proto.prototypes + + refute :close_upvalues in opcodes(f) + end + + test "a function that builds a closure keeps all of them" do + source = """ + function f(n) + local acc = {} + for i = 1, n do + local v = i + acc[i] = function() return v end + end + return acc + end + """ + + before = compile!(source, peephole: false) + after_pass = compile!(source) + + [before_f] = before.prototypes + [after_f] = after_pass.prototypes + + assert Enum.count(opcodes(before_f), &(&1 == :close_upvalues)) == + Enum.count(opcodes(after_f), &(&1 == :close_upvalues)) + end + + test "captured loop locals still see their own value per iteration" do + assert {[1, 2, 3], _} = + Lua.eval!(""" + local acc = {} + for i = 1, 3 do + local v = i + acc[i] = function() return v end + end + return acc[1](), acc[2](), acc[3]() + """) + end + end + + describe "goto opt-out" do + test "a function containing a label is left exactly as codegen emitted it" do + source = """ + function f(n) + local i = 0 + ::top:: + i = i + 1 + if i < n then goto top end + return i + end + """ + + before = compile!(source, peephole: false) + after_pass = compile!(source) + + assert before.prototypes |> hd() |> Map.get(:instructions) == + after_pass.prototypes |> hd() |> Map.get(:instructions) + end + end + + describe "fib" do + test "compiles to the fused ten-opcode form in four registers" do + proto = + compile!(""" + function fib(n) + if n < 2 then return n end + return fib(n-1) + fib(n-2) + end + """) + + [fib] = proto.prototypes + + assert fib.max_registers == 4 + assert tuple_size(fib.bytecode) == 10 + assert Bytecode.fully_compiled?(proto) + end + + test "still computes fib" do + assert {[610], _} = + Lua.eval!(""" + function fib(n) + if n < 2 then return n end + return fib(n-1) + fib(n-2) + end + return fib(15) + """) + end + end + + # A corpus broad enough that a mis-scoped rewrite shows up somewhere: + # every control-flow shape, closures over loop variables, metatables, + # varargs, multi-return, coroutines, string building, and pcall. + @corpus [ + "return 1 + 2 * 3 - 4", + "local x = 5 return x * x, x - 1, x + 1", + "function fib(n) if n < 2 then return n end return fib(n-1) + fib(n-2) end return fib(12)", + "local s = 0 for i = 1, 20 do s = s + i end return s", + "local s = 0 for i = 20, 1, -2 do s = s + i end return s", + "local i, s = 0, 0 while i < 10 do i = i + 1 s = s + i end return i, s", + "local i = 0 repeat i = i + 1 until i >= 7 return i", + "local t = {} for i = 1, 5 do t[i] = i * i end local s = 0 for _, v in ipairs(t) do s = s + v end return s", + "local t = {a = 1, b = 2, c = 3} local n = 0 for k, v in pairs(t) do n = n + v end return n", + "local t = {1, 2, 3, 4, 5} return #t, t[1], t[5]", + "for i = 1, 10 do if i > 4 then break end end return 'done'", + "local a = nil return a or 'fallback', a and 'never'", + "local function add(a, b) return a + b end return add(3, 4)", + "local acc = {} for i = 1, 3 do local v = i acc[i] = function() return v end end return acc[1](), acc[3]()", + "local c = 0 local function inc() c = c + 1 return c end inc() inc() return inc()", + "local function many() return 1, 2, 3 end local a, b, c = many() return a, b, c", + "local function many() return 1, 2, 3 end return {many()}", + "local function v(...) return select('#', ...), ... end return v(1, 2, 3)", + "local function v(...) local t = {...} return #t end return v('a', 'b', 'c', 'd')", + "local mt = {__add = function(a, b) return 'added' end} local t = setmetatable({}, mt) return t + 1", + "local mt = {__index = function(_, k) return k .. '!' end} local t = setmetatable({}, mt) return t.hi", + "local mt = {__lt = function() return true end} local a = setmetatable({}, mt) local b = setmetatable({}, mt) return a < b", + "local mt = {__newindex = function(t, k, v) rawset(t, k, v * 2) end} local t = setmetatable({}, mt) t.x = 5 return t.x", + "local ok, err = pcall(function() error('boom') end) return ok, err", + "local ok, err = pcall(function() local x = nil return x.y end) return ok, type(err)", + "return tostring(1) .. '-' .. tostring(2.5) .. '-' .. tostring(true)", + "local s = '' for i = 1, 8 do s = s .. i end return s", + "return string.format('%d %s %.2f', 7, 'x', 1.5)", + "return string.upper('abc'), string.sub('hello', 2, 4), #('hello')", + "return math.max(1, 9, 3), math.min(1, 9, 3), math.floor(2.7)", + "return 7 // 2, 7 % 2, 2 ^ 10, -7 // 2", + "return 5 & 3, 5 | 3, 5 ~ 3, ~0, 1 << 4, 256 >> 4", + "local t = {} for i = 1, 5 do table.insert(t, 6 - i) end table.sort(t) return table.concat(t, ',')", + """ + Animal = {} + Animal.__index = Animal + function Animal.new(name) local o = setmetatable({}, Animal) o.name = name return o end + function Animal:speak() return self.name .. ' speaks' end + local a = Animal.new('cat') + return a:speak() + """, + """ + local co = coroutine.create(function(a) + local b = coroutine.yield(a + 1) + return b * 2 + end) + local _, x = coroutine.resume(co, 1) + local _, y = coroutine.resume(co, 10) + return x, y + """, + """ + local i = 0 + ::top:: + i = i + 1 + if i < 5 then goto top end + return i + """, + """ + local function outer() + local n = 0 + return function() n = n + 1 return n end, function() return n end + end + local inc, get = outer() + inc() inc() + return get() + """, + """ + local t = {} + for i = 1, 4 do + for j = 1, 4 do + t[#t + 1] = i * j + end + end + return #t, t[1], t[16] + """, + "print('one') print(2) print(nil, true) return 'printed'", + # The folded `_k` forms have to reach the metamethod bridge with the + # constant boxed, and the fused upvalue-field forms have to reach + # `__index` / `__newindex` on `_ENV`. These are the interactions the + # fusions could plausibly break. + """ + local mt = { + __add = function(_, b) return 'ADD:' .. tostring(b) end, + __sub = function(_, b) return 'SUB:' .. tostring(b) end, + __mul = function(_, b) return 'MUL:' .. tostring(b) end + } + local t = setmetatable({}, mt) + function f(x) return x + 1, x - 2, x * 3 end + return f(t) + """, + """ + local mt = {__lt = function() return 'LT' end, __le = function() return 'LE' end} + local t = setmetatable({}, mt) + function f(x) return (x < 1), (x <= 1) end + return f(t) + """, + "function f(x) return x - 1 end return f('10')", + "function f(x) return x + 1, x - 1 end return f(math.maxinteger)", + "function f(x) return x * 2, x + 0.5 end return f(1.5)", + "function f(x) return x == 1, x == 'a', x == nil end return f(1)", + """ + setmetatable(_G, {__index = function(_, k) return 'G:' .. k end}) + function f() return missing_global end + return f() + """, + """ + local log = {} + setmetatable(_G, {__newindex = function(t, k, v) log[#log + 1] = k rawset(t, k, v) end}) + function f() written = 7 end + f() + return written, log[#log] + """ + ] + + describe "differential: peephole off vs on" do + for {source, index} <- Enum.with_index(@corpus) do + test "corpus ##{index} evaluates identically #{inspect(String.slice(source, 0, 40))}" do + source = unquote(source) + + assert run(source, peephole: false) == run(source, peephole: true) + end + end + + test "fixture files evaluate identically" do + for path <- Path.wildcard(Path.join(__DIR__, "../../fixtures/*.lua")), + match?({:ok, _}, Parser.parse_structured(File.read!(path))) do + source = File.read!(path) + + # Some fixtures exist to fail at run time; both sides must fail the + # same way. + assert run(source, peephole: false) == run(source, peephole: true), + "#{Path.basename(path)} diverged between peephole off and on" + end + end + end + + describe "differential: error rendering" do + @failing [ + "local n = nil return n + 1", + "local n = nil return 1 + n", + "local t = {} return t.a.b", + "local t = nil t.x = 1", + "return nil .. 'x'", + "return 'a' < 1", + "local f = nil return f()", + "error('explicit')", + "error({code = 1})", + "local t = setmetatable({}, {}) return t < t", + "assert(false, 'assert message')", + "local x = 'str' return x - 1", + "local h = math.huge return h .. {}", + "for i = 1, 'x' do end", + "local function f(n) return n * 2 end return f({})", + # The folded forms must render the same operand hint as the register + # forms they replaced. + "local function f(n) return n - 1 end return f({})", + "local function f(n) return n + 1 end return f('abc')", + "local function f(n) if n < 1 then return 0 end return n end return f({})" + ] + + for {source, index} <- Enum.with_index(@failing) do + test "failure ##{index} renders identically #{inspect(String.slice(source, 0, 40))}" do + source = unquote(source) + + {{:error, off}, _} = run(source, peephole: false) + {{:error, on}, _} = run(source, peephole: true) + + assert off == on + end + end + end + + # ── Randomized differential ───────────────────────────────────────────── + # + # Small integer programs over three locals: unconditional writes inside + # loop bodies, call-argument copies through helper functions, and reads + # after the loop — exactly the shapes move elision and constant folding + # rewrite, arranged by a generator instead of by hand. + + @program_vars ~w(a b c) + + defp gen_leaf(vars) do + one_of([ + member_of(vars), + map(integer(-9..9), &Integer.to_string/1) + ]) + end + + defp gen_expr(vars) do + leaf = gen_leaf(vars) + + one_of([ + leaf, + map({leaf, member_of(["+", "-"]), leaf}, fn {a, op, b} -> "(#{a} #{op} #{b})" end), + # Multiplication only by a literal keeps repeated self-multiplication + # from wandering into slow huge-integer territory. + map({leaf, integer(-9..9)}, fn {a, k} -> "(#{a} * #{k})" end), + map(leaf, fn a -> "id(#{a})" end), + map({leaf, leaf}, fn {a, b} -> "add2(#{a}, #{b})" end) + ]) + end + + defp gen_statement(loop_vars) do + expr = gen_expr(@program_vars ++ loop_vars) + + one_of([ + map({member_of(@program_vars), expr}, fn {v, e} -> "#{v} = #{e}" end), + map(expr, fn e -> "local t = #{e}" end) + ]) + end + + defp gen_body(loop_vars) do + map(list_of(gen_statement(loop_vars), min_length: 1, max_length: 4), &Enum.join(&1, "\n")) + end + + defp gen_loop do + one_of([ + map({integer(1..3), gen_body(["i"])}, fn {limit, body} -> + "for i = 1, #{limit} do\n#{body}\nend" + end), + map({integer(1..3), gen_body(["n"])}, fn {limit, body} -> + "local n = 0\nwhile n < #{limit} do\nn = n + 1\n#{body}\nend" + end), + map({integer(1..3), gen_body(["n"])}, fn {limit, body} -> + "local n = 0\nrepeat\nn = n + 1\n#{body}\nuntil n >= #{limit}" + end), + map(gen_body(["v"]), fn body -> + "for _, v in ipairs({1, 2, 3}) do\n#{body}\nend" + end) + ]) + end + + defp gen_program do + inits = list_of(integer(-9..9), length: 3) + loops = list_of(gen_loop(), min_length: 1, max_length: 2) + + map({inits, loops}, fn {[a, b, c], loops} -> + """ + local function id(x) return x end + local function add2(x, y) return x + y end + local a = #{a} + local b = #{b} + local c = #{c} + #{Enum.join(loops, "\n")} + return a, b, c, a + b + c + """ + end) + end + + describe "differential: randomized loop programs" do + property "every generated program evaluates identically with the pass off and on" do + check all(source <- gen_program(), max_runs: 300) do + assert run(source, peephole: false) == run(source, peephole: true) + end + end + end + + describe "register and instruction budgets" do + # Compiling the Lua 5.3 conformance suite is a far broader structural + # corpus than anything hand-written here: every construct the language + # has, at scale. These do not need to *run* to prove the pass never + # widens a frame or grows a body. + @suite_files Path.wildcard(Path.join(__DIR__, "../../lua53_tests/*.lua")) + + for path <- @suite_files do + test "#{Path.basename(path)} never widens the frame or grows the stream" do + source = File.read!(unquote(path)) + + case Parser.parse_structured(source) do + {:ok, ast} -> + {:ok, before} = Compiler.compile(ast, source: "suite.lua", peephole: false) + {:ok, after_pass} = Compiler.compile(ast, source: "suite.lua", peephole: true) + + zip_protos(before, after_pass, fn a, b -> + assert b.max_registers <= a.max_registers, + "max_registers grew from #{a.max_registers} to #{b.max_registers}" + + assert Codegen.instruction_peak(b.instructions) <= Codegen.instruction_peak(a.instructions), + "instruction_peak grew" + + assert count_instructions(b) <= count_instructions(a), + "instruction count grew" + + assert b.max_registers >= Codegen.instruction_peak(b.instructions), + "max_registers no longer covers the register peak" + end) + + {:error, _parse_errors} -> + # A few suite files are deliberately unparseable fragments. + :ok + end + end + end + + test "the corpus stays fully dispatcher-compiled" do + for source <- @corpus do + before = compile!(source, peephole: false) + after_pass = compile!(source) + + assert Bytecode.fully_compiled?(after_pass) == Bytecode.fully_compiled?(before), + "dispatcher coverage changed for: #{String.slice(source, 0, 60)}" + end + end + end +end diff --git a/test/lua/vm/upvalue_test.exs b/test/lua/vm/upvalue_test.exs index 72c51cf3..d7283e1f 100644 --- a/test/lua/vm/upvalue_test.exs +++ b/test/lua/vm/upvalue_test.exs @@ -409,7 +409,11 @@ defmodule Lua.VM.UpvalueTest do """ assert {:ok, ast} = Parser.parse(code) - assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + # The watermark is a scope-analysis property, so read it off the raw + # codegen stream. This chunk creates no closures, so the peephole pass + # drops the `close_upvalues` opcodes it would otherwise be observed + # through — correctly, but that hides what is under test here. + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua", peephole: false) assert [_, _] = thresholds = close_thresholds(proto) assert thresholds == Enum.uniq(thresholds) @@ -419,7 +423,8 @@ defmodule Lua.VM.UpvalueTest do # Same program as above, built through the public `Lua.AST.Builder` # rather than the parser, so the nodes start without `meta.id`. The two # `for` bodies are equal terms; only compile-time id stamping keeps - # their close-upvalue watermarks apart. + # their close-upvalue watermarks apart. The peephole pass is off for the + # same reason as above: it drops the opcodes the watermark is read from. chunk = Builder.chunk([ Builder.do_block([ @@ -432,7 +437,7 @@ defmodule Lua.VM.UpvalueTest do Builder.for_num("i", Builder.number(1), Builder.number(1), []) ]) - assert {:ok, proto} = Compiler.compile(chunk, source: "test.lua") + assert {:ok, proto} = Compiler.compile(chunk, source: "test.lua", peephole: false) assert [_, _] = thresholds = close_thresholds(proto) assert thresholds == Enum.uniq(thresholds) diff --git a/website/lib/website/lua_sandbox.ex b/website/lib/website/lua_sandbox.ex index 08c7056b..f7ed8484 100644 --- a/website/lib/website/lua_sandbox.ex +++ b/website/lib/website/lua_sandbox.ex @@ -459,6 +459,16 @@ defmodule Website.LuaSandbox do defp format_op_args(:get_field, [d, t, name | _]), do: ~s|r#{d}, r#{t}.#{name}| defp format_op_args(:set_field, [t, name, v | _]), do: ~s|r#{t}.#{name}, r#{v}| + # Peephole fusions: the `_k` family's right operand is an inline literal, + # and the upvalue-field pair indexes the upvalue table rather than a + # register. + defp format_op_args(op, [d, a, k | _]) + when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k], + do: "r#{d}, r#{a}, #{format_lit(k)}" + + defp format_op_args(:get_field_upvalue, [d, idx, name | _]), do: ~s|r#{d}, up[#{idx}].#{name}| + defp format_op_args(:set_field_upvalue, [idx, name, v | _]), do: ~s|up[#{idx}].#{name}, r#{v}| + defp format_op_args(:set_list, [t, s, c, o]), do: "r#{t}, start=#{s}, count=#{count(c)}, off=#{o}" diff --git a/website/lib/website_web/bytecode.ex b/website/lib/website_web/bytecode.ex index 8bcac046..4571e8f5 100644 --- a/website/lib/website_web/bytecode.ex +++ b/website/lib/website_web/bytecode.ex @@ -60,7 +60,20 @@ defmodule DemoWeb.Bytecode do do: "text-secondary font-semibold" def op_class(op) - when op in [:new_table, :set_list, :get_table, :set_table, :get_field, :set_field], + when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k], + do: "text-secondary font-semibold" + + def op_class(op) + when op in [ + :new_table, + :set_list, + :get_table, + :set_table, + :get_field, + :set_field, + :get_field_upvalue, + :set_field_upvalue + ], do: "text-info font-semibold" def op_class(_), do: "text-success font-semibold" @@ -115,6 +128,13 @@ defmodule DemoWeb.Bytecode do defp do_format(:get_field, [d, t, name | _]), do: ~s|r#{d}, r#{t}.#{name}| defp do_format(:set_field, [t, name, v | _]), do: ~s|r#{t}.#{name}, r#{v}| + defp do_format(op, [d, a, k | _]) + when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k], + do: "r#{d}, r#{a}, #{format_lit(k)}" + + defp do_format(:get_field_upvalue, [d, idx, name | _]), do: ~s|r#{d}, up[#{idx}].#{name}| + defp do_format(:set_field_upvalue, [idx, name, v | _]), do: ~s|up[#{idx}].#{name}, r#{v}| + defp do_format(:set_list, [t, s, c, o]), do: "r#{t}, start=#{s}, count=#{c}, off=#{o}" @@ -218,6 +238,17 @@ defmodule DemoWeb.Bytecode do equal: "Compare `a == b` and write `true` or `false` to a register.", less_than: "Compare `a < b` and write the boolean result.", less_equal: "Compare `a <= b` and write the boolean result.", + add_k: + "Compute `a + K` where `K` is a literal baked into the instruction — no register is spent materialising the constant.", + subtract_k: "Compute `a - K` with the literal inline. This is what `n - 1` compiles to.", + multiply_k: "Compute `a * K` with the literal inline.", + equal_k: "Compare `a == K` against an inline literal.", + less_than_k: "Compare `a < K` against an inline literal. This is what `n < 2` compiles to.", + less_equal_k: "Compare `a <= K` against an inline literal.", + get_field_upvalue: + "Read `up[i].name` in one step. Every global read inside a function is this shape: `_ENV` is an upvalue and the name is a field of it.", + set_field_upvalue: + "Write `up[i].name` in one step — the global-assignment counterpart of `get_field_upvalue`.", bitwise_and: "Compute `a & b` (bitwise AND).", bitwise_or: "Compute `a | b` (bitwise OR).", bitwise_xor: "Compute `a ~ b` (bitwise XOR — the binary `~`).", @@ -359,6 +390,15 @@ defmodule DemoWeb.Bytecode do op when op in [:equal, :less_than, :less_equal] -> "rD, rA, rB" + op when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k] -> + "rD, rA, K" + + :get_field_upvalue -> + "rD, up[i], name" + + :set_field_upvalue -> + "up[i], name, rS" + op when op in [:negate, :not, :length, :bitwise_not] -> "rD, rS" diff --git a/website/lib/website_web/live/opcodes_live.ex b/website/lib/website_web/live/opcodes_live.ex index 963ad3b9..d3d50a76 100644 --- a/website/lib/website_web/live/opcodes_live.ex +++ b/website/lib/website_web/live/opcodes_live.ex @@ -79,6 +79,22 @@ defmodule DemoWeb.OpcodesLive do blurb: "Build nested functions with captured upvalues.", ops: [:closure] }, + %{ + id: "fused", + title: "Fused forms", + blurb: + "Emitted by the peephole pass, never by codegen directly. Each collapses a pair of instructions the naive lowering would otherwise have produced.", + ops: [ + :add_k, + :subtract_k, + :multiply_k, + :equal_k, + :less_than_k, + :less_equal_k, + :get_field_upvalue, + :set_field_upvalue + ] + }, %{ id: "meta", title: "Metadata", From 9ed16138bd80796653d781aa55cf1b7012dd73ee Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 21:42:32 -0400 Subject: [PATCH 09/13] =?UTF-8?q?vm:=20cheaper=20call=20convention=20?= =?UTF-8?q?=E2=80=94=20one-allocation=20register=20files,=20static-arity?= =?UTF-8?q?=20opcodes,=20call=5Fself=20fusion=20(#405)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/lua/compiler/bytecode.ex | 62 ++ lib/lua/compiler/codegen.ex | 4 + lib/lua/compiler/instruction.ex | 8 + lib/lua/compiler/peephole.ex | 403 +++++++++- lib/lua/vm/dispatcher.ex | 719 ++++++++++++++++-- lib/lua/vm/executor.ex | 36 + test/lua/compiler/bytecode_test.exs | 103 ++- .../compiler/max_registers_invariant_test.exs | 36 + test/lua/compiler/peephole_test.exs | 224 +++++- 9 files changed, 1524 insertions(+), 71 deletions(-) diff --git a/lib/lua/compiler/bytecode.ex b/lib/lua/compiler/bytecode.ex index 11f92484..e12fd66d 100644 --- a/lib/lua/compiler/bytecode.ex +++ b/lib/lua/compiler/bytecode.ex @@ -125,6 +125,37 @@ defmodule Lua.Compiler.Bytecode do @op_get_field_upvalue 68 @op_set_field_upvalue 69 + # Static-arity call variants. `arg_count` is fixed at encode time for + # every ordinary call site, so the small arities that dominate real + # programs get their own tag: the dispatcher's handler then reads the + # arguments out of the caller's registers at constant offsets and builds + # the callee's register file as one literal tuple, with no argument-copy + # loop and no clamp against the callee's parameter count. Wider arities + # keep `@op_call_one` / `@op_call_zero`. + @op_call_one_0 70 + @op_call_one_1 71 + @op_call_one_2 72 + @op_call_zero_0 73 + @op_call_zero_1 74 + @op_call_zero_2 75 + + # Self-recursive call: the callee is the prototype making the call, so the + # opcode carries no closure and the dispatcher recurses with the code, + # prototype, and upvalues it is already holding. + # `Lua.Compiler.Peephole` emits the instruction; codegen never does. + @op_call_self 76 + + # The call opcodes whose tuple is `{tag, base, name_hint}` before + # `annotate_line/2` bakes the source line in. + @static_arity_calls [ + @op_call_one_0, + @op_call_one_1, + @op_call_one_2, + @op_call_zero_0, + @op_call_zero_1, + @op_call_zero_2 + ] + @doc """ Compile a prototype, populating its `bytecode` field on success. @@ -204,6 +235,8 @@ defmodule Lua.Compiler.Bytecode do # would otherwise leak `:0:`. Other opcodes pass through unchanged — # line attribution for non-call raise sites (binops, indexing, concat) # is deferred. + defp annotate_line({tag, base, hint}, line) when tag in @static_arity_calls, do: {tag, base, hint, line} + defp annotate_line({@op_call_one, base, args, hint}, line), do: {@op_call_one, base, args, hint, line} defp annotate_line({@op_call_zero, base, args, hint}, line), do: {@op_call_zero, base, args, hint, line} @@ -211,6 +244,9 @@ defmodule Lua.Compiler.Bytecode do defp annotate_line({@op_call_multi, base, args, results, hint}, line), do: {@op_call_multi, base, args, results, hint, line} + defp annotate_line({@op_call_self, base, args, results, hint}, line), + do: {@op_call_self, base, args, results, hint, line} + defp annotate_line({@op_generic_for, base, var_regs, body}, line), do: {@op_generic_for, base, var_regs, body, line} defp annotate_line(other, _line), do: other @@ -363,7 +399,18 @@ defmodule Lua.Compiler.Bytecode do # `{:multi, _}` arg shape, negative arg count) → `:call_multi`. # This is the B5c-v2 catch-all for the multi-return machinery. # + # The 0-, 1-, and 2-argument forms of each get a static-arity tag; they + # cover the overwhelming majority of call sites, and their handlers skip + # the argument-copy loop entirely. + # # `name_hint` is preserved on every shape for error attribution. + defp encode({:call, base, 0, 1, name_hint}), do: {:ok, {@op_call_one_0, base, name_hint}} + defp encode({:call, base, 1, 1, name_hint}), do: {:ok, {@op_call_one_1, base, name_hint}} + defp encode({:call, base, 2, 1, name_hint}), do: {:ok, {@op_call_one_2, base, name_hint}} + defp encode({:call, base, 0, 0, name_hint}), do: {:ok, {@op_call_zero_0, base, name_hint}} + defp encode({:call, base, 1, 0, name_hint}), do: {:ok, {@op_call_zero_1, base, name_hint}} + defp encode({:call, base, 2, 0, name_hint}), do: {:ok, {@op_call_zero_2, base, name_hint}} + defp encode({:call, base, arg_count, 1, name_hint}) when is_integer(arg_count) and arg_count >= 0 do {:ok, {@op_call_one, base, arg_count, name_hint}} end @@ -376,6 +423,14 @@ defmodule Lua.Compiler.Bytecode do {:ok, {@op_call_multi, base, arg_count, result_count, name_hint}} end + # `:call_self` keeps one shape across every result count — the handler + # derives the frame's result destination the same way `@op_call_multi` + # does. Only the statically known argument counts the peephole fuses + # reach here; anything else would have kept its `:call`. + defp encode({:call_self, base, arg_count, result_count, name_hint}) when is_integer(arg_count) and arg_count >= 0 do + {:ok, {@op_call_self, base, arg_count, result_count, name_hint}} + end + # `:return` shapes: # # - `count == 1` is the hot path (every recursive return in fib/factorial). @@ -683,4 +738,11 @@ defmodule Lua.Compiler.Bytecode do def op_equal_k, do: @op_equal_k def op_get_field_upvalue, do: @op_get_field_upvalue def op_set_field_upvalue, do: @op_set_field_upvalue + def op_call_one_0, do: @op_call_one_0 + def op_call_one_1, do: @op_call_one_1 + def op_call_one_2, do: @op_call_one_2 + def op_call_zero_0, do: @op_call_zero_0 + def op_call_zero_1, do: @op_call_zero_1 + def op_call_zero_2, do: @op_call_zero_2 + def op_call_self, do: @op_call_self end diff --git a/lib/lua/compiler/codegen.ex b/lib/lua/compiler/codegen.ex index c515a5d9..a303fd53 100644 --- a/lib/lua/compiler/codegen.ex +++ b/lib/lua/compiler/codegen.ex @@ -126,6 +126,10 @@ defmodule Lua.Compiler.Codegen do defp instruction_size({:vararg, base, _}), do: base + 1 defp instruction_size({:self, base, _obj, _name, _hint}), do: base + 2 defp instruction_size({:call, base, _ac, _rc, _hint}), do: base + 1 + + # `Lua.Compiler.Peephole` emits this: same register extent as `:call`, + # minus the callee the fused form no longer loads. + defp instruction_size({:call_self, base, _ac, _rc, _hint}), do: base + 1 defp instruction_size({:source_line, _line, _src}), do: 0 defp instruction_size({:close_upvalues, _threshold}), do: 0 defp instruction_size({:label, _name, _level, _block_path}), do: 0 diff --git a/lib/lua/compiler/instruction.ex b/lib/lua/compiler/instruction.ex index 84341717..4a6decb5 100644 --- a/lib/lua/compiler/instruction.ex +++ b/lib/lua/compiler/instruction.ex @@ -123,6 +123,14 @@ defmodule Lua.Compiler.Instruction do def closure(dest, proto_index), do: {:closure, dest, proto_index} def call(base, arg_count, result_count, name_hint \\ nil), do: {:call, base, arg_count, result_count, name_hint} + # A call whose callee is the prototype making it, reached through the + # `local function` self-reference upvalue. Operands mirror `call/4` minus + # the closure: the engines already hold the prototype and its upvalues, so + # `base` is only the argument base and the result destination. + # `Lua.Compiler.Peephole` emits these; codegen never does. + def call_self(base, arg_count, result_count, name_hint \\ nil), + do: {:call_self, base, arg_count, result_count, name_hint} + def tail_call(base, arg_count, name_hint \\ nil), do: {:tail_call, base, arg_count, name_hint} def return_instr(base, count), do: {:return, base, count} def return_vararg, do: {:return_vararg} diff --git a/lib/lua/compiler/peephole.ex b/lib/lua/compiler/peephole.ex index 396b287a..3c8cfdb8 100644 --- a/lib/lua/compiler/peephole.ex +++ b/lib/lua/compiler/peephole.ex @@ -25,6 +25,11 @@ defmodule Lua.Compiler.Peephole do 6. **Redundant `close_upvalues` removal** in functions that create no closures — nothing in such a function can open an upvalue cell over one of its own registers, so there is never anything to close. + 7. **Self-recursive call fusion.** A `local function` whose name can be + proved to be permanently bound to itself calls itself through + `:call_self`, which carries no callee: the engines recurse into the + prototype they are already running instead of loading the closure + out of its upvalue cell first. Both engines run the rewritten stream: the interpreter (`Lua.VM.Executor`) walks `instructions` directly, the dispatcher @@ -130,6 +135,12 @@ defmodule Lua.Compiler.Peephole do # `{op, dest, a, constant}` shapes produced by rule 2. @compare_k_ops [:equal_k, :less_than_k, :less_equal_k] + # How far a rewrite may look ahead for the instruction it pairs with. + # Codegen separates a producer from its consumer by the instructions that + # build the other operands, so the pair is rarely adjacent, but it is + # always close. + @window 16 + @doc """ Optimise a prototype and every prototype nested within it. @@ -142,6 +153,7 @@ defmodule Lua.Compiler.Peephole do def optimize(%Prototype{} = proto) do prototypes = Enum.map(proto.prototypes, &optimize/1) instructions = optimize_instructions(proto.instructions, prototypes) + prototypes = fuse_self_calls(instructions, prototypes) %{ proto @@ -235,6 +247,389 @@ defmodule Lua.Compiler.Peephole do defp collapse_roundtrip_pairs([instr | rest]), do: [instr | collapse_roundtrip_pairs(rest)] + # ── Rule 7: self-recursive call fusion ────────────────────────────────── + # + # `local function f(…) … f(…) … end` reaches its own name through an + # upvalue cell, so every self-call loads the closure out of the cell into + # a scratch register before calling it. When the cell provably can only + # ever hold the closure that is already running, the load is pure + # overhead and the call needs no closure value at all: both engines are + # already holding the prototype and its upvalue tuple. `:call_self` says + # exactly that, and the load disappears. + # + # The proof is deliberately narrow, and every step fails closed — any + # doubt leaves the generic `:call` in place: + # + # * the parent binds the child with the shape codegen emits for + # `local function`: a `:closure`, the copy into the local's register, + # and the `set_open_upvalue` that publishes it to the body's cell; + # * that register is written exactly twice in the whole parent — by the + # binding itself — so no assignment anywhere can rebind the name; + # * the parent only ever reads it to call it, and the scratch register + # the closure passed through on its way there is overwritten before + # anything reads it back, so the closure value never becomes an + # operand of anything else; + # * inside the child, the self-upvalue is likewise only ever loaded to + # be called, is never assigned, and is captured by no nested + # prototype — nothing can hand the value (or the cell behind it) to + # `debug.setupvalue`; + # * the child is not vararg, and contains no `goto`. + # + # Mutual recursion never qualifies: each name is a separate register bound + # to a different prototype, so the callee is never the caller. Neither does + # `local f; f = function() … f() … end` — an assignment publishes the + # closure to the cell without ever copying it into the local's register, so + # it is not the binding shape, and the `local f` declaration has already + # written that register anyway. + + defp fuse_self_calls(instructions, prototypes) do + prototypes + |> Enum.with_index() + |> Enum.map(fn {child, index} -> + case self_upvalue(instructions, prototypes, child, index) do + {:ok, upvalue_index} -> + %{child | instructions: fuse_self_block(child.instructions, [], upvalue_index, child.prototypes)} + + :error -> + child + end + end) + end + + # The child's own upvalue index for its self-reference, when every step of + # the proof holds. + defp self_upvalue(instructions, prototypes, child, index) do + with false <- child.is_vararg, + false <- contains_goto?(child.instructions), + {:ok, reg, scratch, after_binding} <- binding_site(instructions, index), + {:ok, upvalue_index} <- self_descriptor(child, reg), + 2 <- count_writes(instructions, reg), + true <- scratch_confined?(after_binding, scratch, prototypes), + true <- callee_only_register?(instructions, prototypes, reg, index), + true <- callee_only_upvalue?(child, upvalue_index) do + {:ok, upvalue_index} + else + _ -> :error + end + end + + # The `local function` binding shape, before and after move elision. + # The trailing `set_open_upvalue` is what publishes the closure to the + # cell the body reads, so a binding without it cannot be self-recursive. + defp binding_site(instructions, index) do + Enum.find_value(blocks(instructions), :error, fn block -> binding_in_block(block, index) end) + end + + defp binding_in_block( + [{:closure, scratch, index}, {:move, reg, scratch}, {:set_open_upvalue, reg, scratch} | rest], + index + ), do: {:ok, reg, scratch, rest} + + defp binding_in_block([{:closure, reg, index}, {:set_open_upvalue, reg, reg} | rest], index), do: {:ok, reg, nil, rest} + + defp binding_in_block([_instr | rest], index), do: binding_in_block(rest, index) + defp binding_in_block([], _index), do: nil + + # The child's descriptor for the parent register it was bound to. Exactly + # one must match: descriptors are deduplicated per function, so two hits + # would mean a shape this analysis does not model. + defp self_descriptor(%Prototype{upvalue_descriptors: descriptors}, reg) do + descriptors + |> Enum.with_index() + |> Enum.filter(fn + {{:parent_local, ^reg, _name}, _index} -> true + _descriptor -> false + end) + |> case do + [{_descriptor, index}] -> {:ok, index} + _ -> :error + end + end + + # The closure passes through a scratch register on its way into the + # local's. The binding is the last thing that may read it: the very next + # write to it — and codegen reuses call bases and temporaries eagerly, so + # there always is one — must come before any read. Running out of block + # without finding that write leaves the question open, which counts + # against the fusion. The move-elided shape has no scratch register. + defp scratch_confined?(_after_binding, nil, _prototypes), do: true + + defp scratch_confined?(after_binding, scratch, prototypes) do + scan(after_binding, scratch, prototypes, false) === :killed + end + + # Every read of the bound register in the parent is either part of the + # binding, or a load of the closure that is consumed as a callee and + # nothing else. A `:closure` for any *other* prototype that captures the + # register counts as a read, so a second function closing over the name + # ends the analysis here. + defp callee_only_register?(instructions, prototypes, reg, index) do + Enum.all?(blocks(instructions), fn block -> + callee_only_block?(block, prototypes, reg, index) + end) + end + + defp callee_only_block?([], _prototypes, _reg, _index), do: true + + defp callee_only_block?([instr | rest], prototypes, reg, index) do + cond do + binding_read?(instr, reg, index) -> + callee_only_block?(rest, prototypes, reg, index) + + match?({:get_open_upvalue, _dest, ^reg}, instr) -> + callee_only?(rest, :erlang.element(2, instr), prototypes) and + callee_only_block?(rest, prototypes, reg, index) + + reads_here?(instr, reg, prototypes) -> + false + + true -> + callee_only_block?(rest, prototypes, reg, index) + end + end + + defp binding_read?({:closure, _dest, index}, _reg, index), do: true + defp binding_read?({:set_open_upvalue, reg, _source}, reg, _index), do: true + defp binding_read?(_instr, _reg, _index), do: false + + # The mirror image inside the child: the self-upvalue may be loaded only + # to be called, never assigned, and never captured by a nested prototype + # (which would put both the value and its cell within reach of code this + # analysis cannot see). + defp callee_only_upvalue?(%Prototype{} = child, upvalue_index) do + not captures_upvalue?(child.prototypes, upvalue_index) and + Enum.all?(blocks(child.instructions), fn block -> + callee_only_upvalue_block?(block, child.prototypes, upvalue_index) + end) + end + + defp captures_upvalue?(prototypes, upvalue_index) do + Enum.any?(prototypes, fn %Prototype{upvalue_descriptors: descriptors} -> + Enum.any?(descriptors, &match?({:parent_upvalue, ^upvalue_index, _name}, &1)) + end) + end + + defp callee_only_upvalue_block?([], _prototypes, _upvalue_index), do: true + + defp callee_only_upvalue_block?([{:set_upvalue, upvalue_index, _source} | _rest], _prototypes, upvalue_index), do: false + + defp callee_only_upvalue_block?([{:get_upvalue, dest, upvalue_index} | rest], prototypes, upvalue_index) do + callee_only?(rest, dest, prototypes) and callee_only_upvalue_block?(rest, prototypes, upvalue_index) + end + + # A field access fused against the self index (`f.x` / `f.x = v`, fused + # by rule 3 before this analysis runs) indexes the value in the cell + # without ever staging it in a register, so the `:get_upvalue` clause + # above never sees it. The value is participating in something other + # than a call, which is exactly what the proof forbids. + defp callee_only_upvalue_block?( + [{:get_field_upvalue, _dest, upvalue_index, _name, _hint} | _rest], + _prototypes, + upvalue_index + ), do: false + + defp callee_only_upvalue_block?( + [{:set_field_upvalue, upvalue_index, _name, _value, _hint} | _rest], + _prototypes, + upvalue_index + ), do: false + + defp callee_only_upvalue_block?([_instr | rest], prototypes, upvalue_index) do + callee_only_upvalue_block?(rest, prototypes, upvalue_index) + end + + # True when the value just loaded into `reg` is consumed as the callee of + # a call and by nothing else. The first instruction that touches `reg` + # settles it: a call with `reg` as its base is the callee position, any + # other read is the value escaping, and an overwrite means nothing ever + # read it. + defp callee_only?([], _reg, _prototypes), do: false + + defp callee_only?([instr | rest], reg, prototypes) do + cond do + match?({:call, ^reg, _arg_count, _result_count, _hint}, instr) -> true + reads?(instr, reg, prototypes) -> false + writes?(instr, reg) -> true + true -> callee_only?(rest, reg, prototypes) + end + end + + # ── Rule 7: the rewrite ───────────────────────────────────────────────── + # + # The load and the call it feeds are rarely adjacent — the arguments are + # computed in between — so the call is searched for within the same + # bounded window `elide_move/3` uses, over instructions transparent to + # the scratch register. Dropping the load is invisible: the register it + # wrote is read by nothing but the call (proved above), and the load + # itself can neither raise nor be observed. + + defp fuse_self_block([], _future, _upvalue_index, _prototypes), do: [] + + defp fuse_self_block([{:get_upvalue, reg, upvalue_index} = load | rest], future, upvalue_index, prototypes) do + case find_self_call(rest, reg, prototypes, @window, []) do + {:ok, call, skipped, after_call} -> + if self_call_safe?(call, reg, [after_call | future], prototypes) do + {:call, base, arg_count, result_count, hint} = call + + Enum.reverse(skipped, [ + {:call_self, base, arg_count, result_count, hint} + | fuse_self_block(after_call, future, upvalue_index, prototypes) + ]) + else + [load | fuse_self_block(rest, future, upvalue_index, prototypes)] + end + + :error -> + [load | fuse_self_block(rest, future, upvalue_index, prototypes)] + end + end + + defp fuse_self_block([instr | rest], future, upvalue_index, prototypes) do + fused = + case bodies(instr) do + [] -> + instr + + list -> + list + |> Enum.zip(body_futures(instr, [rest | future])) + |> Enum.map(fn {body, body_future} -> fuse_self_block(body, body_future, upvalue_index, prototypes) end) + |> then(&put_bodies(instr, &1)) + end + + [fused | fuse_self_block(rest, future, upvalue_index, prototypes)] + end + + # A discarded self-call is the one shape that leaves the callee register + # holding whatever was there before instead of the closure. Every other + # result shape either writes the register back (`1`, `-2`, `n > 1`) or + # returns straight through the frame (`-1`), so nothing can observe the + # difference. + defp self_call_safe?({:call, base, _arg_count, 0, _hint}, base, future, prototypes) do + dead?(base, future, prototypes) + end + + defp self_call_safe?(_call, _reg, _future, _prototypes), do: true + + defp find_self_call( + [{:call, reg, arg_count, result_count, _hint} = call | after_call], + reg, + _prototypes, + _budget, + skipped + ) + when is_integer(arg_count) and arg_count >= 0 and is_integer(result_count) and result_count >= -2 do + {:ok, call, skipped, after_call} + end + + defp find_self_call([instr | rest], reg, prototypes, budget, skipped) when budget > 0 do + if transparent?(instr, reg, prototypes) do + find_self_call(rest, reg, prototypes, budget - 1, [instr | skipped]) + else + :error + end + end + + defp find_self_call(_instructions, _reg, _prototypes, _budget, _skipped), do: :error + + # ── Instruction-tree walks ────────────────────────────────────────────── + + # Every straight-line instruction list in a tree: the list itself and, + # recursively, each nested body. Analyses that ask per-block questions + # walk these instead of `reads?/3`'s folded-in view of nested bodies. + defp blocks(instructions) do + [instructions | Enum.flat_map(instructions, fn instr -> Enum.flat_map(bodies(instr), &blocks/1) end)] + end + + defp count_writes(instructions, reg) do + Enum.reduce(blocks(instructions), 0, fn block, acc -> + acc + Enum.count(block, fn instr -> may_write_here?(instr, reg) end) + end) + end + + # `reads?/3` folds the nested bodies of a branch or loop into its answer. + # The per-block walks above visit those bodies themselves, so here each + # instruction is asked only about its own operands. + defp reads_here?({:test, test_reg, _then_body, _else_body}, reg, _protos), do: test_reg === reg + defp reads_here?({:test_and, _dest, source, _body}, reg, _protos), do: source === reg + defp reads_here?({:test_or, _dest, source, _body}, reg, _protos), do: source === reg + defp reads_here?({:while_loop, _cond_body, test_reg, _body}, reg, _protos), do: test_reg === reg + defp reads_here?({:repeat_loop, _body, _cond_body, test_reg}, reg, _protos), do: test_reg === reg + + defp reads_here?({:numeric_for, base, _loop_var, _body}, reg, _protos), do: reg >= base and reg <= base + 2 + defp reads_here?({:generic_for, base, _var_regs, _body}, reg, _protos), do: reg >= base and reg <= base + 2 + defp reads_here?(instr, reg, protos), do: reads?(instr, reg, protos) + + # Tags whose whole write effect `writes?/2` models exactly. + @modelled_writers [ + :load_nil, + :self, + :load_constant, + :load_boolean, + :load_env, + :move, + :get_upvalue, + :get_open_upvalue, + :get_global, + :new_table, + :get_table, + :get_field, + :get_field_upvalue, + :closure, + :length, + :not, + :negate, + :bitwise_not, + :concatenate + ] ++ @binary_ops ++ @arith_k_ops ++ @compare_ops ++ @compare_k_ops + + # Tags that establish no register at all: stores write through a table, + # an upvalue cell, or nothing. + @non_writers [ + :set_table, + :set_field, + :set_field_upvalue, + :set_upvalue, + :set_list, + :close_upvalues, + :source_line, + :return, + :return_vararg, + :goto, + :label + ] + + # "Could executing this change what `reg` holds, directly or through its + # open-upvalue cell?" The mirror of `writes?/2`, which answers the narrow + # "kills the value on every path" question the liveness scan needs: this + # one over-reports, so an unrecognised shape ends the fusion rather than + # quietly invalidating its premise. + defp may_write_here?(:break, _reg), do: false + defp may_write_here?({:set_open_upvalue, cell_reg, _source}, reg), do: cell_reg === reg + defp may_write_here?({:call, base, _arg_count, _results, _hint}, reg), do: reg >= base + defp may_write_here?({:call_self, base, _arg_count, _results, _hint}, reg), do: reg >= base + defp may_write_here?({:vararg, base, _count}, reg), do: reg >= base + + defp may_write_here?({:numeric_for, base, loop_var, _body}, reg), + do: (reg >= base and reg <= base + 2) or loop_var === reg + + defp may_write_here?({:generic_for, base, _var_regs, _body}, reg), do: reg >= base + defp may_write_here?({:test_and, dest, _source, _body}, reg), do: dest === reg + defp may_write_here?({:test_or, dest, _source, _body}, reg), do: dest === reg + + defp may_write_here?(instr, reg) when is_tuple(instr) do + tag = :erlang.element(1, instr) + + cond do + tag in @non_writers -> false + tag in @modelled_writers -> writes?(instr, reg) + bodies(instr) != [] -> false + true -> true + end + end + + defp may_write_here?(_instr, _reg), do: true + # ── Rules 1–3: fusion ─────────────────────────────────────────────────── # # A single left-to-right walk. Each rewrite collapses two instructions into @@ -394,8 +789,6 @@ defmodule Lua.Compiler.Peephole do # Only straight-line shapes qualify, so no branch, loop, or `break` can # observe the reordering. - @window 16 - defp elide_move([producer | rest], future, prototypes) do with true <- coalescible?(producer), tmp = :erlang.element(2, producer), @@ -696,6 +1089,12 @@ defmodule Lua.Compiler.Peephole do # Unrecognised shape — including `:goto` / `:label`, which only reach here # via the register-extent scan. Assume it observes everything. + # + # `:call_self` also lands here *deliberately*: it is emitted by the last + # rewrite in the pipeline, so no scan needs a precise answer today, and + # the reads-everything default fails closed if one ever sees it. Any + # reordering that runs another rewrite over fused instructions must give + # it a real clause rather than quietly ride on this catch-all. defp reads?(_instr, _reg, _protos), do: true defp captures?(prototypes, index, reg) do diff --git a/lib/lua/vm/dispatcher.ex b/lib/lua/vm/dispatcher.ex index f75a34df..e04d96b7 100644 --- a/lib/lua/vm/dispatcher.ex +++ b/lib/lua/vm/dispatcher.ex @@ -138,6 +138,21 @@ defmodule Lua.VM.Dispatcher do @op_get_field_upvalue 68 @op_set_field_upvalue 69 + # Static-arity call variants. Same semantics as `@op_call_one` / + # `@op_call_zero`; the encoder picks them whenever the argument count is + # one of the small ones that dominate real programs, and the handler then + # reads the arguments at constant offsets into the caller's registers. + @op_call_one_0 70 + @op_call_one_1 71 + @op_call_one_2 72 + @op_call_zero_0 73 + @op_call_zero_1 74 + @op_call_zero_2 75 + + # Self-recursive call. The callee is the prototype currently running, so + # the loop already holds everything the call needs. + @op_call_self 76 + @doc """ Execute a compiled prototype against `args` and `state`. """ @@ -893,9 +908,7 @@ defmodule Lua.VM.Dispatcher do # setelement write when it sees it. {@op_call_zero, base, arg_count, name_hint, line} -> - func_value = :erlang.element(base + 1, regs) - - case func_value do + case :erlang.element(base + 1, regs) do {:compiled_closure, callee_proto, callee_upvalues} -> callee_regs = init_callee_regs(callee_proto, regs, base + 1, arg_count) # Compiled callees may be vararg functions. Testing `is_vararg` @@ -925,51 +938,209 @@ defmodule Lua.VM.Dispatcher do %{} ) - {:lua_closure, _, _} = closure -> - args = collect_args(regs, base + 1, arg_count) + func_value -> + call_zero_bridge( + func_value, + collect_args(regs, base + 1, arg_count), + name_hint, + line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) + end + + {@op_call_one, base, arg_count, name_hint, line} -> + case :erlang.element(base + 1, regs) do + {:compiled_closure, callee_proto, callee_upvalues} -> + callee_regs = init_callee_regs(callee_proto, regs, base + 1, arg_count) + + callee_proto = + if callee_proto.is_vararg, + do: setup_vararg_proto(callee_proto, regs, base + 1, arg_count), + else: callee_proto + + # Frame is a tuple, not a map: pattern-matching a tuple in + # `return_one/7` skips Map.fetch! lookups and lets the BEAM + # bind everything in a single `move` per slot. + frame = {code, pc + 1, regs, upvalues, proto, cont, base, ou} call_info = {proto.source, 0, name_hint} instruction_count = tick(state, instruction_count, cs, cd) ckdepth(state, cs, cd) - state = %{ - state - | call_stack: [call_info | cs], - call_depth: cd + 1, - instruction_count: instruction_count - } + dispatch( + callee_proto.bytecode, + 1, + callee_regs, + callee_upvalues, + callee_proto, + state, + [], + [frame | frames], + instruction_count, + [call_info | cs], + cd + 1, + %{} + ) - {_results, state} = Executor.call_function(closure, args, state) - instruction_count = state.instruction_count - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + func_value -> + call_one_bridge( + func_value, + collect_args(regs, base + 1, arg_count), + base, + name_hint, + line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) + end - _ -> - args = collect_args(regs, base + 1, arg_count) + # ── Static-arity calls ────────────────────────────────────────── + # + # Same semantics as `@op_call_one` / `@op_call_zero`, with the + # argument count fixed at encode time. The arguments come out of the + # caller's registers at constant offsets and the callee's register + # file is one literal tuple: no copy loop, no clamp against the + # callee's parameter count, no blank tuple to overwrite. `name_hint` + # and `line` ride along unchanged, so tracebacks and native-call + # error attribution are identical to the generic forms. + + {@op_call_one_0, base, name_hint, line} -> + case :erlang.element(base + 1, regs) do + {:compiled_closure, callee_proto, callee_upvalues} -> + callee_regs = mkregs0(regs_size(callee_proto)) - state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} + callee_proto = + if callee_proto.is_vararg, + do: %{callee_proto | varargs: []}, + else: callee_proto - {_results, state} = - Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) + frame = {code, pc + 1, regs, upvalues, proto, cont, base, ou} + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) - instruction_count = state.instruction_count + dispatch( + callee_proto.bytecode, + 1, + callee_regs, + callee_upvalues, + callee_proto, + state, + [], + [frame | frames], + instruction_count, + [call_info | cs], + cd + 1, + %{} + ) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + func_value -> + call_one_bridge( + func_value, + [], + base, + name_hint, + line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) end - {@op_call_one, base, arg_count, name_hint, line} -> - func_value = :erlang.element(base + 1, regs) + {@op_call_one_1, base, name_hint, line} -> + case :erlang.element(base + 1, regs) do + {:compiled_closure, callee_proto, callee_upvalues} -> + a1 = :erlang.element(base + 2, regs) + callee_regs = mkregs1(regs_size(callee_proto), callee_proto.param_count, a1) - case func_value do + callee_proto = + if callee_proto.is_vararg, + do: setup_vararg_proto(callee_proto, regs, base + 1, 1), + else: callee_proto + + frame = {code, pc + 1, regs, upvalues, proto, cont, base, ou} + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) + + dispatch( + callee_proto.bytecode, + 1, + callee_regs, + callee_upvalues, + callee_proto, + state, + [], + [frame | frames], + instruction_count, + [call_info | cs], + cd + 1, + %{} + ) + + func_value -> + call_one_bridge( + func_value, + [:erlang.element(base + 2, regs)], + base, + name_hint, + line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) + end + + {@op_call_one_2, base, name_hint, line} -> + case :erlang.element(base + 1, regs) do {:compiled_closure, callee_proto, callee_upvalues} -> - callee_regs = init_callee_regs(callee_proto, regs, base + 1, arg_count) + a1 = :erlang.element(base + 2, regs) + a2 = :erlang.element(base + 3, regs) + callee_regs = mkregs2(regs_size(callee_proto), callee_proto.param_count, a1, a2) callee_proto = if callee_proto.is_vararg, - do: setup_vararg_proto(callee_proto, regs, base + 1, arg_count), + do: setup_vararg_proto(callee_proto, regs, base + 1, 2), else: callee_proto - # Frame is a tuple, not a map: pattern-matching a tuple in - # `return_one/7` skips Map.fetch! lookups and lets the BEAM - # bind everything in a single `move` per slot. frame = {code, pc + 1, regs, upvalues, proto, cont, base, ou} call_info = {proto.source, 0, name_hint} instruction_count = tick(state, instruction_count, cs, cd) @@ -990,51 +1161,239 @@ defmodule Lua.VM.Dispatcher do %{} ) - {:lua_closure, _, _} = closure -> - args = collect_args(regs, base + 1, arg_count) + func_value -> + call_one_bridge( + func_value, + [:erlang.element(base + 2, regs), :erlang.element(base + 3, regs)], + base, + name_hint, + line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) + end + + {@op_call_zero_0, base, name_hint, line} -> + case :erlang.element(base + 1, regs) do + {:compiled_closure, callee_proto, callee_upvalues} -> + callee_regs = mkregs0(regs_size(callee_proto)) + + callee_proto = + if callee_proto.is_vararg, + do: %{callee_proto | varargs: []}, + else: callee_proto + + frame = {code, pc + 1, regs, upvalues, proto, cont, :discard, ou} call_info = {proto.source, 0, name_hint} instruction_count = tick(state, instruction_count, cs, cd) ckdepth(state, cs, cd) - state = %{ - state - | call_stack: [call_info | cs], - call_depth: cd + 1, - instruction_count: instruction_count - } + dispatch( + callee_proto.bytecode, + 1, + callee_regs, + callee_upvalues, + callee_proto, + state, + [], + [frame | frames], + instruction_count, + [call_info | cs], + cd + 1, + %{} + ) - {results, state} = Executor.call_function(closure, args, state) - instruction_count = state.instruction_count + func_value -> + call_zero_bridge( + func_value, + [], + name_hint, + line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) + end - first = - case results do - [v | _] -> v - [] -> nil - end + {@op_call_zero_1, base, name_hint, line} -> + case :erlang.element(base + 1, regs) do + {:compiled_closure, callee_proto, callee_upvalues} -> + a1 = :erlang.element(base + 2, regs) + callee_regs = mkregs1(regs_size(callee_proto), callee_proto.param_count, a1) - regs = :erlang.setelement(base + 1, regs, first) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + callee_proto = + if callee_proto.is_vararg, + do: setup_vararg_proto(callee_proto, regs, base + 1, 1), + else: callee_proto - _ -> - args = collect_args(regs, base + 1, arg_count) + frame = {code, pc + 1, regs, upvalues, proto, cont, :discard, ou} + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) - state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} + dispatch( + callee_proto.bytecode, + 1, + callee_regs, + callee_upvalues, + callee_proto, + state, + [], + [frame | frames], + instruction_count, + [call_info | cs], + cd + 1, + %{} + ) - {results, state} = - Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) + func_value -> + call_zero_bridge( + func_value, + [:erlang.element(base + 2, regs)], + name_hint, + line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) + end - instruction_count = state.instruction_count + {@op_call_zero_2, base, name_hint, line} -> + case :erlang.element(base + 1, regs) do + {:compiled_closure, callee_proto, callee_upvalues} -> + a1 = :erlang.element(base + 2, regs) + a2 = :erlang.element(base + 3, regs) + callee_regs = mkregs2(regs_size(callee_proto), callee_proto.param_count, a1, a2) - first = - case results do - [v | _] -> v - [] -> nil - end + callee_proto = + if callee_proto.is_vararg, + do: setup_vararg_proto(callee_proto, regs, base + 1, 2), + else: callee_proto - regs = :erlang.setelement(base + 1, regs, first) - dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + frame = {code, pc + 1, regs, upvalues, proto, cont, :discard, ou} + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) + + dispatch( + callee_proto.bytecode, + 1, + callee_regs, + callee_upvalues, + callee_proto, + state, + [], + [frame | frames], + instruction_count, + [call_info | cs], + cd + 1, + %{} + ) + + func_value -> + call_zero_bridge( + func_value, + [:erlang.element(base + 2, regs), :erlang.element(base + 3, regs)], + name_hint, + line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) end + # ── Self-recursive calls ──────────────────────────────────────── + # + # The callee is the prototype this loop is already running, reached + # through the `local function` self-reference the compiler proved + # can never be rebound (`Lua.Compiler.Peephole`). There is no closure + # value to read and no upvalue cell to resolve: the frame keeps the + # caller's state, and the loop re-enters `proto.bytecode` with the + # same `upvalues` and a fresh register file. + # + # Everything else about the call is a generic call: a frame is + # pushed so tracebacks and `debug.getinfo` see the same stack, the + # instruction budget ticks, the depth check runs at the same point + # with the same depth, and the callee starts with an empty + # open-upvalue map while the caller's rides in the frame. + + # `line` is carried for shape parity with the other call opcodes and + # for tooling that reads the encoded stream; the handler never needs + # it, because a self-call can never reach the native bridge that + # attributes errors to a source line, and the frame's own line slot + # is `0` for every dispatcher-side call. + {@op_call_self, base, arg_count, result_count, name_hint, _line} -> + callee_regs = init_callee_regs(proto, regs, base + 1, arg_count) + + callee_proto = + if proto.is_vararg, + do: setup_vararg_proto(proto, regs, base + 1, arg_count), + else: proto + + dest = + case result_count do + 0 -> :discard + 1 -> base + _ -> {:multi, base, result_count} + end + + frame = {code, pc + 1, regs, upvalues, proto, cont, dest, ou} + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) + + dispatch( + callee_proto.bytecode, + 1, + callee_regs, + upvalues, + callee_proto, + state, + [], + [frame | frames], + instruction_count, + [call_info | cs], + cd + 1, + %{} + ) + # ── Returns ───────────────────────────────────────────────────── # # In-mode `:call_one` returns thread the single value through @@ -2079,15 +2438,251 @@ defmodule Lua.VM.Dispatcher do clear_nils(:erlang.setelement(dest + 1, regs, nil), dest + 1, n - 1) end + # ── Non-dispatcher callees ────────────────────────────────────────────── + # + # Everything that is not a `:compiled_closure` leaves the dispatch loop: + # interpreted Lua closures through `Executor.call_function/3`, natives and + # callables through `Executor.dispatcher_call_function/6`. Both grow the + # Erlang stack by one frame at the mode boundary. Factored out of the call + # handlers so the generic and static-arity opcodes share one copy — + # `line` reaches the native bridge for error attribution exactly as it did + # when these branches were inline. + + defp call_zero_bridge( + {:lua_closure, _, _} = closure, + args, + name_hint, + _line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) do + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) + + state = %{ + state + | call_stack: [call_info | cs], + call_depth: cd + 1, + instruction_count: instruction_count + } + + {_results, state} = Executor.call_function(closure, args, state) + instruction_count = state.instruction_count + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + defp call_zero_bridge( + func_value, + args, + name_hint, + line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) do + state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} + + {_results, state} = Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) + + instruction_count = state.instruction_count + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + defp call_one_bridge( + {:lua_closure, _, _} = closure, + args, + base, + name_hint, + _line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) do + call_info = {proto.source, 0, name_hint} + instruction_count = tick(state, instruction_count, cs, cd) + ckdepth(state, cs, cd) + + state = %{ + state + | call_stack: [call_info | cs], + call_depth: cd + 1, + instruction_count: instruction_count + } + + {results, state} = Executor.call_function(closure, args, state) + instruction_count = state.instruction_count + regs = :erlang.setelement(base + 1, regs, first_result(results)) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + defp call_one_bridge( + func_value, + args, + base, + name_hint, + line, + code, + pc, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) do + state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} + + {results, state} = Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) + + instruction_count = state.instruction_count + regs = :erlang.setelement(base + 1, regs, first_result(results)) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + defp first_result([v | _]), do: v + defp first_result([]), do: nil + defp init_callee_regs(callee_proto, src_regs, src_off, arg_count) do # Exact-sized like `init_regs/2`; runs on every compiled-closure call, # so sizing to the callee's honest register peak (no slack) is what keeps # deep recursion off a per-frame over-allocation (issue #324). - regs = Tuple.duplicate(nil, max(callee_proto.max_registers, callee_proto.param_count)) - copy_n = min(arg_count, callee_proto.param_count) - copy_regs(src_regs, src_off, regs, 0, copy_n) + param_count = callee_proto.param_count + + mkregs( + max(callee_proto.max_registers, param_count), + min(arg_count, param_count), + src_regs, + src_off + ) + end + + # ── Callee register files ─────────────────────────────────────────────── + # + # Building the callee's register tuple as `Tuple.duplicate/2` plus one + # `setelement` per argument allocates it `1 + copied` times over: every + # `setelement` copies the whole tuple. The generated clauses below build + # the finished tuple in a single literal construction for the small + # (size, parameter-count) shapes ordinary Lua functions have. A + # parameterless shape allocates nothing at all — an all-`nil` tuple is a + # compile-time literal. + # + # `size` is the callee's register-file width, `params` the number of + # arguments actually landing in parameter slots (already clamped to the + # callee's `param_count` by the caller), `src`/`off` the caller's register + # tuple and the 0-based index of the first argument in it. Shapes past the + # generated bounds fall back to duplicate-and-copy; vararg overflow is + # separate machinery (`setup_vararg_proto/4`) and unaffected, as is the + # `grow_regs/2` growth contract for multi-return and vararg writes. + @mkregs_max_size 16 + @mkregs_max_params 6 + + for size <- 1..@mkregs_max_size, params <- 0..min(size, @mkregs_max_params) do + src = Macro.var(:src, __MODULE__) + off = Macro.var(:off, __MODULE__) + + slots = + Enum.map(1..params//1, fn i -> + quote(do: :erlang.element(unquote(i) + unquote(off), unquote(src))) + end) ++ List.duplicate(nil, size - params) + + head_src = if params == 0, do: quote(do: _src), else: src + head_off = if params == 0, do: quote(do: _off), else: off + + defp mkregs(unquote(size), unquote(params), unquote(head_src), unquote(head_off)) do + unquote({:{}, [], slots}) + end + end + + defp mkregs(size, params, src, off) do + copy_regs(src, off, Tuple.duplicate(nil, size), 0, params) + end + + # Static-arity constructors. The arguments arrive already read out of the + # caller's registers, so the only run-time inputs are the callee's file + # width and its parameter count — the second clause of each size covers + # every callee that takes at least that many parameters, which is why no + # `min/2` clamp is needed. Surplus arguments (callee declares fewer + # parameters than the call site passes) are dropped here exactly as + # `copy_regs/5` dropped them. + @compile {:inline, regs_size: 1} + defp regs_size(%{max_registers: max_registers, param_count: param_count}) do + max(max_registers, param_count) + end + + for size <- 1..@mkregs_max_size do + nils = List.duplicate(nil, size) + a1 = Macro.var(:a1, __MODULE__) + a2 = Macro.var(:a2, __MODULE__) + + defp mkregs0(unquote(size)), do: unquote({:{}, [], nils}) + + defp mkregs1(unquote(size), 0, _a1), do: unquote({:{}, [], nils}) + + defp mkregs1(unquote(size), _params, unquote(a1)), do: unquote({:{}, [], [a1 | List.duplicate(nil, size - 1)]}) + + defp mkregs2(unquote(size), 0, _a1, _a2), do: unquote({:{}, [], nils}) + + defp mkregs2(unquote(size), 1, unquote(a1), _a2), do: unquote({:{}, [], [a1 | List.duplicate(nil, size - 1)]}) + + if size >= 2 do + defp mkregs2(unquote(size), _params, unquote(a1), unquote(a2)), + do: unquote({:{}, [], [a1, a2 | List.duplicate(nil, size - 2)]}) + end + end + + # Wide-register-file fallbacks. `size` is `max(max_registers, param_count)`, + # so a callee with `params` parameters always has room for them. + defp mkregs0(size), do: Tuple.duplicate(nil, size) + + defp mkregs1(size, params, a1) when params >= 1 do + :erlang.setelement(1, Tuple.duplicate(nil, size), a1) end + defp mkregs1(size, _params, _a1), do: Tuple.duplicate(nil, size) + + defp mkregs2(size, params, a1, a2) when params >= 2 do + :erlang.setelement(2, :erlang.setelement(1, Tuple.duplicate(nil, size), a1), a2) + end + + defp mkregs2(size, params, a1, _a2) when params >= 1 do + :erlang.setelement(1, Tuple.duplicate(nil, size), a1) + end + + defp mkregs2(size, _params, _a1, _a2), do: Tuple.duplicate(nil, size) + defp copy_regs(_src, _src_i, dst, _dst_i, 0), do: dst defp copy_regs(src, src_i, dst, dst_i, n) do diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index e36e11c8..ccad3618 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -1325,6 +1325,42 @@ defmodule Lua.VM.Executor do do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) end + # ── call_self — a call whose callee is the running prototype ─────────────── + # + # `Lua.Compiler.Peephole` emits this for a `local function` it has proved + # is permanently bound to itself, dropping the `get_upvalue` that used to + # load the closure into the callee register. The dispatcher recurses + # without materialising a closure at all; the interpreter has no such + # short cut to take, so it reconstructs the value the upvalue cell holds — + # this prototype closed over these upvalues — and runs an ordinary call. + # Identical work, identical results, identical errors. + defp do_execute( + [{:call_self, base, arg_count, result_count, name_hint} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + tag = if proto.bytecode, do: :compiled_closure, else: :lua_closure + regs = put_elem(regs, base, {tag, proto, upvalues}) + + do_execute( + [{:call, base, arg_count, result_count, name_hint} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) + end + # ── call — Lua closures via CPS frames; native functions inline ──────────── defp do_execute( diff --git a/test/lua/compiler/bytecode_test.exs b/test/lua/compiler/bytecode_test.exs index b24b2f07..f0ed210e 100644 --- a/test/lua/compiler/bytecode_test.exs +++ b/test/lua/compiler/bytecode_test.exs @@ -351,6 +351,105 @@ defmodule Lua.Compiler.BytecodeTest do end end + # Every shape a `:call` can encode to. The static-arity variants replace + # `@op_call_one` / `@op_call_zero` at the small argument counts, so a test + # that looks for "the call opcodes" has to accept all of them. + defp call_tags do + [ + Bytecode.op_call_one(), + Bytecode.op_call_zero(), + Bytecode.op_call_multi(), + Bytecode.op_call_one_0(), + Bytecode.op_call_one_1(), + Bytecode.op_call_one_2(), + Bytecode.op_call_zero_0(), + Bytecode.op_call_zero_1(), + Bytecode.op_call_zero_2() + ] + end + + describe "static-arity call encoding" do + defp encoded_tags(%Prototype{bytecode: bytecode}) do + bytecode |> Tuple.to_list() |> Enum.map(&:erlang.element(1, &1)) + end + + test "picks a dedicated tag per small argument count" do + proto = + compile!(""" + function f(g) + g() + g(1) + g(1, 2) + g(1, 2, 3) + local a = g() + local b = g(1) + local c = g(1, 2) + local d = g(1, 2, 3) + return a, b, c, d + end + """) + + [f] = proto.prototypes + tags = encoded_tags(f) + + assert Bytecode.op_call_zero_0() in tags + assert Bytecode.op_call_zero_1() in tags + assert Bytecode.op_call_zero_2() in tags + assert Bytecode.op_call_one_0() in tags + assert Bytecode.op_call_one_1() in tags + assert Bytecode.op_call_one_2() in tags + + # Three arguments is past the specialised set, so both result shapes + # fall back to the generic opcodes that carry `arg_count`. + assert Enum.count(tags, &(&1 == Bytecode.op_call_zero())) == 1 + assert Enum.count(tags, &(&1 == Bytecode.op_call_one())) == 1 + end + + test "the specialised tuples keep the name hint and the line" do + proto = + compile!(""" + function f(t) + local x = t.method(1) + return x + end + """) + + [f] = proto.prototypes + + assert [{tag, _base, {:field, "method", {:local, "t"}}, 2}] = + f.bytecode |> Tuple.to_list() |> Enum.filter(&(:erlang.element(1, &1) == Bytecode.op_call_one_1())) + + assert tag == Bytecode.op_call_one_1() + end + end + + describe "self-recursive call encoding" do + test "a fused self-call encodes to one tag across every result shape" do + proto = + compile!(""" + local function f(n) + if n == 0 then return 0 end + f(n - 1) + local x = f(n - 1) + return x + f(n - 2) + end + return f(3) + """) + + [f] = proto.prototypes + self_calls = Enum.filter(Tuple.to_list(f.bytecode), &(:erlang.element(1, &1) == Bytecode.op_call_self())) + + assert length(self_calls) == 3 + assert Enum.all?(self_calls, &(tuple_size(&1) == 6)) + + # {tag, base, arg_count, result_count, name_hint, line}: the discarded + # call, the one-result call, and the operand call. + assert Enum.map(self_calls, &:erlang.element(4, &1)) == [0, 1, 1] + assert Enum.all?(self_calls, &(:erlang.element(5, &1) == {:upvalue, "f"})) + assert Enum.all?(self_calls, &(:erlang.element(6, &1) > 0)) + end + end + describe "call opcodes carry the source line" do test "@op_call_one bakes the line of the call site into its tuple" do # `pairs(x)` is a `:call` with result_count > 0 used as an rvalue, @@ -371,7 +470,7 @@ defmodule Lua.Compiler.BytecodeTest do |> Tuple.to_list() |> Enum.filter(fn op -> tag = :erlang.element(1, op) - tag in [Bytecode.op_call_one(), Bytecode.op_call_zero(), Bytecode.op_call_multi()] + tag in call_tags() end) # Every call opcode carries a positive source line at its last slot. @@ -418,7 +517,7 @@ defmodule Lua.Compiler.BytecodeTest do nested_call_lines = nested_body |> Tuple.to_list() - |> Enum.filter(fn op -> :erlang.element(1, op) == Bytecode.op_call_zero() end) + |> Enum.filter(fn op -> :erlang.element(1, op) == Bytecode.op_call_zero_1() end) |> Enum.map(fn op -> :erlang.element(tuple_size(op), op) end) assert nested_call_lines == [3] diff --git a/test/lua/compiler/max_registers_invariant_test.exs b/test/lua/compiler/max_registers_invariant_test.exs index b5908a53..ea5385ee 100644 --- a/test/lua/compiler/max_registers_invariant_test.exs +++ b/test/lua/compiler/max_registers_invariant_test.exs @@ -62,6 +62,16 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do op == Bytecode.op_test() -> [1] op == Bytecode.op_call_zero() -> [1] op == Bytecode.op_call_one() -> [1] + # Static-arity calls carry no `arg_count` operand: the dispatcher + # reads the arguments at `base + 1 .. base + arity`, so the arity is + # part of the opcode's register extent even though no slot spells it. + op == Bytecode.op_call_one_0() -> [1] + op == Bytecode.op_call_zero_0() -> [1] + op == Bytecode.op_call_one_1() -> :call_arity_1 + op == Bytecode.op_call_zero_1() -> :call_arity_1 + op == Bytecode.op_call_one_2() -> :call_arity_2 + op == Bytecode.op_call_zero_2() -> :call_arity_2 + op == Bytecode.op_call_self() -> :call_self op == Bytecode.op_return_one() -> [1] op == Bytecode.op_return_zero() -> [] # Table opcodes (B5b-v2). @@ -184,6 +194,20 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do var_max = Enum.reduce(Tuple.to_list(var_regs_tuple), -1, &max/2) Enum.max([base + 2, var_max, max_register_used(body_bc)]) + :call_arity_1 -> + # {tag, base, hint, line}: reads base (the callee) and base + 1. + :erlang.element(2, instr) + 1 + + :call_arity_2 -> + :erlang.element(2, instr) + 2 + + :call_self -> + # {tag, base, arg_count, result_count, hint, line}: the fused + # callee is the running prototype, so no register holds it — the + # dispatcher reads the arguments at base+1..base+arg_count and + # writes the result back at base. + :erlang.element(2, instr) + :erlang.element(3, instr) + :self -> # {tag, base, obj_reg, method, hint}: reads obj_reg, writes base # (method) and base+1 (receiver). The base+1 write is not a syntactic @@ -250,6 +274,18 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do return fib(n - 1) + fib(n - 2) end """}, + # A self-recursive `local function` is the shape the peephole pass + # fuses into `:call_self` (a global recursion like the entry above + # never fuses), so this is what puts that opcode in front of the + # walker. + {"self-recursive local function (:call_self)", + """ + local function fib(n) + if n < 2 then return n end + return fib(n - 1) + fib(n - 2) + end + return fib(5) + """}, {"deep temp chain (string.upper)", """ function f(s) diff --git a/test/lua/compiler/peephole_test.exs b/test/lua/compiler/peephole_test.exs index bc1c01b6..bba93030 100644 --- a/test/lua/compiler/peephole_test.exs +++ b/test/lua/compiler/peephole_test.exs @@ -28,17 +28,17 @@ defmodule Lua.Compiler.PeepholeTest do proto end - defp run(source, opts) do + defp run(source, opts, lua \\ nil) do proto = compile!(source, opts) chunk = %Lua.Chunk{prototype: proto} fn -> result = try do - {results, _lua} = Lua.eval!(Lua.new(), chunk) + {results, _lua} = Lua.eval!(lua || Lua.new(), chunk) {:ok, results} rescue - e -> {:error, Lua.format_exception(e)} + e -> {:error, Lua.format_exception(e), Exception.message(e)} end send(self(), {:result, result}) @@ -79,6 +79,8 @@ defmodule Lua.Compiler.PeepholeTest do length(opcodes(proto)) end + defp self_calls(%Prototype{} = proto), do: Enum.count(opcodes(proto), &(&1 == :call_self)) + # Walks a prototype tree pairwise, applying `fun` to each matched pair. defp zip_protos(%Prototype{} = a, %Prototype{} = b, fun) do fun.(a, b) @@ -413,6 +415,152 @@ defmodule Lua.Compiler.PeepholeTest do end end + describe "self-recursive call fusion" do + test "a recursive local function calls itself without loading itself" do + proto = + compile!(""" + local function fib(n) + if n < 2 then return n end + return fib(n-1) + fib(n-2) + end + return fib(15) + """) + + [fib] = proto.prototypes + + assert self_calls(proto) == 2 + assert Enum.count(opcodes(fib), &(&1 == :call)) == 0 + # The two `get_upvalue`s that loaded the closure are gone with them. + assert Enum.count(opcodes(fib), &(&1 == :get_upvalue)) == 0 + assert tuple_size(fib.bytecode) == 8 + assert Bytecode.fully_compiled?(proto) + + assert {[610], _} = + Lua.eval!("local function fib(n) if n < 2 then return n end return fib(n-1) + fib(n-2) end return fib(15)") + end + + test "covers the return-position and statement-call result shapes" do + tail = compile!("local function c(i, a) if i == 0 then return a end return c(i-1, a+i) end return c(10, 0)") + + statement = + compile!("local n = 0 local function loop(i) if i == 0 then return end n = n + i loop(i-1) end loop(4) return n") + + assert self_calls(tail) == 1 + assert self_calls(statement) == 1 + + assert {[55], _} = + Lua.eval!("local function c(i, a) if i == 0 then return a end return c(i-1, a+i) end return c(10, 0)") + + assert {[10], _} = + Lua.eval!( + "local n = 0 local function loop(i) if i == 0 then return end n = n + i loop(i-1) end loop(4) return n" + ) + end + + # Each of these is a way the name could stop meaning "this function". + # The analysis has to see every one of them. + @refused [ + {"mutual recursion", + "local isodd, iseven function isodd(n) if n == 0 then return false end return iseven(n-1) end " <> + "function iseven(n) if n == 0 then return true end return isodd(n-1) end return isodd(7)"}, + {"the name is reassigned afterwards", + "local function f(n) if n == 0 then return 'f' end return f(n-1) end local a = f(2) f = function() return 'g' end return a, f(1)"}, + {"the function is an anonymous value assigned to a pre-declared local", + "local f f = function(n) if n == 0 then return 0 end return f(n-1) end return f(3)"}, + {"a closure captures the name and reassigns it", + "local function f(n) if n == 0 then return 0 end return f(n-1) end " <> + "local function rebind() f = function() return 99 end end local a = f(2) rebind() return a, f(2)"}, + {"a closure captures the name without reassigning it", + "local function f(n) if n == 0 then return 0 end return f(n-1) end local function call() return f(2) end return call()"}, + {"a closure declared inside the body captures the self-upvalue", + "local function f(n) local function g() return f end if n == 0 then return g end return f(n-1) end return type(f(3))"}, + # Field accesses through the name fuse into `get_field_upvalue` / + # `set_field_upvalue` before the self-call analysis runs, so they + # index the closure straight out of its cell with no `get_upvalue` + # left to see — the analysis has to recognise the fused shapes on + # the self index as the value escaping a callee-only life. + {"a field read through the name", + "local function f(n) if n == 99 then return f.x end if n == 0 then return 0 end return f(n-1) end return f(3)"}, + {"a field write through the name", + "local function f(n) if n == 99 then f.x = 1 return 0 end if n == 0 then return 0 end return f(n-1) end return f(3)"}, + {"the function is passed as a value", + "local function f(n) if n == 0 then return 0 end return f(n-1) end local function apply(g) return g(2) end return apply(f)"}, + {"the function is handed to pcall", + "local function f(n) if n == 0 then return 0 end return f(n-1) end return pcall(f, 3)"}, + {"the body is vararg", + "local function f(...) if select('#', ...) == 0 then return 'done' end return f(select(2, ...)) end return f(1, 2)"}, + {"the body uses goto", + "local function f(n) ::top:: if n > 0 then n = n - 1 goto top end return f end return type(f(3))"} + ] + + for {label, source} <- @refused do + test "refuses when #{label}" do + source = unquote(source) + + assert self_calls(compile!(source)) == 0 + assert run(source, peephole: false) == run(source, peephole: true) + end + end + + test "the interpreter runs the fused opcode too" do + # `and` / `or` still fall back to the interpreter, so this child + # prototype carries `:call_self` with no bytecode behind it. + source = """ + local function f(n, flag) + if n == 0 then return 0 end + local x = flag and 1 or 2 + return x + f(n-1, flag) + end + return f(3, true) + """ + + proto = compile!(source) + [f] = proto.prototypes + + assert f.bytecode == nil + assert self_calls(proto) == 1 + assert run(source, peephole: false) == run(source, peephole: true) + assert {[3], _} = Lua.eval!(source) + end + end + + # `max_call_depth` has to be finite for the overflow shapes to terminate, + # and the fusion has to survive the shape — a self-recursive function + # handed straight to `pcall` escapes and keeps its generic call, so those + # programs wrap the recursion one level down. + defp bounded, do: Lua.new(max_call_depth: 200) + + describe "differential: errors through fused self-calls" do + @through_self [ + {"catchable stack overflow", + "local function outer() local function r(n) return 1 + r(n+1) end return r(1) end " <> + "local ok, err = pcall(outer) return ok, err"}, + {"the depth the overflow trips at", + "local d = 0 local function outer() local function r(n) d = d + 1 return 1 + r(n+1) end return r(1) end " <> + "local ok = pcall(outer) return ok, d"}, + {"an uncaught overflow's rendered traceback", "local function r(n) return 1 + r(n+1) end return r(1)"}, + {"error() raised under the recursion", + "local function outer() local function r(n) if n == 0 then error('deep') end return 1 + r(n-1) end return r(3) end " <> + "local ok, err = pcall(outer) return ok, err"}, + {"a type error raised under the recursion", + "local function outer() local function r(n) if n == 0 then return nil .. 'x' end return r(n-1) end return r(2) end return outer()"}, + {"debug.traceback through the frames", + "local function outer() local function r(n) if n == 0 then return debug.traceback('T') end return r(n-1) end return r(3) end return outer()"}, + {"debug.getinfo through the frames", + "local function outer() local function r(n) if n == 0 then return debug.getinfo(2).what end return r(n-1) end return r(2) end return outer()"} + ] + + for {{label, source}, index} <- Enum.with_index(@through_self) do + test "#{label} is unchanged by the fusion" do + source = unquote(source) + + assert self_calls(compile!(source)) > 0, "shape ##{unquote(index)} stopped fusing; it no longer tests anything" + + assert run(source, [peephole: false], bounded()) == run(source, [peephole: true], bounded()) + end + end + end + # A corpus broad enough that a mis-scoped rewrite shows up somewhere: # every control-flow shape, closures over loop variables, metatables, # varargs, multi-return, coroutines, string building, and pcall. @@ -528,7 +676,72 @@ defmodule Lua.Compiler.PeepholeTest do function f() written = 7 end f() return written, log[#log] + """, + # Self-recursion in every result shape the fusion accepts, next to the + # shapes it has to refuse — mutual recursion, a reassigned name, a + # pre-declared local holding an anonymous function, and a name a second + # closure captures. + "local function fib(n) if n < 2 then return n end return fib(n-1) + fib(n-2) end return fib(10)", + "local function c(i, a) if i == 0 then return a end return c(i-1, a+i) end return c(25, 0)", + "local n = 0 local function loop(i) if i == 0 then return end n = n + i loop(i-1) end loop(6) return n", + "local function f(n) if n == 0 then return 1, 2, 3 end return f(n-1) end return f(3)", + "local function f(n) if n == 0 then return 1, 2 end local a, b = f(n-1) return a + b end return f(2)", + "local function f(n) if n == 0 then return 0 end return f(n-1) end return {f(2), f(0)}", """ + local isodd, iseven + function isodd(n) if n == 0 then return false end return iseven(n-1) end + function iseven(n) if n == 0 then return true end return isodd(n-1) end + return isodd(9), iseven(9) + """, + "local function f(n) if n == 0 then return 'f' end return f(n-1) end local a = f(3) f = function() return 'g' end return a, f(1)", + "local f f = function(n) if n == 0 then return 0 end return f(n-1) + 1 end return f(4)", + """ + local function fact(n) if n <= 1 then return 1 end return n * fact(n-1) end + local function rebind() fact = function() return -1 end end + local before = fact(5) + rebind() + return before, fact(5) + """, + "local function f(n) if n == 0 then return 0 end return f(n-1) end return pcall(f, 3)", + "local function v(...) if select('#', ...) == 0 then return 'done' end return v(select(2, ...)) end return v(1, 2, 3)", + """ + local t = {} + for i = 1, 3 do + local function f(n) if n == 0 then return i end return f(n-1) end + t[i] = f(2) + end + return t[1], t[2], t[3] + """, + # The same shape, but each closure outlives the iteration that made it. + # Storing it is a read of the name, so the fusion has to decline — and + # each surviving closure still has to see its own iteration's upvalue. + """ + local fns = {} + for i = 1, 3 do + local function f(n) if n == 0 then return i end return f(n-1) end + fns[i] = f + end + return fns[1](2), fns[2](2), fns[3](2) + """, + """ + local t = {} + local function f(n) if n == 0 then return 'base' end return f(n-1) end + t.f = f + return t.f(3), f(3) + """, + """ + local function walk(node, depth) + if node == nil then return depth end + return walk(node.next, depth + 1) + end + return walk({next = {next = {next = nil}}}, 0) + """, + # Argument counts on both sides of the static-arity encoding boundary. + "local function a0() return 1 end local function a1(x) return x end local function a2(x, y) return x + y end " <> + "local function a3(x, y, z) return x + y + z end return a0(), a1(2), a2(3, 4), a3(5, 6, 7)", + "local function over(a, b) return a, b end return over(1, 2, 3, 4)", + "local function under(a, b, c) return a, b, c end return under(1)", + "print(1) print(1, 2) print(1, 2, 3) return 'printed'" ] describe "differential: peephole off vs on" do @@ -581,10 +794,11 @@ defmodule Lua.Compiler.PeepholeTest do test "failure ##{index} renders identically #{inspect(String.slice(source, 0, 40))}" do source = unquote(source) - {{:error, off}, _} = run(source, peephole: false) - {{:error, on}, _} = run(source, peephole: true) + {{:error, off, off_message}, _} = run(source, peephole: false) + {{:error, on, on_message}, _} = run(source, peephole: true) assert off == on + assert off_message == on_message end end end From bca8b142b5113382f9ca63526b84a0ae96ece4ef Mon Sep 17 00:00:00 2001 From: Simon de Haan Date: Tue, 28 Jul 2026 03:43:01 +0200 Subject: [PATCH 10/13] fix(pattern): honour ^ anchor in gsub and literal caret in gmatch (#406) Co-authored-by: Claude Co-authored-by: Dave Lucia --- lib/lua/vm/stdlib/pattern.ex | 47 ++++++- test/lua/vm/stdlib/pattern_anchor_test.exs | 146 +++++++++++++++++++++ 2 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 test/lua/vm/stdlib/pattern_anchor_test.exs diff --git a/lib/lua/vm/stdlib/pattern.ex b/lib/lua/vm/stdlib/pattern.ex index 953dc13f..b5155f3c 100644 --- a/lib/lua/vm/stdlib/pattern.ex +++ b/lib/lua/vm/stdlib/pattern.ex @@ -74,7 +74,16 @@ defmodule Lua.VM.Stdlib.Pattern do Global match - returns list of all matches as {start, stop, captures}. """ def gmatch(subject, pattern) do - {_anchored, pattern_elems} = compile(pattern) + # Lua 5.3 §6.4 (`string.gmatch`): a leading `^` does not work as an anchor + # (it would prevent the iteration). PUC-Lua's gmatch_aux never strips + # it, so `match` sees it as an ordinary character — recompile the + # pattern with the caret kept as a literal. + pattern_elems = + case compile(pattern) do + {false, elems} -> elems + {true, _elems} -> compile_elements(pattern, []) + end + gmatch_from(subject, 0, byte_size(subject), pattern_elems, [], -1) end @@ -121,7 +130,7 @@ defmodule Lua.VM.Stdlib.Pattern do `gsub_stateful/5` instead. """ def gsub(subject, pattern, repl, max_n \\ nil) do - {_anchored, pattern_elems} = compile(pattern) + {anchored, pattern_elems} = compile(pattern) stateful_repl = if is_function(repl, 1) do @@ -130,8 +139,7 @@ defmodule Lua.VM.Stdlib.Pattern do repl end - {result, count, _state} = - gsub_from(subject, 0, byte_size(subject), pattern_elems, stateful_repl, max_n, 0, [], nil, false) + {result, count, _state} = do_gsub(subject, anchored, pattern_elems, stateful_repl, max_n, nil) {result, count} end @@ -144,8 +152,35 @@ defmodule Lua.VM.Stdlib.Pattern do changes back out. String and table replacements are state-pass-through. """ def gsub_stateful(subject, pattern, repl, state, max_n \\ nil) do - {_anchored, pattern_elems} = compile(pattern) - gsub_from(subject, 0, byte_size(subject), pattern_elems, repl, max_n, 0, [], state, false) + {anchored, pattern_elems} = compile(pattern) + do_gsub(subject, anchored, pattern_elems, repl, max_n, state) + end + + # A `^`-anchored pattern matches only at the start of the subject + # (Lua 5.3 §6.4.1), so gsub performs at most one replacement there and + # keeps the remainder untouched — PUC-Lua str_gsub's `anchor` flag makes + # its scan loop run exactly once. An unanchored pattern scans every + # position via gsub_from/10. + + defp do_gsub(subject, true, _pattern, _repl, max_n, state) when max_n != nil and max_n <= 0 do + {subject, 0, state} + end + + defp do_gsub(subject, true, pattern, repl, _max_n, state) do + case match_pattern(subject, 0, pattern, subject) do + {:match, end_pos, captures} -> + whole_match = binary_part(subject, 0, end_pos) + {replacement, state} = apply_replacement(repl, whole_match, captures, state) + rest = binary_part(subject, end_pos, byte_size(subject) - end_pos) + {IO.iodata_to_binary([replacement, rest]), 1, state} + + :nomatch -> + {subject, 0, state} + end + end + + defp do_gsub(subject, false, pattern, repl, max_n, state) do + gsub_from(subject, 0, byte_size(subject), pattern, repl, max_n, 0, [], state, false) end # Lua 5.3.3+ semantics: an empty match that starts where the *previous* diff --git a/test/lua/vm/stdlib/pattern_anchor_test.exs b/test/lua/vm/stdlib/pattern_anchor_test.exs new file mode 100644 index 00000000..636b9050 --- /dev/null +++ b/test/lua/vm/stdlib/pattern_anchor_test.exs @@ -0,0 +1,146 @@ +defmodule Lua.VM.Stdlib.PatternAnchorTest do + use ExUnit.Case, async: true + + # Pins Lua 5.3 §6.4.1 ^-anchor semantics for string.gsub and + # string.gmatch: a pattern beginning with `^` matches only at the start + # of the subject, so gsub performs at most one replacement and reports a + # count of 0 or 1 (mirrors the `anchor` handling in PUC-Lua lstrlib.c + # str_gsub). In gmatch a leading `^` does not anchor — PUC-Lua matches + # it as a literal caret (§6.4, `string.gmatch`). Expected values verified + # against PUC-Lua. + + alias Lua.VM.Stdlib.Pattern + + describe "anchored string.gsub" do + test "replaces only the leading occurrence" do + assert {["Yax", 1], _} = Lua.eval!(~S|return string.gsub("xax", "^x", "Y")|) + end + + test "replaces nothing when the subject does not start with a match" do + assert {["aha", 0], _} = Lua.eval!(~S|return string.gsub("aha", "^h", "H")|) + end + + test "replaces a leading multi-char run at most once" do + assert {["Xabc", 1], _} = Lua.eval!(~S|return string.gsub("abcabc", "^abc", "X")|) + end + + test "empty anchored match replaces once at the start" do + assert {["Xbbb", 1], _} = Lua.eval!(~S|return string.gsub("bbb", "^a*", "X")|) + end + + test "leading-whitespace trim preserves interior and trailing whitespace" do + assert {["_a b ", 1], _} = Lua.eval!(~S|return string.gsub(" a b ", "^%s+", "_")|) + end + + test "n = 0 suppresses the anchored replacement" do + assert {["xax", 0], _} = Lua.eval!(~S|return string.gsub("xax", "^x", "Y", 0)|) + end + + test "n greater than 1 still allows at most one anchored replacement" do + assert {["Xaa", 1], _} = Lua.eval!(~S|return string.gsub("aaa", "^a", "X", 3)|) + assert {["Xaa", 1], _} = Lua.eval!(~S|return string.gsub("aaa", "^a", "X", 2)|) + assert {["Yax", 1], _} = Lua.eval!(~S|return string.gsub("xax", "^x", "Y", 5)|) + end + + test "negative n suppresses the anchored replacement" do + assert {["xax", 0], _} = Lua.eval!(~S|return string.gsub("xax", "^x", "Y", -1)|) + end + + test "a table replacement is consulted once with the anchored capture" do + assert {["AAlo alo", 1], _} = + Lua.eval!(~S|return string.gsub("alo alo", "^(%a)", {a = "AA"})|) + end + + test "anchored pattern whose $ fails leaves the subject untouched" do + assert {["abc\n", 0], _} = Lua.eval!(~S|return string.gsub("abc\n", "^%a*$", "X")|) + end + + test "captures reach a function replacement exactly once" do + script = ~S""" + local calls = {} + local s, n = string.gsub("abcabc", "^(a)(b)", function(a, b) + calls[#calls + 1] = a .. b + return "<" .. b .. a .. ">" + end) + return s, n, #calls, calls[1] + """ + + assert {["cabc", 1, 1, "ab"], _} = Lua.eval!(script) + end + + test "anchored pattern matching the whole subject replaces it" do + assert {["X", 1], _} = Lua.eval!(~S|return string.gsub("abc", "^abc$", "X")|) + end + end + + describe "leading caret in string.gmatch" do + test "does not anchor and does not match without a literal caret" do + script = ~S""" + local n = 0 + for w in ("aaa"):gmatch("^a") do n = n + 1 end + return n + """ + + assert {[0], _} = Lua.eval!(script) + end + + test "matches a literal caret like any other character" do + script = ~S""" + local t = {} + for w in ("^a ^a"):gmatch("^a") do t[#t + 1] = w end + return #t, t[1], t[2] + """ + + assert {[2, "^a", "^a"], _} = Lua.eval!(script) + end + + test "yields the captures of every literal-caret match" do + script = ~S""" + local t = {} + for w in ("^a^b"):gmatch("^(%a)") do t[#t + 1] = w end + return #t, t[1], t[2] + """ + + assert {[2, "a", "b"], _} = Lua.eval!(script) + end + + test "a quantifier binds to the literal caret" do + script = ~S""" + local t = {} + for w in ("^*x"):gmatch("^*") do t[#t + 1] = w end + return #t, t[1], t[2], t[3] + """ + + assert {[3, "^", "", ""], _} = Lua.eval!(script) + end + + test "a trailing $ still anchors to the end after a literal caret" do + script = ~S""" + local t = {} + for w in ("a^"):gmatch("^$") do t[#t + 1] = w end + return #t, t[1] + """ + + assert {[1, "^"], _} = Lua.eval!(script) + end + end + + describe "anchored Pattern.gsub/4" do + test "replaces at most once at the start" do + assert {"Yax", 1} = Pattern.gsub("xax", "^x", "Y") + assert {"aha", 0} = Pattern.gsub("aha", "^h", "H") + end + + test "honours an explicit max_n of 0" do + assert {"xax", 0} = Pattern.gsub("xax", "^x", "Y", 0) + end + + test "caps the count at 1 for a max_n above 1" do + assert {"Xaa", 1} = Pattern.gsub("aaa", "^a", "X", 3) + end + + test "treats a negative max_n as no replacement" do + assert {"xax", 0} = Pattern.gsub("xax", "^x", "Y", -1) + end + end +end From 637f604ab7d77cfd6e9051cc1c7796999a06e5e0 Mon Sep 17 00:00:00 2001 From: Simon de Haan Date: Tue, 28 Jul 2026 14:48:02 +0200 Subject: [PATCH 11/13] fix(parser): allow comments between a bare return and its terminator (#418) Co-authored-by: Claude Opus 4.8 (1M context) --- lib/lua/parser.ex | 8 ++++++-- test/lua/parser/statement_test.exs | 33 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/lib/lua/parser.ex b/lib/lua/parser.ex index 8ceea8e8..64d9a33a 100644 --- a/lib/lua/parser.ex +++ b/lib/lua/parser.ex @@ -252,7 +252,11 @@ defmodule Lua.Parser do # Placeholder implementations for statements (Phase 3) defp parse_return([{:keyword, :return, pos} | rest]) do - case peek(rest) do + # Comments are whitespace between the `return` keyword and the block + # terminator, so look past them when deciding whether this is a bare + # (valueless) return. For terminator/EOF the comment tokens stay in `rest` + # for the block parser to collect as orphaned/trailing comments. + case peek(skip_comments(rest)) do # End of block or statement {:keyword, terminator, _} when terminator in [:end, :else, :elseif, :until] -> {:ok, %Statement.Return{values: [], meta: Meta.new(pos)}, rest} @@ -261,7 +265,7 @@ defmodule Lua.Parser do {:ok, %Statement.Return{values: [], meta: Meta.new(pos)}, rest} {:delimiter, :semicolon, _} -> - {_, rest2} = consume(rest) + {_, rest2} = consume(skip_comments(rest)) {:ok, %Statement.Return{values: [], meta: Meta.new(pos)}, rest2} _ -> diff --git a/test/lua/parser/statement_test.exs b/test/lua/parser/statement_test.exs index d59bae1b..f3dd5ead 100644 --- a/test/lua/parser/statement_test.exs +++ b/test/lua/parser/statement_test.exs @@ -201,6 +201,39 @@ defmodule Lua.Parser.StatementTest do } = chunk end + test "parses a bare return followed by a comment before elseif/else" do + # A comment sits between an empty `return` and the block terminator. + # Comments are whitespace to Lua, so the return is still empty and the + # branch continues normally. Regression: the parser used to treat the + # comment token as the start of a return expression and fail with + # "Expected expression". + assert {:ok, chunk} = + Parser.parse(""" + if x > 0 then + return + -- leading comment on elseif + elseif x < 0 then + return + -- leading comment on else + else + return + -- trailing comment before end + end + """) + + assert %{ + block: %{ + stmts: [ + %Statement.If{ + then_block: %{stmts: [%Statement.Return{values: []}]}, + elseifs: [{%Expr.BinOp{op: :lt}, %{stmts: [%Statement.Return{values: []}]}}], + else_block: %{stmts: [%Statement.Return{values: []}]} + } + ] + } + } = chunk + end + test "parses if with elseif" do assert {:ok, chunk} = Parser.parse(""" From 3a0d3923249cbb05380a3f17d3920a0b3ed55cb0 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Tue, 28 Jul 2026 09:22:29 -0400 Subject: [PATCH 12/13] release: 1.0.2 (#419) --- CHANGELOG.md | 20 ++++++++++++++++++++ mix.exs | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8432ceb..ea34f27f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ is in the [`1.0.0-rc.0`](#100-rc0---2026-05-26) entry below. ## [Unreleased] +## [1.0.2] - 2026-07-28 + ### Changed - `Lua.new/1` is ~100x faster (roughly 40µs down to 0.4µs) for the default and fully-custom-sandbox configurations. Installing the standard library is pure @@ -58,6 +60,23 @@ is in the [`1.0.0-rc.0`](#100-rc0---2026-05-26) entry below. cache self-invalidates when the modules that built it are recompiled; hosts that hot-load new code in `:embedded` mode (releases) can force a rebuild with `Lua.VM.Bootstrap.reset/0` (#398). +- The VM dispatcher and call convention were reworked to cut per-call and + per-iteration overhead: register files are built in a single allocation with + static-arity opcodes and `call_self` fusion (#405), and the dispatcher trims + work on the hot path for calls and loop iterations (#401). +- The compiler gained a peephole pass that emits fused and constant opcodes + (#403), and now does more work once at compile time: node ids are stamped a + single time (#416), scope resolution is keyed by node id with deduplicated + upvalue descriptors (#400), and the lexer slices tokens directly from the + source binary instead of building per-character position maps (#399). + +### Fixed +- Comments between a bare `return` and its terminator (`end`, `until`, or + end-of-chunk) now parse instead of raising a syntax error (#418). +- Lua patterns honour the `^` anchor in `string.gsub` and treat a leading `^` + as a literal caret in `string.gmatch`, matching Lua 5.3 semantics (#406). +- Encoding or decoding cyclic tables at the eval boundary no longer recurses + unboundedly (#407). ## [1.0.1] - 2026-07-16 @@ -649,6 +668,7 @@ API is intended to be stable. Please report any regressions before final. - Upgrade to Luerl 1.4.1 - Tables must now be explicitly decoded when receiving as arguments `deflua` and other Elixir callbacks +[1.0.2]: https://github.com/tv-labs/lua/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/tv-labs/lua/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/tv-labs/lua/compare/v0.4.0...v1.0.0 [1.0.0-rc.3]: https://github.com/tv-labs/lua/compare/v1.0.0-rc.2...v1.0.0-rc.3 diff --git a/mix.exs b/mix.exs index ff90f21e..833061c5 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule Lua.MixProject do alias Mix.Tasks.Lua.Eval @url "https://github.com/tv-labs/lua" - @version "1.0.1" + @version "1.0.2" # The curated public API surface rendered on HexDocs. Everything else is an # implementation detail: its @moduledoc stays intact for source readers and From 3f2aead2596dfabe0c58415235d766cd70574f53 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Wed, 29 Jul 2026 08:18:39 -0400 Subject: [PATCH 13/13] bench: cross-version benchmarks for v0.4.0, v1.0.0 and v1.0.2 (#420) --- CHANGELOG.md | 14 +- Dockerfile | 4 + bench_results/README.md | 137 ++ bench_results/v0.4.0/closures.txt | 43 + bench_results/v0.4.0/cpu.txt | 1 + bench_results/v0.4.0/encode_decode.txt | 128 ++ bench_results/v0.4.0/environment.md | 53 + bench_results/v0.4.0/fibonacci.txt | 43 + bench_results/v0.4.0/metamethods.txt | 136 ++ bench_results/v0.4.0/oop.txt | 43 + bench_results/v0.4.0/patterns.txt | 139 ++ bench_results/v0.4.0/pcall_varargs.txt | 136 ++ bench_results/v0.4.0/string_format.txt | 139 ++ bench_results/v0.4.0/string_ops.txt | 91 ++ bench_results/v0.4.0/summary.json | 1257 ++++++++++++++++ bench_results/v0.4.0/table_ops.txt | 461 ++++++ bench_results/v0.4.0/timestamp.txt | 1 + bench_results/v0.4.0/versions.txt | 3 + bench_results/v0.4.0/vm_new.txt | 56 + bench_results/v1.0.0/closures.txt | 46 + bench_results/v1.0.0/cpu.txt | 1 + bench_results/v1.0.0/encode_decode.txt | 128 ++ bench_results/v1.0.0/environment.md | 36 + bench_results/v1.0.0/fibonacci.txt | 43 + bench_results/v1.0.0/metamethods.txt | 136 ++ bench_results/v1.0.0/oop.txt | 43 + bench_results/v1.0.0/patterns.txt | 139 ++ bench_results/v1.0.0/pcall_varargs.txt | 136 ++ bench_results/v1.0.0/string_format.txt | 139 ++ bench_results/v1.0.0/string_ops.txt | 91 ++ bench_results/v1.0.0/summary.json | 1290 +++++++++++++++++ bench_results/v1.0.0/table_ops.txt | 461 ++++++ bench_results/v1.0.0/timestamp.txt | 1 + bench_results/v1.0.0/versions.txt | 3 + bench_results/v1.0.0/vm_new.txt | 56 + bench_results/v1.0.2/closures.txt | 46 + bench_results/v1.0.2/commit.txt | 1 + bench_results/v1.0.2/cpu.txt | 1 + bench_results/v1.0.2/encode_decode.txt | 128 ++ bench_results/v1.0.2/environment.md | 36 + bench_results/v1.0.2/fibonacci.txt | 43 + bench_results/v1.0.2/metamethods.txt | 136 ++ bench_results/v1.0.2/oop.txt | 43 + bench_results/v1.0.2/patterns.txt | 136 ++ bench_results/v1.0.2/pcall_varargs.txt | 136 ++ bench_results/v1.0.2/string_format.txt | 136 ++ bench_results/v1.0.2/string_ops.txt | 91 ++ bench_results/v1.0.2/summary.json | 1282 ++++++++++++++++ bench_results/v1.0.2/table_ops.txt | 461 ++++++ bench_results/v1.0.2/timestamp.txt | 1 + bench_results/v1.0.2/versions.txt | 3 + bench_results/v1.0.2/vm_new.txt | 56 + bench_results/versions-2026-07-28.md | 574 ++++++++ benchmarks/closures.exs | 6 +- benchmarks/fibonacci.exs | 6 +- benchmarks/metamethods.exs | 193 +++ benchmarks/oop.exs | 25 +- benchmarks/patterns.exs | 156 ++ benchmarks/pcall_varargs.exs | 161 ++ benchmarks/string_format.exs | 10 +- benchmarks/string_ops.exs | 8 +- benchmarks/table_ops.exs | 51 +- benchmarks/vm_new.exs | 94 ++ website/lib/website/benchmarks.ex | 460 ++++++ website/lib/website_web/components/layouts.ex | 14 + .../controllers/page_controller.ex | 15 + .../lib/website_web/controllers/page_html.ex | 52 + .../page_html/benchmarks.html.heex | 280 ++++ website/lib/website_web/router.ex | 1 + .../controllers/page_controller_test.exs | 58 + 70 files changed, 10486 insertions(+), 48 deletions(-) create mode 100644 bench_results/README.md create mode 100644 bench_results/v0.4.0/closures.txt create mode 100644 bench_results/v0.4.0/cpu.txt create mode 100644 bench_results/v0.4.0/encode_decode.txt create mode 100644 bench_results/v0.4.0/environment.md create mode 100644 bench_results/v0.4.0/fibonacci.txt create mode 100644 bench_results/v0.4.0/metamethods.txt create mode 100644 bench_results/v0.4.0/oop.txt create mode 100644 bench_results/v0.4.0/patterns.txt create mode 100644 bench_results/v0.4.0/pcall_varargs.txt create mode 100644 bench_results/v0.4.0/string_format.txt create mode 100644 bench_results/v0.4.0/string_ops.txt create mode 100644 bench_results/v0.4.0/summary.json create mode 100644 bench_results/v0.4.0/table_ops.txt create mode 100644 bench_results/v0.4.0/timestamp.txt create mode 100644 bench_results/v0.4.0/versions.txt create mode 100644 bench_results/v0.4.0/vm_new.txt create mode 100644 bench_results/v1.0.0/closures.txt create mode 100644 bench_results/v1.0.0/cpu.txt create mode 100644 bench_results/v1.0.0/encode_decode.txt create mode 100644 bench_results/v1.0.0/environment.md create mode 100644 bench_results/v1.0.0/fibonacci.txt create mode 100644 bench_results/v1.0.0/metamethods.txt create mode 100644 bench_results/v1.0.0/oop.txt create mode 100644 bench_results/v1.0.0/patterns.txt create mode 100644 bench_results/v1.0.0/pcall_varargs.txt create mode 100644 bench_results/v1.0.0/string_format.txt create mode 100644 bench_results/v1.0.0/string_ops.txt create mode 100644 bench_results/v1.0.0/summary.json create mode 100644 bench_results/v1.0.0/table_ops.txt create mode 100644 bench_results/v1.0.0/timestamp.txt create mode 100644 bench_results/v1.0.0/versions.txt create mode 100644 bench_results/v1.0.0/vm_new.txt create mode 100644 bench_results/v1.0.2/closures.txt create mode 100644 bench_results/v1.0.2/commit.txt create mode 100644 bench_results/v1.0.2/cpu.txt create mode 100644 bench_results/v1.0.2/encode_decode.txt create mode 100644 bench_results/v1.0.2/environment.md create mode 100644 bench_results/v1.0.2/fibonacci.txt create mode 100644 bench_results/v1.0.2/metamethods.txt create mode 100644 bench_results/v1.0.2/oop.txt create mode 100644 bench_results/v1.0.2/patterns.txt create mode 100644 bench_results/v1.0.2/pcall_varargs.txt create mode 100644 bench_results/v1.0.2/string_format.txt create mode 100644 bench_results/v1.0.2/string_ops.txt create mode 100644 bench_results/v1.0.2/summary.json create mode 100644 bench_results/v1.0.2/table_ops.txt create mode 100644 bench_results/v1.0.2/timestamp.txt create mode 100644 bench_results/v1.0.2/versions.txt create mode 100644 bench_results/v1.0.2/vm_new.txt create mode 100644 bench_results/versions-2026-07-28.md create mode 100644 benchmarks/metamethods.exs create mode 100644 benchmarks/patterns.exs create mode 100644 benchmarks/pcall_varargs.exs create mode 100644 benchmarks/vm_new.exs create mode 100644 website/lib/website/benchmarks.ex create mode 100644 website/lib/website_web/controllers/page_html/benchmarks.html.heex diff --git a/CHANGELOG.md b/CHANGELOG.md index ea34f27f..5104040c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,11 +52,15 @@ is in the [`1.0.0-rc.0`](#100-rc0---2026-05-26) entry below. ## [1.0.2] - 2026-07-28 ### Changed -- `Lua.new/1` is ~100x faster (roughly 40µs down to 0.4µs) for the default and - fully-custom-sandbox configurations. Installing the standard library is pure - and deterministic, so the boot-time VM template is now built once per node - and memoized in `:persistent_term`; every later `Lua.new/1` starts from the - shared template copy-on-write. In `:interactive` mode (dev, IEx, tests) the +- `Lua.new/1` is ~60x faster for the default configuration — 36.7µs down to + 0.6µs median, with per-call allocation down from ~92KB to under 1KB — and + ~5.5x faster when a custom sandbox is passed (36.0µs down to 6.5µs), as + measured by `benchmarks/vm_new.exs` under `mix run` + ([full figures](https://github.com/tv-labs/lua/blob/main/bench_results/versions-2026-07-28.md)). + Installing the + standard library is pure and deterministic, so the boot-time VM template is + now built once per node and memoized in `:persistent_term`; every later + `Lua.new/1` starts from the shared template copy-on-write. In `:interactive` mode (dev, IEx, tests) the cache self-invalidates when the modules that built it are recompiled; hosts that hot-load new code in `:embedded` mode (releases) can force a rebuild with `Lua.VM.Bootstrap.reset/0` (#398). diff --git a/Dockerfile b/Dockerfile index 7e74e943..9f5f0e33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,6 +56,10 @@ COPY website/priv priv COPY website/lib lib COPY website/assets assets +# Recorded benchmark results, read at compile time by lib/website/benchmarks.ex +# (@external_resource) to render /benchmarks. Compile fails loudly without it. +COPY bench_results /app/bench_results + # mix compile must run BEFORE assets.deploy because Phoenix's LiveView # colocated-hooks compiler generates files under _build/ that esbuild # resolves via NODE_PATH (`phoenix-colocated/website`). diff --git a/bench_results/README.md b/bench_results/README.md new file mode 100644 index 00000000..07cbc16c --- /dev/null +++ b/bench_results/README.md @@ -0,0 +1,137 @@ +# `bench_results/` + +Recorded benchmark runs and the reports written from them. + +The benchmark **scripts** live in [`benchmarks/`](../benchmarks/). This +directory holds their **output**: raw stdout, a parsed JSON summary, environment +probes, and the human-readable report for each measurement campaign. Nothing +here is used by the library at runtime, and none of it ships in the Hex package. + +## Contents + +| Path | What it is | +|---|---| +| [`versions-2026-07-28.md`](./versions-2026-07-28.md) | Cross-version comparison — v0.4.0 vs v1.0.0 vs 1.0.2 (`main` @ `3a0d392`), with Luerl 1.5.1 as a same-run control in every table. Supersedes the 1.0.0-era numbers in [`benchmarks/BASELINE.md`](../benchmarks/BASELINE.md). | +| [`v0.4.0/`](./v0.4.0/), [`v1.0.0/`](./v1.0.0/), [`v1.0.2/`](./v1.0.2/) | The data behind that report — one directory per released version. This is the ongoing convention: each release gets its own directory here, measured with the full suite of its day. | + +### Layout of a version directory + +``` +/ + environment.md # ref, commit, mode, CPU, OTP/Elixir, timestamp, command form + summary.json # parsed results: workload -> case -> jobs + comparison lines + .txt # verbatim stdout of one `mix run benchmarks/.exs` + cpu.txt versions.txt timestamp.txt commit.txt +``` + +`summary.json` schema, as produced for the 2026-07-28 run: + +- Top level is keyed by **workload** (`fibonacci`, `table_ops`, `vm_new`, …). +- Each workload maps to its **case banners** (`"default"` for single-case + workloads, otherwise the banner the script printed, e.g. + `"patterns: gsub template substitution (n=200)"`). +- Each case has `jobs` — a list of `{name, ips, average, deviation, median, + p99, memory}` — plus the verbatim Benchee `comparison` lines and, when memory + measurement was on, `memory_comparison` or `memory_note`. +- `table_ops` cases nest one level deeper under `by_input` (or `inputs` on the + v0.4.0 run) keyed by input label — `small (n=10)`, `medium (n=100)`, + `large (n=1000)`. +- `vm_new` carries `cold_call` and `second_call` alongside its jobs: the + first-ever and second `Lua.new()` on the node, measured before Benchee starts. +- `encode_decode` is `{"raw": "..."}`. That script uses its own `:timer.tc` + harness rather than Benchee and prints a per-element-nanoseconds table, so it + is stored unparsed. + +Case-name keys are **not** byte-identical across refs: the v0.4.0 run appends +`" (mode: full)"` to banners and uses `"(single case)"` where later runs use +`"default"`. Normalise by stripping the mode suffix before joining across refs. + +## Reproducing a run + +Full per-ref instructions — including the three adaptations v0.4.0 needs and +the language constraints a cross-version workload must respect — are in the +[Reproduction section of the report](./versions-2026-07-28.md#reproduction). +The short version: + +```sh +MIX_ENV=benchmark mix deps.get +for w in fibonacci closures oop string_ops string_format table_ops \ + patterns metamethods pcall_varargs vm_new encode_decode; do + LUA_BENCH_MODE=full MIX_ENV=benchmark mix run "benchmarks/$w.exs" +done +``` + +Two rules are not optional: + +- **Serially, one `mix run` at a time, on a quiet machine.** Concurrent load + inflates deviation badly — the table and OOP cases swing enough to flip + orderings. +- **`LUA_BENCH_MODE=full` for anything published.** The default `quick` mode + uses short windows, skips memory measurement, and collapses the table + workloads to a single input size. It is for "did my change move the needle" + iteration, not for numbers anyone reads. + +### Older refs + +Each ref is measured in a throwaway detached worktree so the main checkout is +never modified: + +```sh +git worktree add --detach /tmp/lua- +``` + +- **v1.0.0** — copy in the four workloads that postdate it + (`patterns`, `metamethods`, `pcall_varargs`, `vm_new`), then + `MIX_ENV=benchmark mix deps.get`. +- **v0.4.0** — copy in the whole `benchmarks/` directory (the tag has none) and + add `{:benchee, "~> 1.3", only: :benchmark}` to `deps/0`. `luerl` is already + an unconditional dependency there, so the control rows need nothing. + +Remove the worktree when done (`git worktree remove --force /tmp/lua-`). + +## Benchmarking a new release + +The convention: **every released version gets a directory here**, so the +series grows one column per release. + +1. After tagging, run the full suite against the tag (serially, full mode, + quiet machine — see above) and put the outputs in + `bench_results//` with the same file layout as the existing + directories (`environment.md`, `summary.json`, one `.txt` per workload, + plus the env probes). +2. Include the Luerl control rows — they are what make the new column + comparable to the old ones despite machine/OTP drift between sittings. +3. If the suite gained workloads since the last release, note in + `environment.md` which workloads are new (older version directories will + simply lack those files). +4. Write or extend a report named `-.md` quoting + **medians**, not averages — several workloads have allocation-driven GC + pauses that pull the mean around. +5. Add a row to the Contents table above. +6. Do not edit `benchmarks/BASELINE.md`. It is the historical 1.0.0 gate + record; a newer report supersedes it by saying so. + +### What updates itself + +The hosted page at [`/benchmarks`](../website/lib/website_web/controllers/page_html/benchmarks.html.heex) +reads these directories directly, so steps 1–5 are the whole job: + +- **A new `/summary.json` becomes a new column.** Version directories + are found by glob and ordered with `Version.compare/2` — no list to extend. +- **A new `versions-.md` becomes the linked report**, and its date becomes + the page's dated eyebrow. The newest report filename wins. +- **Headline tiles and the "still behind Luerl" figures re-derive** from the new + column, including the "N× faster than " deltas. + +Two things still need a human: + +- **A new workload needs a row spec** in `Website.Benchmarks` (`@rows`) before it + appears — which cases are worth showing, and under what name, is editorial. + A version that lacks a workload another version has renders as `—`. +- **The prose** — the headline claim and the analysis paragraphs — is written, + not generated. Re-read it when the story changes. + +`Website.Benchmarks` registers each `summary.json` as an `@external_resource`, +so editing recorded results recompiles the page in dev. The container build +copies this directory in (see `Dockerfile`); compilation fails loudly rather +than shipping an empty page if it is missing. diff --git a/bench_results/v0.4.0/closures.txt b/bench_results/v0.4.0/closures.txt new file mode 100644 index 00000000..8e16e2ef --- /dev/null +++ b/bench_results/v0.4.0/closures.txt @@ -0,0 +1,43 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 2.69 K 372.35 μs ±7.42% 368.92 μs 507.15 μs +lua (eval) 2.52 K 397.20 μs ±8.30% 390.13 μs 515.81 μs +luerl 2.50 K 400.26 μs ±9.75% 391.42 μs 536.69 μs + +Comparison: +lua (chunk) 2.69 K +lua (eval) 2.52 K - 1.07x slower +24.85 μs +luerl 2.50 K - 1.07x slower +27.91 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 1.89 MB +lua (eval) 1.90 MB - 1.01x memory usage +0.0101 MB +luerl 1.90 MB - 1.00x memory usage +0.00886 MB + +**All measurements for memory usage were the same** diff --git a/bench_results/v0.4.0/cpu.txt b/bench_results/v0.4.0/cpu.txt new file mode 100644 index 00000000..de5c8ad6 --- /dev/null +++ b/bench_results/v0.4.0/cpu.txt @@ -0,0 +1 @@ +Apple M4 diff --git a/bench_results/v0.4.0/encode_decode.txt b/bench_results/v0.4.0/encode_decode.txt new file mode 100644 index 00000000..0f54da60 --- /dev/null +++ b/bench_results/v0.4.0/encode_decode.txt @@ -0,0 +1,128 @@ +lua 0.4.0 — encode!/decode! decomposition +(decode+deep_cast column: enabled) +================================================================================== +op shape N total_us per_elem_ns +encode int_list 8 0.27 33.3 +decode int_list 8 0.09 11.2 +dec+cast int_list 8 0.11 13.7 +encode int_list 64 1.61 25.1 +decode int_list 64 0.54 8.5 +dec+cast int_list 64 0.96 15.0 +encode int_list 512 26.38 51.5 +decode int_list 512 5.11 10.0 +dec+cast int_list 512 8.60 16.8 +encode int_list 4096 226.97 55.4 +decode int_list 4096 43.47 10.6 +dec+cast int_list 4096 88.67 21.6 +---------------------------------------------------------------------------------- +encode float_list 8 0.22 27.7 +decode float_list 8 0.09 11.2 +dec+cast float_list 8 0.11 13.2 +encode float_list 64 1.64 25.7 +decode float_list 64 0.55 8.5 +dec+cast float_list 64 0.94 14.7 +encode float_list 512 27.00 52.7 +decode float_list 512 5.85 11.4 +dec+cast float_list 512 9.00 17.6 +encode float_list 4096 239.42 58.5 +decode float_list 4096 64.74 15.8 +dec+cast float_list 4096 98.40 24.0 +---------------------------------------------------------------------------------- +encode bool_list 8 0.21 26.4 +decode bool_list 8 0.08 10.0 +dec+cast bool_list 8 0.10 12.6 +encode bool_list 64 1.60 25.0 +decode bool_list 64 0.56 8.7 +dec+cast bool_list 64 0.97 15.1 +encode bool_list 512 25.68 50.1 +decode bool_list 512 4.80 9.4 +dec+cast bool_list 512 8.38 16.4 +encode bool_list 4096 224.95 54.9 +decode bool_list 4096 38.99 9.5 +dec+cast bool_list 4096 84.05 20.5 +---------------------------------------------------------------------------------- +encode short_string_list 8 0.21 26.7 +decode short_string_list 8 0.08 10.5 +dec+cast short_string_list 8 0.10 12.9 +encode short_string_list 64 1.62 25.3 +decode short_string_list 64 0.55 8.6 +dec+cast short_string_list 64 0.98 15.4 +encode short_string_list 512 26.45 51.7 +decode short_string_list 512 4.86 9.5 +dec+cast short_string_list 512 9.05 17.7 +encode short_string_list 4096 202.93 49.5 +decode short_string_list 4096 43.46 10.6 +dec+cast short_string_list 4096 73.06 17.8 +---------------------------------------------------------------------------------- +encode long_string_list 8 0.22 27.2 +decode long_string_list 8 0.08 10.4 +dec+cast long_string_list 8 0.10 12.7 +encode long_string_list 64 1.67 26.1 +decode long_string_list 64 0.59 9.2 +dec+cast long_string_list 64 1.03 16.1 +encode long_string_list 512 28.42 55.5 +decode long_string_list 512 5.16 10.1 +dec+cast long_string_list 512 9.09 17.7 +encode long_string_list 4096 465.96 113.8 +decode long_string_list 4096 54.39 13.3 +dec+cast long_string_list 4096 108.40 26.5 +---------------------------------------------------------------------------------- +encode string_map 8 0.52 65.3 +decode string_map 8 0.07 8.2 +dec+cast string_map 8 0.17 21.1 +encode string_map 64 6.56 102.5 +decode string_map 64 0.40 6.2 +dec+cast string_map 64 3.21 50.2 +encode string_map 512 113.10 220.9 +decode string_map 512 4.27 8.3 +dec+cast string_map 512 39.39 76.9 +encode string_map 4096 1295.09 316.2 +decode string_map 4096 42.02 10.3 +dec+cast string_map 4096 367.76 89.8 +---------------------------------------------------------------------------------- +encode int_map 8 0.25 31.2 +decode int_map 8 0.09 10.7 +dec+cast int_map 8 0.10 12.9 +encode int_map 64 3.56 55.6 +decode int_map 64 0.57 8.9 +dec+cast int_map 64 0.97 15.1 +encode int_map 512 51.12 99.8 +decode int_map 512 4.79 9.4 +dec+cast int_map 512 8.76 17.1 +encode int_map 4096 443.17 108.2 +decode int_map 4096 40.30 9.8 +dec+cast int_map 4096 87.24 21.3 +---------------------------------------------------------------------------------- +encode record_list 8 2.07 258.7 +decode record_list 8 0.40 49.9 +dec+cast record_list 8 0.80 100.1 +encode record_list 64 31.54 492.8 +decode record_list 64 4.54 70.9 +dec+cast record_list 64 7.32 114.4 +encode record_list 512 287.96 562.4 +decode record_list 512 52.67 102.9 +dec+cast record_list 512 77.64 151.6 +encode record_list 4096 2346.38 572.8 +decode record_list 4096 406.88 99.3 +dec+cast record_list 4096 658.70 160.8 +---------------------------------------------------------------------------------- +nested chain (depth sweep) — isolates recursion/traversal from fan-out +op shape N total_us per_elem_ns +encode nested_chain 4 0.51 257.3 +decode nested_chain 4 0.12 61.7 +dec+cast nested_chain 4 0.24 118.7 +encode nested_chain 16 2.06 1032.0 +decode nested_chain 16 0.63 314.9 +dec+cast nested_chain 16 1.10 550.2 +encode nested_chain 64 8.53 4265.3 +decode nested_chain 64 4.16 2079.7 +dec+cast nested_chain 64 6.63 3317.0 +encode nested_chain 256 34.34 17170.4 +decode nested_chain 256 37.82 18909.7 +dec+cast nested_chain 256 47.43 23716.3 +================================================================================== +composite anchor — the PR's `original_nested` (matches the 18us/108us figure) +op shape N total_us per_elem_ns +encode original_nested 75 3.48 870.0 +decode original_nested 75 0.58 145.4 +dec+cast original_nested 75 1.32 329.3 diff --git a/bench_results/v0.4.0/environment.md b/bench_results/v0.4.0/environment.md new file mode 100644 index 00000000..897695d4 --- /dev/null +++ b/bench_results/v0.4.0/environment.md @@ -0,0 +1,53 @@ +# Benchmark environment — lua v0.4.0 (full mode) + +- **Ref**: `v0.4.0` (tag), commit `5bf2069` +- **CPU**: Apple M4 +- **OTP**: Erlang/OTP 29 [erts-17.0] [source] [64-bit] [smp:10:10] [ds:10:10:10] [async-threads:1] [jit] +- **Elixir**: 1.20.0 (compiled with Erlang/OTP 29) +- **Mode**: `LUA_BENCH_MODE=full MIX_ENV=benchmark` +- **Setup timestamp**: 2026-07-28T13:59:36Z (UTC) +- **luaport (C Lua)**: not installed — skipped by all workloads as expected +- **luerl**: 1.5.1 (unconditional dep at this tag) + +## Worktree setup + +``` +git -C worktree add --detach v0.4.0 + +cp -R /benchmarks /benchmarks + +# mix.exs deps edit (worktree only): added +# {:benchee, "~> 1.3", only: :benchmark} +# alongside the existing {:luerl, "~> 1.5.1"} unconditional dep. + +MIX_ENV=benchmark mix deps.get +MIX_ENV=benchmark mix compile +``` + +No lockfile/dep conflicts were encountered; `deps.get` resolved cleanly (benchee 1.5.1, deep_merge 1.0.2, statistex 1.1.1 added; luerl/ex_doc/dialyxir unchanged). + +## Measurement commands + +Each workload run strictly serially, one at a time, via: + +``` +(cd && LUA_BENCH_MODE=full MIX_ENV=benchmark mix run benchmarks/.exs) \ + > /.txt 2>&1 +``` + +Order: fibonacci, closures, oop, string_ops, string_format, table_ops, patterns, metamethods, pcall_varargs, vm_new, encode_decode. + +Excluded: `dispatcher_vs_interpreter.exs` (crashes on v0.4.0), `array_vs_map_probe.exs` (runs no Lua). + +`encode_decode.exs` uses its own `:timer.tc` harness (not Benchee) and prints its own table directly. + +## Cleanup + +``` +git -C worktree remove --force +``` + +## Results + +Parsed into `summary.json` in this directory. Raw Benchee/harness stdout for each +workload is preserved verbatim in `.txt`. diff --git a/bench_results/v0.4.0/fibonacci.txt b/bench_results/v0.4.0/fibonacci.txt new file mode 100644 index 00000000..62e549cf --- /dev/null +++ b/bench_results/v0.4.0/fibonacci.txt @@ -0,0 +1,43 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 1.42 703.34 ms ±1.00% 702.84 ms 715.45 ms +lua (eval) 1.40 712.63 ms ±1.13% 711.69 ms 732.64 ms +luerl 1.38 724.80 ms ±3.25% 720.40 ms 801.56 ms + +Comparison: +lua (chunk) 1.42 +lua (eval) 1.40 - 1.01x slower +9.29 ms +luerl 1.38 - 1.03x slower +21.46 ms + +Memory usage statistics: + +Name Memory usage +lua (chunk) 2.45 GB +lua (eval) 2.45 GB - 1.00x memory usage +0.00001 GB +luerl 2.45 GB - 1.00x memory usage +0.00001 GB + +**All measurements for memory usage were the same** diff --git a/bench_results/v0.4.0/metamethods.txt b/bench_results/v0.4.0/metamethods.txt new file mode 100644 index 00000000..a60332a0 --- /dev/null +++ b/bench_results/v0.4.0/metamethods.txt @@ -0,0 +1,136 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== metamethods: self-call method dispatch (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 2.04 K 489.21 μs ±10.11% 476.58 μs 672.87 μs +lua (eval) 1.99 K 503.66 μs ±9.40% 488.92 μs 678.61 μs +luerl 1.98 K 505.65 μs ±10.20% 492.79 μs 680.89 μs + +Comparison: +lua (chunk) 2.04 K +lua (eval) 1.99 K - 1.03x slower +14.45 μs +luerl 1.98 K - 1.03x slower +16.44 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 1.42 MB +lua (eval) 1.43 MB - 1.01x memory usage +0.0105 MB +luerl 1.43 MB - 1.01x memory usage +0.0103 MB + +**All measurements for memory usage were the same** + +=== metamethods: 3-level __index chain (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 4.58 K 218.46 μs ±4.01% 217.92 μs 233.19 μs +lua (chunk) 4.57 K 219.03 μs ±10.32% 216.42 μs 254.20 μs +lua (eval) 4.52 K 221.04 μs ±5.51% 219.33 μs 245.33 μs + +Comparison: +luerl 4.58 K +lua (chunk) 4.57 K - 1.00x slower +0.57 μs +lua (eval) 4.52 K - 1.01x slower +2.57 μs + +Memory usage statistics: + +Name Memory usage +luerl 668.59 KB +lua (chunk) 658.30 KB - 0.98x memory usage -10.28906 KB +lua (eval) 668.80 KB - 1.00x memory usage +0.22 KB + +**All measurements for memory usage were the same** + +=== metamethods: arithmetic/relational metamethods (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 519.76 1.92 ms ±9.38% 1.88 ms 2.25 ms +lua (chunk) 513.47 1.95 ms ±15.04% 1.88 ms 2.30 ms +lua (eval) 508.61 1.97 ms ±10.60% 1.89 ms 2.93 ms + +Comparison: +luerl 519.76 +lua (chunk) 513.47 - 1.01x slower +0.0236 ms +lua (eval) 508.61 - 1.02x slower +0.0422 ms + +Memory usage statistics: + +Name Memory usage +luerl 6.24 MB +lua (chunk) 6.23 MB - 1.00x memory usage -0.01392 MB +lua (eval) 6.24 MB - 1.00x memory usage -0.00303 MB + +**All measurements for memory usage were the same** diff --git a/bench_results/v0.4.0/oop.txt b/bench_results/v0.4.0/oop.txt new file mode 100644 index 00000000..b1671978 --- /dev/null +++ b/bench_results/v0.4.0/oop.txt @@ -0,0 +1,43 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 9.11 K 109.76 μs ±13.66% 106.50 μs 179.25 μs +lua (eval) 8.85 K 113.01 μs ±18.72% 108.58 μs 193.98 μs +lua (chunk) 8.80 K 113.70 μs ±14.10% 110.88 μs 195.87 μs + +Comparison: +luerl 9.11 K +lua (eval) 8.85 K - 1.03x slower +3.25 μs +lua (chunk) 8.80 K - 1.04x slower +3.94 μs + +Memory usage statistics: + +Name Memory usage +luerl 381.45 KB +lua (eval) 382.52 KB - 1.00x memory usage +1.07 KB +lua (chunk) 372.03 KB - 0.98x memory usage -9.42188 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/v0.4.0/patterns.txt b/bench_results/v0.4.0/patterns.txt new file mode 100644 index 00000000..cc66c55d --- /dev/null +++ b/bench_results/v0.4.0/patterns.txt @@ -0,0 +1,139 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== patterns: find/match field extraction (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (eval) 947.52 1.06 ms ±3.75% 1.06 ms 1.18 ms +luerl 937.83 1.07 ms ±6.93% 1.05 ms 1.22 ms +lua (chunk) 919.82 1.09 ms ±4.39% 1.07 ms 1.22 ms + +Comparison: +lua (eval) 947.52 +luerl 937.83 - 1.01x slower +0.0109 ms +lua (chunk) 919.82 - 1.03x slower +0.0318 ms + +Memory usage statistics: + +Name Memory usage +lua (eval) 6.70 MB +luerl 6.70 MB - 1.00x memory usage -0.00021 MB +lua (chunk) 6.69 MB - 1.00x memory usage -0.00996 MB + +**All measurements for memory usage were the same** + +=== patterns: find-based tokenizer (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 731.44 1.37 ms ±3.23% 1.36 ms 1.51 ms +lua (chunk) 721.24 1.39 ms ±4.36% 1.37 ms 1.57 ms +lua (eval) 715.30 1.40 ms ±4.16% 1.39 ms 1.49 ms + +Comparison: +luerl 731.44 +lua (chunk) 721.24 - 1.01x slower +0.0193 ms +lua (eval) 715.30 - 1.02x slower +0.0308 ms + +Memory usage statistics: + +Name Memory usage +luerl 6.65 MB +lua (chunk) 6.64 MB - 1.00x memory usage -0.00977 MB +lua (eval) 6.65 MB - 1.00x memory usage +0.00024 MB + +**All measurements for memory usage were the same** + +=== patterns: gsub template substitution (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 452.66 2.21 ms ±4.59% 2.21 ms 2.50 ms +lua (eval) 446.40 2.24 ms ±8.19% 2.19 ms 2.75 ms +lua (chunk) 436.67 2.29 ms ±10.44% 2.21 ms 3.18 ms + +Comparison: +luerl 452.66 +lua (eval) 446.40 - 1.01x slower +0.0310 ms +lua (chunk) 436.67 - 1.04x slower +0.0809 ms + +Memory usage statistics: + +Name average deviation median 99th % +luerl 11.75 MB ±0.00% 11.75 MB 11.75 MB +lua (eval) 11.75 MB ±0.00% 11.75 MB 11.75 MB +lua (chunk) 11.74 MB ±0.00% 11.74 MB 11.74 MB + +Comparison: +luerl 11.75 MB +lua (eval) 11.75 MB - 1.00x memory usage +0.00021 MB +lua (chunk) 11.74 MB - 1.00x memory usage -0.00975 MB diff --git a/bench_results/v0.4.0/pcall_varargs.txt b/bench_results/v0.4.0/pcall_varargs.txt new file mode 100644 index 00000000..efb881e1 --- /dev/null +++ b/bench_results/v0.4.0/pcall_varargs.txt @@ -0,0 +1,136 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== call protocol: pcall, success path (n=500) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 3.44 K 291.08 μs ±3.72% 289.67 μs 313 μs +lua (eval) 3.42 K 292.45 μs ±3.54% 291.17 μs 315.44 μs +lua (chunk) 3.40 K 293.76 μs ±7.51% 290.50 μs 338.21 μs + +Comparison: +luerl 3.44 K +lua (eval) 3.42 K - 1.00x slower +1.36 μs +lua (chunk) 3.40 K - 1.01x slower +2.68 μs + +Memory usage statistics: + +Name Memory usage +luerl 1.11 MB +lua (eval) 1.11 MB - 1.00x memory usage +0.00021 MB +lua (chunk) 1.10 MB - 0.99x memory usage -0.00993 MB + +**All measurements for memory usage were the same** + +=== call protocol: pcall, raise + catch (n=500) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 2.13 K 468.84 μs ±5.39% 466.38 μs 505.90 μs +lua (eval) 2.13 K 470.00 μs ±3.58% 467.50 μs 498.92 μs +lua (chunk) 2.11 K 474.55 μs ±9.26% 469.29 μs 583.95 μs + +Comparison: +luerl 2.13 K +lua (eval) 2.13 K - 1.00x slower +1.15 μs +lua (chunk) 2.11 K - 1.01x slower +5.70 μs + +Memory usage statistics: + +Name Memory usage +luerl 1.55 MB +lua (eval) 1.55 MB - 1.00x memory usage +0.00021 MB +lua (chunk) 1.54 MB - 0.99x memory usage -0.01002 MB + +**All measurements for memory usage were the same** + +=== call protocol: varargs + multiple returns (n=500) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 418.17 2.39 ms ±4.44% 2.36 ms 2.70 ms +lua (eval) 400.02 2.50 ms ±11.76% 2.39 ms 3.48 ms +lua (chunk) 391.27 2.56 ms ±14.33% 2.40 ms 3.52 ms + +Comparison: +luerl 418.17 +lua (eval) 400.02 - 1.05x slower +0.109 ms +lua (chunk) 391.27 - 1.07x slower +0.164 ms + +Memory usage statistics: + +Name Memory usage +luerl 8.73 MB +lua (eval) 8.73 MB - 1.00x memory usage -0.00005 MB +lua (chunk) 8.72 MB - 1.00x memory usage -0.00979 MB + +**All measurements for memory usage were the same** diff --git a/bench_results/v0.4.0/string_format.txt b/bench_results/v0.4.0/string_format.txt new file mode 100644 index 00000000..b992107a --- /dev/null +++ b/bench_results/v0.4.0/string_format.txt @@ -0,0 +1,139 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== string.format: long literal-heavy format string (n=1000) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 249.24 4.01 ms ±6.43% 3.91 ms 4.54 ms +luerl 242.35 4.13 ms ±6.17% 4.25 ms 4.54 ms +lua (eval) 187.95 5.32 ms ±9.84% 5.58 ms 6.77 ms + +Comparison: +lua (chunk) 249.24 +luerl 242.35 - 1.03x slower +0.114 ms +lua (eval) 187.95 - 1.33x slower +1.31 ms + +Memory usage statistics: + +Name Memory usage +lua (chunk) 20.36 MB +luerl 22.59 MB - 1.11x memory usage +2.23 MB +lua (eval) 20.38 MB - 1.00x memory usage +0.0172 MB + +**All measurements for memory usage were the same** + +=== string.format: width-flagged specifiers (n=1000) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 582.97 1.72 ms ±3.52% 1.72 ms 1.86 ms +lua (eval) 576.12 1.74 ms ±8.52% 1.70 ms 2.41 ms +luerl 572.77 1.75 ms ±8.30% 1.73 ms 2.14 ms + +Comparison: +lua (chunk) 582.97 +lua (eval) 576.12 - 1.01x slower +0.0204 ms +luerl 572.77 - 1.02x slower +0.0306 ms + +Memory usage statistics: + +Name Memory usage +lua (chunk) 7.53 MB +lua (eval) 7.55 MB - 1.00x memory usage +0.0192 MB +luerl 7.55 MB - 1.00x memory usage +0.0161 MB + +**All measurements for memory usage were the same** + +=== string.format: many specifiers (n=1000) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (eval) 370.09 2.70 ms ±4.67% 2.78 ms 2.89 ms +lua (chunk) 367.47 2.72 ms ±4.88% 2.75 ms 2.99 ms +luerl 362.27 2.76 ms ±3.93% 2.80 ms 2.99 ms + +Comparison: +lua (eval) 370.09 +lua (chunk) 367.47 - 1.01x slower +0.0193 ms +luerl 362.27 - 1.02x slower +0.0584 ms + +Memory usage statistics: + +Name average deviation median 99th % +lua (eval) 13.76 MB ±0.00% 13.76 MB 13.76 MB +lua (chunk) 13.73 MB ±0.00% 13.73 MB 13.73 MB +luerl 13.76 MB ±0.00% 13.76 MB 13.76 MB + +Comparison: +lua (eval) 13.76 MB +lua (chunk) 13.73 MB - 1.00x memory usage -0.03384 MB +luerl 13.76 MB - 1.00x memory usage -0.00677 MB diff --git a/bench_results/v0.4.0/string_ops.txt b/bench_results/v0.4.0/string_ops.txt new file mode 100644 index 00000000..7823a7a2 --- /dev/null +++ b/bench_results/v0.4.0/string_ops.txt @@ -0,0 +1,91 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== String Concatenation via table.concat (n=100) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 25.94 K 38.55 μs ±12.21% 38.38 μs 58.63 μs +lua (eval) 25.35 K 39.45 μs ±6.04% 39.54 μs 45.04 μs +luerl 25.19 K 39.69 μs ±19.25% 39.42 μs 46.88 μs + +Comparison: +lua (chunk) 25.94 K +lua (eval) 25.35 K - 1.02x slower +0.89 μs +luerl 25.19 K - 1.03x slower +1.14 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 161.98 KB +lua (eval) 172.77 KB - 1.07x memory usage +10.79 KB +luerl 172.42 KB - 1.06x memory usage +10.44 KB + +**All measurements for memory usage were the same** + +=== String Formatting via string.format (n=100) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 9.75 K 102.58 μs ±11.83% 101.46 μs 124.58 μs +lua (eval) 9.70 K 103.12 μs ±7.60% 102.33 μs 124.83 μs +luerl 9.63 K 103.90 μs ±9.83% 102.83 μs 127.33 μs + +Comparison: +lua (chunk) 9.75 K +lua (eval) 9.70 K - 1.01x slower +0.54 μs +luerl 9.63 K - 1.01x slower +1.31 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 577.43 KB +lua (eval) 587.10 KB - 1.02x memory usage +9.67 KB +luerl 588.84 KB - 1.02x memory usage +11.41 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/v0.4.0/summary.json b/bench_results/v0.4.0/summary.json new file mode 100644 index 00000000..d0fda5d8 --- /dev/null +++ b/bench_results/v0.4.0/summary.json @@ -0,0 +1,1257 @@ +{ + "fibonacci": { + "(single case)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "1.42", + "average": "703.34 ms", + "deviation": "±1.00%", + "median": "702.84 ms", + "p99": "715.45 ms", + "memory": "2.45 GB" + }, + { + "name": "lua (eval)", + "ips": "1.40", + "average": "712.63 ms", + "deviation": "±1.13%", + "median": "711.69 ms", + "p99": "732.64 ms", + "memory": "2.45 GB" + }, + { + "name": "luerl", + "ips": "1.38", + "average": "724.80 ms", + "deviation": "±3.25%", + "median": "720.40 ms", + "p99": "801.56 ms", + "memory": "2.45 GB" + } + ], + "comparison": [ + "lua (chunk) 1.42", + "lua (eval) 1.40 - 1.01x slower +9.29 ms", + "luerl 1.38 - 1.03x slower +21.46 ms" + ] + } + }, + "closures": { + "(single case)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "2.69 K", + "average": "372.35 μs", + "deviation": "±7.42%", + "median": "368.92 μs", + "p99": "507.15 μs", + "memory": "1.89 MB" + }, + { + "name": "lua (eval)", + "ips": "2.52 K", + "average": "397.20 μs", + "deviation": "±8.30%", + "median": "390.13 μs", + "p99": "515.81 μs", + "memory": "1.90 MB" + }, + { + "name": "luerl", + "ips": "2.50 K", + "average": "400.26 μs", + "deviation": "±9.75%", + "median": "391.42 μs", + "p99": "536.69 μs", + "memory": "1.90 MB" + } + ], + "comparison": [ + "lua (chunk) 2.69 K", + "lua (eval) 2.52 K - 1.07x slower +24.85 μs", + "luerl 2.50 K - 1.07x slower +27.91 μs" + ] + } + }, + "oop": { + "(single case)": { + "jobs": [ + { + "name": "luerl", + "ips": "9.11 K", + "average": "109.76 μs", + "deviation": "±13.66%", + "median": "106.50 μs", + "p99": "179.25 μs", + "memory": "381.45 KB" + }, + { + "name": "lua (eval)", + "ips": "8.85 K", + "average": "113.01 μs", + "deviation": "±18.72%", + "median": "108.58 μs", + "p99": "193.98 μs", + "memory": "382.52 KB" + }, + { + "name": "lua (chunk)", + "ips": "8.80 K", + "average": "113.70 μs", + "deviation": "±14.10%", + "median": "110.88 μs", + "p99": "195.87 μs", + "memory": "372.03 KB" + } + ], + "comparison": [ + "luerl 9.11 K", + "lua (eval) 8.85 K - 1.03x slower +3.25 μs", + "lua (chunk) 8.80 K - 1.04x slower +3.94 μs" + ] + } + }, + "string_ops": { + "String Concatenation via table.concat (n=100) (mode: full)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "25.94 K", + "average": "38.55 μs", + "deviation": "±12.21%", + "median": "38.38 μs", + "p99": "58.63 μs", + "memory": "161.98 KB" + }, + { + "name": "lua (eval)", + "ips": "25.35 K", + "average": "39.45 μs", + "deviation": "±6.04%", + "median": "39.54 μs", + "p99": "45.04 μs", + "memory": "172.77 KB" + }, + { + "name": "luerl", + "ips": "25.19 K", + "average": "39.69 μs", + "deviation": "±19.25%", + "median": "39.42 μs", + "p99": "46.88 μs", + "memory": "172.42 KB" + } + ], + "comparison": [ + "lua (chunk) 25.94 K", + "lua (eval) 25.35 K - 1.02x slower +0.89 μs", + "luerl 25.19 K - 1.03x slower +1.14 μs" + ] + }, + "String Formatting via string.format (n=100) (mode: full)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "9.75 K", + "average": "102.58 μs", + "deviation": "±11.83%", + "median": "101.46 μs", + "p99": "124.58 μs", + "memory": "577.43 KB" + }, + { + "name": "lua (eval)", + "ips": "9.70 K", + "average": "103.12 μs", + "deviation": "±7.60%", + "median": "102.33 μs", + "p99": "124.83 μs", + "memory": "587.10 KB" + }, + { + "name": "luerl", + "ips": "9.63 K", + "average": "103.90 μs", + "deviation": "±9.83%", + "median": "102.83 μs", + "p99": "127.33 μs", + "memory": "588.84 KB" + } + ], + "comparison": [ + "lua (chunk) 9.75 K", + "lua (eval) 9.70 K - 1.01x slower +0.54 μs", + "luerl 9.63 K - 1.01x slower +1.31 μs" + ] + } + }, + "string_format": { + "string.format: long literal-heavy format string (n=1000) (mode: full)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "249.24", + "average": "4.01 ms", + "deviation": "±6.43%", + "median": "3.91 ms", + "p99": "4.54 ms", + "memory": "20.36 MB" + }, + { + "name": "luerl", + "ips": "242.35", + "average": "4.13 ms", + "deviation": "±6.17%", + "median": "4.25 ms", + "p99": "4.54 ms", + "memory": "22.59 MB" + }, + { + "name": "lua (eval)", + "ips": "187.95", + "average": "5.32 ms", + "deviation": "±9.84%", + "median": "5.58 ms", + "p99": "6.77 ms", + "memory": "20.38 MB" + } + ], + "comparison": [ + "lua (chunk) 249.24", + "luerl 242.35 - 1.03x slower +0.114 ms", + "lua (eval) 187.95 - 1.33x slower +1.31 ms" + ] + }, + "string.format: width-flagged specifiers (n=1000) (mode: full)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "582.97", + "average": "1.72 ms", + "deviation": "±3.52%", + "median": "1.72 ms", + "p99": "1.86 ms", + "memory": "7.53 MB" + }, + { + "name": "lua (eval)", + "ips": "576.12", + "average": "1.74 ms", + "deviation": "±8.52%", + "median": "1.70 ms", + "p99": "2.41 ms", + "memory": "7.55 MB" + }, + { + "name": "luerl", + "ips": "572.77", + "average": "1.75 ms", + "deviation": "±8.30%", + "median": "1.73 ms", + "p99": "2.14 ms", + "memory": "7.55 MB" + } + ], + "comparison": [ + "lua (chunk) 582.97", + "lua (eval) 576.12 - 1.01x slower +0.0204 ms", + "luerl 572.77 - 1.02x slower +0.0306 ms" + ] + }, + "string.format: many specifiers (n=1000) (mode: full)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "370.09", + "average": "2.70 ms", + "deviation": "±4.67%", + "median": "2.78 ms", + "p99": "2.89 ms", + "memory": "13.76 MB" + }, + { + "name": "lua (chunk)", + "ips": "367.47", + "average": "2.72 ms", + "deviation": "±4.88%", + "median": "2.75 ms", + "p99": "2.99 ms", + "memory": "13.73 MB" + }, + { + "name": "luerl", + "ips": "362.27", + "average": "2.76 ms", + "deviation": "±3.93%", + "median": "2.80 ms", + "p99": "2.99 ms", + "memory": "13.76 MB" + } + ], + "comparison": [ + "lua (eval) 370.09", + "lua (chunk) 367.47 - 1.01x slower +0.0193 ms", + "luerl 362.27 - 1.02x slower +0.0584 ms" + ], + "memory_comparison": [ + "lua (eval) 13.76 MB", + "lua (chunk) 13.73 MB - 1.00x memory usage -0.03384 MB", + "luerl 13.76 MB - 1.00x memory usage -0.00677 MB" + ] + } + }, + "table_ops": { + "Table Build (mode: full)": { + "inputs": { + "With input large (n=1000)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "6.50 K", + "average": "153.83 μs", + "deviation": "±11.45%", + "median": "151.96 μs", + "p99": "195.78 μs", + "memory": "993.93 KB" + }, + { + "name": "luerl", + "ips": "6.44 K", + "average": "155.20 μs", + "deviation": "±14.07%", + "median": "152.33 μs", + "p99": "259.75 μs", + "memory": "996.96 KB" + }, + { + "name": "lua (chunk)", + "ips": "6.35 K", + "average": "157.51 μs", + "deviation": "±18.23%", + "median": "149.92 μs", + "p99": "262.79 μs", + "memory": "983.38 KB" + } + ], + "comparison": [ + "lua (eval) 6.50 K", + "luerl 6.44 K - 1.01x slower +1.37 μs", + "lua (chunk) 6.35 K - 1.02x slower +3.68 μs" + ] + }, + "With input medium (n=100)": { + "jobs": [ + { + "name": "luerl", + "ips": "59.29 K", + "average": "16.87 μs", + "deviation": "±38.96%", + "median": "16.25 μs", + "p99": "26.79 μs", + "memory": "110.78 KB" + }, + { + "name": "lua (eval)", + "ips": "51.13 K", + "average": "19.56 μs", + "deviation": "±34.91%", + "median": "16.46 μs", + "p99": "41.71 μs", + "memory": "111.19 KB" + }, + { + "name": "lua (chunk)", + "ips": "43.75 K", + "average": "22.86 μs", + "deviation": "±38.05%", + "median": "25.96 μs", + "p99": "47.33 μs", + "memory": "100.13 KB" + } + ], + "comparison": [ + "luerl 59.29 K", + "lua (eval) 51.13 K - 1.16x slower +2.69 μs", + "lua (chunk) 43.75 K - 1.36x slower +5.99 μs" + ] + }, + "With input small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "405.22 K", + "average": "2.47 μs", + "deviation": "±243.17%", + "median": "2 μs", + "p99": "5.83 μs", + "memory": "12.45 KB" + }, + { + "name": "luerl", + "ips": "321.06 K", + "average": "3.11 μs", + "deviation": "±187.65%", + "median": "3 μs", + "p99": "4.54 μs", + "memory": "22.62 KB" + }, + { + "name": "lua (eval)", + "ips": "282.18 K", + "average": "3.54 μs", + "deviation": "±178.79%", + "median": "3.13 μs", + "p99": "9.29 μs", + "memory": "23.02 KB" + } + ], + "comparison": [ + "lua (chunk) 405.22 K", + "luerl 321.06 K - 1.26x slower +0.65 μs", + "lua (eval) 282.18 K - 1.44x slower +1.08 μs" + ] + } + } + }, + "Table Sort (mode: full)": { + "inputs": { + "With input large (n=1000)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "5.61 K", + "average": "178.37 μs", + "deviation": "±12.59%", + "median": "174.29 μs", + "p99": "234.12 μs", + "memory": "1.17 MB" + }, + { + "name": "lua (eval)", + "ips": "5.45 K", + "average": "183.60 μs", + "deviation": "±106.15%", + "median": "177.67 μs", + "p99": "334.99 μs", + "memory": "1.18 MB" + }, + { + "name": "luerl", + "ips": "4.88 K", + "average": "205.11 μs", + "deviation": "±25.14%", + "median": "180.59 μs", + "p99": "336.78 μs", + "memory": "1.18 MB" + } + ], + "comparison": [ + "lua (chunk) 5.61 K", + "lua (eval) 5.45 K - 1.03x slower +5.23 μs", + "luerl 4.88 K - 1.15x slower +26.74 μs" + ] + }, + "With input medium (n=100)": { + "jobs": [ + { + "name": "luerl", + "ips": "51.03 K", + "average": "19.60 μs", + "deviation": "±20.90%", + "median": "19.08 μs", + "p99": "28.42 μs", + "memory": "133.34 KB" + }, + { + "name": "lua (chunk)", + "ips": "47.42 K", + "average": "21.09 μs", + "deviation": "±42.23%", + "median": "18.04 μs", + "p99": "51.33 μs", + "memory": "122.98 KB" + }, + { + "name": "lua (eval)", + "ips": "46.64 K", + "average": "21.44 μs", + "deviation": "±39.09%", + "median": "19.33 μs", + "p99": "45.92 μs", + "memory": "133.59 KB" + } + ], + "comparison": [ + "luerl 51.03 K", + "lua (chunk) 47.42 K - 1.08x slower +1.49 μs", + "lua (eval) 46.64 K - 1.09x slower +1.85 μs" + ] + }, + "With input small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "324.41 K", + "average": "3.08 μs", + "deviation": "±194.26%", + "median": "2.54 μs", + "p99": "7.08 μs", + "memory": "15.84 KB" + }, + { + "name": "luerl", + "ips": "271.87 K", + "average": "3.68 μs", + "deviation": "±124.45%", + "median": "3.54 μs", + "p99": "10 μs", + "memory": "25.98 KB" + }, + { + "name": "lua (eval)", + "ips": "245.19 K", + "average": "4.08 μs", + "deviation": "±136.48%", + "median": "3.67 μs", + "p99": "8.25 μs", + "memory": "26.25 KB" + } + ], + "comparison": [ + "lua (chunk) 324.41 K", + "luerl 271.87 K - 1.19x slower +0.60 μs", + "lua (eval) 245.19 K - 1.32x slower +1.00 μs" + ] + } + } + }, + "Table Iterate/Sum (mode: full)": { + "inputs": { + "With input large (n=1000)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "3.81 K", + "average": "262.24 μs", + "deviation": "±19.86%", + "median": "243.42 μs", + "p99": "444.12 μs", + "memory": "1.35 MB" + }, + { + "name": "luerl", + "ips": "3.75 K", + "average": "266.52 μs", + "deviation": "±20.59%", + "median": "243.13 μs", + "p99": "416.21 μs", + "memory": "1.35 MB" + }, + { + "name": "lua (chunk)", + "ips": "3.60 K", + "average": "278.11 μs", + "deviation": "±24.12%", + "median": "242.42 μs", + "p99": "456.96 μs", + "memory": "1.34 MB" + } + ], + "comparison": [ + "lua (eval) 3.81 K", + "luerl 3.75 K - 1.02x slower +4.28 μs", + "lua (chunk) 3.60 K - 1.06x slower +15.87 μs" + ] + }, + "With input medium (n=100)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "37.84 K", + "average": "26.43 μs", + "deviation": "±9.28%", + "median": "26.08 μs", + "p99": "29.92 μs", + "memory": "149.42 KB" + }, + { + "name": "luerl", + "ips": "37.50 K", + "average": "26.66 μs", + "deviation": "±86.90%", + "median": "26.21 μs", + "p99": "36.50 μs", + "memory": "149.28 KB" + }, + { + "name": "lua (chunk)", + "ips": "35.12 K", + "average": "28.47 μs", + "deviation": "±26.41%", + "median": "25.17 μs", + "p99": "53.58 μs", + "memory": "139.25 KB" + } + ], + "comparison": [ + "lua (eval) 37.84 K", + "luerl 37.50 K - 1.01x slower +0.24 μs", + "lua (chunk) 35.12 K - 1.08x slower +2.05 μs" + ] + }, + "With input small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "245.60 K", + "average": "4.07 μs", + "deviation": "±313.25%", + "median": "3.29 μs", + "p99": "9.79 μs", + "memory": "16.70 KB" + }, + { + "name": "luerl", + "ips": "227.18 K", + "average": "4.40 μs", + "deviation": "±102.94%", + "median": "4.29 μs", + "p99": "10.88 μs", + "memory": "26.80 KB" + }, + { + "name": "lua (eval)", + "ips": "206.12 K", + "average": "4.85 μs", + "deviation": "±120.13%", + "median": "4.33 μs", + "p99": "9.83 μs", + "memory": "27.20 KB" + } + ], + "comparison": [ + "lua (chunk) 245.60 K", + "luerl 227.18 K - 1.08x slower +0.33 μs", + "lua (eval) 206.12 K - 1.19x slower +0.78 μs" + ] + } + } + }, + "Table Map + Reduce (mode: full)": { + "inputs": { + "With input large (n=1000)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "2.21 K", + "average": "452.53 μs", + "deviation": "±7.79%", + "median": "446.25 μs", + "p99": "614.89 μs", + "memory": "2.45 MB" + }, + { + "name": "lua (chunk)", + "ips": "2.19 K", + "average": "455.66 μs", + "deviation": "±9.09%", + "median": "444.34 μs", + "p99": "628.64 μs", + "memory": "2.43 MB" + }, + { + "name": "luerl", + "ips": "2.19 K", + "average": "456.58 μs", + "deviation": "±12.12%", + "median": "442.21 μs", + "p99": "659.87 μs", + "memory": "2.45 MB" + } + ], + "comparison": [ + "lua (eval) 2.21 K", + "lua (chunk) 2.19 K - 1.01x slower +3.14 μs", + "luerl 2.19 K - 1.01x slower +4.05 μs" + ] + }, + "With input medium (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "21.79 K", + "average": "45.90 μs", + "deviation": "±12.29%", + "median": "45.25 μs", + "p99": "53.13 μs", + "memory": "252.38 KB" + }, + { + "name": "luerl", + "ips": "21.47 K", + "average": "46.58 μs", + "deviation": "±5.77%", + "median": "46.42 μs", + "p99": "52.54 μs", + "memory": "262.48 KB" + }, + { + "name": "lua (eval)", + "ips": "21.44 K", + "average": "46.65 μs", + "deviation": "±6.16%", + "median": "46.50 μs", + "p99": "51.96 μs", + "memory": "263.02 KB" + } + ], + "comparison": [ + "lua (chunk) 21.79 K", + "luerl 21.47 K - 1.01x slower +0.68 μs", + "lua (eval) 21.44 K - 1.02x slower +0.75 μs" + ] + }, + "With input small (n=10)": { + "jobs": [ + { + "name": "luerl", + "ips": "148.83 K", + "average": "6.72 μs", + "deviation": "±69.12%", + "median": "6.58 μs", + "p99": "14.71 μs", + "memory": "39.39 KB" + }, + { + "name": "lua (chunk)", + "ips": "145.39 K", + "average": "6.88 μs", + "deviation": "±90.79%", + "median": "5.54 μs", + "p99": "14.96 μs", + "memory": "29.06 KB" + }, + { + "name": "lua (eval)", + "ips": "131.69 K", + "average": "7.59 μs", + "deviation": "±88.05%", + "median": "6.71 μs", + "p99": "16.42 μs", + "memory": "39.80 KB" + } + ], + "comparison": [ + "luerl 148.83 K", + "lua (chunk) 145.39 K - 1.02x slower +0.159 μs", + "lua (eval) 131.69 K - 1.13x slower +0.87 μs" + ] + } + } + }, + "Table Pairs (hash) (mode: full)": { + "inputs": { + "With input large (n=1000)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "760.68", + "average": "1.31 ms", + "deviation": "±5.02%", + "median": "1.32 ms", + "p99": "1.45 ms", + "memory": "2.48 MB" + }, + { + "name": "lua (chunk)", + "ips": "759.91", + "average": "1.32 ms", + "deviation": "±5.40%", + "median": "1.32 ms", + "p99": "1.49 ms", + "memory": "2.47 MB" + }, + { + "name": "luerl", + "ips": "754.19", + "average": "1.33 ms", + "deviation": "±6.28%", + "median": "1.33 ms", + "p99": "1.45 ms", + "memory": "2.48 MB" + } + ], + "comparison": [ + "lua (eval) 760.68", + "lua (chunk) 759.91 - 1.00x slower +0.00133 ms", + "luerl 754.19 - 1.01x slower +0.0113 ms" + ] + }, + "With input medium (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "10.96 K", + "average": "91.23 μs", + "deviation": "±12.10%", + "median": "89.92 μs", + "p99": "135.19 μs", + "memory": "238.58 KB" + }, + { + "name": "lua (eval)", + "ips": "10.20 K", + "average": "97.99 μs", + "deviation": "±11.84%", + "median": "97.42 μs", + "p99": "138.25 μs", + "memory": "249.40 KB" + }, + { + "name": "luerl", + "ips": "9.96 K", + "average": "100.37 μs", + "deviation": "±20.92%", + "median": "99.96 μs", + "p99": "144.67 μs", + "memory": "248.99 KB" + } + ], + "comparison": [ + "lua (chunk) 10.96 K", + "lua (eval) 10.20 K - 1.07x slower +6.76 μs", + "luerl 9.96 K - 1.10x slower +9.14 μs" + ] + }, + "With input small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "126.80 K", + "average": "7.89 μs", + "deviation": "±150.76%", + "median": "6.50 μs", + "p99": "18.79 μs", + "memory": "25.83 KB" + }, + { + "name": "luerl", + "ips": "125.64 K", + "average": "7.96 μs", + "deviation": "±72.08%", + "median": "7.29 μs", + "p99": "24.29 μs", + "memory": "36.16 KB" + }, + { + "name": "lua (eval)", + "ips": "109.11 K", + "average": "9.17 μs", + "deviation": "±61.17%", + "median": "9.38 μs", + "p99": "28.33 μs", + "memory": "36.56 KB" + } + ], + "comparison": [ + "lua (chunk) 126.80 K", + "luerl 125.64 K - 1.01x slower +0.0728 μs", + "lua (eval) 109.11 K - 1.16x slower +1.28 μs" + ] + } + } + } + }, + "patterns": { + "patterns: find/match field extraction (n=200) (mode: full)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "947.52", + "average": "1.06 ms", + "deviation": "±3.75%", + "median": "1.06 ms", + "p99": "1.18 ms", + "memory": "6.70 MB" + }, + { + "name": "luerl", + "ips": "937.83", + "average": "1.07 ms", + "deviation": "±6.93%", + "median": "1.05 ms", + "p99": "1.22 ms", + "memory": "6.70 MB" + }, + { + "name": "lua (chunk)", + "ips": "919.82", + "average": "1.09 ms", + "deviation": "±4.39%", + "median": "1.07 ms", + "p99": "1.22 ms", + "memory": "6.69 MB" + } + ], + "comparison": [ + "lua (eval) 947.52", + "luerl 937.83 - 1.01x slower +0.0109 ms", + "lua (chunk) 919.82 - 1.03x slower +0.0318 ms" + ] + }, + "patterns: find-based tokenizer (n=200) (mode: full)": { + "jobs": [ + { + "name": "luerl", + "ips": "731.44", + "average": "1.37 ms", + "deviation": "±3.23%", + "median": "1.36 ms", + "p99": "1.51 ms", + "memory": "6.65 MB" + }, + { + "name": "lua (chunk)", + "ips": "721.24", + "average": "1.39 ms", + "deviation": "±4.36%", + "median": "1.37 ms", + "p99": "1.57 ms", + "memory": "6.64 MB" + }, + { + "name": "lua (eval)", + "ips": "715.30", + "average": "1.40 ms", + "deviation": "±4.16%", + "median": "1.39 ms", + "p99": "1.49 ms", + "memory": "6.65 MB" + } + ], + "comparison": [ + "luerl 731.44", + "lua (chunk) 721.24 - 1.01x slower +0.0193 ms", + "lua (eval) 715.30 - 1.02x slower +0.0308 ms" + ] + }, + "patterns: gsub template substitution (n=200) (mode: full)": { + "jobs": [ + { + "name": "luerl", + "ips": "452.66", + "average": "2.21 ms", + "deviation": "±4.59%", + "median": "2.21 ms", + "p99": "2.50 ms", + "memory": "11.75 MB" + }, + { + "name": "lua (eval)", + "ips": "446.40", + "average": "2.24 ms", + "deviation": "±8.19%", + "median": "2.19 ms", + "p99": "2.75 ms", + "memory": "11.75 MB" + }, + { + "name": "lua (chunk)", + "ips": "436.67", + "average": "2.29 ms", + "deviation": "±10.44%", + "median": "2.21 ms", + "p99": "3.18 ms", + "memory": "11.74 MB" + } + ], + "comparison": [ + "luerl 452.66", + "lua (eval) 446.40 - 1.01x slower +0.0310 ms", + "lua (chunk) 436.67 - 1.04x slower +0.0809 ms" + ], + "memory_comparison": [ + "luerl 11.75 MB", + "lua (eval) 11.75 MB - 1.00x memory usage +0.00021 MB", + "lua (chunk) 11.74 MB - 1.00x memory usage -0.00975 MB" + ] + } + }, + "metamethods": { + "metamethods: self-call method dispatch (n=200) (mode: full)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "2.04 K", + "average": "489.21 μs", + "deviation": "±10.11%", + "median": "476.58 μs", + "p99": "672.87 μs", + "memory": "1.42 MB" + }, + { + "name": "lua (eval)", + "ips": "1.99 K", + "average": "503.66 μs", + "deviation": "±9.40%", + "median": "488.92 μs", + "p99": "678.61 μs", + "memory": "1.43 MB" + }, + { + "name": "luerl", + "ips": "1.98 K", + "average": "505.65 μs", + "deviation": "±10.20%", + "median": "492.79 μs", + "p99": "680.89 μs", + "memory": "1.43 MB" + } + ], + "comparison": [ + "lua (chunk) 2.04 K", + "lua (eval) 1.99 K - 1.03x slower +14.45 μs", + "luerl 1.98 K - 1.03x slower +16.44 μs" + ] + }, + "metamethods: 3-level __index chain (n=200) (mode: full)": { + "jobs": [ + { + "name": "luerl", + "ips": "4.58 K", + "average": "218.46 μs", + "deviation": "±4.01%", + "median": "217.92 μs", + "p99": "233.19 μs", + "memory": "668.59 KB" + }, + { + "name": "lua (chunk)", + "ips": "4.57 K", + "average": "219.03 μs", + "deviation": "±10.32%", + "median": "216.42 μs", + "p99": "254.20 μs", + "memory": "658.30 KB" + }, + { + "name": "lua (eval)", + "ips": "4.52 K", + "average": "221.04 μs", + "deviation": "±5.51%", + "median": "219.33 μs", + "p99": "245.33 μs", + "memory": "668.80 KB" + } + ], + "comparison": [ + "luerl 4.58 K", + "lua (chunk) 4.57 K - 1.00x slower +0.57 μs", + "lua (eval) 4.52 K - 1.01x slower +2.57 μs" + ] + }, + "metamethods: arithmetic/relational metamethods (n=200) (mode: full)": { + "jobs": [ + { + "name": "luerl", + "ips": "519.76", + "average": "1.92 ms", + "deviation": "±9.38%", + "median": "1.88 ms", + "p99": "2.25 ms", + "memory": "6.24 MB" + }, + { + "name": "lua (chunk)", + "ips": "513.47", + "average": "1.95 ms", + "deviation": "±15.04%", + "median": "1.88 ms", + "p99": "2.30 ms", + "memory": "6.23 MB" + }, + { + "name": "lua (eval)", + "ips": "508.61", + "average": "1.97 ms", + "deviation": "±10.60%", + "median": "1.89 ms", + "p99": "2.93 ms", + "memory": "6.24 MB" + } + ], + "comparison": [ + "luerl 519.76", + "lua (chunk) 513.47 - 1.01x slower +0.0236 ms", + "lua (eval) 508.61 - 1.02x slower +0.0422 ms" + ] + } + }, + "pcall_varargs": { + "call protocol: pcall, success path (n=500) (mode: full)": { + "jobs": [ + { + "name": "luerl", + "ips": "3.44 K", + "average": "291.08 μs", + "deviation": "±3.72%", + "median": "289.67 μs", + "p99": "313 μs", + "memory": "1.11 MB" + }, + { + "name": "lua (eval)", + "ips": "3.42 K", + "average": "292.45 μs", + "deviation": "±3.54%", + "median": "291.17 μs", + "p99": "315.44 μs", + "memory": "1.11 MB" + }, + { + "name": "lua (chunk)", + "ips": "3.40 K", + "average": "293.76 μs", + "deviation": "±7.51%", + "median": "290.50 μs", + "p99": "338.21 μs", + "memory": "1.10 MB" + } + ], + "comparison": [ + "luerl 3.44 K", + "lua (eval) 3.42 K - 1.00x slower +1.36 μs", + "lua (chunk) 3.40 K - 1.01x slower +2.68 μs" + ] + }, + "call protocol: pcall, raise + catch (n=500) (mode: full)": { + "jobs": [ + { + "name": "luerl", + "ips": "2.13 K", + "average": "468.84 μs", + "deviation": "±5.39%", + "median": "466.38 μs", + "p99": "505.90 μs", + "memory": "1.55 MB" + }, + { + "name": "lua (eval)", + "ips": "2.13 K", + "average": "470.00 μs", + "deviation": "±3.58%", + "median": "467.50 μs", + "p99": "498.92 μs", + "memory": "1.55 MB" + }, + { + "name": "lua (chunk)", + "ips": "2.11 K", + "average": "474.55 μs", + "deviation": "±9.26%", + "median": "469.29 μs", + "p99": "583.95 μs", + "memory": "1.54 MB" + } + ], + "comparison": [ + "luerl 2.13 K", + "lua (eval) 2.13 K - 1.00x slower +1.15 μs", + "lua (chunk) 2.11 K - 1.01x slower +5.70 μs" + ] + }, + "call protocol: varargs + multiple returns (n=500) (mode: full)": { + "jobs": [ + { + "name": "luerl", + "ips": "418.17", + "average": "2.39 ms", + "deviation": "±4.44%", + "median": "2.36 ms", + "p99": "2.70 ms", + "memory": "8.73 MB" + }, + { + "name": "lua (eval)", + "ips": "400.02", + "average": "2.50 ms", + "deviation": "±11.76%", + "median": "2.39 ms", + "p99": "3.48 ms", + "memory": "8.73 MB" + }, + { + "name": "lua (chunk)", + "ips": "391.27", + "average": "2.56 ms", + "deviation": "±14.33%", + "median": "2.40 ms", + "p99": "3.52 ms", + "memory": "8.72 MB" + } + ], + "comparison": [ + "luerl 418.17", + "lua (eval) 400.02 - 1.05x slower +0.109 ms", + "lua (chunk) 391.27 - 1.07x slower +0.164 ms" + ] + } + }, + "vm_new": { + "VM instantiation: Lua.new/1 vs :luerl.init/0 (mode: full)": { + "jobs": [ + { + "name": "lua (new, no sandbox)", + "ips": "66.32 K", + "average": "15.08 μs", + "deviation": "±21.55%", + "median": "14.88 μs", + "p99": "22.58 μs", + "memory": "51.77 KB" + }, + { + "name": "luerl (init)", + "ips": "64.85 K", + "average": "15.42 μs", + "deviation": "±46.27%", + "median": "15.04 μs", + "p99": "30.25 μs", + "memory": "51.64 KB" + }, + { + "name": "lua (new, custom exclude)", + "ips": "47.89 K", + "average": "20.88 μs", + "deviation": "±63.00%", + "median": "20.63 μs", + "p99": "31.67 μs", + "memory": "72.27 KB" + }, + { + "name": "lua (new)", + "ips": "46.57 K", + "average": "21.47 μs", + "deviation": "±15.57%", + "median": "21.33 μs", + "p99": "31.25 μs", + "memory": "73.78 KB" + } + ], + "comparison": [ + "lua (new, no sandbox) 66.32 K", + "luerl (init) 64.85 K - 1.02x slower +0.34 μs", + "lua (new, custom exclude) 47.89 K - 1.38x slower +5.80 μs", + "lua (new) 46.57 K - 1.42x slower +6.40 μs" + ] + }, + "_cold_start": { + "first_call": "10070.0 us", + "second_call": "48.0 us" + } + }, + "encode_decode": { + "raw": "lua 0.4.0 — encode!/decode! decomposition\n(decode+deep_cast column: enabled)\n==================================================================================\nop shape N total_us per_elem_ns\nencode int_list 8 0.27 33.3\ndecode int_list 8 0.09 11.2\ndec+cast int_list 8 0.11 13.7\nencode int_list 64 1.61 25.1\ndecode int_list 64 0.54 8.5\ndec+cast int_list 64 0.96 15.0\nencode int_list 512 26.38 51.5\ndecode int_list 512 5.11 10.0\ndec+cast int_list 512 8.60 16.8\nencode int_list 4096 226.97 55.4\ndecode int_list 4096 43.47 10.6\ndec+cast int_list 4096 88.67 21.6\n----------------------------------------------------------------------------------\nencode float_list 8 0.22 27.7\ndecode float_list 8 0.09 11.2\ndec+cast float_list 8 0.11 13.2\nencode float_list 64 1.64 25.7\ndecode float_list 64 0.55 8.5\ndec+cast float_list 64 0.94 14.7\nencode float_list 512 27.00 52.7\ndecode float_list 512 5.85 11.4\ndec+cast float_list 512 9.00 17.6\nencode float_list 4096 239.42 58.5\ndecode float_list 4096 64.74 15.8\ndec+cast float_list 4096 98.40 24.0\n----------------------------------------------------------------------------------\nencode bool_list 8 0.21 26.4\ndecode bool_list 8 0.08 10.0\ndec+cast bool_list 8 0.10 12.6\nencode bool_list 64 1.60 25.0\ndecode bool_list 64 0.56 8.7\ndec+cast bool_list 64 0.97 15.1\nencode bool_list 512 25.68 50.1\ndecode bool_list 512 4.80 9.4\ndec+cast bool_list 512 8.38 16.4\nencode bool_list 4096 224.95 54.9\ndecode bool_list 4096 38.99 9.5\ndec+cast bool_list 4096 84.05 20.5\n----------------------------------------------------------------------------------\nencode short_string_list 8 0.21 26.7\ndecode short_string_list 8 0.08 10.5\ndec+cast short_string_list 8 0.10 12.9\nencode short_string_list 64 1.62 25.3\ndecode short_string_list 64 0.55 8.6\ndec+cast short_string_list 64 0.98 15.4\nencode short_string_list 512 26.45 51.7\ndecode short_string_list 512 4.86 9.5\ndec+cast short_string_list 512 9.05 17.7\nencode short_string_list 4096 202.93 49.5\ndecode short_string_list 4096 43.46 10.6\ndec+cast short_string_list 4096 73.06 17.8\n----------------------------------------------------------------------------------\nencode long_string_list 8 0.22 27.2\ndecode long_string_list 8 0.08 10.4\ndec+cast long_string_list 8 0.10 12.7\nencode long_string_list 64 1.67 26.1\ndecode long_string_list 64 0.59 9.2\ndec+cast long_string_list 64 1.03 16.1\nencode long_string_list 512 28.42 55.5\ndecode long_string_list 512 5.16 10.1\ndec+cast long_string_list 512 9.09 17.7\nencode long_string_list 4096 465.96 113.8\ndecode long_string_list 4096 54.39 13.3\ndec+cast long_string_list 4096 108.40 26.5\n----------------------------------------------------------------------------------\nencode string_map 8 0.52 65.3\ndecode string_map 8 0.07 8.2\ndec+cast string_map 8 0.17 21.1\nencode string_map 64 6.56 102.5\ndecode string_map 64 0.40 6.2\ndec+cast string_map 64 3.21 50.2\nencode string_map 512 113.10 220.9\ndecode string_map 512 4.27 8.3\ndec+cast string_map 512 39.39 76.9\nencode string_map 4096 1295.09 316.2\ndecode string_map 4096 42.02 10.3\ndec+cast string_map 4096 367.76 89.8\n----------------------------------------------------------------------------------\nencode int_map 8 0.25 31.2\ndecode int_map 8 0.09 10.7\ndec+cast int_map 8 0.10 12.9\nencode int_map 64 3.56 55.6\ndecode int_map 64 0.57 8.9\ndec+cast int_map 64 0.97 15.1\nencode int_map 512 51.12 99.8\ndecode int_map 512 4.79 9.4\ndec+cast int_map 512 8.76 17.1\nencode int_map 4096 443.17 108.2\ndecode int_map 4096 40.30 9.8\ndec+cast int_map 4096 87.24 21.3\n----------------------------------------------------------------------------------\nencode record_list 8 2.07 258.7\ndecode record_list 8 0.40 49.9\ndec+cast record_list 8 0.80 100.1\nencode record_list 64 31.54 492.8\ndecode record_list 64 4.54 70.9\ndec+cast record_list 64 7.32 114.4\nencode record_list 512 287.96 562.4\ndecode record_list 512 52.67 102.9\ndec+cast record_list 512 77.64 151.6\nencode record_list 4096 2346.38 572.8\ndecode record_list 4096 406.88 99.3\ndec+cast record_list 4096 658.70 160.8\n----------------------------------------------------------------------------------\nnested chain (depth sweep) — isolates recursion/traversal from fan-out\nop shape N total_us per_elem_ns\nencode nested_chain 4 0.51 257.3\ndecode nested_chain 4 0.12 61.7\ndec+cast nested_chain 4 0.24 118.7\nencode nested_chain 16 2.06 1032.0\ndecode nested_chain 16 0.63 314.9\ndec+cast nested_chain 16 1.10 550.2\nencode nested_chain 64 8.53 4265.3\ndecode nested_chain 64 4.16 2079.7\ndec+cast nested_chain 64 6.63 3317.0\nencode nested_chain 256 34.34 17170.4\ndecode nested_chain 256 37.82 18909.7\ndec+cast nested_chain 256 47.43 23716.3\n==================================================================================\ncomposite anchor — the PR's `original_nested` (matches the 18us/108us figure)\nop shape N total_us per_elem_ns\nencode original_nested 75 3.48 870.0\ndecode original_nested 75 0.58 145.4\ndec+cast original_nested 75 1.32 329.3\n" + } +} \ No newline at end of file diff --git a/bench_results/v0.4.0/table_ops.txt b/bench_results/v0.4.0/table_ops.txt new file mode 100644 index 00000000..d0e203b2 --- /dev/null +++ b/bench_results/v0.4.0/table_ops.txt @@ -0,0 +1,461 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== Table Build (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +lua (eval) 6.50 K 153.83 μs ±11.45% 151.96 μs 195.78 μs +luerl 6.44 K 155.20 μs ±14.07% 152.33 μs 259.75 μs +lua (chunk) 6.35 K 157.51 μs ±18.23% 149.92 μs 262.79 μs + +Comparison: +lua (eval) 6.50 K +luerl 6.44 K - 1.01x slower +1.37 μs +lua (chunk) 6.35 K - 1.02x slower +3.68 μs + +Memory usage statistics: + +Name Memory usage +lua (eval) 993.93 KB +luerl 996.96 KB - 1.00x memory usage +3.03 KB +lua (chunk) 983.38 KB - 0.99x memory usage -10.54688 KB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +luerl 59.29 K 16.87 μs ±38.96% 16.25 μs 26.79 μs +lua (eval) 51.13 K 19.56 μs ±34.91% 16.46 μs 41.71 μs +lua (chunk) 43.75 K 22.86 μs ±38.05% 25.96 μs 47.33 μs + +Comparison: +luerl 59.29 K +lua (eval) 51.13 K - 1.16x slower +2.69 μs +lua (chunk) 43.75 K - 1.36x slower +5.99 μs + +Memory usage statistics: + +Name Memory usage +luerl 110.78 KB +lua (eval) 111.19 KB - 1.00x memory usage +0.41 KB +lua (chunk) 100.13 KB - 0.90x memory usage -10.64844 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 405.22 K 2.47 μs ±243.17% 2 μs 5.83 μs +luerl 321.06 K 3.11 μs ±187.65% 3 μs 4.54 μs +lua (eval) 282.18 K 3.54 μs ±178.79% 3.13 μs 9.29 μs + +Comparison: +lua (chunk) 405.22 K +luerl 321.06 K - 1.26x slower +0.65 μs +lua (eval) 282.18 K - 1.44x slower +1.08 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 12.45 KB +luerl 22.62 KB - 1.82x memory usage +10.16 KB +lua (eval) 23.02 KB - 1.85x memory usage +10.57 KB + +**All measurements for memory usage were the same** + +=== Table Sort (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +lua (chunk) 5.61 K 178.37 μs ±12.59% 174.29 μs 234.12 μs +lua (eval) 5.45 K 183.60 μs ±106.15% 177.67 μs 334.99 μs +luerl 4.88 K 205.11 μs ±25.14% 180.59 μs 336.78 μs + +Comparison: +lua (chunk) 5.61 K +lua (eval) 5.45 K - 1.03x slower +5.23 μs +luerl 4.88 K - 1.15x slower +26.74 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 1.17 MB +lua (eval) 1.18 MB - 1.01x memory usage +0.0107 MB +luerl 1.18 MB - 1.01x memory usage +0.00999 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +luerl 51.03 K 19.60 μs ±20.90% 19.08 μs 28.42 μs +lua (chunk) 47.42 K 21.09 μs ±42.23% 18.04 μs 51.33 μs +lua (eval) 46.64 K 21.44 μs ±39.09% 19.33 μs 45.92 μs + +Comparison: +luerl 51.03 K +lua (chunk) 47.42 K - 1.08x slower +1.49 μs +lua (eval) 46.64 K - 1.09x slower +1.85 μs + +Memory usage statistics: + +Name Memory usage +luerl 133.34 KB +lua (chunk) 122.98 KB - 0.92x memory usage -10.35156 KB +lua (eval) 133.59 KB - 1.00x memory usage +0.25 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 324.41 K 3.08 μs ±194.26% 2.54 μs 7.08 μs +luerl 271.87 K 3.68 μs ±124.45% 3.54 μs 10 μs +lua (eval) 245.19 K 4.08 μs ±136.48% 3.67 μs 8.25 μs + +Comparison: +lua (chunk) 324.41 K +luerl 271.87 K - 1.19x slower +0.60 μs +lua (eval) 245.19 K - 1.32x slower +1.00 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 15.84 KB +luerl 25.98 KB - 1.64x memory usage +10.13 KB +lua (eval) 26.25 KB - 1.66x memory usage +10.41 KB + +**All measurements for memory usage were the same** + +=== Table Iterate/Sum (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +lua (eval) 3.81 K 262.24 μs ±19.86% 243.42 μs 444.12 μs +luerl 3.75 K 266.52 μs ±20.59% 243.13 μs 416.21 μs +lua (chunk) 3.60 K 278.11 μs ±24.12% 242.42 μs 456.96 μs + +Comparison: +lua (eval) 3.81 K +luerl 3.75 K - 1.02x slower +4.28 μs +lua (chunk) 3.60 K - 1.06x slower +15.87 μs + +Memory usage statistics: + +Name Memory usage +lua (eval) 1.35 MB +luerl 1.35 MB - 1.00x memory usage -0.00027 MB +lua (chunk) 1.34 MB - 0.99x memory usage -0.01018 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +lua (eval) 37.84 K 26.43 μs ±9.28% 26.08 μs 29.92 μs +luerl 37.50 K 26.66 μs ±86.90% 26.21 μs 36.50 μs +lua (chunk) 35.12 K 28.47 μs ±26.41% 25.17 μs 53.58 μs + +Comparison: +lua (eval) 37.84 K +luerl 37.50 K - 1.01x slower +0.24 μs +lua (chunk) 35.12 K - 1.08x slower +2.05 μs + +Memory usage statistics: + +Name Memory usage +lua (eval) 149.42 KB +luerl 149.28 KB - 1.00x memory usage -0.14063 KB +lua (chunk) 139.25 KB - 0.93x memory usage -10.17188 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 245.60 K 4.07 μs ±313.25% 3.29 μs 9.79 μs +luerl 227.18 K 4.40 μs ±102.94% 4.29 μs 10.88 μs +lua (eval) 206.12 K 4.85 μs ±120.13% 4.33 μs 9.83 μs + +Comparison: +lua (chunk) 245.60 K +luerl 227.18 K - 1.08x slower +0.33 μs +lua (eval) 206.12 K - 1.19x slower +0.78 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 16.70 KB +luerl 26.80 KB - 1.61x memory usage +10.10 KB +lua (eval) 27.20 KB - 1.63x memory usage +10.51 KB + +**All measurements for memory usage were the same** + +=== Table Map + Reduce (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +lua (eval) 2.21 K 452.53 μs ±7.79% 446.25 μs 614.89 μs +lua (chunk) 2.19 K 455.66 μs ±9.09% 444.34 μs 628.64 μs +luerl 2.19 K 456.58 μs ±12.12% 442.21 μs 659.87 μs + +Comparison: +lua (eval) 2.21 K +lua (chunk) 2.19 K - 1.01x slower +3.14 μs +luerl 2.19 K - 1.01x slower +4.05 μs + +Memory usage statistics: + +Name Memory usage +lua (eval) 2.45 MB +lua (chunk) 2.43 MB - 1.00x memory usage -0.01137 MB +luerl 2.45 MB - 1.00x memory usage -0.00098 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +lua (chunk) 21.79 K 45.90 μs ±12.29% 45.25 μs 53.13 μs +luerl 21.47 K 46.58 μs ±5.77% 46.42 μs 52.54 μs +lua (eval) 21.44 K 46.65 μs ±6.16% 46.50 μs 51.96 μs + +Comparison: +lua (chunk) 21.79 K +luerl 21.47 K - 1.01x slower +0.68 μs +lua (eval) 21.44 K - 1.02x slower +0.75 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 252.38 KB +luerl 262.48 KB - 1.04x memory usage +10.11 KB +lua (eval) 263.02 KB - 1.04x memory usage +10.65 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +luerl 148.83 K 6.72 μs ±69.12% 6.58 μs 14.71 μs +lua (chunk) 145.39 K 6.88 μs ±90.79% 5.54 μs 14.96 μs +lua (eval) 131.69 K 7.59 μs ±88.05% 6.71 μs 16.42 μs + +Comparison: +luerl 148.83 K +lua (chunk) 145.39 K - 1.02x slower +0.159 μs +lua (eval) 131.69 K - 1.13x slower +0.87 μs + +Memory usage statistics: + +Name Memory usage +luerl 39.39 KB +lua (chunk) 29.06 KB - 0.74x memory usage -10.32813 KB +lua (eval) 39.80 KB - 1.01x memory usage +0.41 KB + +**All measurements for memory usage were the same** + +=== Table Pairs (hash) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +lua (eval) 760.68 1.31 ms ±5.02% 1.32 ms 1.45 ms +lua (chunk) 759.91 1.32 ms ±5.40% 1.32 ms 1.49 ms +luerl 754.19 1.33 ms ±6.28% 1.33 ms 1.45 ms + +Comparison: +lua (eval) 760.68 +lua (chunk) 759.91 - 1.00x slower +0.00133 ms +luerl 754.19 - 1.01x slower +0.0113 ms + +Memory usage statistics: + +Name Memory usage +lua (eval) 2.48 MB +lua (chunk) 2.47 MB - 1.00x memory usage -0.01072 MB +luerl 2.48 MB - 1.00x memory usage +0.00007 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +lua (chunk) 10.96 K 91.23 μs ±12.10% 89.92 μs 135.19 μs +lua (eval) 10.20 K 97.99 μs ±11.84% 97.42 μs 138.25 μs +luerl 9.96 K 100.37 μs ±20.92% 99.96 μs 144.67 μs + +Comparison: +lua (chunk) 10.96 K +lua (eval) 10.20 K - 1.07x slower +6.76 μs +luerl 9.96 K - 1.10x slower +9.14 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 238.58 KB +lua (eval) 249.40 KB - 1.05x memory usage +10.82 KB +luerl 248.99 KB - 1.04x memory usage +10.41 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 126.80 K 7.89 μs ±150.76% 6.50 μs 18.79 μs +luerl 125.64 K 7.96 μs ±72.08% 7.29 μs 24.29 μs +lua (eval) 109.11 K 9.17 μs ±61.17% 9.38 μs 28.33 μs + +Comparison: +lua (chunk) 126.80 K +luerl 125.64 K - 1.01x slower +0.0728 μs +lua (eval) 109.11 K - 1.16x slower +1.28 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 25.83 KB +luerl 36.16 KB - 1.40x memory usage +10.33 KB +lua (eval) 36.56 KB - 1.42x memory usage +10.73 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/v0.4.0/timestamp.txt b/bench_results/v0.4.0/timestamp.txt new file mode 100644 index 00000000..0c188fe0 --- /dev/null +++ b/bench_results/v0.4.0/timestamp.txt @@ -0,0 +1 @@ +2026-07-28T13:59:36Z diff --git a/bench_results/v0.4.0/versions.txt b/bench_results/v0.4.0/versions.txt new file mode 100644 index 00000000..184e5f9a --- /dev/null +++ b/bench_results/v0.4.0/versions.txt @@ -0,0 +1,3 @@ +Erlang/OTP 29 [erts-17.0] [source] [64-bit] [smp:10:10] [ds:10:10:10] [async-threads:1] [jit] + +Elixir 1.20.0 (compiled with Erlang/OTP 29) diff --git a/bench_results/v0.4.0/vm_new.txt b/bench_results/v0.4.0/vm_new.txt new file mode 100644 index 00000000..7d4449c6 --- /dev/null +++ b/bench_results/v0.4.0/vm_new.txt @@ -0,0 +1,56 @@ +=== VM instantiation: Lua.new/1 vs :luerl.init/0 (mode: full) === + +Lua.new() one-time vs repeat cost (single samples, informational): + first call on this node : 10070.0 us + second call : 48.0 us + +The first figure includes any one-time template build and first-time module +loading. Benchee's steady-state numbers below are the per-request cost after +that point. + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 52 s +Excluding outliers: false + +Benchmarking lua (new) ... +Benchmarking lua (new, custom exclude) ... +Benchmarking lua (new, no sandbox) ... +Benchmarking luerl (init) ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (new, no sandbox) 66.32 K 15.08 μs ±21.55% 14.88 μs 22.58 μs +luerl (init) 64.85 K 15.42 μs ±46.27% 15.04 μs 30.25 μs +lua (new, custom exclude) 47.89 K 20.88 μs ±63.00% 20.63 μs 31.67 μs +lua (new) 46.57 K 21.47 μs ±15.57% 21.33 μs 31.25 μs + +Comparison: +lua (new, no sandbox) 66.32 K +luerl (init) 64.85 K - 1.02x slower +0.34 μs +lua (new, custom exclude) 47.89 K - 1.38x slower +5.80 μs +lua (new) 46.57 K - 1.42x slower +6.40 μs + +Memory usage statistics: + +Name Memory usage +lua (new, no sandbox) 51.77 KB +luerl (init) 51.64 KB - 1.00x memory usage -0.12500 KB +lua (new, custom exclude) 72.27 KB - 1.40x memory usage +20.51 KB +lua (new) 73.78 KB - 1.43x memory usage +22.02 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.0/closures.txt b/bench_results/v1.0.0/closures.txt new file mode 100644 index 00000000..244e4660 --- /dev/null +++ b/bench_results/v1.0.0/closures.txt @@ -0,0 +1,46 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 2.58 K 388.21 μs ±7.60% 382.50 μs 546.24 μs +lua (chunk) 2.09 K 478.08 μs ±6.68% 473.54 μs 613.46 μs +lua (eval) 2.01 K 498.43 μs ±16.91% 482.54 μs 1019.38 μs + +Comparison: +luerl 2.58 K +lua (chunk) 2.09 K - 1.23x slower +89.87 μs +lua (eval) 2.01 K - 1.28x slower +110.23 μs + +Memory usage statistics: + +Name average deviation median 99th % +luerl 1.90 MB ±0.00% 1.90 MB 1.90 MB +lua (chunk) 2.65 MB ±0.08% 2.65 MB 2.65 MB +lua (eval) 2.65 MB ±0.09% 2.65 MB 2.66 MB + +Comparison: +luerl 1.90 MB +lua (chunk) 2.65 MB - 1.39x memory usage +0.75 MB +lua (eval) 2.65 MB - 1.40x memory usage +0.76 MB diff --git a/bench_results/v1.0.0/cpu.txt b/bench_results/v1.0.0/cpu.txt new file mode 100644 index 00000000..de5c8ad6 --- /dev/null +++ b/bench_results/v1.0.0/cpu.txt @@ -0,0 +1 @@ +Apple M4 diff --git a/bench_results/v1.0.0/encode_decode.txt b/bench_results/v1.0.0/encode_decode.txt new file mode 100644 index 00000000..f1b2d16b --- /dev/null +++ b/bench_results/v1.0.0/encode_decode.txt @@ -0,0 +1,128 @@ +lua 1.0.0 — encode!/decode! decomposition +(decode+deep_cast column: enabled) +================================================================================== +op shape N total_us per_elem_ns +encode int_list 8 0.90 113.0 +decode int_list 8 0.28 34.7 +dec+cast int_list 8 0.31 38.5 +encode int_list 64 13.52 211.2 +decode int_list 64 4.23 66.0 +dec+cast int_list 64 7.14 111.6 +encode int_list 512 151.37 295.6 +decode int_list 512 48.44 94.6 +dec+cast int_list 512 73.99 144.5 +encode int_list 4096 1601.11 390.9 +decode int_list 4096 583.70 142.5 +dec+cast int_list 4096 785.61 191.8 +---------------------------------------------------------------------------------- +encode float_list 8 0.78 97.1 +decode float_list 8 0.24 29.8 +dec+cast float_list 8 0.26 32.9 +encode float_list 64 11.47 179.2 +decode float_list 64 3.55 55.4 +dec+cast float_list 64 7.03 109.9 +encode float_list 512 156.43 305.5 +decode float_list 512 45.74 89.3 +dec+cast float_list 512 72.74 142.1 +encode float_list 4096 1611.38 393.4 +decode float_list 4096 551.08 134.5 +dec+cast float_list 4096 839.19 204.9 +---------------------------------------------------------------------------------- +encode bool_list 8 0.77 96.7 +decode bool_list 8 0.23 28.7 +dec+cast bool_list 8 0.25 31.3 +encode bool_list 64 11.21 175.2 +decode bool_list 64 3.46 54.1 +dec+cast bool_list 64 7.00 109.4 +encode bool_list 512 153.74 300.3 +decode bool_list 512 47.42 92.6 +dec+cast bool_list 512 74.01 144.6 +encode bool_list 4096 1569.25 383.1 +decode bool_list 4096 571.91 139.6 +dec+cast bool_list 4096 775.34 189.3 +---------------------------------------------------------------------------------- +encode short_string_list 8 0.78 96.9 +decode short_string_list 8 0.24 29.8 +dec+cast short_string_list 8 0.26 32.6 +encode short_string_list 64 11.40 178.1 +decode short_string_list 64 3.53 55.2 +dec+cast short_string_list 64 7.08 110.6 +encode short_string_list 512 155.05 302.8 +decode short_string_list 512 49.88 97.4 +dec+cast short_string_list 512 72.83 142.2 +encode short_string_list 4096 1715.16 418.7 +decode short_string_list 4096 718.28 175.4 +dec+cast short_string_list 4096 763.05 186.3 +---------------------------------------------------------------------------------- +encode long_string_list 8 0.78 96.9 +decode long_string_list 8 0.24 30.5 +dec+cast long_string_list 8 0.26 33.0 +encode long_string_list 64 11.20 174.9 +decode long_string_list 64 3.49 54.5 +dec+cast long_string_list 64 7.31 114.3 +encode long_string_list 512 157.18 307.0 +decode long_string_list 512 42.76 83.5 +dec+cast long_string_list 512 76.03 148.5 +encode long_string_list 4096 2488.38 607.5 +decode long_string_list 4096 1495.20 365.0 +dec+cast long_string_list 4096 1707.00 416.7 +---------------------------------------------------------------------------------- +encode string_map 8 1.60 199.5 +decode string_map 8 0.10 12.4 +dec+cast string_map 8 0.20 25.5 +encode string_map 64 30.47 476.1 +decode string_map 64 0.74 11.6 +dec+cast string_map 64 3.95 61.7 +encode string_map 512 240.62 470.0 +decode string_map 512 11.04 21.6 +dec+cast string_map 512 39.76 77.7 +encode string_map 4096 2194.00 535.6 +decode string_map 4096 71.08 17.4 +dec+cast string_map 4096 335.83 82.0 +---------------------------------------------------------------------------------- +encode int_map 8 0.83 103.4 +decode int_map 8 0.24 29.8 +dec+cast int_map 8 0.26 32.2 +encode int_map 64 10.97 171.5 +decode int_map 64 3.68 57.5 +dec+cast int_map 64 5.96 93.1 +encode int_map 512 109.35 213.6 +decode int_map 512 27.78 54.3 +dec+cast int_map 512 72.41 141.4 +encode int_map 4096 1307.14 319.1 +decode int_map 4096 576.06 140.6 +dec+cast int_map 4096 881.09 215.1 +---------------------------------------------------------------------------------- +encode record_list 8 5.07 633.8 +decode record_list 8 0.78 98.1 +dec+cast record_list 8 1.20 149.9 +encode record_list 64 47.83 747.4 +decode record_list 64 8.26 129.1 +dec+cast record_list 64 14.64 228.8 +encode record_list 512 565.88 1105.2 +decode record_list 512 118.02 230.5 +dec+cast record_list 512 196.29 383.4 +encode record_list 4096 4732.25 1155.3 +decode record_list 4096 1094.36 267.2 +dec+cast record_list 4096 1628.34 397.5 +---------------------------------------------------------------------------------- +nested chain (depth sweep) — isolates recursion/traversal from fan-out +op shape N total_us per_elem_ns +encode nested_chain 4 1.07 535.2 +decode nested_chain 4 0.21 103.7 +dec+cast nested_chain 4 0.32 161.9 +encode nested_chain 16 4.19 2094.1 +decode nested_chain 16 0.91 456.5 +dec+cast nested_chain 16 1.33 665.1 +encode nested_chain 64 16.94 8469.5 +decode nested_chain 64 3.92 1961.9 +dec+cast nested_chain 64 6.28 3140.2 +encode nested_chain 256 68.77 34386.2 +decode nested_chain 256 18.23 9114.9 +dec+cast nested_chain 256 32.31 16156.7 +================================================================================== +composite anchor — the PR's `original_nested` (matches the 18us/108us figure) +op shape N total_us per_elem_ns +encode original_nested 75 17.11 4276.4 +decode original_nested 75 3.21 802.7 +dec+cast original_nested 75 5.26 1314.8 diff --git a/bench_results/v1.0.0/environment.md b/bench_results/v1.0.0/environment.md new file mode 100644 index 00000000..bd4c848d --- /dev/null +++ b/bench_results/v1.0.0/environment.md @@ -0,0 +1,36 @@ +# Benchmark environment — v1.0.0 + +- **Ref**: `v1.0.0` (commit `69e13a6`) +- **Mode**: `full` (`LUA_BENCH_MODE=full`) +- **CPU**: Apple M4 (see `cpu.txt`) +- **Elixir / OTP**: Elixir 1.20.0, Erlang/OTP 29 [erts-17.0] [64-bit] [jit] (see `versions.txt`) +- **Worktree setup timestamp**: Tue Jul 28 10:48:16 EDT 2026 (see `timestamp.txt`) +- **Run date (this document)**: Tue Jul 28 11:20:58 EDT 2026 + +## Command form + +Each workload was run serially, one process per file, on an otherwise quiet +machine, from the worktree root: + +``` +LUA_BENCH_MODE=full MIX_ENV=benchmark mix run benchmarks/.exs +``` + +Workloads run: `fibonacci`, `closures`, `oop`, `string_ops`, `string_format`, +`table_ops`, `patterns`, `metamethods`, `pcall_varargs`, `vm_new`, +`encode_decode`. + +`encode_decode.exs` is not Benchee-based (it uses a `:timer.tc` harness +directly), but was invoked with the same command form for consistency. + +C Lua via `luaport` was not available in this environment (no local luaport +build) and was skipped by each script's own fallback path; all comparisons +below are lua (chunk)/lua (eval) vs. luerl only. + +## Artifacts + +- `cpu.txt`, `versions.txt`, `timestamp.txt` — raw environment probes captured + at worktree setup time. +- `.txt` — raw stdout of each `mix run` invocation (11 files). +- `summary.json` — parsed structured form of the above (workload -> case -> + jobs/comparison/memory). diff --git a/bench_results/v1.0.0/fibonacci.txt b/bench_results/v1.0.0/fibonacci.txt new file mode 100644 index 00000000..61c6c972 --- /dev/null +++ b/bench_results/v1.0.0/fibonacci.txt @@ -0,0 +1,43 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 1.37 730.63 ms ±0.64% 730.11 ms 743.77 ms +lua (chunk) 1.26 794.25 ms ±1.02% 792.42 ms 811.45 ms +lua (eval) 1.24 807.88 ms ±1.38% 805.75 ms 831.46 ms + +Comparison: +luerl 1.37 +lua (chunk) 1.26 - 1.09x slower +63.62 ms +lua (eval) 1.24 - 1.11x slower +77.25 ms + +Memory usage statistics: + +Name Memory usage +luerl 2.45 GB +lua (chunk) 2.90 GB - 1.18x memory usage +0.44 GB +lua (eval) 2.90 GB - 1.18x memory usage +0.44 GB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.0/metamethods.txt b/bench_results/v1.0.0/metamethods.txt new file mode 100644 index 00000000..07e5541c --- /dev/null +++ b/bench_results/v1.0.0/metamethods.txt @@ -0,0 +1,136 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== metamethods: self-call method dispatch (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 2.06 K 484.65 μs ±9.07% 472.63 μs 658.27 μs +lua (eval) 1.77 K 565.06 μs ±8.07% 552.29 μs 755.54 μs +lua (chunk) 1.75 K 569.99 μs ±12.20% 553.75 μs 849.77 μs + +Comparison: +luerl 2.06 K +lua (eval) 1.77 K - 1.17x slower +80.41 μs +lua (chunk) 1.75 K - 1.18x slower +85.34 μs + +Memory usage statistics: + +Name Memory usage +luerl 1.43 MB +lua (eval) 1.85 MB - 1.30x memory usage +0.43 MB +lua (chunk) 1.84 MB - 1.29x memory usage +0.42 MB + +**All measurements for memory usage were the same** + +=== metamethods: 3-level __index chain (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 4.19 K 238.51 μs ±3.78% 237.13 μs 257.04 μs +lua (eval) 4.01 K 249.27 μs ±5.91% 248.25 μs 273.67 μs +luerl 3.95 K 253.10 μs ±42.54% 221.29 μs 343.51 μs + +Comparison: +lua (chunk) 4.19 K +lua (eval) 4.01 K - 1.05x slower +10.76 μs +luerl 3.95 K - 1.06x slower +14.59 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 788.91 KB +lua (eval) 798.82 KB - 1.01x memory usage +9.91 KB +luerl 668.59 KB - 0.85x memory usage -120.32031 KB + +**All measurements for memory usage were the same** + +=== metamethods: arithmetic/relational metamethods (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 523.21 1.91 ms ±4.47% 1.88 ms 2.20 ms +lua (chunk) 455.21 2.20 ms ±4.68% 2.23 ms 2.54 ms +lua (eval) 453.68 2.20 ms ±6.62% 2.22 ms 2.63 ms + +Comparison: +luerl 523.21 +lua (chunk) 455.21 - 1.15x slower +0.29 ms +lua (eval) 453.68 - 1.15x slower +0.29 ms + +Memory usage statistics: + +Name Memory usage +luerl 6.24 MB +lua (chunk) 7.07 MB - 1.13x memory usage +0.83 MB +lua (eval) 7.08 MB - 1.13x memory usage +0.84 MB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.0/oop.txt b/bench_results/v1.0.0/oop.txt new file mode 100644 index 00000000..e5607b9d --- /dev/null +++ b/bench_results/v1.0.0/oop.txt @@ -0,0 +1,43 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 8.38 K 119.27 μs ±16.72% 116.13 μs 217.71 μs +lua (chunk) 7.88 K 126.88 μs ±17.97% 123.50 μs 232.21 μs +lua (eval) 7.59 K 131.77 μs ±16.80% 126.29 μs 228.05 μs + +Comparison: +luerl 8.38 K +lua (chunk) 7.88 K - 1.06x slower +7.62 μs +lua (eval) 7.59 K - 1.10x slower +12.50 μs + +Memory usage statistics: + +Name Memory usage +luerl 381.45 KB +lua (chunk) 502.34 KB - 1.32x memory usage +120.89 KB +lua (eval) 511.52 KB - 1.34x memory usage +130.07 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.0/patterns.txt b/bench_results/v1.0.0/patterns.txt new file mode 100644 index 00000000..442f7901 --- /dev/null +++ b/bench_results/v1.0.0/patterns.txt @@ -0,0 +1,139 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== patterns: find/match field extraction (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 972.71 1.03 ms ±9.96% 1.01 ms 1.24 ms +lua (eval) 970.99 1.03 ms ±3.16% 1.02 ms 1.13 ms +luerl 906.75 1.10 ms ±5.98% 1.09 ms 1.26 ms + +Comparison: +lua (chunk) 972.71 +lua (eval) 970.99 - 1.00x slower +0.00182 ms +luerl 906.75 - 1.07x slower +0.0748 ms + +Memory usage statistics: + +Name Memory usage +lua (chunk) 3.72 MB +lua (eval) 3.73 MB - 1.00x memory usage +0.00805 MB +luerl 6.70 MB - 1.80x memory usage +2.98 MB + +**All measurements for memory usage were the same** + +=== patterns: find-based tokenizer (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 733.20 1.36 ms ±5.05% 1.35 ms 1.48 ms +lua (chunk) 626.48 1.60 ms ±4.81% 1.57 ms 1.73 ms +lua (eval) 611.43 1.64 ms ±8.70% 1.61 ms 1.99 ms + +Comparison: +luerl 733.20 +lua (chunk) 626.48 - 1.17x slower +0.23 ms +lua (eval) 611.43 - 1.20x slower +0.27 ms + +Memory usage statistics: + +Name average deviation median 99th % +luerl 6.65 MB ±0.00% 6.65 MB 6.65 MB +lua (chunk) 10.11 MB ±0.00% 10.11 MB 10.11 MB +lua (eval) 10.12 MB ±0.00% 10.12 MB 10.12 MB + +Comparison: +luerl 6.65 MB +lua (chunk) 10.11 MB - 1.52x memory usage +3.46 MB +lua (eval) 10.12 MB - 1.52x memory usage +3.48 MB + +=== patterns: gsub template substitution (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 491.94 2.03 ms ±2.07% 2.02 ms 2.14 ms +lua (eval) 488.05 2.05 ms ±4.20% 2.04 ms 2.22 ms +luerl 450.89 2.22 ms ±4.50% 2.17 ms 2.47 ms + +Comparison: +lua (chunk) 491.94 +lua (eval) 488.05 - 1.01x slower +0.0162 ms +luerl 450.89 - 1.09x slower +0.185 ms + +Memory usage statistics: + +Name Memory usage +lua (chunk) 5.05 MB +lua (eval) 5.06 MB - 1.00x memory usage +0.00848 MB +luerl 11.75 MB - 2.33x memory usage +6.71 MB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.0/pcall_varargs.txt b/bench_results/v1.0.0/pcall_varargs.txt new file mode 100644 index 00000000..9048cf74 --- /dev/null +++ b/bench_results/v1.0.0/pcall_varargs.txt @@ -0,0 +1,136 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== call protocol: pcall, success path (n=500) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 3.49 K 286.44 μs ±12.19% 282.13 μs 352.91 μs +luerl 3.44 K 290.86 μs ±3.49% 289.50 μs 309.63 μs +lua (eval) 3.34 K 299.49 μs ±5.85% 297.75 μs 329.07 μs + +Comparison: +lua (chunk) 3.49 K +luerl 3.44 K - 1.02x slower +4.42 μs +lua (eval) 3.34 K - 1.05x slower +13.05 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 1.31 MB +luerl 1.11 MB - 0.85x memory usage -0.20367 MB +lua (eval) 1.32 MB - 1.01x memory usage +0.00981 MB + +**All measurements for memory usage were the same** + +=== call protocol: pcall, raise + catch (n=500) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 1.87 K 533.92 μs ±29.41% 485.38 μs 1138.98 μs +lua (chunk) 1.11 K 904.68 μs ±17.56% 877.33 μs 1891.77 μs +lua (eval) 1.00 K 1001.11 μs ±31.79% 899.42 μs 2144.79 μs + +Comparison: +luerl 1.87 K +lua (chunk) 1.11 K - 1.69x slower +370.76 μs +lua (eval) 1.00 K - 1.88x slower +467.19 μs + +Memory usage statistics: + +Name Memory usage +luerl 1.55 MB +lua (chunk) 3.35 MB - 2.15x memory usage +1.79 MB +lua (eval) 3.36 MB - 2.16x memory usage +1.81 MB + +**All measurements for memory usage were the same** + +=== call protocol: varargs + multiple returns (n=500) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 410.14 2.44 ms ±4.02% 2.40 ms 2.73 ms +lua (eval) 253.78 3.94 ms ±6.97% 3.89 ms 4.79 ms +lua (chunk) 237.27 4.21 ms ±16.27% 4.01 ms 8.17 ms + +Comparison: +luerl 410.14 +lua (eval) 253.78 - 1.62x slower +1.50 ms +lua (chunk) 237.27 - 1.73x slower +1.78 ms + +Memory usage statistics: + +Name Memory usage +luerl 8.73 MB +lua (eval) 19.95 MB - 2.29x memory usage +11.22 MB +lua (chunk) 19.94 MB - 2.28x memory usage +11.21 MB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.0/string_format.txt b/bench_results/v1.0.0/string_format.txt new file mode 100644 index 00000000..39bfaf51 --- /dev/null +++ b/bench_results/v1.0.0/string_format.txt @@ -0,0 +1,139 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== string.format: long literal-heavy format string (n=1000) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (eval) 1063.59 0.94 ms ±10.28% 0.90 ms 1.17 ms +lua (chunk) 966.41 1.03 ms ±6.22% 1.03 ms 1.16 ms +luerl 241.01 4.15 ms ±10.25% 4.32 ms 4.71 ms + +Comparison: +lua (eval) 1063.59 +lua (chunk) 966.41 - 1.10x slower +0.0945 ms +luerl 241.01 - 4.41x slower +3.21 ms + +Memory usage statistics: + +Name Memory usage +lua (eval) 3.27 MB +lua (chunk) 3.26 MB - 1.00x memory usage -0.00974 MB +luerl 22.59 MB - 6.92x memory usage +19.32 MB + +**All measurements for memory usage were the same** + +=== string.format: width-flagged specifiers (n=1000) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (eval) 668.09 1.50 ms ±2.73% 1.49 ms 1.62 ms +lua (chunk) 662.60 1.51 ms ±4.32% 1.50 ms 1.63 ms +luerl 549.59 1.82 ms ±5.86% 1.79 ms 2.09 ms + +Comparison: +lua (eval) 668.09 +lua (chunk) 662.60 - 1.01x slower +0.0124 ms +luerl 549.59 - 1.22x slower +0.32 ms + +Memory usage statistics: + +Name Memory usage +lua (eval) 5.22 MB +lua (chunk) 5.21 MB - 1.00x memory usage -0.01022 MB +luerl 7.55 MB - 1.45x memory usage +2.33 MB + +**All measurements for memory usage were the same** + +=== string.format: many specifiers (n=1000) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (eval) 418.38 2.39 ms ±8.03% 2.39 ms 3.24 ms +lua (chunk) 413.90 2.42 ms ±8.21% 2.35 ms 3.05 ms +luerl 358.58 2.79 ms ±4.13% 2.82 ms 3.00 ms + +Comparison: +lua (eval) 418.38 +lua (chunk) 413.90 - 1.01x slower +0.0259 ms +luerl 358.58 - 1.17x slower +0.40 ms + +Memory usage statistics: + +Name average deviation median 99th % +lua (eval) 14.86 MB ±0.00% 14.86 MB 14.86 MB +lua (chunk) 14.86 MB ±0.00% 14.86 MB 14.86 MB +luerl 13.76 MB ±0.00% 13.76 MB 13.76 MB + +Comparison: +lua (eval) 14.86 MB +lua (chunk) 14.86 MB - 1.00x memory usage -0.00894 MB +luerl 13.76 MB - 0.93x memory usage -1.10735 MB diff --git a/bench_results/v1.0.0/string_ops.txt b/bench_results/v1.0.0/string_ops.txt new file mode 100644 index 00000000..bf8b3b9a --- /dev/null +++ b/bench_results/v1.0.0/string_ops.txt @@ -0,0 +1,91 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== String Concatenation via table.concat (n=100) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 27.97 K 35.76 μs ±9.62% 35.67 μs 41.97 μs +lua (eval) 25.79 K 38.77 μs ±11.58% 38.58 μs 45.21 μs +luerl 24.73 K 40.44 μs ±7.76% 40.17 μs 46.08 μs + +Comparison: +lua (chunk) 27.97 K +lua (eval) 25.79 K - 1.08x slower +3.01 μs +luerl 24.73 K - 1.13x slower +4.68 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 171.33 KB +lua (eval) 180.13 KB - 1.05x memory usage +8.80 KB +luerl 172.42 KB - 1.01x memory usage +1.09 KB + +**All measurements for memory usage were the same** + +=== String Formatting via string.format (n=100) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 12.44 K 80.35 μs ±7.41% 80.21 μs 93.29 μs +lua (eval) 11.53 K 86.74 μs ±27.72% 85.58 μs 111.49 μs +luerl 9.55 K 104.75 μs ±11.48% 103.38 μs 122.63 μs + +Comparison: +lua (chunk) 12.44 K +lua (eval) 11.53 K - 1.08x slower +6.39 μs +luerl 9.55 K - 1.30x slower +24.39 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 333.77 KB +lua (eval) 343.45 KB - 1.03x memory usage +9.68 KB +luerl 588.84 KB - 1.76x memory usage +255.07 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.0/summary.json b/bench_results/v1.0.0/summary.json new file mode 100644 index 00000000..09df2029 --- /dev/null +++ b/bench_results/v1.0.0/summary.json @@ -0,0 +1,1290 @@ +{ + "fibonacci": { + "default": { + "jobs": [ + { + "name": "luerl", + "ips": "1.37", + "average": "730.63 ms", + "deviation": "±0.64%", + "median": "730.11 ms", + "p99": "743.77 ms", + "memory": "2.45 GB" + }, + { + "name": "lua (chunk)", + "ips": "1.26", + "average": "794.25 ms", + "deviation": "±1.02%", + "median": "792.42 ms", + "p99": "811.45 ms", + "memory": "2.90 GB" + }, + { + "name": "lua (eval)", + "ips": "1.24", + "average": "807.88 ms", + "deviation": "±1.38%", + "median": "805.75 ms", + "p99": "831.46 ms", + "memory": "2.90 GB" + } + ], + "comparison": [ + "luerl 1.37", + "lua (chunk) 1.26 - 1.09x slower +63.62 ms", + "lua (eval) 1.24 - 1.11x slower +77.25 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "closures": { + "default": { + "jobs": [ + { + "name": "luerl", + "ips": "2.58 K", + "average": "388.21 μs", + "deviation": "±7.60%", + "median": "382.50 μs", + "p99": "546.24 μs", + "memory": "1.90 MB" + }, + { + "name": "lua (chunk)", + "ips": "2.09 K", + "average": "478.08 μs", + "deviation": "±6.68%", + "median": "473.54 μs", + "p99": "613.46 μs", + "memory": "2.65 MB" + }, + { + "name": "lua (eval)", + "ips": "2.01 K", + "average": "498.43 μs", + "deviation": "±16.91%", + "median": "482.54 μs", + "p99": "1019.38 μs", + "memory": "2.65 MB" + } + ], + "comparison": [ + "luerl 2.58 K", + "lua (chunk) 2.09 K - 1.23x slower +89.87 μs", + "lua (eval) 2.01 K - 1.28x slower +110.23 μs" + ], + "memory_comparison": [ + "luerl 1.90 MB", + "lua (chunk) 2.65 MB - 1.39x memory usage +0.75 MB", + "lua (eval) 2.65 MB - 1.40x memory usage +0.76 MB" + ] + } + }, + "oop": { + "default": { + "jobs": [ + { + "name": "luerl", + "ips": "8.38 K", + "average": "119.27 μs", + "deviation": "±16.72%", + "median": "116.13 μs", + "p99": "217.71 μs", + "memory": "381.45 KB" + }, + { + "name": "lua (chunk)", + "ips": "7.88 K", + "average": "126.88 μs", + "deviation": "±17.97%", + "median": "123.50 μs", + "p99": "232.21 μs", + "memory": "502.34 KB" + }, + { + "name": "lua (eval)", + "ips": "7.59 K", + "average": "131.77 μs", + "deviation": "±16.80%", + "median": "126.29 μs", + "p99": "228.05 μs", + "memory": "511.52 KB" + } + ], + "comparison": [ + "luerl 8.38 K", + "lua (chunk) 7.88 K - 1.06x slower +7.62 μs", + "lua (eval) 7.59 K - 1.10x slower +12.50 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "string_ops": { + "String Concatenation via table.concat (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "27.97 K", + "average": "35.76 μs", + "deviation": "±9.62%", + "median": "35.67 μs", + "p99": "41.97 μs", + "memory": "171.33 KB" + }, + { + "name": "lua (eval)", + "ips": "25.79 K", + "average": "38.77 μs", + "deviation": "±11.58%", + "median": "38.58 μs", + "p99": "45.21 μs", + "memory": "180.13 KB" + }, + { + "name": "luerl", + "ips": "24.73 K", + "average": "40.44 μs", + "deviation": "±7.76%", + "median": "40.17 μs", + "p99": "46.08 μs", + "memory": "172.42 KB" + } + ], + "comparison": [ + "lua (chunk) 27.97 K", + "lua (eval) 25.79 K - 1.08x slower +3.01 μs", + "luerl 24.73 K - 1.13x slower +4.68 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "String Formatting via string.format (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "12.44 K", + "average": "80.35 μs", + "deviation": "±7.41%", + "median": "80.21 μs", + "p99": "93.29 μs", + "memory": "333.77 KB" + }, + { + "name": "lua (eval)", + "ips": "11.53 K", + "average": "86.74 μs", + "deviation": "±27.72%", + "median": "85.58 μs", + "p99": "111.49 μs", + "memory": "343.45 KB" + }, + { + "name": "luerl", + "ips": "9.55 K", + "average": "104.75 μs", + "deviation": "±11.48%", + "median": "103.38 μs", + "p99": "122.63 μs", + "memory": "588.84 KB" + } + ], + "comparison": [ + "lua (chunk) 12.44 K", + "lua (eval) 11.53 K - 1.08x slower +6.39 μs", + "luerl 9.55 K - 1.30x slower +24.39 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "string_format": { + "string.format: long literal-heavy format string (n=1000)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "1063.59", + "average": "0.94 ms", + "deviation": "±10.28%", + "median": "0.90 ms", + "p99": "1.17 ms", + "memory": "3.27 MB" + }, + { + "name": "lua (chunk)", + "ips": "966.41", + "average": "1.03 ms", + "deviation": "±6.22%", + "median": "1.03 ms", + "p99": "1.16 ms", + "memory": "3.26 MB" + }, + { + "name": "luerl", + "ips": "241.01", + "average": "4.15 ms", + "deviation": "±10.25%", + "median": "4.32 ms", + "p99": "4.71 ms", + "memory": "22.59 MB" + } + ], + "comparison": [ + "lua (eval) 1063.59", + "lua (chunk) 966.41 - 1.10x slower +0.0945 ms", + "luerl 241.01 - 4.41x slower +3.21 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "string.format: width-flagged specifiers (n=1000)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "668.09", + "average": "1.50 ms", + "deviation": "±2.73%", + "median": "1.49 ms", + "p99": "1.62 ms", + "memory": "5.22 MB" + }, + { + "name": "lua (chunk)", + "ips": "662.60", + "average": "1.51 ms", + "deviation": "±4.32%", + "median": "1.50 ms", + "p99": "1.63 ms", + "memory": "5.21 MB" + }, + { + "name": "luerl", + "ips": "549.59", + "average": "1.82 ms", + "deviation": "±5.86%", + "median": "1.79 ms", + "p99": "2.09 ms", + "memory": "7.55 MB" + } + ], + "comparison": [ + "lua (eval) 668.09", + "lua (chunk) 662.60 - 1.01x slower +0.0124 ms", + "luerl 549.59 - 1.22x slower +0.32 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "string.format: many specifiers (n=1000)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "418.38", + "average": "2.39 ms", + "deviation": "±8.03%", + "median": "2.39 ms", + "p99": "3.24 ms", + "memory": "14.86 MB" + }, + { + "name": "lua (chunk)", + "ips": "413.90", + "average": "2.42 ms", + "deviation": "±8.21%", + "median": "2.35 ms", + "p99": "3.05 ms", + "memory": "14.86 MB" + }, + { + "name": "luerl", + "ips": "358.58", + "average": "2.79 ms", + "deviation": "±4.13%", + "median": "2.82 ms", + "p99": "3.00 ms", + "memory": "13.76 MB" + } + ], + "comparison": [ + "lua (eval) 418.38", + "lua (chunk) 413.90 - 1.01x slower +0.0259 ms", + "luerl 358.58 - 1.17x slower +0.40 ms" + ], + "memory_comparison": [ + "lua (eval) 14.86 MB", + "lua (chunk) 14.86 MB - 1.00x memory usage -0.00894 MB", + "luerl 13.76 MB - 0.93x memory usage -1.10735 MB" + ] + } + }, + "table_ops": { + "Table Build": { + "by_input": { + "large (n=1000)": { + "jobs": [ + { + "name": "luerl", + "ips": "6.37 K", + "average": "157.04 μs", + "deviation": "±11.82%", + "median": "154.21 μs", + "p99": "218.16 μs", + "memory": "0.97 MB" + }, + { + "name": "lua (chunk)", + "ips": "6.23 K", + "average": "160.45 μs", + "deviation": "±8.91%", + "median": "157.88 μs", + "p99": "198.69 μs", + "memory": "1.00 MB" + }, + { + "name": "lua (eval)", + "ips": "5.90 K", + "average": "169.48 μs", + "deviation": "±14.14%", + "median": "164.58 μs", + "p99": "287.61 μs", + "memory": "1.01 MB" + } + ], + "comparison": [ + "luerl 6.37 K", + "lua (chunk) 6.23 K - 1.02x slower +3.41 μs", + "lua (eval) 5.90 K - 1.08x slower +12.44 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "medium (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "60.98 K", + "average": "16.40 μs", + "deviation": "±32.33%", + "median": "16.17 μs", + "p99": "24.29 μs", + "memory": "105.41 KB" + }, + { + "name": "luerl", + "ips": "58.70 K", + "average": "17.04 μs", + "deviation": "±26.63%", + "median": "16.50 μs", + "p99": "26.67 μs", + "memory": "110.77 KB" + }, + { + "name": "lua (eval)", + "ips": "53.06 K", + "average": "18.85 μs", + "deviation": "±33.45%", + "median": "18.42 μs", + "p99": "27.04 μs", + "memory": "114.74 KB" + } + ], + "comparison": [ + "lua (chunk) 60.98 K", + "luerl 58.70 K - 1.04x slower +0.64 μs", + "lua (eval) 53.06 K - 1.15x slower +2.45 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "437.49 K", + "average": "2.29 μs", + "deviation": "±237.41%", + "median": "2.25 μs", + "p99": "3.42 μs", + "memory": "13.80 KB" + }, + { + "name": "luerl", + "ips": "317.31 K", + "average": "3.15 μs", + "deviation": "±243.81%", + "median": "3.04 μs", + "p99": "4.71 μs", + "memory": "22.62 KB" + }, + { + "name": "lua (eval)", + "ips": "205.32 K", + "average": "4.87 μs", + "deviation": "±231.75%", + "median": "4.46 μs", + "p99": "10.42 μs", + "memory": "23.45 KB" + } + ], + "comparison": [ + "lua (chunk) 437.49 K", + "luerl 317.31 K - 1.38x slower +0.87 μs", + "lua (eval) 205.32 K - 2.13x slower +2.58 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + } + }, + "Table Sort": { + "by_input": { + "large (n=1000)": { + "jobs": [ + { + "name": "luerl", + "ips": "5.52 K", + "average": "181.29 μs", + "deviation": "±10.01%", + "median": "178.00 μs", + "p99": "220.42 μs", + "memory": "1.18 MB" + }, + { + "name": "lua (chunk)", + "ips": "4.27 K", + "average": "234.42 μs", + "deviation": "±13.69%", + "median": "227.63 μs", + "p99": "411.33 μs", + "memory": "1.33 MB" + }, + { + "name": "lua (eval)", + "ips": "4.19 K", + "average": "238.72 μs", + "deviation": "±9.85%", + "median": "234.88 μs", + "p99": "335.73 μs", + "memory": "1.34 MB" + } + ], + "comparison": [ + "luerl 5.52 K", + "lua (chunk) 4.27 K - 1.29x slower +53.13 μs", + "lua (eval) 4.19 K - 1.32x slower +57.42 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "medium (n=100)": { + "jobs": [ + { + "name": "luerl", + "ips": "50.83 K", + "average": "19.67 μs", + "deviation": "±20.44%", + "median": "19.13 μs", + "p99": "28.17 μs", + "memory": "133.33 KB" + }, + { + "name": "lua (chunk)", + "ips": "42.73 K", + "average": "23.40 μs", + "deviation": "±35.27%", + "median": "23.21 μs", + "p99": "26.75 μs", + "memory": "141.38 KB" + }, + { + "name": "lua (eval)", + "ips": "38.35 K", + "average": "26.07 μs", + "deviation": "±37.69%", + "median": "25.58 μs", + "p99": "35.33 μs", + "memory": "151.13 KB" + } + ], + "comparison": [ + "luerl 50.83 K", + "lua (chunk) 42.73 K - 1.19x slower +3.73 μs", + "lua (eval) 38.35 K - 1.33x slower +6.40 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "311.25 K", + "average": "3.21 μs", + "deviation": "±183.71%", + "median": "3.17 μs", + "p99": "4.50 μs", + "memory": "19.06 KB" + }, + { + "name": "luerl", + "ips": "266.96 K", + "average": "3.75 μs", + "deviation": "±163.56%", + "median": "3.58 μs", + "p99": "9.50 μs", + "memory": "25.98 KB" + }, + { + "name": "lua (eval)", + "ips": "169.47 K", + "average": "5.90 μs", + "deviation": "±170.93%", + "median": "5.50 μs", + "p99": "15.75 μs", + "memory": "28.78 KB" + } + ], + "comparison": [ + "lua (chunk) 311.25 K", + "luerl 266.96 K - 1.17x slower +0.53 μs", + "lua (eval) 169.47 K - 1.84x slower +2.69 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + } + }, + "Table Iterate/Sum": { + "by_input": { + "large (n=1000)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "4.10 K", + "average": "243.68 μs", + "deviation": "±11.81%", + "median": "235.09 μs", + "p99": "342.27 μs", + "memory": "1.47 MB" + }, + { + "name": "lua (eval)", + "ips": "4.07 K", + "average": "245.42 μs", + "deviation": "±12.09%", + "median": "241.67 μs", + "p99": "362.47 μs", + "memory": "1.48 MB" + }, + { + "name": "luerl", + "ips": "4.06 K", + "average": "246.09 μs", + "deviation": "±7.76%", + "median": "243.06 μs", + "p99": "342.83 μs", + "memory": "1.35 MB" + } + ], + "comparison": [ + "lua (chunk) 4.10 K", + "lua (eval) 4.07 K - 1.01x slower +1.75 μs", + "luerl 4.06 K - 1.01x slower +2.41 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "medium (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "40.08 K", + "average": "24.95 μs", + "deviation": "±25.49%", + "median": "24.25 μs", + "p99": "52.88 μs", + "memory": "154.27 KB" + }, + { + "name": "lua (eval)", + "ips": "36.72 K", + "average": "27.23 μs", + "deviation": "±17.19%", + "median": "26.67 μs", + "p99": "38.21 μs", + "memory": "164.09 KB" + }, + { + "name": "luerl", + "ips": "36.18 K", + "average": "27.64 μs", + "deviation": "±35.39%", + "median": "27.00 μs", + "p99": "57.79 μs", + "memory": "149.14 KB" + } + ], + "comparison": [ + "lua (chunk) 40.08 K", + "lua (eval) 36.72 K - 1.09x slower +2.28 μs", + "luerl 36.18 K - 1.11x slower +2.69 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "306.46 K", + "average": "3.26 μs", + "deviation": "±172.56%", + "median": "3.21 μs", + "p99": "4.38 μs", + "memory": "19.87 KB" + }, + { + "name": "luerl", + "ips": "231.29 K", + "average": "4.32 μs", + "deviation": "±138.54%", + "median": "4.25 μs", + "p99": "6.33 μs", + "memory": "26.80 KB" + }, + { + "name": "lua (eval)", + "ips": "177.92 K", + "average": "5.62 μs", + "deviation": "±101.11%", + "median": "5.42 μs", + "p99": "9.25 μs", + "memory": "29.51 KB" + } + ], + "comparison": [ + "lua (chunk) 306.46 K", + "luerl 231.29 K - 1.33x slower +1.06 μs", + "lua (eval) 177.92 K - 1.72x slower +2.36 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + } + }, + "Table Map + Reduce": { + "by_input": { + "large (n=1000)": { + "jobs": [ + { + "name": "luerl", + "ips": "2.15 K", + "average": "464.93 μs", + "deviation": "±7.90%", + "median": "449.96 μs", + "p99": "600.72 μs", + "memory": "2.44 MB" + }, + { + "name": "lua (chunk)", + "ips": "2.05 K", + "average": "487.99 μs", + "deviation": "±6.39%", + "median": "480.24 μs", + "p99": "622.53 μs", + "memory": "2.92 MB" + }, + { + "name": "lua (eval)", + "ips": "2.01 K", + "average": "496.87 μs", + "deviation": "±13.21%", + "median": "487.17 μs", + "p99": "772.35 μs", + "memory": "2.93 MB" + } + ], + "comparison": [ + "luerl 2.15 K", + "lua (chunk) 2.05 K - 1.05x slower +23.06 μs", + "lua (eval) 2.01 K - 1.07x slower +31.94 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "medium (n=100)": { + "jobs": [ + { + "name": "luerl", + "ips": "21.41 K", + "average": "46.71 μs", + "deviation": "±6.09%", + "median": "46.50 μs", + "p99": "52.29 μs", + "memory": "262.35 KB" + }, + { + "name": "lua (chunk)", + "ips": "20.87 K", + "average": "47.91 μs", + "deviation": "±8.43%", + "median": "47.54 μs", + "p99": "53.79 μs", + "memory": "303.87 KB" + }, + { + "name": "lua (eval)", + "ips": "19.56 K", + "average": "51.12 μs", + "deviation": "±6.66%", + "median": "50.58 μs", + "p99": "60.46 μs", + "memory": "314.32 KB" + } + ], + "comparison": [ + "luerl 21.41 K", + "lua (chunk) 20.87 K - 1.03x slower +1.20 μs", + "lua (eval) 19.56 K - 1.09x slower +4.40 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "168.91 K", + "average": "5.92 μs", + "deviation": "±76.95%", + "median": "5.83 μs", + "p99": "13.38 μs", + "memory": "37.09 KB" + }, + { + "name": "luerl", + "ips": "145.75 K", + "average": "6.86 μs", + "deviation": "±94.69%", + "median": "6.59 μs", + "p99": "15.96 μs", + "memory": "39.39 KB" + }, + { + "name": "lua (eval)", + "ips": "102.03 K", + "average": "9.80 μs", + "deviation": "±71.35%", + "median": "8.21 μs", + "p99": "22.96 μs", + "memory": "46.72 KB" + } + ], + "comparison": [ + "lua (chunk) 168.91 K", + "luerl 145.75 K - 1.16x slower +0.94 μs", + "lua (eval) 102.03 K - 1.66x slower +3.88 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + } + }, + "Table Pairs (hash)": { + "by_input": { + "large (n=1000)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "1.02 K", + "average": "984.62 μs", + "deviation": "±7.98%", + "median": "995.70 μs", + "p99": "1143.86 μs", + "memory": "2.06 MB" + }, + { + "name": "lua (eval)", + "ips": "1.01 K", + "average": "990.11 μs", + "deviation": "±8.33%", + "median": "1002.18 μs", + "p99": "1157.35 μs", + "memory": "2.05 MB" + }, + { + "name": "luerl", + "ips": "0.75 K", + "average": "1332.28 μs", + "deviation": "±5.93%", + "median": "1334.35 μs", + "p99": "1489.09 μs", + "memory": "2.48 MB" + } + ], + "comparison": [ + "lua (chunk) 1.02 K", + "lua (eval) 1.01 K - 1.01x slower +5.49 μs", + "luerl 0.75 K - 1.35x slower +347.66 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "medium (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "13.46 K", + "average": "74.27 μs", + "deviation": "±10.12%", + "median": "72.92 μs", + "p99": "99.63 μs", + "memory": "211.49 KB" + }, + { + "name": "lua (eval)", + "ips": "12.33 K", + "average": "81.10 μs", + "deviation": "±25.10%", + "median": "78.13 μs", + "p99": "167.55 μs", + "memory": "221.46 KB" + }, + { + "name": "luerl", + "ips": "10.92 K", + "average": "91.56 μs", + "deviation": "±15.90%", + "median": "86.83 μs", + "p99": "142.16 μs", + "memory": "248.84 KB" + } + ], + "comparison": [ + "lua (chunk) 13.46 K", + "lua (eval) 12.33 K - 1.09x slower +6.83 μs", + "luerl 10.92 K - 1.23x slower +17.29 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "130.58 K", + "average": "7.66 μs", + "deviation": "±70.51%", + "median": "7.33 μs", + "p99": "24.42 μs", + "memory": "25.10 KB" + }, + { + "name": "luerl", + "ips": "130.19 K", + "average": "7.68 μs", + "deviation": "±68.45%", + "median": "7.25 μs", + "p99": "23.38 μs", + "memory": "36.16 KB" + }, + { + "name": "lua (eval)", + "ips": "94.66 K", + "average": "10.56 μs", + "deviation": "±56.52%", + "median": "9.92 μs", + "p99": "29.25 μs", + "memory": "34.91 KB" + } + ], + "comparison": [ + "lua (chunk) 130.58 K", + "luerl 130.19 K - 1.00x slower +0.0229 μs", + "lua (eval) 94.66 K - 1.38x slower +2.91 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + } + } + }, + "patterns": { + "patterns: find/match field extraction (n=200)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "972.71", + "average": "1.03 ms", + "deviation": "±9.96%", + "median": "1.01 ms", + "p99": "1.24 ms", + "memory": "3.72 MB" + }, + { + "name": "lua (eval)", + "ips": "970.99", + "average": "1.03 ms", + "deviation": "±3.16%", + "median": "1.02 ms", + "p99": "1.13 ms", + "memory": "3.73 MB" + }, + { + "name": "luerl", + "ips": "906.75", + "average": "1.10 ms", + "deviation": "±5.98%", + "median": "1.09 ms", + "p99": "1.26 ms", + "memory": "6.70 MB" + } + ], + "comparison": [ + "lua (chunk) 972.71", + "lua (eval) 970.99 - 1.00x slower +0.00182 ms", + "luerl 906.75 - 1.07x slower +0.0748 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "patterns: find-based tokenizer (n=200)": { + "jobs": [ + { + "name": "luerl", + "ips": "733.20", + "average": "1.36 ms", + "deviation": "±5.05%", + "median": "1.35 ms", + "p99": "1.48 ms", + "memory": "6.65 MB" + }, + { + "name": "lua (chunk)", + "ips": "626.48", + "average": "1.60 ms", + "deviation": "±4.81%", + "median": "1.57 ms", + "p99": "1.73 ms", + "memory": "10.11 MB" + }, + { + "name": "lua (eval)", + "ips": "611.43", + "average": "1.64 ms", + "deviation": "±8.70%", + "median": "1.61 ms", + "p99": "1.99 ms", + "memory": "10.12 MB" + } + ], + "comparison": [ + "luerl 733.20", + "lua (chunk) 626.48 - 1.17x slower +0.23 ms", + "lua (eval) 611.43 - 1.20x slower +0.27 ms" + ], + "memory_comparison": [ + "luerl 6.65 MB", + "lua (chunk) 10.11 MB - 1.52x memory usage +3.46 MB", + "lua (eval) 10.12 MB - 1.52x memory usage +3.48 MB" + ] + }, + "patterns: gsub template substitution (n=200)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "491.94", + "average": "2.03 ms", + "deviation": "±2.07%", + "median": "2.02 ms", + "p99": "2.14 ms", + "memory": "5.05 MB" + }, + { + "name": "lua (eval)", + "ips": "488.05", + "average": "2.05 ms", + "deviation": "±4.20%", + "median": "2.04 ms", + "p99": "2.22 ms", + "memory": "5.06 MB" + }, + { + "name": "luerl", + "ips": "450.89", + "average": "2.22 ms", + "deviation": "±4.50%", + "median": "2.17 ms", + "p99": "2.47 ms", + "memory": "11.75 MB" + } + ], + "comparison": [ + "lua (chunk) 491.94", + "lua (eval) 488.05 - 1.01x slower +0.0162 ms", + "luerl 450.89 - 1.09x slower +0.185 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "metamethods": { + "metamethods: self-call method dispatch (n=200)": { + "jobs": [ + { + "name": "luerl", + "ips": "2.06 K", + "average": "484.65 μs", + "deviation": "±9.07%", + "median": "472.63 μs", + "p99": "658.27 μs", + "memory": "1.43 MB" + }, + { + "name": "lua (eval)", + "ips": "1.77 K", + "average": "565.06 μs", + "deviation": "±8.07%", + "median": "552.29 μs", + "p99": "755.54 μs", + "memory": "1.85 MB" + }, + { + "name": "lua (chunk)", + "ips": "1.75 K", + "average": "569.99 μs", + "deviation": "±12.20%", + "median": "553.75 μs", + "p99": "849.77 μs", + "memory": "1.84 MB" + } + ], + "comparison": [ + "luerl 2.06 K", + "lua (eval) 1.77 K - 1.17x slower +80.41 μs", + "lua (chunk) 1.75 K - 1.18x slower +85.34 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "metamethods: 3-level __index chain (n=200)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "4.19 K", + "average": "238.51 μs", + "deviation": "±3.78%", + "median": "237.13 μs", + "p99": "257.04 μs", + "memory": "788.91 KB" + }, + { + "name": "lua (eval)", + "ips": "4.01 K", + "average": "249.27 μs", + "deviation": "±5.91%", + "median": "248.25 μs", + "p99": "273.67 μs", + "memory": "798.82 KB" + }, + { + "name": "luerl", + "ips": "3.95 K", + "average": "253.10 μs", + "deviation": "±42.54%", + "median": "221.29 μs", + "p99": "343.51 μs", + "memory": "668.59 KB" + } + ], + "comparison": [ + "lua (chunk) 4.19 K", + "lua (eval) 4.01 K - 1.05x slower +10.76 μs", + "luerl 3.95 K - 1.06x slower +14.59 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "metamethods: arithmetic/relational metamethods (n=200)": { + "jobs": [ + { + "name": "luerl", + "ips": "523.21", + "average": "1.91 ms", + "deviation": "±4.47%", + "median": "1.88 ms", + "p99": "2.20 ms", + "memory": "6.24 MB" + }, + { + "name": "lua (chunk)", + "ips": "455.21", + "average": "2.20 ms", + "deviation": "±4.68%", + "median": "2.23 ms", + "p99": "2.54 ms", + "memory": "7.07 MB" + }, + { + "name": "lua (eval)", + "ips": "453.68", + "average": "2.20 ms", + "deviation": "±6.62%", + "median": "2.22 ms", + "p99": "2.63 ms", + "memory": "7.08 MB" + } + ], + "comparison": [ + "luerl 523.21", + "lua (chunk) 455.21 - 1.15x slower +0.29 ms", + "lua (eval) 453.68 - 1.15x slower +0.29 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "pcall_varargs": { + "call protocol: pcall, success path (n=500)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "3.49 K", + "average": "286.44 μs", + "deviation": "±12.19%", + "median": "282.13 μs", + "p99": "352.91 μs", + "memory": "1.31 MB" + }, + { + "name": "luerl", + "ips": "3.44 K", + "average": "290.86 μs", + "deviation": "±3.49%", + "median": "289.50 μs", + "p99": "309.63 μs", + "memory": "1.11 MB" + }, + { + "name": "lua (eval)", + "ips": "3.34 K", + "average": "299.49 μs", + "deviation": "±5.85%", + "median": "297.75 μs", + "p99": "329.07 μs", + "memory": "1.32 MB" + } + ], + "comparison": [ + "lua (chunk) 3.49 K", + "luerl 3.44 K - 1.02x slower +4.42 μs", + "lua (eval) 3.34 K - 1.05x slower +13.05 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "call protocol: pcall, raise + catch (n=500)": { + "jobs": [ + { + "name": "luerl", + "ips": "1.87 K", + "average": "533.92 μs", + "deviation": "±29.41%", + "median": "485.38 μs", + "p99": "1138.98 μs", + "memory": "1.55 MB" + }, + { + "name": "lua (chunk)", + "ips": "1.11 K", + "average": "904.68 μs", + "deviation": "±17.56%", + "median": "877.33 μs", + "p99": "1891.77 μs", + "memory": "3.35 MB" + }, + { + "name": "lua (eval)", + "ips": "1.00 K", + "average": "1001.11 μs", + "deviation": "±31.79%", + "median": "899.42 μs", + "p99": "2144.79 μs", + "memory": "3.36 MB" + } + ], + "comparison": [ + "luerl 1.87 K", + "lua (chunk) 1.11 K - 1.69x slower +370.76 μs", + "lua (eval) 1.00 K - 1.88x slower +467.19 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "call protocol: varargs + multiple returns (n=500)": { + "jobs": [ + { + "name": "luerl", + "ips": "410.14", + "average": "2.44 ms", + "deviation": "±4.02%", + "median": "2.40 ms", + "p99": "2.73 ms", + "memory": "8.73 MB" + }, + { + "name": "lua (eval)", + "ips": "253.78", + "average": "3.94 ms", + "deviation": "±6.97%", + "median": "3.89 ms", + "p99": "4.79 ms", + "memory": "19.95 MB" + }, + { + "name": "lua (chunk)", + "ips": "237.27", + "average": "4.21 ms", + "deviation": "±16.27%", + "median": "4.01 ms", + "p99": "8.17 ms", + "memory": "19.94 MB" + } + ], + "comparison": [ + "luerl 410.14", + "lua (eval) 253.78 - 1.62x slower +1.50 ms", + "lua (chunk) 237.27 - 1.73x slower +1.78 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "vm_new": { + "VM instantiation: Lua.new/1 vs :luerl.init/0": { + "jobs": [ + { + "name": "luerl (init)", + "ips": "64.86 K", + "average": "15.42 μs", + "deviation": "±23.43%", + "median": "15.21 μs", + "p99": "24.25 μs", + "memory": "51.64 KB" + }, + { + "name": "lua (new, no sandbox)", + "ips": "33.30 K", + "average": "30.03 μs", + "deviation": "±11.23%", + "median": "29.71 μs", + "p99": "38.46 μs", + "memory": "68.76 KB" + }, + { + "name": "lua (new, custom exclude)", + "ips": "27.37 K", + "average": "36.54 μs", + "deviation": "±8.96%", + "median": "35.96 μs", + "p99": "42.63 μs", + "memory": "90.97 KB" + }, + { + "name": "lua (new)", + "ips": "26.58 K", + "average": "37.63 μs", + "deviation": "±62.22%", + "median": "36.67 μs", + "p99": "71.96 μs", + "memory": "91.88 KB" + } + ], + "comparison": [ + "luerl (init) 64.86 K", + "lua (new, no sandbox) 33.30 K - 1.95x slower +14.62 μs", + "lua (new, custom exclude) 27.37 K - 2.37x slower +21.12 μs", + "lua (new) 26.58 K - 2.44x slower +22.21 μs" + ], + "memory_note": "**All measurements for memory usage were the same**", + "cold_call": "6654.0 us", + "second_call": "93.0 us" + } + }, + "encode_decode": { + "raw": "lua 1.0.0 — encode!/decode! decomposition\n(decode+deep_cast column: enabled)\n==================================================================================\nop shape N total_us per_elem_ns\nencode int_list 8 0.90 113.0\ndecode int_list 8 0.28 34.7\ndec+cast int_list 8 0.31 38.5\nencode int_list 64 13.52 211.2\ndecode int_list 64 4.23 66.0\ndec+cast int_list 64 7.14 111.6\nencode int_list 512 151.37 295.6\ndecode int_list 512 48.44 94.6\ndec+cast int_list 512 73.99 144.5\nencode int_list 4096 1601.11 390.9\ndecode int_list 4096 583.70 142.5\ndec+cast int_list 4096 785.61 191.8\n----------------------------------------------------------------------------------\nencode float_list 8 0.78 97.1\ndecode float_list 8 0.24 29.8\ndec+cast float_list 8 0.26 32.9\nencode float_list 64 11.47 179.2\ndecode float_list 64 3.55 55.4\ndec+cast float_list 64 7.03 109.9\nencode float_list 512 156.43 305.5\ndecode float_list 512 45.74 89.3\ndec+cast float_list 512 72.74 142.1\nencode float_list 4096 1611.38 393.4\ndecode float_list 4096 551.08 134.5\ndec+cast float_list 4096 839.19 204.9\n----------------------------------------------------------------------------------\nencode bool_list 8 0.77 96.7\ndecode bool_list 8 0.23 28.7\ndec+cast bool_list 8 0.25 31.3\nencode bool_list 64 11.21 175.2\ndecode bool_list 64 3.46 54.1\ndec+cast bool_list 64 7.00 109.4\nencode bool_list 512 153.74 300.3\ndecode bool_list 512 47.42 92.6\ndec+cast bool_list 512 74.01 144.6\nencode bool_list 4096 1569.25 383.1\ndecode bool_list 4096 571.91 139.6\ndec+cast bool_list 4096 775.34 189.3\n----------------------------------------------------------------------------------\nencode short_string_list 8 0.78 96.9\ndecode short_string_list 8 0.24 29.8\ndec+cast short_string_list 8 0.26 32.6\nencode short_string_list 64 11.40 178.1\ndecode short_string_list 64 3.53 55.2\ndec+cast short_string_list 64 7.08 110.6\nencode short_string_list 512 155.05 302.8\ndecode short_string_list 512 49.88 97.4\ndec+cast short_string_list 512 72.83 142.2\nencode short_string_list 4096 1715.16 418.7\ndecode short_string_list 4096 718.28 175.4\ndec+cast short_string_list 4096 763.05 186.3\n----------------------------------------------------------------------------------\nencode long_string_list 8 0.78 96.9\ndecode long_string_list 8 0.24 30.5\ndec+cast long_string_list 8 0.26 33.0\nencode long_string_list 64 11.20 174.9\ndecode long_string_list 64 3.49 54.5\ndec+cast long_string_list 64 7.31 114.3\nencode long_string_list 512 157.18 307.0\ndecode long_string_list 512 42.76 83.5\ndec+cast long_string_list 512 76.03 148.5\nencode long_string_list 4096 2488.38 607.5\ndecode long_string_list 4096 1495.20 365.0\ndec+cast long_string_list 4096 1707.00 416.7\n----------------------------------------------------------------------------------\nencode string_map 8 1.60 199.5\ndecode string_map 8 0.10 12.4\ndec+cast string_map 8 0.20 25.5\nencode string_map 64 30.47 476.1\ndecode string_map 64 0.74 11.6\ndec+cast string_map 64 3.95 61.7\nencode string_map 512 240.62 470.0\ndecode string_map 512 11.04 21.6\ndec+cast string_map 512 39.76 77.7\nencode string_map 4096 2194.00 535.6\ndecode string_map 4096 71.08 17.4\ndec+cast string_map 4096 335.83 82.0\n----------------------------------------------------------------------------------\nencode int_map 8 0.83 103.4\ndecode int_map 8 0.24 29.8\ndec+cast int_map 8 0.26 32.2\nencode int_map 64 10.97 171.5\ndecode int_map 64 3.68 57.5\ndec+cast int_map 64 5.96 93.1\nencode int_map 512 109.35 213.6\ndecode int_map 512 27.78 54.3\ndec+cast int_map 512 72.41 141.4\nencode int_map 4096 1307.14 319.1\ndecode int_map 4096 576.06 140.6\ndec+cast int_map 4096 881.09 215.1\n----------------------------------------------------------------------------------\nencode record_list 8 5.07 633.8\ndecode record_list 8 0.78 98.1\ndec+cast record_list 8 1.20 149.9\nencode record_list 64 47.83 747.4\ndecode record_list 64 8.26 129.1\ndec+cast record_list 64 14.64 228.8\nencode record_list 512 565.88 1105.2\ndecode record_list 512 118.02 230.5\ndec+cast record_list 512 196.29 383.4\nencode record_list 4096 4732.25 1155.3\ndecode record_list 4096 1094.36 267.2\ndec+cast record_list 4096 1628.34 397.5\n----------------------------------------------------------------------------------\nnested chain (depth sweep) — isolates recursion/traversal from fan-out\nop shape N total_us per_elem_ns\nencode nested_chain 4 1.07 535.2\ndecode nested_chain 4 0.21 103.7\ndec+cast nested_chain 4 0.32 161.9\nencode nested_chain 16 4.19 2094.1\ndecode nested_chain 16 0.91 456.5\ndec+cast nested_chain 16 1.33 665.1\nencode nested_chain 64 16.94 8469.5\ndecode nested_chain 64 3.92 1961.9\ndec+cast nested_chain 64 6.28 3140.2\nencode nested_chain 256 68.77 34386.2\ndecode nested_chain 256 18.23 9114.9\ndec+cast nested_chain 256 32.31 16156.7\n==================================================================================\ncomposite anchor — the PR's `original_nested` (matches the 18us/108us figure)\nop shape N total_us per_elem_ns\nencode original_nested 75 17.11 4276.4\ndecode original_nested 75 3.21 802.7\ndec+cast original_nested 75 5.26 1314.8\n" + } +} diff --git a/bench_results/v1.0.0/table_ops.txt b/bench_results/v1.0.0/table_ops.txt new file mode 100644 index 00000000..5b463f7d --- /dev/null +++ b/bench_results/v1.0.0/table_ops.txt @@ -0,0 +1,461 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== Table Build (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +luerl 6.37 K 157.04 μs ±11.82% 154.21 μs 218.16 μs +lua (chunk) 6.23 K 160.45 μs ±8.91% 157.88 μs 198.69 μs +lua (eval) 5.90 K 169.48 μs ±14.14% 164.58 μs 287.61 μs + +Comparison: +luerl 6.37 K +lua (chunk) 6.23 K - 1.02x slower +3.41 μs +lua (eval) 5.90 K - 1.08x slower +12.44 μs + +Memory usage statistics: + +Name Memory usage +luerl 0.97 MB +lua (chunk) 1.00 MB - 1.03x memory usage +0.0267 MB +lua (eval) 1.01 MB - 1.04x memory usage +0.0361 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +lua (chunk) 60.98 K 16.40 μs ±32.33% 16.17 μs 24.29 μs +luerl 58.70 K 17.04 μs ±26.63% 16.50 μs 26.67 μs +lua (eval) 53.06 K 18.85 μs ±33.45% 18.42 μs 27.04 μs + +Comparison: +lua (chunk) 60.98 K +luerl 58.70 K - 1.04x slower +0.64 μs +lua (eval) 53.06 K - 1.15x slower +2.45 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 105.41 KB +luerl 110.77 KB - 1.05x memory usage +5.35 KB +lua (eval) 114.74 KB - 1.09x memory usage +9.33 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 437.49 K 2.29 μs ±237.41% 2.25 μs 3.42 μs +luerl 317.31 K 3.15 μs ±243.81% 3.04 μs 4.71 μs +lua (eval) 205.32 K 4.87 μs ±231.75% 4.46 μs 10.42 μs + +Comparison: +lua (chunk) 437.49 K +luerl 317.31 K - 1.38x slower +0.87 μs +lua (eval) 205.32 K - 2.13x slower +2.58 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 13.80 KB +luerl 22.62 KB - 1.64x memory usage +8.82 KB +lua (eval) 23.45 KB - 1.70x memory usage +9.66 KB + +**All measurements for memory usage were the same** + +=== Table Sort (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +luerl 5.52 K 181.29 μs ±10.01% 178.00 μs 220.42 μs +lua (chunk) 4.27 K 234.42 μs ±13.69% 227.63 μs 411.33 μs +lua (eval) 4.19 K 238.72 μs ±9.85% 234.88 μs 335.73 μs + +Comparison: +luerl 5.52 K +lua (chunk) 4.27 K - 1.29x slower +53.13 μs +lua (eval) 4.19 K - 1.32x slower +57.42 μs + +Memory usage statistics: + +Name Memory usage +luerl 1.18 MB +lua (chunk) 1.33 MB - 1.13x memory usage +0.156 MB +lua (eval) 1.34 MB - 1.14x memory usage +0.164 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +luerl 50.83 K 19.67 μs ±20.44% 19.13 μs 28.17 μs +lua (chunk) 42.73 K 23.40 μs ±35.27% 23.21 μs 26.75 μs +lua (eval) 38.35 K 26.07 μs ±37.69% 25.58 μs 35.33 μs + +Comparison: +luerl 50.83 K +lua (chunk) 42.73 K - 1.19x slower +3.73 μs +lua (eval) 38.35 K - 1.33x slower +6.40 μs + +Memory usage statistics: + +Name Memory usage +luerl 133.33 KB +lua (chunk) 141.38 KB - 1.06x memory usage +8.05 KB +lua (eval) 151.13 KB - 1.13x memory usage +17.80 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 311.25 K 3.21 μs ±183.71% 3.17 μs 4.50 μs +luerl 266.96 K 3.75 μs ±163.56% 3.58 μs 9.50 μs +lua (eval) 169.47 K 5.90 μs ±170.93% 5.50 μs 15.75 μs + +Comparison: +lua (chunk) 311.25 K +luerl 266.96 K - 1.17x slower +0.53 μs +lua (eval) 169.47 K - 1.84x slower +2.69 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 19.06 KB +luerl 25.98 KB - 1.36x memory usage +6.91 KB +lua (eval) 28.78 KB - 1.51x memory usage +9.72 KB + +**All measurements for memory usage were the same** + +=== Table Iterate/Sum (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +lua (chunk) 4.10 K 243.68 μs ±11.81% 235.09 μs 342.27 μs +lua (eval) 4.07 K 245.42 μs ±12.09% 241.67 μs 362.47 μs +luerl 4.06 K 246.09 μs ±7.76% 243.06 μs 342.83 μs + +Comparison: +lua (chunk) 4.10 K +lua (eval) 4.07 K - 1.01x slower +1.75 μs +luerl 4.06 K - 1.01x slower +2.41 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 1.47 MB +lua (eval) 1.48 MB - 1.01x memory usage +0.0110 MB +luerl 1.35 MB - 0.92x memory usage -0.12469 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +lua (chunk) 40.08 K 24.95 μs ±25.49% 24.25 μs 52.88 μs +lua (eval) 36.72 K 27.23 μs ±17.19% 26.67 μs 38.21 μs +luerl 36.18 K 27.64 μs ±35.39% 27.00 μs 57.79 μs + +Comparison: +lua (chunk) 40.08 K +lua (eval) 36.72 K - 1.09x slower +2.28 μs +luerl 36.18 K - 1.11x slower +2.69 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 154.27 KB +lua (eval) 164.09 KB - 1.06x memory usage +9.81 KB +luerl 149.14 KB - 0.97x memory usage -5.13281 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 306.46 K 3.26 μs ±172.56% 3.21 μs 4.38 μs +luerl 231.29 K 4.32 μs ±138.54% 4.25 μs 6.33 μs +lua (eval) 177.92 K 5.62 μs ±101.11% 5.42 μs 9.25 μs + +Comparison: +lua (chunk) 306.46 K +luerl 231.29 K - 1.33x slower +1.06 μs +lua (eval) 177.92 K - 1.72x slower +2.36 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 19.87 KB +luerl 26.80 KB - 1.35x memory usage +6.93 KB +lua (eval) 29.51 KB - 1.49x memory usage +9.64 KB + +**All measurements for memory usage were the same** + +=== Table Map + Reduce (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +luerl 2.15 K 464.93 μs ±7.90% 449.96 μs 600.72 μs +lua (chunk) 2.05 K 487.99 μs ±6.39% 480.24 μs 622.53 μs +lua (eval) 2.01 K 496.87 μs ±13.21% 487.17 μs 772.35 μs + +Comparison: +luerl 2.15 K +lua (chunk) 2.05 K - 1.05x slower +23.06 μs +lua (eval) 2.01 K - 1.07x slower +31.94 μs + +Memory usage statistics: + +Name Memory usage +luerl 2.44 MB +lua (chunk) 2.92 MB - 1.19x memory usage +0.47 MB +lua (eval) 2.93 MB - 1.20x memory usage +0.48 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +luerl 21.41 K 46.71 μs ±6.09% 46.50 μs 52.29 μs +lua (chunk) 20.87 K 47.91 μs ±8.43% 47.54 μs 53.79 μs +lua (eval) 19.56 K 51.12 μs ±6.66% 50.58 μs 60.46 μs + +Comparison: +luerl 21.41 K +lua (chunk) 20.87 K - 1.03x slower +1.20 μs +lua (eval) 19.56 K - 1.09x slower +4.40 μs + +Memory usage statistics: + +Name Memory usage +luerl 262.35 KB +lua (chunk) 303.87 KB - 1.16x memory usage +41.52 KB +lua (eval) 314.32 KB - 1.20x memory usage +51.97 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 168.91 K 5.92 μs ±76.95% 5.83 μs 13.38 μs +luerl 145.75 K 6.86 μs ±94.69% 6.59 μs 15.96 μs +lua (eval) 102.03 K 9.80 μs ±71.35% 8.21 μs 22.96 μs + +Comparison: +lua (chunk) 168.91 K +luerl 145.75 K - 1.16x slower +0.94 μs +lua (eval) 102.03 K - 1.66x slower +3.88 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 37.09 KB +luerl 39.39 KB - 1.06x memory usage +2.30 KB +lua (eval) 46.72 KB - 1.26x memory usage +9.63 KB + +**All measurements for memory usage were the same** + +=== Table Pairs (hash) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +lua (chunk) 1.02 K 984.62 μs ±7.98% 995.70 μs 1143.86 μs +lua (eval) 1.01 K 990.11 μs ±8.33% 1002.18 μs 1157.35 μs +luerl 0.75 K 1332.28 μs ±5.93% 1334.35 μs 1489.09 μs + +Comparison: +lua (chunk) 1.02 K +lua (eval) 1.01 K - 1.01x slower +5.49 μs +luerl 0.75 K - 1.35x slower +347.66 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 2.06 MB +lua (eval) 2.05 MB - 0.99x memory usage -0.01064 MB +luerl 2.48 MB - 1.20x memory usage +0.42 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +lua (chunk) 13.46 K 74.27 μs ±10.12% 72.92 μs 99.63 μs +lua (eval) 12.33 K 81.10 μs ±25.10% 78.13 μs 167.55 μs +luerl 10.92 K 91.56 μs ±15.90% 86.83 μs 142.16 μs + +Comparison: +lua (chunk) 13.46 K +lua (eval) 12.33 K - 1.09x slower +6.83 μs +luerl 10.92 K - 1.23x slower +17.29 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 211.49 KB +lua (eval) 221.46 KB - 1.05x memory usage +9.97 KB +luerl 248.84 KB - 1.18x memory usage +37.34 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 130.58 K 7.66 μs ±70.51% 7.33 μs 24.42 μs +luerl 130.19 K 7.68 μs ±68.45% 7.25 μs 23.38 μs +lua (eval) 94.66 K 10.56 μs ±56.52% 9.92 μs 29.25 μs + +Comparison: +lua (chunk) 130.58 K +luerl 130.19 K - 1.00x slower +0.0229 μs +lua (eval) 94.66 K - 1.38x slower +2.91 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 25.10 KB +luerl 36.16 KB - 1.44x memory usage +11.05 KB +lua (eval) 34.91 KB - 1.39x memory usage +9.81 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.0/timestamp.txt b/bench_results/v1.0.0/timestamp.txt new file mode 100644 index 00000000..ff5029ff --- /dev/null +++ b/bench_results/v1.0.0/timestamp.txt @@ -0,0 +1 @@ +Tue Jul 28 10:48:16 EDT 2026 diff --git a/bench_results/v1.0.0/versions.txt b/bench_results/v1.0.0/versions.txt new file mode 100644 index 00000000..184e5f9a --- /dev/null +++ b/bench_results/v1.0.0/versions.txt @@ -0,0 +1,3 @@ +Erlang/OTP 29 [erts-17.0] [source] [64-bit] [smp:10:10] [ds:10:10:10] [async-threads:1] [jit] + +Elixir 1.20.0 (compiled with Erlang/OTP 29) diff --git a/bench_results/v1.0.0/vm_new.txt b/bench_results/v1.0.0/vm_new.txt new file mode 100644 index 00000000..9ade4d52 --- /dev/null +++ b/bench_results/v1.0.0/vm_new.txt @@ -0,0 +1,56 @@ +=== VM instantiation: Lua.new/1 vs :luerl.init/0 (mode: full) === + +Lua.new() one-time vs repeat cost (single samples, informational): + first call on this node : 6654.0 us + second call : 93.0 us + +The first figure includes any one-time template build and first-time module +loading. Benchee's steady-state numbers below are the per-request cost after +that point. + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 52 s +Excluding outliers: false + +Benchmarking lua (new) ... +Benchmarking lua (new, custom exclude) ... +Benchmarking lua (new, no sandbox) ... +Benchmarking luerl (init) ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl (init) 64.86 K 15.42 μs ±23.43% 15.21 μs 24.25 μs +lua (new, no sandbox) 33.30 K 30.03 μs ±11.23% 29.71 μs 38.46 μs +lua (new, custom exclude) 27.37 K 36.54 μs ±8.96% 35.96 μs 42.63 μs +lua (new) 26.58 K 37.63 μs ±62.22% 36.67 μs 71.96 μs + +Comparison: +luerl (init) 64.86 K +lua (new, no sandbox) 33.30 K - 1.95x slower +14.62 μs +lua (new, custom exclude) 27.37 K - 2.37x slower +21.12 μs +lua (new) 26.58 K - 2.44x slower +22.21 μs + +Memory usage statistics: + +Name Memory usage +luerl (init) 51.64 KB +lua (new, no sandbox) 68.76 KB - 1.33x memory usage +17.12 KB +lua (new, custom exclude) 90.97 KB - 1.76x memory usage +39.33 KB +lua (new) 91.88 KB - 1.78x memory usage +40.23 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.2/closures.txt b/bench_results/v1.0.2/closures.txt new file mode 100644 index 00000000..c2a370a5 --- /dev/null +++ b/bench_results/v1.0.2/closures.txt @@ -0,0 +1,46 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (eval) 2.68 K 372.50 μs ±10.92% 365.04 μs 532.92 μs +lua (chunk) 2.63 K 379.93 μs ±8.39% 373.71 μs 512.83 μs +luerl 2.53 K 395.50 μs ±7.19% 389.21 μs 548.26 μs + +Comparison: +lua (eval) 2.68 K +lua (chunk) 2.63 K - 1.02x slower +7.43 μs +luerl 2.53 K - 1.06x slower +23.01 μs + +Memory usage statistics: + +Name average deviation median 99th % +lua (eval) 2.12 MB ±0.11% 2.12 MB 2.13 MB +lua (chunk) 2.11 MB ±0.11% 2.11 MB 2.12 MB +luerl 1.90 MB ±0.00% 1.90 MB 1.90 MB + +Comparison: +lua (eval) 2.12 MB +lua (chunk) 2.11 MB - 1.00x memory usage -0.01023 MB +luerl 1.90 MB - 0.89x memory usage -0.22699 MB diff --git a/bench_results/v1.0.2/commit.txt b/bench_results/v1.0.2/commit.txt new file mode 100644 index 00000000..27bf7c3e --- /dev/null +++ b/bench_results/v1.0.2/commit.txt @@ -0,0 +1 @@ +3a0d3923249cbb05380a3f17d3920a0b3ed55cb0 diff --git a/bench_results/v1.0.2/cpu.txt b/bench_results/v1.0.2/cpu.txt new file mode 100644 index 00000000..de5c8ad6 --- /dev/null +++ b/bench_results/v1.0.2/cpu.txt @@ -0,0 +1 @@ +Apple M4 diff --git a/bench_results/v1.0.2/encode_decode.txt b/bench_results/v1.0.2/encode_decode.txt new file mode 100644 index 00000000..204913bc --- /dev/null +++ b/bench_results/v1.0.2/encode_decode.txt @@ -0,0 +1,128 @@ +lua 1.0.2 — encode!/decode! decomposition +(decode+deep_cast column: enabled) +================================================================================== +op shape N total_us per_elem_ns +encode int_list 8 0.81 101.5 +decode int_list 8 0.29 35.9 +dec+cast int_list 8 0.33 40.9 +encode int_list 64 14.59 228.0 +decode int_list 64 4.94 77.1 +dec+cast int_list 64 7.11 111.0 +encode int_list 512 158.65 309.9 +decode int_list 512 47.04 91.9 +dec+cast int_list 512 72.78 142.1 +encode int_list 4096 1568.66 383.0 +decode int_list 4096 565.55 138.1 +dec+cast int_list 4096 892.14 217.8 +---------------------------------------------------------------------------------- +encode float_list 8 0.78 98.0 +decode float_list 8 0.25 31.8 +dec+cast float_list 8 0.27 33.6 +encode float_list 64 11.77 183.9 +decode float_list 64 3.76 58.7 +dec+cast float_list 64 6.99 109.2 +encode float_list 512 162.64 317.6 +decode float_list 512 47.30 92.4 +dec+cast float_list 512 75.86 148.2 +encode float_list 4096 1614.23 394.1 +decode float_list 4096 547.77 133.7 +dec+cast float_list 4096 805.73 196.7 +---------------------------------------------------------------------------------- +encode bool_list 8 0.79 99.1 +decode bool_list 8 0.24 29.9 +dec+cast bool_list 8 0.27 33.9 +encode bool_list 64 14.65 228.8 +decode bool_list 64 4.69 73.3 +dec+cast bool_list 64 6.88 107.4 +encode bool_list 512 158.50 309.6 +decode bool_list 512 45.36 88.6 +dec+cast bool_list 512 71.69 140.0 +encode bool_list 4096 1572.86 384.0 +decode bool_list 4096 581.14 141.9 +dec+cast bool_list 4096 792.78 193.6 +---------------------------------------------------------------------------------- +encode short_string_list 8 0.78 97.8 +decode short_string_list 8 0.24 30.0 +dec+cast short_string_list 8 0.27 34.2 +encode short_string_list 64 11.49 179.6 +decode short_string_list 64 3.51 54.9 +dec+cast short_string_list 64 6.93 108.3 +encode short_string_list 512 162.32 317.0 +decode short_string_list 512 48.13 94.0 +dec+cast short_string_list 512 74.26 145.0 +encode short_string_list 4096 1664.81 406.4 +decode short_string_list 4096 550.34 134.4 +dec+cast short_string_list 4096 810.06 197.8 +---------------------------------------------------------------------------------- +encode long_string_list 8 0.79 98.9 +decode long_string_list 8 0.25 31.1 +dec+cast long_string_list 8 0.26 33.0 +encode long_string_list 64 11.67 182.4 +decode long_string_list 64 3.66 57.3 +dec+cast long_string_list 64 6.75 105.5 +encode long_string_list 512 169.63 331.3 +decode long_string_list 512 47.95 93.7 +dec+cast long_string_list 512 75.67 147.8 +encode long_string_list 4096 2667.06 651.1 +decode long_string_list 4096 1339.42 327.0 +dec+cast long_string_list 4096 1705.52 416.4 +---------------------------------------------------------------------------------- +encode string_map 8 0.72 89.9 +decode string_map 8 0.11 13.2 +dec+cast string_map 8 0.21 26.1 +encode string_map 64 12.18 190.3 +decode string_map 64 0.74 11.6 +dec+cast string_map 64 3.58 56.0 +encode string_map 512 103.35 201.9 +decode string_map 512 9.32 18.2 +dec+cast string_map 512 39.57 77.3 +encode string_map 4096 1046.22 255.4 +decode string_map 4096 79.30 19.4 +dec+cast string_map 4096 343.00 83.7 +---------------------------------------------------------------------------------- +encode int_map 8 0.81 100.9 +decode int_map 8 0.25 31.6 +dec+cast int_map 8 0.27 33.9 +encode int_map 64 11.36 177.6 +decode int_map 64 3.45 54.0 +dec+cast int_map 64 5.64 88.1 +encode int_map 512 111.00 216.8 +decode int_map 512 32.55 63.6 +dec+cast int_map 512 72.18 141.0 +encode int_map 4096 1503.89 367.2 +decode int_map 4096 599.78 146.4 +dec+cast int_map 4096 850.84 207.7 +---------------------------------------------------------------------------------- +encode record_list 8 3.10 387.3 +decode record_list 8 0.89 111.3 +dec+cast record_list 8 1.60 199.4 +encode record_list 64 45.68 713.8 +decode record_list 64 12.94 202.3 +dec+cast record_list 64 18.97 296.4 +encode record_list 512 422.71 825.6 +decode record_list 512 115.71 226.0 +dec+cast record_list 512 154.32 301.4 +encode record_list 4096 3814.88 931.4 +decode record_list 4096 1139.67 278.2 +dec+cast record_list 4096 1656.06 404.3 +---------------------------------------------------------------------------------- +nested chain (depth sweep) — isolates recursion/traversal from fan-out +op shape N total_us per_elem_ns +encode nested_chain 4 0.68 338.0 +decode nested_chain 4 0.24 122.2 +dec+cast nested_chain 4 0.36 179.7 +encode nested_chain 16 2.77 1387.2 +decode nested_chain 16 1.11 552.9 +dec+cast nested_chain 16 1.54 768.1 +encode nested_chain 64 10.94 5468.5 +decode nested_chain 64 6.00 3002.0 +dec+cast nested_chain 64 8.37 4183.6 +encode nested_chain 256 44.55 22273.9 +decode nested_chain 256 28.00 13998.0 +dec+cast nested_chain 256 37.24 18618.2 +================================================================================== +composite anchor — the PR's `original_nested` (matches the 18us/108us figure) +op shape N total_us per_elem_ns +encode original_nested 75 12.26 3065.6 +decode original_nested 75 3.29 821.3 +dec+cast original_nested 75 5.66 1413.8 diff --git a/bench_results/v1.0.2/environment.md b/bench_results/v1.0.2/environment.md new file mode 100644 index 00000000..453867ba --- /dev/null +++ b/bench_results/v1.0.2/environment.md @@ -0,0 +1,36 @@ +# Benchmark environment — main (1.0.2) + +- **Ref**: `main` — the main checkout itself, commit `3a0d3923249cbb05380a3f17d3920a0b3ed55cb0` (the 1.0.2 release). No worktree was created or removed for this run; the main checkout was read but not modified. +- **Mode**: `full` (`LUA_BENCH_MODE=full`) +- **CPU**: Apple M4 (see `cpu.txt`) +- **Elixir / OTP**: Elixir 1.20.0, Erlang/OTP 29 [erts-17.0] [64-bit] [jit] (see `versions.txt`) +- **Run timestamp**: Tue Jul 28 11:14:31 EDT 2026 (see `timestamp.txt`) +- **This document written**: Tue Jul 28 11:37:22 EDT 2026 + +## Command form + +Each workload was run serially, one process per file, on an otherwise quiet +machine, from the main checkout root: + +``` +LUA_BENCH_MODE=full MIX_ENV=benchmark mix run benchmarks/.exs +``` + +Workloads run: `fibonacci`, `closures`, `oop`, `string_ops`, `string_format`, +`table_ops`, `patterns`, `metamethods`, `pcall_varargs`, `vm_new`, +`encode_decode`. + +`encode_decode.exs` is not Benchee-based (it uses a `:timer.tc` harness +directly), but was invoked with the same command form for consistency. + +C Lua via `luaport` was not available in this environment (no local luaport +build) and was skipped by each script's own fallback path; all comparisons +below are lua (chunk)/lua (eval) vs. luerl only. + +## Artifacts + +- `cpu.txt`, `versions.txt`, `timestamp.txt`, `commit.txt` — raw environment + probes. +- `.txt` — raw stdout of each `mix run` invocation (11 files). +- `summary.json` — parsed structured form of the above (workload -> case -> + jobs/comparison/memory), same schema as `results/v1.0.0/summary.json`. diff --git a/bench_results/v1.0.2/fibonacci.txt b/bench_results/v1.0.2/fibonacci.txt new file mode 100644 index 00000000..153dfe11 --- /dev/null +++ b/bench_results/v1.0.2/fibonacci.txt @@ -0,0 +1,43 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (eval) 2.32 431.14 ms ±1.78% 429.30 ms 454.01 ms +lua (chunk) 2.30 435.33 ms ±2.05% 434.12 ms 469.44 ms +luerl 1.35 742.61 ms ±1.33% 741.95 ms 764.71 ms + +Comparison: +lua (eval) 2.32 +lua (chunk) 2.30 - 1.01x slower +4.18 ms +luerl 1.35 - 1.72x slower +311.46 ms + +Memory usage statistics: + +Name Memory usage +lua (eval) 1016.62 MB +lua (chunk) 1016.63 MB - 1.00x memory usage +0.00925 MB +luerl 2513.67 MB - 2.47x memory usage +1497.05 MB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.2/metamethods.txt b/bench_results/v1.0.2/metamethods.txt new file mode 100644 index 00000000..5079db11 --- /dev/null +++ b/bench_results/v1.0.2/metamethods.txt @@ -0,0 +1,136 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== metamethods: self-call method dispatch (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (eval) 2.10 K 476.79 μs ±13.40% 462.04 μs 730.43 μs +luerl 2.05 K 487.93 μs ±8.87% 474.63 μs 657.89 μs +lua (chunk) 2.04 K 490.34 μs ±7.16% 485.83 μs 642.74 μs + +Comparison: +lua (eval) 2.10 K +luerl 2.05 K - 1.02x slower +11.14 μs +lua (chunk) 2.04 K - 1.03x slower +13.55 μs + +Memory usage statistics: + +Name Memory usage +lua (eval) 1.37 MB +luerl 1.43 MB - 1.04x memory usage +0.0544 MB +lua (chunk) 1.36 MB - 0.99x memory usage -0.01070 MB + +**All measurements for memory usage were the same** + +=== metamethods: 3-level __index chain (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 5.24 K 190.75 μs ±4.32% 190.08 μs 206.47 μs +lua (eval) 5.09 K 196.41 μs ±6.91% 194.58 μs 221.08 μs +luerl 4.60 K 217.23 μs ±4.39% 216 μs 233.25 μs + +Comparison: +lua (chunk) 5.24 K +lua (eval) 5.09 K - 1.03x slower +5.65 μs +luerl 4.60 K - 1.14x slower +26.48 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 576.76 KB +lua (eval) 587.70 KB - 1.02x memory usage +10.95 KB +luerl 668.59 KB - 1.16x memory usage +91.83 KB + +**All measurements for memory usage were the same** + +=== metamethods: arithmetic/relational metamethods (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (eval) 533.57 1.87 ms ±5.87% 1.92 ms 2.18 ms +luerl 528.86 1.89 ms ±5.87% 1.85 ms 2.20 ms +lua (chunk) 521.21 1.92 ms ±12.56% 1.93 ms 3.10 ms + +Comparison: +lua (eval) 533.57 +luerl 528.86 - 1.01x slower +0.0167 ms +lua (chunk) 521.21 - 1.02x slower +0.0444 ms + +Memory usage statistics: + +Name Memory usage +lua (eval) 5.71 MB +luerl 6.24 MB - 1.09x memory usage +0.53 MB +lua (chunk) 5.70 MB - 1.00x memory usage -0.01050 MB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.2/oop.txt b/bench_results/v1.0.2/oop.txt new file mode 100644 index 00000000..1e6a3aeb --- /dev/null +++ b/bench_results/v1.0.2/oop.txt @@ -0,0 +1,43 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 10.47 K 95.55 μs ±13.61% 93.21 μs 154.21 μs +lua (eval) 10.27 K 97.33 μs ±13.81% 94.67 μs 153.75 μs +luerl 7.14 K 140.03 μs ±21.43% 133.50 μs 231.96 μs + +Comparison: +lua (chunk) 10.47 K +lua (eval) 10.27 K - 1.02x slower +1.78 μs +luerl 7.14 K - 1.47x slower +44.48 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 368.40 KB +lua (eval) 378.94 KB - 1.03x memory usage +10.54 KB +luerl 381.45 KB - 1.04x memory usage +13.05 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.2/patterns.txt b/bench_results/v1.0.2/patterns.txt new file mode 100644 index 00000000..9bd65efa --- /dev/null +++ b/bench_results/v1.0.2/patterns.txt @@ -0,0 +1,136 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== patterns: find/match field extraction (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 998.95 1.00 ms ±3.02% 1.00 ms 1.06 ms +lua (eval) 986.14 1.01 ms ±10.37% 1.00 ms 1.36 ms +luerl 923.97 1.08 ms ±3.61% 1.08 ms 1.18 ms + +Comparison: +lua (chunk) 998.95 +lua (eval) 986.14 - 1.01x slower +0.0130 ms +luerl 923.97 - 1.08x slower +0.0812 ms + +Memory usage statistics: + +Name Memory usage +lua (chunk) 3.21 MB +lua (eval) 3.22 MB - 1.00x memory usage +0.0110 MB +luerl 6.70 MB - 2.09x memory usage +3.49 MB + +**All measurements for memory usage were the same** + +=== patterns: find-based tokenizer (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 729.19 1.37 ms ±3.06% 1.36 ms 1.48 ms +lua (chunk) 673.77 1.48 ms ±3.57% 1.46 ms 1.61 ms +lua (eval) 661.88 1.51 ms ±4.44% 1.49 ms 1.76 ms + +Comparison: +luerl 729.19 +lua (chunk) 673.77 - 1.08x slower +0.113 ms +lua (eval) 661.88 - 1.10x slower +0.139 ms + +Memory usage statistics: + +Name Memory usage +luerl 6.65 MB +lua (chunk) 9.10 MB - 1.37x memory usage +2.45 MB +lua (eval) 9.11 MB - 1.37x memory usage +2.46 MB + +**All measurements for memory usage were the same** + +=== patterns: gsub template substitution (n=200) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (eval) 513.54 1.95 ms ±2.83% 1.95 ms 2.06 ms +lua (chunk) 480.12 2.08 ms ±16.12% 1.98 ms 3.12 ms +luerl 418.97 2.39 ms ±17.21% 2.20 ms 3.51 ms + +Comparison: +lua (eval) 513.54 +lua (chunk) 480.12 - 1.07x slower +0.136 ms +luerl 418.97 - 1.23x slower +0.44 ms + +Memory usage statistics: + +Name Memory usage +lua (eval) 4.50 MB +lua (chunk) 4.49 MB - 1.00x memory usage -0.01124 MB +luerl 11.75 MB - 2.61x memory usage +7.25 MB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.2/pcall_varargs.txt b/bench_results/v1.0.2/pcall_varargs.txt new file mode 100644 index 00000000..ebeacdd5 --- /dev/null +++ b/bench_results/v1.0.2/pcall_varargs.txt @@ -0,0 +1,136 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== call protocol: pcall, success path (n=500) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 4.47 K 223.70 μs ±4.04% 222.88 μs 241.97 μs +lua (eval) 4.44 K 225.34 μs ±6.09% 224.42 μs 243.03 μs +luerl 3.40 K 294.17 μs ±6.42% 291.54 μs 317.97 μs + +Comparison: +lua (chunk) 4.47 K +lua (eval) 4.44 K - 1.01x slower +1.63 μs +luerl 3.40 K - 1.31x slower +70.46 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 895.93 KB +lua (eval) 911.78 KB - 1.02x memory usage +15.85 KB +luerl 1137.10 KB - 1.27x memory usage +241.17 KB + +**All measurements for memory usage were the same** + +=== call protocol: pcall, raise + catch (n=500) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 2.12 K 471.09 μs ±3.42% 468.42 μs 503.21 μs +lua (chunk) 1.36 K 733.85 μs ±2.60% 727.13 μs 781.15 μs +lua (eval) 1.35 K 742.90 μs ±5.35% 734.58 μs 824.44 μs + +Comparison: +luerl 2.12 K +lua (chunk) 1.36 K - 1.56x slower +262.76 μs +lua (eval) 1.35 K - 1.58x slower +271.81 μs + +Memory usage statistics: + +Name Memory usage +luerl 1.55 MB +lua (chunk) 2.67 MB - 1.72x memory usage +1.12 MB +lua (eval) 2.68 MB - 1.72x memory usage +1.12 MB + +**All measurements for memory usage were the same** + +=== call protocol: varargs + multiple returns (n=500) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +luerl 409.82 2.44 ms ±4.21% 2.41 ms 2.74 ms +lua (eval) 306.73 3.26 ms ±1.81% 3.27 ms 3.44 ms +lua (chunk) 300.00 3.33 ms ±10.97% 3.28 ms 5.01 ms + +Comparison: +luerl 409.82 +lua (eval) 306.73 - 1.34x slower +0.82 ms +lua (chunk) 300.00 - 1.37x slower +0.89 ms + +Memory usage statistics: + +Name Memory usage +luerl 8.73 MB +lua (eval) 16.71 MB - 1.91x memory usage +7.98 MB +lua (chunk) 16.70 MB - 1.91x memory usage +7.97 MB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.2/string_format.txt b/bench_results/v1.0.2/string_format.txt new file mode 100644 index 00000000..97284a35 --- /dev/null +++ b/bench_results/v1.0.2/string_format.txt @@ -0,0 +1,136 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== string.format: long literal-heavy format string (n=1000) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 1.38 K 722.93 μs ±4.47% 716.04 μs 787.74 μs +lua (eval) 1.23 K 815.14 μs ±12.46% 759.94 μs 989.38 μs +luerl 0.25 K 3990.98 μs ±7.83% 3921.50 μs 5009.73 μs + +Comparison: +lua (chunk) 1.38 K +lua (eval) 1.23 K - 1.13x slower +92.22 μs +luerl 0.25 K - 5.52x slower +3268.06 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 2.03 MB +lua (eval) 2.04 MB - 1.01x memory usage +0.0106 MB +luerl 22.59 MB - 11.11x memory usage +20.56 MB + +**All measurements for memory usage were the same** + +=== string.format: width-flagged specifiers (n=1000) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 812.70 1.23 ms ±5.82% 1.22 ms 1.33 ms +lua (eval) 804.55 1.24 ms ±2.99% 1.24 ms 1.32 ms +luerl 571.68 1.75 ms ±3.48% 1.74 ms 1.86 ms + +Comparison: +lua (chunk) 812.70 +lua (eval) 804.55 - 1.01x slower +0.0125 ms +luerl 571.68 - 1.42x slower +0.52 ms + +Memory usage statistics: + +Name Memory usage +lua (chunk) 3.52 MB +lua (eval) 3.53 MB - 1.00x memory usage +0.0132 MB +luerl 7.55 MB - 2.14x memory usage +4.03 MB + +**All measurements for memory usage were the same** + +=== string.format: many specifiers (n=1000) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (eval) 577.23 1.73 ms ±12.08% 1.70 ms 2.30 ms +lua (chunk) 566.54 1.77 ms ±3.99% 1.77 ms 1.94 ms +luerl 359.44 2.78 ms ±7.17% 2.78 ms 3.20 ms + +Comparison: +lua (eval) 577.23 +lua (chunk) 566.54 - 1.02x slower +0.0327 ms +luerl 359.44 - 1.61x slower +1.05 ms + +Memory usage statistics: + +Name Memory usage +lua (eval) 6.64 MB +lua (chunk) 6.63 MB - 1.00x memory usage -0.01004 MB +luerl 13.76 MB - 2.07x memory usage +7.11 MB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.2/string_ops.txt b/bench_results/v1.0.2/string_ops.txt new file mode 100644 index 00000000..9ac629f1 --- /dev/null +++ b/bench_results/v1.0.2/string_ops.txt @@ -0,0 +1,91 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== String Concatenation via table.concat (n=100) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 31.46 K 31.79 μs ±14.86% 31.21 μs 41.21 μs +lua (eval) 29.39 K 34.03 μs ±10.70% 33.63 μs 42.46 μs +luerl 24.71 K 40.47 μs ±7.41% 40.17 μs 45.88 μs + +Comparison: +lua (chunk) 31.46 K +lua (eval) 29.39 K - 1.07x slower +2.24 μs +luerl 24.71 K - 1.27x slower +8.68 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 147.68 KB +lua (eval) 161.46 KB - 1.09x memory usage +13.78 KB +luerl 172.42 KB - 1.17x memory usage +24.74 KB + +**All measurements for memory usage were the same** + +=== String Formatting via string.format (n=100) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 39 s +Excluding outliers: false + +Benchmarking lua (chunk) ... +Benchmarking lua (eval) ... +Benchmarking luerl ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (chunk) 14.08 K 71.03 μs ±8.11% 72.21 μs 80.46 μs +lua (eval) 13.20 K 75.76 μs ±16.78% 76.17 μs 92.94 μs +luerl 9.54 K 104.79 μs ±7.40% 103.04 μs 123.13 μs + +Comparison: +lua (chunk) 14.08 K +lua (eval) 13.20 K - 1.07x slower +4.73 μs +luerl 9.54 K - 1.48x slower +33.75 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 241.46 KB +lua (eval) 252.21 KB - 1.04x memory usage +10.75 KB +luerl 588.84 KB - 2.44x memory usage +347.38 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.2/summary.json b/bench_results/v1.0.2/summary.json new file mode 100644 index 00000000..c146681e --- /dev/null +++ b/bench_results/v1.0.2/summary.json @@ -0,0 +1,1282 @@ +{ + "fibonacci": { + "default": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "2.32", + "average": "431.14 ms", + "deviation": "±1.78%", + "median": "429.30 ms", + "p99": "454.01 ms", + "memory": "1016.62 MB" + }, + { + "name": "lua (chunk)", + "ips": "2.30", + "average": "435.33 ms", + "deviation": "±2.05%", + "median": "434.12 ms", + "p99": "469.44 ms", + "memory": "1016.63 MB" + }, + { + "name": "luerl", + "ips": "1.35", + "average": "742.61 ms", + "deviation": "±1.33%", + "median": "741.95 ms", + "p99": "764.71 ms", + "memory": "2513.67 MB" + } + ], + "comparison": [ + "lua (eval) 2.32", + "lua (chunk) 2.30 - 1.01x slower +4.18 ms", + "luerl 1.35 - 1.72x slower +311.46 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "closures": { + "default": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "2.68 K", + "average": "372.50 μs", + "deviation": "±10.92%", + "median": "365.04 μs", + "p99": "532.92 μs", + "memory": "2.12 MB" + }, + { + "name": "lua (chunk)", + "ips": "2.63 K", + "average": "379.93 μs", + "deviation": "±8.39%", + "median": "373.71 μs", + "p99": "512.83 μs", + "memory": "2.11 MB" + }, + { + "name": "luerl", + "ips": "2.53 K", + "average": "395.50 μs", + "deviation": "±7.19%", + "median": "389.21 μs", + "p99": "548.26 μs", + "memory": "1.90 MB" + } + ], + "comparison": [ + "lua (eval) 2.68 K", + "lua (chunk) 2.63 K - 1.02x slower +7.43 μs", + "luerl 2.53 K - 1.06x slower +23.01 μs" + ], + "memory_comparison": [ + "lua (eval) 2.12 MB", + "lua (chunk) 2.11 MB - 1.00x memory usage -0.01023 MB", + "luerl 1.90 MB - 0.89x memory usage -0.22699 MB" + ] + } + }, + "oop": { + "default": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "10.47 K", + "average": "95.55 μs", + "deviation": "±13.61%", + "median": "93.21 μs", + "p99": "154.21 μs", + "memory": "368.40 KB" + }, + { + "name": "lua (eval)", + "ips": "10.27 K", + "average": "97.33 μs", + "deviation": "±13.81%", + "median": "94.67 μs", + "p99": "153.75 μs", + "memory": "378.94 KB" + }, + { + "name": "luerl", + "ips": "7.14 K", + "average": "140.03 μs", + "deviation": "±21.43%", + "median": "133.50 μs", + "p99": "231.96 μs", + "memory": "381.45 KB" + } + ], + "comparison": [ + "lua (chunk) 10.47 K", + "lua (eval) 10.27 K - 1.02x slower +1.78 μs", + "luerl 7.14 K - 1.47x slower +44.48 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "string_ops": { + "String Concatenation via table.concat (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "31.46 K", + "average": "31.79 μs", + "deviation": "±14.86%", + "median": "31.21 μs", + "p99": "41.21 μs", + "memory": "147.68 KB" + }, + { + "name": "lua (eval)", + "ips": "29.39 K", + "average": "34.03 μs", + "deviation": "±10.70%", + "median": "33.63 μs", + "p99": "42.46 μs", + "memory": "161.46 KB" + }, + { + "name": "luerl", + "ips": "24.71 K", + "average": "40.47 μs", + "deviation": "±7.41%", + "median": "40.17 μs", + "p99": "45.88 μs", + "memory": "172.42 KB" + } + ], + "comparison": [ + "lua (chunk) 31.46 K", + "lua (eval) 29.39 K - 1.07x slower +2.24 μs", + "luerl 24.71 K - 1.27x slower +8.68 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "String Formatting via string.format (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "14.08 K", + "average": "71.03 μs", + "deviation": "±8.11%", + "median": "72.21 μs", + "p99": "80.46 μs", + "memory": "241.46 KB" + }, + { + "name": "lua (eval)", + "ips": "13.20 K", + "average": "75.76 μs", + "deviation": "±16.78%", + "median": "76.17 μs", + "p99": "92.94 μs", + "memory": "252.21 KB" + }, + { + "name": "luerl", + "ips": "9.54 K", + "average": "104.79 μs", + "deviation": "±7.40%", + "median": "103.04 μs", + "p99": "123.13 μs", + "memory": "588.84 KB" + } + ], + "comparison": [ + "lua (chunk) 14.08 K", + "lua (eval) 13.20 K - 1.07x slower +4.73 μs", + "luerl 9.54 K - 1.48x slower +33.75 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "string_format": { + "string.format: long literal-heavy format string (n=1000)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "1.38 K", + "average": "722.93 μs", + "deviation": "±4.47%", + "median": "716.04 μs", + "p99": "787.74 μs", + "memory": "2.03 MB" + }, + { + "name": "lua (eval)", + "ips": "1.23 K", + "average": "815.14 μs", + "deviation": "±12.46%", + "median": "759.94 μs", + "p99": "989.38 μs", + "memory": "2.04 MB" + }, + { + "name": "luerl", + "ips": "0.25 K", + "average": "3990.98 μs", + "deviation": "±7.83%", + "median": "3921.50 μs", + "p99": "5009.73 μs", + "memory": "22.59 MB" + } + ], + "comparison": [ + "lua (chunk) 1.38 K", + "lua (eval) 1.23 K - 1.13x slower +92.22 μs", + "luerl 0.25 K - 5.52x slower +3268.06 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "string.format: width-flagged specifiers (n=1000)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "812.70", + "average": "1.23 ms", + "deviation": "±5.82%", + "median": "1.22 ms", + "p99": "1.33 ms", + "memory": "3.52 MB" + }, + { + "name": "lua (eval)", + "ips": "804.55", + "average": "1.24 ms", + "deviation": "±2.99%", + "median": "1.24 ms", + "p99": "1.32 ms", + "memory": "3.53 MB" + }, + { + "name": "luerl", + "ips": "571.68", + "average": "1.75 ms", + "deviation": "±3.48%", + "median": "1.74 ms", + "p99": "1.86 ms", + "memory": "7.55 MB" + } + ], + "comparison": [ + "lua (chunk) 812.70", + "lua (eval) 804.55 - 1.01x slower +0.0125 ms", + "luerl 571.68 - 1.42x slower +0.52 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "string.format: many specifiers (n=1000)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "577.23", + "average": "1.73 ms", + "deviation": "±12.08%", + "median": "1.70 ms", + "p99": "2.30 ms", + "memory": "6.64 MB" + }, + { + "name": "lua (chunk)", + "ips": "566.54", + "average": "1.77 ms", + "deviation": "±3.99%", + "median": "1.77 ms", + "p99": "1.94 ms", + "memory": "6.63 MB" + }, + { + "name": "luerl", + "ips": "359.44", + "average": "2.78 ms", + "deviation": "±7.17%", + "median": "2.78 ms", + "p99": "3.20 ms", + "memory": "13.76 MB" + } + ], + "comparison": [ + "lua (eval) 577.23", + "lua (chunk) 566.54 - 1.02x slower +0.0327 ms", + "luerl 359.44 - 1.61x slower +1.05 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "table_ops": { + "Table Build": { + "by_input": { + "large (n=1000)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "6.74 K", + "average": "148.28 μs", + "deviation": "±10.00%", + "median": "145.92 μs", + "p99": "180.79 μs", + "memory": "1023.05 KB" + }, + { + "name": "lua (eval)", + "ips": "6.55 K", + "average": "152.58 μs", + "deviation": "±18.06%", + "median": "148.88 μs", + "p99": "259.21 μs", + "memory": "1033.39 KB" + }, + { + "name": "luerl", + "ips": "6.48 K", + "average": "154.42 μs", + "deviation": "±9.20%", + "median": "152.71 μs", + "p99": "189.18 μs", + "memory": "996.96 KB" + } + ], + "comparison": [ + "lua (chunk) 6.74 K", + "lua (eval) 6.55 K - 1.03x slower +4.30 μs", + "luerl 6.48 K - 1.04x slower +6.15 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "medium (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "65.36 K", + "average": "15.30 μs", + "deviation": "±46.45%", + "median": "15 μs", + "p99": "24.63 μs", + "memory": "104.51 KB" + }, + { + "name": "luerl", + "ips": "58.37 K", + "average": "17.13 μs", + "deviation": "±38.71%", + "median": "16.63 μs", + "p99": "27.96 μs", + "memory": "110.78 KB" + }, + { + "name": "lua (eval)", + "ips": "56.97 K", + "average": "17.55 μs", + "deviation": "±21.90%", + "median": "17.21 μs", + "p99": "26.13 μs", + "memory": "115.14 KB" + } + ], + "comparison": [ + "lua (chunk) 65.36 K", + "luerl 58.37 K - 1.12x slower +1.83 μs", + "lua (eval) 56.97 K - 1.15x slower +2.25 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "473.88 K", + "average": "2.11 μs", + "deviation": "±259.11%", + "median": "2.04 μs", + "p99": "3.21 μs", + "memory": "12.89 KB" + }, + { + "name": "luerl", + "ips": "319.03 K", + "average": "3.13 μs", + "deviation": "±185.41%", + "median": "3.04 μs", + "p99": "4.54 μs", + "memory": "22.62 KB" + }, + { + "name": "lua (eval)", + "ips": "234.54 K", + "average": "4.26 μs", + "deviation": "±124.39%", + "median": "4.13 μs", + "p99": "11.13 μs", + "memory": "23.84 KB" + } + ], + "comparison": [ + "lua (chunk) 473.88 K", + "luerl 319.03 K - 1.49x slower +1.02 μs", + "lua (eval) 234.54 K - 2.02x slower +2.15 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + } + }, + "Table Sort": { + "by_input": { + "large (n=1000)": { + "jobs": [ + { + "name": "luerl", + "ips": "5.41 K", + "average": "184.80 μs", + "deviation": "±18.74%", + "median": "179.09 μs", + "p99": "413.57 μs", + "memory": "1.17 MB" + }, + { + "name": "lua (chunk)", + "ips": "4.88 K", + "average": "205.09 μs", + "deviation": "±13.18%", + "median": "199.34 μs", + "p99": "322.32 μs", + "memory": "1.22 MB" + }, + { + "name": "lua (eval)", + "ips": "4.82 K", + "average": "207.48 μs", + "deviation": "±16.82%", + "median": "202.59 μs", + "p99": "278.13 μs", + "memory": "1.24 MB" + } + ], + "comparison": [ + "luerl 5.41 K", + "lua (chunk) 4.88 K - 1.11x slower +20.29 μs", + "lua (eval) 4.82 K - 1.12x slower +22.68 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "medium (n=100)": { + "jobs": [ + { + "name": "luerl", + "ips": "51.19 K", + "average": "19.54 μs", + "deviation": "±19.26%", + "median": "19.04 μs", + "p99": "28.13 μs", + "memory": "133.16 KB" + }, + { + "name": "lua (chunk)", + "ips": "47.57 K", + "average": "21.02 μs", + "deviation": "±75.79%", + "median": "20.38 μs", + "p99": "44.00 μs", + "memory": "129.16 KB" + }, + { + "name": "lua (eval)", + "ips": "43.10 K", + "average": "23.20 μs", + "deviation": "±19.83%", + "median": "22.79 μs", + "p99": "28.67 μs", + "memory": "140.11 KB" + } + ], + "comparison": [ + "luerl 51.19 K", + "lua (chunk) 47.57 K - 1.08x slower +1.49 μs", + "lua (eval) 43.10 K - 1.19x slower +3.67 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "343.00 K", + "average": "2.92 μs", + "deviation": "±179.73%", + "median": "2.88 μs", + "p99": "4.17 μs", + "memory": "16.81 KB" + }, + { + "name": "luerl", + "ips": "267.77 K", + "average": "3.73 μs", + "deviation": "±158.30%", + "median": "3.63 μs", + "p99": "5.67 μs", + "memory": "25.98 KB" + }, + { + "name": "lua (eval)", + "ips": "193.98 K", + "average": "5.16 μs", + "deviation": "±94.77%", + "median": "5.04 μs", + "p99": "8.29 μs", + "memory": "27.75 KB" + } + ], + "comparison": [ + "lua (chunk) 343.00 K", + "luerl 267.77 K - 1.28x slower +0.82 μs", + "lua (eval) 193.98 K - 1.77x slower +2.24 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + } + }, + "Table Iterate/Sum": { + "by_input": { + "large (n=1000)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "4.61 K", + "average": "216.80 μs", + "deviation": "±10.43%", + "median": "212.71 μs", + "p99": "315.74 μs", + "memory": "1.47 MB" + }, + { + "name": "lua (eval)", + "ips": "4.45 K", + "average": "224.56 μs", + "deviation": "±17.51%", + "median": "216.17 μs", + "p99": "421.98 μs", + "memory": "1.48 MB" + }, + { + "name": "luerl", + "ips": "4.01 K", + "average": "249.67 μs", + "deviation": "±8.67%", + "median": "245.63 μs", + "p99": "340.05 μs", + "memory": "1.35 MB" + } + ], + "comparison": [ + "lua (chunk) 4.61 K", + "lua (eval) 4.45 K - 1.04x slower +7.76 μs", + "luerl 4.01 K - 1.15x slower +32.87 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "medium (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "45.22 K", + "average": "22.12 μs", + "deviation": "±15.19%", + "median": "21.75 μs", + "p99": "31.46 μs", + "memory": "152.91 KB" + }, + { + "name": "lua (eval)", + "ips": "40.41 K", + "average": "24.74 μs", + "deviation": "±35.77%", + "median": "24.29 μs", + "p99": "34.58 μs", + "memory": "164.10 KB" + }, + { + "name": "luerl", + "ips": "37.45 K", + "average": "26.70 μs", + "deviation": "±12.27%", + "median": "26.50 μs", + "p99": "36.50 μs", + "memory": "149.13 KB" + } + ], + "comparison": [ + "lua (chunk) 45.22 K", + "lua (eval) 40.41 K - 1.12x slower +2.63 μs", + "luerl 37.45 K - 1.21x slower +4.58 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "335.95 K", + "average": "2.98 μs", + "deviation": "±201.50%", + "median": "2.88 μs", + "p99": "7 μs", + "memory": "18.55 KB" + }, + { + "name": "luerl", + "ips": "220.48 K", + "average": "4.54 μs", + "deviation": "±148.78%", + "median": "4.29 μs", + "p99": "11.21 μs", + "memory": "26.80 KB" + }, + { + "name": "lua (eval)", + "ips": "197.51 K", + "average": "5.06 μs", + "deviation": "±103.13%", + "median": "4.96 μs", + "p99": "12.13 μs", + "memory": "29.64 KB" + } + ], + "comparison": [ + "lua (chunk) 335.95 K", + "luerl 220.48 K - 1.52x slower +1.56 μs", + "lua (eval) 197.51 K - 1.70x slower +2.09 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + } + }, + "Table Map + Reduce": { + "by_input": { + "large (n=1000)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "2.22 K", + "average": "450.65 μs", + "deviation": "±6.37%", + "median": "443.17 μs", + "p99": "596.71 μs", + "memory": "2.91 MB" + }, + { + "name": "luerl", + "ips": "2.20 K", + "average": "454.46 μs", + "deviation": "±6.67%", + "median": "446.63 μs", + "p99": "600.22 μs", + "memory": "2.44 MB" + }, + { + "name": "lua (eval)", + "ips": "2.20 K", + "average": "455.45 μs", + "deviation": "±6.47%", + "median": "447.96 μs", + "p99": "597.34 μs", + "memory": "2.92 MB" + } + ], + "comparison": [ + "lua (chunk) 2.22 K", + "luerl 2.20 K - 1.01x slower +3.81 μs", + "lua (eval) 2.20 K - 1.01x slower +4.80 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "medium (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "22.40 K", + "average": "44.64 μs", + "deviation": "±9.68%", + "median": "44.21 μs", + "p99": "55.09 μs", + "memory": "302.05 KB" + }, + { + "name": "luerl", + "ips": "21.26 K", + "average": "47.05 μs", + "deviation": "±8.41%", + "median": "46.63 μs", + "p99": "56.42 μs", + "memory": "262.49 KB" + }, + { + "name": "lua (eval)", + "ips": "20.96 K", + "average": "47.70 μs", + "deviation": "±17.39%", + "median": "46.71 μs", + "p99": "67.33 μs", + "memory": "313.15 KB" + } + ], + "comparison": [ + "lua (chunk) 22.40 K", + "luerl 21.26 K - 1.05x slower +2.41 μs", + "lua (eval) 20.96 K - 1.07x slower +3.07 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "186.34 K", + "average": "5.37 μs", + "deviation": "±89.27%", + "median": "5.21 μs", + "p99": "12.79 μs", + "memory": "35.40 KB" + }, + { + "name": "luerl", + "ips": "149.86 K", + "average": "6.67 μs", + "deviation": "±89.30%", + "median": "6.58 μs", + "p99": "13.71 μs", + "memory": "39.39 KB" + }, + { + "name": "lua (eval)", + "ips": "132.59 K", + "average": "7.54 μs", + "deviation": "±81.79%", + "median": "7.42 μs", + "p99": "15 μs", + "memory": "46.17 KB" + } + ], + "comparison": [ + "lua (chunk) 186.34 K", + "luerl 149.86 K - 1.24x slower +1.31 μs", + "lua (eval) 132.59 K - 1.41x slower +2.18 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + } + }, + "Table Pairs (hash)": { + "by_input": { + "large (n=1000)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "1.02 K", + "average": "982.50 μs", + "deviation": "±9.43%", + "median": "999.49 μs", + "p99": "1154.53 μs", + "memory": "2.06 MB" + }, + { + "name": "lua (chunk)", + "ips": "1.00 K", + "average": "998.42 μs", + "deviation": "±13.14%", + "median": "1001.12 μs", + "p99": "1477.94 μs", + "memory": "2.05 MB" + }, + { + "name": "luerl", + "ips": "0.74 K", + "average": "1347.55 μs", + "deviation": "±4.60%", + "median": "1349.77 μs", + "p99": "1465.70 μs", + "memory": "2.48 MB" + } + ], + "comparison": [ + "lua (eval) 1.02 K", + "lua (chunk) 1.00 K - 1.02x slower +15.92 μs", + "luerl 0.74 K - 1.37x slower +365.05 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "medium (n=100)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "13.32 K", + "average": "75.07 μs", + "deviation": "±9.83%", + "median": "74.46 μs", + "p99": "96.75 μs", + "memory": "209.77 KB" + }, + { + "name": "lua (eval)", + "ips": "12.97 K", + "average": "77.10 μs", + "deviation": "±11.95%", + "median": "75.33 μs", + "p99": "106.04 μs", + "memory": "221.48 KB" + }, + { + "name": "luerl", + "ips": "11.37 K", + "average": "87.93 μs", + "deviation": "±10.51%", + "median": "86.75 μs", + "p99": "123.60 μs", + "memory": "248.85 KB" + } + ], + "comparison": [ + "lua (chunk) 13.32 K", + "lua (eval) 12.97 K - 1.03x slower +2.04 μs", + "luerl 11.37 K - 1.17x slower +12.86 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "small (n=10)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "134.58 K", + "average": "7.43 μs", + "deviation": "±85.96%", + "median": "7.13 μs", + "p99": "18.33 μs", + "memory": "24.09 KB" + }, + { + "name": "luerl", + "ips": "128.74 K", + "average": "7.77 μs", + "deviation": "±71.32%", + "median": "7.38 μs", + "p99": "23.96 μs", + "memory": "36.16 KB" + }, + { + "name": "lua (eval)", + "ips": "98.68 K", + "average": "10.13 μs", + "deviation": "±86.13%", + "median": "9.54 μs", + "p99": "24.92 μs", + "memory": "34.87 KB" + } + ], + "comparison": [ + "lua (chunk) 134.58 K", + "luerl 128.74 K - 1.05x slower +0.34 μs", + "lua (eval) 98.68 K - 1.36x slower +2.70 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + } + } + }, + "patterns": { + "patterns: find/match field extraction (n=200)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "998.95", + "average": "1.00 ms", + "deviation": "±3.02%", + "median": "1.00 ms", + "p99": "1.06 ms", + "memory": "3.21 MB" + }, + { + "name": "lua (eval)", + "ips": "986.14", + "average": "1.01 ms", + "deviation": "±10.37%", + "median": "1.00 ms", + "p99": "1.36 ms", + "memory": "3.22 MB" + }, + { + "name": "luerl", + "ips": "923.97", + "average": "1.08 ms", + "deviation": "±3.61%", + "median": "1.08 ms", + "p99": "1.18 ms", + "memory": "6.70 MB" + } + ], + "comparison": [ + "lua (chunk) 998.95", + "lua (eval) 986.14 - 1.01x slower +0.0130 ms", + "luerl 923.97 - 1.08x slower +0.0812 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "patterns: find-based tokenizer (n=200)": { + "jobs": [ + { + "name": "luerl", + "ips": "729.19", + "average": "1.37 ms", + "deviation": "±3.06%", + "median": "1.36 ms", + "p99": "1.48 ms", + "memory": "6.65 MB" + }, + { + "name": "lua (chunk)", + "ips": "673.77", + "average": "1.48 ms", + "deviation": "±3.57%", + "median": "1.46 ms", + "p99": "1.61 ms", + "memory": "9.10 MB" + }, + { + "name": "lua (eval)", + "ips": "661.88", + "average": "1.51 ms", + "deviation": "±4.44%", + "median": "1.49 ms", + "p99": "1.76 ms", + "memory": "9.11 MB" + } + ], + "comparison": [ + "luerl 729.19", + "lua (chunk) 673.77 - 1.08x slower +0.113 ms", + "lua (eval) 661.88 - 1.10x slower +0.139 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "patterns: gsub template substitution (n=200)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "513.54", + "average": "1.95 ms", + "deviation": "±2.83%", + "median": "1.95 ms", + "p99": "2.06 ms", + "memory": "4.50 MB" + }, + { + "name": "lua (chunk)", + "ips": "480.12", + "average": "2.08 ms", + "deviation": "±16.12%", + "median": "1.98 ms", + "p99": "3.12 ms", + "memory": "4.49 MB" + }, + { + "name": "luerl", + "ips": "418.97", + "average": "2.39 ms", + "deviation": "±17.21%", + "median": "2.20 ms", + "p99": "3.51 ms", + "memory": "11.75 MB" + } + ], + "comparison": [ + "lua (eval) 513.54", + "lua (chunk) 480.12 - 1.07x slower +0.136 ms", + "luerl 418.97 - 1.23x slower +0.44 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "metamethods": { + "metamethods: self-call method dispatch (n=200)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "2.10 K", + "average": "476.79 μs", + "deviation": "±13.40%", + "median": "462.04 μs", + "p99": "730.43 μs", + "memory": "1.37 MB" + }, + { + "name": "luerl", + "ips": "2.05 K", + "average": "487.93 μs", + "deviation": "±8.87%", + "median": "474.63 μs", + "p99": "657.89 μs", + "memory": "1.43 MB" + }, + { + "name": "lua (chunk)", + "ips": "2.04 K", + "average": "490.34 μs", + "deviation": "±7.16%", + "median": "485.83 μs", + "p99": "642.74 μs", + "memory": "1.36 MB" + } + ], + "comparison": [ + "lua (eval) 2.10 K", + "luerl 2.05 K - 1.02x slower +11.14 μs", + "lua (chunk) 2.04 K - 1.03x slower +13.55 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "metamethods: 3-level __index chain (n=200)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "5.24 K", + "average": "190.75 μs", + "deviation": "±4.32%", + "median": "190.08 μs", + "p99": "206.47 μs", + "memory": "576.76 KB" + }, + { + "name": "lua (eval)", + "ips": "5.09 K", + "average": "196.41 μs", + "deviation": "±6.91%", + "median": "194.58 μs", + "p99": "221.08 μs", + "memory": "587.70 KB" + }, + { + "name": "luerl", + "ips": "4.60 K", + "average": "217.23 μs", + "deviation": "±4.39%", + "median": "216 μs", + "p99": "233.25 μs", + "memory": "668.59 KB" + } + ], + "comparison": [ + "lua (chunk) 5.24 K", + "lua (eval) 5.09 K - 1.03x slower +5.65 μs", + "luerl 4.60 K - 1.14x slower +26.48 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "metamethods: arithmetic/relational metamethods (n=200)": { + "jobs": [ + { + "name": "lua (eval)", + "ips": "533.57", + "average": "1.87 ms", + "deviation": "±5.87%", + "median": "1.92 ms", + "p99": "2.18 ms", + "memory": "5.71 MB" + }, + { + "name": "luerl", + "ips": "528.86", + "average": "1.89 ms", + "deviation": "±5.87%", + "median": "1.85 ms", + "p99": "2.20 ms", + "memory": "6.24 MB" + }, + { + "name": "lua (chunk)", + "ips": "521.21", + "average": "1.92 ms", + "deviation": "±12.56%", + "median": "1.93 ms", + "p99": "3.10 ms", + "memory": "5.70 MB" + } + ], + "comparison": [ + "lua (eval) 533.57", + "luerl 528.86 - 1.01x slower +0.0167 ms", + "lua (chunk) 521.21 - 1.02x slower +0.0444 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "pcall_varargs": { + "call protocol: pcall, success path (n=500)": { + "jobs": [ + { + "name": "lua (chunk)", + "ips": "4.47 K", + "average": "223.70 μs", + "deviation": "±4.04%", + "median": "222.88 μs", + "p99": "241.97 μs", + "memory": "895.93 KB" + }, + { + "name": "lua (eval)", + "ips": "4.44 K", + "average": "225.34 μs", + "deviation": "±6.09%", + "median": "224.42 μs", + "p99": "243.03 μs", + "memory": "911.78 KB" + }, + { + "name": "luerl", + "ips": "3.40 K", + "average": "294.17 μs", + "deviation": "±6.42%", + "median": "291.54 μs", + "p99": "317.97 μs", + "memory": "1137.10 KB" + } + ], + "comparison": [ + "lua (chunk) 4.47 K", + "lua (eval) 4.44 K - 1.01x slower +1.63 μs", + "luerl 3.40 K - 1.31x slower +70.46 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "call protocol: pcall, raise + catch (n=500)": { + "jobs": [ + { + "name": "luerl", + "ips": "2.12 K", + "average": "471.09 μs", + "deviation": "±3.42%", + "median": "468.42 μs", + "p99": "503.21 μs", + "memory": "1.55 MB" + }, + { + "name": "lua (chunk)", + "ips": "1.36 K", + "average": "733.85 μs", + "deviation": "±2.60%", + "median": "727.13 μs", + "p99": "781.15 μs", + "memory": "2.67 MB" + }, + { + "name": "lua (eval)", + "ips": "1.35 K", + "average": "742.90 μs", + "deviation": "±5.35%", + "median": "734.58 μs", + "p99": "824.44 μs", + "memory": "2.68 MB" + } + ], + "comparison": [ + "luerl 2.12 K", + "lua (chunk) 1.36 K - 1.56x slower +262.76 μs", + "lua (eval) 1.35 K - 1.58x slower +271.81 μs" + ], + "memory_note": "**All measurements for memory usage were the same**" + }, + "call protocol: varargs + multiple returns (n=500)": { + "jobs": [ + { + "name": "luerl", + "ips": "409.82", + "average": "2.44 ms", + "deviation": "±4.21%", + "median": "2.41 ms", + "p99": "2.74 ms", + "memory": "8.73 MB" + }, + { + "name": "lua (eval)", + "ips": "306.73", + "average": "3.26 ms", + "deviation": "±1.81%", + "median": "3.27 ms", + "p99": "3.44 ms", + "memory": "16.71 MB" + }, + { + "name": "lua (chunk)", + "ips": "300.00", + "average": "3.33 ms", + "deviation": "±10.97%", + "median": "3.28 ms", + "p99": "5.01 ms", + "memory": "16.70 MB" + } + ], + "comparison": [ + "luerl 409.82", + "lua (eval) 306.73 - 1.34x slower +0.82 ms", + "lua (chunk) 300.00 - 1.37x slower +0.89 ms" + ], + "memory_note": "**All measurements for memory usage were the same**" + } + }, + "vm_new": { + "VM instantiation: Lua.new/1 vs :luerl.init/0": { + "jobs": [ + { + "name": "lua (new, no sandbox)", + "ips": "1.90 M", + "average": "0.53 μs", + "deviation": "±805.41%", + "median": "0.50 μs", + "p99": "0.63 μs", + "memory": "0.95 KB" + }, + { + "name": "lua (new)", + "ips": "1.59 M", + "average": "0.63 μs", + "deviation": "±1209.11%", + "median": "0.58 μs", + "p99": "0.75 μs", + "memory": "0.88 KB" + }, + { + "name": "lua (new, custom exclude)", + "ips": "0.151 M", + "average": "6.63 μs", + "deviation": "±88.13%", + "median": "6.54 μs", + "p99": "8.79 μs", + "memory": "22.57 KB" + }, + { + "name": "luerl (init)", + "ips": "0.0656 M", + "average": "15.24 μs", + "deviation": "±36.68%", + "median": "15.13 μs", + "p99": "22.42 μs", + "memory": "51.64 KB" + } + ], + "comparison": [ + "lua (new, no sandbox) 1.90 M", + "lua (new) 1.59 M - 1.20x slower +0.104 μs", + "lua (new, custom exclude) 0.151 M - 12.61x slower +6.10 μs", + "luerl (init) 0.0656 M - 29.00x slower +14.71 μs" + ], + "memory_note": "**All measurements for memory usage were the same**", + "cold_call": "6892.0 us", + "second_call": "1.0 us" + } + }, + "encode_decode": { + "raw": "lua 1.0.2 — encode!/decode! decomposition\n(decode+deep_cast column: enabled)\n==================================================================================\nop shape N total_us per_elem_ns\nencode int_list 8 0.81 101.5\ndecode int_list 8 0.29 35.9\ndec+cast int_list 8 0.33 40.9\nencode int_list 64 14.59 228.0\ndecode int_list 64 4.94 77.1\ndec+cast int_list 64 7.11 111.0\nencode int_list 512 158.65 309.9\ndecode int_list 512 47.04 91.9\ndec+cast int_list 512 72.78 142.1\nencode int_list 4096 1568.66 383.0\ndecode int_list 4096 565.55 138.1\ndec+cast int_list 4096 892.14 217.8\n----------------------------------------------------------------------------------\nencode float_list 8 0.78 98.0\ndecode float_list 8 0.25 31.8\ndec+cast float_list 8 0.27 33.6\nencode float_list 64 11.77 183.9\ndecode float_list 64 3.76 58.7\ndec+cast float_list 64 6.99 109.2\nencode float_list 512 162.64 317.6\ndecode float_list 512 47.30 92.4\ndec+cast float_list 512 75.86 148.2\nencode float_list 4096 1614.23 394.1\ndecode float_list 4096 547.77 133.7\ndec+cast float_list 4096 805.73 196.7\n----------------------------------------------------------------------------------\nencode bool_list 8 0.79 99.1\ndecode bool_list 8 0.24 29.9\ndec+cast bool_list 8 0.27 33.9\nencode bool_list 64 14.65 228.8\ndecode bool_list 64 4.69 73.3\ndec+cast bool_list 64 6.88 107.4\nencode bool_list 512 158.50 309.6\ndecode bool_list 512 45.36 88.6\ndec+cast bool_list 512 71.69 140.0\nencode bool_list 4096 1572.86 384.0\ndecode bool_list 4096 581.14 141.9\ndec+cast bool_list 4096 792.78 193.6\n----------------------------------------------------------------------------------\nencode short_string_list 8 0.78 97.8\ndecode short_string_list 8 0.24 30.0\ndec+cast short_string_list 8 0.27 34.2\nencode short_string_list 64 11.49 179.6\ndecode short_string_list 64 3.51 54.9\ndec+cast short_string_list 64 6.93 108.3\nencode short_string_list 512 162.32 317.0\ndecode short_string_list 512 48.13 94.0\ndec+cast short_string_list 512 74.26 145.0\nencode short_string_list 4096 1664.81 406.4\ndecode short_string_list 4096 550.34 134.4\ndec+cast short_string_list 4096 810.06 197.8\n----------------------------------------------------------------------------------\nencode long_string_list 8 0.79 98.9\ndecode long_string_list 8 0.25 31.1\ndec+cast long_string_list 8 0.26 33.0\nencode long_string_list 64 11.67 182.4\ndecode long_string_list 64 3.66 57.3\ndec+cast long_string_list 64 6.75 105.5\nencode long_string_list 512 169.63 331.3\ndecode long_string_list 512 47.95 93.7\ndec+cast long_string_list 512 75.67 147.8\nencode long_string_list 4096 2667.06 651.1\ndecode long_string_list 4096 1339.42 327.0\ndec+cast long_string_list 4096 1705.52 416.4\n----------------------------------------------------------------------------------\nencode string_map 8 0.72 89.9\ndecode string_map 8 0.11 13.2\ndec+cast string_map 8 0.21 26.1\nencode string_map 64 12.18 190.3\ndecode string_map 64 0.74 11.6\ndec+cast string_map 64 3.58 56.0\nencode string_map 512 103.35 201.9\ndecode string_map 512 9.32 18.2\ndec+cast string_map 512 39.57 77.3\nencode string_map 4096 1046.22 255.4\ndecode string_map 4096 79.30 19.4\ndec+cast string_map 4096 343.00 83.7\n----------------------------------------------------------------------------------\nencode int_map 8 0.81 100.9\ndecode int_map 8 0.25 31.6\ndec+cast int_map 8 0.27 33.9\nencode int_map 64 11.36 177.6\ndecode int_map 64 3.45 54.0\ndec+cast int_map 64 5.64 88.1\nencode int_map 512 111.00 216.8\ndecode int_map 512 32.55 63.6\ndec+cast int_map 512 72.18 141.0\nencode int_map 4096 1503.89 367.2\ndecode int_map 4096 599.78 146.4\ndec+cast int_map 4096 850.84 207.7\n----------------------------------------------------------------------------------\nencode record_list 8 3.10 387.3\ndecode record_list 8 0.89 111.3\ndec+cast record_list 8 1.60 199.4\nencode record_list 64 45.68 713.8\ndecode record_list 64 12.94 202.3\ndec+cast record_list 64 18.97 296.4\nencode record_list 512 422.71 825.6\ndecode record_list 512 115.71 226.0\ndec+cast record_list 512 154.32 301.4\nencode record_list 4096 3814.88 931.4\ndecode record_list 4096 1139.67 278.2\ndec+cast record_list 4096 1656.06 404.3\n----------------------------------------------------------------------------------\nnested chain (depth sweep) — isolates recursion/traversal from fan-out\nop shape N total_us per_elem_ns\nencode nested_chain 4 0.68 338.0\ndecode nested_chain 4 0.24 122.2\ndec+cast nested_chain 4 0.36 179.7\nencode nested_chain 16 2.77 1387.2\ndecode nested_chain 16 1.11 552.9\ndec+cast nested_chain 16 1.54 768.1\nencode nested_chain 64 10.94 5468.5\ndecode nested_chain 64 6.00 3002.0\ndec+cast nested_chain 64 8.37 4183.6\nencode nested_chain 256 44.55 22273.9\ndecode nested_chain 256 28.00 13998.0\ndec+cast nested_chain 256 37.24 18618.2\n==================================================================================\ncomposite anchor — the PR's `original_nested` (matches the 18us/108us figure)\nop shape N total_us per_elem_ns\nencode original_nested 75 12.26 3065.6\ndecode original_nested 75 3.29 821.3\ndec+cast original_nested 75 5.66 1413.8\n" + } +} diff --git a/bench_results/v1.0.2/table_ops.txt b/bench_results/v1.0.2/table_ops.txt new file mode 100644 index 00000000..d6f11809 --- /dev/null +++ b/bench_results/v1.0.2/table_ops.txt @@ -0,0 +1,461 @@ +luaport not available ({:luaport, {~c"no such file or directory", ~c"luaport.app"}}) — skipping C Lua benchmarks + +=== Table Build (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +lua (chunk) 6.74 K 148.28 μs ±10.00% 145.92 μs 180.79 μs +lua (eval) 6.55 K 152.58 μs ±18.06% 148.88 μs 259.21 μs +luerl 6.48 K 154.42 μs ±9.20% 152.71 μs 189.18 μs + +Comparison: +lua (chunk) 6.74 K +lua (eval) 6.55 K - 1.03x slower +4.30 μs +luerl 6.48 K - 1.04x slower +6.15 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 1023.05 KB +lua (eval) 1033.39 KB - 1.01x memory usage +10.34 KB +luerl 996.96 KB - 0.97x memory usage -26.09375 KB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +lua (chunk) 65.36 K 15.30 μs ±46.45% 15 μs 24.63 μs +luerl 58.37 K 17.13 μs ±38.71% 16.63 μs 27.96 μs +lua (eval) 56.97 K 17.55 μs ±21.90% 17.21 μs 26.13 μs + +Comparison: +lua (chunk) 65.36 K +luerl 58.37 K - 1.12x slower +1.83 μs +lua (eval) 56.97 K - 1.15x slower +2.25 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 104.51 KB +luerl 110.78 KB - 1.06x memory usage +6.27 KB +lua (eval) 115.14 KB - 1.10x memory usage +10.63 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 473.88 K 2.11 μs ±259.11% 2.04 μs 3.21 μs +luerl 319.03 K 3.13 μs ±185.41% 3.04 μs 4.54 μs +lua (eval) 234.54 K 4.26 μs ±124.39% 4.13 μs 11.13 μs + +Comparison: +lua (chunk) 473.88 K +luerl 319.03 K - 1.49x slower +1.02 μs +lua (eval) 234.54 K - 2.02x slower +2.15 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 12.89 KB +luerl 22.62 KB - 1.75x memory usage +9.73 KB +lua (eval) 23.84 KB - 1.85x memory usage +10.95 KB + +**All measurements for memory usage were the same** + +=== Table Sort (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +luerl 5.41 K 184.80 μs ±18.74% 179.09 μs 413.57 μs +lua (chunk) 4.88 K 205.09 μs ±13.18% 199.34 μs 322.32 μs +lua (eval) 4.82 K 207.48 μs ±16.82% 202.59 μs 278.13 μs + +Comparison: +luerl 5.41 K +lua (chunk) 4.88 K - 1.11x slower +20.29 μs +lua (eval) 4.82 K - 1.12x slower +22.68 μs + +Memory usage statistics: + +Name Memory usage +luerl 1.17 MB +lua (chunk) 1.22 MB - 1.04x memory usage +0.0500 MB +lua (eval) 1.24 MB - 1.05x memory usage +0.0602 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +luerl 51.19 K 19.54 μs ±19.26% 19.04 μs 28.13 μs +lua (chunk) 47.57 K 21.02 μs ±75.79% 20.38 μs 44.00 μs +lua (eval) 43.10 K 23.20 μs ±19.83% 22.79 μs 28.67 μs + +Comparison: +luerl 51.19 K +lua (chunk) 47.57 K - 1.08x slower +1.49 μs +lua (eval) 43.10 K - 1.19x slower +3.67 μs + +Memory usage statistics: + +Name Memory usage +luerl 133.16 KB +lua (chunk) 129.16 KB - 0.97x memory usage -3.99219 KB +lua (eval) 140.11 KB - 1.05x memory usage +6.95 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 343.00 K 2.92 μs ±179.73% 2.88 μs 4.17 μs +luerl 267.77 K 3.73 μs ±158.30% 3.63 μs 5.67 μs +lua (eval) 193.98 K 5.16 μs ±94.77% 5.04 μs 8.29 μs + +Comparison: +lua (chunk) 343.00 K +luerl 267.77 K - 1.28x slower +0.82 μs +lua (eval) 193.98 K - 1.77x slower +2.24 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 16.81 KB +luerl 25.98 KB - 1.55x memory usage +9.16 KB +lua (eval) 27.75 KB - 1.65x memory usage +10.94 KB + +**All measurements for memory usage were the same** + +=== Table Iterate/Sum (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +lua (chunk) 4.61 K 216.80 μs ±10.43% 212.71 μs 315.74 μs +lua (eval) 4.45 K 224.56 μs ±17.51% 216.17 μs 421.98 μs +luerl 4.01 K 249.67 μs ±8.67% 245.63 μs 340.05 μs + +Comparison: +lua (chunk) 4.61 K +lua (eval) 4.45 K - 1.04x slower +7.76 μs +luerl 4.01 K - 1.15x slower +32.87 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 1.47 MB +lua (eval) 1.48 MB - 1.01x memory usage +0.0119 MB +luerl 1.35 MB - 0.91x memory usage -0.12543 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +lua (chunk) 45.22 K 22.12 μs ±15.19% 21.75 μs 31.46 μs +lua (eval) 40.41 K 24.74 μs ±35.77% 24.29 μs 34.58 μs +luerl 37.45 K 26.70 μs ±12.27% 26.50 μs 36.50 μs + +Comparison: +lua (chunk) 45.22 K +lua (eval) 40.41 K - 1.12x slower +2.63 μs +luerl 37.45 K - 1.21x slower +4.58 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 152.91 KB +lua (eval) 164.10 KB - 1.07x memory usage +11.20 KB +luerl 149.13 KB - 0.98x memory usage -3.78125 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 335.95 K 2.98 μs ±201.50% 2.88 μs 7 μs +luerl 220.48 K 4.54 μs ±148.78% 4.29 μs 11.21 μs +lua (eval) 197.51 K 5.06 μs ±103.13% 4.96 μs 12.13 μs + +Comparison: +lua (chunk) 335.95 K +luerl 220.48 K - 1.52x slower +1.56 μs +lua (eval) 197.51 K - 1.70x slower +2.09 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 18.55 KB +luerl 26.80 KB - 1.44x memory usage +8.25 KB +lua (eval) 29.64 KB - 1.60x memory usage +11.09 KB + +**All measurements for memory usage were the same** + +=== Table Map + Reduce (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +lua (chunk) 2.22 K 450.65 μs ±6.37% 443.17 μs 596.71 μs +luerl 2.20 K 454.46 μs ±6.67% 446.63 μs 600.22 μs +lua (eval) 2.20 K 455.45 μs ±6.47% 447.96 μs 597.34 μs + +Comparison: +lua (chunk) 2.22 K +luerl 2.20 K - 1.01x slower +3.81 μs +lua (eval) 2.20 K - 1.01x slower +4.80 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 2.91 MB +luerl 2.44 MB - 0.84x memory usage -0.46843 MB +lua (eval) 2.92 MB - 1.00x memory usage +0.0121 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +lua (chunk) 22.40 K 44.64 μs ±9.68% 44.21 μs 55.09 μs +luerl 21.26 K 47.05 μs ±8.41% 46.63 μs 56.42 μs +lua (eval) 20.96 K 47.70 μs ±17.39% 46.71 μs 67.33 μs + +Comparison: +lua (chunk) 22.40 K +luerl 21.26 K - 1.05x slower +2.41 μs +lua (eval) 20.96 K - 1.07x slower +3.07 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 302.05 KB +luerl 262.49 KB - 0.87x memory usage -39.55469 KB +lua (eval) 313.15 KB - 1.04x memory usage +11.10 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 186.34 K 5.37 μs ±89.27% 5.21 μs 12.79 μs +luerl 149.86 K 6.67 μs ±89.30% 6.58 μs 13.71 μs +lua (eval) 132.59 K 7.54 μs ±81.79% 7.42 μs 15 μs + +Comparison: +lua (chunk) 186.34 K +luerl 149.86 K - 1.24x slower +1.31 μs +lua (eval) 132.59 K - 1.41x slower +2.18 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 35.40 KB +luerl 39.39 KB - 1.11x memory usage +3.99 KB +lua (eval) 46.17 KB - 1.30x memory usage +10.77 KB + +**All measurements for memory usage were the same** + +=== Table Pairs (hash) (mode: full) === + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: large (n=1000), medium (n=100), small (n=10) +Estimated total run time: 1 min 57 s +Excluding outliers: false + +Benchmarking lua (chunk) with input large (n=1000) ... +Benchmarking lua (chunk) with input medium (n=100) ... +Benchmarking lua (chunk) with input small (n=10) ... +Benchmarking lua (eval) with input large (n=1000) ... +Benchmarking lua (eval) with input medium (n=100) ... +Benchmarking lua (eval) with input small (n=10) ... +Benchmarking luerl with input large (n=1000) ... +Benchmarking luerl with input medium (n=100) ... +Benchmarking luerl with input small (n=10) ... +Calculating statistics... +Formatting results... + +##### With input large (n=1000) ##### +Name ips average deviation median 99th % +lua (eval) 1.02 K 982.50 μs ±9.43% 999.49 μs 1154.53 μs +lua (chunk) 1.00 K 998.42 μs ±13.14% 1001.12 μs 1477.94 μs +luerl 0.74 K 1347.55 μs ±4.60% 1349.77 μs 1465.70 μs + +Comparison: +lua (eval) 1.02 K +lua (chunk) 1.00 K - 1.02x slower +15.92 μs +luerl 0.74 K - 1.37x slower +365.05 μs + +Memory usage statistics: + +Name Memory usage +lua (eval) 2.06 MB +lua (chunk) 2.05 MB - 0.99x memory usage -0.01048 MB +luerl 2.48 MB - 1.20x memory usage +0.42 MB + +**All measurements for memory usage were the same** + +##### With input medium (n=100) ##### +Name ips average deviation median 99th % +lua (chunk) 13.32 K 75.07 μs ±9.83% 74.46 μs 96.75 μs +lua (eval) 12.97 K 77.10 μs ±11.95% 75.33 μs 106.04 μs +luerl 11.37 K 87.93 μs ±10.51% 86.75 μs 123.60 μs + +Comparison: +lua (chunk) 13.32 K +lua (eval) 12.97 K - 1.03x slower +2.04 μs +luerl 11.37 K - 1.17x slower +12.86 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 209.77 KB +lua (eval) 221.48 KB - 1.06x memory usage +11.72 KB +luerl 248.85 KB - 1.19x memory usage +39.09 KB + +**All measurements for memory usage were the same** + +##### With input small (n=10) ##### +Name ips average deviation median 99th % +lua (chunk) 134.58 K 7.43 μs ±85.96% 7.13 μs 18.33 μs +luerl 128.74 K 7.77 μs ±71.32% 7.38 μs 23.96 μs +lua (eval) 98.68 K 10.13 μs ±86.13% 9.54 μs 24.92 μs + +Comparison: +lua (chunk) 134.58 K +luerl 128.74 K - 1.05x slower +0.34 μs +lua (eval) 98.68 K - 1.36x slower +2.70 μs + +Memory usage statistics: + +Name Memory usage +lua (chunk) 24.09 KB +luerl 36.16 KB - 1.50x memory usage +12.06 KB +lua (eval) 34.87 KB - 1.45x memory usage +10.77 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/v1.0.2/timestamp.txt b/bench_results/v1.0.2/timestamp.txt new file mode 100644 index 00000000..2a012924 --- /dev/null +++ b/bench_results/v1.0.2/timestamp.txt @@ -0,0 +1 @@ +Tue Jul 28 11:14:31 EDT 2026 diff --git a/bench_results/v1.0.2/versions.txt b/bench_results/v1.0.2/versions.txt new file mode 100644 index 00000000..184e5f9a --- /dev/null +++ b/bench_results/v1.0.2/versions.txt @@ -0,0 +1,3 @@ +Erlang/OTP 29 [erts-17.0] [source] [64-bit] [smp:10:10] [ds:10:10:10] [async-threads:1] [jit] + +Elixir 1.20.0 (compiled with Erlang/OTP 29) diff --git a/bench_results/v1.0.2/vm_new.txt b/bench_results/v1.0.2/vm_new.txt new file mode 100644 index 00000000..fb12a496 --- /dev/null +++ b/bench_results/v1.0.2/vm_new.txt @@ -0,0 +1,56 @@ +=== VM instantiation: Lua.new/1 vs :luerl.init/0 (mode: full) === + +Lua.new() one-time vs repeat cost (single samples, informational): + first call on this node : 6892.0 us + second call : 1.0 us + +The first figure includes any one-time template build and first-time module +loading. Benchee's steady-state numbers below are the per-request cost after +that point. + +Operating System: macOS +CPU Information: Apple M4 +Number of Available Cores: 10 +Available memory: 32 GB +Elixir 1.20.0 +Erlang 29.0 +JIT enabled: true + +Benchmark suite executing with the following configuration: +warmup: 2 s +time: 10 s +memory time: 1 s +reduction time: 0 ns +parallel: 1 +inputs: none specified +Estimated total run time: 52 s +Excluding outliers: false + +Benchmarking lua (new) ... +Benchmarking lua (new, custom exclude) ... +Benchmarking lua (new, no sandbox) ... +Benchmarking luerl (init) ... +Calculating statistics... +Formatting results... + +Name ips average deviation median 99th % +lua (new, no sandbox) 1.90 M 0.53 μs ±805.41% 0.50 μs 0.63 μs +lua (new) 1.59 M 0.63 μs ±1209.11% 0.58 μs 0.75 μs +lua (new, custom exclude) 0.151 M 6.63 μs ±88.13% 6.54 μs 8.79 μs +luerl (init) 0.0656 M 15.24 μs ±36.68% 15.13 μs 22.42 μs + +Comparison: +lua (new, no sandbox) 1.90 M +lua (new) 1.59 M - 1.20x slower +0.104 μs +lua (new, custom exclude) 0.151 M - 12.61x slower +6.10 μs +luerl (init) 0.0656 M - 29.00x slower +14.71 μs + +Memory usage statistics: + +Name Memory usage +lua (new, no sandbox) 0.95 KB +lua (new) 0.88 KB - 0.92x memory usage -0.07813 KB +lua (new, custom exclude) 22.57 KB - 23.68x memory usage +21.62 KB +luerl (init) 51.64 KB - 54.18x memory usage +50.69 KB + +**All measurements for memory usage were the same** diff --git a/bench_results/versions-2026-07-28.md b/bench_results/versions-2026-07-28.md new file mode 100644 index 00000000..e04a1b66 --- /dev/null +++ b/bench_results/versions-2026-07-28.md @@ -0,0 +1,574 @@ +# Cross-version benchmarks — v0.4.0 vs v1.0.0 vs 1.0.2 + +Recorded 2026-07-28 for the comparative perf run tracked in +[#267](https://github.com/tv-labs/lua/issues/267). + +This report **supersedes the 1.0.0-era figures in +[`benchmarks/BASELINE.md`](../benchmarks/BASELINE.md)**. That file remains in +the tree as the historical record of the 1.0.0 perf gate; every number in it +predates the call-convention, peephole and bootstrap work that landed after +1.0.0, and several of its conclusions no longer hold (notably its "every +workload is within 25% of Luerl" gate statement — see +[Where we are still behind](#where-we-are-still-behind)). Use this document, +not `BASELINE.md`, for current numbers. + +## What is being compared + +Three points in this library's history, each measured in its own process, on +the same machine, in the same sitting: + +| Column | Ref | What it is | +|---|---|---| +| **v0.4.0** | tag `v0.4.0` | The last release before the native VM. `Lua` was a thin Elixir wrapper over [Luerl](https://github.com/rvirding/luerl) 1.5.1 — parsing, evaluation and the whole standard library were Luerl's. | +| **v1.0.0** | tag `v1.0.0`, commit `69e13a6` | The first release running this project's own Lua 5.3 VM: own lexer, parser, compiler, bytecode dispatcher and standard library. No Luerl on the execution path. | +| **1.0.2** | `main`, commit `3a0d392` | Current release. Adds the peephole/fused-opcode pass ([#403](https://github.com/tv-labs/lua/pull/403)), the one-allocation register file and static-arity call convention ([#405](https://github.com/tv-labs/lua/pull/405)), and the memoized boot template ([#398](https://github.com/tv-labs/lua/pull/398)). | + +### Why Luerl appears in every table + +Every benchmark script runs a Luerl 1.5.1 job **in the same process, in the +same Benchee run, against the same Lua source** as the `lua` jobs. Luerl is +therefore not a competitor here so much as a **control**: it is the one thing +held constant across all three refs, so it absorbs machine drift, thermal +state, and OTP-level variation. When a ratio moves between columns, the Luerl +control tells you whether the library moved or the machine did. + +The control held very steady. Across the three independent runs, Luerl's +`:luerl.init()` median was 15.04 µs / 15.21 µs / 15.13 µs, and its +`string.format` many-specifier median was 2.80 ms / 2.82 ms / 2.78 ms — under +1% spread. Where the control *did* drift, +it is called out inline. + +Ratios below are always **`lua (chunk)` median ÷ same-run `luerl` median**. +Lower is better; **below 1.00 means faster than Luerl in that same run**. + +### Environment + +- Apple M4 (arm64), Elixir 1.20.0, Erlang/OTP 29 [erts-17.0] [64-bit] [jit] +- `LUA_BENCH_MODE=full` (2 s warmup, 10 s measurement, memory measurement on, + and the multi-size sweep for the table workloads) +- Comparison control: Luerl `~> 1.5` (1.5.1) +- C Lua via `:luaport` was **not** available in this environment; each script's + own fallback path skipped it. There are no C Lua rows anywhere in this report. + +### Measurement discipline + +Every workload was run **serially, one `mix run` process at a time, on an +otherwise quiet machine**. This is not optional. Running these concurrently +with tests or other agents inflates deviation to the point where the table and +OOP cases swing wildly and orderings flip. All figures below are the +quiet-machine read. + +**Medians are quoted as primary throughout**, not averages. On workloads with +allocation-driven GC pauses the mean is pulled around by a small number of +long iterations; the median is the number an embedding host actually +experiences per call. Averages, p99s, deviations and memory figures are all in +the `summary.json` files if you want them. + +--- + +## Numeric code: recursion and call overhead + +`fibonacci` computes `fib(30)` recursively through a global function — roughly +2.7 million calls per iteration, each one a small integer compare, two +recursive calls and an add. It is deliberately the worst case for call +overhead: almost nothing happens between calls, so the measurement is +dominated by frame setup, argument passing and return. This is the workload +the static-arity call convention and one-allocation register file +([#405](https://github.com/tv-labs/lua/pull/405)) were built for. + +| Ref | lua (chunk) median | ips | luerl median (same run) | ratio | +|---|---|---|---|---| +| v0.4.0 | 702.84 ms | 1.42 | 720.40 ms | 0.98 | +| v1.0.0 | 792.42 ms | 1.26 | 730.11 ms | 1.09 | +| **1.0.2** | **434.12 ms** | **2.30** | 741.95 ms | **0.59** | + +1.0.0 shipped this workload 9% *slower* than the Luerl control. 1.0.2 runs it +**1.7× faster than Luerl** and 1.83× faster than 1.0.0. Allocation moved with +it: 2.90 GB per iteration on 1.0.0 → 1016.63 MB on 1.0.2, against Luerl's +2513.67 MB in the same run. + +`lua (eval)` is within 1% of `lua (chunk)` here (429.30 ms vs 434.12 ms) — at +this workload size the one-time parse is invisible next to 400 ms of +execution. + +## Closures and upvalues + +`closures` builds 100 counter closures through a factory function, each +capturing and mutating its own upvalue, then calls each one ten times. It +exercises closure allocation, upvalue capture, and mutation of a captured +local through a closure boundary — the machinery `BASELINE.md` flagged as the +weakest area at 1.0.0. + +| Ref | lua (chunk) median | ips | luerl median (same run) | ratio | +|---|---|---|---|---| +| v0.4.0 | 368.92 µs | 2.69 K | 391.42 µs | 0.94 | +| v1.0.0 | 473.54 µs | 2.09 K | 382.50 µs | 1.24 | +| **1.0.2** | **373.71 µs** | **2.63 K** | 389.21 µs | **0.96** | + +This is the clearest single vindication of the post-1.0 work: the 1.25× gap +`BASELINE.md` recorded as "at the bar" reproduces exactly (1.24× here) and is +now closed — 1.0.2 is marginally faster than the control and 1.27× faster than +1.0.0. + +## OOP via metatables + +Two workloads cover this. `oop` is the shallow, construction-dominated end: 50 +instances per iteration, each two field writes plus a `setmetatable`, then one +field-style method call whose body concatenates two fields. `metamethods` +covers dispatch: `:` self-calls (including a chained call on a freshly +constructed receiver), a three-level `__index` prototype chain resolving at +depths 1/2/3, and arithmetic/relational metamethods (`__add`, `__sub`, `__lt`, +`__eq`, `__tostring`) which route through the arithmetic opcodes' metamethod +fallback rather than through table indexing. + +| Case | Ref | lua (chunk) median | ips | luerl median | ratio | +|---|---|---|---|---|---| +| oop (construction) | v0.4.0 | 110.88 µs | 8.80 K | 106.50 µs | 1.04 | +| | v1.0.0 | 123.50 µs | 7.88 K | 116.13 µs | 1.06 | +| | **1.0.2** | **93.21 µs** | **10.47 K** | 133.50 µs | **0.70** | +| self-call dispatch | v0.4.0 | 476.58 µs | 2.04 K | 492.79 µs | 0.97 | +| | v1.0.0 | 553.75 µs | 1.75 K | 472.63 µs | 1.17 | +| | **1.0.2** | **485.83 µs** | **2.04 K** | 474.63 µs | **1.02** | +| 3-level `__index` chain | v0.4.0 | 216.42 µs | 4.57 K | 217.92 µs | 0.99 | +| | v1.0.0 | 237.13 µs | 4.19 K | 221.29 µs | 1.07 | +| | **1.0.2** | **190.08 µs** | **5.24 K** | 216 µs | **0.88** | +| arithmetic metamethods | v0.4.0 | 1.88 ms | 513.47 | 1.88 ms | 1.00 | +| | v1.0.0 | 2.23 ms | 455.21 | 1.88 ms | 1.19 | +| | **1.0.2** | **1.93 ms** | **521.21** | 1.85 ms | **1.04** | + +Prototype-chain walking is now 12% faster than the control and self-call +dispatch has essentially reached parity (1.02×) from 1.17× at 1.0.0 — the +`call_self` fusion in [#405](https://github.com/tv-labs/lua/pull/405) doing its +job. Arithmetic metamethods improved from 1.19× to 1.04× but have not quite +closed. + +**Control drift warning:** the `oop` Luerl median moved 106.50 → 116.13 → +133.50 µs across the three runs (deviations ±13.7% to ±21.4%), a 25% spread on +the one job that should be identical. `oop`'s ratio column is the least +trustworthy in this report; the raw `lua (chunk)` medians (110.88 → 123.50 → +93.21 µs) tell the story more reliably than the ratios do. + +## Strings: building and formatting + +`string_ops` covers string building: `table.concat` over 100 parts, and +`string.format("item_%d=%f")` in a 100-iteration loop. `string_format` pushes +the formatter on three separate axes at n=1000: a long literal-heavy format +string (~430 characters of literal text around three specifiers, so the cost +is copying literal bytes), many width-flagged specifiers (`%-20s`, `%8d`, +`%12.4f`, `%6x` — the padding path on every conversion), and a dozen +specifiers interleaved with short literals (the conversion-heavy counterpart). + +| Case | Ref | lua (chunk) median | ips | luerl median | ratio | +|---|---|---|---|---|---| +| `table.concat` (n=100) | v0.4.0 | 38.38 µs | 25.94 K | 39.42 µs | 0.97 | +| | v1.0.0 | 35.67 µs | 27.97 K | 40.17 µs | 0.89 | +| | **1.0.2** | **31.21 µs** | **31.46 K** | 40.17 µs | **0.78** | +| `string.format` loop (n=100) | v0.4.0 | 101.46 µs | 9.75 K | 102.83 µs | 0.99 | +| | v1.0.0 | 80.21 µs | 12.44 K | 103.38 µs | 0.78 | +| | **1.0.2** | **72.21 µs** | **14.08 K** | 103.04 µs | **0.70** | +| format: long literal-heavy | v0.4.0 | 3.91 ms | 249.24 | 4.25 ms | 0.92 | +| | v1.0.0 | 1.03 ms | 966.41 | 4.32 ms | 0.24 | +| | **1.0.2** | **716.04 µs** | **1.38 K** | 3921.50 µs | **0.18** | +| format: width-flagged | v0.4.0 | 1.72 ms | 582.97 | 1.73 ms | 0.99 | +| | v1.0.0 | 1.50 ms | 662.60 | 1.79 ms | 0.84 | +| | **1.0.2** | **1.22 ms** | **812.70** | 1.74 ms | **0.70** | +| format: many specifiers | v0.4.0 | 2.75 ms | 367.47 | 2.80 ms | 0.98 | +| | v1.0.0 | 2.35 ms | 413.90 | 2.82 ms | 0.83 | +| | **1.0.2** | **1.77 ms** | **566.54** | 2.78 ms | **0.64** | + +`string.format` is this library's strongest area. The literal-heavy case runs +**5.5× faster than Luerl** (716.04 µs vs 3921.50 µs) and allocates 2.03 MB +against Luerl's 22.59 MB — an 11× allocation reduction — thanks to the +bare-specifier fast path, the exact bignum fixed-precision float formatter, and +the parsed-template cache. Every string case improved monotonically across all +three versions. + +## Tables: array and hash parts + +`table_ops` covers five operations, swept over n=10/100/1000 in full mode: +building an array of squares, sorting a reverse-ordered array (worst case), +summing by index, a two-pass map-then-reduce, and a full `pairs` walk over a +**string-keyed** table, which forces every entry into the hash part and drives +the memoized hash-iteration path. + +The **n=1000** column is quoted below. The n=10 and n=100 cells are in the +JSON but carry deviations of ±20–46% at 2–25 µs, which is not enough signal to +publish a ratio from; one such cell (v0.4.0 build at n=100) reads 1.60× purely +from noise, on a ref where the `lua` and `luerl` rows are the same engine. + +| Operation (n=1000) | Ref | lua (chunk) median | ips | luerl median | ratio | +|---|---|---|---|---|---| +| build | v0.4.0 | 149.92 µs | 6.35 K | 152.33 µs | 0.98 | +| | v1.0.0 | 157.88 µs | 6.23 K | 154.21 µs | 1.02 | +| | **1.0.2** | **145.92 µs** | **6.74 K** | 152.71 µs | **0.96** | +| sort | v0.4.0 | 174.29 µs | 5.61 K | 180.59 µs | 0.97 | +| | v1.0.0 | 227.63 µs | 4.27 K | 178.00 µs | 1.28 | +| | **1.0.2** | **199.34 µs** | **4.88 K** | 179.09 µs | **1.11** | +| iterate / sum | v0.4.0 | 242.42 µs | 3.60 K | 243.13 µs | 1.00 | +| | v1.0.0 | 235.09 µs | 4.10 K | 243.06 µs | 0.97 | +| | **1.0.2** | **212.71 µs** | **4.61 K** | 245.63 µs | **0.87** | +| map + reduce | v0.4.0 | 444.34 µs | 2.19 K | 442.21 µs | 1.00 | +| | v1.0.0 | 480.24 µs | 2.05 K | 449.96 µs | 1.07 | +| | **1.0.2** | **443.17 µs** | **2.22 K** | 446.63 µs | **0.99** | +| `pairs` (hash part) | v0.4.0 | 1.32 ms | 759.91 | 1.33 ms | 0.99 | +| | v1.0.0 | 995.70 µs | 1.02 K | 1334.35 µs | 0.75 | +| | **1.0.2** | **1001.12 µs** | **1.00 K** | 1349.77 µs | **0.74** | + +Hash-part iteration is the standout: **1.35× faster than Luerl** at n=1000, +where the memoized order-index makes each `next` step O(1) instead of rescanning +an order list. `table.sort` is the one table operation still behind (1.11×, +improved from 1.28× at 1.0.0) — the array part is rebuilt with a single +`:array.from_list/2` write-back, but the comparison-driven sort itself remains +more expensive than Luerl's. + +## String patterns + +`patterns` exercises the Lua pattern engine, which had no coverage at all +before this report. Three cases: field extraction from a log line with +`string.find` and `string.match` (character classes, quantifiers, escaped magic +characters, captures); a tokenizer that re-enters `string.find(s, "[^,]+", pos)` +at an advancing init offset; and template substitution using all three `gsub` +replacement kinds — a table replacement driven by a capture, a string +replacement over `%s+`, and a function replacement invoked per word. + +The tokenizer deliberately avoids `string.gmatch`. Luerl 1.5.1 raises +`{:badarg, :gmatch, ...}` for any `gmatch` call, so a `gmatch`-based tokenizer +could not be measured against the control on identical Lua source, and keeping +the source identical across engines is this suite's fairness contract. + +| Case | Ref | lua (chunk) median | ips | luerl median | ratio | +|---|---|---|---|---|---| +| find/match extraction | v0.4.0 | 1.07 ms | 919.82 | 1.05 ms | 1.02 | +| | v1.0.0 | 1.01 ms | 972.71 | 1.09 ms | 0.93 | +| | **1.0.2** | **1.00 ms** | **998.95** | 1.08 ms | **0.93** | +| find-based tokenizer | v0.4.0 | 1.37 ms | 721.24 | 1.36 ms | 1.01 | +| | v1.0.0 | 1.57 ms | 626.48 | 1.35 ms | 1.16 | +| | **1.0.2** | **1.46 ms** | **673.77** | 1.36 ms | **1.07** | +| gsub template substitution | v0.4.0 | 2.21 ms | 436.67 | 2.21 ms | 1.00 | +| | v1.0.0 | 2.02 ms | 491.94 | 2.17 ms | 0.93 | +| | **1.0.2** | **1.98 ms** | **480.12** | 2.20 ms | **0.90** | + +Extraction and `gsub` are 7–10% faster than the control. The tokenizer is 7% +behind: it restarts the matcher once per token with a fresh start position, and +that per-restart setup is where the remaining gap sits. Allocation is mixed — +`gsub` allocates 4.49 MB against Luerl's 11.75 MB, but the tokenizer allocates +9.10 MB against Luerl's 6.65 MB. + +## Call protocol: protected calls, varargs, multiple returns + +`pcall_varargs` covers the parts of the call protocol that every host +embedding depends on and that had no coverage before this report. Three cases, +all n=500: protected calls that all succeed (the production-common case, which +isolates the cost of entering and leaving a protected frame); protected calls +that all raise a string error and are caught (error construction, unwinding, +returning `false, err`); and variadic handling — `select("#", ...)`, +positional `select(i, ...)`, tail-position `f(...)` forwarding, +`table.pack`/`table.unpack` round-tripping, and multiple-return destructuring. + +| Case | Ref | lua (chunk) median | ips | luerl median | ratio | +|---|---|---|---|---|---| +| pcall, success path | v0.4.0 | 290.50 µs | 3.40 K | 289.67 µs | 1.00 | +| | v1.0.0 | 282.13 µs | 3.49 K | 289.50 µs | 0.97 | +| | **1.0.2** | **222.88 µs** | **4.47 K** | 291.54 µs | **0.76** | +| pcall, raise + catch | v0.4.0 | 469.29 µs | 2.11 K | 466.38 µs | 1.01 | +| | v1.0.0 | 877.33 µs | 1.11 K | 485.38 µs | 1.81 | +| | **1.0.2** | **727.13 µs** | **1.36 K** | 468.42 µs | **1.55** | +| varargs + multiple returns | v0.4.0 | 2.40 ms | 391.27 | 2.36 ms | 1.02 | +| | v1.0.0 | 4.01 ms | 237.27 | 2.40 ms | 1.67 | +| | **1.0.2** | **3.28 ms** | **300.00** | 2.41 ms | **1.36** | + +The success path — the one that actually runs on every request in a host that +wraps script entry points in `pcall` — is **1.31× faster than Luerl** and 1.27× +faster than 1.0.0. The other two are the subject of +[Where we are still behind](#where-we-are-still-behind). + +## `Lua.new/1`: VM instantiation + +`vm_new` measures the cost of standing up a VM before any Lua runs. A host +that builds a fresh sandbox per request — the recommended isolation model — +pays this on every request. Three instantiation shapes are measured, chosen +because `:sandboxed` and `:exclude` are the only `new/1` options that exist +across the whole version history: + +- **`Lua.new()`** — the default deny-list sandbox. What most embedders call. +- **`Lua.new(sandboxed: [])`** — standard library installed, zero sandbox + passes. The closest like-for-like analogue of a bare `:luerl.init()`, which + also performs no sandboxing. **Compare the Luerl row against this row.** +- **`Lua.new(exclude: [[:require]])`** — the default deny-list minus one entry. + "I want the sandbox but need one thing back." + +| Shape | v0.4.0 median | v1.0.0 median | 1.0.2 median | +|---|---|---|---| +| `Lua.new()` | 21.33 µs | 36.67 µs | **0.58 µs** | +| `Lua.new(sandboxed: [])` | 14.88 µs | 29.71 µs | **0.50 µs** | +| `Lua.new(exclude: [[:require]])` | 20.63 µs | 35.96 µs | **6.54 µs** | +| `:luerl.init()` (control) | 15.04 µs | 15.21 µs | 15.13 µs | + +Allocation for `Lua.new()` fell from 91.88 KB (1.0.0) to **0.88 KB** (1.0.2) — +104× less — which is the expected signature of returning a shared memoized +template rather than rebuilding one. + +### Validating the "~100×" claim + +The 1.0.2 changelog and [#419](https://github.com/tv-labs/lua/pull/419) claim +"`Lua.new/1` ~100× faster via a memoized boot-time VM template +([#398](https://github.com/tv-labs/lua/pull/398))". The claim holds in +substance, with four qualifications that anyone quoting it should carry: + +1. **Measured 63× here, not 100×, and that is expected.** 36.67 µs → 0.58 µs + by median is **63×**; by average (37.63 µs → 0.63 µs) it is **60×**. These + runs are under `mix run`, which puts the VM in `:interactive` code-loading + mode. In that mode a cache hit re-verifies the module-reload fingerprint + that #398 describes as costing ~0.2 µs of a ~0.42 µs hit. Under `:embedded` + mode — releases — that check is skipped entirely and the ~100× figure is + credible. **Quote the mode alongside the multiplier.** +2. **It is shape-specific.** Only the exactly-default `Lua.new()` hits the + fully-sandboxed template. Pass any custom `:sandboxed` or `:exclude` and you + get the shared pre-sandbox install but still pay your own sandbox pass: + 35.96 µs → 6.54 µs, i.e. **5.5×, not 63×**. Passing `sandboxed:` is common, + so an unqualified "100× faster `Lua.new`" overstates it for a real fraction + of users. +3. **The history is non-monotonic.** v1.0.0 *regressed* instantiation relative + to v0.4.0 (36.67 µs vs 21.33 µs — 1.72× slower), because the native VM's + standard-library install cost more than Luerl's `init`. 1.0.2 does not + merely recover that; it beats v0.4.0 by **36.8×**. A two-point + "0.4.0 → 1.0.2" comparison would hide a real regression that existed in + between. +4. **There is a one-time cost, and it is not zero.** The template is written + once per node. The first `Lua.new()` on a node measured 6892 µs on 1.0.2, + the second 1.0 µs. That first figure is an **upper bound, not the template + build cost** — under `mix run` it also absorbs first-time loading of the + standard-library modules, which a release has already done at boot. (For + scale, the same cold measurement is 6654 µs on v1.0.0 and 10070 µs on + v0.4.0, i.e. the *larger* cold number belongs to the *slower* library.) Do + not publish "the first call costs 6.9 ms" as a property of the memoization. + +**Unequal work across refs.** `Lua.new()` does not sandbox the same number of +paths on every ref: v0.4.0's default deny-list has 14 entries, v1.0.0 and 1.0.2 +have 27 (the single `[:io]` entry was split into 14 per-function paths). The +`Lua.new()` row is therefore "what the public API costs on that release", not +identical work. The `sandboxed: []` row is the identical-work comparison, and +it is the one the Luerl control should be read against. + +**Deviation on the sub-microsecond rows.** 1.0.2's `Lua.new()` reports +±1209% deviation. This is Benchee measuring an operation near timer +resolution and batching to compensate, not instability — the median (0.58 µs) +and p99 (0.75 µs) are tight. Use medians for these rows and do not reproduce +the deviation column without this note. + +## Host boundary: `encode!` / `decode!` + +`encode_decode` measures the Elixir↔Lua data boundary — `Lua.encode!/2`, +`Lua.decode!/2`, and `Lua.Table.deep_cast/1` — across container shapes (integer +/ float / boolean / short-string / long-string lists, string-keyed maps, +integer-keyed maps, record-shaped maps) at N = 8/64/512/4096, plus a +nested-chain depth sweep and a composite anchor. It uses its own `:timer.tc` +harness rather than Benchee, reports per-element nanoseconds so a super-linear +curve is visible, and **has no Luerl comparator** — it measures this library's +own boundary code, which on v0.4.0 happens to be Luerl's term conversion. + +This is the one area where the native VM is **materially behind the +Luerl-backed v0.4.0**, and it should be published as such. + +| Operation | v0.4.0 | v1.0.0 | 1.0.2 | 1.0.2 vs v0.4.0 | +|---|---|---|---|---| +| `encode` int_list, N=4096 | 226.97 µs | 1601.11 µs | 1568.66 µs | **6.91× slower** | +| `decode` int_list, N=4096 | 43.47 µs | 583.70 µs | 565.55 µs | **13.01× slower** | +| `decode` long_string_list, N=4096 | 54.39 µs | 1495.20 µs | 1339.42 µs | **24.63× slower** | +| `encode` record_list, N=4096 | 2346.38 µs | 4732.25 µs | 3814.88 µs | 1.63× slower | +| `encode` string_map, N=4096 | 1295.09 µs | 2194.00 µs | 1046.22 µs | **0.81× (faster)** | +| `dec+cast` string_map, N=4096 | 367.76 µs | 335.83 µs | 343.00 µs | 0.93× (faster) | +| `encode` original_nested (composite) | 3.48 µs | 17.11 µs | 12.26 µs | 3.52× slower | +| `decode` original_nested (composite) | 0.58 µs | 3.21 µs | 3.29 µs | 5.67× slower | + +Two things are true at once. **List-shaped payloads regressed badly** at the +boundary and have barely improved since 1.0.0 — integer lists ~7× slower to +encode and ~13× slower to decode than v0.4.0, long-string lists ~25× slower to +decode. **String-keyed maps went the other way**: 1.0.2 encodes them 1.24× +faster than v0.4.0 and 2.1× faster than 1.0.0 (2194.00 → 1046.22 µs), and +`decode + deep_cast` on them is at parity or better across all three refs. + +If your host passes large lists across the boundary per request, v0.4.0 was +faster at that specific thing and this is a known gap, not a measurement +artifact. It is the clearest optimisation target this report surfaces. + +## Where we are still behind + +Three cases where 1.0.2 is slower than the same-run Luerl control. All three +improved relative to 1.0.0; none has closed. + +| Case | v1.0.0 ratio | 1.0.2 ratio | 1.0.2 median vs control | +|---|---|---|---| +| pcall, raise + catch | 1.81× | **1.55×** | 727.13 µs vs 468.42 µs | +| varargs + multiple returns | 1.67× | **1.36×** | 3.28 ms vs 2.41 ms | +| `table.sort` (n=1000) | 1.28× | **1.11×** | 199.34 µs vs 179.09 µs | +| patterns: find-based tokenizer | 1.16× | **1.07×** | 1.46 ms vs 1.36 ms | + +**The raise path is not an apples-to-apples work comparison.** On 1.0.2, +`error("negative")` produces `":1: negative"` — position-prefixed, which +is what PUC-Lua does at error level 1. Luerl (and therefore v0.4.0) produces a +bare `"negative"` with no position information. 1.0.2 is doing strictly more +work per raise, and the extra work is the *conformant* behaviour. The 1.55× +should be read as "we pay 1.55× for a more correct error value", not as pure +overhead. It is still worth optimising — position capture need not cost this +much — but it is not a like-for-like loss. + +**Varargs is a genuine loss.** 1.36× slower and allocating 16.70 MB against +the control's 8.73 MB (1.91×) on the same source. Variadic collection and +`table.pack`/`unpack` round-tripping are doing measurably more allocation than +they need to. This is the most actionable pure-performance gap in the suite. + +For the record, `BASELINE.md`'s 1.0.0 gate statement — "every workload is +within 25% of Luerl on the chunk path" — was true of the workloads that +existed when it was written. It is not a property of the current, wider suite: +the pcall-raise and varargs cases were added for this report and both exceed +that band, on 1.0.0 as well as on 1.0.2. + +## Caveats + +1. **v0.4.0 *is* Luerl.** The v0.4.0 column is a thin Elixir wrapper over + `luerl 1.5.1`, so its `lua` rows and its own `luerl` control row measure + substantially the same engine. The data shows this directly: + `Lua.new(sandboxed: [])` at 14.88 µs vs `:luerl.init()` at 15.04 µs (1.1% + apart); `table.concat` at 38.38 µs vs 39.42 µs; `gsub` substitution at + 2.21 ms vs 2.21 ms; arithmetic metamethods at 1.88 ms vs 1.88 ms. **v0.4.0 + is not an independent data point.** Read it as "what an embedder got before + the native VM", and read the v0.4.0 ratio column as a noise floor — it + should sit at 1.00, and where it strays (0.94 on closures, 1.04 on oop) that + is the measurement error budget for this whole report. + +2. **Coroutines are absent by design and are not benchmarked.** The + `coroutine` library is not implemented on v1.0.0 or 1.0.2 — an explicit 1.0 + capability exclusion, recorded as such in the Lua 5.3 suite skip list. Any + coroutine workload would run only on v0.4.0. This is a real gap in + "representative Lua" coverage and is disclosed rather than benchmarked + around. If your embedding needs coroutines, this library does not currently + provide them. + +3. **Four of these workloads are new as of this report.** `patterns`, + `metamethods`, `pcall_varargs` and `vm_new` were added specifically for this + comparison, to cover the pattern engine, self-call/metamethod dispatch, the + protected-call and variadic protocol, and instantiation — none of which the + pre-existing suite touched. They are run against all three refs from the + same source files, so the cross-version numbers are valid, but they have no + history before 2026-07-28 and no `BASELINE.md` counterpart. + +4. **Single machine, single OTP version.** Everything here is one Apple M4 on + Elixir 1.20.0 / OTP 29. Ratios against the same-run Luerl control should + travel reasonably well; absolute microsecond figures will not. Nothing here + has been reproduced on x86_64, on another OTP release, or under a different + scheduler configuration. + +5. **Sub-microsecond rows need medians.** The `Lua.new()` rows on 1.0.2 report + deviations in the hundreds to over a thousand percent because the operation + is near timer resolution and Benchee batches to compensate. Medians and p99s + are tight and meaningful; the deviation column is not, and should not be + republished for those rows without explanation. + +6. **`table_ops` small and medium sizes are too noisy to publish ratios from.** + At n=10 and n=100 the deviations run ±20–46% on operations taking 2–25 µs. + One such cell (v0.4.0 build at n=100) reads 1.60× purely from noise on a ref + where both rows are the same engine. Only the n=1000 column is quoted above. + +7. **No C Lua reference.** `:luaport` was unavailable in this environment, so + there is no PUC-Lua/C row anywhere here. Nothing in this report should be + read as a comparison against reference Lua; it is a comparison of this + library against itself over time, with Luerl as the control. + +8. **`lua (eval)` vs `lua (chunk)`.** The `chunk` path — compile once, run many + — is quoted throughout because it is the production embedding path. The + `eval` path (parse on every call) is within a few percent on most workloads; + the gap is largest where the script is small relative to its parse, and it + inverts on a few cases where measurement noise exceeds the difference. Full + `eval` figures are in the JSON. + +## Reproduction + +All raw stdout, parsed JSON and environment probes for this run are under +[`v0.4.0/`](./v0.4.0/), [`v1.0.0/`](./v1.0.0/) and [`v1.0.2/`](./v1.0.2/), +one directory per released version. Every figure in this +report is traceable to the `median` field of a job in the corresponding +`summary.json`. To regenerate: + +### 1.0.2 / main + +Benchee and Luerl are gated to the `:benchmark` env, so `MIX_ENV=benchmark` is +mandatory. + +```sh +MIX_ENV=benchmark mix deps.get +for w in fibonacci closures oop string_ops string_format table_ops \ + patterns metamethods pcall_varargs vm_new encode_decode; do + LUA_BENCH_MODE=full MIX_ENV=benchmark mix run "benchmarks/$w.exs" +done +``` + +Run these **serially**, as written — not in parallel, and not alongside a test +suite. `mix lua.bench --workload ` is a convenience wrapper that sets the +env for you, but note that invoking it with no `--workload` also picks up +`array_vs_map_probe` (a data-structure probe that runs no Lua) and +`dispatcher_vs_interpreter` (an internal A/B that reaches into +`Lua.Compiler.Prototype` and `Lua.VM.State`). Neither belongs in a +cross-version comparison, and the latter cannot run on v0.4.0 at all. + +### v1.0.0 + +`benchmarks/` at tag `v1.0.0` is byte-identical to the 1.0.2 tree for the +pre-existing workloads, and `mix.exs` deps are the same. Copy in the four +workloads added for this report, then run as above: + +```sh +git worktree add --detach /tmp/lua-v1.0.0 v1.0.0 +cp benchmarks/{patterns,metamethods,pcall_varargs,vm_new}.exs \ + /tmp/lua-v1.0.0/benchmarks/ +cd /tmp/lua-v1.0.0 && MIX_ENV=benchmark mix deps.get +# then the same loop as above +``` + +### v0.4.0 + +Three adaptations are required: + +1. **There is no `benchmarks/` directory at that tag.** Copy the whole + directory in from the current tree. +2. **There is no `benchee` dependency.** Add + `{:benchee, "~> 1.3", only: :benchmark}` to `deps/0` in the worktree's + `mix.exs`, then `MIX_ENV=benchmark mix deps.get`. (`luerl` is an + unconditional runtime dependency at v0.4.0, so the control rows need no + adaptation on any ref.) +3. **Nothing else.** In particular the `load_chunk!/2` state threading that + v0.4.0 requires is **already committed** in every benchmark file. At v0.4.0 + a `%Lua.Chunk{}` holds a `:ref` into the state it was loaded against, so + discarding that state invalidates the chunk and the `lua (chunk)` job dies + with `key N not found`. Every file now writes + `{chunk, lua} = Lua.load_chunk!(lua, ...)`, which is correct and free on all + three refs. + +```sh +git worktree add --detach /tmp/lua-v0.4.0 v0.4.0 +cp -R benchmarks /tmp/lua-v0.4.0/benchmarks +# add {:benchee, "~> 1.3", only: :benchmark} to deps/0 in /tmp/lua-v0.4.0/mix.exs +cd /tmp/lua-v0.4.0 && MIX_ENV=benchmark mix deps.get +# then the same loop as above +``` + +### Language constraints when editing workloads + +Empirically verified against all three refs. A workload that violates any of +these cannot be measured across the whole history: + +- **Use global `function f(...)`, not `local function f(...)`, for anything + self-recursive.** `local function` self-recursion fails on v0.4.0 + (`undefined function nil`). A forward-declared `local f; f = function ...` + also works. +- **No `string.gmatch`.** Raises `{:badarg, :gmatch, ...}` in Luerl 1.5.1, so + it is broken on v0.4.0 and unusable for the control row on any ref. It works + correctly on v1.0.0 and 1.0.2. +- **No `goto`/labels** (parse error on v0.4.0), **no `xpcall`** (broken on + v0.4.0), **no `coroutine.*`** (absent on v1.0.0 and 1.0.2). +- **No non-string `error()` values.** v0.4.0 stringifies them; 1.0.2 preserves + the table. +- **Beware `select("#", ...)`** — returns a float on v0.4.0/Luerl and an + integer on v1.0.0/1.0.2. Harmless in `pcall_varargs` (same iteration count), + but it makes a dedicated integer-arithmetic benchmark measure float math on + one column and integer math on another. diff --git a/benchmarks/closures.exs b/benchmarks/closures.exs index 8910e607..3e6c350e 100644 --- a/benchmarks/closures.exs +++ b/benchmarks/closures.exs @@ -53,9 +53,13 @@ end call_closures = "return run_closures(100)" # --- This Lua implementation --- +# The state returned by `load_chunk!/2` is threaded through each call rather +# than discarded: a loaded chunk may be a reference *into* the state it was +# loaded against, so dropping that state can invalidate the chunk. Threading it +# is correct on every release and costs nothing. lua = Lua.new() {_, lua} = Lua.eval!(lua, closure_def) -{closure_chunk, _} = Lua.load_chunk!(lua, call_closures) +{closure_chunk, lua} = Lua.load_chunk!(lua, call_closures) # --- Luerl --- luerl_state = :luerl.init() diff --git a/benchmarks/fibonacci.exs b/benchmarks/fibonacci.exs index 502f889f..88ee743d 100644 --- a/benchmarks/fibonacci.exs +++ b/benchmarks/fibonacci.exs @@ -27,9 +27,13 @@ end call_fib = "return fib(30)" # --- This Lua implementation --- +# The state returned by `load_chunk!/2` is threaded through each call rather +# than discarded: a loaded chunk may be a reference *into* the state it was +# loaded against, so dropping that state can invalidate the chunk. Threading it +# is correct on every release and costs nothing. lua = Lua.new() {_, lua} = Lua.eval!(lua, fib_def) -{fib_chunk, _} = Lua.load_chunk!(lua, call_fib) +{fib_chunk, lua} = Lua.load_chunk!(lua, call_fib) # --- Luerl --- luerl_state = :luerl.init() diff --git a/benchmarks/metamethods.exs b/benchmarks/metamethods.exs new file mode 100644 index 00000000..57f54803 --- /dev/null +++ b/benchmarks/metamethods.exs @@ -0,0 +1,193 @@ +# Run with: mix run benchmarks/metamethods.exs +# +# Benchmarks metatable-driven dispatch. oop.exs already covers the shallow +# case — one `setmetatable` + a single-level `__index` table lookup, with +# methods invoked as plain field calls (`Animal.speak(a)`). This script covers +# the three things that shape real Lua OOP code and that the shallow case +# leaves untouched: +# +# - methods: method calls written with the `:` sugar (`v:len2()`), so the +# receiver is threaded as an implicit `self` argument, plus a +# chained call on a freshly constructed receiver +# (`v:scaled(2):len2()`). This is the self-call path; the plain +# `T.f(obj)` form in oop.exs does not reach it. +# - inherit: a three-level prototype chain (Rect -> Polygon -> Shape) built +# the idiomatic way, with `setmetatable` on the class tables +# themselves. Each call resolves at a different depth: one hit on +# the leaf, one two hops up, one three hops up — so the cost of +# walking a chain is separated from the cost of a single hit. +# - arith: arithmetic and relational metamethods (`__add`, `__sub`, +# `__lt`, `__eq`) plus `__tostring`. These route through the +# metamethod fallback in the arithmetic/comparison opcodes rather +# than through table indexing, which is a different VM path from +# `__index` entirely. +# +# Each workload runs n=200 iterations per invocation. +# +# Compares: +# - This Lua implementation (eval with string, eval with pre-compiled chunk) +# - Luerl (Erlang-based Lua 5.3 implementation) +# - C Lua 5.4 via luaport (port-based; results include IPC overhead) +# +# NOTE: luaport requires C Lua 5.4 development headers and a small in-tree +# patch (its 1.6.3 release defaults to LuaJIT and uses LUA_GLOBALSINDEX which +# was removed in Lua 5.2). On macOS: +# brew install lua@5.4 +# ./benchmarks/setup_luaport.sh # idempotent; patches + builds +# MIX_ENV=benchmark mix run benchmarks/metamethods.exs +# If luaport fails to start, the benchmark prints a notice and skips it. +# +# Run modes (see benchmarks/helpers.exs): +# default — quick mode (~4 s per Benchee.run) +# LUA_BENCH_MODE=full — long windows + memory_time, for publishable numbers + +Code.require_file("helpers.exs", __DIR__) + +Application.ensure_all_started(:luerl) + +meta_def = """ +-- Self-call dispatch via the `:` sugar, including a chained call. +Vec = {} +Vec.__index = Vec + +function Vec.new(x, y) + return setmetatable({ x = x, y = y }, Vec) +end + +function Vec:len2() + return self.x * self.x + self.y * self.y +end + +function Vec:scaled(k) + return Vec.new(self.x * k, self.y * k) +end + +function run_methods(n) + local v = Vec.new(3, 4) + local acc = 0 + for i = 1, n do + acc = acc + v:len2() + acc = acc + v:scaled(2):len2() + end + return acc +end + +-- Three-level prototype chain; the three calls below resolve at depth 1, 2 +-- and 3 respectively, so a chain walk is measured alongside a direct hit. +Shape = {} +Shape.__index = Shape + +function Shape:kind() return "shape" end +function Shape:area() return 0 end + +Polygon = setmetatable({}, { __index = Shape }) +Polygon.__index = Polygon + +function Polygon:sides() return 0 end + +Rect = setmetatable({}, { __index = Polygon }) +Rect.__index = Rect + +function Rect.new(w, h) + return setmetatable({ w = w, h = h }, Rect) +end + +function Rect:sides() return 4 end + +function run_inherit(n) + local r = Rect.new(3, 4) + local acc = 0 + for i = 1, n do + acc = acc + r:sides() + acc = acc + r:area() + acc = acc + #r:kind() + end + return acc +end + +-- Arithmetic / relational / tostring metamethods. +Money = {} +Money.__index = Money +Money.__add = function(a, b) return Money.new(a.cents + b.cents) end +Money.__sub = function(a, b) return Money.new(a.cents - b.cents) end +Money.__lt = function(a, b) return a.cents < b.cents end +Money.__eq = function(a, b) return a.cents == b.cents end +Money.__tostring = function(m) return "$" .. tostring(m.cents) end + +function Money.new(cents) + return setmetatable({ cents = cents }, Money) +end + +function run_arith(n) + local acc = Money.new(0) + local flags = 0 + local last = "" + for i = 1, n do + acc = acc + Money.new(i) + acc = acc - Money.new(1) + if Money.new(i) < Money.new(i + 1) then flags = flags + 1 end + if Money.new(i) == Money.new(i) then flags = flags + 1 end + last = tostring(acc) + end + return acc.cents, flags, #last +end +""" + +call_methods = "return run_methods(200)" +call_inherit = "return run_inherit(200)" +call_arith = "return run_arith(200)" + +# --- This Lua implementation --- +# The state returned by `load_chunk!/2` is threaded through each call rather +# than discarded: a loaded chunk may be a reference *into* the state it was +# loaded against, so dropping that state can invalidate the chunk. Threading it +# is correct on every release and costs nothing. +lua = Lua.new() +{_, lua} = Lua.eval!(lua, meta_def) +{methods_chunk, lua} = Lua.load_chunk!(lua, call_methods) +{inherit_chunk, lua} = Lua.load_chunk!(lua, call_inherit) +{arith_chunk, lua} = Lua.load_chunk!(lua, call_arith) + +# --- Luerl --- +luerl_state = :luerl.init() +{:ok, _, luerl_state} = :luerl.do(meta_def, luerl_state) + +# --- C Lua via luaport (optional) --- +{c_lua, c_lua_cleanup} = + case Application.ensure_all_started(:luaport) do + {:ok, _} -> + scripts_dir = Path.join(__DIR__, "scripts") + {:ok, port_pid, _} = :luaport.spawn(:meta_bench, to_charlist(scripts_dir)) + :luaport.load(port_pid, meta_def) + + { + fn func -> %{"C Lua (luaport)" => fn -> :luaport.call(port_pid, func, [200]) end} end, + fn -> :luaport.despawn(:meta_bench) end + } + + {:error, reason} -> + IO.puts("luaport not available (#{inspect(reason)}) — skipping C Lua benchmarks") + {fn _func -> %{} end, fn -> :ok end} + end + +bench = fn name, call_str, chunk, c_lua_func -> + Bench.banner(name) + + Benchee.run( + Map.merge( + %{ + "lua (eval)" => fn -> Lua.eval!(lua, call_str) end, + "lua (chunk)" => fn -> Lua.eval!(lua, chunk) end, + "luerl" => fn -> :luerl.do(call_str, luerl_state) end + }, + c_lua.(c_lua_func) + ), + Bench.opts() + ) +end + +bench.("metamethods: self-call method dispatch (n=200)", call_methods, methods_chunk, :run_methods) +bench.("metamethods: 3-level __index chain (n=200)", call_inherit, inherit_chunk, :run_inherit) +bench.("metamethods: arithmetic/relational metamethods (n=200)", call_arith, arith_chunk, :run_arith) + +c_lua_cleanup.() diff --git a/benchmarks/oop.exs b/benchmarks/oop.exs index e6790e58..2cd4be3e 100644 --- a/benchmarks/oop.exs +++ b/benchmarks/oop.exs @@ -1,14 +1,21 @@ # Run with: mix run benchmarks/oop.exs # -# Benchmarks object-oriented patterns using Lua tables and metatables. -# Uses assignment-style method definitions (e.g. Animal.speak = function(self) ... end) -# which are compatible with this Lua implementation's current feature set. -# Creates 50 Animal instances per iteration and calls a method on each. +# Benchmarks the shallow, construction-dominated end of Lua OOP: 50 instances +# per iteration, each built with two field writes and a `setmetatable`, then +# sent one method whose body concatenates two of its fields. +# +# Methods are defined and invoked in assignment/field-call style +# (`Animal.speak = function(self) ... end`, called as `Animal.speak(a)`) rather +# than with the `:` sugar. That is not a language limitation — `:` method +# definitions and self-calls work — it holds dispatch to its simplest form so +# this workload stays dominated by construction cost. The dispatch-heavy +# counterpart (`:` self-calls, multi-level `__index` chains, arithmetic +# metamethods) is benchmarks/metamethods.exs. # # Patterns tested: # - Table creation and field assignment -# - setmetatable / __index prototype chain lookup -# - Closure creation per object (factory pattern variant) +# - setmetatable, plus __index lookup one level up the prototype chain +# - String concatenation and tostring inside a method body # # Compares: # - This Lua implementation (eval with string, eval with pre-compiled chunk) @@ -60,9 +67,13 @@ end call_oop = "return run_oop(50)" # --- This Lua implementation --- +# The state returned by `load_chunk!/2` is threaded through each call rather +# than discarded: a loaded chunk may be a reference *into* the state it was +# loaded against, so dropping that state can invalidate the chunk. Threading it +# is correct on every release and costs nothing. lua = Lua.new() {_, lua} = Lua.eval!(lua, oop_def) -{oop_chunk, _} = Lua.load_chunk!(lua, call_oop) +{oop_chunk, lua} = Lua.load_chunk!(lua, call_oop) # --- Luerl --- luerl_state = :luerl.init() diff --git a/benchmarks/patterns.exs b/benchmarks/patterns.exs new file mode 100644 index 00000000..05ffd113 --- /dev/null +++ b/benchmarks/patterns.exs @@ -0,0 +1,156 @@ +# Run with: mix run benchmarks/patterns.exs +# +# Benchmarks Lua's string-pattern engine — the part of the string library that +# compiles and matches Lua patterns, as opposed to the byte-copying and +# formatting paths covered by string_ops.exs / string_format.exs. +# +# - scan: repeated `string.find` + `string.match` over one log line. Drives +# character classes (%a, %d, %w), quantifiers, escaped magic +# characters (%[ %]) and single-capture extraction. This is the +# shape of nearly every "pull fields out of a line" script. +# - split: tokenises a comma-separated list with `string.find(s, "[^,]+", pos)` +# advanced by an explicit init offset. Exercises the anchor-free +# restart path — the matcher is re-entered once per token with a +# fresh start position. +# - gsub: template substitution in three passes: `%${(%w+)}` with a table +# replacement, `%s+` whitespace squeezing with a string +# replacement, and `(%a+)` with a *function* replacement. Covers +# all three gsub replacement kinds plus capture-driven lookup. +# +# `split` deliberately uses `string.find` with an init offset rather than +# `string.gmatch`: `gmatch` raises `badarg` in Luerl 1.5.x, so a gmatch-based +# tokeniser could not be measured against the Luerl reference on the same Lua +# source. Keeping the source identical across VMs is the fairness contract of +# this suite, so the iterator-free idiom is used instead. +# +# Each workload runs n=200 pattern-heavy iterations per invocation so the +# per-match cost is visible above harness overhead. +# +# Compares: +# - This Lua implementation (eval with string, eval with pre-compiled chunk) +# - Luerl (Erlang-based Lua 5.3 implementation) +# - C Lua 5.4 via luaport (port-based; results include IPC overhead) +# +# NOTE: luaport requires C Lua 5.4 development headers and a small in-tree +# patch (its 1.6.3 release defaults to LuaJIT and uses LUA_GLOBALSINDEX which +# was removed in Lua 5.2). On macOS: +# brew install lua@5.4 +# ./benchmarks/setup_luaport.sh # idempotent; patches + builds +# MIX_ENV=benchmark mix run benchmarks/patterns.exs +# If luaport fails to start, the benchmark prints a notice and skips it. +# +# Run modes (see benchmarks/helpers.exs): +# default — quick mode (~4 s per Benchee.run) +# LUA_BENCH_MODE=full — long windows + memory_time, for publishable numbers + +Code.require_file("helpers.exs", __DIR__) + +Application.ensure_all_started(:luerl) + +pattern_def = """ +local LOG = "2024-05-01 12:34:56 [warn] request_id=a1b2c3 latency=42ms status=503 path=/api/v1/items" + +-- Field extraction: an escaped-magic-character find plus three captures. +function run_scan(n) + local hits = 0 + for i = 1, n do + local s, e = string.find(LOG, "%[%a+%]") + if s then hits = hits + (e - s) end + local status = string.match(LOG, "status=(%d+)") + local id = string.match(LOG, "request_id=(%w+)") + local lat = string.match(LOG, "latency=(%d+)ms") + if status and id and lat then hits = hits + #status + #id + #lat end + end + return hits +end + +local CSV = "alpha,beta,gamma,delta,epsilon,zeta,eta,theta,iota,kappa" + +-- Tokenise by re-entering the matcher at an advancing init offset. +function run_split(n) + local total = 0 + for i = 1, n do + local pos = 1 + while true do + local s, e = string.find(CSV, "[^,]+", pos) + if not s then break end + total = total + (e - s + 1) + pos = e + 1 + end + end + return total +end + +local TEMPLATE = "Hello ${name}, you have ${count} new ${kind} since ${when}. Visit ${url} for details." +local VALUES = { name = "Ada", count = "7", kind = "messages", when = "Tuesday", url = "/inbox" } + +-- All three gsub replacement kinds: table, string, function. +function run_gsub(n) + local last = "" + for i = 1, n do + local filled = string.gsub(TEMPLATE, "%${(%w+)}", VALUES) + local squeezed = string.gsub(filled, "%s+", " ") + last = string.gsub(squeezed, "(%a+)", function(w) return w end) + end + return #last +end +""" + +call_scan = "return run_scan(200)" +call_split = "return run_split(200)" +call_gsub = "return run_gsub(200)" + +# --- This Lua implementation --- +# The state returned by `load_chunk!/2` is threaded through each call rather +# than discarded: a loaded chunk may be a reference *into* the state it was +# loaded against, so dropping that state can invalidate the chunk. Threading it +# is correct on every release and costs nothing. +lua = Lua.new() +{_, lua} = Lua.eval!(lua, pattern_def) +{scan_chunk, lua} = Lua.load_chunk!(lua, call_scan) +{split_chunk, lua} = Lua.load_chunk!(lua, call_split) +{gsub_chunk, lua} = Lua.load_chunk!(lua, call_gsub) + +# --- Luerl --- +luerl_state = :luerl.init() +{:ok, _, luerl_state} = :luerl.do(pattern_def, luerl_state) + +# --- C Lua via luaport (optional) --- +{c_lua, c_lua_cleanup} = + case Application.ensure_all_started(:luaport) do + {:ok, _} -> + scripts_dir = Path.join(__DIR__, "scripts") + {:ok, port_pid, _} = :luaport.spawn(:pattern_bench, to_charlist(scripts_dir)) + :luaport.load(port_pid, pattern_def) + + { + fn func -> %{"C Lua (luaport)" => fn -> :luaport.call(port_pid, func, [200]) end} end, + fn -> :luaport.despawn(:pattern_bench) end + } + + {:error, reason} -> + IO.puts("luaport not available (#{inspect(reason)}) — skipping C Lua benchmarks") + {fn _func -> %{} end, fn -> :ok end} + end + +bench = fn name, call_str, chunk, c_lua_func -> + Bench.banner(name) + + Benchee.run( + Map.merge( + %{ + "lua (eval)" => fn -> Lua.eval!(lua, call_str) end, + "lua (chunk)" => fn -> Lua.eval!(lua, chunk) end, + "luerl" => fn -> :luerl.do(call_str, luerl_state) end + }, + c_lua.(c_lua_func) + ), + Bench.opts() + ) +end + +bench.("patterns: find/match field extraction (n=200)", call_scan, scan_chunk, :run_scan) +bench.("patterns: find-based tokenizer (n=200)", call_split, split_chunk, :run_split) +bench.("patterns: gsub template substitution (n=200)", call_gsub, gsub_chunk, :run_gsub) + +c_lua_cleanup.() diff --git a/benchmarks/pcall_varargs.exs b/benchmarks/pcall_varargs.exs new file mode 100644 index 00000000..75ba3192 --- /dev/null +++ b/benchmarks/pcall_varargs.exs @@ -0,0 +1,161 @@ +# Run with: mix run benchmarks/pcall_varargs.exs +# +# Benchmarks the call protocol: protected calls and variadic/multiple-return +# argument handling. Both are pervasive in embedded Lua — host integrations +# routinely wrap every script entry point in `pcall`, and `...`/multiple +# returns are how Lua code passes argument lists around — and neither appears +# in the comparative workloads otherwise. +# +# - pcall_ok: n protected calls that all succeed. Isolates the cost of +# entering and leaving a protected frame from the cost of +# actually raising, which is the common case in production. +# - pcall_raise: n protected calls that all raise a string error and are +# caught. Drives error-value construction, stack unwinding +# and the return of `false, err` to the caller. +# - varargs: variadic collection (`select("#", ...)`), positional +# variadic access (`select(i, ...)`), variadic forwarding +# (`f(...)` in tail position), `table.pack`/`table.unpack` +# round-tripping, and multiple-return destructuring +# (`local a, b, c = triple(i)`). +# +# Each workload runs n=500 iterations per invocation. +# +# Compares: +# - This Lua implementation (eval with string, eval with pre-compiled chunk) +# - Luerl (Erlang-based Lua 5.3 implementation) +# - C Lua 5.4 via luaport (port-based; results include IPC overhead) +# +# NOTE: luaport requires C Lua 5.4 development headers and a small in-tree +# patch (its 1.6.3 release defaults to LuaJIT and uses LUA_GLOBALSINDEX which +# was removed in Lua 5.2). On macOS: +# brew install lua@5.4 +# ./benchmarks/setup_luaport.sh # idempotent; patches + builds +# MIX_ENV=benchmark mix run benchmarks/pcall_varargs.exs +# If luaport fails to start, the benchmark prints a notice and skips it. +# +# Run modes (see benchmarks/helpers.exs): +# default — quick mode (~4 s per Benchee.run) +# LUA_BENCH_MODE=full — long windows + memory_time, for publishable numbers + +Code.require_file("helpers.exs", __DIR__) + +Application.ensure_all_started(:luerl) + +call_def = """ +-- Raises for negative input, returns normally otherwise, so the same callee +-- drives both the success and the error path below. +function classify(v) + if v < 0 then + error("negative") + end + return v * 2 +end + +function run_pcall_ok(n) + local acc = 0 + for i = 1, n do + local ok, v = pcall(classify, i) + if ok then acc = acc + v end + end + return acc +end + +function run_pcall_raise(n) + local caught = 0 + for i = 1, n do + local ok, err = pcall(classify, -i) + if not ok and type(err) == "string" then caught = caught + 1 end + end + return caught +end + +-- Variadic collection and positional access. +function tally(...) + local count = select("#", ...) + local acc = 0 + for i = 1, count do + acc = acc + select(i, ...) + end + return acc, count +end + +-- Variadic forwarding in tail position. +function forward(...) + return tally(...) +end + +function triple(i) + return i, i + 1, i + 2 +end + +function run_varargs(n) + local acc = 0 + for i = 1, n do + local a, b, c = triple(i) + local sum, count = forward(a, b, c, i, i * 2) + acc = acc + sum + local packed = table.pack(a, b, c) + acc = acc + tally(table.unpack(packed, 1, packed.n)) + end + return acc +end +""" + +call_pcall_ok = "return run_pcall_ok(500)" +call_pcall_raise = "return run_pcall_raise(500)" +call_varargs = "return run_varargs(500)" + +# --- This Lua implementation --- +# The state returned by `load_chunk!/2` is threaded through each call rather +# than discarded: a loaded chunk may be a reference *into* the state it was +# loaded against, so dropping that state can invalidate the chunk. Threading it +# is correct on every release and costs nothing. +lua = Lua.new() +{_, lua} = Lua.eval!(lua, call_def) +{pcall_ok_chunk, lua} = Lua.load_chunk!(lua, call_pcall_ok) +{pcall_raise_chunk, lua} = Lua.load_chunk!(lua, call_pcall_raise) +{varargs_chunk, lua} = Lua.load_chunk!(lua, call_varargs) + +# --- Luerl --- +luerl_state = :luerl.init() +{:ok, _, luerl_state} = :luerl.do(call_def, luerl_state) + +# --- C Lua via luaport (optional) --- +{c_lua, c_lua_cleanup} = + case Application.ensure_all_started(:luaport) do + {:ok, _} -> + scripts_dir = Path.join(__DIR__, "scripts") + {:ok, port_pid, _} = :luaport.spawn(:call_bench, to_charlist(scripts_dir)) + :luaport.load(port_pid, call_def) + + { + fn func -> %{"C Lua (luaport)" => fn -> :luaport.call(port_pid, func, [500]) end} end, + fn -> :luaport.despawn(:call_bench) end + } + + {:error, reason} -> + IO.puts("luaport not available (#{inspect(reason)}) — skipping C Lua benchmarks") + {fn _func -> %{} end, fn -> :ok end} + end + +bench = fn name, call_str, chunk, c_lua_func -> + Bench.banner(name) + + Benchee.run( + Map.merge( + %{ + "lua (eval)" => fn -> Lua.eval!(lua, call_str) end, + "lua (chunk)" => fn -> Lua.eval!(lua, chunk) end, + "luerl" => fn -> :luerl.do(call_str, luerl_state) end + }, + c_lua.(c_lua_func) + ), + Bench.opts() + ) +end + +bench.("call protocol: pcall, success path (n=500)", call_pcall_ok, pcall_ok_chunk, :run_pcall_ok) +bench.("call protocol: pcall, raise + catch (n=500)", call_pcall_raise, pcall_raise_chunk, :run_pcall_raise) +bench.("call protocol: varargs + multiple returns (n=500)", call_varargs, varargs_chunk, :run_varargs) + +c_lua_cleanup.() diff --git a/benchmarks/string_format.exs b/benchmarks/string_format.exs index 0dd1e16d..70cde337 100644 --- a/benchmarks/string_format.exs +++ b/benchmarks/string_format.exs @@ -78,11 +78,15 @@ call_width = "return run_width_format(1000)" call_many = "return run_many_specs(1000)" # --- This Lua implementation --- +# The state returned by `load_chunk!/2` is threaded through each call rather +# than discarded: a loaded chunk may be a reference *into* the state it was +# loaded against, so dropping that state can invalidate the chunk. Threading it +# is correct on every release and costs nothing. lua = Lua.new() {_, lua} = Lua.eval!(lua, string_def) -{long_chunk, _} = Lua.load_chunk!(lua, call_long) -{width_chunk, _} = Lua.load_chunk!(lua, call_width) -{many_chunk, _} = Lua.load_chunk!(lua, call_many) +{long_chunk, lua} = Lua.load_chunk!(lua, call_long) +{width_chunk, lua} = Lua.load_chunk!(lua, call_width) +{many_chunk, lua} = Lua.load_chunk!(lua, call_many) # --- Luerl --- luerl_state = :luerl.init() diff --git a/benchmarks/string_ops.exs b/benchmarks/string_ops.exs index 50e66881..2c6431d0 100644 --- a/benchmarks/string_ops.exs +++ b/benchmarks/string_ops.exs @@ -43,10 +43,14 @@ call_concat = "return run_concat(100)" call_format = "return run_format(100)" # --- This Lua implementation --- +# The state returned by `load_chunk!/2` is threaded through each call rather +# than discarded: a loaded chunk may be a reference *into* the state it was +# loaded against, so dropping that state can invalidate the chunk. Threading it +# is correct on every release and costs nothing. lua = Lua.new() {_, lua} = Lua.eval!(lua, string_def) -{concat_chunk, _} = Lua.load_chunk!(lua, call_concat) -{format_chunk, _} = Lua.load_chunk!(lua, call_format) +{concat_chunk, lua} = Lua.load_chunk!(lua, call_concat) +{format_chunk, lua} = Lua.load_chunk!(lua, call_format) # --- Luerl --- luerl_state = :luerl.init() diff --git a/benchmarks/table_ops.exs b/benchmarks/table_ops.exs index 684bf376..94deb475 100644 --- a/benchmarks/table_ops.exs +++ b/benchmarks/table_ops.exs @@ -97,37 +97,30 @@ lua = Lua.new() # Pre-compile chunks per (operation, n) pair so the chunk path doesn't # pay the compile cost during measurement. Inputs ship through Benchee's # `inputs:` mechanism so all sizes share warmup/measurement state. +# +# The state returned by `load_chunk!/2` is threaded through every load rather +# than discarded: a loaded chunk may be a reference *into* the state it was +# loaded against, so dropping that state can invalidate the chunk. All five +# chunk maps are therefore built against one accumulating state, and that final +# state is the one the benchmarks below evaluate against. sizes = Bench.table_inputs() -build_chunks = - Map.new(sizes, fn {label, n} -> - {chunk, _} = Lua.load_chunk!(lua, "return run_table_build(#{n})") - {label, {chunk, "return run_table_build(#{n})", n}} - end) - -sort_chunks = - Map.new(sizes, fn {label, n} -> - {chunk, _} = Lua.load_chunk!(lua, "return run_table_sort(#{n})") - {label, {chunk, "return run_table_sort(#{n})", n}} - end) - -sum_chunks = - Map.new(sizes, fn {label, n} -> - {chunk, _} = Lua.load_chunk!(lua, "return run_table_sum(#{n})") - {label, {chunk, "return run_table_sum(#{n})", n}} - end) - -map_reduce_chunks = - Map.new(sizes, fn {label, n} -> - {chunk, _} = Lua.load_chunk!(lua, "return run_table_map_reduce(#{n})") - {label, {chunk, "return run_table_map_reduce(#{n})", n}} - end) - -pairs_hash_chunks = - Map.new(sizes, fn {label, n} -> - {chunk, _} = Lua.load_chunk!(lua, "return run_table_pairs_hash(#{n})") - {label, {chunk, "return run_table_pairs_hash(#{n})", n}} - end) +load_chunks = fn lua, func -> + {loaded, lua} = + Enum.map_reduce(sizes, lua, fn {label, n}, acc -> + call = "return #{func}(#{n})" + {chunk, acc} = Lua.load_chunk!(acc, call) + {{label, {chunk, call, n}}, acc} + end) + + {Map.new(loaded), lua} +end + +{build_chunks, lua} = load_chunks.(lua, "run_table_build") +{sort_chunks, lua} = load_chunks.(lua, "run_table_sort") +{sum_chunks, lua} = load_chunks.(lua, "run_table_sum") +{map_reduce_chunks, lua} = load_chunks.(lua, "run_table_map_reduce") +{pairs_hash_chunks, lua} = load_chunks.(lua, "run_table_pairs_hash") # --- Luerl --- luerl_state = :luerl.init() diff --git a/benchmarks/vm_new.exs b/benchmarks/vm_new.exs new file mode 100644 index 00000000..10d0ad18 --- /dev/null +++ b/benchmarks/vm_new.exs @@ -0,0 +1,94 @@ +# Run with: mix run benchmarks/vm_new.exs +# +# Benchmarks VM instantiation — `Lua.new/1` — the cost an embedding host pays +# before a single line of Lua runs. Hosts that build a fresh sandbox per +# request (the recommended isolation model) pay this on every request, so it +# sits directly in the request path and is worth measuring separately from +# script execution. +# +# Three instantiation shapes are measured, chosen because all three are valid +# on every release of this library (`:sandboxed` and `:exclude` are the only +# `new/1` options that exist across the whole history; the limit options +# `:max_call_depth` / `:max_string_bytes` / `:max_instructions` / `:debug` are +# newer and would raise on older releases): +# +# - new — `Lua.new()`. The default deny-list sandbox. This is +# what >90% of embedders call. +# - new, no sandbox — `Lua.new(sandboxed: [])`. Standard library +# installed, zero sandbox passes. This is the closest +# like-for-like analogue of a bare `:luerl.init()`, +# which also performs no sandboxing — compare the +# luerl row against *this* row, not against `new`. +# - new, custom exclude — `Lua.new(exclude: [[:require]])`. The default +# deny-list minus one entry. Represents "I want the +# sandbox but need one thing back", and on releases +# that memoize instantiation it is the shape that +# still pays a per-call sandbox pass. +# +# --------------------------------------------------------------------------- +# Cold vs steady state +# --------------------------------------------------------------------------- +# Newer releases memoize the boot-time VM template in `:persistent_term`, +# written once per node. That makes the *first* `Lua.new()` on a node more +# expensive than every subsequent one, so a benchmark could mislead in either +# direction: measuring only the first call would report a cost no real +# workload repeats, while reporting only the steady state would hide a +# one-time cost that does exist. +# +# Both are therefore reported. The script prints an explicitly-labelled cold +# and second-call timing for `Lua.new()` before Benchee starts — taken as the +# very first thing that touches the library, so nothing has warmed the cache — +# and then Benchee measures steady state, which is what a host serving its +# second and subsequent request sees. On releases with no memoization the two +# figures converge, which is itself the interesting signal. +# +# The cold figure is an upper bound: under `mix run` it also absorbs first-time +# code loading of the stdlib modules, which a release has already done at boot. +# +# Note also that `mix run` puts the VM in `:interactive` code-loading mode. +# Releases run `:embedded`, where a memoizing implementation can skip the +# module-reload staleness check a cache hit otherwise performs — so the steady +# state measured here is, if anything, pessimistic relative to production. +# +# Compares: +# - This Lua implementation (three instantiation shapes) +# - Luerl (`:luerl.init/0`, the Erlang-based Lua 5.3 implementation) +# +# There is no C Lua row: `:luaport` instantiation means spawning an OS process +# and handshaking over a port, which measures process spawn and IPC rather than +# VM construction. The two numbers would not mean the same thing. +# +# Run modes (see benchmarks/helpers.exs): +# default — quick mode (~4 s per Benchee.run) +# LUA_BENCH_MODE=full — long windows + memory_time, for publishable numbers + +Code.require_file("helpers.exs", __DIR__) + +Application.ensure_all_started(:luerl) + +Bench.banner("VM instantiation: Lua.new/1 vs :luerl.init/0") + +# Taken before anything else touches the library, so this really is the cold +# path — on a memoizing release it is the call that populates the template. +{cold_us, _} = :timer.tc(fn -> Lua.new() end) +{second_us, _} = :timer.tc(fn -> Lua.new() end) + +IO.puts(""" +Lua.new() one-time vs repeat cost (single samples, informational): + first call on this node : #{:erlang.float_to_binary(cold_us / 1, decimals: 1)} us + second call : #{:erlang.float_to_binary(second_us / 1, decimals: 1)} us + +The first figure includes any one-time template build and first-time module +loading. Benchee's steady-state numbers below are the per-request cost after +that point. +""") + +Benchee.run( + %{ + "lua (new)" => fn -> Lua.new() end, + "lua (new, no sandbox)" => fn -> Lua.new(sandboxed: []) end, + "lua (new, custom exclude)" => fn -> Lua.new(exclude: [[:require]]) end, + "luerl (init)" => fn -> :luerl.init() end + }, + Bench.opts() +) diff --git a/website/lib/website/benchmarks.ex b/website/lib/website/benchmarks.ex new file mode 100644 index 00000000..88ee2f70 --- /dev/null +++ b/website/lib/website/benchmarks.ex @@ -0,0 +1,460 @@ +defmodule Website.Benchmarks do + @moduledoc """ + The recorded cross-version benchmark results, read from `bench_results/`. + + Every released version has a directory of committed benchmark output at + `bench_results//`, and this module turns those `summary.json` files + into the rows rendered by `/benchmarks`. + + Version directories are **discovered by glob** and ordered by + `Version.compare/2`, so recording a new release is the whole update: drop + `bench_results//summary.json` in place and the page grows a column. + The same goes for the report link, which tracks the newest + `bench_results/versions-.md`. + + What is *not* automatic is `@rows` below — the editorial choice of which + workload cases are worth showing, and under what name. A new workload only + appears once it has a row spec. A version that lacks a workload another + version has renders as `—` in that cell rather than failing. + + All file reads happen at compile time and are registered as + `@external_resource`, so editing recorded results recompiles the page. + """ + + @bench_results Path.expand("../../../bench_results", __DIR__) + + # label: what the row is called on the page + # sub: second line, chart only + # at: {workload, case, input | nil} — case/input keys as normalised below + # job: the Benchee job whose number we quote + # vs: the same-run control job the ratio is taken against + # metric: :median (time) or :memory (allocation) + # chart?: whether the row also gets a dot in the ratio plot + # warn?: the newest release is behind the control here — flagged in both views + # dagger?: the comparison is not like-for-like; the page footnotes why + @rows [ + %{ + label: "Lua.new() — steady state", + sub: "default options", + at: {"vm_new", "VM instantiation: Lua.new/1 vs :luerl.init/0", nil}, + job: "lua (new)", + vs: "luerl (init)", + metric: :median, + chart?: false, + warn?: false, + dagger?: false + }, + %{ + label: "Lua.new() — allocation", + sub: "default options", + at: {"vm_new", "VM instantiation: Lua.new/1 vs :luerl.init/0", nil}, + job: "lua (new)", + vs: "luerl (init)", + metric: :memory, + chart?: false, + warn?: false, + dagger?: false + }, + %{ + label: "fibonacci fib(30)", + sub: "recursive calls", + at: {"fibonacci", "default", nil}, + job: "lua (chunk)", + vs: "luerl", + metric: :median, + chart?: true, + warn?: false, + dagger?: false + }, + %{ + label: "fibonacci — allocation", + sub: "recursive calls", + at: {"fibonacci", "default", nil}, + job: "lua (chunk)", + vs: "luerl", + metric: :memory, + chart?: false, + warn?: false, + dagger?: false + }, + %{ + label: "string.format (literal-heavy)", + sub: "literal-heavy template", + at: {"string_format", "string.format: long literal-heavy format string (n=1000)", nil}, + job: "lua (chunk)", + vs: "luerl", + metric: :median, + chart?: true, + warn?: false, + dagger?: false + }, + %{ + label: "pcall success path (n=500)", + sub: "protected call, no raise", + at: {"pcall_varargs", "call protocol: pcall, success path (n=500)", nil}, + job: "lua (chunk)", + vs: "luerl", + metric: :median, + chart?: true, + warn?: false, + dagger?: false + }, + %{ + label: "pairs over hash part (n=1000)", + sub: "hash-part iteration", + at: {"table_ops", "Table Pairs (hash)", "large (n=1000)"}, + job: "lua (chunk)", + vs: "luerl", + metric: :median, + chart?: true, + warn?: false, + dagger?: false + }, + %{ + label: "__index 3-level chain", + sub: "prototype lookup", + at: {"metamethods", "metamethods: 3-level __index chain (n=200)", nil}, + job: "lua (chunk)", + vs: "luerl", + metric: :median, + chart?: true, + warn?: false, + dagger?: false + }, + %{ + label: "closures", + sub: "factory + upvalue mutation", + at: {"closures", "default", nil}, + job: "lua (chunk)", + vs: "luerl", + metric: :median, + chart?: true, + warn?: false, + dagger?: false + }, + %{ + label: "table.sort (n=1000)", + sub: "reverse-ordered input", + at: {"table_ops", "Table Sort", "large (n=1000)"}, + job: "lua (chunk)", + vs: "luerl", + metric: :median, + chart?: true, + warn?: false, + dagger?: false + }, + %{ + label: "varargs + multi-return (n=500)", + sub: "call protocol", + at: {"pcall_varargs", "call protocol: varargs + multiple returns (n=500)", nil}, + job: "lua (chunk)", + vs: "luerl", + metric: :median, + chart?: true, + warn?: true, + dagger?: false + }, + %{ + label: "pcall raise + catch (n=500)", + sub: "does strictly more work", + at: {"pcall_varargs", "call protocol: pcall, raise + catch (n=500)", nil}, + job: "lua (chunk)", + vs: "luerl", + metric: :median, + chart?: true, + warn?: true, + dagger?: true + } + ] + + summaries = Path.wildcard(Path.join(@bench_results, "*/summary.json")) + + if summaries == [] do + raise """ + no benchmark summaries found under #{@bench_results} + + /benchmarks renders the committed results in bench_results//. If \ + this is a container build, the image needs the directory: + + COPY bench_results /app/bench_results + """ + end + + for path <- summaries do + @external_resource path + end + + reports = Path.wildcard(Path.join(@bench_results, "versions-*.md")) + + # --- compile-time loading ------------------------------------------------ + + # v0.4.0's run labelled cases differently: banners carry a " (mode: full)" + # suffix, the single-case workloads say "(single case)" where later runs say + # "default", per-input results sit under "inputs" rather than "by_input", and + # input labels are prefixed "With input ". Normalise so one row spec resolves + # against every ref. + normalise_case = fn name -> + case String.replace_suffix(name, " (mode: full)", "") do + "(single case)" -> "default" + other -> other + end + end + + normalise_input = fn name -> String.replace_prefix(name, "With input ", "") end + + parse = fn + nil -> + nil + + value -> + case Regex.run(~r/^([\d.]+)\s*(\S+)$/, String.replace(value, "μ", "µ")) do + [_, number, unit] -> + scale = + case unit do + "ns" -> 1 + "µs" -> 1_000 + "ms" -> 1_000_000 + "s" -> 1_000_000_000 + "B" -> 1 + "KB" -> 1_024 + "MB" -> 1_024 * 1_024 + "GB" -> 1_024 * 1_024 * 1_024 + _ -> nil + end + + # Benchee drops the decimal point on some values ("216 µs"), so + # Float.parse rather than String.to_float. + with true <- is_integer(scale), {number, ""} <- Float.parse(number) do + number * scale + else + _ -> nil + end + + _ -> + nil + end + end + + data = + for path <- summaries, into: %{} do + version = path |> Path.dirname() |> Path.basename() |> String.trim_leading("v") + + cases = + path + |> File.read!() + |> Jason.decode!() + |> Map.new(fn {workload, body} -> + normalised = + case body do + %{"raw" => _} -> + %{} + + cases -> + Map.new(cases, fn {name, one} -> + by_input = Map.get(one, "by_input") || Map.get(one, "inputs") + + value = + if by_input, + do: Map.new(by_input, fn {k, v} -> {normalise_input.(k), v} end), + else: one + + {normalise_case.(name), value} + end) + end + + {workload, normalised} + end) + + {version, cases} + end + + @versions data |> Map.keys() |> Enum.sort(&(Version.compare(&1, &2) != :gt)) + + table_rows = + for spec <- @rows do + {workload, case_name, input} = spec.at + + values = + for version <- @versions, into: %{} do + jobs = + with %{^workload => workloads} <- data[version], + %{^case_name => one} <- workloads do + case input do + nil -> Map.get(one, "jobs") + key -> one |> Map.get(key, %{}) |> Map.get("jobs") + end + else + _ -> nil + end + + find = fn name -> + jobs && Enum.find(jobs, &(&1["name"] == name)) + end + + field = if spec.metric == :memory, do: "memory", else: "median" + mine = find.(spec.job) + control = find.(spec.vs) + + shown = mine && mine[field] + against = control && control[field] + + mine_number = parse.(shown) + against_number = parse.(against) + + ratio = + if is_number(mine_number) and is_number(against_number) and against_number > 0 do + Float.round(mine_number / against_number, 2) + end + + {version, %{value: shown, control: against, ratio: ratio, numeric: mine_number}} + end + + Map.put(spec, :values, values) + end + + @table_rows table_rows + + @report reports |> Enum.map(&Path.basename/1) |> Enum.max(fn -> nil end) + + @report_date (case @report && Regex.run(~r/(\d{4}-\d{2}-\d{2})/, @report) do + [_, date] -> Date.from_iso8601!(date) + _ -> nil + end) + + # --- public API ---------------------------------------------------------- + + @doc """ + Recorded versions, oldest first. + """ + def versions, do: @versions + + @doc """ + The newest recorded version — the one the page's headline numbers describe. + """ + def latest, do: List.last(@versions) + + @doc """ + Table rows: one per row spec, each carrying a value per version. + """ + def rows, do: @table_rows + + @doc """ + The subset of rows plotted as ratio-vs-control dots. + """ + def chart_rows, do: Enum.filter(@table_rows, & &1.chart?) + + @doc """ + Look up one row by label, for the headline tiles. + """ + def row(label), do: Enum.find(@table_rows, &(&1.label == label)) + + @doc """ + The value a row recorded for a version, or `nil` if that version lacks it. + """ + def value(row, version), do: get_in(row.values, [version, :value]) + + @doc """ + Whether a version holds the best (lowest) number in its row. Both metrics — + duration and allocation — are better when smaller. + """ + def best?(row, version) do + numbers = for {_, %{numeric: n}} <- row.values, is_number(n), do: n + mine = get_in(row.values, [version, :numeric]) + + is_number(mine) and numbers != [] and mine == Enum.min(numbers) + end + + @doc """ + The version before the newest — what the headline improvements are measured against. + """ + def previous, do: Enum.at(@versions, -2) + + @doc """ + The figures quoted in the page's headline tiles and callout, derived from the + rows so that recording a new release moves them without an edit here. + """ + def headline do + new = row("Lua.new() — steady state") + new_memory = row("Lua.new() — allocation") + fib = row("fibonacci fib(30)") + fib_memory = row("fibonacci — allocation") + raise_catch = row("pcall raise + catch (n=500)") + varargs = row("varargs + multi-return (n=500)") + + %{ + previous: previous(), + new_median: value(new, latest()), + new_median_prev: value(new, previous()), + new_speedup: improvement(new, previous(), latest()), + new_memory: value(new_memory, latest()), + new_memory_prev: value(new_memory, previous()), + new_memory_factor: improvement(new_memory, previous(), latest()), + fib_median: value(fib, latest()), + fib_median_prev: value(fib, previous()), + fib_speedup: inverse(ratio(fib, latest())), + fib_prev_ratio: ratio(fib, previous()), + fib_memory: value(fib_memory, latest()), + fib_memory_control: get_in(fib_memory.values, [latest(), :control]), + raise_ratio: ratio(raise_catch, latest()), + raise_prev_ratio: ratio(raise_catch, previous()), + varargs_ratio: ratio(varargs, latest()) + } + end + + @doc """ + A row's ratio against its same-run control for one version. + """ + def ratio(row, version), do: get_in(row.values, [version, :ratio]) + + @doc """ + How many times better `to` is than `from` in a row, as display text. + """ + def improvement(row, from, to) do + with a when is_number(a) <- get_in(row.values, [from, :numeric]), + b when is_number(b) <- get_in(row.values, [to, :numeric]), + true <- b > 0 do + format_factor(a / b) + else + _ -> nil + end + end + + @doc """ + Opacity for a version's colour, ramping oldest (faintest) to newest (solid). + """ + def version_weight(_index, count) when count < 2, do: 1.0 + def version_weight(index, count), do: Float.round(0.35 + 0.65 * (index / (count - 1)), 2) + + @doc """ + Filename of the newest campaign report, e.g. `versions-2026-07-28.md`. + """ + def report, do: @report + + @doc """ + Date of the newest campaign report, parsed from its filename. + """ + def report_date, do: @report_date + + @doc """ + Log-scale x position, as a 0..100 percentage, for a ratio in the dot plot. + """ + def plot_x(ratio) when is_number(ratio) do + :math.log(ratio / xmin()) / :math.log(xmax() / xmin()) * 100 + end + + def xmin, do: 0.15 + def xmax, do: 2.1 + + @doc """ + Gridline/tick positions for the plot axis. + """ + def ticks, do: [{0.25, "4× faster"}, {0.5, "2× faster"}, {1.0, "parity"}, {2.0, "2× slower"}] + + # A ratio below parity, restated as the speedup it represents. + defp inverse(ratio) when is_number(ratio) and ratio > 0, do: format_factor(1 / ratio) + defp inverse(_ratio), do: nil + + # Large factors read better whole ("63×"); small ones need the decimal ("1.7×"). + defp format_factor(factor) when factor >= 10, do: factor |> round() |> Integer.to_string() + defp format_factor(factor), do: :erlang.float_to_binary(factor, decimals: 1) +end diff --git a/website/lib/website_web/components/layouts.ex b/website/lib/website_web/components/layouts.ex index f711bfa3..664cbab1 100644 --- a/website/lib/website_web/components/layouts.ex +++ b/website/lib/website_web/components/layouts.ex @@ -63,6 +63,7 @@ defmodule DemoWeb.Layouts do <.nav_link href="/playground" active={@active == :playground}>Playground <.nav_link href="/tour" active={@active == :tour}>Tour <.nav_link href="/reference/opcodes" active={@active == :opcodes}>Opcodes + <.nav_link href="/benchmarks" active={@active == :benchmarks}>Benchmarks <.nav_link href="/about" active={@active == :about}>About +
  • + <.link + navigate="/benchmarks" + class={@active == :benchmarks && "active text-primary bg-primary/10"} + > + Benchmarks + +
  • <.link navigate="/about" @@ -221,6 +230,11 @@ defmodule DemoWeb.Layouts do Opcode reference
  • +
  • + <.link navigate={~p"/benchmarks"} class="text-base-content/70 hover:text-primary"> + Benchmarks + +
  • <.link navigate={~p"/about"} class="text-base-content/70 hover:text-primary"> About diff --git a/website/lib/website_web/controllers/page_controller.ex b/website/lib/website_web/controllers/page_controller.ex index 89060600..44e9c617 100644 --- a/website/lib/website_web/controllers/page_controller.ex +++ b/website/lib/website_web/controllers/page_controller.ex @@ -1,6 +1,8 @@ defmodule DemoWeb.PageController do use DemoWeb, :controller + alias Website.Benchmarks + def home(conn, _params) do %{source: fib_source} = hd(Website.LuaSandbox.home_snippets()) @@ -15,6 +17,19 @@ defmodule DemoWeb.PageController do render(conn, :about, page_title: "About") end + def benchmarks(conn, _params) do + render(conn, :benchmarks, + page_title: "Benchmarks", + versions: Benchmarks.versions(), + latest: Benchmarks.latest(), + rows: Benchmarks.rows(), + chart_rows: Benchmarks.chart_rows(), + headline: Benchmarks.headline(), + report: Benchmarks.report(), + report_date: Benchmarks.report_date() + ) + end + def health(conn, _params) do send_resp(conn, 200, "ok") end diff --git a/website/lib/website_web/controllers/page_html.ex b/website/lib/website_web/controllers/page_html.ex index 0d04475d..1ff41041 100644 --- a/website/lib/website_web/controllers/page_html.ex +++ b/website/lib/website_web/controllers/page_html.ex @@ -6,8 +6,56 @@ defmodule DemoWeb.PageHTML do """ use DemoWeb, :html + alias Website.Benchmarks + embed_templates "page_html/*" + attr :label, :string, required: true + attr :value, :string, required: true + attr :delta, :string, default: nil + slot :inner_block, required: true + + def stat_tile(assigns) do + ~H""" +
    +
    + {@label} +
    +
    {@value}
    +
    {@delta}
    +
    + {render_slot(@inner_block)} +
    +
    + """ + end + + attr :value, :float, default: nil + + def ratio(assigns) do + ~H""" + + + {:erlang.float_to_binary(@value, decimals: 2)}× + + """ + end + + @doc """ + Hover text for a dot in the ratio plot. + """ + def dot_title(row, version) do + values = row.values[version] + + """ + #{row.label} — #{version} + chunk median: #{values.value} + same-run Luerl: #{values.control} + ratio: #{values.ratio}× #{ratio_word(values.ratio)} + """ + |> String.trim() + end + attr :icon, :string, required: true attr :title, :string, required: true attr :accent, :string, default: "primary" @@ -30,6 +78,10 @@ defmodule DemoWeb.PageHTML do """ end + defp ratio_word(ratio) when ratio < 1, do: "(faster)" + defp ratio_word(ratio) when ratio > 1, do: "(slower)" + defp ratio_word(_ratio), do: "" + defp accent_bg("primary"), do: "bg-primary/15" defp accent_bg("secondary"), do: "bg-secondary/15" defp accent_bg("accent"), do: "bg-accent/15" diff --git a/website/lib/website_web/controllers/page_html/benchmarks.html.heex b/website/lib/website_web/controllers/page_html/benchmarks.html.heex new file mode 100644 index 00000000..e1b75794 --- /dev/null +++ b/website/lib/website_web/controllers/page_html/benchmarks.html.heex @@ -0,0 +1,280 @@ + + <%!-- ============== HERO ============== --%> +
    + + +
    +

    + tv-labs/lua · full-mode benchee · {@report_date} +

    +

    + Lua on the BEAM: {Enum.join(@versions, " → ")} +

    +

    + {length(@versions)} 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. {List.first(@versions)} was + a thin wrapper over Luerl; {@headline.previous} introduced the native VM; {@latest} is the + current release. +

    +

    + The headline: + {@latest} is the first release faster than Luerl on most workloads — and + Lua.new() + is now effectively free. +

    +
    +
    + + <%!-- ============== HEADLINE TILES ============== --%> +
    +
    + <.stat_tile + label="Lua.new() median" + value={@headline.new_median} + delta={"#{@headline.new_speedup}× faster than #{@headline.previous} (#{@headline.new_median_prev})"} + > + Steady-state, default options, under mix run. ~100× credible in an + :embedded + release; ~5× when passing custom sandbox options. + + + <.stat_tile + label="Lua.new() allocation" + value={@headline.new_memory} + delta={"#{@headline.new_memory_factor}× less than #{@headline.previous} (#{@headline.new_memory_prev})"} + > + Memoized boot-time VM template; one-time ~7 ms cold build per node. + + + <.stat_tile + label="fibonacci fib(30) median" + value={@headline.fib_median} + delta={"#{@headline.fib_speedup}× faster than same-run Luerl"} + > + Was {@headline.fib_prev_ratio}× slower + on {@headline.previous} ({@headline.fib_median_prev}). Allocation: {@headline.fib_memory} vs Luerl's {@headline.fib_memory_control}. + +
    +
    + + <%!-- ============== RATIO PLOT ============== --%> +
    +

    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. +

    + +
    +
    + + + + {version} +  (Luerl wrapper) + + + │ line = Luerl parity (1.0) +
    + +
    +
    +
    + ← faster than Luerl + slower than Luerl → +
    + +
    +
    + + {row.label} + + {row.sub} +
    + +
    +
    +
    +
    +
    + + + +
    +
    + +
    + +
    + + {label} + +
    +
    +
    +
    +
    +
    + + <%!-- ============== MEDIANS TABLE ============== --%> +
    +

    Medians across releases

    +

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

    + +
    + + + + + + + + + + + + + + + +
    Workload{version}{@latest} vs Luerl
    + + {row.label} + + + + {Benchmarks.value(row, version) || "—"} + <.ratio value={Benchmarks.ratio(row, @latest)} />
    +
    + +

    + † {@latest} builds PUC-Lua-conformant position-prefixed error messages; Luerl returns the + bare error value, doing less work per raise. +

    +
    + + <%!-- ============== WHERE WE'RE BEHIND ============== --%> +
    +
    +

    Where {@latest} is still behind Luerl

    +
    +

    + + pcall raise + catch ({@headline.raise_ratio}× slower). + + Partly apples-to-oranges: {@latest} 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 {@headline.previous} ({@headline.raise_prev_ratio}×) and has only partially recovered. +

    +

    + + varargs + multiple returns ({@headline.varargs_ratio}× 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 {List.first(@versions)}/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 ============== --%> +
    +

    How to read these numbers

    +
      +
    • + + {List.first(@versions)} is not an independent series. + + It wraps luerl 1.5.1; its own no-sandbox instantiation lands within ~1% of raw :luerl.init(). Its dots hugging the parity line is by construction, and it's + why the Luerl control column is meaningful across all {length(@versions)} runs. +
    • +
    • + Discipline: + Apple M4 · Elixir 1.20.0 / OTP 29 · LUA_BENCH_MODE=full + (10 s measure, 2 s warmup, 1 s memory) · one mix run + at a time on a quiet machine · medians quoted, not averages. C Lua (luaport) was + unavailable; Luerl is the reference. +
    • +
    • + The suite got wider for this report. + patterns, metamethods, pcall/varargs, and Lua.new + workloads are new — chosen to make the suite representative of real Lua (pattern engine, + : + method dispatch, protected calls), not just of what was optimized. +
    • +
    • + Coroutines are not benchmarked + — they are an intentional 1.0 capability exclusion in the library, not an omission from + the suite. +
    • +
    • + Sub-microsecond rows + (Lua.new() on {@latest}) show large Benchee deviation percentages due to + batching at that timescale; medians are stable across runs. +
    • +
    + +

    + Full report with per-workload analysis and reproduction instructions: + + bench_results/{@report} + + in tv-labs/lua. Raw Benchee outputs and parsed JSON are committed alongside, one directory + per released version. +

    +
    + diff --git a/website/lib/website_web/router.ex b/website/lib/website_web/router.ex index 4569c206..dadfdbd5 100644 --- a/website/lib/website_web/router.ex +++ b/website/lib/website_web/router.ex @@ -23,6 +23,7 @@ defmodule DemoWeb.Router do get "/", PageController, :home get "/about", PageController, :about + get "/benchmarks", PageController, :benchmarks live "/playground", PlaygroundLive, :index live "/playground/:example", PlaygroundLive, :example live "/tour", TourLive, :index diff --git a/website/test/website_web/controllers/page_controller_test.exs b/website/test/website_web/controllers/page_controller_test.exs index 4a52cb44..d2600a3b 100644 --- a/website/test/website_web/controllers/page_controller_test.exs +++ b/website/test/website_web/controllers/page_controller_test.exs @@ -1,10 +1,68 @@ defmodule DemoWeb.PageControllerTest do use DemoWeb.ConnCase + alias Website.Benchmarks + test "GET / renders the Lua showcase landing page", %{conn: conn} do body = conn |> get(~p"/") |> html_response(200) assert body =~ "Lua, on the" assert body =~ "Playground" assert body =~ "Tour" end + + describe "GET /benchmarks" do + test "renders a column per recorded version", %{conn: conn} do + body = conn |> get(~p"/benchmarks") |> html_response(200) + + for version <- Benchmarks.versions() do + assert body =~ version + end + end + + test "renders every row's recorded value rather than a placeholder", %{conn: conn} do + body = conn |> get(~p"/benchmarks") |> html_response(200) + + for row <- Benchmarks.rows(), version <- Benchmarks.versions() do + value = Benchmarks.value(row, version) + + assert value, "#{row.label} has no value for #{version}" + assert body =~ value, "#{row.label}/#{version} (#{value}) missing from the page" + end + end + + test "links the campaign report it was written from", %{conn: conn} do + body = conn |> get(~p"/benchmarks") |> html_response(200) + + assert body =~ "bench_results/#{Benchmarks.report()}" + end + + test "is reachable from the site navigation", %{conn: conn} do + body = conn |> get(~p"/") |> html_response(200) + + assert body =~ ~p"/benchmarks" + end + end + + describe "recorded results" do + test "every row resolves against every recorded version" do + for row <- Benchmarks.rows(), version <- Benchmarks.versions() do + assert is_binary(Benchmarks.value(row, version)), + "#{row.label} did not resolve for #{version} — check the case-name normalisation" + end + end + + test "ratios are taken against the same-run control" do + for row <- Benchmarks.rows(), version <- Benchmarks.versions() do + assert is_float(Benchmarks.ratio(row, version)), + "#{row.label}/#{version} has no ratio — control job #{row.vs} missing?" + end + end + + test "versions are ordered oldest to newest" do + assert Benchmarks.versions() == + Enum.sort(Benchmarks.versions(), &(Version.compare(&1, &2) != :gt)) + + assert Benchmarks.latest() == List.last(Benchmarks.versions()) + end + end end