diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitStatement.java b/src/main/java/org/perlonjava/backend/jvm/EmitStatement.java index 8837caa37..4c2395e2d 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitStatement.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitStatement.java @@ -103,6 +103,27 @@ static void emitScopeExitNullStores(EmitterContext ctx, int scopeIndex, boolean ctx.javaClassInfo.evalCleanupLocals.addAll(arrayIndices); } + // Fast path: when CleanupNeededVisitor proved the sub has no + // bless / weaken / local / nested-sub / defer / user-sub-call + // activity, the MyVarCleanupStack.unregister emission (Phase E) + // is dead code — MyVarCleanupStack is only populated when + // WeakRefRegistry.weakRefsExist is true, which only ever + // becomes true after a weaken() is called somewhere. If this + // sub couldn't have weakened anything (the visitor proved it), + // skip the per-variable unregister loop. + // + // We deliberately DO NOT skip Phase 1 (scopeExitCleanup on + // scalars) or Phase 1b (scopeExitCleanupHash/Array): those fire + // DESTROY for blessed refs that entered this sub via @_ params + // or via return values. Skipping them breaks DBIC txn_scope_guard, + // tie_scalar DESTROY-on-untie, and other legitimate patterns + // where the sub receives a blessed ref it doesn't know about + // statically. + // + // JPERL_FORCE_CLEANUP=1 forces cleanupNeeded=true at the + // EmitterMethodCreator level for correctness debugging. + boolean skipMyVarCleanup = !ctx.javaClassInfo.cleanupNeeded; + // Only emit flush when there are variables that need cleanup. // Scopes with no my-variables (e.g., while/for loop bodies with no declarations) // skip the Phase 1/1b cleanup but still flush: pending entries from inner sub @@ -152,13 +173,20 @@ static void emitScopeExitNullStores(EmitterContext ctx, int scopeIndex, boolean // treat the scalar as a live lexical and mark its referent as // reachable, causing false-positive leaks (basic rerefrozen in // DBIC's t/52leaks.t). - for (int idx : allIndices) { - ctx.mv.visitVarInsn(Opcodes.ALOAD, idx); - ctx.mv.visitMethodInsn(Opcodes.INVOKESTATIC, - "org/perlonjava/runtime/runtimetypes/MyVarCleanupStack", - "unregister", - "(Ljava/lang/Object;)V", - false); + // + // When skipMyVarCleanup is true (CleanupNeededVisitor proved this + // sub never uses bless/weaken/user-sub-calls/etc.), the stack is + // guaranteed empty for this sub's lexicals, so the unregister + // loop is dead code. Skipping it is the win this fast path buys. + if (!skipMyVarCleanup) { + for (int idx : allIndices) { + ctx.mv.visitVarInsn(Opcodes.ALOAD, idx); + ctx.mv.visitMethodInsn(Opcodes.INVOKESTATIC, + "org/perlonjava/runtime/runtimetypes/MyVarCleanupStack", + "unregister", + "(Ljava/lang/Object;)V", + false); + } } for (int idx : allIndices) { ctx.mv.visitInsn(Opcodes.ACONST_NULL); diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitterMethodCreator.java b/src/main/java/org/perlonjava/backend/jvm/EmitterMethodCreator.java index d05c74118..af9a22349 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitterMethodCreator.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitterMethodCreator.java @@ -46,6 +46,14 @@ public class EmitterMethodCreator implements Opcodes { System.getenv("JPERL_DISABLE_INTERPRETER_FALLBACK") == null; private static final boolean SHOW_FALLBACK = System.getenv("JPERL_SHOW_FALLBACK") != null; + /** + * When true, bypass {@link org.perlonjava.frontend.analysis.CleanupNeededVisitor} + * and always emit the full scope-exit cleanup sequence. Escape hatch + * for debugging suspected correctness regressions introduced by the + * cleanup-skip fast path. Set {@code JPERL_FORCE_CLEANUP=1} to enable. + */ + private static final boolean FORCE_CLEANUP = + System.getenv("JPERL_FORCE_CLEANUP") != null; // Cache additional compile-time debug env vars. These were previously // read with System.getenv() on every method compilation; the native // lookup is ~200ns per call and added up across thousands of compiled @@ -595,6 +603,19 @@ private static byte[] getBytecodeInternal(EmitterContext ctx, Node ast, boolean mv.visitVarInsn(Opcodes.ASTORE, i); } + // Determine whether this sub needs full scope-exit cleanup emission + // or can use a minimal null-store fast path. See CleanupNeededVisitor + // and JavaClassInfo.cleanupNeeded. JPERL_FORCE_CLEANUP=1 bypasses the + // analysis (forces cleanupNeeded=true) as an escape hatch. + if (FORCE_CLEANUP) { + ctx.javaClassInfo.cleanupNeeded = true; + } else { + org.perlonjava.frontend.analysis.CleanupNeededVisitor cleanupVisitor = + new org.perlonjava.frontend.analysis.CleanupNeededVisitor(); + ast.accept(cleanupVisitor); + ctx.javaClassInfo.cleanupNeeded = cleanupVisitor.needsCleanup(); + } + // Manual frames removed - using COMPUTE_FRAMES for automatic frame computation // Allocate slots for tail call trampoline (codeRef and args) diff --git a/src/main/java/org/perlonjava/backend/jvm/JavaClassInfo.java b/src/main/java/org/perlonjava/backend/jvm/JavaClassInfo.java index fb7ba7911..5cd71a3bc 100644 --- a/src/main/java/org/perlonjava/backend/jvm/JavaClassInfo.java +++ b/src/main/java/org/perlonjava/backend/jvm/JavaClassInfo.java @@ -100,6 +100,24 @@ public class JavaClassInfo { public int[] spillSlots; public int spillTop; + /** + * True iff this subroutine's scope exits need the full cleanup + * emission (scopeExitCleanup on scalars/hashes/arrays, + * MyVarCleanupStack.unregister, MortalList flush). + *

+ * Default true (safe). Flipped to false by + * {@link org.perlonjava.frontend.analysis.CleanupNeededVisitor} when + * the sub body is statically proven to have no bless / weaken / + * local / nested-sub / defer activity — in which case scope-exit + * emissions can be dropped to just the null-store sequence, + * matching the fast path master uses for simple numeric loops. + *

+ * {@link EmitStatement#emitScopeExitNullStores} honours this flag. + * The env var {@code JPERL_FORCE_CLEANUP=1} forces this to true + * globally for debugging suspected correctness regressions. + */ + public boolean cleanupNeeded = true; + /** * JVM local variable indices of my-variables (scalar, hash, array) allocated * inside the eval body. Used by the eval catch handler to emit scope-exit diff --git a/src/main/java/org/perlonjava/core/Configuration.java b/src/main/java/org/perlonjava/core/Configuration.java index e8767d879..e8a6d2754 100644 --- a/src/main/java/org/perlonjava/core/Configuration.java +++ b/src/main/java/org/perlonjava/core/Configuration.java @@ -33,7 +33,7 @@ public final class Configuration { * Automatically populated by Gradle/Maven during build. * DO NOT EDIT MANUALLY - this value is replaced at build time. */ - public static final String gitCommitId = "b7d05b77e"; + public static final String gitCommitId = "078e0b3d7"; /** * Git commit date of the build (ISO format: YYYY-MM-DD). @@ -48,7 +48,7 @@ public final class Configuration { * Parsed by App::perlbrew and other tools via: perl -V | grep "Compiled at" * DO NOT EDIT MANUALLY - this value is replaced at build time. */ - public static final String buildTimestamp = "Apr 21 2026 21:47:23"; + public static final String buildTimestamp = "Apr 21 2026 23:11:19"; // Prevent instantiation private Configuration() { diff --git a/src/main/java/org/perlonjava/frontend/analysis/CleanupNeededVisitor.java b/src/main/java/org/perlonjava/frontend/analysis/CleanupNeededVisitor.java new file mode 100644 index 000000000..141483285 --- /dev/null +++ b/src/main/java/org/perlonjava/frontend/analysis/CleanupNeededVisitor.java @@ -0,0 +1,260 @@ +package org.perlonjava.frontend.analysis; + +import org.perlonjava.frontend.astnode.*; + +/** + * Determines whether a subroutine needs the full per-scope-exit cleanup + * machinery (scopeExitCleanup, MyVarCleanupStack.unregister, full + * MortalList flush) or can safely skip it. + * + *

Ultra-hot workloads (tight numeric loops, life_bitpacked, etc.) + * pay ~1 INVOKESTATIC per {@code my}-variable per scope exit for the + * refcount/DESTROY/weaken bookkeeping — even when the sub's lexicals + * are plain integers and refcount_owned never flips to true. Skipping + * this emission when statically provably unnecessary recovers a large + * fraction of the per-iteration cost. + * + *

A sub is "simple" (cleanup not needed) iff its body contains + * NONE of: + *

+ * + *

Builtins like {@code print}, {@code push}, {@code chr}, + * {@code length}, etc. are parsed as {@link OperatorNode} (not as + * {@link BinaryOperatorNode} with the {@code "("} operator), so they + * don't hit this visitor's sub-call branch — they return non-blessed + * values and don't need cleanup. Overrideable builtins that + * the user imported via {@code use subs} are already resolved by the + * parser to user sub calls ({@code BinaryOperatorNode("(", ...)}), + * which DO get flagged here, so the compile-time override decision + * is handled correctly without extra work from this visitor. + * + *

This is the "simple leaf function" heuristic. It's deliberately + * conservative; false positives (marking needsCleanup when it wasn't + * strictly required) just revert to current behavior. False negatives + * (marking skip when cleanup IS needed) would be a correctness bug — + * hence the env-var escape hatch {@code JPERL_FORCE_CLEANUP=1} in + * {@code EmitStatement} to force the slow path for debugging. + */ +public class CleanupNeededVisitor implements Visitor { + + private boolean needsCleanup = false; + + /** + * @return true iff scope-exit cleanup emission is required for + * correctness. Callers should only skip cleanup when this is false. + */ + public boolean needsCleanup() { + return needsCleanup; + } + + public void reset() { + needsCleanup = false; + } + + // Short-circuit: once we've decided cleanup is needed, don't bother + // walking further subtrees (but we still have to satisfy the visitor + // contract; the recursion is short in practice). + private void mark() { + needsCleanup = true; + } + + @Override + public void visit(OperatorNode node) { + if (needsCleanup) return; + // local operator is a scope-exit bookkeeping trigger. + if ("local".equals(node.operator)) { + mark(); + return; + } + if (node.operand != null) node.operand.accept(this); + } + + @Override + public void visit(BinaryOperatorNode node) { + if (needsCleanup) return; + // bless always taints a sub: the blessed target needs refCount + // tracking for DESTROY. + if ("bless".equals(node.operator)) { + mark(); + return; + } + // User sub call: func(args) → BinaryOperatorNode("(", callee, args). + // Callee might return a blessed-with-DESTROY that lands in a + // lexical in this sub, so cleanup is required even if this sub + // itself does no bless. Builtins (push, chr, length, etc.) are + // parsed as OperatorNode, so only user subs hit this branch. + // + // Special-case weaken/isweak: they flip global state too, but + // marking via the general sub-call path handles them + // automatically. Kept explicit for documentation clarity. + if ("(".equals(node.operator)) { + if (node.left instanceof IdentifierNode id) { + String name = id.name; + if (name != null && ( + name.equals("weaken") || name.equals("isweak") + || name.equals("Scalar::Util::weaken") + || name.equals("Scalar::Util::isweak"))) { + mark(); + return; + } + } + // Any other user sub call. + mark(); + return; + } + // Method call: $obj->method or $obj->method(args). + // In AST form, the RHS is either IdentifierNode(method_name) or + // BinaryOperatorNode("(", IdentifierNode(method_name), args). + // Array/hash derefs ($x->[i], $x->{k}) have ArrayLiteralNode / + // HashLiteralNode on the RHS — those are safe (no user code runs). + if ("->".equals(node.operator)) { + Node right = node.right; + if (right instanceof IdentifierNode + || right instanceof BinaryOperatorNode binOp && "(".equals(binOp.operator)) { + mark(); + return; + } + // Array/hash deref — recurse into children only. + if (node.left != null) node.left.accept(this); + if (node.right != null) node.right.accept(this); + return; + } + if (node.left != null) node.left.accept(this); + if (node.right != null) node.right.accept(this); + } + + @Override + public void visit(SubroutineNode node) { + // Nested subroutines might capture our lexicals via closure. + // Conservatively assume they do. + mark(); + // No need to recurse into the body — inner subs run their own + // CleanupNeededVisitor. + } + + @Override + public void visit(BlockNode node) { + if (needsCleanup) return; + for (Node element : node.elements) { + if (needsCleanup) return; + if (element != null) element.accept(this); + } + } + + @Override + public void visit(ListNode node) { + if (needsCleanup) return; + for (Node element : node.elements) { + if (needsCleanup) return; + if (element != null) element.accept(this); + } + } + + @Override + public void visit(HashLiteralNode node) { + if (needsCleanup) return; + for (Node element : node.elements) { + if (needsCleanup) return; + if (element != null) element.accept(this); + } + } + + @Override + public void visit(ArrayLiteralNode node) { + if (needsCleanup) return; + for (Node element : node.elements) { + if (needsCleanup) return; + if (element != null) element.accept(this); + } + } + + @Override + public void visit(IfNode node) { + if (needsCleanup) return; + if (node.condition != null) node.condition.accept(this); + if (node.thenBranch != null) node.thenBranch.accept(this); + if (node.elseBranch != null) node.elseBranch.accept(this); + } + + @Override + public void visit(TernaryOperatorNode node) { + if (needsCleanup) return; + if (node.condition != null) node.condition.accept(this); + if (node.trueExpr != null) node.trueExpr.accept(this); + if (node.falseExpr != null) node.falseExpr.accept(this); + } + + @Override + public void visit(For1Node node) { + if (needsCleanup) return; + if (node.variable != null) node.variable.accept(this); + if (node.list != null) node.list.accept(this); + if (node.body != null) node.body.accept(this); + } + + @Override + public void visit(For3Node node) { + if (needsCleanup) return; + if (node.initialization != null) node.initialization.accept(this); + if (node.condition != null) node.condition.accept(this); + if (node.increment != null) node.increment.accept(this); + if (node.body != null) node.body.accept(this); + } + + @Override + public void visit(TryNode node) { + // try/catch is common without being refcount-touching, but + // catch handlers often do bless/warn/die things. Be conservative. + if (needsCleanup) return; + if (node.tryBlock != null) node.tryBlock.accept(this); + if (node.catchBlock != null) node.catchBlock.accept(this); + if (node.finallyBlock != null) node.finallyBlock.accept(this); + } + + @Override + public void visit(DeferNode node) { + // defer blocks execute at scope exit — mark conservatively. + mark(); + } + + @Override + public void visit(IdentifierNode node) { + } + + @Override + public void visit(NumberNode node) { + } + + @Override + public void visit(StringNode node) { + } + + @Override + public void visit(LabelNode node) { + } + + @Override + public void visit(CompilerFlagNode node) { + } + + @Override + public void visit(FormatNode node) { + } +}