diff --git a/lib/lua/compiler/bytecode.ex b/lib/lua/compiler/bytecode.ex index 11f9248..e12fd66 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 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..3c8cfdb 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 f75a34d..e04d96b 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 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( diff --git a/test/lua/compiler/bytecode_test.exs b/test/lua/compiler/bytecode_test.exs index b24b2f0..f0ed210 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 b5908a5..ea5385e 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 bc1c01b..bba9303 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