diff --git a/README.md b/README.md index bf698f9..ef64d00 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ LuaPyre is a clean-slate Lua runtime written in Python. It targets **Lua 5.5.1** semantics, a sandbox-first embedding model, and optional gradual type annotations that feed runtime optimization without creating a second language/runtime. -**Python 3.13+** · **current pre-alpha: 0.37.0a1** +**Python 3.13+** · **current pre-alpha: 0.38.0a1** LuaPyre implements Lua 5.5.1 language semantics for its supported sandboxed embedding profile. The runtime is built around a register VM and explicit Lua frames, with a guarded tiered JIT that specializes proven hot paths and deoptimizes back to the same interpreter. @@ -189,6 +189,11 @@ code-shape checks, rejected experiments, and correctness gates. strict floats and two integers. See the [0.37 performance record](docs/speed-0.37.md) for paired Python 3.13/3.14 results. +0.38 adds frame-free pure recursive base cases, scalar compiled-call entries, +proved dense primitive-write regions, and cheaper fresh record construction. +See the [0.38 performance record](docs/speed-0.38.md) and +[implementation roadmap](docs/performance-roadmap-0.38.md). + The [0.36 performance roadmap](docs/performance-roadmap-0.36.md) adds fresh Python-headroom measurements, 11 focused speed probes, and the next ordered work on scalar entry, cross-block facts, nested regions, tables, and recursion. diff --git a/benchmarks/inspect_codegen.py b/benchmarks/inspect_codegen.py index 69e847e..8dee949 100644 --- a/benchmarks/inspect_codegen.py +++ b/benchmarks/inspect_codegen.py @@ -111,7 +111,7 @@ def main(): parser.add_argument("--executions", type=int, default=3) parser.add_argument("--top", type=int, default=3) parser.add_argument( - "--suite", choices=("headroom", "036", "037"), default="headroom" + "--suite", choices=("headroom", "036", "037", "038"), default="headroom" ) parser.add_argument("--case", action="append") parser.add_argument("--include-source", action="store_true") @@ -133,6 +133,14 @@ def prepare_case(name): cases = CASES + def prepare_case(name): + runtime, run, _reference = prepare_probe(name) + return runtime, run, lambda value: validate(CASES[name], value) + elif args.suite == "038": + from speed_038_ab import CASES, prepare as prepare_probe, validate + + cases = CASES + def prepare_case(name): runtime, run, _reference = prepare_probe(name) return runtime, run, lambda value: validate(CASES[name], value) diff --git a/benchmarks/results/speed_038.json b/benchmarks/results/speed_038.json new file mode 100644 index 0000000..5157dec --- /dev/null +++ b/benchmarks/results/speed_038.json @@ -0,0 +1,24 @@ +{ + "schema_version": 1, + "baseline_revision": "10abe6061f7c8937e23ab07fc6b9ab5c6cb8d168", + "candidate": "codex/038-performance-tranche worktree", + "method": "Three paired baseline/candidate processes per Python; alternating Lua/Python timing order; seven warmups and 31 checked samples; fixed affinity; median of process medians", + "summary": [ + {"python":"3.13.15","case":"recursive_balanced","baseline_ms":1.658433,"candidate_ms":1.293257,"python_ms":0.042823,"improvement_percent":22.02}, + {"python":"3.14.7","case":"recursive_balanced","baseline_ms":1.335740,"candidate_ms":1.086365,"python_ms":0.032768,"improvement_percent":18.67}, + {"python":"3.13.15","case":"record_continuations","baseline_ms":7.089099,"candidate_ms":6.703220,"python_ms":0.140196,"improvement_percent":5.44}, + {"python":"3.14.7","case":"record_continuations","baseline_ms":5.950264,"candidate_ms":5.758843,"python_ms":0.136160,"improvement_percent":3.22}, + {"python":"3.13.15","case":"dense_alias_write","baseline_ms":3.342436,"candidate_ms":3.289446,"python_ms":0.125945,"improvement_percent":1.59}, + {"python":"3.14.7","case":"dense_alias_write","baseline_ms":2.691030,"candidate_ms":2.580728,"python_ms":0.105475,"improvement_percent":4.10} + ], + "controls": [ + {"python":"3.13.15","case":"recursive_linear","change_percent":-0.85}, + {"python":"3.14.7","case":"recursive_linear","change_percent":-1.54}, + {"python":"3.13.15","case":"scalar_internal_binary","change_percent":-0.32}, + {"python":"3.14.7","case":"scalar_internal_binary","change_percent":0.54}, + {"python":"3.13.15","case":"dense_read","change_percent":-0.08}, + {"python":"3.14.7","case":"dense_read","change_percent":0.60}, + {"python":"3.13.15","case":"table_mix_nested","change_percent":-0.42}, + {"python":"3.14.7","case":"table_mix_nested","change_percent":-2.69} + ] +} diff --git a/benchmarks/speed_038_ab.py b/benchmarks/speed_038_ab.py new file mode 100644 index 0000000..89eb5b4 --- /dev/null +++ b/benchmarks/speed_038_ab.py @@ -0,0 +1,84 @@ +"""Measure the Python/AST-only 0.38 performance tranche. + +The inherited 0.36/0.37 probes remain controls. The focused default cases +cover pure recursive bases, scalar materialized calls, nested dense regions, +and record construction. Run unchanged against baseline and candidate trees. +""" +from __future__ import annotations + +import sys + +import speed_037_ab as _base +from speed_037_ab import * # noqa: F401,F403 + + +def scalar_internal_binary(): + total = 0 + for value in range(1, 2001): + scratch = [value] + total += scratch[0] + value + 1 + return total + + +def table_mix_nested(): + values = [None] + for value in range(1, 6001): + values.append((value * 17) % 1009) + total = 0 + for round_ in range(1, 5): + for index in range(1, 6001): + value = (values[index] * 33 + index + round_) % 10007 + values[index] = value + total = (total + value) % 1000000007 + return total + + +CASES_038 = { + "scalar_internal_binary": Case(""" +local function combine(left: integer, right: integer): integer + local scratch: table = {left} + return scratch[1] + right +end +local total: integer = 0 +for i = 1, 2000 do total = total + combine(i, i + 1) end +return total +""", scalar_internal_binary, 4004000, + "Two-scalar internal calls through a materialized child"), + "table_mix_nested": Case(""" +local n: integer = 6000 +local values: table = {} +for i = 1, n do values[i] = (i * 17) % 1009 end +local total: integer = 0 +for round = 1, 4 do + for i = 1, n do + local value: integer = values[i] + value = (value * 33 + i + round) % 10007 + values[i] = value + total = (total + value) % 1000000007 + end +end +return total +""", table_mix_nested, 119882313, + "Nested-loop dense extent and primitive-write proof"), +} + +_base.CASES.update(CASES_038) +CASES = _base.CASES + + +FOCUSED_CASES = ( + "recursive_linear", + "recursive_balanced", + "scalar_internal_binary", + "record_continuations", + "dense_read", + "dense_alias_write", + "table_mix_nested", +) + + +if __name__ == "__main__": + if "--case" not in sys.argv: + for name in FOCUSED_CASES: + sys.argv.extend(("--case", name)) + main() diff --git a/docs/performance-roadmap-0.38.md b/docs/performance-roadmap-0.38.md new file mode 100644 index 0000000..54bb131 --- /dev/null +++ b/docs/performance-roadmap-0.38.md @@ -0,0 +1,46 @@ +# Performance roadmap: 0.38 + +## Goal + +Implement the next Python/AST-only tranche from the 0.37 roadmap in this +order: pure base-case entry, scalar internal calls, dense-region proofs, and +record-construction improvements. Direct CPython bytecode generation and +native repository runtime code remain out of scope. + +## Implemented path + +1. Recognize a narrow leading typed-integer comparison whose taken arm returns + only its argument or an integer constant. Charge its exact instruction cost + and return the scalar without allocating a child `Frame`. Stack, type, fuel, + debug-hook, and fallback behavior stay live. +2. Give fixed one/two-argument, one-result materialized compiled calls a scalar + entry, avoiding the parent's argument tuple and result-sequence extraction. + Reuse the existing real-frame pool for non-base calls and retain the generic + tuple path for unsupported shapes and suspension. +3. Prove stable table aliases, positive dense index ranges, and primitive + writes for a straight-line numeric-loop region. Guard table identity, + metatable absence, and dense extent once, bind the backing array once, then + emit direct array operations. Reject calls, holes, growth, nil/collectable + writes, unknown aliases, and unproved bounds. +4. Preserve constant-key facts across whole-function call continuations. Mark + unique literal constructor keys as proven fresh, lazily allocate iteration + metadata, and use a fresh pre-hashed setter that retains version increments, + GC barriers, and allocation accounting. + +## Acceptance and rejected forms + +Every change uses generated Python source/AST and CPython's own adaptive +specialization. The reusable `benchmarks/speed_038_ab.py` harness runs the same +checked workload against baseline and candidate checkouts in three paired +processes per CPython version, with alternating Lua/Python timing order, seven +warmups, and 31 samples. + +Scalar recursion is admitted only when at least two recursive call sites make +base-case frame elision frequent enough to amortize entry selection; linear +recursion stayed on the prior path. Read-only dense loops keep their established +checked-array lowering because one-time region binding did not beat it. A +broader modulo-heavy nested-region experiment was also removed after regressing +table mix. These exclusions are part of the cost model, not semantic limits. + +See [the 0.38 performance record](speed-0.38.md) for measurements and +validation. diff --git a/docs/speed-0.38.md b/docs/speed-0.38.md new file mode 100644 index 0000000..be5e361 --- /dev/null +++ b/docs/speed-0.38.md @@ -0,0 +1,64 @@ +# LuaPyre 0.38 performance tranche + +0.38 implements four narrow Python/AST-only paths from the +[0.38 roadmap](performance-roadmap-0.38.md). It does not generate CPython +bytecode directly and adds no native runtime component. + +## Accepted changes + +1. **Pure recursive bases avoid frames.** A certified typed integer base arm + charges exact fuel and returns its scalar directly. Debug hooks, inadequate + fuel, invalid types, and other branches use the existing materialized path. +2. **Scalar compiled-call entries.** Fixed unary/binary, one-result compiled + calls pass Python scalars without the parent's argument tuple or generic + result-sequence extraction. Real frames and the child's internal return + representation remain for non-base execution and suspension recovery. +3. **Dense primitive-write regions.** Stable aliases and positive bounded keys + are proved once. The generated loop guards dense extent and metatable state, + binds the array once, and writes primitive values directly while incrementing + the table version. +4. **Cheaper record construction.** Constant facts now survive recursive call + continuations. Unique literal fields use a fresh pre-hashed setter; ordinary + tables allocate iteration/deletion metadata only if traversal requires it. + GC barriers and allocation accounting remain intact. + +## Measurements + +The baseline is merged `main` at `10abe6061f7c8937e23ab07fc6b9ab5c6cb8d168`. +Each result is the median of three paired process medians with alternating +Lua/Python timing order, seven warmups, and 31 checked samples. Python GC is +disabled only during steady timing; Lua GC remains active. Full medians are retained in +[`speed_038.json`](../benchmarks/results/speed_038.json). + +| Target | Python | 0.37 | 0.38 | Change | 0.38 / Python | +| --- | --- | ---: | ---: | ---: | ---: | +| Balanced recursive calls | 3.13.15 | 1.658 ms | 1.293 ms | **22.0% faster** | 30.20× | +| Balanced recursive calls | 3.14.7 | 1.336 ms | 1.086 ms | **18.7% faster** | 33.15× | +| Recursive record continuations | 3.13.15 | 7.089 ms | 6.703 ms | **5.4% faster** | 47.81× | +| Recursive record continuations | 3.14.7 | 5.950 ms | 5.759 ms | **3.2% faster** | 42.29× | +| Dense aliased primitive writes | 3.13.15 | 3.342 ms | 3.289 ms | **1.6% faster** | 26.12× | +| Dense aliased primitive writes | 3.14.7 | 2.691 ms | 2.581 ms | **4.1% faster** | 24.47× | + +Controls remained below the 5% regression gate: linear recursion was -0.9% +and -1.5%, materialized binary scalar calls -0.3%/+0.5%, read-only dense loops +-0.1%/+0.6%, and modulo-heavy table mix -0.4%/-2.7% on 3.13/3.14 respectively. + +## Validation + +- All 598 Python tests pass on CPython 3.13.15 and 3.14.7. +- Deterministic additions cover frame-free base entry, all tested fuel + boundaries, unary/binary scalar entry, dense code shape, fresh nil writes, + GC adoption/accounting, iteration, and duplicate/dynamic constructor keys. +- All 24 required unchanged official Lua 5.5.1 probes pass on both versions. +- Penlight passes 23/23, luatest 5/5, LuaCov scanner specs 24/24, and Are We + Fast Yet 11/11 on both versions. Established native-module and unsafe-I/O + exclusions remain unchanged. + +## Deferred work + +This is base-case frame elimination, not general lazy activation recovery. +Effectful, mutually recursive, yielding, and single-chain calls retain real +frames. Dense proof excludes collectable/nil writes, calls, holes, growth, +metatables, and unproved aliases. General record-layout replacement remains +deferred because identity, weak tables, finalizers, and iteration order are +observable Lua semantics. diff --git a/pyproject.toml b/pyproject.toml index 6324f90..a8c829f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "luapyre" -version = "0.37.0a1" +version = "0.38.0a1" description = "A high-performance, sandboxed Lua 5.5 runtime for Python with optional gradual typing" readme = "README.md" requires-python = ">=3.13" diff --git a/src/luapyre/__init__.py b/src/luapyre/__init__.py index 56d024b..acfc259 100644 --- a/src/luapyre/__init__.py +++ b/src/luapyre/__init__.py @@ -27,4 +27,4 @@ "LuaTraceFrame", ] -__version__ = "0.37.0a1" +__version__ = "0.38.0a1" diff --git a/src/luapyre/compiler.py b/src/luapyre/compiler.py index b8d0218..6bc547e 100644 --- a/src/luapyre/compiler.py +++ b/src/luapyre/compiler.py @@ -891,6 +891,8 @@ def _expr_table_ctor(self, expr): out = self.alloc() self.emit(Op.NEWTABLE, out) array_index = 1 + fresh_keys: set[object] = set() + unknown_key_seen = False for i, field in enumerate(expr.fields): last = i == len(expr.fields) - 1 if field.key is None: @@ -901,19 +903,32 @@ def _expr_table_ctor(self, expr): vr, _ = self.expr(field.value) kr = self.alloc() self.emit(Op.LOADK, kr, self.proto.add_const(array_index)) - self.emit(Op.SETTABLE, out, kr, vr) + fresh = not unknown_key_seen and array_index not in fresh_keys + self.emit(Op.SETTABLE, out, kr, vr, d=int(fresh)) + fresh_keys.add(array_index) array_index += 1 else: if isinstance(field.key, str) and isinstance(field.value, A.FunctionExpr): field.value.debug_name = field.key field.value.debug_namewhat = "field" if isinstance(field.key, str): + literal_key = field.key.encode() kr = self.alloc() - self.emit(Op.LOADK, kr, self.proto.add_const(field.key.encode())) + self.emit(Op.LOADK, kr, self.proto.add_const(literal_key)) else: + literal_key = None kr, _ = self.expr(field.key) vr, _ = self.expr(field.value) - self.emit(Op.SETTABLE, out, kr, vr) + fresh = ( + literal_key is not None + and not unknown_key_seen + and literal_key not in fresh_keys + ) + self.emit(Op.SETTABLE, out, kr, vr, d=int(fresh)) + if literal_key is None: + unknown_key_seen = True + else: + fresh_keys.add(literal_key) return out, TABLE def _expr_field(self, expr): diff --git a/src/luapyre/function_jit.py b/src/luapyre/function_jit.py index 96a373e..2ebaa51 100644 --- a/src/luapyre/function_jit.py +++ b/src/luapyre/function_jit.py @@ -10,7 +10,7 @@ from .opdispatch import _float_divide, _float_modulo from .range_analysis import analyze_integer_ranges from .table import LuaTable, _ABSENT, _NUM, _hash_key -from .typed_ir import TypedIRCompiler, _reads, _writes +from .typed_ir import IRValueKind, TypedIRCompiler, _reads, _writes from .values import lua_equal, static_value_type, type_matches @@ -86,6 +86,189 @@ class CompiledCallEntry: virtual: bool arg_count: int | None trusted_args: bool + scalar_runner: FunctionType | None = None + + +@dataclass(frozen=True, slots=True) +class PureReturnPrefix: + """A typed, side-effect-free entry branch ending in one scalar return.""" + + comparison: Op + constant: int + result_constant: int | None + returns_argument: bool + instruction_cost: int + + +def _pure_return_prefix(proto: Proto) -> PureReturnPrefix | None: + """Recognize a leading integer test whose taken arm immediately returns. + + This is intentionally narrower than general partial evaluation. Only + LOADK/MOVE and the comparison/boolean conversion may precede RETURN, so the + shortcut cannot skip allocation, mutation, calls, errors, or safepoints. + """ + + if ( + proto.param_count != 1 + or proto.param_types[0].name != "integer" + or len(proto.return_types) != 1 + or proto.return_types[0].name != "integer" + or len(proto.code) < 6 + ): + return None + load, compare, boolean, branch = proto.code[:4] + if not ( + load.op is Op.LOADK + and load.a != 0 + and type(proto.constants[load.b]) is int + and compare.op in (Op.EQ, Op.LT, Op.LE) + and compare.b == 0 + and compare.c == load.a + and compare.a != 0 + and boolean.op is Op.TOBOOL + and boolean.a == compare.a + and boolean.b == compare.a + and branch.op is Op.JMPIFNOT + and branch.b == boolean.a + and 5 < branch.a <= min(len(proto.code), 12) + ): + return None + values: dict[int, tuple[bool, int | None]] = {0: (True, None)} + for pc in range(4, branch.a): + ins = proto.code[pc] + if ins.op is Op.LOADK and type(proto.constants[ins.b]) is int: + values[ins.a] = (False, proto.constants[ins.b]) + elif ins.op in (Op.MOVE, Op.LOCAL) and ins.b in values: + values[ins.a] = values[ins.b] + elif ins.op is Op.RETURN and ins.b == 1 and ins.a in values: + returns_argument, result_constant = values[ins.a] + return PureReturnPrefix( + compare.op, + proto.constants[load.b], + result_constant, + returns_argument, + pc + 1, + ) + else: + return None + return None + + +def _scalar_materialized_call_runner( + compiled: CompiledAstFunction, *, trusted_args: bool, arg_count: int +): + """Build a fixed-arity scalar entry for a materialized child frame.""" + + if arg_count not in (1, 2): + return None + proto = compiled.proto + if ( + proto.param_count != arg_count + or len(proto.return_types) != 1 + or proto.return_types[0].name == "Any" + ): + return None + pool = compiled.frame_pool + compiled_runner = compiled.runner + env_reg = proto.env_reg + expected = tuple(item.name for item in proto.param_types) + prefix = _pure_return_prefix(proto) if arg_count == 1 else None + # A scalar recursive entry pays for a dynamic entry selection at every + # non-base call. It amortizes only when a meaningful share of calls can + # take the frame-free base arm; a single-chain recursion has one such call. + if prefix is not None and sum(ins.op is Op.CALL for ins in proto.code) < 2: + return None + + def validate(index, value): + if not trusted_args and not type_matches(expected[index], value): + raise LuaRuntimeError( + f"argument {index + 1}: expected {expected[index]}, " + f"got {static_value_type(value).name}" + ) + + def finish(vm, frames, child, status, values): + if status == _FUNC_RETURN: + if not frames or frames[-1] is not child: + raise RuntimeError("compiled function stack mismatch") + frames.pop() + vm.jit.function_executions += 1 + if len(pool) < 128 and len(pool) < vm.max_frames: + pool.append(child) + return _FUNC_RETURN, values[0] if values else None + vm.jit.function_suspends += 1 + return _FUNC_SUSPEND, None + + if arg_count == 1: + def run(vm, frames, closure, arg0, dest, want, budget, meter): + if len(frames) >= vm.max_frames: + raise LuaRuntimeError("stack overflow") + validate(0, arg0) + if ( + prefix is not None + and not vm.debug_hooks_enabled + and budget - meter[0] >= prefix.instruction_cost + ): + matched = ( + arg0 == prefix.constant if prefix.comparison is Op.EQ + else arg0 < prefix.constant if prefix.comparison is Op.LT + else arg0 <= prefix.constant + ) + if matched: + meter[0] += prefix.instruction_cost + vm.jit.function_executions += 1 + return ( + _FUNC_RETURN, + arg0 if prefix.returns_argument else prefix.result_constant, + ) + if pool: + child = pool.pop() + child.regs[0] = arg0 + if env_reg >= 0: + child.regs[env_reg] = closure.env + child.closure = closure + child.pc = 0 + child.return_reg = dest + child.return_want = want + if vm.debug_hooks_enabled: + child.hook_call_values = (arg0,) + else: + child = vm._acquire_compiled_frame( + compiled, closure, (arg0,), dest, want, + validate_args=False, + ) + frames.append(child) + status, values = compiled_runner(vm, frames, child, budget, meter) + return finish(vm, frames, child, status, values) + + return run + + def run(vm, frames, closure, arg0, arg1, dest, want, budget, meter): + if len(frames) >= vm.max_frames: + raise LuaRuntimeError("stack overflow") + validate(0, arg0) + validate(1, arg1) + if pool: + child = pool.pop() + child.regs[0] = arg0 + child.regs[1] = arg1 + if env_reg >= 0: + child.regs[env_reg] = closure.env + child.closure = closure + child.pc = 0 + child.return_reg = dest + child.return_want = want + if vm.debug_hooks_enabled: + child.hook_call_values = (arg0, arg1) + else: + child = vm._acquire_compiled_frame( + compiled, closure, (arg0, arg1), dest, want, + validate_args=False, + ) + frames.append(child) + status, values = compiled_runner(vm, frames, child, budget, meter) + return finish(vm, frames, child, status, values) + + return run def _materialized_call_runner(compiled: CompiledAstFunction, *, trusted_args: bool): @@ -349,6 +532,11 @@ def get_call_entry( runner is not None, arg_count, trusted_args, + None if runner is not None else _scalar_materialized_call_runner( + compiled, + trusted_args=trusted_args, + arg_count=arg_count if arg_count is not None else -1, + ), ) self._call_entry_cache[key] = (proto, entry) return entry @@ -697,6 +885,10 @@ def emit_inline_leaf( elif op is Op.GETTABLE: deopt(lines, f"not isinstance({b}, _LuaTable) or {b}.metatable is not None", pc, indent) key = known_constants.get(ins.c, _ABSENT) + if key is _ABSENT: + value = typed_plan.instruction(pc).value_for(ins.c) + if value.kind is IRValueKind.CONSTANT: + key = proto.constants[value.index] if type(key) is float and key.is_integer(): key = int(key) if key is _ABSENT: @@ -723,6 +915,10 @@ def emit_inline_leaf( deopt(lines, f"not isinstance({a}, _LuaTable) or {a}.metatable is not None", pc, indent) deopt(lines, f"{b} is None or (type({b}) is float and _isnan({b}))", pc, indent) key = known_constants.get(ins.b, _ABSENT) + if key is _ABSENT: + value = typed_plan.instruction(pc).value_for(ins.b) + if value.kind is IRValueKind.CONSTANT: + key = proto.constants[value.index] token = _hash_key(key) if key is not _ABSENT else None if key is not _ABSENT and token is not None and not ( token[0] is _NUM @@ -731,12 +927,14 @@ def emit_inline_leaf( ): token_name = f"_key_token_{pc}" constant_tokens[token_name] = token - lines.extend( - [ - f"{indent}used += 1", - f"{indent}{a}.rawset_prehashed({b}, {token_name}, {c})", - ] + setter = ( + "rawset_fresh_prehashed" if ins.d + else "rawset_prehashed" ) + lines.extend([ + f"{indent}used += 1", + f"{indent}{a}.{setter}({b}, {token_name}, {c})", + ]) else: lines.extend([f"{indent}used += 1", f"{indent}{a}.rawset({b}, {c})"]) elif op in (Op.ADD_I, Op.SUB_I, Op.MUL_I): @@ -860,20 +1058,42 @@ def emit_inline_leaf( ): lines.append(f"{indent}regs[{output_reg}] = None") lines.append(f"{indent}frame.pc = {pc + 1}") - args = ", ".join(f"_r{ins.c + i}" for i in range(ins.d)) - if ins.d == 1: - args += "," - lines.append( - f"{indent}_status_{pc}, _values_{pc} = _entry_{pc}.runner(vm, frames, _fn_{pc}, ({args}), {ins.a}, {ins.e}, budget, meter)" - ) + if ins.d in (1, 2) and ins.e == 1: + scalar_args = ", ".join( + f"_r{ins.c + i}" for i in range(ins.d) + ) + lines.append(f"{indent}if _entry_{pc}.scalar_runner is not None:") + lines.append( + f"{indent} _status_{pc}, _scalar_{pc} = _entry_{pc}.scalar_runner(vm, frames, _fn_{pc}, {scalar_args}, {ins.a}, {ins.e}, budget, meter)" + ) + lines.append(f"{indent} _values_{pc} = None") + lines.append(f"{indent}else:") + tuple_args = scalar_args + ("," if ins.d == 1 else "") + lines.append( + f"{indent} _status_{pc}, _values_{pc} = _entry_{pc}.runner(vm, frames, _fn_{pc}, ({tuple_args}), {ins.a}, {ins.e}, budget, meter)" + ) + else: + args = ", ".join( + f"_r{ins.c + i}" for i in range(ins.d) + ) + if ins.d == 1: + args += "," + lines.append( + f"{indent}_status_{pc}, _values_{pc} = _entry_{pc}.runner(vm, frames, _fn_{pc}, ({args}), {ins.a}, {ins.e}, budget, meter)" + ) lines.append(f"{indent}if _status_{pc} == _FUNC_SUSPEND:") lines.append(f"{indent} return _FUNC_SUSPEND") if ins.e > 0: for value_index in range(ins.e): if ins.e == 1: - lines.append( - f"{indent}_r{ins.a} = _values_{pc}[0] if _values_{pc} else None" - ) + if ins.d in (1, 2): + lines.append( + f"{indent}_r{ins.a} = _scalar_{pc} if _entry_{pc}.scalar_runner is not None else (_values_{pc}[0] if _values_{pc} else None)" + ) + else: + lines.append( + f"{indent}_r{ins.a} = _values_{pc}[0] if _values_{pc} else None" + ) else: lines.append( f"{indent}_r{ins.a + value_index} = _values_{pc}[{value_index}] if {value_index} < len(_values_{pc}) else None" diff --git a/src/luapyre/gc.py b/src/luapyre/gc.py index 7008570..e370064 100644 --- a/src/luapyre/gc.py +++ b/src/luapyre/gc.py @@ -411,7 +411,8 @@ def adopt(self, value) -> None: stack.append(current.metatable) for key, item in current.items(): stack.extend((key, item)) - stack.extend(current._deleted_successors.values()) + if current._deleted_successors is not None: + stack.extend(current._deleted_successors.values()) elif isinstance(current, Closure): stack.append(current.env) stack.extend(current.upvalues) @@ -668,8 +669,9 @@ def mark(value, *, force: bool = False) -> bool: for key, item in value.items(): mark(key) mark(item) - for successor in value._deleted_successors.values(): - mark(successor) + if value._deleted_successors is not None: + for successor in value._deleted_successors.values(): + mark(successor) elif mode == b"v": for key, _item in value.items(): mark(key) diff --git a/src/luapyre/structured_jit.py b/src/luapyre/structured_jit.py index a963202..5ed1578 100644 --- a/src/luapyre/structured_jit.py +++ b/src/luapyre/structured_jit.py @@ -522,26 +522,192 @@ def _compile_structured_typed_loop(self, frame, start_pc: int, backedge_pc: int) "_type_matches": type_matches, "_ABSENT": _ABSENT, } + typed_plan = TypedIRCompiler(proto).compile((tuple(enumerate(proto.code)),)) + primitive_types = {"integer", "integer_lua", "float", "boolean", "string"} + def stable_table_register(register: int, before_pc: int) -> int | None: + """Follow loop-local MOVE/LOCAL aliases to an invariant register.""" + + seen: set[tuple[int, int]] = set() + while True: + marker = (register, before_pc) + if marker in seen: + return None + seen.add(marker) + producer = next( + ( + (pc, ins) + for pc, ins in reversed( + list(zip(range(start_pc, before_pc), body[: before_pc - start_pc])) + ) + if self._writes_register(ins, register) + ), + None, + ) + if producer is None: + if any( + self._writes_register(ins, register) + for ins in body[before_pc - start_pc :] + ): + return None + return register + producer_pc, producer_ins = producer + if producer_ins.op not in (Op.MOVE, Op.LOCAL): + return None + register = producer_ins.b + before_pc = producer_pc + + def loop_index_alias(register: int, before_pc: int) -> bool: + """Return whether *register* is a MOVE/LOCAL alias of the index.""" + + seen: set[tuple[int, int]] = set() + while register != loop_ins.a: + marker = (register, before_pc) + if marker in seen: + return False + seen.add(marker) + producer = next( + ( + (pc, ins) + for pc, ins in reversed( + list(zip(range(start_pc, before_pc), body[: before_pc - start_pc])) + ) + if self._writes_register(ins, register) + ), + None, + ) + if producer is None or producer[1].op not in (Op.MOVE, Op.LOCAL): + return False + before_pc, producer_ins = producer + register = producer_ins.b + return True + + # Typed IR is deliberately conservative at CFG joins. This loop is a + # straight-line region, so replay its local type effects to recover the + # type of values written after an in-loop guard. + value_types: dict[int, str] = {} + set_value_types: dict[int, str | None] = {} + for pc, ins in zip(range(start_pc, backedge_pc), body): + if ins.op is Op.SETTABLE: + set_value_types[pc] = value_types.get( + ins.c, typed_plan.instruction(pc).value_for(ins.c).type_name + ) + if ins.op is Op.LOADK: + value_types[ins.a] = static_value_type(proto.constants[ins.b]).name + elif ins.op in (Op.MOVE, Op.LOCAL): + source_type = value_types.get( + ins.b, typed_plan.instruction(pc).value_for(ins.b).type_name + ) + if source_type == "Any": + value_types.pop(ins.a, None) + else: + value_types[ins.a] = source_type + elif ins.op is Op.GUARD: + value_types[ins.a] = str(proto.constants[ins.b]) + elif ins.op in (Op.ADD_I, Op.SUB_I, Op.MUL_I): + value_types[ins.a] = "integer" + elif ins.op in (Op.ADD_F, Op.SUB_F, Op.MUL_F, Op.DIV): + value_types[ins.a] = "float" + elif self._writes_register(ins, ins.a): + value_types.pop(ins.a, None) + + table_sources: dict[int, int] = {} + dense_source_limits: dict[int, int | str] = {} + table_accesses = [ + (pc, ins) + for pc, ins in zip(range(start_pc, backedge_pc), body) + if ins.op in (Op.GETTABLE, Op.SETTABLE) + ] + # Existing read-only lowering already has a cheap checked array path; + # region binding is admitted here only when it also removes write-side + # migration/deletion work and therefore wins the cross-version gate. + unsafe_dense_write = bool(calls) or not any( + ins.op is Op.SETTABLE for _pc, ins in table_accesses + ) + dynamic_dense_loop = False + for pc, ins in table_accesses: + table_reg = ins.b if ins.op is Op.GETTABLE else ins.a + source_reg = stable_table_register(table_reg, pc) + if source_reg is None: + unsafe_dense_write = True + continue + table_sources[table_reg] = source_reg + key_reg = ins.c if ins.op is Op.GETTABLE else ins.b + key_range = ranges.range_at(pc, key_reg) + if key_range is None: + if loop_index_alias(key_reg, pc): + dynamic_dense_loop = True + else: + unsafe_dense_write = True + continue + elif key_range.minimum < 1: + unsafe_dense_write = True + continue + if ins.op is Op.SETTABLE: + value_type = set_value_types.get(pc) + if value_type not in primitive_types: + unsafe_dense_write = True + if not unsafe_dense_write: + for pc, ins in table_accesses: + table_reg = ins.b if ins.op is Op.GETTABLE else ins.a + source_reg = table_sources[table_reg] + key_reg = ins.c if ins.op is Op.GETTABLE else ins.b + key_range = ranges.range_at(pc, key_reg) + maximum: int | str = ( + key_range.maximum if key_range is not None else f"_r{loop_ins.b}" + ) + previous = dense_source_limits.get(source_reg) + if isinstance(maximum, str) or previous is None: + dense_source_limits[source_reg] = maximum + elif isinstance(previous, int): + dense_source_limits[source_reg] = max(previous, maximum) + else: + # Do not perturb the established checked-table code shape when + # the region proof is rejected. + table_sources.clear() + if dense_source_limits and dynamic_dense_loop: + lines.append( + f" if type(_r{loop_ins.a}) is not int or type(_r{loop_ins.b}) is not int " + f"or type(_r{loop_ins.c}) is not int or _r{loop_ins.c} <= 0 " + f"or _r{loop_ins.a} < 1:" + ) + lines.extend(self._spill_lines(registers, " ")) + lines.extend( + [f" frame.pc = {start_pc}", " return 0, False"] + ) + table_arrays: dict[int, str] = {} for pc, ins in zip(range(start_pc, backedge_pc), body): if ins.op not in (Op.GETTABLE, Op.SETTABLE): continue table_reg = ins.b if ins.op is Op.GETTABLE else ins.a - if any(self._writes_register(other, table_reg) for other in body): + source_reg = table_sources.get(table_reg, table_reg) + if source_reg == table_reg and any( + self._writes_register(other, table_reg) for other in body + ): return None if table_reg in table_arrays: continue - array_name = f"_array_r{table_reg}" + existing_array = table_arrays.get(source_reg) + if existing_array is not None: + table_arrays[table_reg] = existing_array + continue + array_name = f"_array_r{source_reg}" table_arrays[table_reg] = array_name + table_arrays[source_reg] = array_name + dense_guard = ( + f" or _r{source_reg}._deleted_successors is not None" + f" or len(_r{source_reg}.array) < {dense_source_limits[source_reg]}" + if source_reg in dense_source_limits else "" + ) lines.append( - f" if not isinstance(_r{table_reg}, _LuaTable) or _r{table_reg}.metatable is not None:" + f" if not isinstance(_r{source_reg}, _LuaTable) or _r{source_reg}.metatable is not None{dense_guard}:" ) lines.extend(self._spill_lines(registers, " ")) lines.extend( [ f" frame.pc = {start_pc}", " return 0, False", - f" {array_name} = _r{table_reg}.array", + f" {array_name} = _r{source_reg}.array", ] ) for pc, (closure, _sequence) in calls.items(): @@ -587,7 +753,7 @@ def _compile_structured_typed_loop(self, frame, start_pc: int, backedge_pc: int) invariant_reads = not any(ins.op in (Op.SETTABLE, Op.CALL) for ins in body) hoisted_reads: list[str] = [] loop_entry = len(lines) - integer_loop = all( + integer_loop = (bool(dense_source_limits) and dynamic_dense_loop) or all( ranges.range_at(start_pc, reg) is not None for reg in (loop_ins.a, loop_ins.b, loop_ins.c) ) @@ -630,7 +796,6 @@ def guard(condition, pc, cost): f"{indent} return {completed_cost} + {cost}, False", ]) - typed_plan = TypedIRCompiler(proto).compile((tuple(enumerate(proto.code)),)) known_constants: dict[int, object] = {} known_types: dict[int, str] = {} numeric_types = {"integer", "integer_lua", "float"} @@ -663,6 +828,11 @@ def guard(condition, pc, cost): known_types.pop(ins.a, None) elif ins.op is Op.GETTABLE: array_name = table_arrays[ins.b] + if table_sources.get(ins.b, ins.b) in dense_source_limits: + lines.append(f"{indent}{a} = {array_name}[{c} - 1]") + known_constants.pop(ins.a, None) + known_types.pop(ins.a, None) + continue key = known_constants.get(ins.c, _ABSENT) if type(key) is float and key.is_integer(): key = int(key) @@ -698,6 +868,10 @@ def guard(condition, pc, cost): known_constants.pop(ins.a, None) known_types.pop(ins.a, None) elif ins.op is Op.SETTABLE: + if table_sources.get(ins.a, ins.a) in dense_source_limits: + lines.append(f"{indent}{a}.version += 1") + lines.append(f"{indent}{table_arrays[ins.a]}[{b} - 1] = {c}") + continue guard(f"{b} is None or (type({b}) is float and {b} != {b})", pc, cost) lines.append(f"{indent}{a}.rawset({b}, {c})") elif ins.op is Op.GUARD: @@ -720,7 +894,11 @@ def guard(condition, pc, cost): left_type = known_types.get(ins.b, site.value_for(ins.b).type_name) right_type = known_types.get(ins.c, site.value_for(ins.c).type_name) if left_type not in numeric_types or right_type not in numeric_types: - return None + guard( + f"type({b}) not in (int, float) or type({c}) not in (int, float)", + pc, + cost, + ) symbol = {Op.ADD: "+", Op.SUB: "-", Op.MUL: "*"}[ins.op] expression = f"{b} {symbol} {c}" if left_type in ("integer", "integer_lua") and right_type in ("integer", "integer_lua"): @@ -729,6 +907,16 @@ def guard(condition, pc, cost): else: lines.extend(self._i64_lines(a, expression, str(pc), indent)) known_types[ins.a] = "integer" + elif left_type not in numeric_types or right_type not in numeric_types: + temporary = f"_generic_{pc}" + lines.append(f"{indent}{temporary} = {expression}") + lines.append(f"{indent}if type({b}) is int and type({c}) is int:") + lines.extend( + self._i64_lines(a, temporary, f"generic_{pc}", indent + " ") + ) + lines.append(f"{indent}else:") + lines.append(f"{indent} {a} = {temporary}") + known_types[ins.a] = "number" else: lines.append(f"{indent}{a} = float({expression})") known_types[ins.a] = "float" diff --git a/src/luapyre/table.py b/src/luapyre/table.py index a04f105..7f17dd8 100644 --- a/src/luapyre/table.py +++ b/src/luapyre/table.py @@ -44,18 +44,25 @@ def __init__(self): self.version = 0 self._gc_owner = None self._gc_age = 0 - self._deleted_successors: dict[object, object | None] = {} + # Most tables are never traversed with next() and never need deletion + # continuation history. Avoid a third container for those hot record + # and dense-array allocations. + self._deleted_successors: dict[object, object | None] | None = None self._reserved_bytes = 0 def _ensure_iteration_index(self) -> None: - state = self._deleted_successors.get(_ITERATION_STATE) + metadata = self._deleted_successors + state = metadata.get(_ITERATION_STATE) if metadata is not None else None if state is not None and state[0] == self.version: return keys = tuple(key for key, _value in self.items()) positions = { _hash_key(key): index for index, key in enumerate(keys) } - self._deleted_successors[_ITERATION_STATE] = (self.version, keys, positions) + if metadata is None: + metadata = {} + self._deleted_successors = metadata + metadata[_ITERATION_STATE] = (self.version, keys, positions) collector = self._gc_owner if collector is not None and keys: collector.account_bytes(40 * len(keys)) @@ -72,16 +79,18 @@ def _next_indexed_key(self, position: int): def next_item(self, key=None): """Return the next raw entry without rebuilding the key order per call.""" self._ensure_iteration_index() + metadata = self._deleted_successors + assert metadata is not None if key is None: successor = self._next_indexed_key(0) else: token = _hash_key(key) - position = self._deleted_successors[_ITERATION_STATE][2].get(token) + position = metadata[_ITERATION_STATE][2].get(token) if position is None: known_deleted, successor = self.successor_after_deleted(key) if not known_deleted: raise LuaRuntimeError("invalid key to 'next'") - elif self.rawhas(key) or token in self._deleted_successors: + elif self.rawhas(key) or token in metadata: successor = self._next_indexed_key(position + 1) else: raise LuaRuntimeError("invalid key to 'next'") @@ -91,9 +100,11 @@ def next_item(self, key=None): def _remember_deleted_successor(self, token) -> None: self._ensure_iteration_index() - position = self._deleted_successors[_ITERATION_STATE][2].get(token) + metadata = self._deleted_successors + assert metadata is not None + position = metadata[_ITERATION_STATE][2].get(token) if position is not None: - self._deleted_successors[token] = self._next_indexed_key(position + 1) + metadata[token] = self._next_indexed_key(position + 1) def rawget(self, key): if type(key) is int and key >= 1: @@ -140,7 +151,8 @@ def rawset(self, key, value): if present: self._remember_deleted_successor(h) else: - self._deleted_successors.pop(h, None) + if self._deleted_successors is not None: + self._deleted_successors.pop(h, None) self.version += 1 if value is None and present: # Deletion leaves the indexed order usable; missing keys are @@ -186,7 +198,8 @@ def rawset_prehashed(self, key, token, value): if present: self._remember_deleted_successor(token) else: - self._deleted_successors.pop(token, None) + if self._deleted_successors is not None: + self._deleted_successors.pop(token, None) self.version += 1 if value is None and present: state = self._deleted_successors[_ITERATION_STATE] @@ -203,8 +216,21 @@ def rawset_prehashed(self, key, token, value): collector.account_bytes(32) self.hash[token] = (key, value) + def rawset_fresh_prehashed(self, key, token, value): + """Set a proven-new constructor field without deletion bookkeeping.""" + self.version += 1 + collector = self._gc_owner + if collector is not None: + collector.table_write_barrier(self, key, value) + if value is not None: + if collector is not None: + collector.account_bytes(32) + self.hash[token] = (key, value) + def successor_after_deleted(self, key): """Find a surviving successor for a key deleted during traversal.""" + if self._deleted_successors is None: + return False, None seen = set() successor = self._deleted_successors.get(_hash_key(key), _ABSENT) while successor is not _ABSENT and successor is not None: diff --git a/src/luapyre/typed_ir_function_jit.py b/src/luapyre/typed_ir_function_jit.py index bcf2a00..3294216 100644 --- a/src/luapyre/typed_ir_function_jit.py +++ b/src/luapyre/typed_ir_function_jit.py @@ -407,14 +407,12 @@ def emit_forloop( ) lines.append(f"{indent}used += 1") if array_index is None: - lines.extend( - [ - f"{indent}if {c} is None:", - f"{indent} {table_tmp}.rawset_prehashed({key_expr}, {token}, None)", - f"{indent}else:", - f"{indent} {table_tmp}.version += 1", - f"{indent} {table_tmp}.hash[{token}] = ({key_expr}, {c})", - ] + setter = ( + "rawset_fresh_prehashed" if ins.d + else "rawset_prehashed" + ) + lines.append( + f"{indent}{table_tmp}.{setter}({key_expr}, {token}, {c})" ) else: lines.append(f"{indent}{table_tmp}.rawset({key_expr}, {c})") diff --git a/tests/test_performance_038.py b/tests/test_performance_038.py new file mode 100644 index 0000000..e7c2733 --- /dev/null +++ b/tests/test_performance_038.py @@ -0,0 +1,129 @@ +"""Correctness and code-shape coverage for the 0.38 performance paths.""" +from __future__ import annotations + +import pytest + +from luapyre import LuaQuotaError, LuaRuntime +from luapyre.bytecode import Op +from luapyre.function_jit import _FUNC_RETURN, _pure_return_prefix +from luapyre.table import LuaTable, _hash_key + + +FIBONACCI = """-- luapyre: typed +local function fib(n: integer): integer + if n < 2 then return n end + return fib(n - 1) + fib(n - 2) +end +return fib +""" + + +def test_pure_base_case_scalar_entry_avoids_a_child_frame(): + runtime = LuaRuntime(jit_threshold=1) + function = runtime.execute_python(FIBONACCI) + compiled = runtime.vm.jit.get_compiled_function(function.raw) + assert compiled is not None + assert _pure_return_prefix(function.raw.proto) is not None + entry = runtime.vm.jit.get_call_entry( + compiled, arg_count=1, trusted_args=True + ) + assert entry.scalar_runner is not None + frames = [] + meter = [0] + status, value = entry.scalar_runner( + runtime.vm, frames, function.raw, 1, 0, 1, 100, meter + ) + assert (status, value, frames) == (_FUNC_RETURN, 1, []) + assert meter[0] == _pure_return_prefix(function.raw.proto).instruction_cost + + +def test_recursive_scalar_entry_preserves_every_fuel_boundary(): + for fuel in range(1, 70): + outcomes = [] + for jit in (False, True): + runtime = LuaRuntime(jit=jit, jit_threshold=1) + function = runtime.execute_python(FIBONACCI) + try: + outcomes.append(("return", function(7, fuel=fuel))) + except LuaQuotaError: + outcomes.append(("quota", None)) + assert outcomes[0] == outcomes[1] + + +def test_two_argument_materialized_entry_has_a_scalar_interface(): + runtime = LuaRuntime(jit_threshold=1) + function = runtime.execute_python("""-- luapyre: typed +return function(left: integer, right: integer): integer + local scratch = {left} + return scratch[1] + right +end +""") + compiled = runtime.vm.jit.get_compiled_function(function.raw) + assert compiled is not None + entry = runtime.vm.jit.get_call_entry( + compiled, arg_count=2, trusted_args=True + ) + assert not entry.virtual and entry.scalar_runner is not None + frames = [] + status, value = entry.scalar_runner( + runtime.vm, frames, function.raw, 4, 5, 0, 1, 100, [0] + ) + assert (status, value, frames) == (_FUNC_RETURN, 9, []) + + +def test_nested_numeric_loop_proves_one_dense_region(): + runtime = LuaRuntime(jit_threshold=1) + proto = runtime.compile("""-- luapyre: typed +local values: table = {} +for i = 1, 64 do values[i] = i end +local total: integer = 0 +for round = 1, 4 do + for i = 1, 64 do + local value: integer = values[i] + values[i] = value + round + total = total + values[i] + end +end +return total +""") + for _ in range(4): + assert runtime.vm.run(proto) == 9600 + runners = [ + state.runner + for state in runtime.vm._jit_loop_states.values() + if hasattr(state, "runner") + ] + dense = next( + runner for runner in runners + if "array" in runner.__code__.co_names + and "range" in runner.__code__.co_names + and "rawget" not in runner.__code__.co_names + and "rawset" not in runner.__code__.co_names + ) + assert "len" in dense.__code__.co_names + + +def test_fresh_constructor_fields_keep_nil_version_gc_and_iteration_rules(): + runtime = LuaRuntime() + table = runtime.vm._new_table() + token = _hash_key(b"left") + before_bytes = runtime.vm.gc.stats.allocated_bytes + table.rawset_fresh_prehashed(b"left", token, None) + assert table.version == 1 and table.hash == {} + assert table._deleted_successors is None + child = LuaTable() + table.rawset_fresh_prehashed(b"left", token, child) + assert table.version == 2 and table.rawget(b"left") is child + assert child._gc_owner is runtime.vm.gc + assert runtime.vm.gc.stats.allocated_bytes == ( + before_bytes + 32 + runtime.vm.gc._object_size(child) + ) + assert table.next_item() == (b"left", table.rawget(b"left")) + + +def test_source_compiler_marks_only_proven_new_record_keys(): + proto = LuaRuntime().compile( + "return {left=1, right=2, left=3, [unknown]=4, tail=5}" + ) + writes = [ins for ins in proto.code if ins.op is Op.SETTABLE] + assert [ins.d for ins in writes] == [1, 1, 0, 0, 0]