This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 3c58690c39 GROOVY-12137: experimental reflective cold tier for indy
dispatch (spike)
3c58690c39 is described below
commit 3c58690c3913d828b56e922a65f806b587dd153e
Author: Paul King <[email protected]>
AuthorDate: Tue Jul 7 20:32:04 2026 +1000
GROOVY-12137: experimental reflective cold tier for indy dispatch (spike)
Behind the groovy.indy.cold.reflection flag (default: off), plain method
calls dispatch reflectively while a call site is cold, deferring all
MethodHandle chain construction — and its one-time LambdaForm creation
cost — to hit-count promotion. The shared cold dispatcher has a single
(Object[])Object shape for every call site, arity, and primitive
pattern; promotion installs the unchanged full guarded chain, so the
hot path is identical to master.
Plain calls are public, non-static, non-category CachedMethods on
public classes with a real receiver and no spread/safe-null/interceptor
semantics; caller-sensitive targets (including unannotated
caller-context-sensitive cases like object serialization, and interface
selections resolving to sensitive implementations) stay on the full
path. Selection reuses Selector's existing logic via a new
selection-only hook; validity is re-checked per call with plain-Java
equivalents of the chain's guards. A per-wrapper cumulative counter
promotes polymorphic receiver shapes that the consecutive-hit counter
never catches.
Measured (deterministic class-load counters + fresh-JVM timing):
~2/3 of per-shape LambdaForm cost removed on cold dispatch workloads,
1.34-1.40x faster cold dispatch with many cold sites, steady state
unchanged after promotion, MOP-heavy test subset (3437 tests) green
with the flag on.
Changes from review feedback: consolidate caller-sensitivity, harden
invariants
- selectForColdReflection now throws GroovyBugError for non-METHOD call
types instead of silently returning null, so a broken fallback gate
cannot hide behind the full handle path
- the NullObject exclusion is marked TODO: its methods are ordinary
methods on a singleton receiver and could be served reflectively
- all method-determined caller-sensitivity — the annotation probe plus
declaring-class cases like object serialization's stack-walking loader
lookup — now lives behind CachedMethod#isCallerSensitive, computed
once per method; the wrapper keeps only the receiver-dependent case
- implementation resolution now applies to abstract selections only
(e.g. the metaclass picking ObjectInput.readObject() for a call on an
ObjectInputStream): interface default methods carry their own
annotations, are probed directly, and take the reflective tier
---
.../java/org/apache/groovy/util/SystemUtil.java | 18 ++
.../codehaus/groovy/reflection/CachedMethod.java | 48 +++++
.../v8/ColdReflectiveMethodHandleWrapper.java | 193 +++++++++++++++++
.../codehaus/groovy/vmplugin/v8/IndyInterface.java | Bin 20799 -> 26208 bytes
.../org/codehaus/groovy/vmplugin/v8/Selector.java | 58 +++++
.../groovy/bench/DynamicDispatchColdBench.java | 23 ++
.../groovy/bench/dispatch/CallsiteBench.java | 30 +++
.../groovy/perf/ColdReflectionParityTest.groovy | 104 +++++++++
.../perf/cold-reflection-parity-corpus.groovy | 234 +++++++++++++++++++++
9 files changed, 708 insertions(+)
diff --git a/src/main/java/org/apache/groovy/util/SystemUtil.java
b/src/main/java/org/apache/groovy/util/SystemUtil.java
index b7b55d3d0b..28a8798de8 100644
--- a/src/main/java/org/apache/groovy/util/SystemUtil.java
+++ b/src/main/java/org/apache/groovy/util/SystemUtil.java
@@ -119,6 +119,24 @@ public class SystemUtil {
return false;
}
+ /**
+ * Retrieves a Boolean System property, supporting an opt-out
(default-true)
+ * flag: absent property yields the default, an explicit value is parsed.
+ *
+ * @param name the name of the system property.
+ * @param def the default value when the property is not set
+ * @return the parsed Boolean system property or the default value
+ */
+ public static boolean getBooleanSafe(String name, boolean def) {
+ try {
+ String value = System.getProperty(name);
+ return (value == null) ? def : Boolean.parseBoolean(value);
+ } catch (SecurityException ignore) {
+ // suppress exception
+ }
+ return def;
+ }
+
/**
* Retrieves an Integer System property
*
diff --git a/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java
b/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java
index 3f38b0ab1c..7f5663592b 100644
--- a/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java
+++ b/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java
@@ -82,6 +82,7 @@ public class CachedMethod extends MetaMethod implements
Comparable {
private int hashCode;
private boolean skipCompiled;
+ private Boolean callerSensitive;
private boolean makeAccessibleDone;
private CachedMethod transformedMethod;
@@ -526,6 +527,53 @@ public class CachedMethod extends MetaMethod implements
Comparable {
}
}
+ /**
+ * Indicates whether the underlying method is caller-sensitive, meaning its
+ * behaviour depends on the immediate caller — such methods must not be
+ * invoked through intermediaries that change the observed caller. This
+ * covers the {@code jdk.internal.reflect.CallerSensitive} annotation and
+ * declaring-class-based cases that are caller-context sensitive without
+ * the annotation (object serialization's stack-walking loader lookup).
+ * Computed once and cached; when the probe cannot decide, the method is
+ * conservatively treated as caller-sensitive. Note that an abstract
+ * method never carries the annotation itself: callers dispatching
+ * virtually should also probe the runtime receiver's implementation.
+ *
+ * @return {@code true} if the method is (or must be assumed)
caller-sensitive
+ * @since 6.0.0
+ */
+ public boolean isCallerSensitive() {
+ Boolean sensitive = callerSensitive;
+ if (sensitive == null) {
+ sensitive = computeCallerSensitive(cachedMethod);
+ callerSensitive = sensitive;
+ }
+ return sensitive;
+ }
+
+ private static boolean computeCallerSensitive(final Method method) {
+ // object serialization is caller-context sensitive without carrying
+ // the annotation: its latestUserDefinedLoader walks the stack for the
+ // class loader, so intermediary frames change its behavior
+ Class<?> declaringClass = method.getDeclaringClass();
+ if (java.io.ObjectInput.class.isAssignableFrom(declaringClass)
+ || java.io.ObjectOutput.class.isAssignableFrom(declaringClass)
+ ||
java.io.ObjectInputStream.class.isAssignableFrom(declaringClass)
+ ||
java.io.ObjectOutputStream.class.isAssignableFrom(declaringClass)) {
+ return true;
+ }
+ try {
+ for (Annotation annotation : method.getDeclaredAnnotations()) {
+ if
("jdk.internal.reflect.CallerSensitive".equals(annotation.annotationType().getName()))
{
+ return true;
+ }
+ }
+ return false;
+ } catch (Throwable ignore) {
+ return true;
+ }
+ }
+
/**
* Makes this method accessible and returns the underlying Java method.
* Synonym for {@link #getCachedMethod()}.
diff --git
a/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
b/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
new file mode 100644
index 0000000000..6cbd8f3687
--- /dev/null
+++
b/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.codehaus.groovy.vmplugin.v8;
+
+import groovy.lang.GroovyObject;
+import groovy.lang.MetaClass;
+import groovy.lang.MetaMethod;
+import org.codehaus.groovy.reflection.CachedMethod;
+import org.codehaus.groovy.runtime.GroovyCategorySupport;
+import org.codehaus.groovy.runtime.wrappers.Wrapper;
+import org.codehaus.groovy.vmplugin.VMPluginFactory;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.SwitchPoint;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+
+/**
+ * Experimental reflective cold-tier wrapper, guarded by the
+ * {@code groovy.indy.cold.reflection} system property.
+ * <p>
+ * The regular cold tier builds a fully guarded MethodHandle chain on the
+ * first invocation of every receiver shape — paying the one-time LambdaForm
+ * creation cost for a MethodType shape determined by the call site's arity
+ * and primitive pattern. This wrapper instead carries only the selected
+ * {@link MetaMethod} plus the data needed to re-validate the selection in
+ * plain Java; invocation happens reflectively via
+ * {@link IndyInterface#invokeColdReflective}. Its bound invocation handle has
+ * the same {@code (Object[])Object} shape for every call site, so the cold
+ * tier spins no per-shape LambdaForms. The full guarded chain is built only
+ * at promotion time (see {@code IndyInterface.fromCacheHandle}), leaving the
+ * hot path unchanged.
+ * <p>
+ * The plain-Java validity checks in {@link #isValidFor} mirror the guards the
+ * full chain would install: switch-point freshness (which also covers
+ * category enter/leave, as those invalidate the switch point), exact argument
+ * classes (at least as strict as the chain's same-class guards), and
+ * metaclass identity for {@code GroovyObject} receivers.
+ */
+class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
+
+ final CacheableCallSite callSite;
+ final Class<?> sender;
+ final String methodName;
+ final int callID;
+ final Boolean safeNavigation;
+ final Boolean thisCall;
+ final Boolean spreadCall;
+ private final MetaClass selectionMetaClass;
+ private final SwitchPoint validity;
+ private final Class<?>[] argClasses;
+ private final MethodHandle reflectiveHandle;
+ /**
+ * Cumulative reflective invocations, independent of the PIC's
+ * consecutive-hit counter (which resets whenever receivers alternate, so
+ * polymorphic sites would otherwise stay on the reflective tier forever).
+ * Racy increments only delay promotion slightly, which is harmless.
+ */
+ private int reflectiveHits;
+
+ private ColdReflectiveMethodHandleWrapper(MetaMethod method,
CacheableCallSite callSite, Class<?> sender,
+ String methodName, int callID, Boolean safeNavigation, Boolean
thisCall, Boolean spreadCall,
+ MetaClass selectionMetaClass, SwitchPoint validity, Class<?>[]
argClasses) {
+ super(null, null, method, true);
+ this.callSite = callSite;
+ this.sender = sender;
+ this.methodName = methodName;
+ this.callID = callID;
+ this.safeNavigation = safeNavigation;
+ this.thisCall = thisCall;
+ this.spreadCall = spreadCall;
+ this.selectionMetaClass = selectionMetaClass;
+ this.validity = validity;
+ this.argClasses = argClasses;
+ this.reflectiveHandle =
IndyInterface.COLD_REFLECTIVE_INVOKER.bindTo(this);
+ }
+
+ @Override
+ public MethodHandle getCachedMethodHandle() {
+ return reflectiveHandle;
+ }
+
+ /**
+ * Counts a reflective invocation.
+ *
+ * @return the cumulative reflective invocation count
+ */
+ int incrementReflectiveHits() {
+ return ++reflectiveHits;
+ }
+
+ /**
+ * Plain-Java equivalent of the guards the full handle chain would install.
+ *
+ * @param arguments receiver-first invocation arguments
+ * @return {@code true} if the cached selection is still valid for them
+ */
+ boolean isValidFor(Object[] arguments) {
+ if (validity.hasBeenInvalidated()) return false;
+ Class<?>[] classes = argClasses;
+ if (arguments.length != classes.length) return false;
+ for (int i = 0; i < classes.length; i++) {
+ Object a = arguments[i];
+ if (a == null ? classes[i] != null : a.getClass() != classes[i])
return false;
+ }
+ Object receiver = arguments[0];
+ return !(receiver instanceof GroovyObject go) || go.getMetaClass() ==
selectionMetaClass;
+ }
+
+ /**
+ * Attempts a reflective cold-tier selection for a plain method call.
+ * "Plain" means: real receiver (non-null, not a {@code Class}, not
+ * {@code GroovyInterceptable}), no spread/safe-null semantics, cacheable
+ * selection resolving to a public, non-static, non-category
+ * {@link CachedMethod} on a public class that is not caller-sensitive.
+ * Anything else returns {@code null} and takes the full handle path.
+ *
+ * @return the reflective wrapper, or {@code null} if unsupported
+ */
+ static MethodHandleWrapper tryBuild(Selector selector, CacheableCallSite
callSite, Class<?> sender,
+ String methodName, int callID, Boolean safeNavigation, Boolean
thisCall, Boolean spreadCall,
+ Object[] arguments) {
+ MetaMethod selected = selector.selectForColdReflection();
+ if (selected == null || selected instanceof
GroovyCategorySupport.CategoryMethod) return null;
+ MetaClass mc = selector.getSelectionMetaClass();
+ if (mc == null || !selector.cache) return null;
+ MetaMethod transformed =
VMPluginFactory.getPlugin().transformMetaMethod(mc, selected, sender);
+ if (!(transformed instanceof CachedMethod cm)) return null;
+ int modifiers = cm.getModifiers();
+ if (Modifier.isStatic(modifiers) || !Modifier.isPublic(modifiers))
return null;
+ if (!Modifier.isPublic(cm.getDeclaringClass().getModifiers())) return
null;
+ if (isCallerSensitive(cm, arguments[0].getClass())) return null;
+ Class<?>[] argClasses = new Class[arguments.length];
+ for (int i = 0; i < arguments.length; i++) {
+ Object a = arguments[i];
+ if (a instanceof Wrapper) return null; // cast markers need the
full coercion path
+ argClasses[i] = (a == null) ? null : a.getClass();
+ }
+ return new ColdReflectiveMethodHandleWrapper(cm, callSite, sender,
methodName, callID,
+ safeNavigation, thisCall, spreadCall, mc,
IndyInterface.switchPoint, argClasses);
+ }
+
+ /**
+ * Reflective invocation would report the wrong caller for caller-sensitive
+ * methods (and promotion would then change the observed caller mid-run),
+ * so such targets stay on the full handle path. The per-method logic —
+ * annotation probe plus the declaring-class cases like serialization —
+ * lives on {@link CachedMethod#isCallerSensitive()}, computed once per
+ * method rather than per call site. The only receiver-dependent concern
+ * handled here is virtual dispatch through an <em>abstract</em> selection:
+ * the metaclass can select e.g. {@code ObjectInput.readObject()} for a
+ * call on an {@code ObjectInputStream}, and the sensitivity then belongs
+ * to the receiver's implementation. Non-abstract interface methods
+ * (defaults) carry their own annotations, so they are probed directly.
+ * When a probe cannot decide, the method is treated as sensitive.
+ */
+ private static boolean isCallerSensitive(CachedMethod cm, Class<?>
receiverClass) {
+ if (cm.isCallerSensitive()) return true;
+ Method method = cm.getCachedMethod();
+ if (Modifier.isAbstract(method.getModifiers())) {
+ // the metaclass can select an abstract method (e.g. it picks
+ // ObjectInput.readObject() for a call on an ObjectInputStream);
+ // virtual dispatch then lands on the receiver's implementation,
+ // which may be caller-sensitive even though the abstract method
+ // cannot carry the annotation. Non-abstract interface methods
+ // (defaults) carry their own annotations and need no resolution.
+ try {
+ Method implementation =
receiverClass.getMethod(method.getName(), method.getParameterTypes());
+ CachedMethod implCached = CachedMethod.find(implementation);
+ if (implCached != null) return implCached.isCallerSensitive();
+ } catch (Throwable ignore) {
+ }
+ return true; // could not resolve the implementation: stay safe
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
index 877f3188e0..d3141658f4 100644
Binary files a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
and b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java differ
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
b/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
index 9f31e9f43c..989e1eb0f5 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
@@ -207,6 +207,31 @@ public abstract class Selector {
*/
abstract void setCallSiteTarget();
+ /**
+ * Experimental, guarded by {@code groovy.indy.cold.reflection}: runs only
+ * the method-selection portion of linking, populating {@link #method}
+ * without constructing any invocation handle, so the cold tier can
+ * dispatch reflectively and defer all MethodHandle building to promotion.
+ * Selector types that do not support this return {@code null}; callers
+ * then continue with the full {@link #setCallSiteTarget()} path (selection
+ * is deterministic, so re-running it there is safe).
+ *
+ * @return the selected meta method, or {@code null} if this call needs the
+ * full handle-building path
+ */
+ MetaMethod selectForColdReflection() {
+ return null;
+ }
+
+ /**
+ * Returns the metaclass captured by {@link #selectForColdReflection()}.
+ *
+ * @return the selection metaclass, or {@code null} if selection did not
run
+ */
+ MetaClass getSelectionMetaClass() {
+ return null;
+ }
+
//--------------------------------------------------------------------------
private static class CastSelector extends MethodSelector {
@@ -1378,6 +1403,39 @@ public abstract class Selector {
addExceptionHandler();
}
}
+
+ @Override
+ MetaMethod selectForColdReflection() {
+ // the cold tier is only for plain method calls; the fallback gates
+ // on CallType.METHOD, so any other call type reaching this method
+ // indicates a broken invariant rather than an unsupported case
+ if (callType != CallType.METHOD) {
+ throw new GroovyBugError("selectForColdReflection called for
call type " + callType);
+ }
+ // these all need the full handle path: spread and safe-null have
+ // their own adaptation/constant handles; a Class receiver is a
+ // static-method style call, which promotes on its first hit anyway
+ // (GROOVY-11935); GroovyInterceptable routes via invokeMethod.
+ // TODO: a null receiver dispatches to NullObject, whose methods
are
+ // ordinary methods on a singleton receiver — investigate serving
+ // them from the reflective tier instead of the full handle path
+ if (safeNavigation || spread || args[0] == null
+ || args[0] instanceof Class || args[0] instanceof
GroovyInterceptable) {
+ return null;
+ }
+ getMetaClass();
+ if (!cache) return null; // e.g. per-instance metaclasses in play
+ setSelectionBase();
+ MetaClassImpl mci = getMetaClassImpl(mc, true);
+ if (mci == null) return null;
+ chooseMeta(mci);
+ return method;
+ }
+
+ @Override
+ MetaClass getSelectionMetaClass() {
+ return mc;
+ }
}
/**
diff --git
a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchColdBench.java
b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchColdBench.java
index ba8c03c94b..3fd1e66bbc 100644
---
a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchColdBench.java
+++
b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchColdBench.java
@@ -92,6 +92,29 @@ public class DynamicDispatchColdBench {
return new DynamicDispatchCold().polySum(n);
}
+ /**
+ * As {@link #dynamicMono_groovy} but with the experimental reflective cold
+ * tier enabled (GROOVY-12137): cold dispatch runs reflectively until
+ * hit-count promotion, avoiding per-shape MethodHandle chain construction.
+ * @return the computed sum
+ */
+ @Benchmark
+ @Fork(value = 25, jvmArgsAppend = "-Dgroovy.indy.cold.reflection=true")
+ public int dynamicMono_groovyColdReflect() {
+ return new DynamicDispatchCold().monoSum(n);
+ }
+
+ /**
+ * As {@link #dynamicPoly_groovy} but with the experimental reflective cold
+ * tier enabled (GROOVY-12137).
+ * @return the computed sum
+ */
+ @Benchmark
+ @Fork(value = 25, jvmArgsAppend = "-Dgroovy.indy.cold.reflection=true")
+ public int dynamicPoly_groovyColdReflect() {
+ return new DynamicDispatchCold().polySum(n);
+ }
+
/**
* Dynamic property reads from a cold call site.
* @return the computed sum
diff --git
a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/dispatch/CallsiteBench.java
b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/dispatch/CallsiteBench.java
index 268d91be39..70e5ca21e3 100644
---
a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/dispatch/CallsiteBench.java
+++
b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/dispatch/CallsiteBench.java
@@ -46,6 +46,21 @@ public class CallsiteBench {
Callsite.dispatch(state.receivers, bh);
}
+ /**
+ * Monomorphic dispatch via Groovy dynamic with the experimental reflective
+ * cold tier enabled (GROOVY-12137). Steady state must match
+ * {@link #dispatch_1_monomorphic_groovy}: after hit-count promotion the
+ * hot path is identical, so a sustained gap on the dashboards is a
+ * regression signal for the cold tier.
+ * @param state the monomorphic receiver state
+ * @param bh the blackhole for consuming results
+ */
+ @Benchmark
+ @Fork(value = 2, jvmArgsAppend = "-Dgroovy.indy.cold.reflection=true")
+ public void dispatch_1_monomorphic_groovyColdReflect(MonomorphicState
state, Blackhole bh) {
+ Callsite.dispatch(state.receivers, bh);
+ }
+
/**
* Monomorphic dispatch via Groovy {@code @CompileStatic}.
* @param state the monomorphic receiver state
@@ -76,6 +91,21 @@ public class CallsiteBench {
Callsite.dispatch(state.receivers, bh);
}
+ /**
+ * Polymorphic dispatch (3 types) via Groovy dynamic with the experimental
+ * reflective cold tier enabled (GROOVY-12137). Polymorphic sites never
+ * reach the consecutive-hit promotion, so this exercises the per-wrapper
+ * cumulative promotion; steady state must match
+ * {@link #dispatch_3_polymorphic_groovy}.
+ * @param state the polymorphic receiver state
+ * @param bh the blackhole for consuming results
+ */
+ @Benchmark
+ @Fork(value = 2, jvmArgsAppend = "-Dgroovy.indy.cold.reflection=true")
+ public void dispatch_3_polymorphic_groovyColdReflect(PolymorphicState
state, Blackhole bh) {
+ Callsite.dispatch(state.receivers, bh);
+ }
+
/**
* Polymorphic dispatch (3 types) via Groovy {@code @CompileStatic}.
* @param state the polymorphic receiver state
diff --git
a/subprojects/performance/src/test/groovy/org/apache/groovy/perf/ColdReflectionParityTest.groovy
b/subprojects/performance/src/test/groovy/org/apache/groovy/perf/ColdReflectionParityTest.groovy
new file mode 100644
index 0000000000..54561e9c6f
--- /dev/null
+++
b/subprojects/performance/src/test/groovy/org/apache/groovy/perf/ColdReflectionParityTest.groovy
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.perf
+
+import org.junit.jupiter.api.Test
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Standing parity guard for the reflective cold tier (GROOVY-12137,
+ * {@code groovy.indy.cold.reflection}, on by default). The tier's fast path
+ * dispatches via {@code java.lang.reflect.Method.invoke} while the normal
+ * path uses MethodHandles, so its unique risk is behavioural divergence
+ * between those two invocation mechanisms (e.g. primitive-return box identity,
+ * caller-sensitivity, exception unwrapping, argument coercion).
+ * <p>
+ * The companion corpus {@code cold-reflection-parity-corpus.groovy} exercises
+ * a broad battery of dynamic-dispatch shapes and prints a deterministic
+ * transcript. This test runs it in two fresh JVMs — the tier explicitly
+ * disabled and explicitly enabled (the flag is read once at
+ * {@code IndyInterface} class-init, so it cannot be toggled in-process, and
+ * both states are pinned so the test is independent of the default) — and
+ * asserts the transcripts are byte-identical. Any difference is a genuine
+ * reflection-vs-MethodHandle divergence.
+ */
+class ColdReflectionParityTest {
+
+ private static final String CORPUS_RESOURCE =
'cold-reflection-parity-corpus.groovy'
+
+ @Test
+ void coldReflectionMatchesBaselineDispatch() {
+ String off = runCorpus(false)
+ String on = runCorpus(true)
+ if (off != on) {
+ // surface the first divergence for a readable failure
+ def a = off.readLines(), b = on.readLines()
+ def diff = (0..<Math.max(a.size(), b.size())).findResults { i ->
+ def x = i < a.size() ? a[i] : '<missing>'
+ def y = i < b.size() ? b[i] : '<missing>'
+ x != y ? " line ${i + 1}:\n off: $x\n on : $y" : null
+ }.take(10).join('\n')
+ assert false, "cold.reflection transcript diverged from
baseline:\n$diff"
+ }
+ assertTrue(off.readLines().size() >= 60, "corpus produced too few
checks (${off.readLines().size()})")
+ }
+
+ @Test
+ void coldReflectionTierGatedByFlag() {
+ // The tier logs each reflective dispatch. Assert both directions:
+ // enabled must fire broadly (so the parity test cannot pass
vacuously),
+ // and disabled must not fire at all (so the "off" case genuinely
+ // exercises the non-reflective path, not merely a coincident result).
+ int firedOn = countColdDispatches(runCorpus(true, true))
+ int firedOff = countColdDispatches(runCorpus(false, true))
+ assertTrue(firedOn >= 50, "reflective cold tier fired only $firedOn
times when enabled; expected broad exercise")
+ assertEquals(0, firedOff, "reflective cold tier fired $firedOff times
when disabled; expected none")
+ }
+
+ private static int countColdDispatches(String log) {
+ log.readLines().count { it.contains('using reflective cold tier') }
+ }
+
+ private static String runCorpus(boolean coldReflection, boolean logging =
false) {
+ def corpus = File.createTempFile('cold-reflection-parity', '.groovy')
+ corpus.deleteOnExit()
+ corpus.bytes =
ColdReflectionParityTest.getResourceAsStream(CORPUS_RESOURCE).bytes
+
+ def java = new File(System.getProperty('java.home'),
'bin/java').absolutePath
+ def cp = System.getProperty('java.class.path')
+ def cmd = [java, '-cp', cp]
+ // pin the state explicitly (the flag is opt-out / on by default), so
+ // the off case actively disables and the test does not rely on the
default
+ cmd << "-Dgroovy.indy.cold.reflection=${coldReflection}".toString()
+ if (logging) cmd << '-Dgroovy.indy.logging=true'
+ cmd += ['groovy.ui.GroovyMain', corpus.absolutePath]
+
+ def pb = new ProcessBuilder(cmd)
+ // when logging, the tier writes to the JUL logger (stderr); fold it in
+ if (logging) pb.redirectErrorStream(true)
+ def process = pb.start()
+ def stdout = process.inputStream.text
+ def stderr = logging ? '' : process.errorStream.text
+ int rc = process.waitFor()
+ assertEquals(0, rc, "corpus JVM (coldReflection=$coldReflection)
failed rc=$rc:\n$stderr")
+ return stdout
+ }
+}
diff --git
a/subprojects/performance/src/test/resources/org/apache/groovy/perf/cold-reflection-parity-corpus.groovy
b/subprojects/performance/src/test/resources/org/apache/groovy/perf/cold-reflection-parity-corpus.groovy
new file mode 100644
index 0000000000..ffe5860332
--- /dev/null
+++
b/subprojects/performance/src/test/resources/org/apache/groovy/perf/cold-reflection-parity-corpus.groovy
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Differential parity corpus for groovy.indy.cold.reflection (GROOVY-12137),
+// driven by ColdReflectionParityTest. Dynamic dispatch throughout (no
+// @CompileStatic) so plain calls hit the reflective cold tier when the flag
+// is on. Prints a fully deterministic transcript; the test runs it with the
+// flag off and on in separate JVMs and asserts the transcripts are
+// byte-identical, so any difference is a genuine reflection-vs-MethodHandle
+// divergence. Every check returns a stable String (or throws, captured
+// stably) — keep all inputs/outputs deterministic (no identity hashes,
+// timing, or randomness) when extending it.
+
+// ---- target classes ----
+class Prim {
+ int i(int x) { x }
+ long lng(long x) { x }
+ char ch() { (char) 65 }
+ boolean bool() { true }
+ byte by() { (byte) 7 }
+ short sh() { (short) 9 }
+ float fl() { 1.5f }
+ double db() { 2.5d }
+}
+class Coerce {
+ String s(String x) { x }
+ long widen(long x) { x }
+ String chr(char c) { String.valueOf(c) }
+ String num(Number n) { n.getClass().simpleName + ':' + n }
+}
+class Vararg {
+ String v(String... xs) { xs.length + ':' + xs.join(',') }
+ int arr(byte[]... a) { a.length }
+ String mix(Object... o) { o.length + ':' + o.collect {
it?.getClass()?.simpleName }.join(',') }
+}
+class Overload {
+ String m(String s) { 'S' }
+ String m(Integer i) { 'I' }
+ String m(Object o) { 'O' }
+ String m(int i) { 'prim' }
+}
+class Ex {
+ def boom() { throw new IllegalStateException('boom') }
+ def missing() { this.nope() }
+ def checked() { throw new java.io.IOException('io') }
+}
+class Ret {
+ void v() { }
+ def nul() { null }
+ int[] arr() { [1, 2, 3] as int[] }
+ Void voidBox() { null }
+}
+class Vis {
+ String pub() { 'pub' }
+ protected String prot() { 'prot' }
+ private String priv() { 'priv' }
+}
+class Pogo {
+ String greet() { 'OWNER' }
+ int echoInt(int x) { x }
+}
+class Pogo2 {
+ int echoInt(int x) { x * 10 }
+ String label() { 'P2' }
+}
+class Intercept implements GroovyInterceptable {
+ def invokeMethod(String name, args) { "intercept:$name" }
+ String real() { 'real' }
+}
+class MethMissing {
+ def methodMissing(String name, args) { "mm:$name/${args.length}" }
+ String real() { 'real' }
+}
+
+// ---- harness ----
+def out = new StringBuilder()
+Closure rec = { String tag, Closure body ->
+ String line
+ try {
+ def r = body()
+ line = "$tag | OK | ${r}"
+ } catch (Throwable t) {
+ line = "$tag | EX | ${t.getClass().name}: ${t.message}"
+ }
+ out.append(line).append('\n')
+}
+// helper: value + box-cache identity, deterministic
+def boxId = { v, canonical -> "${v}/cached=${v.is(canonical)}" }
+
+// ---- A. primitive return value + box identity (the Method.invoke box axis)
----
+rec('A.int.cached') { def p = new Prim(); boxId(p.i(42),
Integer.valueOf(42)) }
+rec('A.int.uncached') { def p = new Prim(); boxId(p.i(9999),
Integer.valueOf(9999)) }
+rec('A.int.neg') { def p = new Prim(); boxId(p.i(-128),
Integer.valueOf(-128)) }
+rec('A.long') { def p = new Prim(); boxId(p.lng(7L), Long.valueOf(7L))
}
+rec('A.long.big') { def p = new Prim(); boxId(p.lng(99999L),
Long.valueOf(99999L)) }
+rec('A.char') { def p = new Prim(); def v = p.ch(); "${(int)
v}/cached=${v.is(Character.valueOf((char) 65))}" }
+rec('A.bool') { def p = new Prim(); def v = p.bool();
"${v}/cached=${v.is(Boolean.TRUE)}" }
+rec('A.byte') { def p = new Prim(); boxId(p.by(), Byte.valueOf((byte)
7)) }
+rec('A.short') { def p = new Prim(); boxId(p.sh(),
Short.valueOf((short) 9)) }
+rec('A.float') { def p = new Prim(); "${p.fl()}" }
+rec('A.double') { def p = new Prim(); "${p.db()}" }
+
+// ---- B. exceptions ----
+rec('B.rte') { new Ex().boom() }
+rec('B.mme') { new Ex().missing() }
+rec('B.checked') { new Ex().checked() }
+rec('B.npe') { def x = null; x.foo() }
+rec('B.nosuch') { new Prim().totallyUnknownMethod() }
+
+// ---- C. argument coercion ----
+rec('C.gstring') { def who = 'x'; new Coerce().s("a${who}${1 + 1}b") }
+rec('C.widen') { new Coerce().widen(5) } // Integer 5 -> long
+rec('C.char') { new Coerce().chr('Z' as char) }
+rec('C.num.int') { new Coerce().num(5) }
+rec('C.num.bd') { new Coerce().num(3.14G) }
+rec('C.num.big') { new Coerce().num(123456789012345G) }
+
+// ---- D. varargs / arrays ----
+rec('D.varg.0') { new Vararg().v() }
+rec('D.varg.1') { new Vararg().v('a') }
+rec('D.varg.3') { new Vararg().v('a', 'b', 'c') }
+rec('D.varg.arr') { new Vararg().arr('ab'.bytes) } // byte[] into
byte[]... (GROOVY-12139)
+rec('D.varg.arr2') { new Vararg().arr('ab'.bytes, 'cd'.bytes) }
+rec('D.varg.mix') { new Vararg().mix(1, 'x', [2], null) }
+
+// ---- E. overload selection ----
+rec('E.ov.string') { new Overload().m('x') }
+rec('E.ov.int') { new Overload().m(5) }
+rec('E.ov.integer'){ new Overload().m(Integer.valueOf(5)) }
+rec('E.ov.obj') { new Overload().m([1, 2]) }
+rec('E.ov.null') { new Overload().m(null) }
+
+// ---- F. caller-sensitive: serialization (must stay off the cold tier) ----
+rec('F.ser.list') {
+ def baos = new ByteArrayOutputStream()
+ new ObjectOutputStream(baos).withCloseable { it.writeObject([1, 2, 3]) }
+ def r = null
+ new ObjectInputStream(new
ByteArrayInputStream(baos.toByteArray())).withCloseable { r = it.readObject() }
+ r.toString()
+}
+
+// ---- G. visibility ----
+rec('G.pub') { new Vis().pub() }
+rec('G.prot') { new Vis().prot() }
+rec('G.priv') { new Vis().priv() }
+
+// ---- H. return-type corners ----
+rec('H.void') { new Ret().v(); 'done' }
+rec('H.null') { new Ret().nul() }
+rec('H.array') { def a = new Ret().arr(); "${a.length}:${a[0]},${a[2]}" }
+rec('H.voidBox') { new Ret().voidBox() }
+
+// ---- I. receiver kinds (POJO / DGM) ----
+rec('I.pojo.substr') { 'Hello'.substring(1, 3) }
+rec('I.pojo.listget') { [10, 20, 30].get(1) }
+rec('I.dgm.reverse') { 'abc'.reverse() }
+rec('I.dgm.sum') { [1, 2, 3, 4].sum() }
+rec('I.dgm.collect') { [1, 2, 3].collect { it * 2 }.toString() }
+rec('I.dgm.max') { [3, 1, 2].max() }
+
+// ---- J. metaclass dynamics (validity invalidation across cold entry) ----
+rec('J.emc') {
+ def r = new Pogo()
+ def a = r.greet()
+ Pogo.metaClass.greet = { -> 'MC' }
+ def b = new Pogo().greet()
+ GroovySystem.metaClassRegistry.removeMetaClass(Pogo)
+ def c = new Pogo().greet()
+ "$a|$b|$c"
+}
+rec('J.category') {
+ def r = new Pogo()
+ def before = r.greet()
+ def during = use(PogoCat) { r.greet() }
+ def after = r.greet()
+ "$before|$during|$after"
+}
+
+// ---- K. promotion boundary: many calls crossing the hit-count threshold ----
+rec('K.promote') {
+ def r = new Pogo()
+ long acc = 0
+ for (int i = 0; i < 2500; i++) { acc += r.echoInt(i % 100) }
+ acc
+}
+rec('K.promote.poly') {
+ def rs = [new Pogo(), new Pogo2()] // both have echoInt; alternate to
stress per-wrapper promotion
+ long acc = 0
+ for (int i = 0; i < 3000; i++) { acc += rs[i % 2].echoInt(i % 50) }
+ acc
+}
+
+// ---- N. more breadth: statics, null-into-primitive, chaining, GString
coercion ----
+rec('N.static.parseInt') { Integer.parseInt('42') } // Class receiver
— cold tier excluded
+rec('N.static.valueOf') { Long.valueOf('123') }
+rec('N.null.prim') { new Prim().i(null) } // null into
primitive param
+rec('N.chain') { 'Hello World'.toLowerCase().split(' ')[1] }
+rec('N.chain.dgm') { [3, 1, 2, 1].unique().sort().join('-') }
+rec('N.gstring.coerce') { new Coerce().widen("${40 + 2}".toInteger()) }
+rec('N.ov.char') { new Overload().m('x' as char) } // char -> which
overload?
+rec('N.map.get') { [a: 1, b: 2].get('b') }
+rec('N.string.eq') { 'abc'.equals('abc') }
+rec('N.number.plus') { 5.plus(3) } // operator as
method via DGM/number
+rec('N.list.add') { def l = [1, 2]; l.add(3); l.toString() }
+rec('N.bool.method') { Boolean.parseBoolean('TRUE') }
+
+// ---- M. MOP interception (must decline the cold tier) ----
+rec('M.interceptable.dyn') { new Intercept().anything(1, 2) }
+rec('M.interceptable.real') { new Intercept().real() }
+rec('M.mm.real') { new MethMissing().real() }
+rec('M.mm.missing') { new MethMissing().ghost(1, 2) }
+
+print out.toString()
+
+class PogoCat {
+ static String greet(Pogo self) { 'CAT' }
+ static int echoInt(Pogo self, int x) { x }
+}