Re: version planning
Jochen Theodorou <[email protected]> Sun, 5 Jul 2026 15:12:13 +0200
| Newsgroups | gmane.comp.lang.groovy.devel |
|---|---|
| Message-ID | <[email protected]> |
On 7/5/26 00:16, Paul King wrote: > Thanks Jochen, >=20 > Some AI comments below in two parts. What did you use for that (Agent, LLM model)? I did not read the=20 comments yet, just wondering if we use the same stuff here. After=20 reading I am pretty sure that is a no. Still I am curious about the=20 tooling. I am mostly using cline with whatever is currently a free=20 model, plus also chatgpt chats and other things. Is there any free for OSS work models out there? They really help=20 supporting summarizing and analyzing as well as solution finding in=20 general, even if the agent does not change the code in the end. [...] > 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. while the I omitted points before are not wrong, the just show we build=20 a very complex handle. This resulting paragraph is the actual problem. > ## The plan =E2=80=94 four workstreams >=20 > ### W0 (do first): a noise-free cold-path metric >=20 > 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=E2=80=93W3 shows up as a monotone drop in that counter, immune to run= ner > variance. Add alongside it: >=20 > - 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** =E2=80=94 = that's > the number users actually feel, and none of the 30-odd JMH benches > measures it. >=20 > This is a week of work and it de-risks everything below. To understand the basic problem I was doing benchmarks on a much lower=20 level. How much does creating a Callsite in invokedynamic cost? How much= =20 a single uncached MethodHandle, how much a single reflective call. How=20 much doing this twice? Those are really microbenchmarks. But if they say= =20 one is 15 times slower than the other and one has repeating cost because= =20 of different usages and the other not... well then a pattern forms. And=20 that is even before we do the Groovy based logic > ### W1: contained wins on the current architecture >=20 > 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 =E2=80=94 this extends the same idea from > generated DGMs to all methods. Of course I have thought about this too, I am a bit afraid of this=20 meaning we pay with longer startup/metaclass init times. The more=20 methods with different signatures the higher the cost most likely. But I= =20 have not tested it. Adding a MethodHandle to CachedMethod would,=20 unfortunately kind of change the public API. As for the lookup object...= =20 it depends on what you use. In a bootstrap scenario we have the lookup=20 of the caller class. If we are going to make a call to a private method=20 on the same class, then this is very possible. There is also the=20 question of JPMS. Just making every method available that Groovy has=20 read rights to is not going to make Groovy a proper member in that=20 infrastructure. > 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 =E2=80=94 the guard chain already protects correctness a= nd > `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". I think the problem is already there on the first call. > 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. catchException... did I not add skipping that for some CachedMethod=20 cases? I have it in my draft PR. It caused a lot of trouble though. > ### W2: collapse MethodType shape diversity >=20 > 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 =E2=80=94 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 =E2=80=94 I'd expect this to be the single biggest LambdaForm-co= unt > reduction. My theory is that if you have have (Foo,int)Object on the callsite (like= =20 when we call a method on Foo, that takes int) and the method we call=20 returns int, then we have a mismatch of what the callsite requires and=20 what the target requires. This is then solved by asType or=20 explicitCastArguments, but it changes the lambda form, causing us to=20 have to pay the cost for it. Ass I said, a theory. If that is no changed= =20 to (Object,Object)Object then yes, we save on the incoming MethodType=20 variety, but we still will produce many different lambda forms in the end. > ### W3: reflective cold tier (the architecture change Jochen is circling= ) >=20 > 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 =E2=80=94 all trivially > checkable outside MH guards), and invocation during the cold phase > goes through `CachedMethod.invoke` =E2=86=92 `Method.invoke`, i.e. the > reflection path Jochen measured as 10=E2=80=9315=C3=97 faster cold. Only= at > promotion does the full guarded handle chain get built and `setTarget` > =E2=80=94 so the hot path is *byte-for-byte unchanged*. >=20 > 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, =E2=80=A6)`. 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 =E2=80=94 a cheap = cold > tier makes the 1000-call threshold much less painful. specifically I am considering doing a template class for a hidden nested= =20 class to do the call to reflection, which would handle the logic as=20 well. But this comes at a cost of course and I have to check if it is=20 worth it. > ### W4: stop fighting the one-time cost =E2=80=94 cache it across runs >=20 > 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 =E2=80=94 includin= g 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=E2=80=93W3. That I have not looked into yet. Especially since I do not know how and=20 when handles project to what is cached. If it is classes it may not help= =20 with the cold state, since classes are not used right away for=20 handles... at least as far as I know. > ### Track 2 (parallel, correctness not speed): hidden classes + JPMS >=20 > Replace `CallSiteGenerator`/`ClassLoaderForClassArtifacts.defineClass` > and `DgmConverter` loading with `defineHiddenClass(..., NESTMATE)` =E2= =80=94 not DgmConverter. [...] > ## Suggested sequencing >=20 > W0 first (it's cheap and everything else needs it) =E2=86=92 W1.1 and W1= .2 > immediately after (contained, high confidence, measurable) =E2=86=92 W2 = as a > 6.0-beta bytecode change with the counter as evidence =E2=86=92 W3 proto= typed > behind the flag in parallel, promoted only if the dashboards agree =E2= =86=92 > 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. The bytecode change is not necessarily deeply impacting in terms of=20 compatibility. We can use a different bootstrap method for this and keep= =20 the old one in parallel for example. [...] > I've read both PRs in depth. Here's the assessment and how they slot > into the roadmap. >=20 > ## PR 2549 =E2=80=94 Jochen's GROOVY-12023 indy cache rework >=20 > **What it actually does** (the title undersells it =E2=80=94 this is a > redesign of the call-site lifecycle): >=20 > 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 =3D 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=C3=97 on > `dispatch_3_polymorphic_groovy` and 2.35=C3=97 on megamorphic in the PR'= s > benchmark run =E2=80=94 and unlike PR 2591's alert (see below), those de= ltas > are credible because they're exactly what a PIC chain should deliver > and the alert lists only Groovy benchmarks. https://github.com/apache/groovy/pull/2549#pullrequestreview-4414324894 I am confused... I did before see only ms/op. And this is ops/ms. I did=20 go through the edits before and they are all ms/op... no.. actually some= =20 are op/ms and other are ms/op. But whatever compares does not care about= =20 that and only thinks in higher=3Dworse. Can we improve that? Like have all= =20 benchmarks uniformly enter either ms/op or ops/ms and then compare with=20 less is better for ms/op or more is better for ops/ms. The branch itself is bit of a mess, but some of the ideas in there I=20 have transformed already in other PRs, for example the DGM MethodHandles= =20 and the DTT improvement. There is more to take from. The leading idea=20 was a simple PIC in Methodhandles, to try to stabilize the target. Then=20 moving guards from the branches to the front of the PIC. The switchpoint= =20 guard for example, and removing the switchpoint guard if not needed. But= =20 to do those things cleanly I think we need something like a "profile" on= =20 the callsite, information we keep and can reuse later for extending the=20 handle. But I genuinely missed the performance increase reports > 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 =E2=80= =94 > killing per-call String allocation and hashing in the cold tier. I think that is something to take over > 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** =E2=80=94 the exact building block works= tream > W3 (reflective cold tier) needs, just triggered by churn instead of > coldness. The idea is to start on a cold path, optimize the hot path and fall back= =20 to a slow path if the callsite degrades. The slow path and the cold path= =20 could very well be the same. But they don=C2=B4t have to. The work in this= PR=20 has been done before I was looking at the cold paths. Which does not=20 deny that there are possible overlaps. > 4. **Cold-path trims that overlap my W1.** `catchException` becomes > conditional (only GroovyObject MOP methods =E2=80=94 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`. looks like I already forgot a few things I put into those spikes, haha. > **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 =E2=80=94 returning the exception object to th= e > caller would be a semantic change. Worth a targeted test (MOP methods > using exceptions for control flow, e.g. `MissingMethodException` > fallthrough). yeah.. there was weirdness with ScriptToTreeNodeAdapterTest especially.=20 And I do mean it expecting a returned exception somehow. One of the=20 reasons why I wanted to do the changes from this branch more isolated=20 and incremental > - 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. yeah, that sounds bad. > - `picInsertIfMissing` doesn't insert anything =E2=80=94 it's a containm= ent > check; the name will mislead maintainers. got messed up over time... again, that is why I want to restart on this. > - 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 =E2=80=94 almost certainly unrelated flakes, but should be > confirmed on rebase. I would not rebase it. Instead take the idea and do a new cleaner=20 implementation in its own PR. > **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) =E2=80=94 so the roadmap stands, with W3 now be= st > 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. > The question for me is more how much of this should be done before beta1 > ## PR 2591 =E2=80=94 Daniel's GROOVY-12065 peephole optimizer >=20 > A 737-line `PeepholeOptimizingMethodVisitor` wired into > `AsmClassGenerator` that compacts constant loads (`LDC` =E2=86=92 > `ICONST_*`/`BIPUSH`/`SIPUSH`/`LCONST_*`/`FCONST_*`/`DCONST_*`, > correctly preserving `-0.0f`/`-0.0d`), and simplifies `OperandStack` > by routing constants through `visitLdcInsn` (=E2=88=9251 lines). Tests p= ass, > coverage 94%, review comments are minor (Copilot's `visitCode` pairing > nit; Eric's operand-stack-type question answered by Jochen =E2=80=94 ASM > handles it). >=20 > **Honest sizing of the win:** compacted constants shrink method > bytecode, and HotSpot's inlining thresholds (`MaxInlineSize`=3D35 > bytecode bytes) count bytes =E2=80=94 so this genuinely helps small gene= rated > 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=E2=80=935.4=C3=97 improvements is **provably runner noise**= : pure-Java > baselines (`dispatch_8_megamorphic_java` 5.37=C3=97, `staticFib_java` > 1.90=C3=97) "improved" too, which a Groovy codegen change cannot do. and that is a clear sign of polluting the context ;) Daniel=C2=B4s PR has= =20 nothing to do with cold path improvements. > **Verdict:** low-risk, merge-worthy after the minor comments; its real > strategic value is the *infrastructure* =E2=80=94 a peephole pass is whe= re > 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. here I agree > ## One cross-cutting finding >=20 > 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=E2=80=935=C3=97 swings on pure-Jav= a > 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` =E2=80=94 plus the LambdaForm-spin counter, which would have c= leanly > quantified 2549's cold-path effect and shown 2591 as neutral. My idea would be to have a baseline benchmark in Groovy unrelated code=20 in pure Java. And then adjust everything to be relative to that. The AI=20 comment makes the assumption that a runner is performing the same=20 throughout the benchmark suits. I do not know about that. It surely is=20 not the case on my local computer. But my idea also would suffer from this= . [...] bye Jochen