From 6412374b77b00ce754252ba997c461ca6799e5d9 Mon Sep 17 00:00:00 2001 From: shinyobjectz Date: Thu, 1 Oct 2026 13:32:15 -0700 Subject: [PATCH] vm: compile short-circuit and/or for the dispatcher A function containing `and` or `or` fell back to the instruction interpreter, because the encoder had no lowering for :test_and / :test_or. They now encode to two opcodes, @op_test_and and @op_test_or, that run the right operand as a nested body and resume through the same cont marker :test uses. Tests that used and/or to force a function onto the interpreter now strip its bytecode instead, and the fallback tests use a synthetic prototype. --- lib/lua/compiler/bytecode.ex | 25 ++- lib/lua/vm/dispatcher.ex | 56 +++++++ test/lua/compiler/bytecode_test.exs | 76 ++++----- .../compiler/max_registers_invariant_test.exs | 9 ++ test/lua/compiler/peephole_test.exs | 11 +- test/lua/vm/and_or_engines_test.exs | 153 ++++++++++++++++++ test/lua/vm/max_instructions_test.exs | 56 ++++--- 7 files changed, 308 insertions(+), 78 deletions(-) create mode 100644 test/lua/vm/and_or_engines_test.exs diff --git a/lib/lua/compiler/bytecode.ex b/lib/lua/compiler/bytecode.ex index e12fd66..db71071 100644 --- a/lib/lua/compiler/bytecode.ex +++ b/lib/lua/compiler/bytecode.ex @@ -145,6 +145,12 @@ defmodule Lua.Compiler.Bytecode do # `Lua.Compiler.Peephole` emits the instruction; codegen never does. @op_call_self 76 + # Short-circuit `and` / `or`. Each carries the right operand's code as a + # nested body: the dispatcher runs it only when the left operand does not + # decide the result, and otherwise copies the left operand into `dest`. + @op_test_and 77 + @op_test_or 78 + # The call opcodes whose tuple is `{tag, base, name_hint}` before # `annotate_line/2` bakes the source line in. @static_arity_calls [ @@ -188,8 +194,7 @@ defmodule Lua.Compiler.Bytecode do encoding — i.e. nothing in the tree fell back to the interpreter. Use this after `compile/1` to assert dispatcher coverage. A `false` result - means at least one prototype contains an opcode the encoder still rejects - (today: `:goto` / `:label`). + means at least one prototype contains an opcode the encoder rejects. """ @spec fully_compiled?(Prototype.t()) :: boolean() def fully_compiled?(%Prototype{bytecode: nil}), do: false @@ -293,6 +298,20 @@ defmodule Lua.Compiler.Bytecode do end end + defp encode({:test_and, dest, source, body}, current_line) do + case encode_list(body, [], current_line) do + {:ok, body_enc} -> {:ok, {@op_test_and, dest, source, List.to_tuple(body_enc)}} + :fallback -> :fallback + end + end + + defp encode({:test_or, dest, source, body}, current_line) do + case encode_list(body, [], current_line) do + {:ok, body_enc} -> {:ok, {@op_test_or, dest, source, List.to_tuple(body_enc)}} + :fallback -> :fallback + end + end + defp encode({:generic_for, base, var_regs, body}, current_line) when is_list(var_regs) do case encode_list(body, [], current_line) do {:ok, body_enc} -> @@ -745,4 +764,6 @@ defmodule Lua.Compiler.Bytecode do def op_call_zero_1, do: @op_call_zero_1 def op_call_zero_2, do: @op_call_zero_2 def op_call_self, do: @op_call_self + def op_test_and, do: @op_test_and + def op_test_or, do: @op_test_or end diff --git a/lib/lua/vm/dispatcher.ex b/lib/lua/vm/dispatcher.ex index e04d96b..f3746fa 100644 --- a/lib/lua/vm/dispatcher.ex +++ b/lib/lua/vm/dispatcher.ex @@ -153,6 +153,12 @@ defmodule Lua.VM.Dispatcher do # the loop already holds everything the call needs. @op_call_self 76 + # Short-circuit `and` / `or`: `{tag, dest, source, body}`. When `source` + # decides the result it is copied into `dest`; otherwise `body` (the right + # operand, ending in a write to `dest`) runs as a nested block. + @op_test_and 77 + @op_test_or 78 + @doc """ Execute a compiled prototype against `args` and `state`. """ @@ -893,6 +899,56 @@ defmodule Lua.VM.Dispatcher do dispatch(branch, 1, regs, upvalues, proto, state, [{code, pc + 1} | cont], frames, instruction_count, cs, cd, ou) + # `and` / `or` resume exactly like `:test`: the nested body ends and + # `finish_body` pops the `{code, pc + 1}` marker. When the left operand + # decides the result, no body runs and nothing is pushed. + + {@op_test_and, dest, source, body} -> + case :erlang.element(source + 1, regs) do + v when v === nil or v === false -> + regs = :erlang.setelement(dest + 1, regs, v) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + _ -> + dispatch( + body, + 1, + regs, + upvalues, + proto, + state, + [{code, pc + 1} | cont], + frames, + instruction_count, + cs, + cd, + ou + ) + end + + {@op_test_or, dest, source, body} -> + case :erlang.element(source + 1, regs) do + v when v === nil or v === false -> + dispatch( + body, + 1, + regs, + upvalues, + proto, + state, + [{code, pc + 1} | cont], + frames, + instruction_count, + cs, + cd, + ou + ) + + v -> + regs = :erlang.setelement(dest + 1, regs, v) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + end + # ── Calls ─────────────────────────────────────────────────────── # # `:call_one` always asks for exactly one result placed at `base`. diff --git a/test/lua/compiler/bytecode_test.exs b/test/lua/compiler/bytecode_test.exs index f0ed210..690cee8 100644 --- a/test/lua/compiler/bytecode_test.exs +++ b/test/lua/compiler/bytecode_test.exs @@ -26,6 +26,18 @@ defmodule Lua.Compiler.BytecodeTest do proto end + # Current codegen emits no opcode the encoder rejects, so the fallback + # path is exercised with a synthetic prototype: `:set_list` with + # `count == 0` is the one shape the encoder refuses (see below). + defp uncovered(children \\ []) do + %Prototype{ + instructions: [{:set_list, 0, 1, 0, 0}, {:return, 0, 0}], + prototypes: children, + max_registers: 2, + source: "test-synthetic" + } + end + describe "supported-opcode coverage" do test "a pure-arithmetic function compiles to bytecode" do proto = compile!("function f(a, b) return a + b - 1 end") @@ -176,13 +188,10 @@ defmodule Lua.Compiler.BytecodeTest do assert result.bytecode == nil end - test "short-circuit and/or falls back (test_and/test_or not covered)" do - # `:test_and` / `:test_or` carry a nested continuation body the encoder - # does not lower yet, so a function using short-circuit `and`/`or` keeps - # its prototype on the interpreter. + test "short-circuit and/or compiles (test_and/test_or)" do proto = compile!("function f(a, b) return a and b or 0 end") [fn_proto] = proto.prototypes - assert fn_proto.bytecode == nil + assert is_tuple(fn_proto.bytecode) end end @@ -205,14 +214,10 @@ defmodule Lua.Compiler.BytecodeTest do describe "cascade independence" do test "child prototype compiles even when sibling falls back" do - # `pure` is pure arithmetic (covered). `impure` uses short-circuit - # `and`/`or` (`:test_and` / `:test_or`), which the encoder does not yet - # cover, so it stays on the interpreter. - proto = - compile!(""" - function pure(a, b) return a + b end - function impure(a, b) return a and b or 0 end - """) + # `pure` is pure arithmetic (covered). Its sibling holds an opcode the + # encoder rejects, so it stays on the interpreter. + chunk = compile!("function pure(a, b) return a + b end") + proto = Bytecode.compile(%{chunk | prototypes: chunk.prototypes ++ [uncovered()]}) [pure_proto, impure_proto] = proto.prototypes assert is_tuple(pure_proto.bytecode) @@ -220,18 +225,10 @@ defmodule Lua.Compiler.BytecodeTest do end test "deeply-nested function compiles even when its parent falls back" do - # The outer `make` uses short-circuit `and`/`or` (fallback), but the - # inner adder is a pure-arithmetic single-result function (compiles). - proto = - compile!(""" - function make(a, b) - local guard = a and b or 0 - local function add(x, y) return x + y + guard end - return add - end - """) - - [make_proto] = proto.prototypes + # The outer `make` holds an opcode the encoder rejects (fallback), but + # the inner adder is a pure-arithmetic single-result function (compiles). + %Prototype{prototypes: [add_source]} = compile!("function add(x, y) return x + y end") + make_proto = Bytecode.compile(uncovered([%{add_source | bytecode: nil}])) [add_proto] = make_proto.prototypes assert make_proto.bytecode == nil @@ -242,9 +239,7 @@ defmodule Lua.Compiler.BytecodeTest do describe "fully_compiled?/1 coverage guard" do # A representative corpus exercising every covered opcode family. Each # program must compile end-to-end — root chunk and every nested function — - # so the dispatcher never silently falls back to the interpreter. The only - # documented exception is short-circuit `and`/`or` (`:test_and` / - # `:test_or`), asserted separately below. + # so the dispatcher never silently falls back to the interpreter. @corpus [ {"goto forward", "local x = 1 goto skip x = 99 ::skip:: return x"}, {"goto backward loop", "local i = 0 ::top:: i = i + 1 if i < 5 then goto top end return i"}, @@ -264,7 +259,8 @@ defmodule Lua.Compiler.BytecodeTest do "local function counter() local n = 0 return function() n = n + 1 return n end end local c = counter() c() return c()"}, {"varargs + multi-return", "local function f(...) return ... end return f(1, 2, 3)"}, {"method call (self)", "local t = {v = 10} function t:get() return self.v end return t:get()"}, - {"string concat", ~s{local function f(a, b) return a .. b end return f("x", "y")}} + {"string concat", ~s{local function f(a, b) return a .. b end return f("x", "y")}}, + {"short-circuit and/or", "local function f(a, b) return a and b or 0 end return f(1, 2)"} ] for {label, src} <- @corpus do @@ -273,12 +269,9 @@ defmodule Lua.Compiler.BytecodeTest do end end - test "short-circuit and/or is the remaining documented exception" do - # `:test_and` / `:test_or` (short-circuit `and`/`or`) are the only - # opcodes current codegen emits that the dispatcher does not yet cover, - # so a function using them still falls back to the interpreter. (goto / - # label are now covered — see the corpus above.) - refute Bytecode.fully_compiled?(compile!("local function f(a, b) return a and b or 0 end return f(1, 2)")) + test "a tree with one fallen-back prototype is not fully compiled" do + chunk = compile!("local function f(a, b) return a + b end return f(1, 2)") + refute Bytecode.fully_compiled?(Bytecode.compile(%{chunk | prototypes: chunk.prototypes ++ [uncovered()]})) end end @@ -335,17 +328,8 @@ defmodule Lua.Compiler.BytecodeTest do end test "fallback returns a Prototype with bytecode: nil, never an error" do - # The encoder must not crash on any well-formed prototype. Short-circuit - # `and`/`or` (`:test_and` / `:test_or`) stays on the interpreter, so use - # it to exercise the fallback path. - proto = - compile!(""" - function f(a, b) - return a and b or 0 - end - """) - - [fn_proto] = proto.prototypes + # The encoder must not crash on any well-formed prototype. + [fn_proto] = Bytecode.compile(uncovered([uncovered()])).prototypes assert %Prototype{} = fn_proto assert fn_proto.bytecode == nil end diff --git a/test/lua/compiler/max_registers_invariant_test.exs b/test/lua/compiler/max_registers_invariant_test.exs index ea5385e..465e101 100644 --- a/test/lua/compiler/max_registers_invariant_test.exs +++ b/test/lua/compiler/max_registers_invariant_test.exs @@ -129,6 +129,9 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do op == Bytecode.op_equal_k() -> [1, 2] op == Bytecode.op_get_field_upvalue() -> [1] op == Bytecode.op_set_field_upvalue() -> [3] + # Short-circuit `and` / `or`: {tag, dest, source, body_bc}. + op == Bytecode.op_test_and() -> :short_circuit + op == Bytecode.op_test_or() -> :short_circuit true -> raise "register_positions/1 is missing a case for opcode #{inspect(op)}" end end @@ -194,6 +197,12 @@ defmodule Lua.Compiler.MaxRegistersInvariantTest do var_max = Enum.reduce(Tuple.to_list(var_regs_tuple), -1, &max/2) Enum.max([base + 2, var_max, max_register_used(body_bc)]) + :short_circuit -> + # Writes dest, reads source, recurses into the right operand's body. + dest = :erlang.element(2, instr) + source = :erlang.element(3, instr) + Enum.max([dest, source, max_register_used(:erlang.element(4, instr))]) + :call_arity_1 -> # {tag, base, hint, line}: reads base (the callee) and base + 1. :erlang.element(2, instr) + 1 diff --git a/test/lua/compiler/peephole_test.exs b/test/lua/compiler/peephole_test.exs index bba9303..df219e1 100644 --- a/test/lua/compiler/peephole_test.exs +++ b/test/lua/compiler/peephole_test.exs @@ -503,8 +503,8 @@ defmodule Lua.Compiler.PeepholeTest do end test "the interpreter runs the fused opcode too" do - # `and` / `or` still fall back to the interpreter, so this child - # prototype carries `:call_self` with no bytecode behind it. + # Strip `f`'s bytecode so the interpreter, not the dispatcher, runs the + # `:call_self` the peephole left in its instructions. source = """ local function f(n, flag) if n == 0 then return 0 end @@ -516,11 +516,12 @@ defmodule Lua.Compiler.PeepholeTest do proto = compile!(source) [f] = proto.prototypes - - assert f.bytecode == nil assert self_calls(proto) == 1 + + interpreted = %{proto | prototypes: [%{f | bytecode: nil}]} + state = Lua.VM.Stdlib.install(Lua.VM.State.new()) + assert {:ok, [3], _state} = Lua.VM.execute(interpreted, state) assert run(source, peephole: false) == run(source, peephole: true) - assert {[3], _} = Lua.eval!(source) end end diff --git a/test/lua/vm/and_or_engines_test.exs b/test/lua/vm/and_or_engines_test.exs new file mode 100644 index 0000000..50ba0f8 --- /dev/null +++ b/test/lua/vm/and_or_engines_test.exs @@ -0,0 +1,153 @@ +defmodule Lua.VM.AndOrEnginesTest do + @moduledoc """ + Pins short-circuit `and` / `or` (Lua 5.3 §3.4.5) under both execution + engines, and pins that functions using them are dispatcher-compiled. + + `and` returns its first operand if that operand is falsy, otherwise its + second; `or` returns its first operand if truthy, otherwise its second. + Only `nil` and `false` are falsy, so `0` and `""` are truthy. The right + operand is evaluated only when the left one does not decide the result. + + Each case runs under both execution engines: the default compile path + (closures carry bytecode and run on the dispatcher) and with bytecode + recursively stripped (closures run on the instruction interpreter). + """ + use ExUnit.Case, async: true + + alias Lua.Compiler + alias Lua.Compiler.Bytecode + alias Lua.Compiler.Prototype + alias Lua.Parser + alias Lua.VM + alias Lua.VM.State + alias Lua.VM.Stdlib + + defp compile!(code) do + {:ok, ast} = Parser.parse(code) + {:ok, proto} = Compiler.compile(ast, source: "test.lua") + proto + end + + defp run(code, engine) do + proto = + case engine do + :compiled -> compile!(code) + :interpreted -> strip_bytecode(compile!(code)) + end + + state = Stdlib.install(State.new()) + {:ok, results, _state} = VM.execute(proto, state) + results + end + + defp strip_bytecode(%Prototype{} = proto) do + %{proto | bytecode: nil, prototypes: Enum.map(proto.prototypes, &strip_bytecode/1)} + end + + # {label, source, expected results} + @cases [ + {"value-returning and/or", "return 1 and 2, nil and 1, false and 1, 1 or 2, nil or 2, false or nil, nil or false", + [2, nil, false, 1, 2, nil, false]}, + {"0 and empty string are truthy", ~s{return 0 and "x", "" and "y", 0 or "x", "" or "y"}, ["x", "y", 0, ""]}, + {"operands from locals", + ~s{local a, b, c = nil, false, 3 return (a or b) or c, a and b or c, (a or c) and (b or "z"), a or b and c}, + [3, 3, "z", false]}, + {"deep nesting", + ~s{local t, f = 1, false return ((t and f) or (f or t)) and ((nil or "a") and (f and "b" or "c")), not (t and f or nil)}, + ["c", true]}, + {"right operand side effects run only when needed", + """ + local calls = 0 + local function bump() calls = calls + 1 return true end + local r1 = false and bump() + local r2 = 1 or bump() + local r3 = nil and bump() or "fallback" + local r4 = 0 or bump() + local r5 = "" and bump() + local r6 = nil or bump() + return r1, r2, r3, r4, r5, r6, calls + """, [false, 1, "fallback", 0, true, true, 2]}, + {"and/or inside if / elseif conditions", + """ + local function f(x) + if x and x > 0 or x == -1 then return "pos" + elseif not x or x == 0 then return "none" + else return "neg" end + end + return f(1), f(-1), f(nil), f(0), f(-5), f(false) + """, ["pos", "pos", "none", "none", "neg", "none"]}, + {"and/or as a while condition", "local i, n = 0, 0 while i < 10 and n < 3 do i = i + 1 n = n + 1 end return i, n", + [3, 3]}, + {"and/or in a repeat-until condition", "local i = 0 repeat i = i + 1 until i > 100 or i * i > 30 return i", [6]}, + {"default-argument idiom in assignments", + """ + local function f(a, b) b = b or 10 return a and a + b end + local t = {} + t.x = t.x or 5 + t.x = t.x or 6 + local u + local function g() u = u or "set" return u end + return f(1), f(1, 2), f(nil), f(false), t.x, g(), g() + """, [11, 3, nil, false, 5, "set", "set"]}, + {"fib written with and/or", "local function fib(n) return n < 2 and n or fib(n - 1) + fib(n - 2) end return fib(15)", + [610]}, + {"multi-return call in an operand is truncated", + "local function two() return 1, 2 end local t = {nil or two()} return nil or two(), #t", [1, 1]}, + {"and/or inside loops with break", + """ + local out = {} + for i = 1, 10 do + local v = i % 2 == 0 and "e" or "o" + out[#out + 1] = v + if i >= 4 and v == "e" then break end + end + return table.concat(out) + """, ["oeoe"]}, + {"closure built in the right operand", + "local x = 5 local function f(c) return c and function() return x end or nil end return f(true)(), f(false)", + [5, nil]}, + {"error raised in the right operand", + """ + local ok, e = pcall(function() local x return true and x + 1 end) + local ok2 = pcall(function() local x return false and x + 1 end) + return ok, string.find(e, "arithmetic", 1, true) ~= nil, ok2 + """, [false, true, true]} + ] + + for engine <- [:compiled, :interpreted] do + @engine engine + + describe "and/or (#{engine} engine)" do + for {label, src, expected} <- @cases do + test label do + assert run(unquote(src), @engine) == unquote(Macro.escape(expected)) + end + end + end + end + + describe "dispatcher coverage" do + for {label, src, _expected} <- @cases do + test "compiles fully: #{label}" do + assert Bytecode.fully_compiled?(compile!(unquote(src))) + end + end + + test "and / or encode to their own opcodes" do + proto = compile!("local function f(a, b) return a and b or 0 end return f(1, 2)") + assert encodes_op?(proto, Bytecode.op_test_and()) + assert encodes_op?(proto, Bytecode.op_test_or()) + end + end + + defp encodes_op?(%Prototype{} = proto, op) do + deep_has_op?(proto.bytecode, op) or Enum.any?(proto.prototypes, &encodes_op?(&1, op)) + end + + defp deep_has_op?(t, op) when is_tuple(t) do + (tuple_size(t) > 0 and :erlang.element(1, t) == op) or + Enum.any?(Tuple.to_list(t), &deep_has_op?(&1, op)) + end + + defp deep_has_op?(_other, _op), do: false +end diff --git a/test/lua/vm/max_instructions_test.exs b/test/lua/vm/max_instructions_test.exs index ea9095d..6580112 100644 --- a/test/lua/vm/max_instructions_test.exs +++ b/test/lua/vm/max_instructions_test.exs @@ -13,6 +13,7 @@ defmodule Lua.VM.MaxInstructionsTest do alias Lua.Parser alias Lua.RuntimeException alias Lua.VM.Dispatcher + alias Lua.VM.RuntimeError alias Lua.VM.State alias Lua.VM.Stdlib @@ -154,54 +155,59 @@ defmodule Lua.VM.MaxInstructionsTest do {:ok, [closure], state} = Lua.VM.execute(proto, state) {:compiled_closure, callee_proto, upvalues} = closure - assert_raise Lua.VM.RuntimeError, ~r/instruction budget exceeded/, fn -> + assert_raise RuntimeError, ~r/instruction budget exceeded/, fn -> Dispatcher.execute(callee_proto, [], upvalues, state) end end end describe "cross-engine mutual recursion" do + # Compiles `code` and strips the bytecode from its first function, so + # that function runs on the interpreter as a `:lua_closure` while every + # other function stays a dispatcher `:compiled_closure`. + defp split_engines!(code) do + {:ok, ast} = Parser.parse(code) + {:ok, proto} = Compiler.compile(ast, source: "test.lua") + [first | rest] = proto.prototypes + %{proto | prototypes: [%{first | bytecode: nil} | rest]} + end + test "the budget bounds recursion that alternates execution engines" do - # A function whose body contains a short-circuit `and`/`or` cannot be - # bytecode-encoded, so it stays an interpreted `:lua_closure`; a plain - # body compiles to a `:compiled_closure`. Pairing them in unbounded + # `ping` is interpreted and `pong` compiled. Pairing them in unbounded # mutual recursion with no loop on either side forces a hand-off between # the interpreter and the dispatcher on every call. The budget must span # those hand-offs rather than resetting at each boundary, so this # raises the budget error rather than recursing until `max_call_depth` # (which defaults to `:infinity`) or forever. - lua = Lua.new(max_instructions: 1000) + proto = + split_engines!(""" + local pong + local function ping(n) + return pong(n) + end + pong = function(n) return ping(n) end + return ping(1) + """) - code = """ - local pong - -- short-circuit `and` keeps this body off the bytecode path: interpreted closure. - local function ping(n) - return n and pong(n) - end - -- Plain body: compiles to a dispatcher closure. - pong = function(n) return ping(n) end - return ping(1) - """ + state = %{Stdlib.install(State.new()) | max_instructions: 1000} - assert_raise RuntimeException, ~r/instruction budget exceeded/, fn -> - eval!(lua, code) + assert_raise RuntimeError, ~r/instruction budget exceeded/, fn -> + Lua.VM.execute(proto, state) end end test "the alternating pair is genuinely split across both engines" do - # Guards the regression test above: if a compiler change ever tagged - # both functions into the same engine, the cross-engine assertion - # would silently degrade into a same-engine one. Assert the split - # holds by inspecting the closure tags the chunk produces. - {:ok, ast} = - Parser.parse(""" + # Guards the regression test above: if the split ever stopped holding, + # the cross-engine assertion would silently degrade into a same-engine + # one. Assert the split by inspecting the closure tags it produces. + proto = + split_engines!(""" local pong - local function ping(n) return n and pong end + local function ping(n) return pong end pong = function(n) return ping end return ping, pong """) - {:ok, proto} = Compiler.compile(ast, source: "test.lua") state = Stdlib.install(State.new()) {:ok, [ping, pong], _state} = Lua.VM.execute(proto, state)