diff --git a/lib/lua/compiler.ex b/lib/lua/compiler.ex index b88ffa99..56e5df54 100644 --- a/lib/lua/compiler.ex +++ b/lib/lua/compiler.ex @@ -11,22 +11,31 @@ defmodule Lua.Compiler do alias Lua.Compiler.Codegen alias Lua.Compiler.GotoResolution alias Lua.Compiler.GotoValidation + alias Lua.Compiler.Peephole alias Lua.Compiler.Prototype alias Lua.Compiler.Scope @type compile_opts :: [ - source: binary() + source: binary(), + peephole: boolean() ] @doc """ Compiles a Lua AST chunk into a prototype. - After codegen, the prototype is offered to `Lua.Compiler.Bytecode` for - dense encoding. Sub-prototypes are encoded independently — the dispatcher - takes over per-prototype wherever every opcode in that prototype falls - within its coverage; anything else stays on the interpreter. The - original instruction stream is preserved either way, so error reporting - and tooling continue to work unchanged. + Codegen's output first goes through `Lua.Compiler.Peephole`, which elides + redundant moves, folds literals into `_k` opcode variants, fuses upvalue + field access, and re-derives `max_registers` from the result. Both engines + run the rewritten stream. Pass `peephole: false` to skip it — the + unoptimised stream is semantically identical and the differential tests + compare the two. + + The prototype is then offered to `Lua.Compiler.Bytecode` for dense + encoding. Sub-prototypes are encoded independently — the dispatcher takes + over per-prototype wherever every opcode in that prototype falls within + its coverage; anything else stays on the interpreter. The instruction + stream is preserved either way, so error reporting and tooling continue to + work unchanged. """ @spec compile(Chunk.t(), compile_opts()) :: {:ok, Prototype.t()} | {:error, term()} def compile(%Chunk{} = chunk, opts \\ []) do @@ -41,12 +50,15 @@ defmodule Lua.Compiler do with :ok <- GotoValidation.validate(chunk), {:ok, scope_state} <- Scope.resolve(chunk, opts), {:ok, prototype} <- Codegen.generate(chunk, scope_state, opts) do - # Encode bytecode first (it reads the raw `:goto` / `:label` stream), - # then resolve gotos for the list interpreter. The two passes are - # independent: the dispatcher runs `bytecode`, the interpreter runs the - # resolved `instructions` plus `goto_targets`. + # Peephole first — it rewrites the raw instruction stream, so both the + # bytecode encoding and the interpreter's list see the same code. Then + # encode bytecode (it reads the raw `:goto` / `:label` stream) and + # finally resolve gotos for the list interpreter. The last two passes + # are independent: the dispatcher runs `bytecode`, the interpreter runs + # the resolved `instructions` plus `goto_targets`. prototype = prototype + |> maybe_peephole(opts) |> Bytecode.compile() |> GotoResolution.resolve() @@ -54,6 +66,14 @@ defmodule Lua.Compiler do end end + defp maybe_peephole(prototype, opts) do + if Keyword.get(opts, :peephole, true) do + Peephole.optimize(prototype) + else + prototype + end + end + @doc """ Compiles a Lua AST chunk, raising on error. """ diff --git a/lib/lua/compiler/bytecode.ex b/lib/lua/compiler/bytecode.ex index bb8702be..11f92484 100644 --- a/lib/lua/compiler/bytecode.ex +++ b/lib/lua/compiler/bytecode.ex @@ -112,6 +112,19 @@ defmodule Lua.Compiler.Bytecode do @op_label 60 @op_goto 61 + # Fused opcodes produced by `Lua.Compiler.Peephole`. The `_k` family + # carries its right operand as an inline literal instead of a register; + # the upvalue-field pair folds a `get_upvalue` into the field access that + # consumes it. Codegen never emits any of them directly. + @op_add_k 62 + @op_subtract_k 63 + @op_multiply_k 64 + @op_less_than_k 65 + @op_less_equal_k 66 + @op_equal_k 67 + @op_get_field_upvalue 68 + @op_set_field_upvalue 69 + @doc """ Compile a prototype, populating its `bytecode` field on success. @@ -306,6 +319,26 @@ defmodule Lua.Compiler.Bytecode do defp encode({:shift_right, dest, a, b, hint_a, hint_b}), do: {:ok, {@op_shift_right, dest, a, b, hint_a, hint_b}} defp encode({:bitwise_not, dest, src, hint}), do: {:ok, {@op_bitwise_not, dest, src, hint}} + # Constant-folded arithmetic and comparison. Slot 4 is a literal Lua + # value, not a register index — the dispatcher and the interpreter both + # use it directly as the right operand. `hint_a` still rides along so + # `attempt to perform arithmetic` errors keep their `(local 'n')` suffix; + # the constant side never had a hint. + defp encode({:add_k, dest, a, constant, hint_a}), do: {:ok, {@op_add_k, dest, a, constant, hint_a}} + defp encode({:subtract_k, dest, a, constant, hint_a}), do: {:ok, {@op_subtract_k, dest, a, constant, hint_a}} + defp encode({:multiply_k, dest, a, constant, hint_a}), do: {:ok, {@op_multiply_k, dest, a, constant, hint_a}} + defp encode({:less_than_k, dest, a, constant}), do: {:ok, {@op_less_than_k, dest, a, constant}} + defp encode({:less_equal_k, dest, a, constant}), do: {:ok, {@op_less_equal_k, dest, a, constant}} + defp encode({:equal_k, dest, a, constant}), do: {:ok, {@op_equal_k, dest, a, constant}} + + # Field access through an upvalue-held table — the shape of every global + # read and write outside the chunk itself. + defp encode({:get_field_upvalue, dest, index, name, name_hint}), + do: {:ok, {@op_get_field_upvalue, dest, index, name, name_hint}} + + defp encode({:set_field_upvalue, index, name, value_reg, name_hint}), + do: {:ok, {@op_set_field_upvalue, index, name, value_reg, name_hint}} + defp encode({:less_than, dest, a, b}), do: {:ok, {@op_less_than, dest, a, b}} defp encode({:less_equal, dest, a, b}), do: {:ok, {@op_less_equal, dest, a, b}} defp encode({:greater_than, dest, a, b}), do: {:ok, {@op_greater_than, dest, a, b}} @@ -642,4 +675,12 @@ defmodule Lua.Compiler.Bytecode do def op_set_list_multi, do: @op_set_list_multi def op_label, do: @op_label def op_goto, do: @op_goto + def op_add_k, do: @op_add_k + def op_subtract_k, do: @op_subtract_k + def op_multiply_k, do: @op_multiply_k + def op_less_than_k, do: @op_less_than_k + def op_less_equal_k, do: @op_less_equal_k + def op_equal_k, do: @op_equal_k + def op_get_field_upvalue, do: @op_get_field_upvalue + def op_set_field_upvalue, do: @op_set_field_upvalue end diff --git a/lib/lua/compiler/codegen.ex b/lib/lua/compiler/codegen.ex index 36a70eba..c515a5d9 100644 --- a/lib/lua/compiler/codegen.ex +++ b/lib/lua/compiler/codegen.ex @@ -120,7 +120,8 @@ defmodule Lua.Compiler.Codegen do # Returns the register-slot count an instruction proves is needed (its # highest written register index + 1), recursing into nested bodies. - defp instruction_size({:load_nil, dest, count}), do: dest + count + # `load_nil` clears `count + 1` registers, `dest..dest + count`. + defp instruction_size({:load_nil, dest, count}), do: dest + count + 1 defp instruction_size({:vararg, base, count}) when is_integer(count) and count > 0, do: base + count defp instruction_size({:vararg, base, _}), do: base + 1 defp instruction_size({:self, base, _obj, _name, _hint}), do: base + 2 @@ -160,6 +161,10 @@ defmodule Lua.Compiler.Codegen do defp instruction_size({:set_upvalue, _index, _source}), do: 0 defp instruction_size({:set_open_upvalue, _reg, _source}), do: 0 + # `Lua.Compiler.Peephole` emits this: operand 1 is an *upvalue* index, not + # a register, so it must not reach the default clause below. + defp instruction_size({:set_field_upvalue, _index, _name, _value, _hint}), do: 0 + # Everything that reaches here is an ordinary value-producing opcode — # `{tag, dest, ...}` whose destination is operand 1. That is the rule for # every load / move / arithmetic / comparison / bitwise / table-read / diff --git a/lib/lua/compiler/instruction.ex b/lib/lua/compiler/instruction.ex index 04540ccd..84341717 100644 --- a/lib/lua/compiler/instruction.ex +++ b/lib/lua/compiler/instruction.ex @@ -39,6 +39,16 @@ defmodule Lua.Compiler.Instruction do def set_table(table, key, value, name_hint \\ nil), do: {:set_table, table, key, value, name_hint} def get_field(dest, table, name, name_hint \\ nil), do: {:get_field, dest, table, name, name_hint} def set_field(table, name, value, name_hint \\ nil), do: {:set_field, table, name, value, name_hint} + + # Field access through an upvalue-held table, fusing a `get_upvalue` with + # the `get_field` / `set_field` that consumes it. Every read or write of a + # global is exactly that pair (`_ENV` is an upvalue in every function but + # the chunk), so the fused form halves their instruction count. + # `Lua.Compiler.Peephole` emits these; codegen never does. + def get_field_upvalue(dest, index, name, name_hint \\ nil), do: {:get_field_upvalue, dest, index, name, name_hint} + + def set_field_upvalue(index, name, value, name_hint \\ nil), do: {:set_field_upvalue, index, name, value, name_hint} + def set_list(table, start, count, offset), do: {:set_list, table, start, count, offset} # Arithmetic. @@ -78,6 +88,19 @@ defmodule Lua.Compiler.Instruction do def less_than(dest, a, b), do: {:less_than, dest, a, b} def less_equal(dest, a, b), do: {:less_equal, dest, a, b} + # Constant-folded variants. The right operand is an inline literal rather + # than a register, so the `load_constant` that materialised it disappears + # along with the register it occupied. Only `hint_a` survives: the + # constant side never carried a name hint to begin with, so error + # rendering is unchanged. `Lua.Compiler.Peephole` emits these; codegen + # never does. + def add_k(dest, a, constant, hint_a \\ nil), do: {:add_k, dest, a, constant, hint_a} + def subtract_k(dest, a, constant, hint_a \\ nil), do: {:subtract_k, dest, a, constant, hint_a} + def multiply_k(dest, a, constant, hint_a \\ nil), do: {:multiply_k, dest, a, constant, hint_a} + def equal_k(dest, a, constant), do: {:equal_k, dest, a, constant} + def less_than_k(dest, a, constant), do: {:less_than_k, dest, a, constant} + def less_equal_k(dest, a, constant), do: {:less_equal_k, dest, a, constant} + # Unary / logical def logical_not(dest, source), do: {:not, dest, source} def length(dest, source), do: {:length, dest, source} diff --git a/lib/lua/compiler/peephole.ex b/lib/lua/compiler/peephole.ex new file mode 100644 index 00000000..396b287a --- /dev/null +++ b/lib/lua/compiler/peephole.ex @@ -0,0 +1,789 @@ +defmodule Lua.Compiler.Peephole do + @moduledoc """ + Peephole optimiser over the instruction stream `Lua.Compiler.Codegen` + emits, run before `Lua.Compiler.Bytecode.compile/1`. + + Codegen is deliberately naive: it allocates a fresh temporary for every + intermediate value and copies it into place, it re-reads `_ENV` out of the + upvalue table before every global access, and it materialises every literal + into a register before using it. That keeps codegen simple, and leaves a + small set of purely local rewrites on the table: + + 1. **Move elision.** `{op, tmp, …}` immediately followed by + `{:move, dst, tmp}` retargets the producer at `dst` when `tmp` is + neither an operand of the producer nor read again. + 2. **Constant folding.** `{:load_constant, k, value}` immediately followed + by an arithmetic or comparison op using `k` as its right operand folds + into a `_k` variant carrying the constant inline. + 3. **Upvalue-field fusion.** `{:get_upvalue, t, i}` immediately followed by + a field read or write through `t` fuses into + `:get_field_upvalue` / `:set_field_upvalue`. Every global access is + exactly this shape, so this halves their instruction count. + 4. **Unreachable-code removal** after an unconditional `return` / `break`. + 5. **Upvalue round-trip collapse.** `set_upvalue i, r` immediately + followed by `get_upvalue d, i` reads the register directly. + 6. **Redundant `close_upvalues` removal** in functions that create no + closures — nothing in such a function can open an upvalue cell over + one of its own registers, so there is never anything to close. + + Both engines run the rewritten stream: the interpreter + (`Lua.VM.Executor`) walks `instructions` directly, the dispatcher + (`Lua.VM.Dispatcher`) walks the `Lua.Compiler.Bytecode` encoding of the + same list. Every opcode introduced here therefore has a handler in both. + + ## Safety + + Every rewrite is gated on the rewritten temporary being *dead* — never + read again on any path that can follow. Liveness is answered by scanning + the instructions that may execute after the rewrite site: the rest of the + enclosing block, then the rest of each enclosing block outward. Inside a + loop body two continuations follow the rewrite site — one more trip + around the loop, and the loop exiting into the code after it — and the + register must be dead along both: a write on the back edge settles only + the back-edge path, never the exit path. Registers read through an + upvalue cell count: `:closure` reads every parent register its child + prototype captures, and the open-upvalue opcodes read theirs + syntactically. + + `reads?/3` defaults to "reads everything" for an instruction shape it does + not recognise, so an opcode added to codegen without a clause here disables + the optimisation rather than miscompiling it. + + Functions containing `goto` / `::label::` opt out entirely. A backward jump + makes "the instructions that may follow" a control-flow-graph question + rather than a lexical one, and `goto` is rare enough that the conservative + answer costs nothing. + """ + + alias Lua.Compiler.Codegen + alias Lua.Compiler.Prototype + + # Producers whose destination register is operand 1, that write exactly + # that one register, and that read every operand before writing it. Only + # these can have their destination retargeted by move elision. + @coalescible [ + :load_constant, + :load_boolean, + :load_env, + :move, + :get_upvalue, + :get_open_upvalue, + :get_global, + :new_table, + :get_table, + :get_field, + :get_field_upvalue, + :closure, + :length, + :not, + :negate, + :bitwise_not, + :concatenate, + :add, + :subtract, + :multiply, + :divide, + :floor_divide, + :modulo, + :power, + :bitwise_and, + :bitwise_or, + :bitwise_xor, + :shift_left, + :shift_right, + :add_k, + :subtract_k, + :multiply_k, + :equal, + :not_equal, + :less_than, + :less_equal, + :greater_than, + :greater_equal, + :equal_k, + :less_than_k, + :less_equal_k + ] + + # `{op, dest, a, b, hint_a, hint_b}` shapes. + @binary_ops [ + :add, + :subtract, + :multiply, + :divide, + :floor_divide, + :modulo, + :power, + :bitwise_and, + :bitwise_or, + :bitwise_xor, + :shift_left, + :shift_right + ] + + # `{op, dest, a, b}` shapes. + @compare_ops [:equal, :not_equal, :less_than, :less_equal, :greater_than, :greater_equal] + + # `{op, dest, a, constant, hint_a}` shapes produced by rule 2. + @arith_k_ops [:add_k, :subtract_k, :multiply_k] + + # `{op, dest, a, constant}` shapes produced by rule 2. + @compare_k_ops [:equal_k, :less_than_k, :less_equal_k] + + @doc """ + Optimise a prototype and every prototype nested within it. + + `max_registers` is recomputed from the rewritten stream. It only ever + shrinks: the result is clamped to the incoming value so a shape this + module does not model can never widen the register file, and bounded + below by the highest register index the rewritten stream still touches. + """ + @spec optimize(Prototype.t()) :: Prototype.t() + def optimize(%Prototype{} = proto) do + prototypes = Enum.map(proto.prototypes, &optimize/1) + instructions = optimize_instructions(proto.instructions, prototypes) + + %{ + proto + | prototypes: prototypes, + instructions: instructions, + max_registers: recompute_max_registers(proto, instructions, prototypes) + } + end + + defp optimize_instructions(instructions, prototypes) do + if contains_goto?(instructions) do + instructions + else + instructions + |> drop_redundant_closes() + |> drop_unreachable() + |> collapse_upvalue_roundtrip() + |> fuse_block([], prototypes) + end + end + + # ── Rule 6: redundant `close_upvalues` ────────────────────────────────── + # + # An open upvalue cell over one of this function's registers can only be + # created by a `:closure` opcode in this function capturing it as a + # `:parent_local`. A function that builds no closures therefore never has + # anything to close, and every `close_upvalues` it carries is a pure + # dispatch cost. The gate is the whole function, not the individual block: + # `goto` closes at explicit levels and loop bodies close at iteration + # boundaries, and neither is safe to reason about block by block. + + defp drop_redundant_closes(instructions) do + if contains_closure?(instructions) do + instructions + else + strip_closes(instructions) + end + end + + defp strip_closes(instructions) do + instructions + |> Enum.reject(&match?({:close_upvalues, _}, &1)) + |> Enum.map(fn instr -> map_bodies(instr, &strip_closes/1) end) + end + + # ── Rule 4: unreachable code ──────────────────────────────────────────── + # + # Nothing after an unconditional `return` / `break` in the same block can + # run. Codegen appends a block-exit `close_upvalues` unconditionally, so + # every `if … then return x end` carries one. + + defp drop_unreachable(instructions) do + instructions + |> Enum.map(fn instr -> map_bodies(instr, &drop_unreachable/1) end) + |> truncate_after_terminator([]) + end + + defp truncate_after_terminator([], acc), do: Enum.reverse(acc) + + defp truncate_after_terminator([instr | rest], acc) do + if terminator?(instr) do + Enum.reverse([instr | acc]) + else + truncate_after_terminator(rest, [instr | acc]) + end + end + + defp terminator?({:return, _base, _count}), do: true + defp terminator?({:return_vararg}), do: true + defp terminator?(:break), do: true + defp terminator?(_instr), do: false + + # ── Rule 5: upvalue round-trip ────────────────────────────────────────── + # + # `set_upvalue i, r` followed immediately by `get_upvalue d, i` reads back + # the value just written. Nothing can run between the two, so the register + # still holds it — read it directly and skip the cell map entirely. The + # write stays: the cell is shared state other closures observe. + + defp collapse_upvalue_roundtrip(instructions) do + instructions + |> Enum.map(fn instr -> map_bodies(instr, &collapse_upvalue_roundtrip/1) end) + |> collapse_roundtrip_pairs() + end + + defp collapse_roundtrip_pairs([]), do: [] + + defp collapse_roundtrip_pairs([{:set_upvalue, index, source} = set, {:get_upvalue, dest, index} | rest]) do + [set, {:move, dest, source} | collapse_roundtrip_pairs(rest)] + end + + defp collapse_roundtrip_pairs([instr | rest]), do: [instr | collapse_roundtrip_pairs(rest)] + + # ── Rules 1–3: fusion ─────────────────────────────────────────────────── + # + # A single left-to-right walk. Each rewrite collapses two instructions into + # one and the result is re-examined against its new successor, so chains + # (`get_upvalue` → `get_field` → `move`) collapse in one pass without a + # fixpoint loop. + # + # `future` is the list of instruction lists that may execute after the + # current position, innermost first. Scanning a not-yet-optimised tail is + # conservative: no rewrite ever adds a read, and while move elision does + # delete a write (the copy), the retargeted producer re-establishes that + # write earlier across instructions transparent to it — a kill the scan + # relied on only ever moves earlier, and the temporary whose own write + # disappears was already proven unread on every following path. + + defp fuse_block([], _future, _prototypes), do: [] + + defp fuse_block([instr], future, prototypes) do + [fuse_bodies(instr, future, prototypes)] + end + + defp fuse_block([first, second | rest] = block, future, prototypes) do + case fuse(first, second, [rest | future], prototypes) do + {:ok, fused} -> + fuse_block([fused | rest], future, prototypes) + + :error -> + case elide_move(block, future, prototypes) do + {:ok, rewritten} -> + fuse_block(rewritten, future, prototypes) + + :error -> + tail = [second | rest] + [fuse_bodies(first, [tail | future], prototypes) | fuse_block(tail, future, prototypes)] + end + end + end + + defp fuse_bodies(instr, future, prototypes) do + case bodies(instr) do + [] -> + instr + + list -> + optimised = + list + |> Enum.zip(body_futures(instr, future)) + |> Enum.map(fn {body, body_future} -> fuse_block(body, body_future, prototypes) end) + + put_bodies(instr, optimised) + end + end + + # The future of each nested body, parallel to `bodies/1`. + # + # A branch body simply continues into whatever follows the branch. A loop + # body continues into one more trip around the loop first, spelled out + # instruction by instruction so the scan can find a read on the back edge. + # The loop instruction with its bodies emptied stands in for the header's + # own register reads (the `for` control triple, the `while` test + # register). + # + # The trip is tagged `:back_edge` because it is only one of two + # continuations: the loop may equally exit into `future` without running + # it. `dead?/3` therefore treats a kill inside the trip as settling the + # back-edge path only, and still requires the register to be dead along + # the exit path — a write at the top of every iteration must not license + # deleting a write that the code after the loop reads. + defp body_futures({:test, _reg, _then_body, _else_body}, future), do: [future, future] + defp body_futures({:test_and, _dest, _source, _body}, future), do: [future] + defp body_futures({:test_or, _dest, _source, _body}, future), do: [future] + + defp body_futures({:while_loop, cond_body, _reg, body} = instr, future) do + header = [put_bodies(instr, [[], []])] + + [ + [{:back_edge, header ++ body ++ cond_body} | future], + [{:back_edge, cond_body ++ header ++ body} | future] + ] + end + + defp body_futures({:repeat_loop, body, cond_body, _reg} = instr, future) do + header = [put_bodies(instr, [[], []])] + + [ + [{:back_edge, cond_body ++ header ++ body} | future], + [{:back_edge, header ++ body ++ cond_body} | future] + ] + end + + defp body_futures({:numeric_for, _base, _loop_var, body} = instr, future) do + [[{:back_edge, [put_bodies(instr, [[]])] ++ body} | future]] + end + + defp body_futures({:generic_for, _base, _var_regs, body} = instr, future) do + [[{:back_edge, [put_bodies(instr, [[]])] ++ body} | future]] + end + + # Rule 3: `_ENV` (or any upvalue) field access. `t` holding the table is a + # scratch register the fused form no longer needs. When the field read + # writes back into `t` its own write kills the value, so no liveness check + # is needed. + defp fuse({:get_upvalue, table_reg, index}, {:get_field, dest, table_reg, name, hint}, future, prototypes) do + if dest == table_reg or dead?(table_reg, future, prototypes) do + {:ok, {:get_field_upvalue, dest, index, name, hint}} + else + :error + end + end + + defp fuse({:get_upvalue, table_reg, index}, {:set_field, table_reg, name, value_reg, hint}, future, prototypes) do + if value_reg != table_reg and dead?(table_reg, future, prototypes) do + {:ok, {:set_field_upvalue, index, name, value_reg, hint}} + else + :error + end + end + + # Rule 2: fold a literal into the operation that consumes it. Only the + # right operand folds, and only when the constant side carries no error + # hint — which is the shape codegen emits for a literal, so nothing is + # lost from `Lua.format_exception/1` output. + defp fuse({:load_constant, k_reg, value}, {op, dest, a, k_reg, hint_a, nil}, future, prototypes) do + with {:ok, fused_op} <- fetch_arith_k(op), + true <- a != k_reg, + true <- dest == k_reg or dead?(k_reg, future, prototypes) do + {:ok, {fused_op, dest, a, value, hint_a}} + else + _ -> :error + end + end + + defp fuse({:load_constant, k_reg, value}, {op, dest, a, k_reg}, future, prototypes) do + with {:ok, fused_op} <- fetch_compare_k(op), + true <- a != k_reg, + true <- dest == k_reg or dead?(k_reg, future, prototypes) do + {:ok, {fused_op, dest, a, value}} + else + _ -> :error + end + end + + defp fuse(_first, _second, _future, _prototypes), do: :error + + # ── Rule 1: destination coalescing ────────────────────────────────────── + # + # A producer writing a scratch register that is later copied into its real + # home writes the real home directly, and the copy disappears. Codegen + # emits the pair for every call argument and every `for` header, usually + # but not always adjacently — `move base+1, tmp1; move base+2, tmp2` + # separates each producer from its copy — so the copy is searched for + # within a bounded window. + # + # Instructions in the window must be transparent to both registers: they + # may not read or write `tmp` (whose write is moving later in the stream) + # and they may not read or write `dest` (whose write is moving earlier). + # Only straight-line shapes qualify, so no branch, loop, or `break` can + # observe the reordering. + + @window 16 + + defp elide_move([producer | rest], future, prototypes) do + with true <- coalescible?(producer), + tmp = :erlang.element(2, producer), + false <- reads?(producer, tmp, prototypes), + {:ok, dest, skipped, after_move} <- find_copy(rest, tmp, prototypes, @window, []), + true <- tmp != dest, + false <- reads?(producer, dest, prototypes), + true <- Enum.all?(skipped, &transparent?(&1, dest, prototypes)), + true <- dead?(tmp, [after_move | future], prototypes) do + {:ok, [:erlang.setelement(2, producer, dest) | Enum.reverse(skipped, after_move)]} + else + _ -> :error + end + end + + defp find_copy([{:move, dest, tmp} | after_move], tmp, _prototypes, _budget, skipped) do + {:ok, dest, skipped, after_move} + end + + defp find_copy([instr | rest], tmp, prototypes, budget, skipped) when budget > 0 do + if transparent?(instr, tmp, prototypes) do + find_copy(rest, tmp, prototypes, budget - 1, [instr | skipped]) + else + :error + end + end + + defp find_copy(_instructions, _tmp, _prototypes, _budget, _skipped), do: :error + + # Straight-line shapes a coalesced write may cross. Everything omitted — + # `:test`, the loops, `:return`, `:break`, `:goto`, `:vararg` (whose + # written range is a run-time value) — ends the window. + @window_safe [ + :load_constant, + :load_boolean, + :load_nil, + :load_env, + :move, + :get_upvalue, + :set_upvalue, + :get_open_upvalue, + :set_open_upvalue, + :close_upvalues, + :get_global, + :new_table, + :get_table, + :set_table, + :get_field, + :set_field, + :get_field_upvalue, + :set_field_upvalue, + :set_list, + :length, + :not, + :negate, + :bitwise_not, + :concatenate, + :self, + :call, + :closure, + :source_line, + :add, + :subtract, + :multiply, + :divide, + :floor_divide, + :modulo, + :power, + :bitwise_and, + :bitwise_or, + :bitwise_xor, + :shift_left, + :shift_right, + :add_k, + :subtract_k, + :multiply_k, + :equal, + :not_equal, + :less_than, + :less_equal, + :greater_than, + :greater_equal, + :equal_k, + :less_than_k, + :less_equal_k + ] + + defp transparent?(instr, reg, prototypes) when is_tuple(instr) do + :erlang.element(1, instr) in @window_safe and + not reads?(instr, reg, prototypes) and + not window_writes?(instr, reg) + end + + defp transparent?(_instr, _reg, _prototypes), do: false + + # A call distributes its results from `base` upward, and how far is a + # run-time property of the callee. + defp window_writes?({:call, base, _args, _results, _hint}, reg), do: reg >= base + defp window_writes?(instr, reg), do: writes?(instr, reg) + + defp fetch_arith_k(:add), do: {:ok, :add_k} + defp fetch_arith_k(:subtract), do: {:ok, :subtract_k} + defp fetch_arith_k(:multiply), do: {:ok, :multiply_k} + defp fetch_arith_k(_op), do: :error + + defp fetch_compare_k(:equal), do: {:ok, :equal_k} + defp fetch_compare_k(:less_than), do: {:ok, :less_than_k} + defp fetch_compare_k(:less_equal), do: {:ok, :less_equal_k} + defp fetch_compare_k(_op), do: :error + + defp coalescible?(instr) when is_tuple(instr) and tuple_size(instr) > 1 do + :erlang.element(1, instr) in @coalescible and is_integer(:erlang.element(2, instr)) + end + + defp coalescible?(_instr), do: false + + # ── Liveness ──────────────────────────────────────────────────────────── + + # True when nothing that can execute after the rewrite site observes the + # current contents of `reg`. + # + # `future` is the enclosing blocks' remaining instructions, innermost + # first. Each list is scanned in order: a read of `reg` settles the + # question, an unconditional straight-line write to `reg` kills the value + # and ends the search, and anything else moves on. Running off the end of + # the outermost list means the frame is gone, which is the strongest form + # of dead. + # + # A `{:back_edge, instructions}` entry is one more trip around an + # enclosing loop, and the loop may exit instead of taking it. A read + # inside the trip still settles the question, but a kill inside it only + # settles the back-edge path — the exit continuation (the lists after it) + # is scanned as well, so a register the code after the loop reads stays + # live no matter what the next iteration would do to it. + # + # Conditional writes (a write nested inside a branch or loop body) do not + # kill: the scan just keeps going, which can only under-report deadness. + # A `break` reached before the killing write does suppress it, though — + # the value would survive the block on that path and reach code the outer + # lists cover. + defp dead?(_reg, [], _prototypes), do: true + + defp dead?(reg, [{:back_edge, instructions} | outer], prototypes) do + case scan(instructions, reg, prototypes, false) do + :read -> false + _killed_or_through -> dead?(reg, outer, prototypes) + end + end + + defp dead?(reg, [instructions | outer], prototypes) do + case scan(instructions, reg, prototypes, false) do + :read -> false + :killed -> true + :through -> dead?(reg, outer, prototypes) + end + end + + defp scan([], _reg, _prototypes, _escaped), do: :through + + defp scan([instr | rest], reg, prototypes, escaped) do + cond do + reads?(instr, reg, prototypes) -> :read + writes?(instr, reg) and not escaped -> :killed + writes?(instr, reg) -> :through + true -> scan(rest, reg, prototypes, escaped or breaks?(instr)) + end + end + + # A `break` in the block (or in a nested branch of it) can leave before a + # later write kills `reg`, so the value escapes to the enclosing block. + # A `break` inside a nested *loop* leaves that loop, not this block. + defp breaks?(:break), do: true + defp breaks?({:while_loop, _cond_body, _reg, _body}), do: false + defp breaks?({:repeat_loop, _body, _cond_body, _reg}), do: false + defp breaks?({:numeric_for, _base, _loop_var, _body}), do: false + defp breaks?({:generic_for, _base, _var_regs, _body}), do: false + defp breaks?(instr), do: Enum.any?(bodies(instr), fn body -> Enum.any?(body, &breaks?/1) end) + + # True when `instr` unconditionally overwrites `reg` on every path through + # it, discarding whatever was there. Anything with a nested body, and + # anything whose written range is only known at run time (`:call`, + # `:vararg`), answers false — the scan then simply continues. + defp writes?({:load_nil, dest, count}, reg), do: reg >= dest and reg <= dest + count + defp writes?({:self, base, _object, _name, _hint}, reg), do: reg === base or reg === base + 1 + defp writes?({:load_constant, dest, _value}, reg), do: dest === reg + defp writes?({:load_boolean, dest, _value}, reg), do: dest === reg + defp writes?({:load_env, dest}, reg), do: dest === reg + defp writes?({:move, dest, _source}, reg), do: dest === reg + defp writes?({:get_upvalue, dest, _index}, reg), do: dest === reg + defp writes?({:get_open_upvalue, dest, _source}, reg), do: dest === reg + defp writes?({:get_global, dest, _name}, reg), do: dest === reg + defp writes?({:new_table, dest, _array, _hash}, reg), do: dest === reg + defp writes?({:get_table, dest, _table, _key, _hint}, reg), do: dest === reg + defp writes?({:get_field, dest, _table, _name, _hint}, reg), do: dest === reg + defp writes?({:get_field_upvalue, dest, _index, _name, _hint}, reg), do: dest === reg + defp writes?({:closure, dest, _index}, reg), do: dest === reg + defp writes?({:length, dest, _source}, reg), do: dest === reg + defp writes?({:not, dest, _source}, reg), do: dest === reg + defp writes?({:negate, dest, _source, _hint}, reg), do: dest === reg + defp writes?({:bitwise_not, dest, _source, _hint}, reg), do: dest === reg + defp writes?({:concatenate, dest, _a, _b}, reg), do: dest === reg + defp writes?({op, dest, _a, _b, _hint_a, _hint_b}, reg) when op in @binary_ops, do: dest === reg + defp writes?({op, dest, _a, _constant, _hint_a}, reg) when op in @arith_k_ops, do: dest === reg + defp writes?({op, dest, _a, _b}, reg) when op in @compare_ops, do: dest === reg + defp writes?({op, dest, _a, _constant}, reg) when op in @compare_k_ops, do: dest === reg + defp writes?(_instr, _reg), do: false + + defp any_reads?(instructions, reg, prototypes) do + Enum.any?(instructions, &reads?(&1, reg, prototypes)) + end + + # True when executing `instr` can observe the current contents of `reg`. + # + # The clauses with a literal opcode in position 1 must precede the guarded + # catch-alls for the arithmetic and comparison families, which match on + # arity alone. + defp reads?({:load_constant, _dest, _value}, _reg, _protos), do: false + defp reads?({:load_boolean, _dest, _value}, _reg, _protos), do: false + defp reads?({:load_nil, _dest, _count}, _reg, _protos), do: false + defp reads?({:load_env, _dest}, _reg, _protos), do: false + defp reads?({:move, _dest, source}, reg, _protos), do: source === reg + defp reads?({:get_upvalue, _dest, _index}, _reg, _protos), do: false + defp reads?({:set_upvalue, _index, source}, reg, _protos), do: source === reg + defp reads?({:get_open_upvalue, _dest, source}, reg, _protos), do: source === reg + defp reads?({:set_open_upvalue, cell_reg, source}, reg, _protos), do: cell_reg === reg or source === reg + defp reads?({:get_global, _dest, _name}, _reg, _protos), do: false + defp reads?({:new_table, _dest, _array, _hash}, _reg, _protos), do: false + defp reads?({:get_table, _dest, table, key, _hint}, reg, _protos), do: table === reg or key === reg + defp reads?({:set_table, table, key, value, _hint}, reg, _protos), do: table === reg or key === reg or value === reg + + defp reads?({:get_field, _dest, table, _name, _hint}, reg, _protos), do: table === reg + defp reads?({:set_field, table, _name, value, _hint}, reg, _protos), do: table === reg or value === reg + defp reads?({:get_field_upvalue, _dest, _index, _name, _hint}, _reg, _protos), do: false + defp reads?({:set_field_upvalue, _index, _name, value, _hint}, reg, _protos), do: value === reg + + defp reads?({:set_list, table, start, count, _offset}, reg, _protos) when is_integer(count), + do: table === reg or (reg >= start and reg < start + count) + + defp reads?({:set_list, table, start, _multi, _offset}, reg, _protos), do: table === reg or reg >= start + + defp reads?({:length, _dest, source}, reg, _protos), do: source === reg + defp reads?({:not, _dest, source}, reg, _protos), do: source === reg + defp reads?({:negate, _dest, source, _hint}, reg, _protos), do: source === reg + defp reads?({:bitwise_not, _dest, source, _hint}, reg, _protos), do: source === reg + defp reads?({:concatenate, _dest, a, b}, reg, _protos), do: a === reg or b === reg + defp reads?({:self, _base, object, _name, _hint}, reg, _protos), do: object === reg + defp reads?({:vararg, _base, _count}, _reg, _protos), do: false + defp reads?({:source_line, _line, _file}, _reg, _protos), do: false + defp reads?({:return_vararg}, _reg, _protos), do: false + defp reads?(:break, _reg, _protos), do: false + + # `close_upvalues` filters the frame's open-cell map by register index; it + # never touches the register file. + defp reads?({:close_upvalues, _threshold}, _reg, _protos), do: false + + # A closure reads every parent register its child prototype captures. + defp reads?({:closure, _dest, index}, reg, prototypes), do: captures?(prototypes, index, reg) + + defp reads?({:call, base, arg_count, _results, _hint}, reg, _protos) when is_integer(arg_count) and arg_count >= 0, + do: reg >= base and reg <= base + arg_count + + defp reads?({:call, base, _arg_count, _results, _hint}, reg, _protos), do: reg >= base + + defp reads?({:return, base, count}, reg, _protos) when is_integer(count) and count > 0, + do: reg >= base and reg < base + count + + defp reads?({:return, _base, 0}, _reg, _protos), do: false + defp reads?({:return, base, _count}, reg, _protos), do: reg >= base + + defp reads?({:test, test_reg, then_body, else_body}, reg, protos), + do: test_reg === reg or any_reads?(then_body, reg, protos) or any_reads?(else_body, reg, protos) + + defp reads?({:test_and, _dest, source, body}, reg, protos), do: source === reg or any_reads?(body, reg, protos) + + defp reads?({:test_or, _dest, source, body}, reg, protos), do: source === reg or any_reads?(body, reg, protos) + + defp reads?({:while_loop, cond_body, test_reg, body}, reg, protos), + do: test_reg === reg or any_reads?(cond_body, reg, protos) or any_reads?(body, reg, protos) + + defp reads?({:repeat_loop, body, cond_body, test_reg}, reg, protos), + do: test_reg === reg or any_reads?(body, reg, protos) or any_reads?(cond_body, reg, protos) + + # The numeric/generic `for` header occupies `base..base + 2` (initial + # value, limit, step / iterator, state, control). + defp reads?({:numeric_for, base, _loop_var, body}, reg, protos), + do: (reg >= base and reg <= base + 2) or any_reads?(body, reg, protos) + + defp reads?({:generic_for, base, _var_regs, body}, reg, protos), + do: (reg >= base and reg <= base + 2) or any_reads?(body, reg, protos) + + defp reads?({op, _dest, a, b, _hint_a, _hint_b}, reg, _protos) when op in @binary_ops, do: a === reg or b === reg + + defp reads?({op, _dest, a, _constant, _hint_a}, reg, _protos) when op in @arith_k_ops, do: a === reg + + defp reads?({op, _dest, a, b}, reg, _protos) when op in @compare_ops, do: a === reg or b === reg + + defp reads?({op, _dest, a, _constant}, reg, _protos) when op in @compare_k_ops, do: a === reg + + # Unrecognised shape — including `:goto` / `:label`, which only reach here + # via the register-extent scan. Assume it observes everything. + defp reads?(_instr, _reg, _protos), do: true + + defp captures?(prototypes, index, reg) do + case Enum.at(prototypes, index) do + %Prototype{upvalue_descriptors: descriptors} -> + Enum.any?(descriptors, fn + {:parent_local, parent_reg, _name} -> parent_reg === reg + _descriptor -> false + end) + + _missing -> + true + end + end + + # ── Register file ─────────────────────────────────────────────────────── + # + # Move elision removes the highest-numbered temporaries first, so the + # rewritten stream usually needs a narrower register tuple — and every + # call frame allocates and every `setelement` copies that tuple, so the + # narrowing is worth as much as the dropped dispatches. + # + # The new bound is `Codegen.instruction_peak/1` (every statically-fixed + # destination) widened to cover every register still *read*, then clamped + # to the incoming value. Probing `reads?/3` per register reuses the same + # table the rewrites are gated on rather than duplicating it, and its + # "reads everything" default makes an unmodelled opcode pin the bound at + # the incoming value instead of shrinking it. + + defp recompute_max_registers(proto, instructions, prototypes) do + peak = Codegen.instruction_peak(instructions) + reads = highest_read(instructions, prototypes, proto.max_registers) + + min(proto.max_registers, Enum.max([proto.param_count, peak, reads])) + end + + # Probe downward from the incoming bound and stop at the first hit: the + # answer is normally within a slot or two of the top, so this is a couple + # of scans rather than one per register. + defp highest_read(instructions, prototypes, limit) do + Enum.find_value((limit - 1)..0//-1, 0, fn reg -> + if any_reads?(instructions, reg, prototypes), do: reg + 1 + end) + end + + # ── Nested bodies ─────────────────────────────────────────────────────── + + defp bodies({:test, _reg, then_body, else_body}), do: [then_body, else_body] + defp bodies({:test_and, _dest, _source, body}), do: [body] + defp bodies({:test_or, _dest, _source, body}), do: [body] + defp bodies({:while_loop, cond_body, _reg, body}), do: [cond_body, body] + defp bodies({:repeat_loop, body, cond_body, _reg}), do: [body, cond_body] + defp bodies({:numeric_for, _base, _loop_var, body}), do: [body] + defp bodies({:generic_for, _base, _var_regs, body}), do: [body] + defp bodies(_instr), do: [] + + defp put_bodies({:test, reg, _then_body, _else_body}, [then_body, else_body]), do: {:test, reg, then_body, else_body} + + defp put_bodies({:test_and, dest, source, _body}, [body]), do: {:test_and, dest, source, body} + defp put_bodies({:test_or, dest, source, _body}, [body]), do: {:test_or, dest, source, body} + + defp put_bodies({:while_loop, _cond_body, reg, _body}, [cond_body, body]), do: {:while_loop, cond_body, reg, body} + + defp put_bodies({:repeat_loop, _body, _cond_body, reg}, [body, cond_body]), do: {:repeat_loop, body, cond_body, reg} + + defp put_bodies({:numeric_for, base, loop_var, _body}, [body]), do: {:numeric_for, base, loop_var, body} + + defp put_bodies({:generic_for, base, var_regs, _body}, [body]), do: {:generic_for, base, var_regs, body} + + defp put_bodies(instr, []), do: instr + + defp map_bodies(instr, fun) do + case bodies(instr) do + [] -> instr + list -> put_bodies(instr, Enum.map(list, fun)) + end + end + + # ── Whole-function predicates ─────────────────────────────────────────── + + defp contains_closure?(instructions), do: Enum.any?(instructions, &closure?/1) + + defp closure?({:closure, _dest, _index}), do: true + defp closure?(instr), do: Enum.any?(bodies(instr), &contains_closure?/1) + + defp contains_goto?(instructions), do: Enum.any?(instructions, &goto?/1) + + defp goto?({:goto, _name, _block_path}), do: true + defp goto?({:label, _name, _level, _block_path}), do: true + defp goto?(instr), do: Enum.any?(bodies(instr), &contains_goto?/1) +end diff --git a/lib/lua/vm/dispatcher.ex b/lib/lua/vm/dispatcher.ex index c9b5fd84..f75a34df 100644 --- a/lib/lua/vm/dispatcher.ex +++ b/lib/lua/vm/dispatcher.ex @@ -124,6 +124,20 @@ defmodule Lua.VM.Dispatcher do @op_label 60 @op_goto 61 + # Fused opcodes from `Lua.Compiler.Peephole`. The `_k` family carries its + # right operand inline, so the fast path skips one register read and the + # `load_constant` that fed it. `@op_get_field_upvalue` / + # `@op_set_field_upvalue` source the table straight out of `upvalues` + # instead of a scratch register. + @op_add_k 62 + @op_subtract_k 63 + @op_multiply_k 64 + @op_less_than_k 65 + @op_less_equal_k 66 + @op_equal_k 67 + @op_get_field_upvalue 68 + @op_set_field_upvalue 69 + @doc """ Execute a compiled prototype against `args` and `state`. """ @@ -315,6 +329,72 @@ defmodule Lua.VM.Dispatcher do dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end + # Same shape as `@op_get_field`, but the table comes from the + # upvalue cell rather than a register — the fused form of the + # `get_upvalue` + `get_field` pair every global read compiles to. + {@op_get_field_upvalue, dest, index, name, name_hint} -> + cell_ref = :erlang.element(index + 1, upvalues) + table_val = :maps.get(cell_ref, state.upvalue_cells, nil) + + case table_val do + {:tref, id} -> + table = :erlang.map_get(id, state.tables) + data = :erlang.map_get(:data, table) + + case data do + %{^name => value} -> + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + _ -> + case :erlang.map_get(:metatable, table) do + nil -> + regs = :erlang.setelement(dest + 1, regs, nil) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + _ -> + {value, state} = + Executor.dispatcher_get_field(table_val, name, sync(state, cs, cd), proto, name_hint) + + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + end + + _ -> + {value, state} = + Executor.dispatcher_get_field(table_val, name, sync(state, cs, cd), proto, name_hint) + + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + # Mirror of `@op_set_field` sourcing the table from an upvalue cell — + # the fused form of the pair every global write compiles to. + {@op_set_field_upvalue, index, name, value_reg, name_hint} -> + cell_ref = :erlang.element(index + 1, upvalues) + table_val = :maps.get(cell_ref, state.upvalue_cells, nil) + value = :erlang.element(value_reg + 1, regs) + + case table_val do + {:tref, id} -> + table = :erlang.map_get(id, state.tables) + + state = + case :erlang.map_get(:metatable, table) do + nil -> + %{state | tables: :maps.put(id, Table.put(table, name, value), state.tables)} + + _ -> + Executor.dispatcher_set_field(table_val, name, value, sync(state, cs, cd), proto, name_hint) + end + + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + _ -> + Executor.dispatcher_set_field(table_val, name, value, sync(state, cs, cd), proto, name_hint) + end + # ── Arithmetic ────────────────────────────────────────────────── # # Integer fast paths mirror the interpreter's. Numbers can't carry @@ -385,6 +465,73 @@ defmodule Lua.VM.Dispatcher do dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end + # ── Constant-folded arithmetic ────────────────────────────────── + # + # Same three tiers as the register forms, with `k` read straight out + # of the opcode tuple. The slow path boxes `k` and hands it to the + # shared bridge, so `__add` / `__sub` / `__mul` fidelity and the + # `(local 'n')` error suffix are unchanged. + + {@op_add_k, dest, a, k, hint_a} -> + va = :erlang.element(a + 1, regs) + + cond do + is_integer(va) and is_integer(k) -> + sum = va + k + wrapped = if sum >= @min_int and sum <= @max_int, do: sum, else: Numeric.to_signed_int64(sum) + regs = :erlang.setelement(dest + 1, regs, wrapped) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va + k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_binop(:add, va, k, sync(state, cs, cd), proto, hint_a, nil) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + {@op_subtract_k, dest, a, k, hint_a} -> + va = :erlang.element(a + 1, regs) + + cond do + is_integer(va) and is_integer(k) -> + diff = va - k + wrapped = if diff >= @min_int and diff <= @max_int, do: diff, else: Numeric.to_signed_int64(diff) + regs = :erlang.setelement(dest + 1, regs, wrapped) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va - k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_binop(:subtract, va, k, sync(state, cs, cd), proto, hint_a, nil) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + {@op_multiply_k, dest, a, k, hint_a} -> + va = :erlang.element(a + 1, regs) + + cond do + is_integer(va) and is_integer(k) -> + prod = va * k + wrapped = if prod >= @min_int and prod <= @max_int, do: prod, else: Numeric.to_signed_int64(prod) + regs = :erlang.setelement(dest + 1, regs, wrapped) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va * k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_binop(:multiply, va, k, sync(state, cs, cd), proto, hint_a, nil) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + {@op_divide, dest, a, b, hint_a, hint_b} -> {value, state} = Executor.dispatcher_binop( @@ -643,6 +790,67 @@ defmodule Lua.VM.Dispatcher do dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end + # ── Constant-folded comparisons ───────────────────────────────── + # + # `k` is a literal, so it can never carry a metatable: the fast + # paths fire whenever the register side is a number or a binary. + # Everything else still routes through the shared bridge so `__lt` + # / `__le` / `__eq` behave exactly as in the register form. + + {@op_less_than_k, dest, a, k} -> + va = :erlang.element(a + 1, regs) + + cond do + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va < k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_binary(va) and is_binary(k) -> + regs = :erlang.setelement(dest + 1, regs, va < k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_cmp(:less_than, va, k, sync(state, cs, cd), proto) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + {@op_less_equal_k, dest, a, k} -> + va = :erlang.element(a + 1, regs) + + cond do + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va <= k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_binary(va) and is_binary(k) -> + regs = :erlang.setelement(dest + 1, regs, va <= k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_cmp(:less_equal, va, k, sync(state, cs, cd), proto) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + + {@op_equal_k, dest, a, k} -> + va = :erlang.element(a + 1, regs) + + cond do + is_number(va) and is_number(k) -> + regs = :erlang.setelement(dest + 1, regs, va == k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + is_binary(va) and is_binary(k) -> + regs = :erlang.setelement(dest + 1, regs, va == k) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + true -> + {value, state} = Executor.dispatcher_cmp(:equal, va, k, sync(state, cs, cd), proto) + regs = :erlang.setelement(dest + 1, regs, value) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + {@op_not, dest, src} -> v = :erlang.element(src + 1, regs) # Inline truthiness — Lua treats nil and false as the only falsy diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 4aa879f1..e36e11c8 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -2122,6 +2122,152 @@ defmodule Lua.VM.Executor do do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) end + # ── Constant-folded arithmetic ───────────────────────────────────────────── + # + # `Lua.Compiler.Peephole` folds the `load_constant` that materialised a + # literal into the operation that consumes it, so `k` is a value rather + # than a register index. Same three tiers as the register forms; the slow + # path hands `k` to the same metamethod bridge, so `__add` / `__sub` / + # `__mul` and the `(local 'n')` error suffix behave identically. + + defp do_execute( + [{:add_k, dest, a, k, _hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) + when is_integer(:erlang.element(a + 1, regs)) and is_integer(k) do + sum = :erlang.element(a + 1, regs) + k + regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(sum)) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + end + + defp do_execute( + [{:add_k, dest, a, k, hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + val_a = elem(regs, a) + + if is_number(val_a) and is_number(k) do + regs = put_elem(regs, dest, val_a + k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + else + src = proto.source + + {result, new_state} = + try_binary_metamethod("__add", val_a, k, state, fn -> + safe_add(val_a, k, line, src, hint_a, nil, state) + end) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + + defp do_execute( + [{:subtract_k, dest, a, k, _hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) + when is_integer(:erlang.element(a + 1, regs)) and is_integer(k) do + diff = :erlang.element(a + 1, regs) - k + regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(diff)) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + end + + defp do_execute( + [{:subtract_k, dest, a, k, hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + val_a = elem(regs, a) + + if is_number(val_a) and is_number(k) do + regs = put_elem(regs, dest, val_a - k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + else + src = proto.source + + {result, new_state} = + try_binary_metamethod("__sub", val_a, k, state, fn -> + safe_subtract(val_a, k, line, src, hint_a, nil, state) + end) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + + defp do_execute( + [{:multiply_k, dest, a, k, _hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) + when is_integer(:erlang.element(a + 1, regs)) and is_integer(k) do + prod = :erlang.element(a + 1, regs) * k + regs = :erlang.setelement(dest + 1, regs, Numeric.to_signed_int64(prod)) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + end + + defp do_execute( + [{:multiply_k, dest, a, k, hint_a} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + val_a = elem(regs, a) + + if is_number(val_a) and is_number(k) do + regs = put_elem(regs, dest, val_a * k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + else + src = proto.source + + {result, new_state} = + try_binary_metamethod("__mul", val_a, k, state, fn -> + safe_multiply(val_a, k, line, src, hint_a, nil, state) + end) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + # ── Comparison operations ────────────────────────────────────────────────── # Comparison fast paths: number-vs-number and string-vs-string skip the @@ -2194,6 +2340,95 @@ defmodule Lua.VM.Executor do end end + # ── Constant-folded comparisons ──────────────────────────────────────────── + # + # A literal can never carry a metatable, so the fast paths fire whenever + # the register side is a number or a binary. Anything else routes through + # the same metamethod helpers as the register forms. + + defp do_execute([{:equal_k, dest, a, k} | rest], regs, upvalues, proto, state, cont, frames, line, instruction_count) do + val_a = elem(regs, a) + + cond do + is_number(val_a) and is_number(k) -> + regs = put_elem(regs, dest, val_a == k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + is_binary(val_a) and is_binary(k) -> + regs = put_elem(regs, dest, val_a == k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + true -> + {result, new_state} = try_equality_metamethod(val_a, k, state, fn -> lua_equal(val_a, k) end) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + + defp do_execute( + [{:less_than_k, dest, a, k} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + val_a = elem(regs, a) + + cond do + is_number(val_a) and is_number(k) -> + regs = put_elem(regs, dest, val_a < k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + is_binary(val_a) and is_binary(k) -> + regs = put_elem(regs, dest, val_a < k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + true -> + src = proto.source + + {result, new_state} = + try_binary_metamethod("__lt", val_a, k, state, fn -> safe_compare_lt(val_a, k, line, src, state) end) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + + defp do_execute( + [{:less_equal_k, dest, a, k} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + val_a = elem(regs, a) + + cond do + is_number(val_a) and is_number(k) -> + regs = put_elem(regs, dest, val_a <= k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + is_binary(val_a) and is_binary(k) -> + regs = put_elem(regs, dest, val_a <= k) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + true -> + {result, new_state} = compare_le(val_a, k, state, line, proto.source) + + regs = put_elem(regs, dest, result) + do_execute(rest, regs, upvalues, proto, new_state, cont, frames, line, instruction_count) + end + end + defp do_execute( [{:greater_than, dest, a, b} | rest], regs, @@ -2496,6 +2731,85 @@ defmodule Lua.VM.Executor do end end + # ── get_field_upvalue ────────────────────────────────────────────────────── + # + # `Lua.Compiler.Peephole` fuses `get_upvalue` + `get_field` into this — the + # shape of every global read outside the chunk itself. Identical to + # `:get_field` except the table comes from the upvalue cell instead of a + # scratch register. + + defp do_execute( + [{:get_field_upvalue, dest, index, name, name_hint} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + cell_ref = elem(upvalues, index) + table_val = :maps.get(cell_ref, state.upvalue_cells, nil) + + case table_val do + {:tref, id} -> + table = :erlang.map_get(id, state.tables) + + case :erlang.map_get(:data, table) do + %{^name => value} -> + regs = put_elem(regs, dest, value) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + _data -> + case :erlang.map_get(:metatable, table) do + nil -> + regs = put_elem(regs, dest, nil) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + _ -> + {value, state} = index_value(table_val, name, state, line, proto.source, name_hint) + regs = put_elem(regs, dest, value) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + end + end + + _ -> + {value, state} = index_value(table_val, name, state, line, proto.source, name_hint) + regs = put_elem(regs, dest, value) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + end + end + + # ── set_field_upvalue ────────────────────────────────────────────────────── + # + # The `set_field` mirror of the fusion above — every global write. + + defp do_execute( + [{:set_field_upvalue, index, name, value_reg, name_hint} | rest], + regs, + upvalues, + proto, + state, + cont, + frames, + line, + instruction_count + ) do + cell_ref = elem(upvalues, index) + table_val = :maps.get(cell_ref, state.upvalue_cells, nil) + + case table_val do + {:tref, _} -> + value = elem(regs, value_reg) + state = table_newindex(table_val, name, value, state) + do_execute(rest, regs, upvalues, proto, state, cont, frames, line, instruction_count) + + _ -> + raise_index_type_error(table_val, line, proto.source, name_hint, state) + end + end + # ── set_field ────────────────────────────────────────────────────────────── defp do_execute( diff --git a/test/lua/compiler/instruction_size_test.exs b/test/lua/compiler/instruction_size_test.exs index 4f377da6..db545169 100644 --- a/test/lua/compiler/instruction_size_test.exs +++ b/test/lua/compiler/instruction_size_test.exs @@ -71,7 +71,8 @@ defmodule Lua.Compiler.InstructionSizeTest do # Structural opcodes: write a range, a fixed offset off a base, or recurse # into nested bodies. @structural [ - {{:load_nil, 5, 3}, 8}, + # `load_nil` clears `count + 1` registers, so 5..8 needs 9 slots. + {{:load_nil, 5, 3}, 9}, {{:vararg, 5, 3}, 8}, {{:vararg, 5, 0}, 6}, {{:self, 5, 1, "m", nil}, 7}, diff --git a/test/lua/compiler/max_registers_invariant_test.exs b/test/lua/compiler/max_registers_invariant_test.exs index 1aa3d878..b5908a53 100644 --- a/test/lua/compiler/max_registers_invariant_test.exs +++ b/test/lua/compiler/max_registers_invariant_test.exs @@ -106,6 +106,19 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do # multi-return values occupy start..top at runtime, but the only # syntactic register operands are table_reg and the start slot. op == Bytecode.op_set_list_multi() -> [1, 2] + # Peephole fusions. The `_k` family's slot 3 is a literal value, not a + # register, so only dest and the left operand count. + # `get_field_upvalue`'s slot 2 is an upvalue index and + # `set_field_upvalue`'s slot 1 likewise — neither indexes the register + # file, and both would blow past `max_registers` if counted. + op == Bytecode.op_add_k() -> [1, 2] + op == Bytecode.op_subtract_k() -> [1, 2] + op == Bytecode.op_multiply_k() -> [1, 2] + op == Bytecode.op_less_than_k() -> [1, 2] + op == Bytecode.op_less_equal_k() -> [1, 2] + op == Bytecode.op_equal_k() -> [1, 2] + op == Bytecode.op_get_field_upvalue() -> [1] + op == Bytecode.op_set_field_upvalue() -> [3] true -> raise "register_positions/1 is missing a case for opcode #{inspect(op)}" end end diff --git a/test/lua/compiler/peephole_test.exs b/test/lua/compiler/peephole_test.exs new file mode 100644 index 00000000..bc1c01b6 --- /dev/null +++ b/test/lua/compiler/peephole_test.exs @@ -0,0 +1,724 @@ +defmodule Lua.Compiler.PeepholeTest do + @moduledoc """ + Pins the peephole pass: the rewrites it performs, the rewrites it must + refuse, and — the load-bearing part — that turning it on changes nothing + an observer can see. + + The differential compiles each program twice, once with `peephole: false` + and once with it on, evaluates both, and compares results, printed output, + and (for the failing battery) the rendered exception byte for byte. The + rewritten stream must also never need a wider register file or more + instruction slots than the stream it came from. + """ + + use ExUnit.Case, async: true + use ExUnitProperties + + import ExUnit.CaptureIO + + alias Lua.Compiler + alias Lua.Compiler.Bytecode + alias Lua.Compiler.Codegen + alias Lua.Compiler.Prototype + alias Lua.Parser + + defp compile!(source, opts \\ []) do + {:ok, ast} = Parser.parse_structured(source) + {:ok, proto} = Compiler.compile(ast, Keyword.merge([source: "peephole-test.lua"], opts)) + proto + end + + defp run(source, opts) do + proto = compile!(source, opts) + chunk = %Lua.Chunk{prototype: proto} + + fn -> + result = + try do + {results, _lua} = Lua.eval!(Lua.new(), chunk) + {:ok, results} + rescue + e -> {:error, Lua.format_exception(e)} + end + + send(self(), {:result, result}) + end + |> capture_io() + |> then(fn output -> + receive do + {:result, result} -> {result, output} + end + end) + end + + # Every opcode tag in a prototype tree, own instructions only. + defp opcodes(%Prototype{} = proto) do + tags(proto.instructions) ++ Enum.flat_map(proto.prototypes, &opcodes/1) + end + + defp tags(instructions) do + Enum.flat_map(instructions, fn + instr when is_tuple(instr) -> + [:erlang.element(1, instr) | Enum.flat_map(bodies(instr), &tags/1)] + + atom -> + [atom] + end) + end + + defp bodies({:test, _reg, then_body, else_body}), do: [then_body, else_body] + defp bodies({:test_and, _dest, _source, body}), do: [body] + defp bodies({:test_or, _dest, _source, body}), do: [body] + defp bodies({:while_loop, cond_body, _reg, body}), do: [cond_body, body] + defp bodies({:repeat_loop, body, cond_body, _reg}), do: [body, cond_body] + defp bodies({:numeric_for, _base, _loop_var, body}), do: [body] + defp bodies({:generic_for, _base, _var_regs, body}), do: [body] + defp bodies(_instr), do: [] + + defp count_instructions(%Prototype{} = proto) do + length(opcodes(proto)) + end + + # Walks a prototype tree pairwise, applying `fun` to each matched pair. + defp zip_protos(%Prototype{} = a, %Prototype{} = b, fun) do + fun.(a, b) + + a.prototypes + |> Enum.zip(b.prototypes) + |> Enum.each(fn {child_a, child_b} -> zip_protos(child_a, child_b, fun) end) + end + + describe "move elision" do + test "retargets an adjacent producer at the move's destination" do + proto = compile!("function f(t) local x = t.a return x end") + [f] = proto.prototypes + + # `get_field tmp, t, "a"` + `move x, tmp` collapses into a single + # `get_field x, t, "a"`. + assert Enum.count(opcodes(f), &(&1 == :move)) == 0 + assert Enum.count(opcodes(f), &(&1 == :get_field)) == 1 + end + + test "finds the copy across intervening transparent instructions" do + # The `for` header loads three temporaries and then copies all three + # into the control triple, so no producer is adjacent to its copy. + before = compile!("function f(n) for i = 1, n do end end", peephole: false) + after_pass = compile!("function f(n) for i = 1, n do end end") + + [before_f] = before.prototypes + [after_f] = after_pass.prototypes + + # Three loads and three copies become three loads; the empty body's + # block close goes too. + assert count_instructions(before_f) - count_instructions(after_f) >= 2 + end + + test "refuses to coalesce when the temporary is read again" do + # `x` is used twice, so the register holding it is live past the copy. + proto = compile!("function f(t) local x = t.a return x + x end") + [f] = proto.prototypes + + assert :get_field in opcodes(f) + end + + test "a conditional reassignment still wins" do + source = "function f(t, c) local x = t.a if c then x = 1 end return x end" + + before = compile!(source, peephole: false) + after_pass = compile!(source) + + # Whatever it rewrites, it must not widen the frame. + zip_protos(before, after_pass, fn a, b -> assert b.max_registers <= a.max_registers end) + + assert {[7, 1], _} = + Lua.eval!(source <> " return f({a = 7}, false), f({a = 7}, true)") + end + end + + describe "move elision across loop exits" do + # A local written unconditionally inside a loop body and read only + # after the loop. The back edge overwrites it every iteration, but the + # exit path reads the final iteration's value — the write to the local + # must survive. The call argument copy is the bait: with an unsound + # scan, a second elision retargets the producer back at the temporary + # and the local is never written at all. + @live_out_cases [ + {"numeric for with call argument", + """ + local function id(x) return x end + local c = 0 + for i = 1, 2 do + c = i + local y = id(c) + end + return c + """, [2]}, + {"numeric for with arithmetic producer", + """ + local function id(x) return x end + local c = 0 + for i = 1, 2 do + c = i + 1 + local y = id(c) + end + return c + """, [3]}, + {"while loop", + """ + local function id(x) return x end + local c = 0 + local i = 0 + while i < 2 do + i = i + 1 + c = i + local y = id(c) + end + return c + """, [2]}, + {"repeat loop", + """ + local function id(x) return x end + local c = 0 + local i = 0 + repeat + i = i + 1 + c = i + local y = id(c) + until i >= 2 + return c + """, [2]}, + {"generic for", + """ + local function id(x) return x end + local c = 0 + for _, v in ipairs({1, 2}) do + c = v + local y = id(c) + end + return c + """, [2]}, + {"method call argument", + """ + local o = {} + function o:m(x) return x end + local c = 0 + for i = 1, 2 do + c = i + local y = o:m(c) + end + return c + """, [2]}, + {"inside a nested function", + """ + local function id(x) return x end + local function run() + local c = 0 + for i = 1, 2 do + c = i + local y = id(c) + end + return c + end + return run() + """, [2]} + ] + + for {name, source, expected} <- @live_out_cases do + test "#{name}: the loop-exit read keeps the write alive" do + source = unquote(source) + expected = unquote(Macro.escape(expected)) + + assert {{:ok, ^expected}, ""} = run(source, peephole: false) + assert {{:ok, ^expected}, ""} = run(source, peephole: true) + end + end + end + + describe "constant folding" do + test "folds a literal right operand into the arithmetic op" do + proto = compile!("function f(n) return n - 1 end") + [f] = proto.prototypes + + assert :subtract_k in opcodes(f) + refute :subtract in opcodes(f) + refute :load_constant in opcodes(f) + end + + test "folds a literal right operand into a comparison" do + proto = compile!("function f(n) if n < 2 then return n end return 0 end") + [f] = proto.prototypes + + assert :less_than_k in opcodes(f) + refute :less_than in opcodes(f) + end + + test "leaves the register form alone when both operands are registers" do + proto = compile!("function f(a, b) return a - b end") + [f] = proto.prototypes + + assert :subtract in opcodes(f) + refute :subtract_k in opcodes(f) + end + + test "does not fold a literal on the left" do + proto = compile!("function f(n) return 1 - n end") + [f] = proto.prototypes + + assert :subtract in opcodes(f) + refute :subtract_k in opcodes(f) + end + + test "does not fold operations with no _k variant" do + proto = compile!("function f(n) return n / 2, n % 2, n ^ 2 end") + [f] = proto.prototypes + + assert :divide in opcodes(f) + assert :modulo in opcodes(f) + assert :power in opcodes(f) + end + + test "the folded form preserves the operand hint" do + proto = compile!("function f(n) return n - 1 end") + [f] = proto.prototypes + + assert [{:subtract_k, _dest, _a, 1, {:local, "n"}}] = + Enum.filter(f.instructions, &match?({:subtract_k, _, _, _, _}, &1)) + end + end + + describe "upvalue-field fusion" do + test "fuses the global read every free name compiles to" do + proto = compile!("function f() return print end") + [f] = proto.prototypes + + assert :get_field_upvalue in opcodes(f) + refute :get_upvalue in opcodes(f) + refute :get_field in opcodes(f) + end + + test "fuses the global write" do + proto = compile!("function f() x = 1 end") + [f] = proto.prototypes + + assert :set_field_upvalue in opcodes(f) + refute :get_upvalue in opcodes(f) + end + + test "leaves the chunk's own _ENV alone — it lives in a register, not an upvalue" do + proto = compile!("x = 1 return x") + + refute :get_field_upvalue in tags(proto.instructions) + refute :set_field_upvalue in tags(proto.instructions) + end + end + + describe "unreachable code" do + test "drops the block close codegen appends after a return" do + proto = compile!("function f(n) if n < 2 then return n end return 0 end") + [f] = proto.prototypes + + refute :close_upvalues in opcodes(f) + end + end + + describe "redundant close_upvalues" do + test "a closure-free function keeps none" do + proto = compile!("function f(n) local s = 0 for i = 1, n do local t = i * 2 s = s + t end return s end") + [f] = proto.prototypes + + refute :close_upvalues in opcodes(f) + end + + test "a function that builds a closure keeps all of them" do + source = """ + function f(n) + local acc = {} + for i = 1, n do + local v = i + acc[i] = function() return v end + end + return acc + end + """ + + before = compile!(source, peephole: false) + after_pass = compile!(source) + + [before_f] = before.prototypes + [after_f] = after_pass.prototypes + + assert Enum.count(opcodes(before_f), &(&1 == :close_upvalues)) == + Enum.count(opcodes(after_f), &(&1 == :close_upvalues)) + end + + test "captured loop locals still see their own value per iteration" do + assert {[1, 2, 3], _} = + Lua.eval!(""" + local acc = {} + for i = 1, 3 do + local v = i + acc[i] = function() return v end + end + return acc[1](), acc[2](), acc[3]() + """) + end + end + + describe "goto opt-out" do + test "a function containing a label is left exactly as codegen emitted it" do + source = """ + function f(n) + local i = 0 + ::top:: + i = i + 1 + if i < n then goto top end + return i + end + """ + + before = compile!(source, peephole: false) + after_pass = compile!(source) + + assert before.prototypes |> hd() |> Map.get(:instructions) == + after_pass.prototypes |> hd() |> Map.get(:instructions) + end + end + + describe "fib" do + test "compiles to the fused ten-opcode form in four registers" do + proto = + compile!(""" + function fib(n) + if n < 2 then return n end + return fib(n-1) + fib(n-2) + end + """) + + [fib] = proto.prototypes + + assert fib.max_registers == 4 + assert tuple_size(fib.bytecode) == 10 + assert Bytecode.fully_compiled?(proto) + end + + test "still computes fib" do + assert {[610], _} = + Lua.eval!(""" + function fib(n) + if n < 2 then return n end + return fib(n-1) + fib(n-2) + end + return fib(15) + """) + end + end + + # A corpus broad enough that a mis-scoped rewrite shows up somewhere: + # every control-flow shape, closures over loop variables, metatables, + # varargs, multi-return, coroutines, string building, and pcall. + @corpus [ + "return 1 + 2 * 3 - 4", + "local x = 5 return x * x, x - 1, x + 1", + "function fib(n) if n < 2 then return n end return fib(n-1) + fib(n-2) end return fib(12)", + "local s = 0 for i = 1, 20 do s = s + i end return s", + "local s = 0 for i = 20, 1, -2 do s = s + i end return s", + "local i, s = 0, 0 while i < 10 do i = i + 1 s = s + i end return i, s", + "local i = 0 repeat i = i + 1 until i >= 7 return i", + "local t = {} for i = 1, 5 do t[i] = i * i end local s = 0 for _, v in ipairs(t) do s = s + v end return s", + "local t = {a = 1, b = 2, c = 3} local n = 0 for k, v in pairs(t) do n = n + v end return n", + "local t = {1, 2, 3, 4, 5} return #t, t[1], t[5]", + "for i = 1, 10 do if i > 4 then break end end return 'done'", + "local a = nil return a or 'fallback', a and 'never'", + "local function add(a, b) return a + b end return add(3, 4)", + "local acc = {} for i = 1, 3 do local v = i acc[i] = function() return v end end return acc[1](), acc[3]()", + "local c = 0 local function inc() c = c + 1 return c end inc() inc() return inc()", + "local function many() return 1, 2, 3 end local a, b, c = many() return a, b, c", + "local function many() return 1, 2, 3 end return {many()}", + "local function v(...) return select('#', ...), ... end return v(1, 2, 3)", + "local function v(...) local t = {...} return #t end return v('a', 'b', 'c', 'd')", + "local mt = {__add = function(a, b) return 'added' end} local t = setmetatable({}, mt) return t + 1", + "local mt = {__index = function(_, k) return k .. '!' end} local t = setmetatable({}, mt) return t.hi", + "local mt = {__lt = function() return true end} local a = setmetatable({}, mt) local b = setmetatable({}, mt) return a < b", + "local mt = {__newindex = function(t, k, v) rawset(t, k, v * 2) end} local t = setmetatable({}, mt) t.x = 5 return t.x", + "local ok, err = pcall(function() error('boom') end) return ok, err", + "local ok, err = pcall(function() local x = nil return x.y end) return ok, type(err)", + "return tostring(1) .. '-' .. tostring(2.5) .. '-' .. tostring(true)", + "local s = '' for i = 1, 8 do s = s .. i end return s", + "return string.format('%d %s %.2f', 7, 'x', 1.5)", + "return string.upper('abc'), string.sub('hello', 2, 4), #('hello')", + "return math.max(1, 9, 3), math.min(1, 9, 3), math.floor(2.7)", + "return 7 // 2, 7 % 2, 2 ^ 10, -7 // 2", + "return 5 & 3, 5 | 3, 5 ~ 3, ~0, 1 << 4, 256 >> 4", + "local t = {} for i = 1, 5 do table.insert(t, 6 - i) end table.sort(t) return table.concat(t, ',')", + """ + Animal = {} + Animal.__index = Animal + function Animal.new(name) local o = setmetatable({}, Animal) o.name = name return o end + function Animal:speak() return self.name .. ' speaks' end + local a = Animal.new('cat') + return a:speak() + """, + """ + local co = coroutine.create(function(a) + local b = coroutine.yield(a + 1) + return b * 2 + end) + local _, x = coroutine.resume(co, 1) + local _, y = coroutine.resume(co, 10) + return x, y + """, + """ + local i = 0 + ::top:: + i = i + 1 + if i < 5 then goto top end + return i + """, + """ + local function outer() + local n = 0 + return function() n = n + 1 return n end, function() return n end + end + local inc, get = outer() + inc() inc() + return get() + """, + """ + local t = {} + for i = 1, 4 do + for j = 1, 4 do + t[#t + 1] = i * j + end + end + return #t, t[1], t[16] + """, + "print('one') print(2) print(nil, true) return 'printed'", + # The folded `_k` forms have to reach the metamethod bridge with the + # constant boxed, and the fused upvalue-field forms have to reach + # `__index` / `__newindex` on `_ENV`. These are the interactions the + # fusions could plausibly break. + """ + local mt = { + __add = function(_, b) return 'ADD:' .. tostring(b) end, + __sub = function(_, b) return 'SUB:' .. tostring(b) end, + __mul = function(_, b) return 'MUL:' .. tostring(b) end + } + local t = setmetatable({}, mt) + function f(x) return x + 1, x - 2, x * 3 end + return f(t) + """, + """ + local mt = {__lt = function() return 'LT' end, __le = function() return 'LE' end} + local t = setmetatable({}, mt) + function f(x) return (x < 1), (x <= 1) end + return f(t) + """, + "function f(x) return x - 1 end return f('10')", + "function f(x) return x + 1, x - 1 end return f(math.maxinteger)", + "function f(x) return x * 2, x + 0.5 end return f(1.5)", + "function f(x) return x == 1, x == 'a', x == nil end return f(1)", + """ + setmetatable(_G, {__index = function(_, k) return 'G:' .. k end}) + function f() return missing_global end + return f() + """, + """ + local log = {} + setmetatable(_G, {__newindex = function(t, k, v) log[#log + 1] = k rawset(t, k, v) end}) + function f() written = 7 end + f() + return written, log[#log] + """ + ] + + describe "differential: peephole off vs on" do + for {source, index} <- Enum.with_index(@corpus) do + test "corpus ##{index} evaluates identically #{inspect(String.slice(source, 0, 40))}" do + source = unquote(source) + + assert run(source, peephole: false) == run(source, peephole: true) + end + end + + test "fixture files evaluate identically" do + for path <- Path.wildcard(Path.join(__DIR__, "../../fixtures/*.lua")), + match?({:ok, _}, Parser.parse_structured(File.read!(path))) do + source = File.read!(path) + + # Some fixtures exist to fail at run time; both sides must fail the + # same way. + assert run(source, peephole: false) == run(source, peephole: true), + "#{Path.basename(path)} diverged between peephole off and on" + end + end + end + + describe "differential: error rendering" do + @failing [ + "local n = nil return n + 1", + "local n = nil return 1 + n", + "local t = {} return t.a.b", + "local t = nil t.x = 1", + "return nil .. 'x'", + "return 'a' < 1", + "local f = nil return f()", + "error('explicit')", + "error({code = 1})", + "local t = setmetatable({}, {}) return t < t", + "assert(false, 'assert message')", + "local x = 'str' return x - 1", + "local h = math.huge return h .. {}", + "for i = 1, 'x' do end", + "local function f(n) return n * 2 end return f({})", + # The folded forms must render the same operand hint as the register + # forms they replaced. + "local function f(n) return n - 1 end return f({})", + "local function f(n) return n + 1 end return f('abc')", + "local function f(n) if n < 1 then return 0 end return n end return f({})" + ] + + for {source, index} <- Enum.with_index(@failing) do + test "failure ##{index} renders identically #{inspect(String.slice(source, 0, 40))}" do + source = unquote(source) + + {{:error, off}, _} = run(source, peephole: false) + {{:error, on}, _} = run(source, peephole: true) + + assert off == on + end + end + end + + # ── Randomized differential ───────────────────────────────────────────── + # + # Small integer programs over three locals: unconditional writes inside + # loop bodies, call-argument copies through helper functions, and reads + # after the loop — exactly the shapes move elision and constant folding + # rewrite, arranged by a generator instead of by hand. + + @program_vars ~w(a b c) + + defp gen_leaf(vars) do + one_of([ + member_of(vars), + map(integer(-9..9), &Integer.to_string/1) + ]) + end + + defp gen_expr(vars) do + leaf = gen_leaf(vars) + + one_of([ + leaf, + map({leaf, member_of(["+", "-"]), leaf}, fn {a, op, b} -> "(#{a} #{op} #{b})" end), + # Multiplication only by a literal keeps repeated self-multiplication + # from wandering into slow huge-integer territory. + map({leaf, integer(-9..9)}, fn {a, k} -> "(#{a} * #{k})" end), + map(leaf, fn a -> "id(#{a})" end), + map({leaf, leaf}, fn {a, b} -> "add2(#{a}, #{b})" end) + ]) + end + + defp gen_statement(loop_vars) do + expr = gen_expr(@program_vars ++ loop_vars) + + one_of([ + map({member_of(@program_vars), expr}, fn {v, e} -> "#{v} = #{e}" end), + map(expr, fn e -> "local t = #{e}" end) + ]) + end + + defp gen_body(loop_vars) do + map(list_of(gen_statement(loop_vars), min_length: 1, max_length: 4), &Enum.join(&1, "\n")) + end + + defp gen_loop do + one_of([ + map({integer(1..3), gen_body(["i"])}, fn {limit, body} -> + "for i = 1, #{limit} do\n#{body}\nend" + end), + map({integer(1..3), gen_body(["n"])}, fn {limit, body} -> + "local n = 0\nwhile n < #{limit} do\nn = n + 1\n#{body}\nend" + end), + map({integer(1..3), gen_body(["n"])}, fn {limit, body} -> + "local n = 0\nrepeat\nn = n + 1\n#{body}\nuntil n >= #{limit}" + end), + map(gen_body(["v"]), fn body -> + "for _, v in ipairs({1, 2, 3}) do\n#{body}\nend" + end) + ]) + end + + defp gen_program do + inits = list_of(integer(-9..9), length: 3) + loops = list_of(gen_loop(), min_length: 1, max_length: 2) + + map({inits, loops}, fn {[a, b, c], loops} -> + """ + local function id(x) return x end + local function add2(x, y) return x + y end + local a = #{a} + local b = #{b} + local c = #{c} + #{Enum.join(loops, "\n")} + return a, b, c, a + b + c + """ + end) + end + + describe "differential: randomized loop programs" do + property "every generated program evaluates identically with the pass off and on" do + check all(source <- gen_program(), max_runs: 300) do + assert run(source, peephole: false) == run(source, peephole: true) + end + end + end + + describe "register and instruction budgets" do + # Compiling the Lua 5.3 conformance suite is a far broader structural + # corpus than anything hand-written here: every construct the language + # has, at scale. These do not need to *run* to prove the pass never + # widens a frame or grows a body. + @suite_files Path.wildcard(Path.join(__DIR__, "../../lua53_tests/*.lua")) + + for path <- @suite_files do + test "#{Path.basename(path)} never widens the frame or grows the stream" do + source = File.read!(unquote(path)) + + case Parser.parse_structured(source) do + {:ok, ast} -> + {:ok, before} = Compiler.compile(ast, source: "suite.lua", peephole: false) + {:ok, after_pass} = Compiler.compile(ast, source: "suite.lua", peephole: true) + + zip_protos(before, after_pass, fn a, b -> + assert b.max_registers <= a.max_registers, + "max_registers grew from #{a.max_registers} to #{b.max_registers}" + + assert Codegen.instruction_peak(b.instructions) <= Codegen.instruction_peak(a.instructions), + "instruction_peak grew" + + assert count_instructions(b) <= count_instructions(a), + "instruction count grew" + + assert b.max_registers >= Codegen.instruction_peak(b.instructions), + "max_registers no longer covers the register peak" + end) + + {:error, _parse_errors} -> + # A few suite files are deliberately unparseable fragments. + :ok + end + end + end + + test "the corpus stays fully dispatcher-compiled" do + for source <- @corpus do + before = compile!(source, peephole: false) + after_pass = compile!(source) + + assert Bytecode.fully_compiled?(after_pass) == Bytecode.fully_compiled?(before), + "dispatcher coverage changed for: #{String.slice(source, 0, 60)}" + end + end + end +end diff --git a/test/lua/vm/upvalue_test.exs b/test/lua/vm/upvalue_test.exs index 72c51cf3..d7283e1f 100644 --- a/test/lua/vm/upvalue_test.exs +++ b/test/lua/vm/upvalue_test.exs @@ -409,7 +409,11 @@ defmodule Lua.VM.UpvalueTest do """ assert {:ok, ast} = Parser.parse(code) - assert {:ok, proto} = Compiler.compile(ast, source: "test.lua") + # The watermark is a scope-analysis property, so read it off the raw + # codegen stream. This chunk creates no closures, so the peephole pass + # drops the `close_upvalues` opcodes it would otherwise be observed + # through — correctly, but that hides what is under test here. + assert {:ok, proto} = Compiler.compile(ast, source: "test.lua", peephole: false) assert [_, _] = thresholds = close_thresholds(proto) assert thresholds == Enum.uniq(thresholds) @@ -419,7 +423,8 @@ defmodule Lua.VM.UpvalueTest do # Same program as above, built through the public `Lua.AST.Builder` # rather than the parser, so the nodes start without `meta.id`. The two # `for` bodies are equal terms; only compile-time id stamping keeps - # their close-upvalue watermarks apart. + # their close-upvalue watermarks apart. The peephole pass is off for the + # same reason as above: it drops the opcodes the watermark is read from. chunk = Builder.chunk([ Builder.do_block([ @@ -432,7 +437,7 @@ defmodule Lua.VM.UpvalueTest do Builder.for_num("i", Builder.number(1), Builder.number(1), []) ]) - assert {:ok, proto} = Compiler.compile(chunk, source: "test.lua") + assert {:ok, proto} = Compiler.compile(chunk, source: "test.lua", peephole: false) assert [_, _] = thresholds = close_thresholds(proto) assert thresholds == Enum.uniq(thresholds) diff --git a/website/lib/website/lua_sandbox.ex b/website/lib/website/lua_sandbox.ex index 08c7056b..f7ed8484 100644 --- a/website/lib/website/lua_sandbox.ex +++ b/website/lib/website/lua_sandbox.ex @@ -459,6 +459,16 @@ defmodule Website.LuaSandbox do defp format_op_args(:get_field, [d, t, name | _]), do: ~s|r#{d}, r#{t}.#{name}| defp format_op_args(:set_field, [t, name, v | _]), do: ~s|r#{t}.#{name}, r#{v}| + # Peephole fusions: the `_k` family's right operand is an inline literal, + # and the upvalue-field pair indexes the upvalue table rather than a + # register. + defp format_op_args(op, [d, a, k | _]) + when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k], + do: "r#{d}, r#{a}, #{format_lit(k)}" + + defp format_op_args(:get_field_upvalue, [d, idx, name | _]), do: ~s|r#{d}, up[#{idx}].#{name}| + defp format_op_args(:set_field_upvalue, [idx, name, v | _]), do: ~s|up[#{idx}].#{name}, r#{v}| + defp format_op_args(:set_list, [t, s, c, o]), do: "r#{t}, start=#{s}, count=#{count(c)}, off=#{o}" diff --git a/website/lib/website_web/bytecode.ex b/website/lib/website_web/bytecode.ex index 8bcac046..4571e8f5 100644 --- a/website/lib/website_web/bytecode.ex +++ b/website/lib/website_web/bytecode.ex @@ -60,7 +60,20 @@ defmodule DemoWeb.Bytecode do do: "text-secondary font-semibold" def op_class(op) - when op in [:new_table, :set_list, :get_table, :set_table, :get_field, :set_field], + when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k], + do: "text-secondary font-semibold" + + def op_class(op) + when op in [ + :new_table, + :set_list, + :get_table, + :set_table, + :get_field, + :set_field, + :get_field_upvalue, + :set_field_upvalue + ], do: "text-info font-semibold" def op_class(_), do: "text-success font-semibold" @@ -115,6 +128,13 @@ defmodule DemoWeb.Bytecode do defp do_format(:get_field, [d, t, name | _]), do: ~s|r#{d}, r#{t}.#{name}| defp do_format(:set_field, [t, name, v | _]), do: ~s|r#{t}.#{name}, r#{v}| + defp do_format(op, [d, a, k | _]) + when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k], + do: "r#{d}, r#{a}, #{format_lit(k)}" + + defp do_format(:get_field_upvalue, [d, idx, name | _]), do: ~s|r#{d}, up[#{idx}].#{name}| + defp do_format(:set_field_upvalue, [idx, name, v | _]), do: ~s|up[#{idx}].#{name}, r#{v}| + defp do_format(:set_list, [t, s, c, o]), do: "r#{t}, start=#{s}, count=#{c}, off=#{o}" @@ -218,6 +238,17 @@ defmodule DemoWeb.Bytecode do equal: "Compare `a == b` and write `true` or `false` to a register.", less_than: "Compare `a < b` and write the boolean result.", less_equal: "Compare `a <= b` and write the boolean result.", + add_k: + "Compute `a + K` where `K` is a literal baked into the instruction — no register is spent materialising the constant.", + subtract_k: "Compute `a - K` with the literal inline. This is what `n - 1` compiles to.", + multiply_k: "Compute `a * K` with the literal inline.", + equal_k: "Compare `a == K` against an inline literal.", + less_than_k: "Compare `a < K` against an inline literal. This is what `n < 2` compiles to.", + less_equal_k: "Compare `a <= K` against an inline literal.", + get_field_upvalue: + "Read `up[i].name` in one step. Every global read inside a function is this shape: `_ENV` is an upvalue and the name is a field of it.", + set_field_upvalue: + "Write `up[i].name` in one step — the global-assignment counterpart of `get_field_upvalue`.", bitwise_and: "Compute `a & b` (bitwise AND).", bitwise_or: "Compute `a | b` (bitwise OR).", bitwise_xor: "Compute `a ~ b` (bitwise XOR — the binary `~`).", @@ -359,6 +390,15 @@ defmodule DemoWeb.Bytecode do op when op in [:equal, :less_than, :less_equal] -> "rD, rA, rB" + op when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k] -> + "rD, rA, K" + + :get_field_upvalue -> + "rD, up[i], name" + + :set_field_upvalue -> + "up[i], name, rS" + op when op in [:negate, :not, :length, :bitwise_not] -> "rD, rS" diff --git a/website/lib/website_web/live/opcodes_live.ex b/website/lib/website_web/live/opcodes_live.ex index 963ad3b9..d3d50a76 100644 --- a/website/lib/website_web/live/opcodes_live.ex +++ b/website/lib/website_web/live/opcodes_live.ex @@ -79,6 +79,22 @@ defmodule DemoWeb.OpcodesLive do blurb: "Build nested functions with captured upvalues.", ops: [:closure] }, + %{ + id: "fused", + title: "Fused forms", + blurb: + "Emitted by the peephole pass, never by codegen directly. Each collapses a pair of instructions the naive lowering would otherwise have produced.", + ops: [ + :add_k, + :subtract_k, + :multiply_k, + :equal_k, + :less_than_k, + :less_equal_k, + :get_field_upvalue, + :set_field_upvalue + ] + }, %{ id: "meta", title: "Metadata",