From 465ade68d25f1ffea70a77e0944206cb955a4bca Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 00:12:23 -0400 Subject: [PATCH 1/7] vm: build callee register files in one allocation Every compiled-closure call allocated the callee's register tuple twice over: `Tuple.duplicate/2` for the blank file, then one `setelement` per argument, each copying the whole tuple again. Generated `mkregs/4` clauses build the finished tuple in a single literal construction for the small (width, parameter-count) shapes ordinary Lua functions have; a parameterless callee now allocates nothing at all, since an all-nil tuple is a compile-time literal. Wider shapes keep the duplicate-and-copy path, and the `grow_regs/2` growth contract for vararg and multi-return writes is untouched. --- lib/lua/vm/dispatcher.ex | 52 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/lib/lua/vm/dispatcher.ex b/lib/lua/vm/dispatcher.ex index f75a34d..7e5fda6 100644 --- a/lib/lua/vm/dispatcher.ex +++ b/lib/lua/vm/dispatcher.ex @@ -2083,9 +2083,55 @@ defmodule Lua.VM.Dispatcher 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 defp copy_regs(_src, _src_i, dst, _dst_i, 0), do: dst From d992f5c7b9611808de7e8a2a5fca5bf0d6815efc Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 00:18:15 -0400 Subject: [PATCH 2/7] vm: give the small call arities their own opcodes The argument count at a call site is fixed at encode time, so the zero-, one-, and two-argument forms of `:call` with a fixed result count now encode to dedicated tags. Their handlers read the arguments out of the caller's registers at constant offsets and build the callee's register file as one literal tuple: no copy loop, no clamp against the callee's parameter count, no blank tuple to overwrite. Wider arities keep the generic opcodes. `name_hint` and the baked source line ride along unchanged, so tracebacks and native-call error attribution are byte-identical to the generic forms. The interpreter is untouched: this is an encoding choice inside the dispatcher's own representation, and it runs the same instruction stream as before. The non-compiled-closure branches of the call handlers move into shared `call_one_bridge/17` and `call_zero_bridge/16` helpers so the generic and static-arity opcodes keep exactly one copy of the interpreter and native bridges between them. --- lib/lua/compiler/bytecode.ex | 44 ++ lib/lua/vm/dispatcher.ex | 608 ++++++++++++++++-- test/lua/compiler/bytecode_test.exs | 21 +- .../compiler/max_registers_invariant_test.exs | 16 + 4 files changed, 628 insertions(+), 61 deletions(-) diff --git a/lib/lua/compiler/bytecode.ex b/lib/lua/compiler/bytecode.ex index 11f9248..b975e3f 100644 --- a/lib/lua/compiler/bytecode.ex +++ b/lib/lua/compiler/bytecode.ex @@ -125,6 +125,31 @@ 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 + + # 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 +229,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} @@ -363,7 +390,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 @@ -683,4 +721,10 @@ 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 end diff --git a/lib/lua/vm/dispatcher.ex b/lib/lua/vm/dispatcher.ex index 7e5fda6..8354592 100644 --- a/lib/lua/vm/dispatcher.ex +++ b/lib/lua/vm/dispatcher.ex @@ -138,6 +138,17 @@ 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 + @doc """ Execute a compiled prototype against `args` and `state`. """ @@ -893,9 +904,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 +934,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,49 +1157,182 @@ 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 # ── Returns ───────────────────────────────────────────────────── @@ -2079,6 +2379,143 @@ 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 @@ -2134,6 +2571,59 @@ defmodule Lua.VM.Dispatcher 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/test/lua/compiler/bytecode_test.exs b/test/lua/compiler/bytecode_test.exs index b24b2f0..ddd56b1 100644 --- a/test/lua/compiler/bytecode_test.exs +++ b/test/lua/compiler/bytecode_test.exs @@ -351,6 +351,23 @@ 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 "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 +388,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 +435,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 b5908a5..9bf2190 100644 --- a/test/lua/compiler/max_registers_invariant_test.exs +++ b/test/lua/compiler/max_registers_invariant_test.exs @@ -62,6 +62,15 @@ 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_return_one() -> [1] op == Bytecode.op_return_zero() -> [] # Table opcodes (B5b-v2). @@ -184,6 +193,13 @@ 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 + :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 From f5478c39ec2b04f408ff9012f1a9a66122254bfe Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 00:34:26 -0400 Subject: [PATCH 3/7] compiler: fuse provably self-recursive calls into call_self MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `local function` 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, that load is pure overhead and the call needs no callee at all: both engines already hold the prototype and its upvalue tuple. `Lua.Compiler.Peephole` proves it, and every step of the proof fails closed — mutual recursion, a reassigned name, `local f; f = function()`, a name captured by any second closure, a vararg body, or a `goto` anywhere in the function all keep the generic call: * the parent binds the child with the shape codegen emits for `local function`, and writes that register nowhere else; * the parent only ever reads it to call it, and the scratch register the closure passed through dies before anything reads it back; * inside the child the self-upvalue is only ever loaded to be called, is never assigned, and is captured by no nested prototype — so neither the value nor the cell behind it can reach code that could rebind it, `debug.setupvalue` included. The dispatcher's handler re-enters `proto.bytecode` with the upvalues it is already carrying. It is a full call in every other respect: it pushes a frame, ticks the instruction budget, checks the call depth at the same point, and starts the callee with an empty open-upvalue map — so tracebacks, `debug.getinfo`, and the `max_call_depth` trip point are identical to a generic call's. The interpreter reconstructs the closure the cell holds and runs an ordinary call. --- lib/lua/compiler/bytecode.ex | 18 ++ lib/lua/compiler/codegen.ex | 4 + lib/lua/compiler/instruction.ex | 8 + lib/lua/compiler/peephole.ex | 378 +++++++++++++++++++++++++++++++- lib/lua/vm/dispatcher.ex | 59 +++++ lib/lua/vm/executor.ex | 36 +++ 6 files changed, 500 insertions(+), 3 deletions(-) diff --git a/lib/lua/compiler/bytecode.ex b/lib/lua/compiler/bytecode.ex index b975e3f..e12fd66 100644 --- a/lib/lua/compiler/bytecode.ex +++ b/lib/lua/compiler/bytecode.ex @@ -139,6 +139,12 @@ defmodule Lua.Compiler.Bytecode do @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 [ @@ -238,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 @@ -414,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). @@ -727,4 +744,5 @@ defmodule Lua.Compiler.Bytecode do 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 c515a5d..a303fd5 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 8434171..4a6decb 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 396b287..9a9325f 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. @@ -145,7 +156,7 @@ defmodule Lua.Compiler.Peephole do %{ proto - | prototypes: prototypes, + | prototypes: fuse_self_calls(instructions, prototypes), instructions: instructions, max_registers: recompute_max_registers(proto, instructions, prototypes) } @@ -235,6 +246,369 @@ 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 is read only by the binding, 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), and neither does a `local function` + # that is later reassigned, nor `local f; f = function() … f() … end` + # (whose `local f` write makes the register's write count 3). + + 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 + + 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 +768,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), diff --git a/lib/lua/vm/dispatcher.ex b/lib/lua/vm/dispatcher.ex index 8354592..e04d96b 100644 --- a/lib/lua/vm/dispatcher.ex +++ b/lib/lua/vm/dispatcher.ex @@ -149,6 +149,10 @@ defmodule Lua.VM.Dispatcher do @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`. """ @@ -1335,6 +1339,61 @@ defmodule Lua.VM.Dispatcher do ) 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 diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index e36e11c..ccad361 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( From 7b0694e64e07de1a62cb2c0207b9c95f57a527c7 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 00:39:32 -0400 Subject: [PATCH 4/7] test: pin the call-convention opcodes and the self-call proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the peephole differential battery with the shapes the new opcodes introduce: self-recursion in every result shape the fusion accepts, argument counts on both sides of the static-arity encoding boundary, and the shapes the fusion must refuse — mutual recursion, a reassigned `local function`, `local f; f = function() … f() … end`, a name a second closure captures, a name passed as a value, a vararg body, and a body containing `goto`. Adds an error-fidelity battery for the fused frames: a catchable stack overflow, the depth it trips at, an uncaught overflow's rendered traceback, `error()` and type errors raised under the recursion, and `debug.traceback` / `debug.getinfo` read through them. Each asserts the program still fuses before comparing, so a change that quietly stops fusing fails instead of passing vacuously. The differential now compares `Exception.message/1` alongside the rendered exception. One case pins the interpreter's own handler: a self-recursive function containing short-circuit `and`/`or` keeps its prototype off the dispatcher, so `:call_self` runs on the interpreter there. --- test/lua/compiler/bytecode_test.exs | 82 ++++++++++++ test/lua/compiler/peephole_test.exs | 196 +++++++++++++++++++++++++++- 2 files changed, 273 insertions(+), 5 deletions(-) diff --git a/test/lua/compiler/bytecode_test.exs b/test/lua/compiler/bytecode_test.exs index ddd56b1..f0ed210 100644 --- a/test/lua/compiler/bytecode_test.exs +++ b/test/lua/compiler/bytecode_test.exs @@ -368,6 +368,88 @@ defmodule Lua.Compiler.BytecodeTest do ] 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, diff --git a/test/lua/compiler/peephole_test.exs b/test/lua/compiler/peephole_test.exs index bc1c01b..79cf2a2 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,141 @@ 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()"}, + {"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 +665,55 @@ 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] + """, + """ + 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 +766,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 a872684a6b9abee8ab190f7f13b6039b8823e13f Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 00:44:03 -0400 Subject: [PATCH 5/7] test: cover self-recursive closures that outlive their binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `local function` declared in a loop body gets a fresh upvalue cell per iteration, and a closure that escapes the iteration keeps the one it was made with. Both properties matter to the self-call proof: the escape is what makes it decline, and the per-iteration cell is what would break if it did not. Two corpus programs pin the pair — one storing each iteration's closure and calling them all afterwards, one storing a chunk-level recursive function in a table and calling it both ways. --- test/lua/compiler/peephole_test.exs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/lua/compiler/peephole_test.exs b/test/lua/compiler/peephole_test.exs index 79cf2a2..8ba4987 100644 --- a/test/lua/compiler/peephole_test.exs +++ b/test/lua/compiler/peephole_test.exs @@ -701,6 +701,23 @@ defmodule Lua.Compiler.PeepholeTest do 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 From 3299a17f7470852cd8f38d2270cbe1f62b19cd83 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 00:45:27 -0400 Subject: [PATCH 6/7] compiler: correct the self-call proof's commentary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bullets described guards the code does not have: the scratch register is proved dead at its next write, not proved unread across the whole function, and `local f; f = function() … f() … end` is refused because an assignment never copies the closure into the local's register, not by a write count. --- lib/lua/compiler/peephole.ex | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/lua/compiler/peephole.ex b/lib/lua/compiler/peephole.ex index 9a9325f..7fbf103 100644 --- a/lib/lua/compiler/peephole.ex +++ b/lib/lua/compiler/peephole.ex @@ -265,18 +265,21 @@ defmodule Lua.Compiler.Peephole do # * 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 is read only by the binding, so the - # closure value never becomes an operand of anything else; + # 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), and neither does a `local function` - # that is later reassigned, nor `local f; f = function() … f() … end` - # (whose `local f` write makes the register's write count 3). + # 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 From 8a6f20dca066616fd2695a1e83ade73547081782 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 13:27:06 -0400 Subject: [PATCH 7/7] compiler: fail closed on field ops against the self upvalue; cover call_self in the register invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-call fusion's child-side proof modelled only :set_upvalue and :get_upvalue on the self index. Field accesses through the name fuse into :get_field_upvalue / :set_field_upvalue before the analysis runs, index the closure straight out of its cell with no :get_upvalue left to see, and fell through the catch-all — so `f.x = 1` inside the body still fused. Unexploitable today (function indexing raises identically on both builds), but the proof is supposed to fail closed, and now it does: both fused field shapes on the self index disqualify the fusion, with @refused fixtures pinning them plus the previously unexercised captures_upvalue?/2 guard (a closure declared inside the body that captures the self-upvalue). The max-registers invariant test's independent walker gained the six static-arity call opcodes but not :call_self, and its only recursive corpus entry recursed through a global, which never fuses — the opcode was both unhandled and unwalked. Add the walker case (arguments at base+1..base+arg_count, result written at base) and a self-recursive local function corpus entry that actually emits it. Also pass the post-fusion prototype list to recompute_max_registers/3 instead of the pre-fusion one (only upvalue_descriptors are read and fusion does not change them, but reading the stale list was an accident waiting to be relied on), and document that :call_self deliberately rides reads?/3's conservative catch-all. Claude-Session: https://claude.ai/code/session_01BYHGFLoHBJAsUrTzjVp5nC --- lib/lua/compiler/peephole.ex | 26 ++++++++++++++++++- .../compiler/max_registers_invariant_test.exs | 20 ++++++++++++++ test/lua/compiler/peephole_test.exs | 11 ++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/lib/lua/compiler/peephole.ex b/lib/lua/compiler/peephole.ex index 7fbf103..3c8cfdb 100644 --- a/lib/lua/compiler/peephole.ex +++ b/lib/lua/compiler/peephole.ex @@ -153,10 +153,11 @@ 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 - | prototypes: fuse_self_calls(instructions, prototypes), + | prototypes: prototypes, instructions: instructions, max_registers: recompute_max_registers(proto, instructions, prototypes) } @@ -416,6 +417,23 @@ defmodule Lua.Compiler.Peephole 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 @@ -1071,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/test/lua/compiler/max_registers_invariant_test.exs b/test/lua/compiler/max_registers_invariant_test.exs index 9bf2190..ea5385e 100644 --- a/test/lua/compiler/max_registers_invariant_test.exs +++ b/test/lua/compiler/max_registers_invariant_test.exs @@ -71,6 +71,7 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do 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). @@ -200,6 +201,13 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do :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 @@ -266,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 8ba4987..bba9303 100644 --- a/test/lua/compiler/peephole_test.exs +++ b/test/lua/compiler/peephole_test.exs @@ -472,6 +472,17 @@ defmodule Lua.Compiler.PeepholeTest do "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",