Thanks Jochen,

Some AI comments below in two parts.

First asking for an analysis and some suggestions on the codebase as is.

Second commenting on GROOVY-12023 and GROOVY-12065.

I'll go ahead with the extra benchmark tests (W0).

Cheers, Paul.

--------
--------

## Diagnosis: where the cold-path cost actually is

The three explorations confirm and sharpen Jochen's analysis. On
master, a single dynamic instance call pays, in its first ~1000
invocations:

1. **Bootstrap** builds a per-site boot handle from `insertArguments`
+ `foldArguments(exactInvoker)` + `asCollector` + `asType`
(`IndyInterface.java:271-299`) — 4 combinator layers whose LambdaForms
are keyed by the site's `MethodType`.
2. **First call (selection)** builds ~7 more layers around the target:
`explicitCastArguments` (always, `Selector.java:1224`),
`catchException`, up to three `guardWithTest`s (metaclass identity,
SwitchPoint, argument classes), `asSpreader` + `asType`
(`IndyInterface.java:469`). Roughly **11 combinator layers** traversed
per cold call.
3. **Every PIC miss re-runs `Lookup.unreflect`**
(`Selector.java:819`). There is no per-`CachedMethod` handle cache —
`CachedMethod` softly caches the *old-style* callsite constructors but
not `MethodHandle`s. Fifty call sites invoking `String.size()` pay
fifty unreflects and fifty guard-chain constructions.
4. **Every call site has a unique, fully specialized `MethodType`** —
`InvokeDynamicWriter` emits `(ReceiverStaticType, Arg1StaticType,
…)Object` descriptors (`InvokeDynamicWriter.java:128,216,268`).
LambdaForm creation cost is per *shape* (MethodType × combinator
structure), so this specialization multiplies the one-time cost across
the whole codebase instead of amortizing it.
5. The **cold tier lasts 1000 calls per site**
(`groovy.indy.optimize.threshold`), and during it every call runs the
folded `exactInvoker` path — statics got early promotion in
GROOVY-11935, instance calls didn't.

Meanwhile post-JEP-416 reflection is fast cold precisely because the
whole JVM shares a handful of per-arity LambdaForms. Groovy's design
is the opposite: maximal shape diversity, paid up front, per site.

## The plan — four workstreams

### W0 (do first): a noise-free cold-path metric

CI-runner timing noise is drowning your signal, but there's a metric
that is *deterministic*: **the number of LambdaForm/hidden classes
spun during a canonical workload**. Count
`LambdaForm$MH`/`LambdaForm$DMH`/hidden class definitions (JFR
`jdk.ClassLoad`, or a `-verbose:class` parse, or loaded-class-count
deltas) while running a fixed script corpus. Every improvement in
W1–W3 shows up as a monotone drop in that counter, immune to runner
variance. Add alongside it:

- an **instance-dispatch cold bench** (clone of
`StaticMethodCallIndyColdBench`, which is currently the *only*
single-shot bench, and it only covers statics),
- an **end-to-end "fresh JVM runs a realistic script" bench** — that's
the number users actually feel, and none of the 30-odd JMH benches
measures it.

This is a week of work and it de-risks everything below.

### W1: contained wins on the current architecture

1. **Per-`CachedMethod` MethodHandle cache** (Jochen's "receiver
view", minimal form). Cache the unreflected handle on `CachedMethod`
via `SoftReference`, mirroring the existing
`pogo/pojo/staticCallSiteConstructor` pattern. The subtlety:
`unreflect` goes through the caller lookup, so the cached handle is
caller-independent only for public methods on public, accessible
classes and **never for `@CallerSensitive` methods** (unreflect binds
the lookup class into those). Gate the cache on `isPublic(method) &&
isPublic(declaringClass)` plus a caller-sensitivity check
(annotation-name probe with a "don't cache" fallback). The
`GeneratedMetaMethod.getTargetMethodHandle()` mechanism from
GROOVY-12069 is the precedent — this extends the same idea from
generated DGMs to all methods.
2. **Adaptive/early promotion for instance sites.** GROOVY-11935
promotes statics on first hit; instance sites wait 1000 calls. A
monomorphic instance site whose PIC key repeats could promote after a
handful of hits — the guard chain already protects correctness and
`groovy.indy.fallback.cutoff` already protects against deopt storms.
Cheap experiment: sweep `groovy.indy.optimize.threshold` with the cold
bench, then implement "promote early once the same wrapper hits k
times".
3. **Adapter-layer audit in `Selector`.** Make `explicitCastArguments`
conditional on the types actually differing; check whether
`catchException` can be skipped for more cases than the current
number-method/DGM exceptions; order guards cheapest-first. Each layer
removed is one fewer LambdaForm shape per site.

### W2: collapse MethodType shape diversity

Change `InvokeDynamicWriter` to erase **reference types to `Object`**
in indy descriptors while keeping primitives (boxing avoidance) and
the `Wrapper` cast-marker (semantics). Selection is driven by
*runtime* receiver class anyway, so static reference-type
specialization buys almost nothing at selection time — but it's what
makes every site's boot handle, guard chain, and
`explicitCastArguments` a novel shape. Post-erasure, all
`(Object,Object)Object`-shaped sites share LambdaForms JVM-wide. Old
compiled classes keep working because they call the same bootstrap
with their old descriptors. Groovy 6.0 is exactly the release where a
bytecode-format change like this is possible. Measure with the W0
counter — I'd expect this to be the single biggest LambdaForm-count
reduction.

### W3: reflective cold tier (the architecture change Jochen is circling)

Restructure the cold tier so it **doesn't build MethodHandle chains at
all**: the boot target becomes a plain-Java dispatcher (`asCollector`
to `Object[]` is the only MH machinery), the PIC caches the selected
`MetaMethod` plus cheap plain-Java validity checks (metaclass
identity, SwitchPoint validity, category flag — all trivially
checkable outside MH guards), and invocation during the cold phase
goes through `CachedMethod.invoke` → `Method.invoke`, i.e. the
reflection path Jochen measured as 10–15× faster cold. Only at
promotion does the full guarded handle chain get built and `setTarget`
— so the hot path is *byte-for-byte unchanged*.

This is "a Handle calling reflection" from his mail, made concrete.
Caveats to design in: caller-sensitive and JPMS-inaccessible targets
must bypass the reflective tier and use the existing unreflect path
(the `Java9.transformMetaMethod` machinery already identifies the
inaccessible cases); exception unwrapping semantics must match
`catchException(GroovyRuntimeException, …)`. Build it behind
`groovy.indy.cold.reflection` so both tiers ship in 6.0 betas and the
daily indy/classic dashboards can grow a third series to compare. If
it proves out, it also *shrinks* the pressure on W1.2 — a cheap cold
tier makes the 1000-call threshold much less painful.

### W4: stop fighting the one-time cost — cache it across runs

Jochen is right that he's fighting unwinnable JVM internals *within a
single JVM run*. But Leyden ships exactly the counter-tool: the **AOT
cache (JEP 483/514/515, JDK 24+)** archives loaded classes, resolved
constant pool state, and profiles from a training run — including spun
LambdaForms. For Grails apps, Gradle builds, and test suites (the
workloads where Groovy cold cost hurts most), a training-run +
AOT-cache recipe may recover more wall-clock time than any runtime
cleverness. Concretely: verify Groovy's generated classes and indy
bootstraps are AOT-cache-friendly, measure `groovy run` / Grails
startup with and without, document it, and consider a `groovy`
launcher flag. Low risk, doesn't touch dispatch code, and it compounds
with W1–W3.

### Track 2 (parallel, correctness not speed): hidden classes + JPMS

Replace `CallSiteGenerator`/`ClassLoaderForClassArtifacts.defineClass`
and `DgmConverter` loading with `defineHiddenClass(..., NESTMATE)` —
Java 17 baseline makes this unconditional now. As Jochen says, this
won't gain performance, but it makes generated code live in the right
module/nest for the JPMS story, and hidden classes are unloadable and
AOT-friendlier. Sensible to pair with whatever module-info decisions
Groovy 6 makes (master is still automatic-modules-only, no
`module-info.java` anywhere).

## Suggested sequencing

W0 first (it's cheap and everything else needs it) → W1.1 and W1.2
immediately after (contained, high confidence, measurable) → W2 as a
6.0-beta bytecode change with the counter as evidence → W3 prototyped
behind the flag in parallel, promoted only if the dashboards agree →
W4 as documentation/tooling work anyone can pick up independently.
Nothing here blocks beta-1; W2 is the only one that wants to land
before a format freeze.

If useful next steps: I can draft the dev@ thread summarizing this for
Jochen's discussion, or start directly on W1.1 (the `CachedMethod`
handle cache) and the W0 instance-cold benchmark — both are
well-bounded first commits.

--------
--------

I've read both PRs in depth. Here's the assessment and how they slot
into the roadmap.

## PR 2549 — Jochen's GROOVY-12023 indy cache rework

**What it actually does** (the title undersells it — this is a
redesign of the call-site lifecycle):

1. **A real PIC chain in the call-site target.** Up to 4
(`groovy.indy.pic.size`) class-guarded handles chained via
`guardWithTest` and installed with `setTarget`, with **one** top-level
SwitchPoint guard around the whole chain (each link is built with
`skipSwitchPoint = true`). On master, only a *monomorphic* winner ever
gets `setTarget`; polymorphic sites bounce through the folded
`exactInvoker` path forever. This is what produces the 7.06× on
`dispatch_3_polymorphic_groovy` and 2.35× on megamorphic in the PR's
benchmark run — and unlike PR 2591's alert (see below), those deltas
are credible because they're exactly what a PIC chain should deliver
and the alert lists only Groovy benchmarks.
2. **Lock-free MRU entry + identity keys.** The synchronized LRU map
moves to level 3; the common repeat-hit goes through a volatile
single-entry check. Cache keys become `receiver.getClass()` /
`ClassValue` objects instead of concatenated class-name Strings —
killing per-call String allocation and hashing in the cold tier.
3. **Degraded mode for metaclass churn.** After 10 fallback rounds,
the site abandons the SwitchPoint and installs a class-guarded handle
dispatching through `InvokerHelper.getMetaClass().invokeMethod()`.
This is significant beyond Grails-churn relief: **`invokeDegraded` is
the first working instance of "a MethodHandle calling the
MOP/reflection" from his mail** — the exact building block workstream
W3 (reflective cold tier) needs, just triggered by churn instead of
coldness.
4. **Cold-path trims that overlap my W1.** `catchException` becomes
conditional (only GroovyObject MOP methods — resolving the old "TODO:
save this guard" in `Selector`), the boot handle binds 4 arguments
instead of 8 (flags move into `CacheableCallSite`), wrappers carry
their SwitchPoint so stale entries self-evict, the polling
cache-cleaner becomes a proper `ReferenceQueue`, and there's an MRU
classloader-leak guard. Plus `DefaultTypeTransformation` hot/cold
method splitting for inline-budget friendliness and a cleaner
`findStatic`-in-`<clinit>` DGM `TARGET`.

**Concerns to raise in review:**
- `invokeDegraded`/`getPropertyDegraded` **return** the caught
`GroovyRuntimeException` as the call result (`return e`). The comment
claims parity with `catchException + UNWRAP_EXCEPTION`, but master's
unwrap rethrows the cause — returning the exception object to the
caller would be a semantic change. Worth a targeted test (MOP methods
using exceptions for control flow, e.g. `MissingMethodException`
fallthrough).
- In degraded mode, `fromCacheHandle` rebuilds the degraded handle
(plus `asSpreader`/`asType`) on every invocation that reaches it, with
no caching; and polymorphic degraded sites `setTarget` per
receiver-class change. Probably rare in practice, but it's per-call MH
construction on a path chosen *because* the site is unstable.
- `picInsertIfMissing` doesn't insert anything — it's a containment
check; the name will mislead maintainers.
- It's ~73+ commits behind master and will conflict with GROOVY-12092
(the cleaner-thread flag touches code this PR replaces). The 4 failing
JDK-25 tests are `ListMultimap`/Guava resolution failures in the test
environment — almost certainly unrelated flakes, but should be
confirmed on rebase.

**Verdict:** this is the highest-value open work on the cold/dispatch
problem and substantially delivers workstream W1 while adding a
hot-path polymorphic win my plan didn't claim. Path to merge: rebase,
describe the PR (body is empty), address the exception-semantics
question, rerun full suites + the cold bench. Importantly, it does
**not** cover: per-`CachedMethod` unreflect caching
(`Selector.setBaseHandleForCachedMethod` still unreflects on every
miss), MethodType erasure (W2), a coldness-triggered reflective tier
(W3 proper), or AOT (W4) — so the roadmap stands, with W3 now best
framed as *generalizing this PR's degraded-mode machinery*: use an
`invokeDegraded`-style MOP handle as every site's initial cold target,
and promote to selector-built handles at threshold.

## PR 2591 — Daniel's GROOVY-12065 peephole optimizer

A 737-line `PeepholeOptimizingMethodVisitor` wired into
`AsmClassGenerator` that compacts constant loads (`LDC` →
`ICONST_*`/`BIPUSH`/`SIPUSH`/`LCONST_*`/`FCONST_*`/`DCONST_*`,
correctly preserving `-0.0f`/`-0.0d`), and simplifies `OperandStack`
by routing constants through `visitLdcInsn` (−51 lines). Tests pass,
coverage 94%, review comments are minor (Copilot's `visitCode` pairing
nit; Eric's operand-stack-type question answered by Jochen — ASM
handles it).

**Honest sizing of the win:** compacted constants shrink method
bytecode, and HotSpot's inlining thresholds (`MaxInlineSize`=35
bytecode bytes) count bytes — so this genuinely helps small generated
methods near inline cliffs, plus class-file size. But it's a marginal
steady-state win, not a dispatch fix. The PR's benchmark alert
claiming 1.6–5.4× improvements is **provably runner noise**: pure-Java
baselines (`dispatch_8_megamorphic_java` 5.37×, `staticFib_java`
1.90×) "improved" too, which a Groovy codegen change cannot do.

**Verdict:** low-risk, merge-worthy after the minor comments; its real
strategic value is the *infrastructure* — a peephole pass is where
future wins live (dead-store elimination, box/unbox pair elimination,
`DUP`/`POP` cleanup). Suggest adding a deterministic bytecode-size
metric (total bytes for a fixed compile corpus) to the compiler
dashboard so this class of change gets a noise-free signal.

## One cross-cutting finding

Both PRs' comment threads expose the same measurement problem: the
per-PR JMH comparison fetches gh-pages history from *different runner
hardware*, so it flagged impossible 2–5× swings on pure-Java
benchmarks. That makes per-PR benchmark gating actively misleading
today. Cheap fix that strengthens W0: have the PR workflow build and
run **master HEAD and the PR head in the same job on the same runner**
and compare within-run, instead of comparing against historical
`data.js` — plus the LambdaForm-spin counter, which would have cleanly
quantified 2549's cold-path effect and shown 2591 as neutral.

**Bottom line:** prioritize getting 2549 rebased and reviewed (it's
~60% of W1 plus a polymorphic hot-path win, and its degraded mode is
the seed of W3); land 2591 as an independent low-risk improvement; fix
the per-PR benchmark comparison before using it to judge either.

On Sun, Jul 5, 2026 at 6:56 AM Jochen Theodorou <[email protected]> wrote:
>
> Hi Paul,
>
> I am still trying to find the key to making indy performing better on
> cold calls (and in general). Something that seems to be a real uphill
> battle.
>
> Maybe a small extract of things I concluded so far...
>
> Reflection:
> * Reflection in newer Java uses MethodHandles and is very fast
> * Reflection will get us no where in the problem area of JPMS or
> @CallerSensitive. That said, this is for a central invoke style
> invocation like MetaClass.invoke or InvokerHelper.invoke. There might be
> ways around.
> * Reflection is fast for the cold path because they use only very few
> LambdaForms in the end
>
> MethodHandles:
> * have a huge one-time-creation cost per lambda form, which is cached.
> * adapters caused by insertArguments, folding, asType and all that
> change the Lambdaform, resulting in huge one time cost.
> * A simple invocation of a MethodHandle with an previously unused lambda
> form results in code that performs about 10-15 times slower than doing
> the same  via reflection on the cold path
> * important: cold path is not hot path, it has to be looked at
> independently and is for the first few invocations
> * @CallerSensitive and JPMS can be made working properly for this
> * Reflective Methods can be unreflected to get a MethodHandle, but these
> are not the high performing handles Reflection uses inside.
>
> HiddenClasses nest mates:
> * could be used to replace our current bytecode generation for callsites
> * they are part of the same classloader and module, making the old
> callsite mechanism more interesting for the JPMS case again.
> * This will not gain performance, but may make things more "correct" and
> usable for JPMS
> * Still @CallerSensitive may not work properly for this. The same as
> now. But for getting the caller class loader or module, this would work
> better than now.
> * bytecode generation (hidden class or not) is even more expensive for
> the cold path
>
> The whole thing is quite frustrating because I am basically fighting JVM
> implementation details I have no influence on and that are possible
> subject to change. It makes me consider to investigate Graal again.
> Maybe I can get further with the help of AI here. Also I may have to
> consider an change of architecture away from the callsite view to a
> receiver view for the cold path. There are still a few things to
> investigate. Will this influence beta-1... probably not. If I have to do
> a really really big change maybe. But then I will of course start a
> Thread here. I may for example change how the callsite for indy are
> generated to get away from the specialized types. Maybe even a Handle
> calling reflection. Promoting from cold to hot path implementation is
> also not easy, since everything I add will potentially make the cold
> path slower.
>
> bye Jochen
>
> On 7/4/26 08:57, Paul King wrote:
> > Hi folks,
> >
> > Thanks to everyone for helping get out the last round of releases.
> >
> > In Jira, I renamed the next Groovy 6 release to be 6.0.0-beta-1.
> > Depending on any feedback we get, we can always rename back to
> > alpha-3, or if we decide that we are in fact feature complete, we
> > could push for RC-1 instead.
> >
> > There are a few open PRs and a few open GEPs we should discuss as we
> > lock those in for Groovy 6/7 versions. I'll send those as separate
> > emails over the coming week. The optimistic plan would be a beta-1 in
> > a few weeks, then RC-1 a few weeks after that, and then 6 GA a few
> > weeks after that. But obviously, more versions and/or more time
> > between versions if we need it.
> >
> > We have also been in discussions with JetBrains about working with
> > them to improve the Groovy plugin for IDEA. I think that is something
> > we should try to do next once 6 looks locked down (which it is or is
> > close).
> >
> > I also have draft reference implementations for groovy-ginq-sql (SQL
> > and jOOQ extensions for groovy-ginq) and for switch pattern matching,
> > but they are things I think we should target for Groovy 7. That is two
> > of the "seeking feedback" emails I hope to write soon but feel free to
> > look at the draft PRs in the meantime if you want.
> >
> > Cheers, Paul.
>

Reply via email to