Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions src/main/java/org/perlonjava/backend/jvm/EmitStatement.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/org/perlonjava/backend/jvm/EmitterMethodCreator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/org/perlonjava/backend/jvm/JavaClassInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -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).
* <p>
* 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.
* <p>
* {@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
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/org/perlonjava/core/Configuration.java
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>A sub is "simple" (cleanup not needed) iff its body contains
* NONE of:
* <ul>
* <li><b>bless</b> — creates blessed-with-DESTROY targets that need
* refCount decrement on scope exit.</li>
* <li><b>weaken</b> / <b>isweak</b> (any Scalar::Util qualified form)
* — sets the global {@code weakRefsExist} flag and requires the
* reachability walker to see our live lexicals.</li>
* <li><b>local</b> — dynamic-scope bookkeeping changes.</li>
* <li><b>eval STRING</b> — can do anything.</li>
* <li>nested <b>SubroutineNode</b> — might capture our lexicals via
* closure; conservatively assume so.</li>
* <li><b>user sub call</b> ({@code func(args)}) — callee might
* return a blessed-with-DESTROY ref that lands in one of our
* lexicals; cleanup must fire on scope exit.</li>
* <li><b>method call</b> ({@code $obj-&gt;method} or
* {@code $obj-&gt;method(args)}) — same reason. Array / hash
* derefs ({@code $x-&gt;[idx]} / {@code $x-&gt;{key}}) are NOT
* flagged — they don't invoke user code.</li>
* </ul>
*
* <p>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. <em>Overrideable builtins</em> 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.
*
* <p>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) {
}
}