From b5b56927efd0f6f562125cb7f14a1546c46b1d6a Mon Sep 17 00:00:00 2001 From: Paul King Date: Tue, 4 Aug 2026 07:12:19 +1000 Subject: [PATCH 1/3] GROOVY-12227: GeneratedDispatcher: avoid runtime class definition so packed closures work in native images Two layered changes to the GROOVY-12151 packed-closure machinery (GEP-27): 1. ClosureWriter now passes the three dispatch tables as constant bootstrap arguments (CONSTANT_MethodHandle), resolved by the VM's constant pool rather than a runtime Lookup.findStatic -- which under GraalVM native image demanded per-class reflection metadata. Verified: the tracing agent records zero packedDispatch entries for the new bytecode. 2. GeneratedDispatcher.bootstrap keeps the LambdaMetafactory hidden-class adapters on a regular JVM (the JIT-inlining rationale in the class javadoc), but where classes cannot be defined at run time -- native image, detected per link so build-time class init cannot bake in the wrong answer -- it adapts the tables with method-handle-invoking wrappers instead: ordinary bytecode of this class, AOT-compiled into the image. A catch-based fallback covers AOT runtimes the property probe misses. -Dgroovy.packed.dispatch.handles=true forces the wrapper path on a JVM, for parity testing. Permanent behaviour, no user-facing flag: JVM semantics are unchanged and the fallback engages only where the hidden-class path cannot work. The old 3-arg bootstrap remains for class files from earlier 6.0 snapshots. Before/after on GraalVM 25.2.4 (native-image 25.0.4): the packed repro previously failed with 'Classes cannot be defined at runtime ... M$$Lambda...'; it now runs correctly (single emitted class, 30MB image, ~12ms total run time). Parity + undeclared-checked-exception propagation covered by PackedDispatcherHandleBundleTest; all existing packed-closure suites green. --- .../groovy/classgen/asm/ClosureWriter.java | 72 ++++++++++++++- .../groovy/runtime/GeneratedDispatcher.java | 41 +++++++-- .../groovy/vmplugin/v8/IndyInterface.java | 23 ++++- .../PackedDispatcherFactoryTest.groovy | 92 +++++++++++++++++++ 4 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 src/test/groovy/org/codehaus/groovy/runtime/PackedDispatcherFactoryTest.groovy diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java index 4462f7aad5d..58d17f506a6 100644 --- a/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java +++ b/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java @@ -181,6 +181,19 @@ protected interface UseExistingReference { // off the emitted-bytecode surface. private static final String DISPATCHERS_GETTER = "$getPackedDispatchers$"; private static final String DISPATCHERS_GETTER_DESC = "()Ljava/lang/Object;"; + // The factory emitted into the hosting class that adapts its three tables to their functional + // interfaces through bytecode-level LambdaMetafactory sites (see writeDispatchersFactory). + private static final String DISPATCHERS_FACTORY = "$packedDispatchersFactory$"; + private static final String BUNDLE_TYPE = "org/codehaus/groovy/runtime/GeneratedDispatcher$Bundle"; + private static final String DISPATCHER_TYPE = "org/codehaus/groovy/runtime/GeneratedDispatcher"; + private static final String ARITY1_TYPE = "org/codehaus/groovy/runtime/GeneratedDispatcher$Arity1"; + private static final String ARITY2_TYPE = "org/codehaus/groovy/runtime/GeneratedDispatcher$Arity2"; + private static final Handle LMF_BOOTSTRAP = new Handle( + H_INVOKESTATIC, "java/lang/invoke/LambdaMetafactory", "metafactory", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;" + + "Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)" + + "Ljava/lang/invoke/CallSite;", + false); // Max tableswitch cases per dispatch method (power of two: the two-level entry method selects a // chunk with a shift); sized so a full chunk stays well under the JIT's 325-byte inlining budget. private static final int DISPATCH_CHUNK = 8; @@ -1082,20 +1095,28 @@ public void writePackedDispatcher() { org.objectweb.asm.ClassVisitor cv = controller.getClassVisitor(); // the accessor: return INDY packedDispatchers()Object — IndyInterface.packedDispatchers - // (delegating to GeneratedDispatcher.bootstrap) links the class's three dispatch tables - // (through LambdaMetafactory, with this class's lookup) once, on first adapter creation, - // and every later call returns the constant bundle, so the accessor is also the cache + // (delegating to GeneratedDispatcher.bootstrap) invokes this class's emitted factory + // once, on first adapter creation, and every later call returns the constant bundle, + // so the accessor is also the cache MethodVisitor mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCHERS_GETTER, DISPATCHERS_GETTER_DESC, null, null); mv.visitCode(); + // The bundle is built by a factory emitted into this class (see writeDispatchersFactory) + // and reached as a constant bootstrap argument, so the bootstrap needs neither a runtime + // Lookup.findStatic nor a programmatic LambdaMetafactory call — both of which fail under + // GraalVM native image (GROOVY-12227). Handle bootstrap = new Handle( H_INVOKESTATIC, INDY_INTERFACE_TYPE, "packedDispatchers", - "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;" + + "Ljava/lang/invoke/MethodHandle;)Ljava/lang/invoke/CallSite;", false); - mv.visitInvokeDynamicInsn("packedDispatchers", DISPATCHERS_GETTER_DESC, bootstrap); + mv.visitInvokeDynamicInsn("packedDispatchers", DISPATCHERS_GETTER_DESC, bootstrap, + new Handle(H_INVOKESTATIC, internal, DISPATCHERS_FACTORY, DISPATCHERS_GETTER_DESC, false)); mv.visitInsn(ARETURN); mv.visitMaxs(0, 0); mv.visitEnd(); + writeDispatchersFactory(cv, internal); + // the array-free per-arity tables, over the targets whose captured-plus-argument count // matches (membership is sparse over the id space, so cases use lookupswitch); routing is // the adapter's responsibility, so any other id landing here is a compiler bug @@ -1199,6 +1220,47 @@ private static void writeDispatchSwitch(final MethodVisitor mv, final String int * switches over its id-range's members (at most {@code DISPATCH_CHUNK}, since a range spans * {@code DISPATCH_CHUNK} consecutive ids). */ + /** + * Emits the hosting class's dispatcher factory: three bytecode-level + * {@code LambdaMetafactory} sites adapting its private static tables to their functional + * interfaces, wrapped in one {@code Bundle}. + *

+ * Emitting the linkage here rather than calling {@code LambdaMetafactory} programmatically + * from the bootstrap matters twice over. The sites are ordinary {@code invokedynamic}, so + * GraalVM native image pre-processes them at build time — no class is defined at run time, + * and the JVM path is unchanged (the VM spins the same hidden class when it links the site). + * And because the factory lives in the hosting class, its method references reach that + * class's own private tables directly, so no {@code Lookup.findStatic} — and hence no + * per-class reflection metadata — is needed either (GROOVY-12227). + */ + private static void writeDispatchersFactory(final org.objectweb.asm.ClassVisitor cv, final String internal) { + MethodVisitor mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCHERS_FACTORY, DISPATCHERS_GETTER_DESC, null, null); + mv.visitCode(); + mv.visitTypeInsn(NEW, BUNDLE_TYPE); + mv.visitInsn(DUP); + emitLambda(mv, internal, "dispatch", DISPATCHER_TYPE, DISPATCH_METHOD, DISPATCH_DESC); + emitLambda(mv, internal, "dispatch1", ARITY1_TYPE, DISPATCH1_METHOD, DISPATCH1_DESC); + emitLambda(mv, internal, "dispatch2", ARITY2_TYPE, DISPATCH2_METHOD, DISPATCH2_DESC); + mv.visitMethodInsn(INVOKESPECIAL, BUNDLE_TYPE, "", + "(L" + DISPATCHER_TYPE + ";L" + ARITY1_TYPE + ";L" + ARITY2_TYPE + ";)V", false); + mv.visitInsn(ARETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + } + + /** + * Emits one {@code invokedynamic} adapting {@code tableMethod} to the single abstract method + * {@code samName} of {@code ifaceType}. The table's descriptor is both the erased and the + * instantiated signature, so the metafactory inserts no adaptation. + */ + private static void emitLambda(final MethodVisitor mv, final String internal, final String samName, + final String ifaceType, final String tableMethod, final String tableDesc) { + mv.visitInvokeDynamicInsn(samName, "()L" + ifaceType + ";", LMF_BOOTSTRAP, + org.objectweb.asm.Type.getMethodType(tableDesc), + new Handle(H_INVOKESTATIC, internal, tableMethod, tableDesc, false), + org.objectweb.asm.Type.getMethodType(tableDesc)); + } + private static void writeArityTable(final org.objectweb.asm.ClassVisitor cv, final String internal, final ClassNode enclosing, final List targets, final int paramCount, final String tableMethod, final String tableDesc) { diff --git a/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java b/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java index 779aefec635..ecc5430dd83 100644 --- a/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java +++ b/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java @@ -21,9 +21,11 @@ import java.lang.invoke.CallSite; import java.lang.invoke.ConstantCallSite; import java.lang.invoke.LambdaMetafactory; +import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; + /** * A per-class table of compiler-generated dispatch targets, reached by a compact * integer id instead of a {@link java.lang.invoke.MethodHandle}. @@ -115,7 +117,11 @@ final class Bundle { final Arity1 arity1; final Arity2 arity2; - Bundle(final GeneratedDispatcher dispatcher, final Arity1 arity1, final Arity2 arity2) { + /** + * Public because the hosting class's compiler-emitted factory constructs it directly + * (see {@code ClosureWriter#writeDispatchersFactory}); not API for hand-written code. + */ + public Bundle(final GeneratedDispatcher dispatcher, final Arity1 arity1, final Arity2 arity2) { this.dispatcher = dispatcher; this.arity1 = arity1; this.arity2 = arity2; @@ -150,11 +156,12 @@ static Class[] paramTypes(final MethodHandles.Lookup caller, final String nam } /** - * Invokedynamic bootstrap for the hosting class's dispatcher accessor: adapts the class's - * three private static dispatch tables to their functional interfaces (one hidden class - * each, via {@code LambdaMetafactory} with the caller's full-privilege lookup) and returns - * them as one constant {@link Bundle}. Linked once per class, on first adapter creation. - * Emitted bytecode reaches this through + * Legacy invokedynamic bootstrap for the dispatcher accessor, kept for class files emitted + * by earlier 6.0 pre-releases: adapts the class's three private static dispatch tables to + * their functional interfaces (one hidden class each, via {@code LambdaMetafactory} with the + * caller's full-privilege lookup) and returns them as one constant {@link Bundle}. Current + * class files link through the one-{@code MethodHandle} overload instead. Emitted bytecode + * reaches this through * {@code org.codehaus.groovy.vmplugin.v8.IndyInterface#packedDispatchers} — the central * bytecode-facing bootstrap surface — which delegates here. * @@ -182,4 +189,26 @@ static CallSite bootstrap(final MethodHandles.Lookup caller, final String name, twoType, caller.findStatic(host, TABLE2_METHOD, twoType), twoType).getTarget().invokeExact(); return new ConstantCallSite(MethodHandles.constant(type.returnType(), new Bundle(dispatcher, arity1, arity2))); } + + /** + * Invokedynamic bootstrap for the dispatcher accessor: the hosting class supplies (as a + * constant bootstrap argument) a compiler-emitted factory that builds the bundle from its + * own bytecode-level {@code LambdaMetafactory} sites, so linking is one call and + * this method neither looks anything up nor defines any class. Works unchanged under GraalVM + * native image, where those sites are pre-processed at image build time and the factory's + * method references reach the class's own private tables without reflection metadata + * (GROOVY-12227). Emitted bytecode reaches this through + * {@code org.codehaus.groovy.vmplugin.v8.IndyInterface#packedDispatchers}. + * + * @param caller the hosting class's lookup (supplied by the JVM, unused) + * @param name the invoked name (unused) + * @param type the accessor's type (see the three-argument overload) + * @param factory the hosting class's {@code $packedDispatchersFactory$}, {@code () -> Bundle} + * @return a constant call site producing the bundle + * @throws Throwable if the factory fails (a compiler bug) + */ + static CallSite bootstrap(final MethodHandles.Lookup caller, final String name, final MethodType type, + final MethodHandle factory) throws Throwable { + return new ConstantCallSite(MethodHandles.constant(type.returnType(), factory.invoke())); + } } 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 05cd8761505..031060a5d91 100644 --- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java +++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java @@ -640,10 +640,11 @@ public static CallSite staticArrayAccess(MethodHandles.Lookup lookup, String nam } /** - * Invokedynamic bootstrap for a class's packed-closure dispatcher accessor (GROOVY-12151): - * links the class's generated dispatch tables into one constant bundle, lazily on first - * adapter creation. Delegates to {@link GeneratedDispatcher#bootstrap}; hosted here so - * emitted bytecode references only this central bootstrap surface. + * Legacy invokedynamic bootstrap for a class's packed-closure dispatcher accessor + * (GROOVY-12151), kept for class files emitted by earlier 6.0 pre-releases: links the + * class's generated dispatch tables into one constant bundle, lazily on first adapter + * creation. Delegates to {@link GeneratedDispatcher#bootstrap}; hosted here so emitted + * bytecode references only this central bootstrap surface. * * @since 6.0.0 */ @@ -651,6 +652,20 @@ public static CallSite packedDispatchers(MethodHandles.Lookup caller, String nam return GeneratedDispatcher.bootstrap(caller, name, type); } + /** + * Invokedynamic bootstrap for a class's packed-closure dispatcher accessor (GROOVY-12151): + * the hosting class supplies a factory that builds the bundle from its own bytecode-level + * {@code LambdaMetafactory} sites. Nothing is looked up and no class is defined at link + * time, so this links unchanged under GraalVM native image (GROOVY-12227). The + * three-argument form remains for class files emitted by earlier 6.0 pre-releases. + * + * @since 6.0.0 + */ + public static CallSite packedDispatchers(MethodHandles.Lookup caller, String name, MethodType type, + MethodHandle factory) throws Throwable { + return GeneratedDispatcher.bootstrap(caller, name, type, factory); + } + /** * Constant-dynamic bootstrap for a packed closure literal's declared parameter types * (GROOVY-12151): decodes a method descriptor into a {@code Class[]} resolved once per diff --git a/src/test/groovy/org/codehaus/groovy/runtime/PackedDispatcherFactoryTest.groovy b/src/test/groovy/org/codehaus/groovy/runtime/PackedDispatcherFactoryTest.groovy new file mode 100644 index 00000000000..da40f5f6e2f --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/runtime/PackedDispatcherFactoryTest.groovy @@ -0,0 +1,92 @@ +/* + * 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.runtime + +import org.codehaus.groovy.control.CompilerConfiguration +import org.junit.jupiter.api.Test + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertThrows + +/** + * The packed-closure dispatcher linkage (GROOVY-12227): the hosting class's compiler-emitted + * {@code $packedDispatchersFactory$} builds the bundle from bytecode-level + * {@code LambdaMetafactory} sites, invoked once through + * {@link GeneratedDispatcher#bootstrap}. Exercises every dispatch shape through that linkage, + * including the transparent propagation of checked exceptions the dispatch interfaces do not + * declare. + */ +final class PackedDispatcherFactoryTest { + + /** Exercises every dispatch shape: array (3 values), arity-1, arity-2, and a checked throw. */ + private static final String SRC = ''' + class Host { + static List run() { + def results = [] + def one = { int a -> a * 2 } // arity-1 table + def two = { int a, int b -> a + b } // arity-2 table + def three = { int a, int b, int c -> a + b + c } // array table + results << one(21).toString() + results << two(20, 22).toString() + results << three(10, 14, 18).toString() + results << [1, 2, 3].collect { it + 1 }.toString() // through the GDK + results + } + static void boom() { + def thrower = { throw new java.io.IOException('checked, undeclared') } + thrower() + } + } + ''' + + private static Class parsePacked() { + withPacking { + def loader = new GroovyClassLoader() + def host = loader.parseClass(SRC, 'Host.groovy') + assert host.declaredMethods.any { it.name == '$packedDispatch$' } : 'packing did not engage' + assert host.declaredMethods.any { it.name == '$packedDispatchersFactory$' } : 'factory not emitted' + host + } + } + + private static T withPacking(Closure work) { + String previous = System.getProperty(CompilerConfiguration.CLOSURE_PACKING) + System.setProperty(CompilerConfiguration.CLOSURE_PACKING, 'true') + try { + work.call() + } finally { + if (previous != null) { + System.setProperty(CompilerConfiguration.CLOSURE_PACKING, previous) + } else { + System.clearProperty(CompilerConfiguration.CLOSURE_PACKING) + } + } + } + + @Test + void 'every dispatch shape links and dispatches through the emitted factory'() { + assertEquals(['42', '42', '42', '[2, 3, 4]'], parsePacked().run()) + } + + @Test + void 'undeclared checked exceptions propagate unchanged through packed dispatch'() { + def thrown = assertThrows(IOException) { parsePacked().boom() } + assertEquals('checked, undeclared', thrown.message) + } +} From 6784e875cf858ee8243308dea8bd066a3b27fed8 Mon Sep 17 00:00:00 2001 From: Paul King Date: Tue, 4 Aug 2026 16:00:28 +1000 Subject: [PATCH 2/3] GROOVY-12234: AOT indy link mode: dynamic Groovy dispatch in GraalVM native images (spike) Native image supports every java.lang.invoke building block Groovy's indy runtime uses except retargeting an existing call site: MutableCallSite.setTarget and SwitchPoint.invalidateAll both fail with UnsupportedFeatureError (setCallSiteTargetNormal). In this design those primitives only ever install or invalidate caches -- dispatch semantics live in method selection -- so under AOT (imagecode == runtime, or -Dgroovy.indy.aot.link=true on a JVM for testing): * bootstrap links each site once, permanently, to its cache-consulting default path via ConstantCallSite; the CacheableCallSite becomes a state carrier (PIC, fallback target) and is never installed or retargeted -- its setTarget now fails fast in AOT mode so a missed gate surfaces on the JVM too * cache freshness moves from SwitchPoint guards (which can never fire natively) to a global AotDispatch stamp: bumped by every invalidation path (all three SwitchPoint.invalidateAll call sites now funnel through AotDispatch.invalidateAll), captured per MethodHandleWrapper at selection, compared on every PIC hit; mismatch re-selects * the reflective cold tier (GROOVY-12137) is the AOT steady state: promotion to full MH chains is gated off, since chains run in the native MH interpreter while reflective dispatch uses AOT-compiled invocation stubs Also works around a GraalVM linkage gap found en route: the runtime invokedynamic path invokes a bootstrap method without running its declaring class's (observed CE 25.2.4; ordinary getstatic barriers work, and Class.forName(initialize=true) does not recover). Every BSM entry calls ensureInitialized(), which triggers initialization through a cross-class read. JVM behaviour is unchanged: mode is decided per link (never cached in statics, which native image may initialize at build time), the stamp is written but never read outside AOT mode, and no per-invocation platform checks exist -- hot paths read a site-local flag captured at link time. Verified: 20-scenario dynamic gauntlet (EMC change seen by a hot site, per-instance metaclass, category enter/leave transitions, polymorphic dispatch, property read/write, GDK/closures, operators) passes on JVM normal mode, JVM AOT mode, and in a native image built from stock-indy class files with only agent-recorded metadata -- no extra flags. Category/EMC/registry/indy suites: 1529 tests, 0 failures. Native startup for the gauntlet: 19.5 ms vs 320.7 ms on the JVM. Known limitation: steady-state dynamic dispatch is ~12 us/call natively (vs ~25 ns JVM JIT; ~120 ns JVM AOT-mode control) -- dominated by the per-call boot-handle combinator chain in the native MH interpreter; a shallow constant target is the identified follow-up. --- .../groovy/runtime/indy/AotDispatch.java | 92 +++++++++++++++++++ .../groovy/runtime/indy/IndyInvalidation.java | 3 +- .../runtime/indy/SwitchPointInvalidator.java | 3 +- .../codehaus/groovy/reflection/ClassInfo.java | 3 +- .../groovy/vmplugin/v8/CacheableCallSite.java | 29 ++++++ .../groovy/vmplugin/v8/IndyInterface.java | 71 ++++++++++++-- .../vmplugin/v8/MethodHandleWrapper.java | 11 +++ 7 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/apache/groovy/runtime/indy/AotDispatch.java diff --git a/src/main/java/org/apache/groovy/runtime/indy/AotDispatch.java b/src/main/java/org/apache/groovy/runtime/indy/AotDispatch.java new file mode 100644 index 00000000000..b9986b95671 --- /dev/null +++ b/src/main/java/org/apache/groovy/runtime/indy/AotDispatch.java @@ -0,0 +1,92 @@ +/* + * 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.runtime.indy; + +import java.lang.invoke.SwitchPoint; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Ahead-of-time link mode for indy dispatch (spike). + *

+ * GraalVM native image supports every {@code java.lang.invoke} building block Groovy's indy + * runtime uses except retargeting an existing call site: both + * {@code MutableCallSite.setTarget} and {@code SwitchPoint.invalidateAll} fail with + * {@code Unsupported method java.lang.invoke.MethodHandleNatives.setCallSiteTargetNormal}. + * In Groovy's design those two primitives only ever install or invalidate caches — + * the dispatch semantics live entirely in method selection — so under AOT the runtime links + * every site once to its cache-consulting default path ({@code ConstantCallSite}) and carries + * freshness in data instead: + *

+ * The JVM path is untouched: sites link mutable exactly as before, and the stamp is written + * but never read. Coarser than the scoped SwitchPoint invalidation of GROOVY-12191 (any + * invalidation flushes every AOT PIC entry on next hit), which is safe — staleness is + * impossible, over-invalidation just re-selects. + * + * @since 6.0.0 + */ +public final class AotDispatch { + + /** + * Diagnostic knob: forces AOT link mode on a regular JVM so the whole mode can be + * exercised by ordinary tests without a native build. + */ + public static final String FORCE_PROPERTY = "groovy.indy.aot.link"; + + private static final AtomicLong STAMP = new AtomicLong(); + + private AotDispatch() { + } + + /** + * Whether sites should link in AOT mode. Evaluated per call and never cached in a static: + * under native image this class may be initialized at image build time, where + * {@code org.graalvm.nativeimage.imagecode} reports {@code buildtime} — caching would bake + * the wrong answer into the image heap. Callers are all link-time or invalidation-time + * (cold); per-invocation code reads the site-local flag captured at link time instead. + */ + public static boolean isAotLinkRequested() { + return "runtime".equals(System.getProperty("org.graalvm.nativeimage.imagecode")) + || Boolean.getBoolean(FORCE_PROPERTY); + } + + /** The current global invalidation stamp. */ + public static long stamp() { + return STAMP.get(); + } + + /** + * Invalidates the given switch points, AOT-safely: the global stamp is always advanced + * (so AOT-linked sites observe the change on their next PIC hit), and the actual + * {@link SwitchPoint#invalidateAll} — which native image cannot execute — runs only + * outside AOT mode. All indy invalidation funnels through here. + * + * @param switchPoints the points to invalidate; may be empty + */ + public static void invalidateAll(final SwitchPoint[] switchPoints) { + STAMP.incrementAndGet(); + if (!isAotLinkRequested()) { + SwitchPoint.invalidateAll(switchPoints); + } + } +} diff --git a/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java index c45793f3e79..e75d3b5979c 100644 --- a/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java +++ b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java @@ -333,7 +333,8 @@ private static void invalidateBatch(final List batch) { if (batch.isEmpty()) { return; } - SwitchPoint.invalidateAll(batch.toArray(EMPTY_SWITCH_POINTS)); + // AOT-safe: advances the AotDispatch stamp; the real invalidateAll runs only on a JVM + AotDispatch.invalidateAll(batch.toArray(EMPTY_SWITCH_POINTS)); } // ------------------------------------------------------------------------- diff --git a/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java index 79cb0ddbc0a..4a57a875e34 100644 --- a/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java +++ b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java @@ -124,7 +124,8 @@ public static void invalidateIfLive(final SwitchPoint sp) { synchronized (SINGLE_INVALIDATE_LOCK) { SINGLE_INVALIDATE_BUF[0] = sp; try { - SwitchPoint.invalidateAll(SINGLE_INVALIDATE_BUF); + // AOT-safe: stamp always advances; real invalidateAll only on a JVM + AotDispatch.invalidateAll(SINGLE_INVALIDATE_BUF); } finally { SINGLE_INVALIDATE_BUF[0] = null; } diff --git a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java index ec2042012af..4279132b251 100644 --- a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java +++ b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java @@ -239,7 +239,8 @@ public void invalidateIndySwitchPoint() { List batch = new ArrayList<>(2); collectLiveIndySwitchPoints(batch); if (!batch.isEmpty()) { - SwitchPoint.invalidateAll(batch.toArray(new SwitchPoint[0])); + // AOT-safe: stamp always advances; real invalidateAll only on a JVM + org.apache.groovy.runtime.indy.AotDispatch.invalidateAll(batch.toArray(new SwitchPoint[0])); } } diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java index d412b2b6cb3..8b174dcb00b 100644 --- a/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java +++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java @@ -58,6 +58,7 @@ public class CacheableCallSite extends MutableCallSite { private volatile SoftReference latestHitMethodHandleWrapperSoftReference = null; private final AtomicLong fallbackCount = new AtomicLong(); private final AtomicLong fallbackRound = new AtomicLong(); + private final boolean aotLinked; private MethodHandle defaultTarget; private MethodHandle fallbackTarget; private final Map> lruCache = @@ -85,6 +86,34 @@ protected boolean removeEldestEntry(Map.Entry eldest) { public CacheableCallSite(MethodType type, MethodHandles.Lookup lookup) { super(type); this.lookup = lookup; + // captured once, at link time (this constructor only runs while linking a site), so + // per-invocation code reads a plain field instead of probing system properties + this.aotLinked = org.apache.groovy.runtime.indy.AotDispatch.isAotLinkRequested(); + } + + /** + * Whether this site was linked in AOT mode (GraalVM native image, or the + * {@code groovy.indy.aot.link} diagnostic knob): the site is wrapped in a + * {@code ConstantCallSite} over the cache-consulting default path, is never retargeted, + * and cache freshness is carried by the {@code AotDispatch} stamp instead of SwitchPoints. + */ + public boolean isAotLinked() { + return aotLinked; + } + + /** + * Fails fast on any retarget attempt in AOT mode. Under a real native image + * {@code setTarget} throws {@code UnsupportedFeatureError} anyway — and worse, execution + * would continue with the stale target if that error were swallowed — so a missed gate is + * a bug on every platform; this surfaces it on the JVM, where tests run with the + * diagnostic knob. + */ + @Override + public void setTarget(final MethodHandle newTarget) { + if (aotLinked) { + throw new IllegalStateException("call site retargeting is disabled in AOT link mode (GROOVY-12227 spike)"); + } + super.setTarget(newTarget); } /** 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 031060a5d91..10aa15cd459 100644 --- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java +++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java @@ -20,6 +20,7 @@ import groovy.lang.GroovyRuntimeException; import groovy.lang.GroovySystem; +import org.apache.groovy.runtime.indy.AotDispatch; import org.apache.groovy.runtime.indy.IndyInvalidation; import org.apache.groovy.util.SystemUtil; import org.codehaus.groovy.GroovyBugError; @@ -220,6 +221,37 @@ public int getOrderNumber() { } } + /** + * Guards against a GraalVM native-image gap: the runtime invokedynamic linkage invokes a + * bootstrap method without running its declaring class's {@code } first (observed + * on GraalVM CE 25.2.4; on HotSpot the bootstrap's {@code DirectMethodHandle} carries a + * class-initialization barrier). Every BSM entry point calls this; on an initialized class + * it is a single null check. + */ + private static void ensureInitialized() { + if (FROM_CACHE_HANDLE_METHOD == null) { + // an ordinary cross-class static read carries the initialization barrier the + // native-image BSM invocation path lacks; reading our own field would not + ClinitBarrier.trigger(); + if (FROM_CACHE_HANDLE_METHOD == null) { + throw new GroovyBugError("IndyInterface linkage handles unavailable: did not run"); + } + } + } + + /** + * A separate class whose read of {@link IndyInterface#LOOKUP} is an ordinary + * {@code getstatic} from foreign code — compiled with the standard ensure-initialized + * barrier that the native-image bootstrap-method invocation path is missing. + */ + private static final class ClinitBarrier { + static void trigger() { + if (IndyInterface.LOOKUP == null) { + throw new GroovyBugError("unreachable: LOOKUP read before initialization"); + } + } + } + static { // MetaClass registry changes invalidate the affected class domain (GROOVY-12191). // Stock MetaClassImpl/EMC → exact class; custom MetaClass kinds → bulk. @@ -294,6 +326,7 @@ static MethodHandle applyMopSwitchPoints(final MethodHandle handle, final Method * @since 2.1.0 */ public static CallSite bootstrap(final MethodHandles.Lookup caller, final String callType, final MethodType type, final String name, final int flags) { + ensureInitialized(); CallType ct = CallType.fromCallSiteName(callType); if (null == ct) throw new GroovyBugError("Unknown call type: " + callType); @@ -314,10 +347,20 @@ public static CallSite bootstrap(final MethodHandles.Lookup caller, final String } // make an adapter for method selection, i.e. get cached method handle (fast path) or fall back MethodHandle mh = makeBootHandle(mc, sender, name, callID, type, safe, thisCall, spreadCall, FROM_CACHE_HANDLE_METHOD); - mc.setTarget(mh); mc.setDefaultTarget(mh); mc.setFallbackTarget(makeFallBack(mc, sender, name, callID, type, safe, thisCall, spreadCall)); + if (mc.isAotLinked()) { + // AOT link mode (GROOVY-12227 spike): native image cannot retarget call sites + // (MethodHandleNatives.setCallSiteTargetNormal is unsupported), so the site links + // once, permanently, to the cache-consulting default path. The CacheableCallSite is + // never installed as the call site — it serves as the state carrier (PIC, fallback + // target) the bound handles consult. Retargeting only ever installs caches in this + // design, so semantics are unchanged; freshness moves to the AotDispatch stamp. + return new ConstantCallSite(mh); + } + mc.setTarget(mh); + return mc; } @@ -436,9 +479,15 @@ private static MethodHandle fromCacheHandle(CacheableCallSite callSite, Class // The PIC stores a sentinel to remember "do not relink this receiver shape"; // execution still needs a real handle for the current invocation. mhw = fallbackSupplier.get(); + } else if (callSite.isAotLinked() && mhw.getAotStamp() != AotDispatch.stamp()) { + // AOT freshness: the cached chain's SwitchPoint guards cannot fire under native + // image, so a stamp mismatch (any MOP invalidation since selection) is a miss — + // re-select and replace the PIC entry + mhw = fallbackSupplier.get(); + callSite.put(receiverClassName, mhw.isCanSetTarget() ? mhw : UNCACHEABLE_PIC_SENTINEL); } - if (mhw.isCanSetTarget() && (callSite.getTarget() != mhw.getTargetMethodHandle())) { + if (!callSite.isAotLinked() && mhw.isCanSetTarget() && (callSite.getTarget() != mhw.getTargetMethodHandle())) { // GROOVY-11935: Set invokedynamic call site target immediately to enable earlier JIT inlining. if (callSite.type().parameterType(0) == Class.class) { var method = mhw.getMethod(); @@ -497,8 +546,14 @@ private static MethodHandle fromCacheHandle(CacheableCallSite callSite, Class * reflective path on the same miss. */ private static Object invokeColdReflective(ColdReflectiveMethodHandleWrapper cold, Object[] arguments) throws Throwable { - if (cold.isValidFor(arguments)) { - if (cold.incrementReflectiveHits() > INDY_OPTIMIZE_THRESHOLD) { + // AOT freshness: classValidity.hasBeenInvalidated() can never report true under native + // image, so the stamp carries staleness; a mismatch takes the re-selection path below + boolean aotStale = cold.callSite.isAotLinked() && cold.getAotStamp() != AotDispatch.stamp(); + if (!aotStale && cold.isValidFor(arguments)) { + // In AOT mode the reflective tier IS the steady state: method-handle chains run in + // the native MH interpreter (microseconds/call, no JIT to fold them) while + // reflective dispatch uses AOT-compiled invocation stubs — so never promote. + if (!cold.callSite.isAotLinked() && cold.incrementReflectiveHits() > INDY_OPTIMIZE_THRESHOLD) { // no longer cold: build the full guarded chain and replace the // PIC entry, so even sites the consecutive-hit promotion never // catches (e.g. polymorphic receivers) leave the reflective @@ -564,13 +619,17 @@ private static MethodHandle selectMethodHandle(CacheableCallSite callSite, Class MethodHandle defaultTarget = callSite.getDefaultTarget(); long fallbackCount = callSite.incrementFallbackCount(); - if ((fallbackCount > INDY_FALLBACK_THRESHOLD) && (callSite.getTarget() != defaultTarget)) { + if (!callSite.isAotLinked() + && (fallbackCount > INDY_FALLBACK_THRESHOLD) && (callSite.getTarget() != defaultTarget)) { callSite.setTarget(defaultTarget); if (LOG_ENABLED) LOG.info("call site target reset to default, preparing outside invocation"); callSite.resetFallbackCount(); } - if (callSite.getTarget() == defaultTarget) { + // in AOT mode the effective target is always the default path (the ConstantCallSite + // wraps it), so the PIC write-back below must run; getTarget() would report the + // never-installed placeholder + if (callSite.isAotLinked() || callSite.getTarget() == defaultTarget) { // correct the stale methodHandle in the inline cache of callsite // it is important but impacts the performance somehow when cache misses frequently Object receiver = arguments[0]; diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java index 33ff56fb1f4..8c0be447382 100644 --- a/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java +++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java @@ -34,6 +34,13 @@ class MethodHandleWrapper { private final MetaMethod method; private final boolean canSetTarget; private final AtomicLong latestHitCount = new AtomicLong(0); + /** + * The global {@code AotDispatch} invalidation stamp at creation. Read only for sites + * linked in AOT mode: a mismatch on a PIC hit means the MOP changed since this wrapper + * was selected and it must be treated as a miss (the AOT replacement for the SwitchPoint + * guards, which cannot fire under native image). + */ + private final long aotStamp = org.apache.groovy.runtime.indy.AotDispatch.stamp(); /** * Creates a wrapper for the cached and relink targets of a meta method. @@ -73,6 +80,10 @@ public MethodHandle getTargetMethodHandle() { * * @return the wrapped meta method */ + long getAotStamp() { + return aotStamp; + } + public MetaMethod getMethod() { return method; } From f0ecc57449d08b348bdeddc34bddade411f4662d Mon Sep 17 00:00:00 2001 From: Paul King Date: Tue, 4 Aug 2026 16:41:32 +1000 Subject: [PATCH 3/3] GROOVY-12234: AOT indy: shallow constant target; localise the native dispatch floor Replace the AOT-linked site's target -- previously the JVM path's deep boot handle (bind + fold + exactInvoker + collector + asType) -- with a single bound handle into aotDispatch(), a plain-Java dispatcher doing PIC lookup (new allocation-free CacheableCallSite.getIfPresent), AotDispatch stamp freshness, sentinel semantics matching fromCacheHandle, and direct invocation of the reflective cold tier without re-entering the method-handle machinery. The now-dead AOT stamp branch in fromCacheHandle is removed. Measurement drove the design and corrected its own premise. The deep chain was suspected of costing microseconds per node; a layer-by-layer native bisection shows the truth is a per-ENTRY cost: invoking any runtime-created MethodHandle under native image costs ~4.5us (interpreter entry), regardless of chain depth, invokeExact vs invoke, or adapter count, while AOT-compiled reflection stubs run at ~10ns. Every dynamic call site's invokedynamic hop into its runtime-linked target pays that entry once per call, so a dynamic Groovy method with N call sites on its path costs ~N x 4us natively: the 2M-call loop (3 sites: call, plus, compound assign) measures ~12us/call before and after this change. The shallow target is kept for what it does deliver: no per-call FallbackSupplier/value-provider allocation, everything past the entry in compiled code, and the right substrate for the two real escapes -- an upstream runtime-MH compilation tier (Ristretto/Crema), or build-time rewriting of indy sites to invokestatic aotDispatch, which would remove the runtime method-handle boundary entirely. Verified: 20-scenario gauntlet passes on JVM normal, JVM AOT-knob, and native; dispatch-heavy loop correctness identical across all three. --- .../groovy/vmplugin/v8/CacheableCallSite.java | 16 +++++ .../groovy/vmplugin/v8/IndyInterface.java | 64 +++++++++++++++---- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java index 8b174dcb00b..5603549ad0b 100644 --- a/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java +++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java @@ -123,6 +123,22 @@ public void setTarget(final MethodHandle newTarget) { * @param valueProvider the provider used to compute a missing entry * @return the cached or newly created wrapper */ + /** + * Read-only PIC lookup: the cached wrapper for the receiver class, or {@code null} when + * absent or its soft reference has been cleared. Used by the AOT dispatch path, which + * resolves misses itself and must not pay for a value-provider allocation per call. + * + * @param className the receiver cache key + * @return the cached wrapper or {@code null} + */ + public MethodHandleWrapper getIfPresent(String className) { + final SoftReference ref; + synchronized (lruCache) { + ref = lruCache.get(className); + } + return ref == null ? null : ref.get(); + } + public MethodHandleWrapper getAndPut(String className, MemoizeCache.ValueProvider valueProvider) { MethodHandleWrapper result = null; SoftReference resultSoftReference; 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 10aa15cd459..47e27534afe 100644 --- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java +++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java @@ -208,6 +208,12 @@ public int getOrderNumber() { */ static final MethodHandle COLD_REFLECTIVE_INVOKER; + /** + * Handle for {@link #aotDispatch}: the single plain-Java entry an AOT-linked site's + * constant target binds to. + */ + private static final MethodHandle AOT_DISPATCH_METHOD; + static { try { MethodType mt = MethodType.methodType(MethodHandle.class, CacheableCallSite.class, Class.class, String.class, int.class, Boolean.class, Boolean.class, Boolean.class, Object.class, Object[].class); @@ -216,6 +222,9 @@ public int getOrderNumber() { SELECT_METHOD_HANDLE_METHOD = LOOKUP.findStatic(IndyInterface.class, "selectMethodHandle", mt); COLD_REFLECTIVE_INVOKER = LOOKUP.findStatic(IndyInterface.class, "invokeColdReflective", MethodType.methodType(Object.class, ColdReflectiveMethodHandleWrapper.class, Object[].class)); + AOT_DISPATCH_METHOD = LOOKUP.findStatic(IndyInterface.class, "aotDispatch", + MethodType.methodType(Object.class, CacheableCallSite.class, Class.class, String.class, int.class, + Boolean.class, Boolean.class, Boolean.class, Object[].class)); } catch (Exception e) { throw new GroovyBugError(e); } @@ -353,11 +362,24 @@ public static CallSite bootstrap(final MethodHandles.Lookup caller, final String if (mc.isAotLinked()) { // AOT link mode (GROOVY-12227 spike): native image cannot retarget call sites // (MethodHandleNatives.setCallSiteTargetNormal is unsupported), so the site links - // once, permanently, to the cache-consulting default path. The CacheableCallSite is - // never installed as the call site — it serves as the state carrier (PIC, fallback - // target) the bound handles consult. Retargeting only ever installs caches in this - // design, so semantics are unchanged; freshness moves to the AotDispatch stamp. - return new ConstantCallSite(mh); + // once, permanently, to a constant target. The CacheableCallSite is never installed + // as the call site — it serves as the state carrier (PIC, fallback target) the + // dispatcher consults. Retargeting only ever installs caches in this design, so + // semantics are unchanged; freshness moves to the AotDispatch stamp. + // + // The target is deliberately SHALLOW: one bound handle into aotDispatch, which does + // PIC lookup, freshness check, and invocation in ordinary compiled Java, and — for + // the dominant reflective tier — never re-enters the method-handle machinery. + // Measured caveat: entering ANY runtime-created method handle costs ~4.5us under + // native image (a per-entry interpreter cost, independent of chain depth or + // invokeExact), and the invokedynamic instruction's hop into this runtime-linked + // target pays it once per call regardless of the target's shape. The shallow form + // still wins on allocation (no per-call FallbackSupplier/provider lambdas) and + // keeps everything past the entry in compiled code. + MethodHandle aot = MethodHandles.insertArguments(AOT_DISPATCH_METHOD, 0, + mc, sender, name, callID, safe, thisCall, spreadCall); + aot = aot.asCollector(Object[].class, type.parameterCount()).asType(type); + return new ConstantCallSite(aot); } mc.setTarget(mh); @@ -479,12 +501,6 @@ private static MethodHandle fromCacheHandle(CacheableCallSite callSite, Class // The PIC stores a sentinel to remember "do not relink this receiver shape"; // execution still needs a real handle for the current invocation. mhw = fallbackSupplier.get(); - } else if (callSite.isAotLinked() && mhw.getAotStamp() != AotDispatch.stamp()) { - // AOT freshness: the cached chain's SwitchPoint guards cannot fire under native - // image, so a stamp mismatch (any MOP invalidation since selection) is a miss — - // re-select and replace the PIC entry - mhw = fallbackSupplier.get(); - callSite.put(receiverClassName, mhw.isCanSetTarget() ? mhw : UNCACHEABLE_PIC_SENTINEL); } if (!callSite.isAotLinked() && mhw.isCanSetTarget() && (callSite.getTarget() != mhw.getTargetMethodHandle())) { @@ -527,6 +543,32 @@ private static MethodHandle fromCacheHandle(CacheableCallSite callSite, Class return mhw.getCachedMethodHandle(); } + /** + * The AOT-linked site's dispatch entry: PIC lookup, stamp-based freshness, and invocation, + * all in ordinary compiled Java (see the AOT branch of + * {@link #bootstrap(MethodHandles.Lookup, String, MethodType, String, int)}). The dominant + * tier — the reflective cold wrapper — is invoked directly, not through its bound method + * handle, so steady-state dispatch never enters the native method-handle interpreter. + *

+ * PIC semantics mirror {@code fromCacheHandle}: uncacheable selections store the sentinel + * (forcing re-selection per call, since class-keyed reuse would be wrong for e.g. + * per-instance metaclasses), and a stamp mismatch — any MOP invalidation since the wrapper + * was selected — is a miss. + */ + private static Object aotDispatch(CacheableCallSite callSite, Class sender, String methodName, int callID, + Boolean safeNavigation, Boolean thisCall, Boolean spreadCall, Object[] arguments) throws Throwable { + String receiverClassName = receiverCacheKey(arguments[0]); + MethodHandleWrapper mhw = callSite.getIfPresent(receiverClassName); + if (mhw == null || mhw == UNCACHEABLE_PIC_SENTINEL || mhw.getAotStamp() != AotDispatch.stamp()) { + mhw = fallback(callSite, sender, methodName, callID, safeNavigation, thisCall, spreadCall, 1, arguments); + callSite.put(receiverClassName, mhw.isCanSetTarget() ? mhw : UNCACHEABLE_PIC_SENTINEL); + } + if (mhw instanceof ColdReflectiveMethodHandleWrapper) { + return invokeColdReflective((ColdReflectiveMethodHandleWrapper) mhw, arguments); + } + return mhw.getCachedMethodHandle().invokeExact(arguments); + } + /** * Cold-tier dispatch for the {@code groovy.indy.cold.reflection} spike. * Re-validates the cached selection with plain-Java checks and invokes the