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. >
