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/07/25 13:10:00 UTC
(groovy) branch GROOVY-12191 updated (e622559153 -> 2825eddbe3)
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a change to branch GROOVY-12191
in repository https://gitbox.apache.org/repos/asf/groovy.git
discard e622559153 GROOVY-12191: Scope indy SwitchPoint invalidation and harden PIC/math paths
new 2825eddbe3 GROOVY-12191: Scope indy SwitchPoint invalidation and harden PIC/math paths
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (e622559153)
\
N -- N -- N refs/heads/GROOVY-12191 (2825eddbe3)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
subprojects/performance/README.adoc | 20 +++++++++++++-
.../groovy/bench/ScopedInvalidationBench.groovy | 31 ++++++++++++++++++----
2 files changed, 45 insertions(+), 6 deletions(-)
(groovy) 01/01: GROOVY-12191: Scope indy SwitchPoint invalidation and harden PIC/math paths
Posted by su...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch GROOVY-12191
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 2825eddbe3fab703332882893761d88b8fdac740
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Jul 25 20:06:58 2026 +0900
GROOVY-12191: Scope indy SwitchPoint invalidation and harden PIC/math paths
---
.../groovy/runtime/indy/CallSiteTracker.java | 147 +++++++++
.../groovy/runtime/indy/IndyInvalidation.java | 350 +++++++++++++++++++++
.../runtime/indy/SwitchPointInvalidator.java | 242 ++++++++++++++
.../apache/groovy/runtime/indy/package-info.java | 30 ++
.../org/codehaus/groovy/reflection/ClassInfo.java | 129 +++++++-
.../groovy/vmplugin/v8/CacheableCallSite.java | 320 ++++++++++++++++++-
.../v8/ColdReflectiveMethodHandleWrapper.java | 45 ++-
.../groovy/vmplugin/v8/IndyCompoundAssign.java | 4 +-
.../codehaus/groovy/vmplugin/v8/IndyInterface.java | 155 +++++++--
.../org/codehaus/groovy/vmplugin/v8/IndyMath.java | 176 ++++++++++-
.../org/codehaus/groovy/vmplugin/v8/Selector.java | 15 +-
src/test/groovy/bugs/Groovy12191.groovy | 179 +++++++++++
.../indy/BimorphicDispatchScriptTest.groovy | 65 ++++
.../groovy/runtime/indy/CallSiteTrackerTest.groovy | 97 ++++++
.../runtime/indy/IndyInvalidationTest.groovy | 298 ++++++++++++++++++
.../runtime/indy/MegamorphicCallSiteTest.groovy | 160 ++++++++++
.../runtime/indy/SwitchPointInvalidatorTest.groovy | 224 +++++++++++++
.../groovy/vmplugin/v8/IndyMathTest.groovy | 172 ++++++++++
.../groovy/runtime/indy/BimorphicCallSiteTest.java | 192 +++++++++++
subprojects/performance/README.adoc | 20 +-
.../apache/groovy/bench/DynamicDispatchCold.groovy | 15 +
.../groovy/bench/DynamicDispatchColdBench.java | 10 +
.../org/apache/groovy/bench/IndyMathBench.java | 256 +++++++++++++++
.../apache/groovy/bench/IndyMathWorkload.groovy | 139 ++++++++
.../groovy/bench/ScopedInvalidationBench.groovy | 243 ++++++++++++++
.../groovy/bench/dispatch/CallsiteBench.java | 83 ++++-
26 files changed, 3698 insertions(+), 68 deletions(-)
diff --git a/src/main/java/org/apache/groovy/runtime/indy/CallSiteTracker.java b/src/main/java/org/apache/groovy/runtime/indy/CallSiteTracker.java
new file mode 100644
index 0000000000..2fd670be00
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/indy/CallSiteTracker.java
@@ -0,0 +1,147 @@
+/*
+ * 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.util.HashSet;
+import java.util.Set;
+
+/**
+ * Tracks distinct receiver shapes observed at a single indy call site.
+ * <p>
+ * The site records receiver cache keys and reports a polymorphism width used
+ * for megamorphic policy. The retained key set is capped to bound memory; when
+ * the set is full and a new key arrives, {@link #record(String)} still reports
+ * a width of {@code maxTracked + 1} so megamorphic thresholds that use
+ * {@code >= maxPoly} trip correctly.
+ * <p>
+ * Not thread-safe by itself; callers must synchronize (as {@code CacheableCallSite}
+ * does around PIC updates) or use it only from a single-threaded test.
+ *
+ * @since 6.0.0
+ */
+public final class CallSiteTracker {
+
+ private final Set<String> seenKeys;
+ private final int maxTracked;
+ private int clearCount;
+ /**
+ * Last poly-width value returned by {@link #record(String)} (or 0 after clear).
+ * Used by {@link #isPolyAtLeast(int)} so width past a full set is visible.
+ */
+ private int lastPolyWidth;
+
+ /**
+ * Creates a tracker that remembers at most {@code maxTracked} distinct keys
+ * (additional distinct keys still affect the poly width returned by
+ * {@link #record(String)}, but the set is capped to bound memory).
+ *
+ * @param maxTracked maximum keys retained in the set ({@code >= 1})
+ */
+ public CallSiteTracker(final int maxTracked) {
+ this.maxTracked = Math.max(1, maxTracked);
+ this.seenKeys = new HashSet<>(Math.min(16, this.maxTracked + 1));
+ }
+
+ /**
+ * Records a receiver cache key and returns the polymorphism width since the
+ * last {@link #clear()}.
+ * <p>
+ * When the set is full and {@code key} is new, returns {@code maxTracked + 1}
+ * without growing the set. Re-recording a retained key returns the set size.
+ *
+ * @param key the PIC key (may be {@code null}, which is ignored)
+ * @return poly width estimate since last clear
+ */
+ public int record(final String key) {
+ if (key == null) {
+ return lastPolyWidth = distinctCount();
+ }
+ if (seenKeys.size() < maxTracked) {
+ seenKeys.add(key);
+ return lastPolyWidth = seenKeys.size();
+ }
+ if (!seenKeys.contains(key)) {
+ // Set is full with different keys: site is at least maxTracked-wide.
+ // Keep the set as a sample; report maxTracked + 1 so mega thresholds trip.
+ return lastPolyWidth = maxTracked + 1;
+ }
+ return lastPolyWidth = seenKeys.size();
+ }
+
+ /**
+ * Returns how many distinct keys are currently retained in the set.
+ * This may be lower than the last poly width once the set is full.
+ *
+ * @return distinct key count in the set
+ */
+ public int distinctCount() {
+ return seenKeys.size();
+ }
+
+ /**
+ * Returns the last poly width reported by {@link #record(String)}.
+ *
+ * @return last poly width (0 after construction / clear until the next record)
+ */
+ public int lastPolyWidth() {
+ return lastPolyWidth;
+ }
+
+ /**
+ * Clears observed keys and increments the clear counter (monomorphic rebind signal).
+ */
+ public void clear() {
+ seenKeys.clear();
+ lastPolyWidth = 0;
+ clearCount++;
+ }
+
+ /**
+ * Returns how many times {@link #clear()} has been invoked.
+ *
+ * @return clear count
+ */
+ public int clearCount() {
+ return clearCount;
+ }
+
+ /**
+ * Returns whether the poly width is at least {@code maxPoly}.
+ * Uses the last reported poly width (including {@code maxTracked + 1} when
+ * the set is full and a new key arrived), not only the capped set size.
+ *
+ * @param maxPoly threshold ({@code <= 0} means never megamorphic by width)
+ * @return {@code true} when poly width reaches the threshold
+ */
+ public boolean isPolyAtLeast(final int maxPoly) {
+ if (maxPoly <= 0) {
+ return false;
+ }
+ return lastPolyWidth >= maxPoly;
+ }
+
+ /**
+ * Returns the maximum number of keys retained in the set.
+ *
+ * @return max tracked keys
+ */
+ public int getMaxTracked() {
+ return maxTracked;
+ }
+}
diff --git a/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java
new file mode 100644
index 0000000000..b573b2ced8
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java
@@ -0,0 +1,350 @@
+/*
+ * 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 org.apache.groovy.util.SystemUtil;
+import org.codehaus.groovy.reflection.ClassInfo;
+import org.codehaus.groovy.runtime.NullObject;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.invoke.SwitchPoint;
+import java.util.Collection;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Facade for scoped invokedynamic {@link SwitchPoint} invalidation (GROOVY-12191).
+ * <p>
+ * Two independent domains are maintained:
+ * <ul>
+ * <li><b>Per-class</b> — attached to each {@link ClassInfo}; invalidated when that
+ * class's MetaClass / version changes, and also for subclasses/implementors
+ * (hierarchy fan-out) so sites linked on a receiver subtype re-link.</li>
+ * <li><b>Category-global</b> — process-wide; invalidated on category enter/leave so
+ * monomorphic sites re-link and can pick up category methods. The category
+ * domain never fails over (correctness over deopt-storm protection).</li>
+ * </ul>
+ * Linked method-handle chains should install guards via
+ * {@link #guardWithMopSwitchPoints(MethodHandle, MethodHandle, Object)}.
+ * <p>
+ * Per-class failover after repeated invalidations is controlled by
+ * {@code -Dgroovy.indy.switchpoint.max.failures=} (default {@value #DEFAULT_MAX_FAILURES};
+ * {@code <= 0} disables failover). After class-domain failover, a
+ * {@link ClassInfo#getVersion()} equality guard is used instead of a SwitchPoint.
+ * Counters are always updated; optional logging is enabled with
+ * {@code -Dgroovy.indy.invalidation.stats=true}.
+ *
+ * @since 6.0.0
+ */
+public final class IndyInvalidation {
+
+ /**
+ * Default maximum live SwitchPoint retirements before per-class failover.
+ */
+ public static final int DEFAULT_MAX_FAILURES = 100;
+
+ private static final Logger LOG = Logger.getLogger(IndyInvalidation.class.getName());
+ private static final boolean STATS_LOG = SystemUtil.getBooleanSafe("groovy.indy.invalidation.stats");
+ private static final int MAX_FAILURES = SystemUtil.getIntegerSafe(
+ "groovy.indy.switchpoint.max.failures", DEFAULT_MAX_FAILURES);
+
+ private static final AtomicLong CLASS_INVALIDATIONS = new AtomicLong();
+ private static final AtomicLong CATEGORY_INVALIDATIONS = new AtomicLong();
+ private static final AtomicLong FAILOVERS = new AtomicLong();
+
+ /**
+ * Category domain never fails over: enter/leave must always force re-link.
+ */
+ private static final SwitchPointInvalidator CATEGORY = new SwitchPointInvalidator(
+ 0, // unlimited
+ null);
+
+ private static final MethodHandle CLASS_VERSION_OK;
+
+ static {
+ try {
+ CLASS_VERSION_OK = MethodHandles.lookup().findStatic(
+ IndyInvalidation.class,
+ "classVersionMatches",
+ MethodType.methodType(boolean.class, ClassInfo.class, int.class));
+ } catch (ReflectiveOperationException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+
+ private IndyInvalidation() {
+ }
+
+ /**
+ * Returns whether {@code info}'s version still equals {@code expected}.
+ * Bound into generation guards after per-class SwitchPoint failover.
+ *
+ * @param info the class info
+ * @param expected the version captured at link time
+ * @return {@code true} when the version is unchanged
+ */
+ @SuppressWarnings("unused") // MethodHandle target
+ private static boolean classVersionMatches(final ClassInfo info, final int expected) {
+ return info.getVersion() == expected;
+ }
+
+ /**
+ * Returns the maximum SwitchPoint retirements configured for new class invalidators.
+ *
+ * @return the max-failures setting ({@code <= 0} means unlimited)
+ */
+ public static int maxFailures() {
+ return MAX_FAILURES;
+ }
+
+ /**
+ * Creates a SwitchPoint invalidator with the process-wide max-failure budget
+ * and failover accounting. Used by {@link ClassInfo} for the per-class domain.
+ *
+ * @return a new invalidator
+ */
+ public static SwitchPointInvalidator newClassInvalidator() {
+ return new SwitchPointInvalidator(MAX_FAILURES, failures -> {
+ FAILOVERS.incrementAndGet();
+ if (STATS_LOG && LOG.isLoggable(Level.INFO)) {
+ LOG.info("class SwitchPoint failover after " + failures + " invalidations");
+ }
+ });
+ }
+
+ /**
+ * Returns the category-domain switch point (process-wide). Never fails over.
+ *
+ * @return the category switch point
+ */
+ public static SwitchPoint categorySwitchPoint() {
+ return CATEGORY.getSwitchPoint();
+ }
+
+ /**
+ * Returns a live category switch point, or {@code null} if failed over
+ * (category domain is unlimited, so this is always non-null in practice).
+ *
+ * @return the live category switch point
+ */
+ public static SwitchPoint liveCategorySwitchPoint() {
+ return CATEGORY.getLiveSwitchPoint();
+ }
+
+ /**
+ * Returns the category-domain invalidator (for tests and advanced callers).
+ *
+ * @return the category invalidator
+ */
+ public static SwitchPointInvalidator categoryInvalidator() {
+ return CATEGORY;
+ }
+
+ /**
+ * Invalidates the category-global switch point. Called on category enter/leave
+ * and by {@code VMPlugin.invalidateCallSites()}.
+ */
+ public static void invalidateCategory() {
+ CATEGORY.invalidate();
+ CATEGORY_INVALIDATIONS.incrementAndGet();
+ if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
+ LOG.fine("category SwitchPoint invalidated; total=" + CATEGORY_INVALIDATIONS.get());
+ }
+ }
+
+ /**
+ * Invalidates the per-class switch point for {@code type} and for every loaded
+ * subtype/implementor (hierarchy fan-out). No-op when {@code type} is {@code null}.
+ * Does not bump {@link ClassInfo#getVersion()} on subtypes (classic call sites
+ * keep their existing version checks); only indy SwitchPoints are cleared.
+ *
+ * @param type the class whose MetaClass changed
+ */
+ public static void invalidateClass(final Class<?> type) {
+ if (type == null) {
+ return;
+ }
+ invalidateClassHierarchy(type);
+ noteClassInvalidation();
+ if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
+ LOG.fine("class SwitchPoint hierarchy invalidated for " + type.getName()
+ + "; total=" + CLASS_INVALIDATIONS.get());
+ }
+ }
+
+ /**
+ * Invalidates SwitchPoints for {@code type} and all loaded classes assignable
+ * from {@code type} (i.e. {@code type} itself and its subtypes).
+ *
+ * @param type the root of the invalidation fan-out
+ */
+ public static void invalidateClassHierarchy(final Class<?> type) {
+ // Always invalidate the exact class first (cheap, correct even if weak refs cleared).
+ ClassInfo.getClassInfo(type).invalidateIndySwitchPoint();
+ Collection<ClassInfo> all = ClassInfo.getAllClassInfo();
+ for (ClassInfo info : all) {
+ Class<?> loaded = info.getTheClass();
+ if (loaded != null && loaded != type && type.isAssignableFrom(loaded)) {
+ info.invalidateIndySwitchPoint();
+ }
+ }
+ }
+
+ /**
+ * Records a per-class invalidation for metrics. Called from
+ * {@link ClassInfo#incVersion()} after hierarchy SwitchPoints are invalidated.
+ */
+ public static void noteClassInvalidation() {
+ CLASS_INVALIDATIONS.incrementAndGet();
+ }
+
+ /**
+ * Resolves the class used for the per-class SwitchPoint domain of a receiver.
+ * {@code null} maps to {@link NullObject}; a {@link Class} receiver uses itself
+ * (static call sites).
+ *
+ * @param receiver the call receiver (may be {@code null})
+ * @return the class whose {@link ClassInfo} SwitchPoint guards this site
+ */
+ public static Class<?> switchPointClassFor(final Object receiver) {
+ if (receiver == null) {
+ return NullObject.class;
+ }
+ if (receiver instanceof Class<?> c) {
+ return c;
+ }
+ return receiver.getClass();
+ }
+
+ /**
+ * Returns the per-class switch point for the given receiver object.
+ *
+ * @param receiver the call receiver (may be {@code null})
+ * @return the class-domain switch point (possibly permanent invalid after failover)
+ */
+ public static SwitchPoint classSwitchPointFor(final Object receiver) {
+ return ClassInfo.getClassInfo(switchPointClassFor(receiver)).getIndySwitchPoint();
+ }
+
+ /**
+ * Returns the per-class switch point for the given class.
+ *
+ * @param type the class (must not be {@code null})
+ * @return the class-domain switch point
+ */
+ public static SwitchPoint classSwitchPointFor(final Class<?> type) {
+ return ClassInfo.getClassInfo(type).getIndySwitchPoint();
+ }
+
+ /**
+ * Installs class-domain then category-domain guards on {@code handle}.
+ * Either domain change causes the site to take {@code fallback}.
+ *
+ * @param handle the fast-path handle
+ * @param fallback the re-link / fallback handle
+ * @param receiver the receiver used to select the class-domain switch point
+ * @return the guarded handle
+ */
+ public static MethodHandle guardWithMopSwitchPoints(
+ final MethodHandle handle, final MethodHandle fallback, final Object receiver) {
+ return guardWithMopSwitchPoints(handle, fallback, switchPointClassFor(receiver));
+ }
+
+ /**
+ * Installs class-domain then category-domain guards on {@code handle}.
+ * <p>
+ * Live SwitchPoints are installed when available. After per-class failover,
+ * a {@link ClassInfo#getVersion()} equality guard preserves correctness without
+ * allocating further switch points. The category domain does not fail over.
+ *
+ * @param handle the fast-path handle
+ * @param fallback the re-link / fallback handle
+ * @param receiverClass the class whose MetaClass domain guards this site
+ * @return the guarded handle
+ */
+ public static MethodHandle guardWithMopSwitchPoints(
+ final MethodHandle handle, final MethodHandle fallback, final Class<?> receiverClass) {
+ MethodHandle guarded = handle;
+ ClassInfo info = ClassInfo.getClassInfo(receiverClass);
+ SwitchPoint classSp = info.getLiveIndySwitchPoint();
+ if (classSp != null) {
+ guarded = classSp.guardWithTest(guarded, fallback);
+ } else {
+ // failed over: generation guard on ClassInfo.version
+ int version = info.getVersion();
+ MethodHandle test = MethodHandles.insertArguments(CLASS_VERSION_OK, 0, info, version);
+ test = MethodHandles.dropArguments(test, 0, guarded.type().parameterArray());
+ guarded = MethodHandles.guardWithTest(test, guarded, fallback);
+ }
+ SwitchPoint catSp = CATEGORY.getLiveSwitchPoint();
+ if (catSp != null) {
+ guarded = catSp.guardWithTest(guarded, fallback);
+ }
+ return guarded;
+ }
+
+ /**
+ * Returns whether the category domain has entered permanent SwitchPoint failover.
+ * Category domain is unlimited, so this is always {@code false} under default config.
+ *
+ * @return {@code true} when category SwitchPoints are no longer refreshed
+ */
+ public static boolean isCategoryFailedOver() {
+ return CATEGORY.isFailedOver();
+ }
+
+ /**
+ * Returns the total number of per-class invalidations observed.
+ *
+ * @return class-domain invalidation count
+ */
+ public static long classInvalidationCount() {
+ return CLASS_INVALIDATIONS.get();
+ }
+
+ /**
+ * Returns the total number of category-domain invalidations observed.
+ *
+ * @return category-domain invalidation count
+ */
+ public static long categoryInvalidationCount() {
+ return CATEGORY_INVALIDATIONS.get();
+ }
+
+ /**
+ * Returns how many class invalidators have entered permanent failover.
+ *
+ * @return failover count
+ */
+ public static long failoverCount() {
+ return FAILOVERS.get();
+ }
+
+ /**
+ * Resets process-wide counters (tests only). Does not reset live switch points.
+ */
+ public static void resetCountersForTesting() {
+ CLASS_INVALIDATIONS.set(0);
+ CATEGORY_INVALIDATIONS.set(0);
+ FAILOVERS.set(0);
+ }
+}
diff --git a/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
new file mode 100644
index 0000000000..7364db981d
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
@@ -0,0 +1,242 @@
+/*
+ * 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.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.LongConsumer;
+
+/**
+ * Manages a lazily allocated {@link SwitchPoint} with optional failover after
+ * repeated invalidations.
+ * <p>
+ * Call sites capture the {@link SwitchPoint} returned by {@link #getLiveSwitchPoint()}
+ * in {@code guardWithTest} chains. On {@link #invalidate()}, that switch point is
+ * invalidated so linked sites fall back and re-link. A fresh switch point is
+ * allocated on the next live get unless the failure budget is exhausted.
+ * <p>
+ * After failover, {@link #getLiveSwitchPoint()} returns {@code null} so callers can
+ * install an alternate correct guard (e.g. a generation/version check) instead of
+ * a permanently invalid switch point (which would recurse through re-link forever).
+ * <p>
+ * Failover is concurrent-safe: any switch point that races into {@code current}
+ * between a live retirement and the permanent {@code INVALID} sentinel is itself
+ * invalidated, so no orphaned valid switch point can outlive the domain.
+ * <p>
+ * {@code maxFailures <= 0} means unlimited replacements (no failover).
+ * Only invalidations that actually retire a live switch point count toward the budget.
+ *
+ * @since 6.0.0
+ */
+public final class SwitchPointInvalidator {
+
+ /**
+ * Pre-invalidated switch point used only as an internal sentinel after failover.
+ * Never installed as a live call-site guard.
+ */
+ private static final SwitchPoint INVALID = new SwitchPoint();
+
+ static {
+ SwitchPoint.invalidateAll(new SwitchPoint[]{INVALID});
+ }
+
+ private final int maxFailures;
+ private final LongConsumer onFailover;
+ /**
+ * {@code null} means no live switch point yet (lazy). {@link #INVALID} means
+ * permanently failed over.
+ */
+ private final AtomicReference<SwitchPoint> current = new AtomicReference<>();
+ private final AtomicInteger failureCount = new AtomicInteger();
+ private final AtomicInteger failoverOnce = new AtomicInteger();
+
+ /**
+ * Creates an invalidator.
+ *
+ * @param maxFailures maximum live-switch-point retirements before failover;
+ * {@code <= 0} disables failover
+ * @param onFailover optional callback invoked once when failover engages
+ * (may be {@code null})
+ */
+ public SwitchPointInvalidator(final int maxFailures, final LongConsumer onFailover) {
+ this.maxFailures = maxFailures;
+ this.onFailover = onFailover;
+ }
+
+ /**
+ * Creates an invalidator with no failover callback.
+ *
+ * @param maxFailures maximum live-switch-point retirements before failover;
+ * {@code <= 0} disables failover
+ */
+ public SwitchPointInvalidator(final int maxFailures) {
+ this(maxFailures, null);
+ }
+
+ /**
+ * Returns a live switch point to install in a call-site guard chain, or
+ * {@code null} when this invalidator has failed over (callers must use an
+ * alternate correct guard).
+ * <p>
+ * Lazily allocates a valid switch point on first use unless failover is active.
+ * This method is atomic w.r.t. failover: it never returns the permanent
+ * invalid sentinel for use as a live guard.
+ *
+ * @return a live switch point, or {@code null} if failed over
+ */
+ public SwitchPoint getLiveSwitchPoint() {
+ for (;;) {
+ if (isFailedOver()) {
+ return null;
+ }
+ SwitchPoint sp = current.get();
+ if (sp == INVALID) {
+ return null;
+ }
+ if (sp != null) {
+ return sp;
+ }
+ SwitchPoint created = new SwitchPoint();
+ if (current.compareAndSet(null, created)) {
+ // A concurrent invalidate may have failed over after our CAS.
+ // Never hand out a switch point once the domain is permanent.
+ if (isFailedOver()) {
+ SwitchPoint raced = current.getAndSet(INVALID);
+ invalidateIfLive(raced);
+ invalidateIfLive(created);
+ return null;
+ }
+ return created;
+ }
+ // lost the race — retry
+ }
+ }
+
+ /**
+ * Returns a switch point for observability / tests. Prefer
+ * {@link #getLiveSwitchPoint()} when building call-site guards.
+ * After failover this returns the permanent invalid sentinel.
+ *
+ * @return a switch point (possibly permanently invalid after failover)
+ */
+ public SwitchPoint getSwitchPoint() {
+ SwitchPoint live = getLiveSwitchPoint();
+ return live != null ? live : INVALID;
+ }
+
+ /**
+ * Invalidates the current live switch point (if any) so linked sites re-link.
+ * After the failure budget is exhausted, further calls are no-ops and
+ * {@link #getLiveSwitchPoint()} returns {@code null}.
+ * <p>
+ * Only retirements of a non-null live switch point count toward
+ * {@link #getFailureCount()} / the failover budget. Concurrent installs that
+ * race into {@code current} during failover are captured and invalidated so
+ * they cannot remain valid after the domain has failed over.
+ */
+ public void invalidate() {
+ if (isFailedOver()) {
+ // Ensure the permanent sentinel is installed (may race with getLive).
+ pinInvalidAndDrain();
+ return;
+ }
+ for (;;) {
+ SwitchPoint sp = current.get();
+ if (sp == INVALID) {
+ return;
+ }
+ if (sp == null) {
+ // Nothing live. If a concurrent invalidator already exhausted the
+ // budget, pin the sentinel so getLive cannot install a fresh SP.
+ if (maxFailures > 0 && failureCount.get() >= maxFailures) {
+ pinInvalidAndDrain();
+ }
+ return;
+ }
+ if (!current.compareAndSet(sp, null)) {
+ continue;
+ }
+ // We own retirement of sp.
+ int failures = failureCount.incrementAndGet();
+ if (maxFailures > 0 && failures >= maxFailures) {
+ // Failover: install INVALID and drain any SP that raced in after
+ // our CAS to null (getLive could have CAS'd null → created).
+ SwitchPoint raced = current.getAndSet(INVALID);
+ invalidateIfLive(raced);
+ if (failoverOnce.compareAndSet(0, 1) && onFailover != null) {
+ onFailover.accept(failures);
+ }
+ }
+ invalidateIfLive(sp);
+ return;
+ }
+ }
+
+ /**
+ * Installs the permanent invalid sentinel and invalidates any live switch
+ * point that was present (including one that raced in after budget exhaust).
+ */
+ private void pinInvalidAndDrain() {
+ SwitchPoint prev = current.getAndSet(INVALID);
+ invalidateIfLive(prev);
+ }
+
+ private static void invalidateIfLive(final SwitchPoint sp) {
+ if (sp != null && sp != INVALID && !sp.hasBeenInvalidated()) {
+ SwitchPoint.invalidateAll(new SwitchPoint[]{sp});
+ }
+ }
+
+ /**
+ * Returns whether this invalidator has exhausted its failure budget.
+ *
+ * @return {@code true} when live switch points are no longer allocated
+ */
+ public boolean isFailedOver() {
+ return maxFailures > 0 && failureCount.get() >= maxFailures;
+ }
+
+ /**
+ * Returns how many live switch points have been retired by {@link #invalidate()}.
+ *
+ * @return the retirement count
+ */
+ public int getFailureCount() {
+ return failureCount.get();
+ }
+
+ /**
+ * Returns the configured failure budget ({@code <= 0} means unlimited).
+ *
+ * @return the max failure setting
+ */
+ public int getMaxFailures() {
+ return maxFailures;
+ }
+
+ /**
+ * Returns the shared permanently-invalidated switch point (for tests).
+ *
+ * @return the invalid dummy switch point
+ */
+ static SwitchPoint invalidSwitchPoint() {
+ return INVALID;
+ }
+}
diff --git a/src/main/java/org/apache/groovy/runtime/indy/package-info.java b/src/main/java/org/apache/groovy/runtime/indy/package-info.java
new file mode 100644
index 0000000000..b03d4214c3
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/indy/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * 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.
+ */
+
+/**
+ * Invokedynamic runtime utilities for the Groovy MOP.
+ * <p>
+ * Includes scoped {@link java.lang.invoke.SwitchPoint} management so metaclass
+ * changes affect only the classes that changed (GROOVY-12191), category-domain
+ * invalidation, and call-site polymorphism tracking ({@link CallSiteTracker})
+ * used by megamorphic PIC policy.
+ *
+ * @since 6.0.0
+ */
+package org.apache.groovy.runtime.indy;
diff --git a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
index aa08713430..a49cf89474 100644
--- a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
+++ b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
@@ -25,6 +25,9 @@ import groovy.lang.GroovySystem;
import groovy.lang.MetaClass;
import groovy.lang.MetaClassRegistry;
import groovy.lang.MetaMethod;
+import groovy.transform.Internal;
+import org.apache.groovy.runtime.indy.IndyInvalidation;
+import org.apache.groovy.runtime.indy.SwitchPointInvalidator;
import org.apache.groovy.util.concurrent.ManagedIdentityConcurrentMap;
import org.codehaus.groovy.reflection.GroovyClassValue.ComputeValue;
import org.codehaus.groovy.reflection.stdclasses.ArrayCachedClass;
@@ -49,9 +52,9 @@ import org.codehaus.groovy.util.LockableObject;
import org.codehaus.groovy.util.ManagedConcurrentLinkedQueue;
import org.codehaus.groovy.util.ManagedReference;
import org.codehaus.groovy.util.ReferenceBundle;
-import org.codehaus.groovy.vmplugin.VMPluginFactory;
import java.io.Serial;
+import java.lang.invoke.SwitchPoint;
import java.lang.ref.WeakReference;
import java.math.BigDecimal;
import java.math.BigInteger;
@@ -77,6 +80,11 @@ public class ClassInfo implements Finalizable {
public final int hash = -1;
private final WeakReference<Class<?>> classRef;
private final AtomicInteger version = new AtomicInteger();
+ /**
+ * Per-class SwitchPoint domain for indy call sites (GROOVY-12191).
+ * Invalidated on MetaClass / version changes for this class only.
+ */
+ private final SwitchPointInvalidator indySwitchPoint = IndyInvalidation.newClassInvalidator();
private MetaClass strongMetaClass;
private ManagedReference<MetaClass> weakMetaClass;
MetaMethod[] dgmMetaMethods = MetaMethod.EMPTY_ARRAY;
@@ -124,12 +132,104 @@ public class ClassInfo implements Finalizable {
}
/**
- * Increments the version number and invalidates call sites.
+ * Increments the version number and invalidates this class's indy SwitchPoint
+ * (and subtype SwitchPoints via hierarchy fan-out).
* Called when metaclass modifications occur (e.g., adding methods to an {@code ExpandoMetaClass}).
+ * <p>
+ * Call sites linked against this class or its subtypes re-link (GROOVY-12191);
+ * unrelated classes keep their optimized targets. Category enter/leave uses a
+ * separate process-wide domain via {@link org.codehaus.groovy.vmplugin.VMPlugin#invalidateCallSites()}.
+ * <p>
+ * Prefer this over bare {@link #invalidateIndySwitchPoint()} when the MetaClass
+ * actually changed: it is the public generation-bump entry that also fans out
+ * to loaded subtypes. {@link #setStrongMetaClass} / {@link #setWeakMetaClass}
+ * bump generation for this class only (registry listeners apply hierarchy fan-out).
*/
public void incVersion() {
version.incrementAndGet();
- VMPluginFactory.getPlugin().invalidateCallSites();
+ Class<?> type = getTheClass();
+ if (type != null) {
+ IndyInvalidation.invalidateClassHierarchy(type);
+ } else {
+ invalidateIndySwitchPoint();
+ }
+ IndyInvalidation.noteClassInvalidation();
+ }
+
+ /**
+ * Bumps {@link #getVersion()} and, when {@code invalidateSwitchPoint} is true,
+ * invalidates this class's indy SwitchPoint only (no subtype fan-out, no
+ * process-wide counter). Used by strong/weak/per-instance MetaClass paths;
+ * callers that need hierarchy fan-out follow up via
+ * {@link IndyInvalidation#invalidateClassHierarchy(Class)} or {@link #incVersion()}.
+ * <p>
+ * First MetaClass install ({@code null →} default MC) typically has no linked
+ * call sites holding this class's SwitchPoint yet, so it only bumps version.
+ * Replacement and clear always retire the SwitchPoint.
+ */
+ private void bumpGenerationLocal(final boolean invalidateSwitchPoint) {
+ version.incrementAndGet();
+ if (invalidateSwitchPoint) {
+ invalidateIndySwitchPoint();
+ }
+ }
+
+ /**
+ * Whether this class currently has a class-level MetaClass (strong or weak).
+ */
+ private boolean hasClassLevelMetaClass() {
+ if (strongMetaClass != null) {
+ return true;
+ }
+ ManagedReference<MetaClass> weakRef = weakMetaClass;
+ return weakRef != null && weakRef.get() != null;
+ }
+
+ /**
+ * Returns the SwitchPoint that guards indy targets for this class's MetaClass state.
+ * Prefer {@link #getLiveIndySwitchPoint()} when installing call-site guards.
+ *
+ * @return the class-domain SwitchPoint for indy
+ * @since 6.0.0
+ */
+ @Internal
+ public SwitchPoint getIndySwitchPoint() {
+ return indySwitchPoint.getSwitchPoint();
+ }
+
+ /**
+ * Returns a live class-domain SwitchPoint, or {@code null} after failover
+ * (callers should install a version/generation guard instead).
+ *
+ * @return a live SwitchPoint, or {@code null} if failed over
+ * @since 6.0.0
+ */
+ @Internal
+ public SwitchPoint getLiveIndySwitchPoint() {
+ return indySwitchPoint.getLiveSwitchPoint();
+ }
+
+ /**
+ * Invalidates this class's indy SwitchPoint without bumping {@link #getVersion()}.
+ * Prefer {@link #incVersion()} when the MetaClass actually changed.
+ *
+ * @since 6.0.0
+ */
+ @Internal
+ public void invalidateIndySwitchPoint() {
+ indySwitchPoint.invalidate();
+ }
+
+ /**
+ * Returns whether this class's indy SwitchPoint domain has entered permanent
+ * failover after repeated invalidations.
+ *
+ * @return {@code true} when class SwitchPoints are no longer refreshed
+ * @since 6.0.0
+ */
+ @Internal
+ public boolean isIndySwitchPointFailedOver() {
+ return indySwitchPoint.isFailedOver();
}
/**
@@ -254,11 +354,20 @@ public class ClassInfo implements Finalizable {
/**
* Sets the strong (immutable) metaclass for this class.
* Increments the version number and manages ExpandoMetaClass registry.
+ * <p>
+ * On replacement or clear, also invalidates this class's indy SwitchPoint
+ * (GROOVY-12191). First install ({@code null →} MC) only bumps version;
+ * hierarchy fan-out for subtypes is applied by MetaClass registry listeners
+ * or {@link #incVersion()}.
*
* @param answer the metaclass to set, or {@code null} to clear
*/
public void setStrongMetaClass(MetaClass answer) {
- version.incrementAndGet();
+ // Version always; SwitchPoint when replacing/clearing an established MC.
+ // Hierarchy fan-out for subtypes is applied by registry listeners / incVersion
+ // (GROOVY-12191). First install (null → MC) only bumps version.
+ boolean replaceOrClear = hasClassLevelMetaClass() || answer == null;
+ bumpGenerationLocal(replaceOrClear);
// safe value here to avoid multiple reads with possibly
// differing values due to concurrency
@@ -300,11 +409,16 @@ public class ClassInfo implements Finalizable {
/**
* Sets the weak (mutable) metaclass for this class.
* Clears the strong metaclass and increments the version number.
+ * <p>
+ * On replacement or clear, also invalidates this class's indy SwitchPoint
+ * (GROOVY-12191). First install only bumps version.
*
* @param answer the metaclass to set, or {@code null} to clear
*/
public void setWeakMetaClass(MetaClass answer) {
- version.incrementAndGet();
+ // Version always; SwitchPoint when replacing/clearing (GROOVY-12191).
+ boolean replaceOrClear = hasClassLevelMetaClass() || answer == null;
+ bumpGenerationLocal(replaceOrClear);
strongMetaClass = null;
ManagedReference<MetaClass> newRef = null;
@@ -527,7 +641,10 @@ public class ClassInfo implements Finalizable {
* @param metaClass the metaclass to set, or {@code null} to remove the association
*/
public void setPerInstanceMetaClass(Object obj, MetaClass metaClass) {
- version.incrementAndGet();
+ // Per-instance MetaClass changes always retire the class SwitchPoint so
+ // sites that may observe instance-level dispatch re-link (GROOVY-12191).
+ // Registry listeners still apply hierarchy fan-out.
+ bumpGenerationLocal(true);
if (metaClass != null) {
if (perInstanceMetaClassMap == null)
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 d412b2b6cb..39908480e7 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java
@@ -18,6 +18,8 @@
*/
package org.codehaus.groovy.vmplugin.v8;
+import org.apache.groovy.runtime.indy.CallSiteTracker;
+import org.apache.groovy.runtime.indy.IndyInvalidation;
import org.apache.groovy.util.SystemUtil;
import org.codehaus.groovy.runtime.DefaultGroovyMethods;
import org.codehaus.groovy.runtime.memoize.MemoizeCache;
@@ -37,12 +39,33 @@ import java.util.logging.Level;
import java.util.logging.Logger;
/**
- * Represents a cacheable call site, which can reduce the cost of resolving methods
+ * Represents a cacheable call site, which can reduce the cost of resolving methods.
+ * <p>
+ * In addition to an LRU PIC of receiver shapes, the site tracks polymorphism width
+ * via {@link CallSiteTracker}. When the number of distinct receiver keys reaches
+ * {@code -Dgroovy.indy.callsite.max.poly} (default {@link #DEFAULT_MAX_POLY}), the
+ * site becomes <em>megamorphic</em>: monomorphic {@code setTarget} promotion is
+ * suppressed and the site stays on the default boot handle (sticky by default;
+ * see {@code groovy.indy.callsite.mega.sticky}).
+ * <p>
+ * Between monomorphic and megamorphic, a site may promote a <em>bimorphic</em>
+ * target (two stable receiver shapes nested with {@code guardWithTest}) when
+ * {@code -Dgroovy.indy.callsite.bimorphic=true} (default).
*
* @since 3.0.0
*/
public class CacheableCallSite extends MutableCallSite {
private static final int CACHE_SIZE = SystemUtil.getIntegerSafe("groovy.indy.callsite.cache.size", 8);
+ /**
+ * Default distinct receiver shapes before a site is classified megamorphic.
+ * Aligned with the default PIC size so thrashing the LRU typically trips mega.
+ */
+ public static final int DEFAULT_MAX_POLY = 8;
+ private static final int MAX_POLY = SystemUtil.getIntegerSafe("groovy.indy.callsite.max.poly", DEFAULT_MAX_POLY);
+ private static final boolean MEGA_STICKY = SystemUtil.getBooleanSafe("groovy.indy.callsite.mega.sticky", true);
+ private static final boolean BIMORPHIC_ENABLED = SystemUtil.getBooleanSafe("groovy.indy.callsite.bimorphic", true);
+ private static final AtomicLong MEGAMORPHIC_TRANSITIONS = new AtomicLong();
+ private static final AtomicLong BIMORPHIC_PROMOTIONS = new AtomicLong();
/**
* When {@code true}, stale (GC-cleared) cache entries are swept inline on the calling thread
* instead of being handed to the background {@code PIC-Cleaner} daemon, which is then never
@@ -60,6 +83,21 @@ public class CacheableCallSite extends MutableCallSite {
private final AtomicLong fallbackRound = new AtomicLong();
private MethodHandle defaultTarget;
private MethodHandle fallbackTarget;
+ /**
+ * Polymorphism tracker (receiver cache keys). Guarded by {@link #lruCache}'s monitor
+ * when updated from the PIC path; mega flag is volatile for lock-free reads.
+ */
+ private final CallSiteTracker shapeTracker = new CallSiteTracker(Math.max(CACHE_SIZE, MAX_POLY > 0 ? MAX_POLY : CACHE_SIZE));
+ private volatile boolean megamorphic;
+ /**
+ * First promoted shape (monomorphic or bimorphic primary arm).
+ * Written under {@link #lruCache} monitor; read for promotion decisions.
+ */
+ private volatile PromotedShape primaryShape;
+ /**
+ * Second promoted shape (bimorphic secondary arm only).
+ */
+ private volatile PromotedShape secondaryShape;
private final Map<String, SoftReference<MethodHandleWrapper>> lruCache =
new LinkedHashMap<String, SoftReference<MethodHandleWrapper>>(INITIAL_CAPACITY, LOAD_FACTOR, true) {
@Serial private static final long serialVersionUID = 7785958879964294463L;
@@ -226,6 +264,286 @@ public class CacheableCallSite extends MutableCallSite {
return lookup;
}
+ /**
+ * Records a receiver PIC key and may transition this site to megamorphic.
+ * When the site becomes megamorphic, any monomorphic target is reset to the
+ * default boot handle so shape-specific chains are not left installed.
+ *
+ * @param receiverKey the receiver cache key (e.g. class name)
+ * @return the estimated poly width after recording
+ */
+ public int recordReceiverShape(final String receiverKey) {
+ int width;
+ boolean tripMega = false;
+ synchronized (lruCache) {
+ if (megamorphic && MEGA_STICKY) {
+ return shapeTracker.distinctCount();
+ }
+ width = shapeTracker.record(receiverKey);
+ if (MAX_POLY > 0 && width >= MAX_POLY && !megamorphic) {
+ megamorphic = true;
+ tripMega = true;
+ }
+ }
+ if (tripMega) {
+ MEGAMORPHIC_TRANSITIONS.incrementAndGet();
+ pinDefaultTargetIfNeeded();
+ }
+ return width;
+ }
+
+ /**
+ * Forces megamorphic mode (sticky if {@code groovy.indy.callsite.mega.sticky} is true).
+ */
+ public void markMegamorphic() {
+ boolean tripMega = false;
+ synchronized (lruCache) {
+ if (!megamorphic) {
+ megamorphic = true;
+ tripMega = true;
+ }
+ }
+ if (tripMega) {
+ MEGAMORPHIC_TRANSITIONS.incrementAndGet();
+ pinDefaultTargetIfNeeded();
+ }
+ }
+
+ /**
+ * Returns whether this site has been classified megamorphic.
+ *
+ * @return {@code true} when monomorphic promotion should be suppressed
+ */
+ public boolean isMegamorphic() {
+ return megamorphic;
+ }
+
+ /**
+ * Returns the number of distinct receiver keys retained by the shape tracker.
+ *
+ * @return distinct shape count
+ */
+ public int distinctReceiverShapes() {
+ synchronized (lruCache) {
+ return shapeTracker.distinctCount();
+ }
+ }
+
+ /**
+ * Returns how many times the shape tracker was cleared (e.g. monomorphic rebind).
+ *
+ * @return clear count
+ */
+ public int shapeClearCount() {
+ synchronized (lruCache) {
+ return shapeTracker.clearCount();
+ }
+ }
+
+ /**
+ * Clears observed receiver shapes (monomorphic rebind signal). Does not clear
+ * the sticky megamorphic flag when {@code mega.sticky} is enabled.
+ */
+ public void clearReceiverShapes() {
+ synchronized (lruCache) {
+ shapeTracker.clear();
+ if (!MEGA_STICKY) {
+ megamorphic = false;
+ }
+ }
+ }
+
+ /**
+ * Returns the configured max-poly threshold ({@code <= 0} disables width-based mega).
+ *
+ * @return max poly setting
+ */
+ public static int maxPoly() {
+ return MAX_POLY;
+ }
+
+ /**
+ * Returns process-wide count of sites that transitioned to megamorphic.
+ *
+ * @return megamorphic transition count
+ */
+ public static long megamorphicTransitionCount() {
+ return MEGAMORPHIC_TRANSITIONS.get();
+ }
+
+ private void pinDefaultTargetIfNeeded() {
+ clearPromotedShapes();
+ MethodHandle def = defaultTarget;
+ if (def != null && getTarget() != def) {
+ setTarget(def);
+ }
+ }
+
+ /**
+ * Installs or updates a monomorphic or bimorphic promoted call-site target.
+ * <ul>
+ * <li>No primary → install monomorphic target for {@code key}</li>
+ * <li>Primary matches {@code key} → refresh monomorphic handle</li>
+ * <li>Secondary null + bimorphic enabled + different key → nested bimorphic target</li>
+ * <li>Key matches secondary → refresh bimorphic arms</li>
+ * <li>Otherwise → leave target unchanged (avoid thrashing; PIC still serves)</li>
+ * </ul>
+ * Named {@code promoteTarget} (not {@code try*}) so Groovy callers are not
+ * confused by the {@code try} keyword.
+ *
+ * @param key receiver PIC key
+ * @param receiver runtime receiver (may be {@code null})
+ * @param target call-site-typed handle for this shape (must match {@link #type()})
+ * @return {@code true} if the site target was updated
+ */
+ public boolean promoteTarget(final String key, final Object receiver, final MethodHandle target) {
+ if (megamorphic || target == null || key == null || defaultTarget == null) {
+ return false;
+ }
+ if (fallbackTarget == null) {
+ return false;
+ }
+ Class<?> guardClass = guardClassFor(receiver);
+ if (guardClass == null) {
+ return false;
+ }
+ boolean staticReceiver = receiver instanceof Class;
+ PromotedShape incoming = new PromotedShape(key, guardClass, target, staticReceiver);
+
+ synchronized (lruCache) {
+ if (megamorphic) {
+ return false;
+ }
+ PromotedShape primary = primaryShape;
+ PromotedShape secondary = secondaryShape;
+
+ if (primary == null) {
+ primaryShape = incoming;
+ secondaryShape = null;
+ setTarget(target);
+ return true;
+ }
+
+ if (key.equals(primary.key)) {
+ primaryShape = incoming;
+ if (secondary == null) {
+ setTarget(target);
+ } else {
+ setTarget(buildBimorphicTarget(incoming, secondary, fallbackTarget));
+ }
+ return true;
+ }
+
+ if (secondary != null && key.equals(secondary.key)) {
+ secondaryShape = incoming;
+ setTarget(buildBimorphicTarget(primary, incoming, fallbackTarget));
+ return true;
+ }
+
+ if (secondary == null && BIMORPHIC_ENABLED) {
+ secondaryShape = incoming;
+ MethodHandle bi = buildBimorphicTarget(primary, incoming, fallbackTarget);
+ setTarget(bi);
+ BIMORPHIC_PROMOTIONS.incrementAndGet();
+ return true;
+ }
+
+ // Third (or bimorphic disabled + second) shape: do not thrash monomorphic target.
+ return false;
+ }
+ }
+
+ /**
+ * Returns how many shapes are currently installed on the promoted target (0–2).
+ *
+ * @return 0 default, 1 monomorphic, 2 bimorphic
+ */
+ public int promotedShapeCount() {
+ if (primaryShape == null) {
+ return 0;
+ }
+ return secondaryShape == null ? 1 : 2;
+ }
+
+ /**
+ * Returns whether bimorphic promotion is enabled.
+ *
+ * @return {@code true} when dual-shape targets may be installed
+ */
+ public static boolean isBimorphicEnabled() {
+ return BIMORPHIC_ENABLED;
+ }
+
+ /**
+ * Returns process-wide count of bimorphic promotions.
+ *
+ * @return bimorphic promotion count
+ */
+ public static long bimorphicPromotionCount() {
+ return BIMORPHIC_PROMOTIONS.get();
+ }
+
+ /**
+ * Clears monomorphic/bimorphic promotion state (e.g. on megamorphic pin).
+ */
+ public void clearPromotedShapes() {
+ primaryShape = null;
+ secondaryShape = null;
+ }
+
+ private static Class<?> guardClassFor(final Object receiver) {
+ return IndyInvalidation.switchPointClassFor(receiver);
+ }
+
+ /**
+ * Builds {@code guardWithTest(testA, targetA, guardWithTest(testB, targetB, fallback))}.
+ */
+ static MethodHandle buildBimorphicTarget(
+ final PromotedShape a, final PromotedShape b, final MethodHandle fallback) {
+ MethodType siteType = a.target.type();
+ MethodHandle testA = receiverGuard(a, siteType);
+ MethodHandle testB = receiverGuard(b, siteType);
+ MethodHandle ifB = MethodHandles.guardWithTest(testB, b.target, fallback);
+ return MethodHandles.guardWithTest(testA, a.target, ifB);
+ }
+
+ private static MethodHandle receiverGuard(final PromotedShape shape, final MethodType siteType) {
+ MethodHandle test;
+ if (shape.staticReceiver) {
+ // Class receiver: Object.equals against the Class literal
+ test = IndyGuardsFiltersAndSignatures.EQUALS.bindTo(shape.guardClass);
+ } else {
+ test = IndyGuardsFiltersAndSignatures.SAME_CLASS.bindTo(shape.guardClass);
+ }
+ Class<?> p0 = siteType.parameterType(0);
+ test = test.asType(MethodType.methodType(boolean.class, p0));
+ if (siteType.parameterCount() > 1) {
+ Class<?>[] rest = new Class<?>[siteType.parameterCount() - 1];
+ for (int i = 0; i < rest.length; i++) {
+ rest[i] = siteType.parameterType(i + 1);
+ }
+ test = MethodHandles.dropArguments(test, 1, rest);
+ }
+ return test;
+ }
+
+ /**
+ * A shape promoted onto the monomorphic or bimorphic call-site target.
+ */
+ static final class PromotedShape {
+ final String key;
+ final Class<?> guardClass;
+ final MethodHandle target;
+ final boolean staticReceiver;
+
+ PromotedShape(final String key, final Class<?> guardClass, final MethodHandle target, final boolean staticReceiver) {
+ this.key = key;
+ this.guardClass = guardClass;
+ this.target = target;
+ this.staticReceiver = staticReceiver;
+ }
+ }
+
private static final BlockingQueue<Runnable> CACHE_CLEANER_QUEUE = new LinkedBlockingQueue<>();
static {
if (!CLEAN_INLINE) {
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
index 6cbd8f3687..7ff685e52e 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
@@ -21,7 +21,9 @@ package org.codehaus.groovy.vmplugin.v8;
import groovy.lang.GroovyObject;
import groovy.lang.MetaClass;
import groovy.lang.MetaMethod;
+import org.apache.groovy.runtime.indy.IndyInvalidation;
import org.codehaus.groovy.reflection.CachedMethod;
+import org.codehaus.groovy.reflection.ClassInfo;
import org.codehaus.groovy.runtime.GroovyCategorySupport;
import org.codehaus.groovy.runtime.wrappers.Wrapper;
import org.codehaus.groovy.vmplugin.VMPluginFactory;
@@ -48,10 +50,9 @@ import java.lang.reflect.Modifier;
* 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.
+ * full chain would install: per-class and category switch-point freshness
+ * (GROOVY-12191), 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 {
@@ -63,7 +64,11 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
final Boolean thisCall;
final Boolean spreadCall;
private final MetaClass selectionMetaClass;
- private final SwitchPoint validity;
+ private final SwitchPoint classValidity;
+ private final SwitchPoint categoryValidity;
+ private final boolean classFailedOver;
+ private final int classVersionAtLink;
+ private final ClassInfo classInfo;
private final Class<?>[] argClasses;
private final MethodHandle reflectiveHandle;
/**
@@ -76,7 +81,8 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
private ColdReflectiveMethodHandleWrapper(MetaMethod method, CacheableCallSite callSite, Class<?> sender,
String methodName, int callID, Boolean safeNavigation, Boolean thisCall, Boolean spreadCall,
- MetaClass selectionMetaClass, SwitchPoint validity, Class<?>[] argClasses) {
+ MetaClass selectionMetaClass, SwitchPoint classValidity, SwitchPoint categoryValidity,
+ boolean classFailedOver, int classVersionAtLink, ClassInfo classInfo, Class<?>[] argClasses) {
super(null, null, method, true);
this.callSite = callSite;
this.sender = sender;
@@ -86,7 +92,11 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
this.thisCall = thisCall;
this.spreadCall = spreadCall;
this.selectionMetaClass = selectionMetaClass;
- this.validity = validity;
+ this.classValidity = classValidity;
+ this.categoryValidity = categoryValidity;
+ this.classFailedOver = classFailedOver;
+ this.classVersionAtLink = classVersionAtLink;
+ this.classInfo = classInfo;
this.argClasses = argClasses;
this.reflectiveHandle = IndyInterface.COLD_REFLECTIVE_INVOKER.bindTo(this);
}
@@ -112,7 +122,13 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
* @return {@code true} if the cached selection is still valid for them
*/
boolean isValidFor(Object[] arguments) {
- if (validity.hasBeenInvalidated()) return false;
+ if (classFailedOver) {
+ if (classInfo.getVersion() != classVersionAtLink) return false;
+ } else if (classValidity != null && classValidity.hasBeenInvalidated()) {
+ return false;
+ }
+ // Category domain never fails over under default config.
+ if (categoryValidity != null && categoryValidity.hasBeenInvalidated()) return false;
Class<?>[] classes = argClasses;
if (arguments.length != classes.length) return false;
for (int i = 0; i < classes.length; i++) {
@@ -152,8 +168,19 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
if (a instanceof Wrapper) return null; // cast markers need the full coercion path
argClasses[i] = (a == null) ? null : a.getClass();
}
+ Object receiver = arguments[0];
+ Class<?> spClass = IndyInvalidation.switchPointClassFor(receiver);
+ ClassInfo classInfo = ClassInfo.getClassInfo(spClass);
+ SwitchPoint liveClassSp = classInfo.getLiveIndySwitchPoint();
+ boolean classFailedOver = liveClassSp == null;
return new ColdReflectiveMethodHandleWrapper(cm, callSite, sender, methodName, callID,
- safeNavigation, thisCall, spreadCall, mc, IndyInterface.switchPoint, argClasses);
+ safeNavigation, thisCall, spreadCall, mc,
+ liveClassSp,
+ IndyInvalidation.liveCategorySwitchPoint(),
+ classFailedOver,
+ classInfo.getVersion(),
+ classInfo,
+ argClasses);
}
/**
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java
index 81325caa32..46d9a3f89f 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java
@@ -146,8 +146,8 @@ public final class IndyCompoundAssign {
Class<?> ac = (arg == null) ? null : arg.getClass();
MethodHandle test = MethodHandles.insertArguments(GUARD, 0, rc, ac);
MethodHandle guarded = MethodHandles.guardWithTest(test, invoke, elseTarget);
- // a metaclass change invalidates the site, exactly as for a normal indy call.
- guarded = IndyInterface.switchPoint.guardWithTest(guarded, elseTarget);
+ // Per-class + category SwitchPoints, same domains as a normal indy call (GROOVY-12191).
+ guarded = IndyInterface.applyMopSwitchPoints(guarded, elseTarget, receiver);
// Per-instance metaclasses make a class-keyed cache unsound, so mark such
// shapes uncacheable (the wrapper is still used for the current call) —
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 9379244204..edae006976 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 @@ package org.codehaus.groovy.vmplugin.v8;
import groovy.lang.GroovyRuntimeException;
import groovy.lang.GroovySystem;
+import org.apache.groovy.runtime.indy.IndyInvalidation;
import org.apache.groovy.util.SystemUtil;
import org.codehaus.groovy.GroovyBugError;
import org.codehaus.groovy.runtime.GeneratedClosure;
@@ -215,27 +216,68 @@ public class IndyInterface {
}
/**
- * Shared switch point invalidated when metaclass state changes.
+ * Category-domain SwitchPoint snapshot for binary compatibility and legacy
+ * readers. Metaclass changes are scoped per class (and subtypes) via
+ * {@link org.codehaus.groovy.reflection.ClassInfo} SwitchPoints (GROOVY-12191);
+ * this field tracks the <em>category</em> invalidation domain only and is
+ * refreshed when {@link #invalidateSwitchPoints()} runs.
+ * <p>
+ * <strong>Behavioral change in 6.0:</strong> this is no longer a process-wide
+ * MOP SwitchPoint for MetaClass changes — only the category domain.
+ *
+ * @see IndyInvalidation
*/
- protected static SwitchPoint switchPoint = new SwitchPoint();
+ protected static volatile SwitchPoint switchPoint = IndyInvalidation.categorySwitchPoint();
static {
- GroovySystem.getMetaClassRegistry().addMetaClassRegistryChangeEventListener(cmcu -> invalidateSwitchPoints());
+ // MetaClass registry changes invalidate the affected class + subtypes (GROOVY-12191).
+ GroovySystem.getMetaClassRegistry().addMetaClassRegistryChangeEventListener(cmcu -> {
+ Class<?> type = cmcu.getClassToUpdate();
+ if (type != null) {
+ IndyInvalidation.invalidateClass(type);
+ if (LOG_ENABLED) {
+ LOG.info("invalidating class SwitchPoint hierarchy for " + type.getName());
+ }
+ } else {
+ // Unattributed change: category-domain invalidation so sites still re-link.
+ invalidateSwitchPoints();
+ if (LOG_ENABLED) {
+ LOG.info("invalidating category switch point (unattributed MetaClass change)");
+ }
+ }
+ });
}
/**
- * Callback for constant metaclass update change
+ * Invalidates the category-global SwitchPoint domain.
+ * <p>
+ * Used by category enter/leave and {@code VMPlugin.invalidateCallSites()}.
+ * Per-class MetaClass changes must use {@link IndyInvalidation#invalidateClass(Class)}
+ * (or {@link org.codehaus.groovy.reflection.ClassInfo#incVersion()}) instead
+ * so unrelated call sites stay optimized (GROOVY-12191).
+ * <p>
+ * Note: this no longer flushes all MetaClass-linked indy sites — only the
+ * category domain. That is intentional for GROOVY-12191.
*/
protected static void invalidateSwitchPoints() {
if (LOG_ENABLED) {
- LOG.info("invalidating switch point");
+ LOG.info("invalidating category switch point");
}
+ IndyInvalidation.invalidateCategory();
+ // Keep the protected field pointing at the live category SwitchPoint.
+ switchPoint = IndyInvalidation.categorySwitchPoint();
+ }
- synchronized (IndyInterface.class) {
- SwitchPoint old = switchPoint;
- switchPoint = new SwitchPoint();
- SwitchPoint.invalidateAll(new SwitchPoint[]{old});
- }
+ /**
+ * Installs per-class and category SwitchPoint guards on a linked handle.
+ *
+ * @param handle fast-path handle
+ * @param fallback re-link handle
+ * @param receiver call receiver (may be {@code null})
+ * @return doubly-guarded handle
+ */
+ static MethodHandle applyMopSwitchPoints(final MethodHandle handle, final MethodHandle fallback, final Object receiver) {
+ return IndyInvalidation.guardWithMopSwitchPoints(handle, fallback, receiver);
}
/**
@@ -385,6 +427,11 @@ public class IndyInterface {
FallbackSupplier fallbackSupplier = new FallbackSupplier(callSite, sender, methodName, callID, safeNavigation, thisCall, spreadCall, dummyReceiver, arguments);
Object receiver = arguments[0];
String receiverClassName = receiverCacheKey(receiver);
+ // Polymorphism width tracking; may trip sticky megamorphic (pin default target).
+ callSite.recordReceiverShape(receiverClassName);
+
+ // Always use the PIC so selection stays amortized under megamorphic policy.
+ // Mega only suppresses monomorphic/bimorphic setTarget promotion.
MethodHandleWrapper mhw = callSite.getAndPut(receiverClassName, (theName) -> {
MethodHandleWrapper fallback = fallbackSupplier.get();
if (fallback.isCanSetTarget()) return fallback;
@@ -397,37 +444,41 @@ public class IndyInterface {
mhw = fallbackSupplier.get();
}
+ // Sticky megamorphic: keep default boot target; PIC serves selection hits.
+ if (callSite.isMegamorphic()) {
+ pinDefaultTarget(callSite);
+ if (LOG_ENABLED) LOG.info("megamorphic site; monomorphic promotion suppressed for " + methodName);
+ return mhw.getCachedMethodHandle();
+ }
+
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());
+ maybePromote(callSite, receiverClassName, receiver, mhw.getTargetMethodHandle(), methodName);
}
}
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());
- }
+ // reset the call site target to default forever to avoid JIT deoptimization storm further
+ pinDefaultTarget(callSite);
+ } else if (callSite.isMegamorphic()) {
+ // raced into mega while counting hits — do not promote
+ pinDefaultTarget(callSite);
} else if (mhw instanceof ColdReflectiveMethodHandleWrapper) {
// the reflective cold tier carries no relink target; build the
// full guarded chain now, promote to it, and replace the PIC entry
MethodHandleWrapper full = fallback(callSite, sender, methodName, callID, safeNavigation, thisCall, spreadCall, dummyReceiver, arguments, false);
- if (full.isCanSetTarget()) {
+ if (full.isCanSetTarget() && !callSite.isMegamorphic()) {
callSite.put(receiverClassName, full);
- callSite.setTarget(full.getTargetMethodHandle());
- if (LOG_ENABLED) LOG.info("cold reflective wrapper promoted, call site target set");
+ maybePromote(callSite, receiverClassName, receiver, full.getTargetMethodHandle(), methodName);
mhw.resetLatestHitCount();
return full.getCachedMethodHandle();
}
- } else {
- if (callSite.getTarget() != mhw.getTargetMethodHandle()) {
- callSite.setTarget(mhw.getTargetMethodHandle());
- if (LOG_ENABLED) LOG.info("call site target set, preparing outside invocation");
- }
+ } else if (!callSite.isMegamorphic()) {
+ maybePromote(callSite, receiverClassName, receiver, mhw.getTargetMethodHandle(), methodName);
}
mhw.resetLatestHitCount();
@@ -437,6 +488,40 @@ public class IndyInterface {
return mhw.getCachedMethodHandle();
}
+ /**
+ * Pins the call site to its default boot handle and clears promoted shapes.
+ */
+ private static void pinDefaultTarget(final CacheableCallSite callSite) {
+ MethodHandle def = callSite.getDefaultTarget();
+ if (def != null && callSite.getTarget() != def) {
+ callSite.clearPromotedShapes();
+ callSite.setTarget(def);
+ }
+ }
+
+ /**
+ * Attempts monomorphic/bimorphic promotion; clears shape history only when the
+ * site remains monomorphic (rebind signal), not when bimorphic.
+ */
+ private static void maybePromote(
+ final CacheableCallSite callSite,
+ final String receiverClassName,
+ final Object receiver,
+ final MethodHandle target,
+ final String methodName) {
+ if (callSite.isMegamorphic()) {
+ return;
+ }
+ if (callSite.promoteTarget(receiverClassName, receiver, target)) {
+ if (callSite.promotedShapeCount() == 1) {
+ callSite.clearReceiverShapes();
+ }
+ if (LOG_ENABLED) {
+ LOG.info("call site promote shapes=" + callSite.promotedShapeCount() + " for " + methodName);
+ }
+ }
+ }
+
/**
* Cold-tier dispatch for the {@code groovy.indy.cold.reflection} spike.
* Re-validates the cached selection with plain-Java checks and invokes the
@@ -472,9 +557,13 @@ public class IndyInterface {
throw ScriptBytecodeAdapter.unwrap(gre);
}
}
- MethodHandle mh = selectMethodHandle(cold.callSite, cold.sender, cold.methodName, cold.callID,
- cold.safeNavigation, cold.thisCall, cold.spreadCall, 1, arguments);
- return mh.invokeExact(arguments);
+ // Re-select without the cold tier so an always-invalid SwitchPoint (or any
+ // permanent cold miss) cannot recurse through tryBuild → invokeColdReflective.
+ MethodHandleWrapper full = fallback(cold.callSite, cold.sender, cold.methodName, cold.callID,
+ cold.safeNavigation, cold.thisCall, cold.spreadCall, 1, arguments, false);
+ cold.callSite.put(receiverCacheKey(arguments[0]),
+ full.isCanSetTarget() ? full : NULL_METHOD_HANDLE_WRAPPER);
+ return full.getCachedMethodHandle().invokeExact(arguments);
}
/**
@@ -491,6 +580,10 @@ public class IndyInterface {
* Core method for indy method selection using runtime types.
*/
private static MethodHandle selectMethodHandle(CacheableCallSite callSite, Class<?> sender, String methodName, int callID, Boolean safeNavigation, Boolean thisCall, Boolean spreadCall, Object dummyReceiver, Object[] arguments) throws Throwable {
+ Object receiver = arguments[0];
+ String receiverClassName = receiverCacheKey(receiver);
+ callSite.recordReceiverShape(receiverClassName);
+
MethodHandleWrapper mhw = fallback(callSite, sender, methodName, callID, safeNavigation, thisCall, spreadCall, dummyReceiver, arguments);
MethodHandle defaultTarget = callSite.getDefaultTarget();
@@ -501,13 +594,17 @@ public class IndyInterface {
callSite.resetFallbackCount();
}
+ // Megamorphic sites never keep a monomorphic/bimorphic target.
+ if (callSite.isMegamorphic()) {
+ pinDefaultTarget(callSite);
+ if (LOG_ENABLED) LOG.info("megamorphic site; target pinned to default");
+ }
+
if (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];
-
// Avoid PIC pollution: don't write back uncached wrappers, e.g. for instance-level metaClass dispatches.
- callSite.put(receiverCacheKey(receiver), mhw.isCanSetTarget() ? mhw : NULL_METHOD_HANDLE_WRAPPER);
+ callSite.put(receiverClassName, mhw.isCanSetTarget() ? mhw : NULL_METHOD_HANDLE_WRAPPER);
}
return mhw.getCachedMethodHandle();
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyMath.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyMath.java
index 14ef2d89ec..673deee924 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyMath.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyMath.java
@@ -20,6 +20,7 @@ package org.codehaus.groovy.vmplugin.v8;
import groovy.lang.MetaMethod;
import org.codehaus.groovy.GroovyBugError;
+import org.codehaus.groovy.runtime.typehandling.NumberMath;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
@@ -39,6 +40,12 @@ import static org.codehaus.groovy.vmplugin.v8.TypeHelper.replaceWithMoreSpecific
* This class contains math operations used by indy instead of the normal
* meta method and call site caching system. The goal is to avoid boxing, thus
* use primitive types for parameters and return types where possible.
+ * <p>
+ * BigDecimal / BigInteger category operands (including mixed integral +
+ * {@link BigDecimal}) are handled via {@link NumberMath} so results match the
+ * classic DGM / {@code NumberNumber*} paths. Primitive fast paths remain for
+ * pure int / long / double pairs.
+ * <p>
* WARNING: This class is for internal use only. Do not use it outside the
* org.codehaus.groovy.vmplugin.v8 package of groovy-core.
*/
@@ -57,8 +64,15 @@ public class IndyMath {
DD = MethodType.methodType(double.class, double.class),
DDV = MethodType.methodType(Void.TYPE, double.class, double.class),
DDD = MethodType.methodType(double.class, double.class, double.class),
+ /**
+ * Lookup key used when {@link #widenOperators} promotes to the BigDecimal category.
+ * The corresponding handles accept {@link Number} so Integer/Long/BigInteger/BigDecimal
+ * all asType cleanly.
+ */
GV = MethodType.methodType(Void.TYPE, BigDecimal.class),
GGV = MethodType.methodType(Void.TYPE, BigDecimal.class, BigDecimal.class),
+ NN = MethodType.methodType(Number.class, Number.class),
+ NNN = MethodType.methodType(Number.class, Number.class, Number.class),
OOV = MethodType.methodType(Void.TYPE, Object.class, Object.class);
private static void makeMapEntry(String method, MethodType[] keys, MethodType[] values) throws NoSuchMethodException, IllegalAccessException {
@@ -74,18 +88,20 @@ public class IndyMath {
static {
try {
- MethodType[] keys = new MethodType[]{IIV, LLV, DDV};
- MethodType[] values = new MethodType[]{III, LLL, DDD};
+ // GGV → NumberMath for exact BD/BI (and mixed integral+BD) semantics
+ MethodType[] keys = new MethodType[]{IIV, LLV, DDV, GGV};
+ MethodType[] values = new MethodType[]{III, LLL, DDD, NNN};
makeMapEntry("minus", keys, values);
makeMapEntry("plus", keys, values);
makeMapEntry("multiply", keys, values);
- keys = new MethodType[]{DDV};
- values = new MethodType[]{DDD};
+ // div: floating primitives stay binary; integral/BD use NumberMath.divide (BigDecimal result)
+ keys = new MethodType[]{IIV, LLV, DDV, GGV};
+ values = new MethodType[]{NNN, NNN, DDD, NNN};
makeMapEntry("div", keys, values);
- keys = new MethodType[]{IV, LV, DV};
- values = new MethodType[]{II, LL, DD};
+ keys = new MethodType[]{IV, LV, DV, GV};
+ values = new MethodType[]{II, LL, DD, NN};
makeMapEntry("next", keys, values);
makeMapEntry("previous", keys, values);
@@ -98,12 +114,48 @@ public class IndyMath {
makeMapEntry("and", keys, values);
makeMapEntry("leftShift", keys, values);
makeMapEntry("rightShift", keys, values);
+ makeMapEntry("rightShiftUnsigned", keys, values);
+
+ // integer division (Groovy intdiv) for integral pairs
+ keys = new MethodType[]{IIV, LLV, GGV};
+ values = new MethodType[]{NNN, NNN, NNN};
+ makeMapEntry("intdiv", keys, values);
} catch (Exception e) {
throw new GroovyBugError(e);
}
}
+ /**
+ * Returns whether {@code name} has an IndyMath fast path registered.
+ *
+ * @param name the method name (e.g. {@code plus}, {@code intdiv})
+ * @return {@code true} if a fast path exists
+ */
+ public static boolean isRegistered(final String name) {
+ return METHODS.containsKey(name);
+ }
+
+ /**
+ * Names that may be selected as {@link org.codehaus.groovy.reflection.GeneratedMetaMethod}
+ * (DGM) and are safe to route through IndyMath.
+ * <p>
+ * Shift ops ({@code leftShift}, {@code rightShift}, {@code rightShiftUnsigned})
+ * are excluded: their result type follows the left operand under Groovy/Java
+ * rules, while {@link #widenOperators} would promote {@code int >>> long} (and
+ * similar mixed pairs) to long/long and change the result type. Primitive
+ * same-width shift entries remain in the IndyMath table for paths that already
+ * resolved through number meta-methods with matching operand categories.
+ *
+ * @param name the method name
+ * @return {@code true} if GeneratedMetaMethod may use IndyMath for this name
+ */
+ public static boolean isGeneratedMetaMethodFastPath(final String name) {
+ return "next".equals(name)
+ || "previous".equals(name)
+ || "intdiv".equals(name);
+ }
+
/**
* Choose a method to replace the originally chosen metaMethod to have a
* more efficient call path.
@@ -265,6 +317,17 @@ public class IndyMath {
return a >> b;
}
+ /**
+ * Unsigned right-shifts an {@code int} value.
+ *
+ * @param a the value to shift
+ * @param b the shift distance
+ * @return the shifted result
+ */
+ public static int rightShiftUnsigned(int a, int b) {
+ return a >>> b;
+ }
+
// long x long
/**
* Adds two {@code long} values.
@@ -376,6 +439,17 @@ public class IndyMath {
return a >> b;
}
+ /**
+ * Unsigned right-shifts a {@code long} value.
+ *
+ * @param a the value to shift
+ * @param b the shift distance
+ * @return the shifted result
+ */
+ public static long rightShiftUnsigned(long a, long b) {
+ return a >>> b;
+ }
+
// double x double
/**
* Adds two {@code double} values.
@@ -482,10 +556,88 @@ public class IndyMath {
return d - 1;
}
- /*
- further operations to be handled here maybe:
- a / b a.div(b) (if one is double, return double, otherwise BD)
- a[b] a.getAt(b)
- a[b] = c a.putAt(b, c)
- */
+ // ---------------------------------------------------------------------
+ // BigDecimal / BigInteger category and mixed integral+BD (via NumberMath)
+ // Lookup keys are GGV/GV from widenOperators; handles accept Number so
+ // Integer/Long/BigInteger/BigDecimal all convert with MethodHandle.asType.
+ // ---------------------------------------------------------------------
+
+ /**
+ * Adds two numbers using Groovy {@link NumberMath} promotion rules.
+ * Used for BigDecimal-category (and mixed) operands under indy.
+ *
+ * @param a the left operand
+ * @param b the right operand
+ * @return the sum
+ */
+ public static Number plus(Number a, Number b) {
+ return NumberMath.add(a, b);
+ }
+
+ /**
+ * Subtracts two numbers using Groovy {@link NumberMath} promotion rules.
+ *
+ * @param a the left operand
+ * @param b the right operand
+ * @return the difference
+ */
+ public static Number minus(Number a, Number b) {
+ return NumberMath.subtract(a, b);
+ }
+
+ /**
+ * Multiplies two numbers using Groovy {@link NumberMath} promotion rules.
+ *
+ * @param a the left operand
+ * @param b the right operand
+ * @return the product
+ */
+ public static Number multiply(Number a, Number b) {
+ return NumberMath.multiply(a, b);
+ }
+
+ /**
+ * Divides two numbers using Groovy {@link NumberMath} rules (exact
+ * {@link BigDecimal} for integral operands). Also used for pure int/long
+ * pairs so {@code /} matches classic DGM semantics under indy.
+ *
+ * @param a the left operand
+ * @param b the right operand
+ * @return the quotient
+ */
+ public static Number div(Number a, Number b) {
+ return NumberMath.divide(a, b);
+ }
+
+ /**
+ * Integer division of two numbers (Groovy {@code intdiv}).
+ *
+ * @param a the left operand
+ * @param b the right operand
+ * @return the integer quotient
+ */
+ public static Number intdiv(Number a, Number b) {
+ return NumberMath.intdiv(a, b);
+ }
+
+ /**
+ * Returns the successor of a number ({@code n + 1}) via {@link NumberMath}.
+ * Used for BigInteger / BigDecimal unary next under indy.
+ *
+ * @param n the input value
+ * @return {@code n + 1}
+ */
+ public static Number next(Number n) {
+ return NumberMath.add(n, Integer.valueOf(1));
+ }
+
+ /**
+ * Returns the predecessor of a number ({@code n - 1}) via {@link NumberMath}.
+ *
+ * @param n the input value
+ * @return {@code n - 1}
+ */
+ public static Number previous(Number n) {
+ return NumberMath.subtract(n, Integer.valueOf(1));
+ }
}
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 989e1eb0f5..fd4f467e0b 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
@@ -100,7 +100,7 @@ import static org.codehaus.groovy.vmplugin.v8.IndyGuardsFiltersAndSignatures.unw
import static org.codehaus.groovy.vmplugin.v8.IndyInterface.CallType;
import static org.codehaus.groovy.vmplugin.v8.IndyInterface.LOG;
import static org.codehaus.groovy.vmplugin.v8.IndyInterface.LOG_ENABLED;
-import static org.codehaus.groovy.vmplugin.v8.IndyInterface.switchPoint;
+import static org.codehaus.groovy.vmplugin.v8.IndyInterface.applyMopSwitchPoints;
/**
* Base state holder for invokedynamic method, property, constructor, and cast selection.
@@ -1002,7 +1002,7 @@ public abstract class Selector {
private boolean setHandleForSimpleCases() {
if (method instanceof NumberNumberMetaMethod
- || (method instanceof GeneratedMetaMethod && ("next".equals(name) || "previous".equals(name)))) {
+ || (method instanceof GeneratedMetaMethod && IndyMath.isGeneratedMetaMethodFastPath(name))) {
if (LOG_ENABLED) LOG.info("meta method is number method");
if (IndyMath.chooseMathMethod(this, method)) {
catchException = false;
@@ -1283,9 +1283,11 @@ public abstract class Selector {
}
}
- // handle constant metaclass and category changes
- handle = switchPoint.guardWithTest(handle, fallback);
- if (LOG_ENABLED) LOG.info("added switch point guard");
+ // Per-class MetaClass domain + category-global domain (GROOVY-12191).
+ // Metaclass changes invalidate only the receiver class's SwitchPoint;
+ // category enter/leave invalidates the category domain for all sites.
+ handle = applyMopSwitchPoints(handle, fallback, receiver);
+ if (LOG_ENABLED) LOG.info("added class and category switch point guards");
java.util.function.Predicate<Class<?>> nonFinalOrNullUnsafe = (t) -> {
return !Modifier.isFinal(t.getModifiers())
@@ -1444,7 +1446,8 @@ public abstract class Selector {
* ({@code arguments[0]} is the receiver). The result has type
* {@code callSite.type()}. Guard and switch-point wrapping are intentionally
* omitted — the caller (compound-assignment) applies its own per-shape guard
- * and shares the global MOP {@link IndyInterface#switchPoint}. The caller must
+ * and the scoped MOP SwitchPoints (per-class + category; see
+ * {@link org.apache.groovy.runtime.indy.IndyInvalidation}). The caller must
* have already established that {@code methodName} resolves for this receiver
* (e.g. via {@code respondsTo}); this routes the actual invocation through the
* same selection, coercion and wrapping path as a normal call.
diff --git a/src/test/groovy/bugs/Groovy12191.groovy b/src/test/groovy/bugs/Groovy12191.groovy
new file mode 100644
index 0000000000..d7126ad384
--- /dev/null
+++ b/src/test/groovy/bugs/Groovy12191.groovy
@@ -0,0 +1,179 @@
+/*
+ * 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 bugs
+
+import groovy.transform.CompileDynamic
+import org.apache.groovy.runtime.indy.IndyInvalidation
+import org.codehaus.groovy.reflection.ClassInfo
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.SwitchPoint
+
+import static groovy.test.GroovyAssert.assertScript
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * GROOVY-12191: scoped indy SwitchPoint invalidation.
+ * Metaclass changes for class A must not invalidate class B's SwitchPoint;
+ * category enter/leave still uses a process-wide domain so category methods
+ * become visible to previously linked sites.
+ */
+final class Groovy12191 {
+
+ @Test
+ void metaclassChange_invalidatesOnlyAffectedClassSwitchPoint() {
+ SwitchPoint spA = ClassInfo.getClassInfo(TypeA).indySwitchPoint
+ SwitchPoint spB = ClassInfo.getClassInfo(TypeB).indySwitchPoint
+ assertFalse(spA.hasBeenInvalidated())
+ assertFalse(spB.hasBeenInvalidated())
+
+ TypeA.metaClass.extra = { -> 'from-emc' }
+ // ExpandoMetaClass.incVersion → ClassInfo.incVersion → class-domain only
+ assertTrue(spA.hasBeenInvalidated())
+ assertFalse(spB.hasBeenInvalidated())
+
+ // functional: A sees the new method; B is unaffected
+ assertScript '''
+ class TypeA { }
+ class TypeB {
+ String id() { 'B' }
+ }
+ TypeA.metaClass.extra = { -> 'from-emc' }
+ assert new TypeA().extra() == 'from-emc'
+ assert new TypeB().id() == 'B'
+ '''
+ }
+
+ @Test
+ void registrySetMetaClass_invalidatesClassDomain() {
+ SwitchPoint sp = ClassInfo.getClassInfo(TypeC).indySwitchPoint
+ def mc = new ExpandoMetaClass(TypeC, true, true)
+ mc.initialize()
+ GroovySystem.metaClassRegistry.setMetaClass(TypeC, mc)
+ assertTrue(sp.hasBeenInvalidated())
+ }
+
+ @Test
+ void categoryEnterLeave_invalidatesCategoryDomain_notUnrelatedClassOnly() {
+ SwitchPoint classSp = ClassInfo.getClassInfo(TypeD).indySwitchPoint
+ SwitchPoint catBefore = IndyInvalidation.categorySwitchPoint()
+
+ use(TypeDCategory) {
+ assert 'decorated' == new TypeD().label()
+ }
+
+ // enter + leave each invalidate the category domain
+ assertTrue(catBefore.hasBeenInvalidated())
+ // class domain for TypeD is independent of category enter/leave
+ assertFalse(classSp.hasBeenInvalidated())
+ }
+
+ @Test
+ void categoryMethods_areVisibleToDynamicCalls() {
+ assertScript '''
+ class Host {
+ String name() { 'host' }
+ }
+ class HostCategory {
+ static String name(Host self) { 'category' }
+ }
+ def h = new Host()
+ assert h.name() == 'host'
+ use (HostCategory) {
+ assert h.name() == 'category'
+ }
+ assert h.name() == 'host'
+ '''
+ }
+
+ @Test
+ @CompileDynamic
+ void indyCalls_pickUpNewMetaClassMethodsAfterChange() {
+ // Adding a *new* method via EMC must become visible on instances that
+ // pick up the class-level MetaClass (clear any instance-cached MetaClass).
+ // Class SwitchPoint invalidation (GROOVY-12191) forces re-link of sites.
+ assertScript '''
+ class Svc {
+ int add(int a, int b) { a + b }
+ }
+ def s = new Svc()
+ assert s.add(1, 2) == 3
+ Svc.metaClass.mul = { int a, int b -> a * b }
+ s.metaClass = null
+ assert s.mul(3, 4) == 12
+ assert s.add(3, 4) == 7
+ // new instances also see the expanded MetaClass
+ assert new Svc().mul(2, 5) == 10
+ '''
+ }
+
+ @Test
+ void parentMetaClassChange_invalidatesSubclassSwitchPoint() {
+ // Hierarchy fan-out: MetaClass change on Parent must invalidate Child's
+ // SwitchPoint so subclass receiver sites re-link (GROOVY-12191).
+ SwitchPoint childSp = ClassInfo.getClassInfo(HierChild).liveIndySwitchPoint
+ assertFalse(childSp.hasBeenInvalidated())
+
+ HierParent.metaClass.hello = { -> 'from-parent' }
+
+ assertTrue(childSp.hasBeenInvalidated())
+ // functional smoke: subclass observes parent EMC method
+ assertScript '''
+ class HierParent {}
+ class HierChild extends HierParent {}
+ HierParent.metaClass.hello = { -> 'from-parent' }
+ assert new HierChild().hello() == 'from-parent'
+ '''
+ }
+
+ @Test
+ @CompileDynamic
+ void indyCalls_overrideViaFreshMetaClassInstance() {
+ // Replacing the MetaClass identity (not only mutating EMC in place) must
+ // be observed by sites that guard on MetaClass equality and/or SwitchPoint.
+ assertScript '''
+ class Svc {
+ String greet() { 'hello' }
+ }
+ def s = new Svc()
+ assert s.greet() == 'hello'
+ def emc = new ExpandoMetaClass(Svc, true, true)
+ emc.greet = { -> 'world' }
+ emc.initialize()
+ GroovySystem.metaClassRegistry.setMetaClass(Svc, emc)
+ s.metaClass = null // drop any instance-cached MetaClass
+ assert s.greet() == 'world'
+ '''
+ }
+
+ static class TypeA {}
+ static class TypeB {
+ String id() { 'B' }
+ }
+ static class TypeC {}
+ static class TypeD {
+ String label() { 'plain' }
+ }
+ static class TypeDCategory {
+ static String label(TypeD self) { 'decorated' }
+ }
+ static class HierParent {}
+ static class HierChild extends HierParent {}
+}
diff --git a/src/test/groovy/org/apache/groovy/runtime/indy/BimorphicDispatchScriptTest.groovy b/src/test/groovy/org/apache/groovy/runtime/indy/BimorphicDispatchScriptTest.groovy
new file mode 100644
index 0000000000..683c64ee46
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/indy/BimorphicDispatchScriptTest.groovy
@@ -0,0 +1,65 @@
+/*
+ * 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 org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * End-to-end dispatch smoke for dual receiver types under indy.
+ */
+final class BimorphicDispatchScriptTest {
+
+ @Test
+ void dualReceiverTypes_dispatchCorrectlyUnderIndy() {
+ assertScript '''
+ def f = { x -> x.tag() }
+ class Left { String tag() { 'L' } }
+ class Right { String tag() { 'R' } }
+ def left = new Left()
+ def right = new Right()
+ 500.times {
+ assert f(left) == 'L'
+ assert f(right) == 'R'
+ }
+ '''
+ }
+
+ @Test
+ void dualReceiverTypes_withUnrelatedMetaClassChurn_stayCorrect() {
+ // Scoped invalidation (GROOVY-12191): MetaClass changes on an unrelated
+ // type must not break bimorphic dispatch correctness on the hot site.
+ assertScript '''
+ def f = { x -> x.tag() }
+ class Left { String tag() { 'L' } }
+ class Right { String tag() { 'R' } }
+ class Unrelated { }
+ def left = new Left()
+ def right = new Right()
+ 200.times { i ->
+ assert f(left) == 'L'
+ assert f(right) == 'R'
+ if (i % 20 == 0) {
+ Unrelated.metaClass."dyn${i % 3}" = { -> i }
+ }
+ }
+ '''
+ }
+}
diff --git a/src/test/groovy/org/apache/groovy/runtime/indy/CallSiteTrackerTest.groovy b/src/test/groovy/org/apache/groovy/runtime/indy/CallSiteTrackerTest.groovy
new file mode 100644
index 0000000000..95b24e84de
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/indy/CallSiteTrackerTest.groovy
@@ -0,0 +1,97 @@
+/*
+ * 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 org.junit.jupiter.api.Test
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Unit tests for {@link CallSiteTracker}.
+ */
+final class CallSiteTrackerTest {
+
+ @Test
+ void record_tracksDistinctKeys() {
+ def t = new CallSiteTracker(8)
+ assertEquals(1, t.record('a'))
+ assertEquals(1, t.record('a'))
+ assertEquals(2, t.record('b'))
+ assertEquals(2, t.distinctCount())
+ assertEquals(2, t.lastPolyWidth())
+ assertFalse(t.isPolyAtLeast(3))
+ assertTrue(t.isPolyAtLeast(2))
+ }
+
+ @Test
+ void record_whenFull_reportsWidthBeyondMaxTracked() {
+ def t = new CallSiteTracker(2)
+ assertEquals(1, t.record('a'))
+ assertEquals(2, t.record('b'))
+ assertEquals(3, t.record('c')) // full + new key
+ assertEquals(2, t.distinctCount()) // set still capped
+ assertEquals(3, t.lastPolyWidth())
+ // isPolyAtLeast must see the poly width, not only the capped set size
+ assertTrue(t.isPolyAtLeast(2))
+ assertTrue(t.isPolyAtLeast(3))
+ assertFalse(t.isPolyAtLeast(4))
+ // re-recording a retained key keeps lastPolyWidth at set size
+ assertEquals(2, t.record('a'))
+ assertEquals(2, t.lastPolyWidth())
+ }
+
+ @Test
+ void clear_resetsDistinctAndIncrementsCounter() {
+ def t = new CallSiteTracker(8)
+ t.record('a')
+ t.record('b')
+ t.clear()
+ assertEquals(0, t.distinctCount())
+ assertEquals(0, t.lastPolyWidth())
+ assertEquals(1, t.clearCount())
+ assertEquals(1, t.record('a'))
+ }
+
+ @Test
+ void maxPolyZero_neverMegamorphicByWidth() {
+ def t = new CallSiteTracker(4)
+ t.record('a')
+ t.record('b')
+ assertFalse(t.isPolyAtLeast(0))
+ assertFalse(t.isPolyAtLeast(-1))
+ }
+
+ @Test
+ void nullKey_isIgnored() {
+ def t = new CallSiteTracker(4)
+ assertEquals(0, t.record(null))
+ assertEquals(0, t.distinctCount())
+ assertEquals(0, t.lastPolyWidth())
+ }
+
+ @Test
+ void maxTrackedClampedToAtLeastOne() {
+ def t = new CallSiteTracker(0)
+ assertEquals(1, t.maxTracked)
+ assertEquals(1, t.record('only'))
+ assertEquals(2, t.record('another'))
+ }
+}
diff --git a/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy b/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy
new file mode 100644
index 0000000000..e1be18c83a
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy
@@ -0,0 +1,298 @@
+/*
+ * 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 org.codehaus.groovy.reflection.ClassInfo
+import org.codehaus.groovy.runtime.NullObject
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.SwitchPoint
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertNotSame
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Unit tests for {@link IndyInvalidation} façade and dual SwitchPoint domains.
+ */
+final class IndyInvalidationTest {
+
+ @BeforeEach
+ void resetCounters() {
+ IndyInvalidation.resetCountersForTesting()
+ }
+
+ @Test
+ void switchPointClassFor_null_classReceiver_andInstance() {
+ assertSame(NullObject, IndyInvalidation.switchPointClassFor(null))
+ assertSame(String, IndyInvalidation.switchPointClassFor(String))
+ assertSame(String, IndyInvalidation.switchPointClassFor('hi'))
+ assertSame(Integer, IndyInvalidation.switchPointClassFor(42))
+ }
+
+ @Test
+ void invalidateClass_onlyAffectsThatClass_notSiblings() {
+ SwitchPoint spA = ClassInfo.getClassInfo(ClassA).liveIndySwitchPoint
+ SwitchPoint spB = ClassInfo.getClassInfo(ClassB).liveIndySwitchPoint
+ assertFalse(spA.hasBeenInvalidated())
+ assertFalse(spB.hasBeenInvalidated())
+
+ long before = IndyInvalidation.classInvalidationCount()
+ IndyInvalidation.invalidateClass(ClassA)
+ assertTrue(spA.hasBeenInvalidated())
+ assertFalse(spB.hasBeenInvalidated())
+ assertEquals(before + 1, IndyInvalidation.classInvalidationCount())
+
+ // ClassB still has a live switch point; ClassA gets a fresh one on next get
+ SwitchPoint spA2 = ClassInfo.getClassInfo(ClassA).liveIndySwitchPoint
+ assertNotSame(spA, spA2)
+ assertFalse(spA2.hasBeenInvalidated())
+ assertSame(spB, ClassInfo.getClassInfo(ClassB).liveIndySwitchPoint)
+ }
+
+ @Test
+ void invalidateClass_fansOutToSubtypes() {
+ // Ensure ClassInfo entries exist for hierarchy members.
+ SwitchPoint parentSp = ClassInfo.getClassInfo(Parent).liveIndySwitchPoint
+ SwitchPoint childSp = ClassInfo.getClassInfo(Child).liveIndySwitchPoint
+ SwitchPoint siblingSp = ClassInfo.getClassInfo(ClassB).liveIndySwitchPoint
+
+ IndyInvalidation.invalidateClass(Parent)
+
+ assertTrue(parentSp.hasBeenInvalidated())
+ assertTrue(childSp.hasBeenInvalidated())
+ assertFalse(siblingSp.hasBeenInvalidated())
+ }
+
+ @Test
+ void invalidateClass_null_isNoOp() {
+ long before = IndyInvalidation.classInvalidationCount()
+ IndyInvalidation.invalidateClass(null)
+ assertEquals(before, IndyInvalidation.classInvalidationCount())
+ }
+
+ @Test
+ void invalidateCategory_doesNotInvalidateClassDomain() {
+ SwitchPoint classSp = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+ SwitchPoint catBefore = IndyInvalidation.categorySwitchPoint()
+ long catCount = IndyInvalidation.categoryInvalidationCount()
+
+ IndyInvalidation.invalidateCategory()
+
+ assertTrue(catBefore.hasBeenInvalidated())
+ assertFalse(classSp.hasBeenInvalidated())
+ assertEquals(catCount + 1, IndyInvalidation.categoryInvalidationCount())
+ if (!IndyInvalidation.categoryInvalidator().failedOver) {
+ SwitchPoint catAfter = IndyInvalidation.categorySwitchPoint()
+ assertNotSame(catBefore, catAfter)
+ assertFalse(catAfter.hasBeenInvalidated())
+ }
+ }
+
+ @Test
+ void classInfoIncVersion_invalidatesClassDomainOnly() {
+ SwitchPoint spA = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+ SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+ SwitchPoint cat = IndyInvalidation.categorySwitchPoint()
+ int versionBefore = ClassInfo.getClassInfo(ClassA).version
+
+ ClassInfo.getClassInfo(ClassA).incVersion()
+
+ assertEquals(versionBefore + 1, ClassInfo.getClassInfo(ClassA).version)
+ assertTrue(spA.hasBeenInvalidated())
+ assertFalse(spB.hasBeenInvalidated())
+ assertFalse(cat.hasBeenInvalidated())
+ }
+
+ @Test
+ void guardWithMopSwitchPoints_fallsBackOnClassOrCategoryInvalidate() {
+ def target = MethodHandles.constant(int, 1)
+ def fallback = MethodHandles.constant(int, 2)
+ def receiver = new ClassA()
+
+ def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, receiver)
+ assertEquals(1, guarded.invokeWithArguments())
+
+ IndyInvalidation.invalidateClass(ClassA)
+ assertEquals(2, guarded.invokeWithArguments())
+
+ // re-link with fresh guards
+ guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, receiver)
+ assertEquals(1, guarded.invokeWithArguments())
+ IndyInvalidation.invalidateCategory()
+ assertEquals(2, guarded.invokeWithArguments())
+ }
+
+ @Test
+ void noteClassInvalidation_incrementsCounter() {
+ long before = IndyInvalidation.classInvalidationCount()
+ IndyInvalidation.noteClassInvalidation()
+ assertEquals(before + 1, IndyInvalidation.classInvalidationCount())
+ }
+
+ @Test
+ void maxFailures_isReadable() {
+ // default or system-property override; just ensure it is accessible
+ int max = IndyInvalidation.maxFailures()
+ assertTrue(max == IndyInvalidation.DEFAULT_MAX_FAILURES || max != Integer.MIN_VALUE)
+ }
+
+ @Test
+ void categoryDomain_neverFailedOver_andLiveSpAvailable() {
+ assertFalse(IndyInvalidation.isCategoryFailedOver())
+ SwitchPoint live = IndyInvalidation.liveCategorySwitchPoint()
+ assertTrue(live != null)
+ assertFalse(live.hasBeenInvalidated())
+ assertSame(live, IndyInvalidation.categorySwitchPoint())
+ }
+
+ @Test
+ void classSwitchPointFor_classAndReceiverOverloads() {
+ SwitchPoint byClass = IndyInvalidation.classSwitchPointFor(String)
+ SwitchPoint byReceiver = IndyInvalidation.classSwitchPointFor('x')
+ assertSame(byClass, byReceiver)
+ assertFalse(byClass.hasBeenInvalidated())
+ SwitchPoint byNull = IndyInvalidation.classSwitchPointFor((Object) null)
+ assertSame(ClassInfo.getClassInfo(NullObject).indySwitchPoint, byNull)
+ }
+
+ @Test
+ void resetCountersForTesting_zerosAllCounters() {
+ IndyInvalidation.invalidateClass(ClassA)
+ IndyInvalidation.invalidateCategory()
+ IndyInvalidation.noteClassInvalidation()
+ assertTrue(IndyInvalidation.classInvalidationCount() > 0)
+ assertTrue(IndyInvalidation.categoryInvalidationCount() > 0)
+ IndyInvalidation.resetCountersForTesting()
+ assertEquals(0, IndyInvalidation.classInvalidationCount())
+ assertEquals(0, IndyInvalidation.categoryInvalidationCount())
+ assertEquals(0, IndyInvalidation.failoverCount())
+ }
+
+ @Test
+ void guardWithMopSwitchPoints_skipsFailedOverClassDomain() {
+ // Drive a dedicated ClassInfo into failover, then ensure guarding still
+ // produces an invokable handle (no always-invalid SP recursion).
+ def inv = new SwitchPointInvalidator(1)
+ // Exercise the public façade path with a normal receiver; correctness of
+ // omit-on-failover is covered by unit logic + ClassInfo.isIndySwitchPointFailedOver.
+ def target = MethodHandles.constant(int, 11)
+ def fallback = MethodHandles.constant(int, 22)
+ def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, new ClassA())
+ assertEquals(11, guarded.invokeWithArguments())
+ // class invalidation still forces fallback while under budget
+ IndyInvalidation.invalidateClass(ClassA)
+ assertEquals(22, guarded.invokeWithArguments())
+ }
+
+ @Test
+ void setStrongMetaClass_firstInstall_bumpsVersionWithoutSwitchPoint() {
+ // First MetaClass install should not retire a pre-captured SwitchPoint —
+ // no call site has linked against an established MC yet.
+ def info = ClassInfo.getClassInfo(LocalGen)
+ // ensure clean class-level MC
+ info.setStrongMetaClass(null)
+ info.setWeakMetaClass(null)
+ SwitchPoint sp = info.liveIndySwitchPoint
+ assertFalse(sp.hasBeenInvalidated())
+ int versionBefore = info.version
+
+ def mc = new groovy.lang.MetaClassImpl(LocalGen)
+ mc.initialize()
+ info.setStrongMetaClass(mc)
+
+ assertEquals(versionBefore + 1, info.version)
+ assertFalse(sp.hasBeenInvalidated(), 'first install must not invalidate SwitchPoint')
+ // cleanup
+ info.setStrongMetaClass(null)
+ }
+
+ @Test
+ void setStrongMetaClass_replace_invalidatesLocalSwitchPoint() {
+ def info = ClassInfo.getClassInfo(LocalGenReplace)
+ def mc1 = new groovy.lang.MetaClassImpl(LocalGenReplace)
+ mc1.initialize()
+ info.setStrongMetaClass(mc1)
+ SwitchPoint sp = info.liveIndySwitchPoint
+ assertFalse(sp.hasBeenInvalidated())
+
+ def mc2 = new groovy.lang.MetaClassImpl(LocalGenReplace)
+ mc2.initialize()
+ info.setStrongMetaClass(mc2)
+
+ assertTrue(sp.hasBeenInvalidated(), 'replacement must invalidate SwitchPoint')
+ info.setStrongMetaClass(null)
+ }
+
+ @Test
+ void setWeakMetaClass_replace_invalidatesLocalSwitchPoint() {
+ def info = ClassInfo.getClassInfo(LocalGenWeak)
+ def mc1 = new groovy.lang.MetaClassImpl(LocalGenWeak)
+ mc1.initialize()
+ info.setWeakMetaClass(mc1)
+ SwitchPoint sp = info.liveIndySwitchPoint
+ assertFalse(sp.hasBeenInvalidated())
+
+ def mc2 = new groovy.lang.MetaClassImpl(LocalGenWeak)
+ mc2.initialize()
+ info.setWeakMetaClass(mc2)
+
+ assertTrue(sp.hasBeenInvalidated())
+ info.setWeakMetaClass(null)
+ }
+
+ @Test
+ void guardWithMopSwitchPoints_versionGuardAfterClassFailover() {
+ // After class-domain failover, re-link must use ClassInfo.version guards
+ // so MetaClass changes still force fallback without a live SwitchPoint.
+ def info = ClassInfo.getClassInfo(FailoverHost)
+ int budget = IndyInvalidation.maxFailures()
+ if (budget <= 0) {
+ return // unlimited SwitchPoints in this JVM — nothing to failover
+ }
+ budget.times {
+ SwitchPoint live = info.liveIndySwitchPoint
+ if (live != null) {
+ info.invalidateIndySwitchPoint()
+ }
+ }
+ assertTrue(info.indySwitchPointFailedOver)
+ assertEquals(null, info.liveIndySwitchPoint)
+
+ def target = MethodHandles.constant(int, 7)
+ def fallback = MethodHandles.constant(int, 9)
+ def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, new FailoverHost())
+ assertEquals(7, guarded.invokeWithArguments())
+ info.incVersion() // version bump must trip generation guard
+ assertEquals(9, guarded.invokeWithArguments())
+ }
+
+ private static final class ClassA {}
+ private static final class ClassB {}
+ private static class Parent {}
+ private static final class Child extends Parent {}
+ private static final class LocalGen {}
+ private static final class LocalGenReplace {}
+ private static final class LocalGenWeak {}
+ private static final class FailoverHost {}
+}
diff --git a/src/test/groovy/org/apache/groovy/runtime/indy/MegamorphicCallSiteTest.groovy b/src/test/groovy/org/apache/groovy/runtime/indy/MegamorphicCallSiteTest.groovy
new file mode 100644
index 0000000000..1bad7176b5
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/indy/MegamorphicCallSiteTest.groovy
@@ -0,0 +1,160 @@
+/*
+ * 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 org.codehaus.groovy.vmplugin.v8.CacheableCallSite
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.MethodHandle
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.MethodType
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Tests for megamorphic sticky policy on {@link CacheableCallSite}.
+ */
+final class MegamorphicCallSiteTest {
+
+ private static CacheableCallSite newSite() {
+ MethodType type = MethodType.methodType(Object, Object)
+ def site = new CacheableCallSite(type, MethodHandles.lookup())
+ MethodHandle identity = MethodHandles.identity(Object)
+ site.defaultTarget = identity
+ site.target = identity
+ site.fallbackTarget = identity
+ site
+ }
+
+ @Test
+ void recordReceiverShape_underMaxPoly_doesNotTripMega() {
+ def site = newSite()
+ int max = CacheableCallSite.maxPoly()
+ if (max <= 0) {
+ return // mega disabled in this JVM
+ }
+ int n = Math.min(3, max - 1)
+ if (n < 1) return
+ n.times { i ->
+ site.recordReceiverShape("shape-$i")
+ }
+ assertFalse(site.megamorphic)
+ assertEquals(n, site.distinctReceiverShapes())
+ }
+
+ @Test
+ void recordReceiverShape_reachesMaxPoly_tripsMegaAndPinsDefault() {
+ def site = newSite()
+ int max = CacheableCallSite.maxPoly()
+ if (max <= 0) {
+ return
+ }
+ // Install a non-default monomorphic target first
+ MethodHandle other = MethodHandles.dropArguments(
+ MethodHandles.constant(Object, 'mono'), 0, Object)
+ site.target = other
+ assertSame(other, site.target)
+
+ max.times { i ->
+ site.recordReceiverShape("mega-shape-$i")
+ }
+ assertTrue(site.megamorphic)
+ assertSame(site.defaultTarget, site.target)
+ }
+
+ @Test
+ void markMegamorphic_isSticky() {
+ def site = newSite()
+ site.markMegamorphic()
+ assertTrue(site.megamorphic)
+ site.clearReceiverShapes()
+ // sticky default: mega flag remains
+ assertTrue(site.megamorphic || !System.getProperty('groovy.indy.callsite.mega.sticky', 'true').toBoolean())
+ }
+
+ @Test
+ void clearReceiverShapes_resetsDistinctCount() {
+ def site = newSite()
+ site.recordReceiverShape('a')
+ site.recordReceiverShape('b')
+ int distinct = site.distinctReceiverShapes()
+ assertTrue(distinct >= 2)
+ int clears = site.shapeClearCount()
+ site.clearReceiverShapes()
+ assertEquals(0, site.distinctReceiverShapes())
+ assertEquals(clears + 1, site.shapeClearCount())
+ }
+
+ @Test
+ void megamorphic_manyReceiverTypes_stillDispatchCorrectly() {
+ // Functional correctness with many distinct receiver types on one call
+ // site (more than default max.poly of 8) — sticky mega must not break
+ // method selection.
+ groovy.test.GroovyAssert.assertScript '''
+ def f = { x -> x.id() }
+ class T0 { String id() { '0' } }
+ class T1 { String id() { '1' } }
+ class T2 { String id() { '2' } }
+ class T3 { String id() { '3' } }
+ class T4 { String id() { '4' } }
+ class T5 { String id() { '5' } }
+ class T6 { String id() { '6' } }
+ class T7 { String id() { '7' } }
+ class T8 { String id() { '8' } }
+ def types = [new T0(), new T1(), new T2(), new T3(), new T4(),
+ new T5(), new T6(), new T7(), new T8()]
+ 20.times {
+ types.eachWithIndex { t, i ->
+ assert f(t) == String.valueOf(i)
+ }
+ }
+ '''
+ }
+
+ @Test
+ void maxPoly_isReadable() {
+ int max = CacheableCallSite.maxPoly()
+ assertTrue(max == CacheableCallSite.DEFAULT_MAX_POLY || max != Integer.MIN_VALUE)
+ }
+
+ @Test
+ void megamorphic_suppressesPromoteTarget() {
+ // Mega policy must not thrash monomorphic/bimorphic targets; the site
+ // stays on the default boot handle while the PIC path (getAndPut) still
+ // amortizes selection for subsequent calls.
+ def site = newSite()
+ int max = CacheableCallSite.maxPoly()
+ if (max <= 0) {
+ return
+ }
+ max.times { i ->
+ site.recordReceiverShape("pic-shape-$i")
+ }
+ assertTrue(site.megamorphic)
+
+ MethodHandle mono = MethodHandles.dropArguments(
+ MethodHandles.constant(Object, 'should-not-install'), 0, Object)
+ assertFalse(site.promoteTarget('pic-shape-0', new Object(), mono))
+ assertSame(site.defaultTarget, site.target)
+ assertEquals(0, site.promotedShapeCount())
+ }
+}
diff --git a/src/test/groovy/org/apache/groovy/runtime/indy/SwitchPointInvalidatorTest.groovy b/src/test/groovy/org/apache/groovy/runtime/indy/SwitchPointInvalidatorTest.groovy
new file mode 100644
index 0000000000..0cabd39b09
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/indy/SwitchPointInvalidatorTest.groovy
@@ -0,0 +1,224 @@
+/*
+ * 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 org.junit.jupiter.api.Test
+
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.SwitchPoint
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.atomic.AtomicInteger
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertNotSame
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Unit tests for {@link SwitchPointInvalidator}.
+ */
+final class SwitchPointInvalidatorTest {
+
+ @Test
+ void lazyAllocation_returnsValidSwitchPointOnFirstUse() {
+ def inv = new SwitchPointInvalidator(10)
+ SwitchPoint sp = inv.switchPoint
+ assertFalse(sp.hasBeenInvalidated())
+ assertSame(sp, inv.switchPoint)
+ assertEquals(0, inv.failureCount)
+ assertFalse(inv.failedOver)
+ }
+
+ @Test
+ void invalidate_withoutPriorGet_isSafe_andDoesNotCount() {
+ def inv = new SwitchPointInvalidator(10)
+ inv.invalidate()
+ // no live SP was retired — budget unchanged
+ assertEquals(0, inv.failureCount)
+ SwitchPoint sp = inv.liveSwitchPoint
+ assertFalse(sp.hasBeenInvalidated())
+ }
+
+ @Test
+ void getLiveSwitchPoint_returnsNullAfterFailover() {
+ def inv = new SwitchPointInvalidator(1)
+ SwitchPoint first = inv.liveSwitchPoint
+ inv.invalidate()
+ assertTrue(inv.failedOver)
+ assertTrue(first.hasBeenInvalidated())
+ assertEquals(null, inv.liveSwitchPoint)
+ assertSame(SwitchPointInvalidator.invalidSwitchPoint(), inv.switchPoint)
+ }
+
+ @Test
+ void invalidate_invalidatesCapturedSwitchPoint_andAllocatesFresh() {
+ def inv = new SwitchPointInvalidator(10)
+ SwitchPoint first = inv.switchPoint
+ inv.invalidate()
+ assertTrue(first.hasBeenInvalidated())
+ SwitchPoint second = inv.switchPoint
+ assertNotSame(first, second)
+ assertFalse(second.hasBeenInvalidated())
+ assertEquals(1, inv.failureCount)
+ }
+
+ @Test
+ void failover_afterMaxFailures_returnsPermanentInvalid() {
+ def failovers = new AtomicInteger()
+ def inv = new SwitchPointInvalidator(2, { failovers.incrementAndGet() })
+ SwitchPoint a = inv.liveSwitchPoint
+ inv.invalidate()
+ SwitchPoint b = inv.liveSwitchPoint
+ inv.invalidate() // reaches maxFailures=2 → failover
+ assertTrue(inv.failedOver)
+ assertEquals(1, failovers.get())
+ assertEquals(null, inv.liveSwitchPoint)
+ SwitchPoint after = inv.switchPoint
+ assertTrue(after.hasBeenInvalidated())
+ assertSame(SwitchPointInvalidator.invalidSwitchPoint(), after)
+ // further invalidates are no-ops
+ inv.invalidate()
+ assertEquals(2, inv.failureCount)
+ assertSame(after, inv.switchPoint)
+ assertTrue(a.hasBeenInvalidated())
+ assertTrue(b.hasBeenInvalidated())
+ }
+
+ @Test
+ void unlimitedFailures_neverFailOver() {
+ def inv = new SwitchPointInvalidator(0)
+ for (int i = 0; i < 50; i++) {
+ SwitchPoint sp = inv.switchPoint
+ inv.invalidate()
+ assertTrue(sp.hasBeenInvalidated())
+ assertFalse(inv.failedOver)
+ }
+ assertFalse(inv.switchPoint.hasBeenInvalidated())
+ assertEquals(50, inv.failureCount)
+ assertEquals(0, inv.maxFailures)
+ }
+
+ @Test
+ void guardWithTest_fallsBackAfterInvalidate() {
+ def inv = new SwitchPointInvalidator(10)
+ def target = MethodHandles.constant(int, 1)
+ def fallback = MethodHandles.constant(int, 2)
+ def guarded = inv.switchPoint.guardWithTest(target, fallback)
+ assertEquals(1, guarded.invokeWithArguments())
+ inv.invalidate()
+ assertEquals(2, guarded.invokeWithArguments())
+ }
+
+ @Test
+ void failover_guardWithTest_doesNotUsePermanentInvalidAsLiveGuard() {
+ // After failover, callers should omit SwitchPoint guards (see IndyInvalidation).
+ // This unit test only checks the invalidator itself reports failover and
+ // returns the permanent invalid switch point.
+ def inv = new SwitchPointInvalidator(1)
+ SwitchPoint first = inv.switchPoint
+ inv.invalidate()
+ assertTrue(inv.failedOver)
+ assertTrue(first.hasBeenInvalidated())
+ assertTrue(inv.switchPoint.hasBeenInvalidated())
+ assertSame(SwitchPointInvalidator.invalidSwitchPoint(), inv.switchPoint)
+ }
+
+ @Test
+ void concurrentInvalidate_isThreadSafe() {
+ def inv = new SwitchPointInvalidator(1000)
+ int threads = 8
+ int perThread = 50
+ def start = new CountDownLatch(1)
+ def done = new CountDownLatch(threads)
+ threads.times {
+ Thread.start {
+ start.await()
+ perThread.times {
+ inv.switchPoint
+ inv.invalidate()
+ }
+ done.countDown()
+ }
+ }
+ start.countDown()
+ assertTrue(done.await(30, java.util.concurrent.TimeUnit.SECONDS))
+ // Concurrent invalidates may race on a null current (sibling already
+ // cleared); only live retirements count, so expect a positive count
+ // not exceeding the attempted total.
+ assertTrue(inv.failureCount > 0)
+ assertTrue(inv.failureCount <= threads * perThread)
+ // under budget: still allocates a valid SwitchPoint after the storm
+ assertFalse(inv.failedOver)
+ assertFalse(inv.liveSwitchPoint.hasBeenInvalidated())
+ }
+
+ @Test
+ void concurrentFailover_doesNotLeaveOrphanValidSwitchPoint() {
+ // Stress the invalidate ↔ getLive handoff at the failover boundary.
+ // Any switch point observed by a reader after the domain has failed over
+ // must already be invalidated (no permanently-valid orphan).
+ def inv = new SwitchPointInvalidator(3)
+ int threads = 8
+ int perThread = 200
+ def start = new CountDownLatch(1)
+ def done = new CountDownLatch(threads)
+ def orphans = new AtomicInteger()
+ threads.times {
+ Thread.start {
+ start.await()
+ perThread.times {
+ SwitchPoint sp = inv.liveSwitchPoint
+ inv.invalidate()
+ if (sp != null && inv.failedOver && !sp.hasBeenInvalidated()) {
+ // Give a brief moment for the invalidator to drain the race;
+ // a true orphan stays valid forever.
+ Thread.yield()
+ if (!sp.hasBeenInvalidated()) {
+ orphans.incrementAndGet()
+ }
+ }
+ }
+ done.countDown()
+ }
+ }
+ start.countDown()
+ assertTrue(done.await(30, java.util.concurrent.TimeUnit.SECONDS))
+ assertTrue(inv.failedOver)
+ assertEquals(null, inv.liveSwitchPoint)
+ assertEquals(0, orphans.get(), 'orphaned valid SwitchPoint after failover')
+ assertTrue(inv.switchPoint.hasBeenInvalidated())
+ // further gets stay failed-over
+ assertEquals(null, inv.liveSwitchPoint)
+ inv.invalidate()
+ assertEquals(null, inv.liveSwitchPoint)
+ }
+
+ @Test
+ void getLiveSwitchPoint_afterFailover_neverReturnsValidSp() {
+ def inv = new SwitchPointInvalidator(1)
+ SwitchPoint first = inv.liveSwitchPoint
+ inv.invalidate()
+ assertTrue(inv.failedOver)
+ assertTrue(first.hasBeenInvalidated())
+ 20.times {
+ assertEquals(null, inv.liveSwitchPoint)
+ }
+ }
+}
diff --git a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyMathTest.groovy b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyMathTest.groovy
new file mode 100644
index 0000000000..5114bb961a
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyMathTest.groovy
@@ -0,0 +1,172 @@
+/*
+ * 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 org.junit.jupiter.api.Test
+
+import java.math.BigDecimal
+import java.math.BigInteger
+
+import static groovy.test.GroovyAssert.assertScript
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Unit and integration tests for {@link IndyMath} fast paths, including
+ * BigDecimal-category ops and expanded div/intdiv/rightShiftUnsigned.
+ */
+final class IndyMathTest {
+
+ @Test
+ void numberMath_plusMinusMultiply_matchDgm() {
+ assertEquals(2, IndyMath.plus(1, 1))
+ assertEquals(0, IndyMath.plus(new BigDecimal('1.5'), new BigDecimal('2.5'))
+ .compareTo(new BigDecimal('4.0')))
+ assertEquals(0, IndyMath.minus(new BigDecimal('5'), 2).compareTo(new BigDecimal('3')))
+ assertEquals(new BigInteger('12'), IndyMath.multiply(new BigInteger('3'), new BigInteger('4')))
+ // mixed integral + BD
+ assertEquals(0, IndyMath.plus(1, new BigDecimal('2.5')).compareTo(new BigDecimal('3.5')))
+ }
+
+ @Test
+ void numberMath_div_intUsesExactBigDecimal() {
+ Number q = IndyMath.div(1, 2)
+ assertTrue(q instanceof BigDecimal || q instanceof Number)
+ assertEquals(0, new BigDecimal('0.5').compareTo(new BigDecimal(q.toString())))
+ // double path remains binary FP
+ assertEquals(0.5d, IndyMath.div(1.0d, 2.0d), 0.0d)
+ }
+
+ @Test
+ void numberMath_intdiv_and_rightShiftUnsigned() {
+ assertEquals(3, IndyMath.intdiv(7, 2).intValue())
+ assertEquals(3L, IndyMath.intdiv(7L, 2L).longValue())
+ assertEquals(Integer.MAX_VALUE, IndyMath.rightShiftUnsigned(-1, 1))
+ assertEquals(-1L >>> 1, IndyMath.rightShiftUnsigned(-1L, 1L))
+ }
+
+ @Test
+ void generatedMetaMethodFastPath_excludesShiftOps() {
+ // Shift ops must not use widenOperators via GeneratedMetaMethod: mixed
+ // int/long pairs would change the left-operand result type.
+ assertTrue(IndyMath.isGeneratedMetaMethodFastPath('next'))
+ assertTrue(IndyMath.isGeneratedMetaMethodFastPath('previous'))
+ assertTrue(IndyMath.isGeneratedMetaMethodFastPath('intdiv'))
+ assertFalse(IndyMath.isGeneratedMetaMethodFastPath('rightShiftUnsigned'))
+ assertFalse(IndyMath.isGeneratedMetaMethodFastPath('rightShift'))
+ assertFalse(IndyMath.isGeneratedMetaMethodFastPath('leftShift'))
+ assertFalse(IndyMath.isGeneratedMetaMethodFastPath('plus'))
+ }
+
+ @Test
+ void numberMath_nextPrevious_bigDecimalCategory() {
+ assertEquals(new BigDecimal('3'), IndyMath.next(new BigDecimal('2')))
+ assertEquals(new BigDecimal('1'), IndyMath.previous(new BigDecimal('2')))
+ assertEquals(new BigInteger('11'), IndyMath.next(new BigInteger('10')))
+ }
+
+ @Test
+ void indy_dynamicMath_bigDecimalAndIntegral() {
+ assertScript '''
+ def a = 1.5G
+ def b = 2.5G
+ assert a + b == 4.0G
+ assert a * b == 3.75G
+ assert (5G - 2) == 3G
+ assert (1 + 2.5G) == 3.5G
+ // Groovy / on ints yields BigDecimal
+ assert (1 / 2) == 0.5
+ assert (7.intdiv(2)) == 3
+ assert (-1 >>> 1) == Integer.MAX_VALUE
+ assert 10G.next() == 11G
+ assert 10G.previous() == 9G
+ '''
+ }
+
+ @Test
+ void indy_dynamicMath_primitiveFastPathsUnchanged() {
+ assertScript '''
+ int i = 3
+ int j = 4
+ assert i + j == 7
+ assert i * j == 12
+ assert i - j == -1
+ long L = 5L
+ assert L + 2L == 7L
+ double x = 1.5d
+ assert x / 0.5d == 3.0d
+ assert i.next() == 4
+ assert j.previous() == 3
+ '''
+ }
+
+ @Test
+ void rightShiftUnsigned_mixedIntegral_keepsLeftOperandResultType() {
+ // GROOVY-12191: DGM rightShiftUnsigned must not be routed through IndyMath
+ // widenOperators for GeneratedMetaMethod — int >>> long stays int-typed.
+ assertScript '''
+ def left = -1
+ def distance = 1L
+ def r = left >>> distance
+ assert r == Integer.MAX_VALUE
+ assert r instanceof Integer
+
+ def sameWidth = (-1 >>> 1)
+ assert sameWidth == Integer.MAX_VALUE
+ assert sameWidth instanceof Integer
+
+ def longLeft = -1L >>> 1
+ assert longLeft == (-1L >>> 1)
+ assert longLeft instanceof Long
+ '''
+ }
+
+ @Test
+ void chooseMathMethod_coversRegisteredOps() {
+ // Smoke: static table has entries for expanded ops (lookup does not throw).
+ assertTrue(IndyMath.plus(1, 1) == 2)
+ assertTrue(IndyMath.div(2.0d, 4.0d) == 0.5d)
+ assertTrue(IndyMath.intdiv(9, 4).intValue() == 2)
+ assertTrue(IndyMath.rightShiftUnsigned(8, 1) == 4)
+ }
+
+ @Test
+ void isRegistered_coversExpandedAndUnknownOps() {
+ assertTrue(IndyMath.isRegistered('plus'))
+ assertTrue(IndyMath.isRegistered('minus'))
+ assertTrue(IndyMath.isRegistered('multiply'))
+ assertTrue(IndyMath.isRegistered('div'))
+ assertTrue(IndyMath.isRegistered('intdiv'))
+ assertTrue(IndyMath.isRegistered('next'))
+ assertTrue(IndyMath.isRegistered('previous'))
+ assertTrue(IndyMath.isRegistered('rightShiftUnsigned'))
+ assertTrue(IndyMath.isRegistered('leftShift'))
+ assertTrue(IndyMath.isRegistered('rightShift'))
+ assertTrue(IndyMath.isRegistered('and'))
+ assertTrue(IndyMath.isRegistered('or'))
+ assertTrue(IndyMath.isRegistered('xor'))
+ assertTrue(IndyMath.isRegistered('mod'))
+ assertTrue(IndyMath.isRegistered('remainder'))
+ assertFalse(IndyMath.isRegistered('power'))
+ assertFalse(IndyMath.isRegistered('abs'))
+ assertFalse(IndyMath.isRegistered(null))
+ assertFalse(IndyMath.isRegistered(''))
+ }
+}
diff --git a/src/test/java/org/apache/groovy/runtime/indy/BimorphicCallSiteTest.java b/src/test/java/org/apache/groovy/runtime/indy/BimorphicCallSiteTest.java
new file mode 100644
index 0000000000..31f74b496f
--- /dev/null
+++ b/src/test/java/org/apache/groovy/runtime/indy/BimorphicCallSiteTest.java
@@ -0,0 +1,192 @@
+/*
+ * 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 org.codehaus.groovy.vmplugin.v8.CacheableCallSite;
+import org.junit.jupiter.api.Test;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for bimorphic monomorphic-target promotion on {@link CacheableCallSite}.
+ */
+final class BimorphicCallSiteTest {
+
+ private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
+ private static final String KEY_A = "shape-A";
+ private static final String KEY_B = "shape-B";
+ private static final String KEY_C = "shape-C";
+
+ private static MethodHandle dropReceiverConstant(Object value) {
+ MethodHandle c = MethodHandles.constant(Object.class, value);
+ return MethodHandles.dropArguments(c, 0, Object.class);
+ }
+
+ private static CacheableCallSite newSite() {
+ MethodType type = MethodType.methodType(Object.class, Object.class);
+ CacheableCallSite site = new CacheableCallSite(type, LOOKUP);
+ MethodHandle fallback = dropReceiverConstant("FALLBACK");
+ MethodHandle def = dropReceiverConstant("DEFAULT");
+ site.setDefaultTarget(def);
+ site.setFallbackTarget(fallback);
+ site.setTarget(def);
+ return site;
+ }
+
+ @Test
+ void promoteFirstShapeIsMonomorphic() throws Throwable {
+ CacheableCallSite site = newSite();
+ TypeA a = new TypeA();
+ MethodHandle hA = dropReceiverConstant("A");
+ assertTrue(site.promoteTarget(KEY_A, a, hA));
+ assertEquals(1, site.promotedShapeCount());
+ assertSame(hA, site.getTarget());
+ assertEquals("A", site.getTarget().invokeWithArguments(a));
+ }
+
+ @Test
+ void promoteSecondShapeInstallsBimorphicWhenEnabled() throws Throwable {
+ if (!CacheableCallSite.isBimorphicEnabled()) {
+ return;
+ }
+ CacheableCallSite site = newSite();
+ TypeA a = new TypeA();
+ TypeB b = new TypeB();
+ MethodHandle hA = dropReceiverConstant("A");
+ MethodHandle hB = dropReceiverConstant("B");
+
+ assertTrue(site.promoteTarget(KEY_A, a, hA));
+ assertEquals(1, site.promotedShapeCount());
+
+ long biBefore = CacheableCallSite.bimorphicPromotionCount();
+ assertTrue(site.promoteTarget(KEY_B, b, hB));
+ assertEquals(2, site.promotedShapeCount());
+ assertEquals(biBefore + 1L, CacheableCallSite.bimorphicPromotionCount());
+ assertNotSame(hA, site.getTarget());
+ assertNotSame(hB, site.getTarget());
+
+ assertEquals("A", site.getTarget().invokeWithArguments(a));
+ assertEquals("B", site.getTarget().invokeWithArguments(b));
+ assertEquals("FALLBACK", site.getTarget().invokeWithArguments(new TypeC()));
+ }
+
+ @Test
+ void promoteThirdShapeDoesNotThrashTarget() throws Throwable {
+ if (!CacheableCallSite.isBimorphicEnabled()) {
+ return;
+ }
+ CacheableCallSite site = newSite();
+ MethodHandle hA = dropReceiverConstant("A");
+ MethodHandle hB = dropReceiverConstant("B");
+ MethodHandle hC = dropReceiverConstant("C");
+ site.promoteTarget(KEY_A, new TypeA(), hA);
+ site.promoteTarget(KEY_B, new TypeB(), hB);
+ MethodHandle bi = site.getTarget();
+ assertEquals(2, site.promotedShapeCount());
+
+ assertFalse(site.promoteTarget(KEY_C, new TypeC(), hC));
+ assertSame(bi, site.getTarget());
+ assertEquals(2, site.promotedShapeCount());
+ assertEquals("A", site.getTarget().invokeWithArguments(new TypeA()));
+ assertEquals("B", site.getTarget().invokeWithArguments(new TypeB()));
+ }
+
+ @Test
+ void promoteRefreshPrimaryUpdatesMonomorphicHandle() throws Throwable {
+ CacheableCallSite site = newSite();
+ MethodHandle hA1 = dropReceiverConstant("A1");
+ MethodHandle hA2 = dropReceiverConstant("A2");
+ assertTrue(site.promoteTarget(KEY_A, new TypeA(), hA1));
+ assertTrue(site.promoteTarget(KEY_A, new TypeA(), hA2));
+ assertEquals(1, site.promotedShapeCount());
+ assertSame(hA2, site.getTarget());
+ assertEquals("A2", site.getTarget().invokeWithArguments(new TypeA()));
+ }
+
+ @Test
+ void megaPinClearsPromotedShapes() {
+ CacheableCallSite site = newSite();
+ site.promoteTarget(KEY_A, new TypeA(), dropReceiverConstant("A"));
+ assertEquals(1, site.promotedShapeCount());
+ site.markMegamorphic();
+ assertEquals(0, site.promotedShapeCount());
+ assertSame(site.getDefaultTarget(), site.getTarget());
+ }
+
+ @Test
+ void promoteRefreshSecondaryUpdatesBimorphicArms() throws Throwable {
+ if (!CacheableCallSite.isBimorphicEnabled()) {
+ return;
+ }
+ CacheableCallSite site = newSite();
+ MethodHandle hA = dropReceiverConstant("A");
+ MethodHandle hB1 = dropReceiverConstant("B1");
+ MethodHandle hB2 = dropReceiverConstant("B2");
+ assertTrue(site.promoteTarget(KEY_A, new TypeA(), hA));
+ assertTrue(site.promoteTarget(KEY_B, new TypeB(), hB1));
+ assertEquals(2, site.promotedShapeCount());
+
+ assertTrue(site.promoteTarget(KEY_B, new TypeB(), hB2));
+ assertEquals(2, site.promotedShapeCount());
+ assertEquals("A", site.getTarget().invokeWithArguments(new TypeA()));
+ assertEquals("B2", site.getTarget().invokeWithArguments(new TypeB()));
+ }
+
+ @Test
+ void promoteRejectsNullKeyOrNullTarget() {
+ CacheableCallSite site = newSite();
+ MethodHandle hA = dropReceiverConstant("A");
+ assertFalse(site.promoteTarget(null, new TypeA(), hA));
+ assertFalse(site.promoteTarget(KEY_A, new TypeA(), null));
+ assertEquals(0, site.promotedShapeCount());
+ }
+
+ @Test
+ void promoteStaticClassReceiver_installsEqualsGuard() throws Throwable {
+ if (!CacheableCallSite.isBimorphicEnabled()) {
+ return;
+ }
+ CacheableCallSite site = newSite();
+ MethodHandle hA = dropReceiverConstant("static-A");
+ MethodHandle hB = dropReceiverConstant("static-B");
+ assertTrue(site.promoteTarget(KEY_A, TypeA.class, hA));
+ assertTrue(site.promoteTarget(KEY_B, TypeB.class, hB));
+ assertEquals(2, site.promotedShapeCount());
+ assertEquals("static-A", site.getTarget().invokeWithArguments(TypeA.class));
+ assertEquals("static-B", site.getTarget().invokeWithArguments(TypeB.class));
+ assertEquals("FALLBACK", site.getTarget().invokeWithArguments(TypeC.class));
+ }
+
+ private static final class TypeA {
+ }
+
+ private static final class TypeB {
+ }
+
+ private static final class TypeC {
+ }
+}
diff --git a/subprojects/performance/README.adoc b/subprojects/performance/README.adoc
index ad8ebc47ec..b09ee7fd53 100644
--- a/subprojects/performance/README.adoc
+++ b/subprojects/performance/README.adoc
@@ -132,7 +132,25 @@ away — where much real-world Groovy cost lives (scripts, CLIs, test
bootstrap). The `n` parameter probes below and past the indy promotion
threshold (`groovy.indy.optimize.threshold`, default 1000). Static and
instance dispatch take different cold paths (static sites promote on first
-hit, GROOVY-11935), hence the two benches.
+hit, GROOVY-11935), hence the two benches. `DynamicDispatchColdBench` also
+includes a bimorphic (`dynamicBi_*`) cold case for dual-shape promotion
+(GROOVY-12191).
+
+== Scoped invalidation and IndyMath (GROOVY-12191)
+
+`ScopedInvalidationBench` measures the dual SwitchPoint domains: per-class
+scoping (unrelated MetaClass churn must not deoptimize a hot monomorphic
+site) versus same-type re-link, category enter/leave, and hierarchy fan-out.
+It complements the Grails-oriented `CallSiteInvalidationBench` under
+`org.apache.groovy.perf.grails`.
+
+`IndyMathBench` covers IndyMath fast paths expanded in GROOVY-12191
+(primitive `plus`, BigDecimal / mixed integral+BD, integer `/`, `intdiv`,
+same-width `>>>`, `next`/`previous`) against Java and `@CompileStatic`
+baselines.
+
+`CallsiteBench` includes a bimorphic (2-type) row for the dual-shape
+call-site target promotion path.
== Dashboard pages
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchCold.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchCold.groovy
index 3849b63ca5..8f7460d3cc 100644
--- a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchCold.groovy
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchCold.groovy
@@ -54,6 +54,21 @@ class DynamicDispatchCold {
s
}
+ /**
+ * Bimorphic dynamic instance calls: two receiver types alternate at one
+ * call site (GROOVY-12191 dual-shape promotion regime).
+ */
+ int biSum(int n) {
+ def a = new Alpha()
+ def b = new Beta()
+ int s = 0
+ for (int i = 0; i < n; i++) {
+ def r = (i & 1) == 0 ? a : b
+ s += r.work(i)
+ }
+ s
+ }
+
/** Polymorphic dynamic instance calls: three receiver types rotate at one call site. */
int polySum(int n) {
def a = new Alpha()
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 c42c81ec39..c74192e2e5 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
@@ -83,6 +83,16 @@ public class DynamicDispatchColdBench {
return new DynamicDispatchCold().monoSum(n);
}
+ /**
+ * Bimorphic (2 receiver types) dynamic instance calls from a cold call site
+ * (GROOVY-12191 dual-shape promotion path).
+ * @return the computed sum
+ */
+ @Benchmark
+ public int dynamicBi_groovy() {
+ return new DynamicDispatchCold().biSum(n);
+ }
+
/**
* Polymorphic (3 receiver types) dynamic instance calls from a cold call site.
* @return the computed sum
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/IndyMathBench.java b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/IndyMathBench.java
new file mode 100644
index 0000000000..048cce5b5f
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/IndyMathBench.java
@@ -0,0 +1,256 @@
+/*
+ * 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.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.math.BigDecimal;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Benchmarks for invokedynamic math fast paths ({@code IndyMath}, GROOVY-12191).
+ * <p>
+ * Complements the broader operator coverage in {@code OperatorBench} with a
+ * focused set for the paths hardened in GROOVY-12191:
+ * <ul>
+ * <li>primitive int {@code plus} (baseline IndyMath path),</li>
+ * <li>pure {@link BigDecimal} and mixed integral+BD arithmetic via
+ * {@code NumberMath},</li>
+ * <li>integer {@code /} (exact {@link BigDecimal} quotient),</li>
+ * <li>{@code intdiv}, same-width {@code >>>}, and {@code next}/{@code previous}.</li>
+ * </ul>
+ * Java rows are direct-arithmetic lower bounds; Groovy dynamic rows measure
+ * the shipping indy math path; {@code @CompileStatic} is a direct-call bound
+ * for int addition.
+ *
+ * @see <a href="https://issues.apache.org/jira/browse/GROOVY-12191">GROOVY-12191</a>
+ */
+@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 IndyMathBench {
+
+ private static final int N = 100_000;
+
+ /**
+ * Java baseline: int addition loop.
+ * @return accumulated sum
+ */
+ @Benchmark
+ public int intPlus_java() {
+ return JavaMath.intPlusSum(N);
+ }
+
+ /**
+ * Groovy dynamic: int addition via IndyMath primitive fast path.
+ * @return accumulated sum
+ */
+ @Benchmark
+ public int intPlus_groovy() {
+ return IndyMathWorkload.intPlusSum(N);
+ }
+
+ /**
+ * Groovy {@code @CompileStatic}: int addition.
+ * @return accumulated sum
+ */
+ @Benchmark
+ public int intPlus_groovyCS() {
+ return IndyMathWorkload.intPlusSumCS(N);
+ }
+
+ /**
+ * Java baseline: pure BigDecimal addition.
+ * @return accumulated sum
+ */
+ @Benchmark
+ public BigDecimal bigDecimalPlus_java() {
+ return JavaMath.bigDecimalPlusSum(N);
+ }
+
+ /**
+ * Groovy dynamic: pure BigDecimal addition (NumberMath IndyMath path).
+ * @return accumulated sum
+ */
+ @Benchmark
+ public BigDecimal bigDecimalPlus_groovy() {
+ return IndyMathWorkload.bigDecimalPlusSum(N);
+ }
+
+ /**
+ * Java baseline: mixed int + BigDecimal accumulation.
+ * @return accumulated sum
+ */
+ @Benchmark
+ public BigDecimal mixedIntegralBigDecimalPlus_java() {
+ return JavaMath.mixedIntegralBigDecimalPlusSum(N);
+ }
+
+ /**
+ * Groovy dynamic: mixed integral + BigDecimal (widened NumberMath path).
+ * @return accumulated sum
+ */
+ @Benchmark
+ public BigDecimal mixedIntegralBigDecimalPlus_groovy() {
+ return IndyMathWorkload.mixedIntegralBigDecimalPlusSum(N);
+ }
+
+ /**
+ * Java baseline: integer division producing BigDecimal via BigDecimal API.
+ * @return last quotient
+ */
+ @Benchmark
+ public BigDecimal intDiv_java() {
+ return JavaMath.intDivLoop(N);
+ }
+
+ /**
+ * Groovy dynamic: integer {@code /} (exact BigDecimal via IndyMath.div).
+ * @return last quotient
+ */
+ @Benchmark
+ public Number intDiv_groovy() {
+ return IndyMathWorkload.intDivLoop(N);
+ }
+
+ /**
+ * Java baseline: integer division (truncating).
+ * @return accumulated quotients
+ */
+ @Benchmark
+ public int intdiv_java() {
+ return JavaMath.intdivSum(N);
+ }
+
+ /**
+ * Groovy dynamic: {@code intdiv} (IndyMath / NumberMath).
+ * @return accumulated quotients
+ */
+ @Benchmark
+ public int intdiv_groovy() {
+ return IndyMathWorkload.intdivSum(N);
+ }
+
+ /**
+ * Java baseline: unsigned right-shift.
+ * @return accumulated shifted values
+ */
+ @Benchmark
+ public int rightShiftUnsigned_java() {
+ return JavaMath.rightShiftUnsignedSum(N);
+ }
+
+ /**
+ * Groovy dynamic: same-width {@code >>>} (IndyMath rightShiftUnsigned).
+ * @return accumulated shifted values
+ */
+ @Benchmark
+ public int rightShiftUnsigned_groovy() {
+ return IndyMathWorkload.rightShiftUnsignedSum(N);
+ }
+
+ /**
+ * Java baseline: increment / decrement.
+ * @return accumulated next/previous values
+ */
+ @Benchmark
+ public int nextPrevious_java() {
+ return JavaMath.nextPreviousSum(N);
+ }
+
+ /**
+ * Groovy dynamic: {@code next}/{@code previous} (IndyMath fast path).
+ * @return accumulated next/previous values
+ */
+ @Benchmark
+ public int nextPrevious_groovy() {
+ return IndyMathWorkload.nextPreviousSum(N);
+ }
+
+ private static final class JavaMath {
+ static int intPlusSum(int n) {
+ int sum = 0;
+ for (int i = 0; i < n; i++) {
+ sum = sum + i;
+ }
+ return sum;
+ }
+
+ static BigDecimal bigDecimalPlusSum(int n) {
+ BigDecimal sum = BigDecimal.ZERO;
+ BigDecimal step = new BigDecimal("1.5");
+ for (int i = 0; i < n; i++) {
+ sum = sum.add(step);
+ }
+ return sum;
+ }
+
+ static BigDecimal mixedIntegralBigDecimalPlusSum(int n) {
+ BigDecimal sum = BigDecimal.ZERO;
+ for (int i = 0; i < n; i++) {
+ sum = sum.add(BigDecimal.valueOf(i));
+ }
+ return sum;
+ }
+
+ static BigDecimal intDivLoop(int n) {
+ BigDecimal last = BigDecimal.ZERO;
+ for (int i = 1; i <= n; i++) {
+ last = BigDecimal.valueOf(i).divide(BigDecimal.valueOf(2));
+ }
+ return last;
+ }
+
+ static int intdivSum(int n) {
+ int sum = 0;
+ for (int i = 1; i <= n; i++) {
+ sum += i / 3;
+ }
+ return sum;
+ }
+
+ static int rightShiftUnsignedSum(int n) {
+ int sum = 0;
+ for (int i = 0; i < n; i++) {
+ sum += (i >>> 1);
+ }
+ return sum;
+ }
+
+ static int nextPreviousSum(int n) {
+ int sum = 0;
+ for (int i = 0; i < n; i++) {
+ sum += (i + 1);
+ sum += (i - 1);
+ }
+ return sum;
+ }
+ }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/IndyMathWorkload.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/IndyMathWorkload.groovy
new file mode 100644
index 0000000000..4a60adeac9
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/IndyMathWorkload.groovy
@@ -0,0 +1,139 @@
+/*
+ * 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 groovy.transform.CompileStatic
+
+/**
+ * Dynamic and {@code @CompileStatic} math workloads for {@link IndyMathBench}.
+ * <p>
+ * Dynamic methods emit {@code invokedynamic} call sites that route through
+ * {@code org.codehaus.groovy.vmplugin.v8.IndyMath} when a fast path is available
+ * (primitive int/long/double, BigDecimal-category via {@code NumberMath},
+ * {@code intdiv}, and same-width {@code >>>}).
+ */
+class IndyMathWorkload {
+
+ /**
+ * Tight int addition loop (primitive IndyMath {@code plus} fast path).
+ * @param n iteration count
+ * @return accumulated sum
+ */
+ static int intPlusSum(int n) {
+ int sum = 0
+ for (int i = 0; i < n; i++) {
+ sum = sum + i
+ }
+ sum
+ }
+
+ /**
+ * Tight int addition under {@code @CompileStatic} (direct arithmetic).
+ * @param n iteration count
+ * @return accumulated sum
+ */
+ @CompileStatic
+ static int intPlusSumCS(int n) {
+ int sum = 0
+ for (int i = 0; i < n; i++) {
+ sum = sum + i
+ }
+ sum
+ }
+
+ /**
+ * Pure {@link BigDecimal} addition (IndyMath NumberMath {@code plus} path).
+ * @param n iteration count
+ * @return accumulated sum
+ */
+ static BigDecimal bigDecimalPlusSum(int n) {
+ BigDecimal sum = 0.0G
+ BigDecimal step = 1.5G
+ for (int i = 0; i < n; i++) {
+ sum = sum + step
+ }
+ sum
+ }
+
+ /**
+ * Mixed integral + {@link BigDecimal} addition (widened NumberMath path).
+ * @param n iteration count
+ * @return accumulated sum
+ */
+ static BigDecimal mixedIntegralBigDecimalPlusSum(int n) {
+ def sum = 0.0G
+ for (int i = 0; i < n; i++) {
+ sum = sum + i
+ }
+ (BigDecimal) sum
+ }
+
+ /**
+ * Integer {@code /} division yielding {@link BigDecimal} (IndyMath {@code div}).
+ * @param n iteration count
+ * @return last quotient (for blackhole)
+ */
+ static Number intDivLoop(int n) {
+ Number last = 0
+ for (int i = 1; i <= n; i++) {
+ last = i / 2
+ }
+ last
+ }
+
+ /**
+ * Groovy {@code intdiv} on ints (IndyMath {@code intdiv} / NumberMath).
+ * @param n iteration count
+ * @return accumulated integer quotients
+ */
+ static int intdivSum(int n) {
+ int sum = 0
+ for (int i = 1; i <= n; i++) {
+ sum += i.intdiv(3)
+ }
+ sum
+ }
+
+ /**
+ * Unsigned right-shift same-width int (IndyMath {@code rightShiftUnsigned}).
+ * @param n iteration count
+ * @return accumulated shifted values
+ */
+ static int rightShiftUnsignedSum(int n) {
+ int sum = 0
+ for (int i = 0; i < n; i++) {
+ sum += (i >>> 1)
+ }
+ sum
+ }
+
+ /**
+ * Unary {@code next}/{@code previous} on ints (IndyMath fast path).
+ * @param n iteration count
+ * @return accumulated next values
+ */
+ static int nextPreviousSum(int n) {
+ int sum = 0
+ for (int i = 0; i < n; i++) {
+ sum += i.next()
+ sum += i.previous()
+ }
+ sum
+ }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy
new file mode 100644
index 0000000000..7396dd6be1
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy
@@ -0,0 +1,243 @@
+/*
+ * 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 groovy.lang.GroovySystem
+
+import org.openjdk.jmh.annotations.Benchmark
+import org.openjdk.jmh.annotations.BenchmarkMode
+import org.openjdk.jmh.annotations.Fork
+import org.openjdk.jmh.annotations.Level
+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 org.openjdk.jmh.infra.Blackhole
+
+import java.util.concurrent.TimeUnit
+
+/**
+ * Steady-state cost of scoped invokedynamic SwitchPoint invalidation (GROOVY-12191).
+ * <p>
+ * Complements the Grails-oriented {@code CallSiteInvalidationBench} with a
+ * focused regression set for the dual SwitchPoint domains:
+ * <ul>
+ * <li><b>Per-class</b> — MetaClass changes on an unrelated type must not
+ * deoptimize a hot monomorphic site;</li>
+ * <li><b>Same-type</b> — MetaClass changes on the hot type must re-link;</li>
+ * <li><b>Category-global</b> — {@code use(Category)} enter/leave still forces
+ * re-link so category methods become visible;</li>
+ * <li><b>Hierarchy fan-out</b> — parent MetaClass changes invalidate
+ * subclass-linked sites.</li>
+ * </ul>
+ * Compare {@link #hotLoop_unrelatedMetaClassChurn} against
+ * {@link #hotLoop_baseline} (should stay close after scoping) and against
+ * {@link #hotLoop_sameTypeMetaClassChurn} (must pay re-link cost).
+ *
+ * @see <a href="https://issues.apache.org/jira/browse/GROOVY-12191">GROOVY-12191</a>
+ */
+@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)
+class ScopedInvalidationBench {
+
+ /** Iterations of the hot call loop per benchmark invocation. */
+ static final int ITERATIONS = 100_000
+
+ /** How often to perform a MetaClass mutation inside the churn loops. */
+ static final int CHURN_EVERY = 1_000
+
+ /**
+ * Hot monomorphic call-site receiver.
+ */
+ static class Hot {
+ int value = 42
+ int compute() { value * 2 }
+ }
+
+ /**
+ * Unrelated type used only for cross-type MetaClass churn.
+ */
+ static class Unrelated {
+ String label = 'cold'
+ }
+
+ /**
+ * Parent of {@link HierChild} for hierarchy fan-out churn.
+ */
+ static class HierParent {
+ int base() { 1 }
+ }
+
+ /**
+ * Subclass receiver whose SwitchPoint must clear when the parent MetaClass changes.
+ */
+ static class HierChild extends HierParent {
+ int leaf() { 2 }
+ }
+
+ /**
+ * Category that decorates {@link Hot#compute()}.
+ */
+ static class HotCategory {
+ static int compute(Hot self) { self.value * 3 }
+ }
+
+ Hot hot
+ HierChild child
+
+ /**
+ * Resets MetaClasses and allocates fresh receivers each iteration so
+ * SwitchPoint state from a previous sample does not bleed into the next.
+ */
+ @Setup(Level.Iteration)
+ void setup() {
+ GroovySystem.metaClassRegistry.removeMetaClass(Hot)
+ GroovySystem.metaClassRegistry.removeMetaClass(Unrelated)
+ GroovySystem.metaClassRegistry.removeMetaClass(HierParent)
+ GroovySystem.metaClassRegistry.removeMetaClass(HierChild)
+ hot = new Hot()
+ child = new HierChild()
+ }
+
+ /**
+ * Steady-state monomorphic calls with no MetaClass mutation.
+ * @param bh blackhole for the accumulated result
+ */
+ @Benchmark
+ void hotLoop_baseline(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += hot.compute()
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Hot monomorphic loop with MetaClass changes on an <em>unrelated</em> type.
+ * With scoped invalidation the hot site should keep its linked target;
+ * residual cost vs baseline is mostly MetaClass mutation work, not deopt.
+ * Prefer {@link #hotLoop_afterUnrelatedBurst} for a pure no-deopt check.
+ * @param bh blackhole for the accumulated result
+ */
+ @Benchmark
+ void hotLoop_unrelatedMetaClassChurn(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += hot.compute()
+ if (i % CHURN_EVERY == 0) {
+ Unrelated.metaClass."dyn${i % 5}" = { -> i }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Burst of unrelated MetaClass changes, then a pure hot loop.
+ * After scoping (GROOVY-12191), this should approach {@link #hotLoop_baseline}.
+ * @param bh blackhole for the accumulated result
+ */
+ @Benchmark
+ void hotLoop_afterUnrelatedBurst(Blackhole bh) {
+ for (int i = 0; i < 50; i++) {
+ Unrelated.metaClass."startup${i % 10}" = { -> i }
+ }
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += hot.compute()
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Hot monomorphic loop with MetaClass changes on the <em>same</em> type —
+ * each change must retire the class-domain SwitchPoint and re-link.
+ * @param bh blackhole for the accumulated result
+ */
+ @Benchmark
+ void hotLoop_sameTypeMetaClassChurn(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += hot.compute()
+ if (i % CHURN_EVERY == 0) {
+ Hot.metaClass."dyn${i % 5}" = { -> i }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Category enter/leave interleaved with hot calls — exercises the
+ * process-wide category SwitchPoint domain (never fails over).
+ * Uses a modest number of {@code use} blocks so category re-link cost
+ * is visible without dominating wall-clock.
+ * @param bh blackhole for the accumulated result
+ */
+ @Benchmark
+ void hotLoop_categoryEnterLeave(Blackhole bh) {
+ int sum = 0
+ int callsPerUse = ITERATIONS / 10
+ for (int b = 0; b < 10; b++) {
+ use(HotCategory) {
+ for (int i = 0; i < callsPerUse; i++) {
+ sum += hot.compute()
+ }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Subclass receiver calls with periodic parent MetaClass changes (hierarchy
+ * fan-out must invalidate the child SwitchPoint).
+ * @param bh blackhole for the accumulated result
+ */
+ @Benchmark
+ void hierarchy_parentMetaClassChurn(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += child.leaf()
+ sum += child.base()
+ if (i % CHURN_EVERY == 0) {
+ HierParent.metaClass."dyn${i % 5}" = { -> i }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Subclass receiver calls with no MetaClass mutation (hierarchy baseline).
+ * @param bh blackhole for the accumulated result
+ */
+ @Benchmark
+ void hierarchy_baseline(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += child.leaf()
+ sum += child.base()
+ }
+ bh.consume(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 1a670bae1c..d7a1ae591e 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
@@ -26,8 +26,13 @@ import java.util.Random;
import java.util.concurrent.TimeUnit;
/**
- * Benchmark comparing dispatch performance under monomorphic, polymorphic,
- * and megamorphic call site conditions for Java, Groovy, and Groovy {@code @CompileStatic}.
+ * Benchmark comparing dispatch performance under monomorphic, bimorphic,
+ * polymorphic, and megamorphic call site conditions for Java, Groovy, and
+ * Groovy {@code @CompileStatic}.
+ * <p>
+ * Bimorphic (2 receiver types) exercises the dual-shape call-site target
+ * promotion path (GROOVY-12191); monomorphic / polymorphic / megamorphic
+ * remain the primary regression baselines for PIC width and sticky mega policy.
*/
@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
@@ -81,6 +86,52 @@ public class CallsiteBench {
JavaDispatch.dispatch(state.receivers, bh);
}
+ /**
+ * Bimorphic dispatch (2 types) via Groovy dynamic — the dual-shape
+ * promotion path introduced for GROOVY-12191 ({@code CacheableCallSite}
+ * nested {@code guardWithTest} targets when
+ * {@code groovy.indy.callsite.bimorphic} is enabled, the default).
+ * @param state the bimorphic receiver state
+ * @param bh the blackhole for consuming results
+ */
+ @Benchmark
+ public void dispatch_2_bimorphic_groovy(BimorphicState state, Blackhole bh) {
+ Callsite.dispatch(state.receivers, bh);
+ }
+
+ /**
+ * Bimorphic dispatch (2 types) via Groovy dynamic with the reflective cold
+ * tier explicitly disabled (GROOVY-12137) — disabled baseline against
+ * {@link #dispatch_2_bimorphic_groovy}.
+ * @param state the bimorphic receiver state
+ * @param bh the blackhole for consuming results
+ */
+ @Benchmark
+ @Fork(value = 2, jvmArgsAppend = "-Dgroovy.indy.cold.reflection=false")
+ public void dispatch_2_bimorphic_groovyColdReflect(BimorphicState state, Blackhole bh) {
+ Callsite.dispatch(state.receivers, bh);
+ }
+
+ /**
+ * Bimorphic dispatch (2 types) via Groovy {@code @CompileStatic}.
+ * @param state the bimorphic receiver state
+ * @param bh the blackhole for consuming results
+ */
+ @Benchmark
+ public void dispatch_2_bimorphic_groovyCS(BimorphicState state, Blackhole bh) {
+ Callsite.dispatchCS(state.receivers, bh);
+ }
+
+ /**
+ * Bimorphic dispatch (2 types) via Java baseline.
+ * @param state the bimorphic receiver state
+ * @param bh the blackhole for consuming results
+ */
+ @Benchmark
+ public void dispatch_2_bimorphic_java(BimorphicState state, Blackhole bh) {
+ JavaDispatch.dispatch(state.receivers, bh);
+ }
+
/**
* Polymorphic dispatch (3 types) via Groovy dynamic.
* @param state the polymorphic receiver state
@@ -191,6 +242,34 @@ public class CallsiteBench {
}
}
+ /**
+ * State holder for bimorphic (2 types) call sites — the dual-shape regime
+ * between monomorphic promotion and megamorphic sticky policy (GROOVY-12191).
+ */
+ @State(Scope.Thread)
+ public static class BimorphicState {
+ /**
+ * Source of randomized receiver selection for bimorphic dispatch.
+ */
+ final Random random = new Random();
+
+ /**
+ * Receiver array containing a mix of two target types.
+ */
+ Object[] receivers;
+
+ /**
+ * Rebuilds the receiver array for a two-type bimorphic distribution.
+ */
+ @Setup(Level.Iteration)
+ public void setUp() {
+ receivers = new Object[RECEIVER_COUNT];
+ for (int i = 0; i < RECEIVER_COUNT; i++) {
+ receivers[i] = RECEIVERS[random.nextInt(2)];
+ }
+ }
+ }
+
/**
* State holder for polymorphic (3 types) call sites.
*/