From ad0c0afcfce33d462dbdd8be708634d3a2a9d841 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Sun, 26 Jul 2026 23:50:30 -0400 Subject: [PATCH 1/6] compiler: add a codegen peephole pass with fused and constant opcodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codegen lowers naively: a fresh temporary for every intermediate value copied into place, _ENV re-read from the upvalue table before every global access, and every literal materialised into a register. A new Lua.Compiler.Peephole pass runs between codegen and bytecode encoding and cleans that up: 1. move elision (retarget the producer, drop the copy) 2. constant folding into _k opcode variants 3. get_upvalue + get_field/set_field fusion (every global access) 4. unreachable code after an unconditional return/break 5. set_upvalue + get_upvalue round-trip collapse 6. close_upvalues removal in closure-free functions plus a recomputed max_registers, clamped so it can only shrink. Every rewrite is gated on the rewritten temporary being dead, answered by scanning the instructions that can follow — including the loop back edge, spelled out instruction by instruction. reads?/3 defaults to 'reads everything' for unrecognised shapes, and functions containing goto opt out entirely. The eight new opcodes (add_k, subtract_k, multiply_k, less_than_k, less_equal_k, equal_k, get_field_upvalue, set_field_upvalue) have handlers in both engines, so the interpreter and the dispatcher stay in lockstep. Their slow paths bridge to the same metamethod helpers as the register forms, and the folded arithmetic keeps hint_a, so error rendering is byte-identical. fib's body goes from 19 opcodes in 6 registers to 10 in 4; a run_oop-style loop body from 30 instructions to 19. Compile with peephole: false to skip the pass; the new differential test compiles a corpus both ways and compares results, output, and rendered exceptions. --- lib/lua/compiler.ex | 42 +- lib/lua/compiler/bytecode.ex | 41 + lib/lua/compiler/codegen.ex | 4 + lib/lua/compiler/instruction.ex | 23 + lib/lua/compiler/peephole.ex | 752 ++++++++++++++++++ lib/lua/vm/dispatcher.ex | 208 +++++ lib/lua/vm/executor.ex | 314 ++++++++ .../compiler/max_registers_invariant_test.exs | 13 + test/lua/compiler/peephole_test.exs | 498 ++++++++++++ test/lua/vm/upvalue_test.exs | 6 +- website/lib/website/lua_sandbox.ex | 10 + website/lib/website_web/bytecode.ex | 42 +- website/lib/website_web/live/opcodes_live.ex | 16 + 13 files changed, 1956 insertions(+), 13 deletions(-) create mode 100644 lib/lua/compiler/peephole.ex create mode 100644 test/lua/compiler/peephole_test.exs diff --git a/lib/lua/compiler.ex b/lib/lua/compiler.ex index 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..fd89c7e5 100644 --- a/lib/lua/compiler/codegen.ex +++ b/lib/lua/compiler/codegen.ex @@ -160,6 +160,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..5f3379a1 --- /dev/null +++ b/lib/lua/compiler/peephole.ex @@ -0,0 +1,752 @@ +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, plus the + enclosing loop instruction itself (which covers the back edge). 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. Every rewrite only ever removes reads, + # so scanning a not-yet-optimised tail is conservative in the safe + # direction. + + 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 kill on the back edge + # — a temporary rewritten at the top of every iteration is dead at the + # bottom of the previous one, which is most of them. The loop instruction + # with its bodies emptied stands in for the header's own register reads + # (the `for` control triple, the `while` test register). + 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, [[], []])] + [[header ++ body ++ cond_body | future], [cond_body ++ header ++ body | future]] + end + + defp body_futures({:repeat_loop, body, cond_body, _reg} = instr, future) do + header = [put_bodies(instr, [[], []])] + [[cond_body ++ header ++ body | future], [header ++ body ++ cond_body | future]] + end + + defp body_futures({:numeric_for, _base, _loop_var, body} = instr, future) do + [[[put_bodies(instr, [[]])] ++ body | future]] + end + + defp body_futures({:generic_for, _base, _var_regs, body} = instr, future) do + [[[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. + # + # 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, [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 + + defp highest_read(instructions, prototypes, limit) do + Enum.reduce(0..(limit - 1)//1, 0, fn reg, acc -> + if any_reads?(instructions, reg, prototypes), do: reg + 1, else: acc + 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/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..223f3619 --- /dev/null +++ b/test/lua/compiler/peephole_test.exs @@ -0,0 +1,498 @@ +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 + + 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 "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'" + ] + + 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({})" + ] + + 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 + + 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..5cd3f5bd 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) diff --git a/website/lib/website/lua_sandbox.ex b/website/lib/website/lua_sandbox.ex index 08c7056b..f7ed8484 100644 --- a/website/lib/website/lua_sandbox.ex +++ b/website/lib/website/lua_sandbox.ex @@ -459,6 +459,16 @@ defmodule Website.LuaSandbox do defp format_op_args(:get_field, [d, t, name | _]), do: ~s|r#{d}, r#{t}.#{name}| defp format_op_args(:set_field, [t, name, v | _]), do: ~s|r#{t}.#{name}, r#{v}| + # Peephole fusions: the `_k` family's right operand is an inline literal, + # and the upvalue-field pair indexes the upvalue table rather than a + # register. + defp format_op_args(op, [d, a, k | _]) + when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k], + do: "r#{d}, r#{a}, #{format_lit(k)}" + + defp format_op_args(:get_field_upvalue, [d, idx, name | _]), do: ~s|r#{d}, up[#{idx}].#{name}| + defp format_op_args(:set_field_upvalue, [idx, name, v | _]), do: ~s|up[#{idx}].#{name}, r#{v}| + defp format_op_args(:set_list, [t, s, c, o]), do: "r#{t}, start=#{s}, count=#{count(c)}, off=#{o}" diff --git a/website/lib/website_web/bytecode.ex b/website/lib/website_web/bytecode.ex index 8bcac046..4571e8f5 100644 --- a/website/lib/website_web/bytecode.ex +++ b/website/lib/website_web/bytecode.ex @@ -60,7 +60,20 @@ defmodule DemoWeb.Bytecode do do: "text-secondary font-semibold" def op_class(op) - when op in [:new_table, :set_list, :get_table, :set_table, :get_field, :set_field], + when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k], + do: "text-secondary font-semibold" + + def op_class(op) + when op in [ + :new_table, + :set_list, + :get_table, + :set_table, + :get_field, + :set_field, + :get_field_upvalue, + :set_field_upvalue + ], do: "text-info font-semibold" def op_class(_), do: "text-success font-semibold" @@ -115,6 +128,13 @@ defmodule DemoWeb.Bytecode do defp do_format(:get_field, [d, t, name | _]), do: ~s|r#{d}, r#{t}.#{name}| defp do_format(:set_field, [t, name, v | _]), do: ~s|r#{t}.#{name}, r#{v}| + defp do_format(op, [d, a, k | _]) + when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k], + do: "r#{d}, r#{a}, #{format_lit(k)}" + + defp do_format(:get_field_upvalue, [d, idx, name | _]), do: ~s|r#{d}, up[#{idx}].#{name}| + defp do_format(:set_field_upvalue, [idx, name, v | _]), do: ~s|up[#{idx}].#{name}, r#{v}| + defp do_format(:set_list, [t, s, c, o]), do: "r#{t}, start=#{s}, count=#{c}, off=#{o}" @@ -218,6 +238,17 @@ defmodule DemoWeb.Bytecode do equal: "Compare `a == b` and write `true` or `false` to a register.", less_than: "Compare `a < b` and write the boolean result.", less_equal: "Compare `a <= b` and write the boolean result.", + add_k: + "Compute `a + K` where `K` is a literal baked into the instruction — no register is spent materialising the constant.", + subtract_k: "Compute `a - K` with the literal inline. This is what `n - 1` compiles to.", + multiply_k: "Compute `a * K` with the literal inline.", + equal_k: "Compare `a == K` against an inline literal.", + less_than_k: "Compare `a < K` against an inline literal. This is what `n < 2` compiles to.", + less_equal_k: "Compare `a <= K` against an inline literal.", + get_field_upvalue: + "Read `up[i].name` in one step. Every global read inside a function is this shape: `_ENV` is an upvalue and the name is a field of it.", + set_field_upvalue: + "Write `up[i].name` in one step — the global-assignment counterpart of `get_field_upvalue`.", bitwise_and: "Compute `a & b` (bitwise AND).", bitwise_or: "Compute `a | b` (bitwise OR).", bitwise_xor: "Compute `a ~ b` (bitwise XOR — the binary `~`).", @@ -359,6 +390,15 @@ defmodule DemoWeb.Bytecode do op when op in [:equal, :less_than, :less_equal] -> "rD, rA, rB" + op when op in [:add_k, :subtract_k, :multiply_k, :equal_k, :less_than_k, :less_equal_k] -> + "rD, rA, K" + + :get_field_upvalue -> + "rD, up[i], name" + + :set_field_upvalue -> + "up[i], name, rS" + op when op in [:negate, :not, :length, :bitwise_not] -> "rD, rS" diff --git a/website/lib/website_web/live/opcodes_live.ex b/website/lib/website_web/live/opcodes_live.ex index 963ad3b9..d3d50a76 100644 --- a/website/lib/website_web/live/opcodes_live.ex +++ b/website/lib/website_web/live/opcodes_live.ex @@ -79,6 +79,22 @@ defmodule DemoWeb.OpcodesLive do blurb: "Build nested functions with captured upvalues.", ops: [:closure] }, + %{ + id: "fused", + title: "Fused forms", + blurb: + "Emitted by the peephole pass, never by codegen directly. Each collapses a pair of instructions the naive lowering would otherwise have produced.", + ops: [ + :add_k, + :subtract_k, + :multiply_k, + :equal_k, + :less_than_k, + :less_equal_k, + :get_field_upvalue, + :set_field_upvalue + ] + }, %{ id: "meta", title: "Metadata", From fcda7345e80c940ea19d7d523ac86a20d0a93402 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Sun, 26 Jul 2026 23:54:10 -0400 Subject: [PATCH 2/6] compiler: probe the read extent downward when re-deriving max_registers Scanning every register 0..max for a read makes the pass quadratic in the register count. The answer is almost always within a slot or two of the incoming bound, so search from the top and stop at the first hit. Cuts the pass's share of compile time roughly in half on a multi-function chunk (parse_chunk of a ~60-line chunk: +19% -> +13.5%). --- lib/lua/compiler/peephole.ex | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/lua/compiler/peephole.ex b/lib/lua/compiler/peephole.ex index 5f3379a1..b42fa720 100644 --- a/lib/lua/compiler/peephole.ex +++ b/lib/lua/compiler/peephole.ex @@ -698,9 +698,12 @@ defmodule Lua.Compiler.Peephole do 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.reduce(0..(limit - 1)//1, 0, fn reg, acc -> - if any_reads?(instructions, reg, prototypes), do: reg + 1, else: acc + Enum.find_value((limit - 1)..0//-1, 0, fn reg -> + if any_reads?(instructions, reg, prototypes), do: reg + 1 end) end From 25b01570c3b1c43135ee807cd458659b8606d251 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Sun, 26 Jul 2026 23:57:09 -0400 Subject: [PATCH 3/6] test: pin metamethod fidelity through the fused opcodes The riskiest thing the peephole pass can break is a slow path: a folded _k form has to reach the metamethod bridge with its constant boxed, and a fused upvalue-field form has to reach __index / __newindex on _ENV. Add those to the differential corpus and the error battery, covering __add/__sub/__mul/__lt/__le on a folded literal, string-to-number coercion, int64 wrap, float promotion, and _ENV metatables. --- test/lua/compiler/peephole_test.exs | 45 +++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/test/lua/compiler/peephole_test.exs b/test/lua/compiler/peephole_test.exs index 223f3619..9da2747a 100644 --- a/test/lua/compiler/peephole_test.exs +++ b/test/lua/compiler/peephole_test.exs @@ -392,7 +392,43 @@ defmodule Lua.Compiler.PeepholeTest do end return #t, t[1], t[16] """, - "print('one') print(2) print(nil, true) return 'printed'" + "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 @@ -433,7 +469,12 @@ defmodule Lua.Compiler.PeepholeTest do "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({})" + "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 From bc330d6bc57895ac3a012b27661b6e3320e7efbf Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 13:29:52 -0400 Subject: [PATCH 4/6] compiler: make move-elision liveness sound across loop exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `body_futures/2` modelled a loop body's continuation as the back edge alone — one more trip around the loop, with whatever follows the loop reachable only after that trip. An unconditional write near the top of the body therefore registered as a `:killed` in `scan/4` and ended the scan before the exit path was ever examined. `dead?/3` then declared the register dead and `elide_move/3` deleted the `{:move, local, tmp}` that was its only write. The `escaped` flag modelled `:break` but not normal condition-false exit or `for` exhaustion, so every loop kind was affected. local function id(x) return x end local c = 0 for i = 1, 2 do c = i local y = id(c) end return c -- 0 with the pass on, 2 with it off The back-edge trip is now tagged `{:back_edge, instructions}`. A read inside it still settles liveness, but a kill inside it settles only the back-edge path: `dead?/3` continues into the exit continuation, so a register the code after the loop reads stays live regardless of what the next iteration would do to it. Cost across the 5.3 suite and fixtures is 1.3% fewer elisions (13414 -> 13233 instructions removed). Regression tests cover the reproducer plus an arithmetic producer, `while`, `repeat`, `generic_for`, a method-call argument, and the same shape nested in a function (the dispatcher path). A randomized differential property generates small integer programs over three locals — loop bodies that write them unconditionally, calls that copy them into arguments, reads after the loop — and asserts the pass off and on agree; it fails on 300 runs without this fix. Two module comments that claimed the pass only ever removes reads now state what move elision actually does to the copy's write. Claude-Session: https://claude.ai/code/session_01BYHGFLoHBJAsUrTzjVp5nC --- lib/lua/compiler/peephole.ex | 66 +++++++--- test/lua/compiler/peephole_test.exs | 185 ++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 16 deletions(-) diff --git a/lib/lua/compiler/peephole.ex b/lib/lua/compiler/peephole.ex index b42fa720..396b287a 100644 --- a/lib/lua/compiler/peephole.ex +++ b/lib/lua/compiler/peephole.ex @@ -36,10 +36,13 @@ defmodule Lua.Compiler.Peephole do 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, plus the - enclosing loop instruction itself (which covers the back edge). Registers - read through an upvalue cell count: `:closure` reads every parent register - its child prototype captures, and the open-upvalue opcodes read theirs + 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 @@ -240,9 +243,12 @@ defmodule Lua.Compiler.Peephole do # fixpoint loop. # # `future` is the list of instruction lists that may execute after the - # current position, innermost first. Every rewrite only ever removes reads, - # so scanning a not-yet-optimised tail is conservative in the safe - # direction. + # 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: [] @@ -286,31 +292,45 @@ defmodule Lua.Compiler.Peephole do # # 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 kill on the back edge - # — a temporary rewritten at the top of every iteration is dead at the - # bottom of the previous one, which is most of them. The loop instruction - # with its bodies emptied stands in for the header's own register reads - # (the `for` control triple, the `while` test register). + # 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, [[], []])] - [[header ++ body ++ cond_body | future], [cond_body ++ header ++ body | future]] + + [ + [{: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, [[], []])] - [[cond_body ++ header ++ body | future], [header ++ body ++ cond_body | future]] + + [ + [{: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 - [[[put_bodies(instr, [[]])] ++ body | future]] + [[{:back_edge, [put_bodies(instr, [[]])] ++ body} | future]] end defp body_futures({:generic_for, _base, _var_regs, body} = instr, future) do - [[[put_bodies(instr, [[]])] ++ body | future]] + [[{:back_edge, [put_bodies(instr, [[]])] ++ body} | future]] end # Rule 3: `_ENV` (or any upvalue) field access. `t` holding the table is a @@ -504,6 +524,13 @@ defmodule Lua.Compiler.Peephole do # 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 — @@ -511,6 +538,13 @@ defmodule Lua.Compiler.Peephole do # 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 diff --git a/test/lua/compiler/peephole_test.exs b/test/lua/compiler/peephole_test.exs index 9da2747a..bc1c01b6 100644 --- a/test/lua/compiler/peephole_test.exs +++ b/test/lua/compiler/peephole_test.exs @@ -12,6 +12,7 @@ defmodule Lua.Compiler.PeepholeTest do """ use ExUnit.Case, async: true + use ExUnitProperties import ExUnit.CaptureIO @@ -134,6 +135,105 @@ defmodule Lua.Compiler.PeepholeTest do 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") @@ -489,6 +589,91 @@ defmodule Lua.Compiler.PeepholeTest do 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 From b1d30493043cc7029346fde7593b55a73575477f Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 13:30:04 -0400 Subject: [PATCH 5/6] compiler: size load_nil by the registers it actually clears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `instruction_size({:load_nil, dest, count})` returned `dest + count`, but the opcode clears `count + 1` registers — `dest..dest + count`, per `Lua.Compiler.Bytecode` and the dispatcher's `clear_nils(regs, dest, count + 1)`. It undercounted the frame by one slot. Nothing emits `:load_nil` today, but `Peephole.recompute_max_registers/3` uses `instruction_peak/1` as the floor it may shrink to, so the undercount would become a too-narrow register file rather than a benign slack. `Peephole`'s own `writes?/2` clause already used the correct extent. Claude-Session: https://claude.ai/code/session_01BYHGFLoHBJAsUrTzjVp5nC --- lib/lua/compiler/codegen.ex | 3 ++- test/lua/compiler/instruction_size_test.exs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/lua/compiler/codegen.ex b/lib/lua/compiler/codegen.ex index fd89c7e5..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 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}, From 4a8fc63134c94de73aaf0c36a248fc989858caf4 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 27 Jul 2026 15:56:54 -0400 Subject: [PATCH 6/6] test: read the hand-built watermark off the unoptimised stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close-upvalue watermark is a scope-analysis property. This chunk creates no closures, so the peephole pass drops the `close_upvalues` opcodes the assertion reads it through — correctly, but that leaves nothing to observe. Compile with the pass off, matching the sibling test that covers the same property for parsed source. Claude-Session: https://claude.ai/code/session_01BYHGFLoHBJAsUrTzjVp5nC --- test/lua/vm/upvalue_test.exs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/lua/vm/upvalue_test.exs b/test/lua/vm/upvalue_test.exs index 5cd3f5bd..d7283e1f 100644 --- a/test/lua/vm/upvalue_test.exs +++ b/test/lua/vm/upvalue_test.exs @@ -423,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([ @@ -436,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)