Re: some things about Closure

Paul King <[email protected]> Wed, 15 Jul 2026 14:01:41 +1000
Newsgroups gmane.comp.lang.groovy.devel
Message-ID <CAMbkE7Su-xYJirbP4NzMoXX-FVfngGin0c3LoYJ9wNOdd4Qc4A@mail.gmail.com>
Hi Jochen,

I merged GROOVY-12165 reworking it to I think the direction you were
outlining and fixing GROOVY-12164 as part of that. It is definitely
still CTR, so feedback certainly welcome.

Then I reworked GROOVY-12151 (still marked draft) and updated GEP-27:

https://github.com/apache/groovy/pull/2709
https://groovy.apache.org/wiki/GEP-27.html

Re: "I would like to have this as standard feature, not as opt-in", I
have included the AI read on the opt-in decision below.

Cheers, Paul.

----

Subject: GEP-27 closure packing: factors for the opt-in vs default-on decis=
ion

Here is a summary of the factors bearing on whether
`groovy.target.closure.pack` should eventually default on, to help
that discussion happen against facts rather than impressions. To scope
it first: the question only concerns the @CompileStatic PROVEN path
(the type checker's delegate-independence proof). Dynamic packing
stays annotation-only (@PackedClosures) regardless =E2=80=94 it cannot be
proven sound without types =E2=80=94 so the blast radius of any default is:
statically compiled closures the compiler has proved safe, with the
escape analysis already declining the risky shapes (field-assigned DSL
blocks like Grails constraints never pack, by construction).

In favour of default-on
-----------------------
* Class count and bytes: -93% classes / -73% bytecode on the measurement co=
rpus;
  the Grails domain-class extreme was 24 closure classes (80 KB) for one cl=
ass.
  Less classloading, verification, JIT profiling, metaspace; smaller jars.
* Throughput: capturing shapes (the ones GEP-27 exists for) run 1.4-1.9x FA=
STER
  packed than as generated classes (JMH, current master baseline including =
the
  Closure.call fast-path work), and the win holds under megamorphic dispatc=
h.
  On Grails-shaped in-situ workloads (domain pipelines, validation cycles) =
the
  blend nets +17-23% with identical outputs. Allocation at or below the
  closure-class baseline on capturing shapes.
* Semantics: the MOP matrix is fully green =E2=80=94 per-instance metaclass
interception,
  categories, and respondsTo behave identically to generated closure classe=
s on
  both the dynamic and Java/GDK paths (pinned by tests); corpus transcripts=
 are
  byte-identical flag on/off; verified equally under classic (indy=3Dfalse)
  compilation.
* JPMS: packed dispatch uses the capability model Java lambdas use, so it w=
orks
  under strict encapsulation with no --add-opens, where reflective dispatch=
 of
  closure classes does not.
* Back-out valves at every granularity: the flag, and @PackedClosures(mode =
=3D
  DISABLED) per class or per method.

Costs and residuals (all documented in the @PackedClosures javadoc and GEP-=
27)
---------------------------------------------------------------------------=
---
* The tight non-capturing collect shape runs ~0.6x in microbenchmarks =E2=
=80=94 the
  MOP-transparency guard's per-call checks, which are loop-invariant
and hoist to
  zero when the JIT inlines the chain. Assessed against realistic Grails-sh=
aped
  workloads: no measurable in-situ effect (the blend is +17-23% as above), =
so
  this is a micro-visible, macro-invisible cost.
* Serialization: a packed closure is not serializable, and this now has the=
 same
  three-layer treatment as the delegate boundary =E2=80=94 a literal VISIBL=
Y
  serialization-bound (cast/coerced to a Serializable type, or passed
directly to
  writeObject) declines at compile time and keeps its class (so it serializ=
es as
  before, and WARN/STRICT report it); a transitive route fails fast at runt=
ime
  with a message naming the closure and the opt-out (dehydrate()
cannot help =E2=80=94 the
  dispatch state remains); and the escape analysis already blocks the
main routes
  into serialized object graphs (no field stores, returns, or collections).
* Class identity: closure.getClass() no longer distinguishes literals =E2=
=80=94 tooling
  or code keyed on generated $_closureN names/types will not find them.
* Class-level metaclass changes on the shared adapter are global to packed
  closures (per-instance setMetaClass is fully honoured).
* Reflection surface: hoisted bodies and dispatch tables appear as private
  synthetic methods on the host class, as Java lambda impl methods do.

Remaining gates before a default flip
-------------------------------------
1. The human-only IDE/tooling pass: breakpoints and step-into on hoisted bo=
dies,
   variables view, JDWP, coverage/decompilers. The scriptable layer is done=
 for
   both legs (automated tests assert per-line LineNumberTables and fully na=
med
   LocalVariableTables =E2=80=94 captures including written ones, typed and=
 implicit-it
   parameters, body locals =E2=80=94 plus ACC_SYNTHETIC on everything gener=
ated). Note
   this is the same gate groovy.target.lambda.hoist is waiting on, and that
   feature has zero semantic residuals =E2=80=94 so lambdas reasonably flip=
 first, and
   packing follows rather than jumps the queue.
2. One ecosystem due-diligence: frameworks that serialize closures as progr=
am
   state (the Jenkins CPS pattern) should be checked at flip time. Expected
   unaffected =E2=80=94 such frameworks transform closures at the AST level=
 before our
   codegen sees them, and would not enable the flag for those compilations =
=E2=80=94 but
   expected is not verified.
3. Migrating our own tests that assert the old bytecode shape (mechanical;
   currently flag-skipped).

My reading: the mechanism work is complete and verified; what remains is
tooling verification and policy. The conservative sequencing that fits our
conventions is: ship opt-in in 6.0 as proposed, revisit the default alongsi=
de
lambda.hoist once the IDE pass is done.

Opinions welcome, especially from anyone who can help with the IDE verifica=
tion
side.


On Wed, Jul 15, 2026 at 5:43=E2=80=AFAM Jochen Theodorou <[email protected]=
> wrote:
>
> On 7/14/26 14:50, Paul King wrote:
> > Hi Jochen,
> >
> > AI read below. It could be wrong. I am still pondering but won't get
> > time again until my tomorrow. So this is something to read in the
> > meantime.
>
> take your time. I recently feel like I do reviews and comments on PRs in
> 100% of my spare time ;)
> Some comments inline
>
> [...]
> > 1) The doCall-only contract has never held on the dynamic path.
> >
> > I tested a Closure subclass declaring only `String call(String)` (no do=
Call)
> > across releases:
> >
> >                         dynamic cl("foo")    GDK collect(cl)
> >    Groovy 3.0.21-25     works                MissingMethodException
> >    Groovy 4.0.32        works                MissingMethodException
> >    Groovy 5.0.6         works                MissingMethodException
> >    Groovy 6.0.0-alpha-1 works                MissingMethodException
> >    master (12164/65)    works                works
>
> This is an obvious outcome. call(Object) delegates to call(Object...),
> which looks for doCall. GDK does not see call(String), only
> call(Object), thus the failure. Which is exactly why overloading call
> methods in Closure was never really supported and only sometimes working
> if you override the call method instead. But if you override
> call(Object) and then do the call through call(Object...), it was still
> failing, since the dispatch direction was designed the other way around.
> The only choice left, if you want to avoid doCall, is then to override
> call(Object...) instead. That aligns with the other mail I think. The
> javadoc of Closure even mentions, that if you want to use the short form
> like cl("foo") you have to provide a doCall method.
> I will not deny that cl("foo") works, but I don`t think that was really
> intended. More like accidentally worked and then nobody changed it.
>
> [...] > That leaves a genuine decision rather than a bug report: either
> custom call
> > overloads are accepted as the (now consistent) de-facto contract, or
> > doCall-only is enforced on BOTH paths =E2=80=94 which is a behavioural =
break with
> > 3.x-6.x dynamic dispatch.
>
> Which is why I wanted to start a discussion in the first place.
>
> [...]
> > 2) On PackedClosure,
> [...]
> > - one shared MetaClass for all packed closures: conceded, and it is the=
 one
> >    semantic difference that cannot be engineered away =E2=80=94 per-lit=
eral metaclass
> >    is definitionally what packing removes. Class-level metaClass change=
s on
> >    PackedClosure would affect every packed closure. The delegate/
> >    resolveStrategy axis is already fenced by the runtime guard; this ax=
is
> >    needs documenting as a caveat of the opt-in feature (and is one more=
 reason
> >    packing must stay off for code that plays MOP games).
>
> the problem is that it will stay an opt-in feature, since it cannot
> replace the regular construct for the same case.
>
> > - "the call path seems to be the only way to invoke the real method": t=
rue,
> >    but I'd frame what that path now IS: a statically compiled, metaclas=
s-free,
> >    inlinable route (adapter -> per-arity dispatch table -> direct invoc=
ation of
> >    the typed hoisted body). For statically compiled callers, invokevirt=
ual
> >    call() on a packed closure reaches a typed body with no MOP involvem=
ent at
> >    all =E2=80=94 which is the closest thing we currently have to your p=
oint (6),
> >    since the typed doCall of a closure class is hidden behind Closure<T=
> for
> >    static compilation, but a packed closure's entry is structurally fix=
ed.
> >    Recent measurements: packed closures now run 1.8-4.7x FASTER than ge=
nerated
> >    closure classes on capturing shapes with byte-identical behaviour, l=
argely
> >    because of this property.
>
> With GeneratedClosure the class is part of the same package and module
> of the class it was declared in. So if a class of module A gets an
> instance of GeneratedClosure from module B, and I do a dynamic call from
> A to B using that Closure, then the call will be a call from A via
> doCall into B, requiring JPMS to allow it. Now in case of PackedClosure,
> as long as we not bypass the call method and use the target directly, we
> effectively make it a call from A into <Groovy Runtime> into B.
> Bypassing any possible restriction between A and B. Of course I am only
> talking about A and B as modules written in Groovy. And yes, this is
> currently a theoretical case.
>
> Now... the other part to think about is what happens if the MOP
> involvement is intended? Like if EMC is used. Do we say it does not work
> if PackedClosure is enabled? As I said, that probably disqualifies it as
> an incubation feature that will become a standard without flag. If, on
> the other hand, we move the call code into a special metaclass, we
> should still achieve similar results in performance while we ensure a
> potential MOP path stays more open. And we can enable potential direct
> invocation paths with invokedynamic. Plus, most likely, optimizing that
> path will also suggest similar optimizations for ClosureMetaClass. Then
> the main difference between the two would be that PackedClosure saves on
> class loading overhead.
>
> > - one point in the architecture's favour under your criterion (2): dyna=
mic
> >    call sites invoking closures are megamorphic across per-literal clos=
ure
> >    classes, but monomorphic on the single PackedClosure class =E2=80=94=
 the per-target
> >    selection moves into a switch the JIT handles well. Indy caching get=
s
> >    easier, not harder.
>
> which is why I would like to have this as standard feature, not as opt-in
>   [...]
>
>
> bye Jochen
>