You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/06/06 05:43:43 UTC

(groovy) 01/01: GROOVY-11935: Set invokedynamic call site target immediately to enable earlier JIT inlining(private and final cases)

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch GROOVY-11935-2
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 7989b700f652248af34b43bbc3874e1b85525268
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat May 23 23:05:11 2026 +0900

    GROOVY-11935: Set invokedynamic call site target immediately to enable earlier JIT inlining(private and final cases)
---
 .../groovy/vmplugin/v8/CacheableCallSite.java      |  18 ++-
 .../codehaus/groovy/vmplugin/v8/IndyInterface.java |  80 +++++++---
 .../groovy/vmplugin/v8/MethodHandleWrapper.java    |  68 +++++++++
 .../v8/IndyInterfaceCallSiteTargetTest.groovy      | 165 ++++++++++++++++++++-
 .../bench/FinalInstanceMethodCallIndy.groovy       |  56 +++++++
 .../bench/FinalInstanceMethodCallIndyBench.java    |  95 ++++++++++++
 .../FinalInstanceMethodCallIndyColdBench.java      |  57 +++++++
 .../bench/PrivateInstanceMethodCallIndy.groovy     |  61 ++++++++
 .../bench/PrivateInstanceMethodCallIndyBench.java  |  95 ++++++++++++
 .../PrivateInstanceMethodCallIndyColdBench.java    |  57 +++++++
 10 files changed, 717 insertions(+), 35 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 b6a9a4622c..9ef47cc58f 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java
@@ -101,15 +101,19 @@ public class CacheableCallSite extends MutableCallSite {
                 lruCache.put(className, resultSoftReference);
             }
         }
-        final SoftReference<MethodHandleWrapper> mhwsr = latestHitMethodHandleWrapperSoftReference;
-        final MethodHandleWrapper methodHandleWrapper = null == mhwsr ? null : mhwsr.get();
-
-        if (methodHandleWrapper == result) {
+        final SoftReference<MethodHandleWrapper> latestHitReference = latestHitMethodHandleWrapperSoftReference;
+        if (latestHitReference == resultSoftReference) {
             result.incrementLatestHitCount();
         } else {
-            result.resetLatestHitCount();
-            if (null != methodHandleWrapper) methodHandleWrapper.resetLatestHitCount();
-            latestHitMethodHandleWrapperSoftReference = resultSoftReference;
+            final MethodHandleWrapper latestHitMethodHandleWrapper = null == latestHitReference ? null : latestHitReference.get();
+            if (latestHitMethodHandleWrapper == result) {
+                result.incrementLatestHitCount();
+                latestHitMethodHandleWrapperSoftReference = resultSoftReference;
+            } else {
+                result.resetLatestHitCount();
+                if (null != latestHitMethodHandleWrapper) latestHitMethodHandleWrapper.resetLatestHitCount();
+                latestHitMethodHandleWrapperSoftReference = resultSoftReference;
+            }
         }
 
         return result;
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 c088992337..e54c3f1975 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
@@ -30,7 +30,6 @@ import java.lang.invoke.MethodHandles;
 import java.lang.invoke.MethodType;
 import java.lang.invoke.MutableCallSite;
 import java.lang.invoke.SwitchPoint;
-import java.lang.reflect.Modifier;
 import java.util.Map;
 import java.util.function.Function;
 import java.util.logging.Level;
@@ -371,35 +370,69 @@ public class IndyInterface {
             mhw = fallbackSupplier.get();
         }
 
-        if (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();
-                if (method != null && Modifier.isStatic(method.getModifiers())) {
-                    callSite.setTarget(mhw.getTargetMethodHandle());
-                }
-            }
-
-            if (mhw.getLatestHitCount() > INDY_OPTIMIZE_THRESHOLD) {
-                if (callSite.getFallbackRound().get() > INDY_FALLBACK_CUTOFF) {
-                    if (callSite.getTarget() != callSite.getDefaultTarget()) {
-                        // reset the call site target to default forever to avoid JIT deoptimization storm further
-                        callSite.setTarget(callSite.getDefaultTarget());
+        if (mhw.isCanSetTarget()) {
+            long latestHitCount = mhw.getLatestHitCount();
+            boolean optimizeTarget = latestHitCount > INDY_OPTIMIZE_THRESHOLD;
+            boolean setTargetEarly = shouldSetCallSiteTargetEarly(mhw, receiver, latestHitCount);
+
+            if (setTargetEarly || optimizeTarget) {
+                MethodHandle targetMethodHandle = mhw.getTargetMethodHandle();
+                MethodHandle currentTarget = callSite.getTarget();
+                if (currentTarget != targetMethodHandle) {
+                    if (setTargetEarly) {
+                        callSite.setTarget(targetMethodHandle);
+                        currentTarget = targetMethodHandle;
                     }
-                } else {
-                    if (callSite.getTarget() != mhw.getTargetMethodHandle()) {
-                        callSite.setTarget(mhw.getTargetMethodHandle());
-                        if (LOG_ENABLED) LOG.info("call site target set, preparing outside invocation");
+
+                    if (optimizeTarget) {
+                        if (callSite.getFallbackRound().get() > INDY_FALLBACK_CUTOFF) {
+                            MethodHandle defaultTarget = callSite.getDefaultTarget();
+                            if (currentTarget != defaultTarget) {
+                                // reset the call site target to default forever to avoid JIT deoptimization storm further
+                                callSite.setTarget(defaultTarget);
+                            }
+                        } else {
+                            if (currentTarget != targetMethodHandle) {
+                                callSite.setTarget(targetMethodHandle);
+                                if (LOG_ENABLED) LOG.info("call site target set, preparing outside invocation");
+                            }
+                        }
+
+                        mhw.resetLatestHitCount();
                     }
                 }
-
-                mhw.resetLatestHitCount();
             }
         }
 
         return mhw.getCachedMethodHandle();
     }
 
+    /**
+     * GROOVY-11935: install direct-looking targets early when the receiver shape is already
+     * specific enough to make earlier JIT inlining worthwhile.
+     *
+     * <p>Three cases trigger early relinking (in priority order):
+     * <ol>
+     *   <li><b>Private method (static or instance)</b> — non-overridable by definition; the
+     *       dispatch target is uniquely determined regardless of the call-site receiver type,
+     *       so relinking is safe on the very first hit.</li>
+     *   <li><b>Static call on a {@code Class} receiver</b> — the dispatch target
+     *       is fully determined by the declared call-site type; relink on first hit.</li>
+     *   <li><b>Final receiver type</b> — the JVM verifier guarantees that any non-null,
+     *       non-{@code Class} object reaching a call site whose static parameter type is a
+     *       {@code final} class is exactly that class (no subclass can exist). The runtime
+     *       type therefore needs no separate equality check; one repeated hit is still
+     *       required to avoid thrashing cold sites.</li>
+     * </ol>
+     */
+    private static boolean shouldSetCallSiteTargetEarly(MethodHandleWrapper mhw, Object receiver, long latestHitCount) {
+        if (mhw.shouldSetCallSiteTargetImmediately()) return true;
+
+        return mhw.shouldSetCallSiteTargetOnRepeatedHit()
+            && latestHitCount > 0
+            && receiver != null;
+    }
+
     /**
      * Core method for indy method selection using runtime types.
      * @deprecated Use the new bootHandle-based approach instead.
@@ -452,11 +485,12 @@ public class IndyInterface {
         Selector selector = Selector.getSelector(callSite, sender, methodName, callID, safeNavigation, thisCall, spreadCall, arguments);
         selector.setCallSiteTarget();
 
-        return new MethodHandleWrapper(
+        return MethodHandleWrapper.create(
                 selector.handle.asSpreader(Object[].class, arguments.length).asType(MethodType.methodType(Object.class, Object[].class)),
                 selector.handle,
                 selector.method,
-                selector.cache
+                selector.cache,
+                callSite.type().parameterType(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 71c3cb5dbb..eb93e6baca 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java
@@ -21,6 +21,7 @@ package org.codehaus.groovy.vmplugin.v8;
 import groovy.lang.MetaMethod;
 
 import java.lang.invoke.MethodHandle;
+import java.lang.reflect.Modifier;
 import java.util.concurrent.atomic.AtomicLong;
 
 /**
@@ -33,6 +34,7 @@ class MethodHandleWrapper {
     private final MethodHandle targetMethodHandle;
     private final MetaMethod method;
     private final boolean canSetTarget;
+    private final CallSiteTargetRelinkPolicy callSiteTargetRelinkPolicy;
     private final AtomicLong latestHitCount = new AtomicLong(0);
 
     /**
@@ -44,10 +46,35 @@ class MethodHandleWrapper {
      * @param canSetTarget whether the call site target may be updated to this handle
      */
     public MethodHandleWrapper(MethodHandle cachedMethodHandle, MethodHandle targetMethodHandle, MetaMethod method, boolean canSetTarget) {
+        this(cachedMethodHandle, targetMethodHandle, method, canSetTarget, CallSiteTargetRelinkPolicy.NEVER);
+    }
+
+    private MethodHandleWrapper(MethodHandle cachedMethodHandle, MethodHandle targetMethodHandle, MetaMethod method, boolean canSetTarget, CallSiteTargetRelinkPolicy callSiteTargetRelinkPolicy) {
         this.cachedMethodHandle = cachedMethodHandle;
         this.targetMethodHandle = targetMethodHandle;
         this.method = method;
         this.canSetTarget = canSetTarget;
+        this.callSiteTargetRelinkPolicy = callSiteTargetRelinkPolicy;
+    }
+
+    /**
+     * Creates a wrapper and precomputes the early call-site relink policy for the supplied receiver type.
+     *
+     * @param cachedMethodHandle the cached invocation handle
+     * @param targetMethodHandle the relink target handle
+     * @param method the associated meta method
+     * @param canSetTarget whether the call site target may be updated to this handle
+     * @param receiverType the declared call-site receiver type
+     * @return the configured wrapper
+     */
+    static MethodHandleWrapper create(MethodHandle cachedMethodHandle, MethodHandle targetMethodHandle, MetaMethod method, boolean canSetTarget, Class<?> receiverType) {
+        return new MethodHandleWrapper(
+            cachedMethodHandle,
+            targetMethodHandle,
+            method,
+            canSetTarget,
+            resolveCallSiteTargetRelinkPolicy(method, receiverType)
+        );
     }
 
     /**
@@ -86,6 +113,24 @@ class MethodHandleWrapper {
         return canSetTarget;
     }
 
+    /**
+     * Indicates whether the call site can be relinked on the first cache hit.
+     *
+     * @return {@code true} when the target can be installed immediately
+     */
+    boolean shouldSetCallSiteTargetImmediately() {
+        return callSiteTargetRelinkPolicy == CallSiteTargetRelinkPolicy.IMMEDIATE;
+    }
+
+    /**
+     * Indicates whether the call site can be relinked after observing a repeated exact-final receiver hit.
+     *
+     * @return {@code true} when a repeated hit may trigger relinking
+     */
+    boolean shouldSetCallSiteTargetOnRepeatedHit() {
+        return callSiteTargetRelinkPolicy == CallSiteTargetRelinkPolicy.AFTER_REPEATED_HIT;
+    }
+
     /**
      * Increments the hit count for the latest inline-cache hit.
      *
@@ -120,6 +165,29 @@ class MethodHandleWrapper {
         return NullMethodHandleWrapper.INSTANCE;
     }
 
+    private static CallSiteTargetRelinkPolicy resolveCallSiteTargetRelinkPolicy(MetaMethod method, Class<?> receiverType) {
+        if (method == null) return CallSiteTargetRelinkPolicy.NEVER;
+
+        int modifiers = method.getModifiers();
+        if (Modifier.isPrivate(modifiers)) return CallSiteTargetRelinkPolicy.IMMEDIATE;
+        if (Modifier.isStatic(modifiers)) {
+            return receiverType == Class.class
+                ? CallSiteTargetRelinkPolicy.IMMEDIATE
+                : CallSiteTargetRelinkPolicy.NEVER;
+        }
+        if (receiverType == Class.class) return CallSiteTargetRelinkPolicy.NEVER;
+
+        return Modifier.isFinal(receiverType.getModifiers())
+            ? CallSiteTargetRelinkPolicy.AFTER_REPEATED_HIT
+            : CallSiteTargetRelinkPolicy.NEVER;
+    }
+
+    private enum CallSiteTargetRelinkPolicy {
+        NEVER,
+        IMMEDIATE,
+        AFTER_REPEATED_HIT
+    }
+
     private static class NullMethodHandleWrapper extends MethodHandleWrapper {
         /**
          * Shared sentinel wrapper representing the absence of a reusable method handle.
diff --git a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyInterfaceCallSiteTargetTest.groovy b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyInterfaceCallSiteTargetTest.groovy
index 2a499a222a..98ab6317a5 100644
--- a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyInterfaceCallSiteTargetTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyInterfaceCallSiteTargetTest.groovy
@@ -42,6 +42,10 @@ final class IndyInterfaceCallSiteTargetTest {
         return 'foo-result'
     }
 
+    protected String protectedFoo() {
+        return 'protected-foo-result'
+    }
+
     private static String staticFoo() {
         return 'static-foo-result'
     }
@@ -58,14 +62,24 @@ final class IndyInterfaceCallSiteTargetTest {
 
     private static final class ClassA {
         private static String bar() { return 'bar-from-A' }
+        static String baz() { return 'baz-from-A' }
     }
 
     private static final class ClassB {
         private static String bar() { return 'bar-from-B' }
+        static String baz() { return 'baz-from-B' }
     }
 
     private static final class InstanceStaticCallTarget {
         private static String valueOf(String value) { return "instance-static-$value" }
+        static String visibleValueOf(String value) { return "instance-visible-static-$value" }
+    }
+
+    private static class PrivateMethodBase {
+        private String hidden() { return 'hidden-from-base' }
+    }
+
+    private static final class PrivateMethodChild extends PrivateMethodBase {
     }
 
     @Test
@@ -90,6 +104,29 @@ final class IndyInterfaceCallSiteTargetTest {
         assertNotSame(callSite.defaultTarget, callSite.target)
     }
 
+    @Test
+    void testDeprecatedFromCacheRelinksTargetImmediatelyForPrivateMethod() {
+        MethodType type = MethodType.methodType(Object, Object)
+        CacheableCallSite callSite = newCallSite(type)
+        def receiver = new IndyInterfaceCallSiteTargetTest()
+        Object[] args = [receiver] as Object[]
+
+        Object result = IndyInterface.fromCache(
+            callSite,
+            IndyInterfaceCallSiteTargetTest,
+            'foo',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE,
+            Boolean.TRUE,
+            Boolean.FALSE,
+            1,
+            args
+        )
+
+        assertEquals(receiver.foo(), result)
+        assertNotSame(callSite.defaultTarget, callSite.target)
+    }
+
     @Test
     void testFromCacheHandleKeepsDefaultTargetForSpreadCall() {
         MethodType type = MethodType.methodType(Object, Class, Object[])
@@ -137,6 +174,95 @@ final class IndyInterfaceCallSiteTargetTest {
         assertEquals(0L, wrapper.latestHitCount)
     }
 
+    @Test
+    void testFromCacheHandleRelinksImmediatelyForPrivateMethodEvenWithGenericCallSiteType() {
+        MethodType type = MethodType.methodType(Object, Object)
+        CacheableCallSite callSite = newCallSite(type)
+        def receiver = new IndyInterfaceCallSiteTargetTest()
+        Object[] args = [receiver] as Object[]
+
+        MethodHandle methodHandle = invokeFromCacheHandle(
+            callSite, IndyInterfaceCallSiteTargetTest, 'foo',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.TRUE, Boolean.FALSE, 1, args
+        )
+
+        assertEquals(receiver.foo(), methodHandle.invokeWithArguments([args] as Object[]))
+        assertNotSame(callSite.defaultTarget, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandleRelinksImmediatelyForPrivateMethodOnSubclassReceiver() {
+        MethodType type = MethodType.methodType(Object, Object)
+        CacheableCallSite callSite = newCallSite(type)
+        def receiver = new PrivateMethodChild()
+        Object[] args = [receiver] as Object[]
+
+        MethodHandle methodHandle = invokeFromCacheHandle(
+            callSite, PrivateMethodBase, 'hidden',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.TRUE, Boolean.FALSE, 1, args
+        )
+
+        assertEquals('hidden-from-base', methodHandle.invokeWithArguments([args] as Object[]))
+        assertNotSame(callSite.defaultTarget, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandleRelinksExactFinalReceiverAfterRepeatedHit() {
+        MethodType type = MethodType.methodType(Object, IndyInterfaceCallSiteTargetTest)
+        CacheableCallSite callSite = newCallSite(type)
+        def receiver = new IndyInterfaceCallSiteTargetTest()
+        Object[] args = [receiver] as Object[]
+        MethodHandleWrapper wrapper = newCachedWrapper(
+            type, 'cached-final-result', 'final-target-result',
+            CachedMethod.find(IndyInterfaceCallSiteTargetTest.getDeclaredMethod('protectedFoo')), true
+        )
+
+        cacheWrapper(callSite, receiver, wrapper)
+
+        MethodHandle firstHit = invokeFromCacheHandle(
+            callSite, IndyInterfaceCallSiteTargetTest, 'protectedFoo',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
+        )
+        assertSame(wrapper.cachedMethodHandle, firstHit)
+        assertSame(callSite.defaultTarget, callSite.target)
+
+        MethodHandle secondHit = invokeFromCacheHandle(
+            callSite, IndyInterfaceCallSiteTargetTest, 'protectedFoo',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
+        )
+        assertSame(wrapper.cachedMethodHandle, secondHit)
+        assertSame(wrapper.targetMethodHandle, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandleDoesNotRelinkFinalReceiverWhenCallSiteTypeIsNotExact() {
+        MethodType type = MethodType.methodType(Object, Object)
+        CacheableCallSite callSite = newCallSite(type)
+        def receiver = new IndyInterfaceCallSiteTargetTest()
+        Object[] args = [receiver] as Object[]
+        MethodHandleWrapper wrapper = newCachedWrapper(
+            type, 'cached-object-result', 'ignored-object-target',
+            CachedMethod.find(IndyInterfaceCallSiteTargetTest.getDeclaredMethod('protectedFoo')), true
+        )
+
+        cacheWrapper(callSite, receiver, wrapper)
+
+        2.times {
+            MethodHandle methodHandle = invokeFromCacheHandle(
+                callSite, IndyInterfaceCallSiteTargetTest, 'protectedFoo',
+                IndyInterface.CallType.METHOD.getOrderNumber(),
+                Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
+            )
+            assertSame(wrapper.cachedMethodHandle, methodHandle)
+        }
+
+        assertSame(callSite.defaultTarget, callSite.target)
+    }
+
     @Test
     void testFromCacheHandleLeavesDefaultTargetAfterFallbackCutoff() {
         assertFallbackCutoffLeavesDefaultTarget(true)
@@ -230,19 +356,19 @@ final class IndyInterfaceCallSiteTargetTest {
     }
 
     @Test
-    void testFromCacheHandleDoesNotRelinkWhenCallSiteParamIsObjectEvenIfReceiverIsClass() {
+    void testFromCacheHandleDoesNotRelinkWhenCallSiteParamIsObjectEvenIfReceiverIsClassForNonPrivateStaticMethod() {
         MethodType type = MethodType.methodType(Object, Object)
         CacheableCallSite callSite = newCallSite(type)
         Object[] args = [ClassA] as Object[]
         MethodHandleWrapper wrapper = newCachedWrapper(
             type, 'class-a-result', 'class-a-target',
-            CachedMethod.find(ClassA.getDeclaredMethod('bar')), true
+            CachedMethod.find(ClassA.getDeclaredMethod('baz')), true
         )
 
         cacheWrapper(callSite, ClassA, wrapper)
 
         MethodHandle methodHandle = invokeFromCacheHandle(
-            callSite, ClassA, 'bar',
+            callSite, ClassA, 'baz',
             IndyInterface.CallType.METHOD.getOrderNumber(),
             Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
         )
@@ -315,7 +441,7 @@ final class IndyInterfaceCallSiteTargetTest {
     }
 
     @Test
-    void testFromCacheHandleDoesNotRelinkStaticMethodInvokedThroughInstanceReceiver() {
+    void testFromCacheHandleRelinksImmediatelyForPrivateStaticMethodInvokedThroughInstanceReceiver() {
         MethodType type = MethodType.methodType(Object, InstanceStaticCallTarget, String)
         CacheableCallSite callSite = newCallSite(type)
         def receiver = new InstanceStaticCallTarget()
@@ -340,6 +466,35 @@ final class IndyInterfaceCallSiteTargetTest {
 
         assertSame(cachedWrapper.cachedMethodHandle, cachedHandle)
         assertEquals(InstanceStaticCallTarget.valueOf('abc'), cachedHandle.invokeWithArguments([args] as Object[]))
+        assertNotSame(callSite.defaultTarget, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandleDoesNotRelinkNonPrivateStaticMethodInvokedThroughInstanceReceiver() {
+        MethodType type = MethodType.methodType(Object, InstanceStaticCallTarget, String)
+        CacheableCallSite callSite = newCallSite(type)
+        def receiver = new InstanceStaticCallTarget()
+        Object[] args = [receiver, 'abc'] as Object[]
+
+        MethodHandle selectedHandle = invokeSelectMethodHandle(
+            callSite, InstanceStaticCallTarget, 'visibleValueOf',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
+        )
+
+        assertEquals(InstanceStaticCallTarget.visibleValueOf('abc'), selectedHandle.invokeWithArguments([args] as Object[]))
+        MethodHandleWrapper cachedWrapper = requireCachedWrapper(callSite, receiver)
+        assertTrue(Modifier.isStatic(cachedWrapper.method.modifiers))
+        assertSame(callSite.defaultTarget, callSite.target)
+
+        MethodHandle cachedHandle = invokeFromCacheHandle(
+            callSite, InstanceStaticCallTarget, 'visibleValueOf',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
+        )
+
+        assertSame(cachedWrapper.cachedMethodHandle, cachedHandle)
+        assertEquals(InstanceStaticCallTarget.visibleValueOf('abc'), cachedHandle.invokeWithArguments([args] as Object[]))
         assertSame(callSite.defaultTarget, callSite.target)
     }
 
@@ -399,7 +554,7 @@ final class IndyInterfaceCallSiteTargetTest {
     }
 
     private static MethodHandleWrapper newCachedWrapper(MethodType type, Object cachedValue, Object targetValue, MetaMethod method, boolean canSetTarget) {
-        new MethodHandleWrapper(cachedHandle(cachedValue), targetHandle(type, targetValue), method, canSetTarget)
+        MethodHandleWrapper.create(cachedHandle(cachedValue), targetHandle(type, targetValue), method, canSetTarget, type.parameterType(0))
     }
 
     private static MethodHandle cachedHandle(Object value) {
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/FinalInstanceMethodCallIndy.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/FinalInstanceMethodCallIndy.groovy
new file mode 100644
index 0000000000..334083b734
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/FinalInstanceMethodCallIndy.groovy
@@ -0,0 +1,56 @@
+/*
+ *  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.bench
+
+/**
+ * Final-instance counterpart to {@link StaticMethodCallIndy}.
+ * <p>
+ * The receiver type is exact and final at the indy call site, which makes it a good probe for
+ * earlier relink heuristics that are still too broad to fire on the first hit.
+ */
+final class FinalInstanceMethodCallIndy {
+
+    int instanceAdd(int a, int b) {
+        return a + b
+    }
+
+    int instanceSum(int n) {
+        int sum = 0
+        for (int i = 0; i < n; i++) {
+            sum = instanceAdd(sum, i)
+        }
+        return sum
+    }
+
+    int instanceFib(int n) {
+        if (n < 2) return n
+        return instanceFib(n - 1) + instanceFib(n - 2)
+    }
+
+    int instanceSquare(int x) { return x * x }
+
+    int instanceIncrement(int x) { return x + 1 }
+
+    int instanceDouble(int x) { return x * 2 }
+
+    int instanceChain(int x) {
+        return instanceDouble(instanceIncrement(instanceSquare(x)))
+    }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/FinalInstanceMethodCallIndyBench.java b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/FinalInstanceMethodCallIndyBench.java
new file mode 100644
index 0000000000..faad33eca1
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/FinalInstanceMethodCallIndyBench.java
@@ -0,0 +1,95 @@
+/*
+ *  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.bench;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Benchmarks exact-final receiver call sites independently of the static-method benchmarks.
+ */
+@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@State(Scope.Thread)
+public class FinalInstanceMethodCallIndyBench {
+
+    private static final int SUM_N = 1000;
+    private static final int FIB_N = 25;
+    private static final int CHAIN_ITERATIONS = 1000;
+
+    private FinalInstanceMethodCallIndy finalInstance;
+    private StaticMethodCallIndy instance;
+
+    @Setup
+    public void setUp() {
+        finalInstance = new FinalInstanceMethodCallIndy();
+        instance = new StaticMethodCallIndy();
+    }
+
+    @Benchmark
+    public int finalInstanceSum_groovy() {
+        return finalInstance.instanceSum(SUM_N);
+    }
+
+    @Benchmark
+    public int instanceSum_groovy() {
+        return instance.instanceSum(SUM_N);
+    }
+
+    @Benchmark
+    public int finalInstanceFib_groovy() {
+        return finalInstance.instanceFib(FIB_N);
+    }
+
+    @Benchmark
+    public int instanceFib_groovy() {
+        return instance.instanceFib(FIB_N);
+    }
+
+    @Benchmark
+    public int finalInstanceChain_groovy() {
+        int result = 0;
+        for (int i = 0; i < CHAIN_ITERATIONS; i++) {
+            result += finalInstance.instanceChain(i);
+        }
+        return result;
+    }
+
+    @Benchmark
+    public int instanceChain_groovy() {
+        int result = 0;
+        for (int i = 0; i < CHAIN_ITERATIONS; i++) {
+            result += instance.instanceChain(i);
+        }
+        return result;
+    }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/FinalInstanceMethodCallIndyColdBench.java b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/FinalInstanceMethodCallIndyColdBench.java
new file mode 100644
index 0000000000..70a24068a8
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/FinalInstanceMethodCallIndyColdBench.java
@@ -0,0 +1,57 @@
+/*
+ *  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.bench;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Cold-start benchmark for exact-final receiver call sites.
+ */
+@Warmup(iterations = 0)
+@Measurement(iterations = 1, batchSize = 1)
+@Fork(80)
+@BenchmarkMode(Mode.SingleShotTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@State(Scope.Thread)
+public class FinalInstanceMethodCallIndyColdBench {
+
+    @Param({"500", "2000", "20000"})
+    public int n;
+
+    @Benchmark
+    public int finalInstanceSum_groovy() {
+        return new FinalInstanceMethodCallIndy().instanceSum(n);
+    }
+
+    @Benchmark
+    public int instanceSum_groovy() {
+        return new StaticMethodCallIndy().instanceSum(n);
+    }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/PrivateInstanceMethodCallIndy.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/PrivateInstanceMethodCallIndy.groovy
new file mode 100644
index 0000000000..e4062c021b
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/PrivateInstanceMethodCallIndy.groovy
@@ -0,0 +1,61 @@
+/*
+ *  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.bench
+
+/**
+ * Private-method counterpart to {@link StaticMethodCallIndy}.
+ * <p>
+ * Dispatch stays dynamic at the call site, but the selected target is lexically fixed because
+ * the helper methods are {@code private}. This makes it a good probe for eager relink decisions
+ * that should not require an exact-final receiver type.
+ */
+class PrivateInstanceMethodCallIndy {
+
+    private int instanceAdd(int a, int b) {
+        return a + b
+    }
+
+    int instanceSum(int n) {
+        int sum = 0
+        for (int i = 0; i < n; i++) {
+            sum = instanceAdd(sum, i)
+        }
+        return sum
+    }
+
+    private int instanceFib0(int n) {
+        if (n < 2) return n
+        return instanceFib0(n - 1) + instanceFib0(n - 2)
+    }
+
+    int instanceFib(int n) {
+        return instanceFib0(n)
+    }
+
+    private int instanceSquare(int x) { return x * x }
+
+    private int instanceIncrement(int x) { return x + 1 }
+
+    private int instanceDouble(int x) { return x * 2 }
+
+    int instanceChain(int x) {
+        return instanceDouble(instanceIncrement(instanceSquare(x)))
+    }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/PrivateInstanceMethodCallIndyBench.java b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/PrivateInstanceMethodCallIndyBench.java
new file mode 100644
index 0000000000..a9bcaeb068
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/PrivateInstanceMethodCallIndyBench.java
@@ -0,0 +1,95 @@
+/*
+ *  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.bench;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Benchmarks private-method call sites independently of the static-method benchmarks.
+ */
+@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@State(Scope.Thread)
+public class PrivateInstanceMethodCallIndyBench {
+
+    private static final int SUM_N = 1000;
+    private static final int FIB_N = 25;
+    private static final int CHAIN_ITERATIONS = 1000;
+
+    private PrivateInstanceMethodCallIndy privateInstance;
+    private StaticMethodCallIndy instance;
+
+    @Setup
+    public void setUp() {
+        privateInstance = new PrivateInstanceMethodCallIndy();
+        instance = new StaticMethodCallIndy();
+    }
+
+    @Benchmark
+    public int privateInstanceSum_groovy() {
+        return privateInstance.instanceSum(SUM_N);
+    }
+
+    @Benchmark
+    public int instanceSum_groovy() {
+        return instance.instanceSum(SUM_N);
+    }
+
+    @Benchmark
+    public int privateInstanceFib_groovy() {
+        return privateInstance.instanceFib(FIB_N);
+    }
+
+    @Benchmark
+    public int instanceFib_groovy() {
+        return instance.instanceFib(FIB_N);
+    }
+
+    @Benchmark
+    public int privateInstanceChain_groovy() {
+        int result = 0;
+        for (int i = 0; i < CHAIN_ITERATIONS; i++) {
+            result += privateInstance.instanceChain(i);
+        }
+        return result;
+    }
+
+    @Benchmark
+    public int instanceChain_groovy() {
+        int result = 0;
+        for (int i = 0; i < CHAIN_ITERATIONS; i++) {
+            result += instance.instanceChain(i);
+        }
+        return result;
+    }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/PrivateInstanceMethodCallIndyColdBench.java b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/PrivateInstanceMethodCallIndyColdBench.java
new file mode 100644
index 0000000000..6acb9ad620
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/PrivateInstanceMethodCallIndyColdBench.java
@@ -0,0 +1,57 @@
+/*
+ *  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.bench;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Cold-start benchmark for private-method call sites.
+ */
+@Warmup(iterations = 0)
+@Measurement(iterations = 1, batchSize = 1)
+@Fork(80)
+@BenchmarkMode(Mode.SingleShotTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@State(Scope.Thread)
+public class PrivateInstanceMethodCallIndyColdBench {
+
+    @Param({"500", "2000", "20000"})
+    public int n;
+
+    @Benchmark
+    public int privateInstanceSum_groovy() {
+        return new PrivateInstanceMethodCallIndy().instanceSum(n);
+    }
+
+    @Benchmark
+    public int instanceSum_groovy() {
+        return new StaticMethodCallIndy().instanceSum(n);
+    }
+}