From 1af2f51c9cd75f59a9da4d4704aa4e461e53b87d Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Wed, 28 Oct 2026 11:40:53 +0100 Subject: [PATCH 1/6] perf: reduce per-eval allocation overhead (issue #1911) JFR profiling of 1M `eval '1'` iterations revealed that per-eval overhead is dominated by object allocation, not parsing or execution. This commit targets the top allocation hotspots: - Parser: replace IdentityHashMap sourceLineOffsets with int[] (416 samples) - EmitterContext: lazy-init contextCache HashMap (97 samples) - JavaClassInfo: lazy-init 4 pin/literal maps (135 samples) - RuntimeCode + EvalStringHandler: skip HashMap deep copy of %^H when empty The ScopedSymbolTable warningBitsString cache (plan item #3) was dropped because getWarningBitsString() depends on external mutable state (WarningFlags custom category registry) that cannot be reliably invalidated from within ScopedSymbolTable. Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.6 --- .../backend/bytecode/EvalStringHandler.java | 13 ++++-- .../backend/jvm/EmitterContext.java | 13 +++--- .../perlonjava/backend/jvm/JavaClassInfo.java | 45 ++++++++++++------- .../perlonjava/frontend/parser/Parser.java | 20 +++++---- .../runtime/runtimetypes/RuntimeCode.java | 12 +++-- 5 files changed, 67 insertions(+), 36 deletions(-) diff --git a/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java b/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java index ebd18043d8..c69aa12bdd 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/EvalStringHandler.java @@ -23,6 +23,7 @@ import org.perlonjava.runtime.runtimetypes.*; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -320,9 +321,15 @@ private static RuntimeList evalStringList(String perlCode, boolean savedCurrentScopeUtf8 = savedCurrentScope.isStrictOptionEnabled(Strict.HINT_UTF8); boolean savedMutationScopeUtf8 = compileTimeMutationScope.isStrictOptionEnabled(Strict.HINT_UTF8); RuntimeHash activeHintHash = GlobalVariable.getGlobalHash(GlobalContext.encodeSpecialVar("H")); - Map savedHintHash = new HashMap<>(); - for (Map.Entry entry : activeHintHash.elements.entrySet()) { - savedHintHash.put(entry.getKey(), new RuntimeScalar(entry.getValue())); + boolean hintHashEmpty = activeHintHash.elements.isEmpty(); + Map savedHintHash; + if (hintHashEmpty) { + savedHintHash = Collections.emptyMap(); + } else { + savedHintHash = new HashMap<>(activeHintHash.elements.size()); + for (Map.Entry entry : activeHintHash.elements.entrySet()) { + savedHintHash.put(entry.getKey(), new RuntimeScalar(entry.getValue())); + } } int savedCallSiteHintHashId = HintHashRegistry.getCallSiteHintHashId(); Map lexicalHintHash = diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitterContext.java b/src/main/java/org/perlonjava/backend/jvm/EmitterContext.java index 5429baf973..c635463c27 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitterContext.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitterContext.java @@ -24,7 +24,7 @@ public class EmitterContext { /** * Cache for contexts with different ContextTypes */ - private final Map contextCache = new HashMap<>(); + private Map contextCache; /** * CompilerOptions is a configuration class that holds various settings and flags @@ -162,11 +162,12 @@ static void fixupContext(EmitterContext ctx) { * @return a new EmitterContext with the updated context type */ public EmitterContext with(int contextType) { - // Check if the context is already cached - if (contextCache.containsKey(contextType)) { - return contextCache.get(contextType); + if (contextCache != null) { + EmitterContext cached = contextCache.get(contextType); + if (cached != null) return cached; + } else { + contextCache = new HashMap<>(); } - // Create a new context and cache it EmitterContext newContext = new EmitterContext( this.javaClassInfo, this.symbolTable, this.mv, this.cw, contextType, this.isBoxed, this.errorUtil, this.compilerOptions, @@ -184,7 +185,7 @@ public void logDebug(String message) { } public void clearContextCache() { - contextCache.clear(); + if (contextCache != null) contextCache.clear(); } @Override diff --git a/src/main/java/org/perlonjava/backend/jvm/JavaClassInfo.java b/src/main/java/org/perlonjava/backend/jvm/JavaClassInfo.java index 299fff38de..143dbe20fa 100644 --- a/src/main/java/org/perlonjava/backend/jvm/JavaClassInfo.java +++ b/src/main/java/org/perlonjava/backend/jvm/JavaClassInfo.java @@ -64,10 +64,10 @@ public TryFinallyContext(Label finallyStart, int returnValueSlot, public String javaClassName; /** Static fields holding this class's package-scalar pins, keyed by the pinned name. */ - private final Map scalarPinFields = new LinkedHashMap<>(); - private final Map formatExpressionFields = new LinkedHashMap<>(); - private final IdentityHashMap stringLiteralFields = new IdentityHashMap<>(); - private final IdentityHashMap integerLiteralFields = new IdentityHashMap<>(); + private Map scalarPinFields; + private Map formatExpressionFields; + private IdentityHashMap stringLiteralFields; + private IdentityHashMap integerLiteralFields; /** Optional proven read-only {@code my (...) = @_} fast path for this generated CV. */ public ReadOnlyArgumentUnpackAnalysis.Candidate readOnlyArgumentCandidate; @@ -87,11 +87,12 @@ public TryFinallyContext(Label finallyStart, int returnValueSlot, /** The pin field already declared for {@code name} in this class, or null. */ public String scalarPinField(String name) { - return scalarPinFields.get(name); + return scalarPinFields != null ? scalarPinFields.get(name) : null; } /** Reserve a pin field for {@code name} in this class and return its name. */ public String registerScalarPinField(String name) { + if (scalarPinFields == null) scalarPinFields = new LinkedHashMap<>(); String field = "scalarPin$" + scalarPinFields.size(); scalarPinFields.put(name, field); return field; @@ -99,9 +100,13 @@ public String registerScalarPinField(String name) { /** Reserve a static field initialized from a compiled format-expression template. */ public String registerFormatExpressionField(String registryKey, ClassWriter cw) { - String field = formatExpressionFields.get(registryKey); - if (field != null) return field; - field = "formatExpressions$" + formatExpressionFields.size(); + if (formatExpressionFields != null) { + String field = formatExpressionFields.get(registryKey); + if (field != null) return field; + } else { + formatExpressionFields = new LinkedHashMap<>(); + } + String field = "formatExpressions$" + formatExpressionFields.size(); cw.visitField(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL, field, "Ljava/util/List;", null, null).visitEnd(); formatExpressionFields.put(registryKey, field); @@ -109,14 +114,20 @@ public String registerFormatExpressionField(String registryKey, ClassWriter cw) } public Map getFormatExpressionFields() { - return Collections.unmodifiableMap(formatExpressionFields); + return formatExpressionFields != null + ? Collections.unmodifiableMap(formatExpressionFields) + : Collections.emptyMap(); } /** Register an instance field for one compiled string literal occurrence. */ public String stringLiteralField(StringNode node, ClassWriter cw) { - String field = stringLiteralFields.get(node); - if (field != null) return field; - field = "stringLiteral$" + stringLiteralFields.size(); + if (stringLiteralFields != null) { + String field = stringLiteralFields.get(node); + if (field != null) return field; + } else { + stringLiteralFields = new IdentityHashMap<>(); + } + String field = "stringLiteral$" + stringLiteralFields.size(); stringLiteralFields.put(node, field); cw.visitField(Opcodes.ACC_PRIVATE, field, "Lorg/perlonjava/runtime/runtimetypes/RuntimeScalarReadOnly;", null, null).visitEnd(); @@ -125,9 +136,13 @@ public String stringLiteralField(StringNode node, ClassWriter cw) { /** Register an instance field for one boxed integer literal outside the shared cache. */ public String integerLiteralField(NumberNode node, ClassWriter cw) { - String field = integerLiteralFields.get(node); - if (field != null) return field; - field = "integerLiteral$" + integerLiteralFields.size(); + if (integerLiteralFields != null) { + String field = integerLiteralFields.get(node); + if (field != null) return field; + } else { + integerLiteralFields = new IdentityHashMap<>(); + } + String field = "integerLiteral$" + integerLiteralFields.size(); integerLiteralFields.put(node, field); cw.visitField(Opcodes.ACC_PRIVATE | Opcodes.ACC_VOLATILE, field, "Lorg/perlonjava/runtime/runtimetypes/RuntimeScalarReadOnly;", null, null).visitEnd(); diff --git a/src/main/java/org/perlonjava/frontend/parser/Parser.java b/src/main/java/org/perlonjava/frontend/parser/Parser.java index 0c378e56d9..faaa1976ed 100644 --- a/src/main/java/org/perlonjava/frontend/parser/Parser.java +++ b/src/main/java/org/perlonjava/frontend/parser/Parser.java @@ -325,7 +325,8 @@ public boolean hasVisibleLexicalSubroutine(String name) { // to rescan every preceding token for every quote-like operator, which made // large generated Perl data files (notably CPAN CHECKSUMS) quadratic to parse. // Identity keys remain valid when parsing rewrites or removes nearby tokens. - private final IdentityHashMap sourceLineOffsets = new IdentityHashMap<>(); + private int[] sourceLineOffsets; + private int sourceLineOffsetsTokenCount; /** * When {@code true} (qq and normal strings), {@link Variable#parseBracedVariable} may rewrite * {@code \"} before {@code "} inside {@code ${...}} so patterns like {@code "${\"name\"}"} @@ -380,10 +381,13 @@ public java.util.Map getVisibleParserKeywordPlugins } private void indexSourceLines() { + int size = tokens.size(); + sourceLineOffsetsTokenCount = size; + sourceLineOffsets = new int[size]; int lineOffset = 0; - for (LexerToken token : tokens) { - sourceLineOffsets.put(token, lineOffset); - if (token.type == LexerTokenType.NEWLINE) { + for (int i = 0; i < size; i++) { + sourceLineOffsets[i] = lineOffset; + if (tokens.get(i).type == LexerTokenType.NEWLINE) { lineOffset++; } } @@ -392,11 +396,9 @@ private void indexSourceLines() { /** Return the source line at a token without repeatedly scanning the token list. */ public int sourceLineAt(int tokenIndex) { int baseLine = baseLineNumber > 0 ? baseLineNumber : 1; - if (tokenIndex >= 0 && tokenIndex < tokens.size()) { - Integer offset = sourceLineOffsets.get(tokens.get(tokenIndex)); - if (offset != null) { - return baseLine + offset; - } + if (tokenIndex >= 0 && tokenIndex < sourceLineOffsetsTokenCount + && tokens.size() == sourceLineOffsetsTokenCount) { + return baseLine + sourceLineOffsets[tokenIndex]; } // Synthetic tokens are rare; retain a correctness fallback for them. diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index 2033a30b8e..6322edd68d 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -5173,9 +5173,15 @@ public static RuntimeList evalStringWithInterpreter( // warning/feature flags than the caller, and we must not leak the eval's scope. ScopedSymbolTable savedCurrentScope = getCurrentScope(); RuntimeHash activeHintHash = GlobalVariable.getGlobalHash(GlobalContext.encodeSpecialVar("H")); - Map savedHintHash = new HashMap<>(); - for (Map.Entry entry : activeHintHash.elements.entrySet()) { - savedHintHash.put(entry.getKey(), new RuntimeScalar(entry.getValue())); + boolean hintHashEmpty = activeHintHash.elements.isEmpty(); + Map savedHintHash; + if (hintHashEmpty) { + savedHintHash = Collections.emptyMap(); + } else { + savedHintHash = new HashMap<>(activeHintHash.elements.size()); + for (Map.Entry entry : activeHintHash.elements.entrySet()) { + savedHintHash.put(entry.getKey(), new RuntimeScalar(entry.getValue())); + } } int savedCallSiteHintHashId = HintHashRegistry.getCallSiteHintHashId(); Map lexicalHintHash = From 4f1eecdf4efcbd9dd7a388f2ac204aab63c3a464 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Thu, 29 Oct 2026 10:09:25 +0100 Subject: [PATCH 2/6] perf: eliminate stat64 syscall and cache warning bits (issue #1911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit async-profiler revealed two major CPU hotspots in the eval STRING path: - InterpretedCode.shouldKeepRuntimeDeparseSource() called File.isFile() on every eval, causing a stat64 syscall (16% of CPU). Eval STRING source names like "(eval 42)" start with '(' and are never real files. Short-circuit on that prefix. - WarningFlags.toWarningBitsString() iterated a ConcurrentHashMap (~40 entries) twice per eval (~12.5% of CPU). Add a last-result cache keyed on the input BitSets and map sizes. The eval hot path re-computes the same warning state repeatedly. Together these reduce eval '1' from 14.1 to 9.0 µs/eval (36% faster). Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.6 --- .../backend/bytecode/InterpretedCode.java | 2 +- .../runtime/runtimetypes/WarningFlags.java | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java b/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java index f128aa3863..1a6bf693cf 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java +++ b/src/main/java/org/perlonjava/backend/bytecode/InterpretedCode.java @@ -320,7 +320,7 @@ private static boolean shouldKeepRuntimeDeparseSource(String sourceName) { if (sourceName == null || sourceName.isEmpty()) { return true; } - if ("-e".equals(sourceName)) { + if ("-e".equals(sourceName) || sourceName.charAt(0) == '(') { return true; } return !new java.io.File(sourceName).isFile(); diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java b/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java index 2da76916b8..cd4b33d60e 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/WarningFlags.java @@ -275,8 +275,24 @@ public static int registerUserCategoryOffset(String category) { * @param categoryToInternalBit Map from category name to internal bit position * @return The Perl 5 compatible warning bits string */ + private static BitSet cachedEnabled; + private static BitSet cachedFatal; + private static int cachedMapSize; + private static int cachedUserOffsetsSize; + private static String cachedResult; + public static String toWarningBitsString(BitSet enabled, BitSet fatal, Map categoryToInternalBit) { + int mapSize = categoryToInternalBit != null ? categoryToInternalBit.size() : 0; + int userSize = state().userWarningCategoryOffsets.size(); + if (cachedResult != null + && mapSize == cachedMapSize + && userSize == cachedUserOffsetsSize + && enabled.equals(cachedEnabled) + && (fatal == null ? cachedFatal == null : fatal.equals(cachedFatal))) { + return cachedResult; + } + // Calculate required size int maxOffset = WARN_SIZE * 4; // Default Perl 5 size in categories for (String category : state().userWarningCategoryOffsets.keySet()) { @@ -330,7 +346,13 @@ public static String toWarningBitsString(BitSet enabled, BitSet fatal, } } - return new String(bytes, StandardCharsets.ISO_8859_1); + String result = new String(bytes, StandardCharsets.ISO_8859_1); + cachedEnabled = (BitSet) enabled.clone(); + cachedFatal = fatal != null ? (BitSet) fatal.clone() : null; + cachedMapSize = mapSize; + cachedUserOffsetsSize = userSize; + cachedResult = result; + return result; } /** From 0ed7fb61a4141340db7d74c5eda91facc99d92e5 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Thu, 29 Oct 2026 10:19:21 +0100 Subject: [PATCH 3/6] perf: short-circuit getDisabledWarningCategories for empty BitSet When no warnings are explicitly disabled (the common case for eval '1'), skip the ConcurrentHashMap iteration and return Collections.emptySet(). Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.6 --- .../org/perlonjava/frontend/semantic/ScopedSymbolTable.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/perlonjava/frontend/semantic/ScopedSymbolTable.java b/src/main/java/org/perlonjava/frontend/semantic/ScopedSymbolTable.java index 0db414f979..4aa534dc6a 100644 --- a/src/main/java/org/perlonjava/frontend/semantic/ScopedSymbolTable.java +++ b/src/main/java/org/perlonjava/frontend/semantic/ScopedSymbolTable.java @@ -1171,8 +1171,11 @@ public boolean isWarningCategoryDisabled(String category) { } public Set getDisabledWarningCategories() { - Set categories = new HashSet<>(); BitSet disabled = warningDisabledStack.peek(); + if (disabled.isEmpty()) { + return Collections.emptySet(); + } + Set categories = new HashSet<>(); for (Map.Entry entry : warningBitPositions().entrySet()) { int bitPosition = entry.getValue(); if (bitPosition >= 0 && disabled.get(bitPosition)) { From 48ddf8044a266fdbf642707a27c4f6fa22d9d920 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Thu, 29 Oct 2026 12:44:38 +0100 Subject: [PATCH 4/6] perf: add interpreter eval cache for repeated eval STRING (issue #1911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache compiled InterpretedCode objects in an LRU map keyed by eval string + context (package, features, unicode, evalbytes). On cache hit, skip parsing and compilation entirely — only withCapturedVars runs. Evals that emit compile-time warnings (e.g. deprecated Unicode property names) are excluded from caching via a thread-local flag in WarnDie, so warning handlers always fire correctly on every eval. Reduces eval "1" from ~7-9 µs/eval to ~3 µs/eval (system Perl: ~1 µs). Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.6 --- .../perlonjava/runtime/operators/WarnDie.java | 14 +++++ .../runtime/runtimetypes/RuntimeCode.java | 60 +++++++++++++++---- .../runtimetypes/RuntimeCodeRuntimeState.java | 3 + 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/perlonjava/runtime/operators/WarnDie.java b/src/main/java/org/perlonjava/runtime/operators/WarnDie.java index d7baa3e966..34cc732dfc 100644 --- a/src/main/java/org/perlonjava/runtime/operators/WarnDie.java +++ b/src/main/java/org/perlonjava/runtime/operators/WarnDie.java @@ -22,6 +22,18 @@ public class WarnDie { private static final ThreadLocal WRITING_WARNING = ThreadLocal.withInitial(() -> Boolean.FALSE); + + private static final ThreadLocal COMPILE_TIME_WARNING_EMITTED = + ThreadLocal.withInitial(() -> Boolean.FALSE); + + public static void clearCompileTimeWarningFlag() { + COMPILE_TIME_WARNING_EMITTED.set(Boolean.FALSE); + } + + public static boolean wasCompileTimeWarningEmitted() { + return COMPILE_TIME_WARNING_EMITTED.get(); + } + public static boolean isInsideUnhandledDieHandler() { return PerlRuntime.current().executionState().insideUnhandledDieHandler; } @@ -348,6 +360,7 @@ public static RuntimeBase warn(RuntimeBase message, RuntimeScalar where) { } public static RuntimeBase warn(RuntimeBase message, RuntimeScalar where, String fileName, int lineNumber) { + COMPILE_TIME_WARNING_EMITTED.set(Boolean.TRUE); // PERL5DB is debugger bootstrap code; Perl emits its implicit warning // without the eval-string source suffix in this special context. if (org.perlonjava.runtime.debugger.DebugHooks.isExecutingPerl5db()) { @@ -594,6 +607,7 @@ public static RuntimeBase warnWithCategoryFromCode(RuntimeBase message, RuntimeS private static RuntimeBase warnWithCategory(RuntimeBase message, RuntimeScalar where, String category, String fileName, int lineNumber, boolean enabledByDefault, String warningBitsOverride) { + COMPILE_TIME_WARNING_EMITTED.set(Boolean.TRUE); if (WarningFlags.areWarningsForcedOff()) { return new RuntimeScalar(); } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index 6322edd68d..20a427077f 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -986,6 +986,10 @@ private static String currentActiveEvalText() { private static Map> evalCache() { return PerlRuntime.current().runtimeCodeState().evalCache; } + + private static Map interpreterEvalCache() { + return PerlRuntime.current().runtimeCodeState().interpreterEvalCache; + } /** * Flag to enable disassembly of eval STRING bytecode. * When set, prints the interpreter bytecode for each eval STRING compilation. @@ -5334,20 +5338,40 @@ public static RuntimeList evalStringWithInterpreter( } } + // Compute evalCurrentPackage early (needed for cache key) + String runtimePackage = InterpreterState.currentPackage.get().toString(); + String callsiteSub = capturedSymbolTable.getCurrentSubroutine(); + boolean topLevelMain = callsiteSub == null || callsiteSub.isEmpty() + || "main".equals(callsiteSub); + String evalCurrentPackage = ClassRegistry.isClass(runtimePackage) + ? runtimePackage + : topLevelMain ? runtimePackage : capturedSymbolTable.getCurrentPackage(); + try { + // Check interpreter eval cache (skip when debugger is active) + boolean isDebugging = debuggerFlagsAtEntry != 0; + String interpreterCacheKey = null; + if (!isDebugging) { + int featureFlags = capturedSymbolTable.featureFlagsStack.peek(); + interpreterCacheKey = evalString + '\0' + evalTag + '\0' + hasUnicode + + '\0' + ctx.isEvalbytes + '\0' + featureFlags + '\0' + evalCurrentPackage; + Map cache = interpreterEvalCache(); + synchronized (cache) { + interpretedCode = cache.get(interpreterCacheKey); + } + if (interpretedCode != null) { + compiledSuccessfully = true; + } + } + + if (interpretedCode == null) { + WarnDie.clearCompileTimeWarningFlag(); // Parse the eval string Lexer lexer = new Lexer(evalString); tokens = lexer.tokenize(); // Create parser context ScopedSymbolTable parseSymbolTable = capturedSymbolTable.snapShot(); - String runtimePackage = InterpreterState.currentPackage.get().toString(); - String callsiteSub = capturedSymbolTable.getCurrentSubroutine(); - boolean topLevelMain = callsiteSub == null || callsiteSub.isEmpty() - || "main".equals(callsiteSub); - String evalCurrentPackage = ClassRegistry.isClass(runtimePackage) - ? runtimePackage - : topLevelMain ? runtimePackage : capturedSymbolTable.getCurrentPackage(); parseSymbolTable.setCurrentPackage( evalCurrentPackage, ClassRegistry.isClass(evalCurrentPackage)); String lexicalEvalWarningBits = parseSymbolTable.getWarningBitsString(); @@ -5477,13 +5501,13 @@ public static RuntimeList evalStringWithInterpreter( System.out.println(Disassemble.disassemble(interpretedCode)); } - // Set captured variables - if (runtimeValues.length > 0) { - RuntimeBase[] capturedVars2 = new RuntimeBase[runtimeValues.length]; - for (int i = 0; i < runtimeValues.length; i++) { - capturedVars2[i] = (RuntimeBase) runtimeValues[i]; + // Store in interpreter eval cache (before withCapturedVars) + if (interpreterCacheKey != null && interpretedCode != null + && !WarnDie.wasCompileTimeWarningEmitted()) { + Map cache = interpreterEvalCache(); + synchronized (cache) { + cache.put(interpreterCacheKey, interpretedCode); } - interpretedCode = interpretedCode.withCapturedVars(capturedVars2); } // Retention is decided at eval compilation time. In @@ -5491,6 +5515,16 @@ public static RuntimeList evalStringWithInterpreter( // turn an 0x800-only failed eval into a retained source. storeSourceLines(evalString, evalFilename, ast, tokens, true, debuggerFlagsAtEntry); sourceLinesStored = true; + } // end if (interpretedCode == null) + + // Set captured variables (both cached and compiled paths) + if (runtimeValues.length > 0) { + RuntimeBase[] capturedVars2 = new RuntimeBase[runtimeValues.length]; + for (int i = 0; i < runtimeValues.length; i++) { + capturedVars2[i] = (RuntimeBase) runtimeValues[i]; + } + interpretedCode = interpretedCode.withCapturedVars(capturedVars2); + } } catch (Throwable e) { // Dynamic `use Test::More skip_all => ...` executes its diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCodeRuntimeState.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCodeRuntimeState.java index c7d92a34b6..e15c83b8d2 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCodeRuntimeState.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCodeRuntimeState.java @@ -1,5 +1,6 @@ package org.perlonjava.runtime.runtimetypes; +import org.perlonjava.backend.bytecode.InterpretedCode; import org.perlonjava.backend.jvm.EmitterContext; import org.perlonjava.frontend.astnode.OperatorNode; @@ -20,6 +21,7 @@ public final class RuntimeCodeRuntimeState { final IdentityHashMap evalBeginIds = new IdentityHashMap<>(); final Map> evalCache = lruMap(EVAL_CACHE_SIZE); + final Map interpreterEvalCache = lruMap(EVAL_CACHE_SIZE); final Map, MethodHandle> methodHandleCache = lruMap(METHOD_HANDLE_CACHE_SIZE); final HashMap> anonymousSubs = new HashMap<>(); final HashMap deparseSourceTextsByClassName = new HashMap<>(); @@ -138,6 +140,7 @@ void clearStableHashMethodBlessIdCache() { void clearCaches() { evalBeginIds.clear(); evalCache.clear(); + interpreterEvalCache.clear(); methodHandleCache.clear(); anonymousSubs.clear(); deparseSourceTextsByClassName.clear(); From 1fb6d48fce72deb4eb4d841662b4f4211d8b47ee Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Thu, 29 Oct 2026 15:44:48 +0100 Subject: [PATCH 5/6] docs: record repeated eval performance work Add the #1911 eval STRING improvements to the work-in-progress changelog. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex <158243242+openai-codex[bot]@users.noreply.github.com> --- docs/about/changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 0ab501d2e0..4906d62cc9 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -6,6 +6,8 @@ priorities and future plans. ## Work in progress +- Reduce repeated eval STRING overhead with lazy per-eval state and compiled interpreter-code caching (#1911). + - Run MakeMaker `MY::post_initialize` hooks so generated files join `pm_to_blib`, and parse Perl's `$]` version variable. - Preserve hash-reference constant calls after typeglob undefinition, dereference glob-backed hash values during Morpheus configuration traversal, parse Error.pm's dynamic `catch $class with { ... }` form on both backends, and retain integer scalar identity through `int()` for decimal stringification; preserve regex referent types through blessed scalar references and cover Morpheus relational overload dispatch and caller filename normalization (#1342, #1376, #1427, #1428). From 9e560c09b87121756eab98e4fb4918f4de3c0522 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Thu, 29 Oct 2026 17:23:25 +0100 Subject: [PATCH 6/6] fix: avoid caching context-sensitive eval source (#1926) Restrict interpreter eval caching to context-free decimal integer literals so compile-time effects and dynamic symbol lookups run each time. Add regressions for attributes and ambiguous time dispatch. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex <158243242+openai-codex[bot]@users.noreply.github.com> --- docs/about/changelog.md | 2 +- .../runtime/runtimetypes/RuntimeCode.java | 42 ++++++++++++++++++- .../unit/issue_1926_eval_cache_context.t | 37 ++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 src/test/resources/unit/issue_1926_eval_cache_context.t diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 4906d62cc9..bb8d70841a 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -6,7 +6,7 @@ priorities and future plans. ## Work in progress -- Reduce repeated eval STRING overhead with lazy per-eval state and compiled interpreter-code caching (#1911). +- Reduce repeated eval STRING overhead with lazy state and caching for context-free integer-literal evals (#1911). - Run MakeMaker `MY::post_initialize` hooks so generated files join `pm_to_blib`, and parse Perl's `$]` version variable. diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java index 20a427077f..ec71340b37 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java @@ -990,6 +990,46 @@ private static Map> evalCache() { private static Map interpreterEvalCache() { return PerlRuntime.current().runtimeCodeState().interpreterEvalCache; } + + /** + * Eval parsing can execute compile-time code and resolve names against + * mutable lexical and package state. The interpreter cache currently has + * no complete fingerprint for that state, so admit only decimal integer + * literals, whose compiled form is independent of those side effects. + */ + private static boolean isCacheableInterpreterEvalSource(String source) { + if (source == null || source.length() > 32) { + return false; + } + int index = 0; + while (index < source.length() && isEvalAsciiWhitespace(source.charAt(index))) { + index++; + } + if (index < source.length() && (source.charAt(index) == '+' || source.charAt(index) == '-')) { + index++; + } + int digitCount = 0; + while (index < source.length()) { + char current = source.charAt(index); + if (current < '0' || current > '9') { + break; + } + digitCount++; + index++; + } + if (digitCount == 0 || digitCount > 9) { + return false; + } + while (index < source.length() && isEvalAsciiWhitespace(source.charAt(index))) { + index++; + } + return index == source.length(); + } + + private static boolean isEvalAsciiWhitespace(char value) { + return value == ' ' || value == '\t' || value == '\n' || value == '\r' || value == '\f'; + } + /** * Flag to enable disassembly of eval STRING bytecode. * When set, prints the interpreter bytecode for each eval STRING compilation. @@ -5351,7 +5391,7 @@ public static RuntimeList evalStringWithInterpreter( // Check interpreter eval cache (skip when debugger is active) boolean isDebugging = debuggerFlagsAtEntry != 0; String interpreterCacheKey = null; - if (!isDebugging) { + if (!isDebugging && isCacheableInterpreterEvalSource(evalString)) { int featureFlags = capturedSymbolTable.featureFlagsStack.peek(); interpreterCacheKey = evalString + '\0' + evalTag + '\0' + hasUnicode + '\0' + ctx.isEvalbytes + '\0' + featureFlags + '\0' + evalCurrentPackage; diff --git a/src/test/resources/unit/issue_1926_eval_cache_context.t b/src/test/resources/unit/issue_1926_eval_cache_context.t new file mode 100644 index 0000000000..01867640ec --- /dev/null +++ b/src/test/resources/unit/issue_1926_eval_cache_context.t @@ -0,0 +1,37 @@ +use strict; +use warnings; +use Test::More; +use attributes (); +use warnings 'ambiguous'; +no warnings qw(redefine once); + +our $scalar; +sub method_target :method { 1 } +my $value; +my @attribute_errors; +for $value (\&method_target, \$scalar) { + eval "use attributes __PACKAGE__, \$value, 'method'"; + push @attribute_errors, $@; +} +is $attribute_errors[0], '', 'method is valid on a code reference'; +like $attribute_errors[1], qr/^Invalid SCALAR attribute: method/, + 'same eval source validates the current reference type'; + +sub method_time :method { 1 } +sub plain_time { 1 } +my @warning_runs; +for my $sub (\&method_time, \&plain_time) { + my @warnings; + { + local *time = $sub; + local $SIG{__WARN__} = sub { push @warnings, $_[0] }; + eval '()=time'; + } + push @warning_runs, \@warnings; +} +is scalar(@{$warning_runs[0]}), 0, + 'method attribute suppresses ambiguous core warning'; +like join('', @{$warning_runs[1]}), qr/^Ambiguous call resolved as CORE::time\(\)/, + 'same eval source rechecks current symbol table'; + +done_testing;