This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch asf-site
in repository https://gitbox.apache.org/repos/asf/groovy-website.git
The following commit(s) were added to refs/heads/asf-site by this push:
new 18871b0 updated with recent changes for GROOVY-12151
18871b0 is described below
commit 18871b004e46552ea5c38b5b0e0fa15758dc4a5d
Author: Paul King <[email protected]>
AuthorDate: Wed Jul 15 13:07:57 2026 +1000
updated with recent changes for GROOVY-12151
---
site/src/site/wiki/GEP-27.adoc | 155 ++++++++++++++++++++++++-----------------
1 file changed, 93 insertions(+), 62 deletions(-)
diff --git a/site/src/site/wiki/GEP-27.adoc b/site/src/site/wiki/GEP-27.adoc
index ac4c066..5548e7a 100644
--- a/site/src/site/wiki/GEP-27.adoc
+++ b/site/src/site/wiki/GEP-27.adoc
@@ -14,7 +14,7 @@
*Comment*:: Reduces the per-closure/per-lambda generated-class explosion by
hoisting bodies onto the enclosing class. The sound-by-construction SAM-lambda
slice is merged for Groovy 6; closure packing (S1) is implemented and proposed
as a Groovy 6 stretch goal (GROOVY-12151); object elision and writer
unification remain for Groovy 7. Strictly additive; gated by a flag for
back-out.
*Leader*:: Paul King
*Created*:: 2026-07-09
-*Last modification*:: 2026-07-13
+*Last modification*:: 2026-07-15
****
[width="80%",align="center"]
@@ -54,9 +54,9 @@ actually needs:
The proposal is deliberately phased. A **small, sound-by-construction slice —
eliminating the
generated class for non-capturing and read-only-capturing SAM lambdas — is
merged for Groovy 6.0**.
-**Closure packing (S1), including its soundness analysis and a
`MethodHandle`-based dispatch path that
-reaches `@CompileStatic` call-parity, is now implemented and proposed as a
Groovy 6.0 stretch goal
-(GROOVY-12151)**; the remaining, more
+**Closure packing (S1), including its soundness analysis, a per-class
dispatch-table path that
+outruns the generated classes it replaces on capturing shapes, and full MOP
transparency, is now
+implemented and proposed as a Groovy 6.0 stretch goal (GROOVY-12151)**; the
remaining, more
aggressive work — object elision and unifying the two writers — targets Groovy
7.0. The change is
strictly additive, defaults can be flipped via a flag for back-out, and it is
gated so that any
tooling that reflects on generated-class names has an escape hatch.
@@ -181,13 +181,17 @@ hoisted method is still a real `Closure`, so it preserves
almost everything: it
has per-instance identity, and `curry`/`rcurry`/`ncurry`, `memoize*`,
`trampoline`,
`compose`, and iteration all continue to work (they operate through `call()`).
-A hoisted representation loses exactly two things relative to a normal
generated closure:
+A hoisted representation loses exactly three things relative to a normal
generated closure:
* *Externally-set `delegate`/`resolveStrategy`.* A hoisted body binds free
names at compile
time against the owner; a normal closure resolves free names against a
`delegate` set at
runtime. This is the one true soundness boundary (see _The soundness
boundary_).
-* *Serialization.* A method-handle/dispatch-backed adapter is not portably
serializable the way
- a generated closure class is.
+* *Serialization.* A dispatch-table-backed adapter is not portably
serializable the way a
+ generated closure class is.
+* *Per-literal class identity.* All packed closures share one adapter class, so
+ `closure.getClass()` no longer distinguishes literals, and class-level
metaclass changes on
+ the adapter are global to packed closures (per-instance `setMetaClass` is
fully honoured —
+ see _MOP transparency_).
So "needs closure identity" is not a broad lattice; it is essentially the
predicate *"does
externally-set delegate/resolveStrategy or serialization reach this value?"*
@@ -422,12 +426,29 @@ diagnostics. The two headline capabilities are done:
`@PackedClosures`, backed by the runtime guard.
* *Fast dispatch* — two parts. The hoisted body is typed from the inferred
types the type checker
already computed and compiles statically (typed captures/params/return, zero
`invokedynamic` in the
- body). And the shared adapter reaches that body through a `MethodHandle`
constant to the private
- hoisted method, invoked with `invokeExact` (no reflection), behind a
`doCall` fast path that skips
- argument adaptation on the common exact-arity call. Measured per `call()`
against the generated
- closure class it replaces: a naive shared adapter is ~7–9× slower, whereas
the shipped one reaches
- `@CompileStatic` parity and ~2× under dynamic compilation — so packing is a
class-count win at no
- dispatch-speed penalty under `@CompileStatic`.
+ body). And the shared adapter reaches that body through compiler-generated
per-class *dispatch
+ tables*: a general `(int id, Object[])` table covering every hoisted target
plus array-free
+ per-arity tables for targets taking one or two values beyond the receiver
(captures count as
+ values — the overwhelmingly common shapes), each case casting to the
declared parameter types and
+ invoking the target directly. One `invokedynamic` accessor per class links
the tables into a
+ constant bundle lazily on first use, and each literal's parameter-type
`Class[]` is a
+ constant-dynamic resolved once per site. The whole chain is ordinary,
JIT-friendly bytecode — no
+ reflection, and no per-instance `MethodHandle` (not constant-foldable, hence
many times slower).
+ Measured with JMH against the generated classes it replaces (same build,
both compilations):
+ capturing shapes run **1.4–1.9× faster packed** — the win holding under
megamorphic dispatch across
+ many classes — while the tightest non-capturing `collect` trails at ~0.6×;
on Grails-shaped
+ in-situ workloads (domain pipelines, validation cycles) the blend nets
**+17–23% packed** with
+ identical outputs, so the adverse micro-shape dilutes out under real work.
+* *MOP transparency* — the direct dispatch path is semantically invisible: it
is taken only when the
+ instance's metaclass is stock and no category is active (one latched-boolean
check, the same
+ conditions the classic call-site caches test), and anything MOP-relevant
routes through
+ `invokeMethod`. Interception via a per-instance metaclass is honoured
identically on the dynamic
+ and Java/GDK paths, matching generated closure classes. The adapter has its
own registered stock
+ metaclass, `PackedClosureMetaClass` — the analog of `ClosureMetaClass` for
generated closures —
+ giving reflection-free `call`/`doCall` dispatch on the MOP route,
`respondsTo` answers faithful to
+ the instance's declared parameter types, and the same category stance as
`ClosureMetaClass`. (The
+ `GeneratedClosure` marker is deliberately not used: its metaclass machinery
assumes an inner class
+ of the owner, which a shared top-level adapter is not.)
The API surface (`@PackedClosures`, `PackMode`) is marked `@Incubating`. What
keeps this a stretch
goal rather than a committed 6.0 deliverable is that it is opt-in only (the
automatic path is not
@@ -463,20 +484,22 @@ Fold the lambda and closure code paths behind the single
hoist backend, so the S
A related follow-up surfaced in review: `MethodClosure` (from `.&` method
references) is the same
shape — a `Closure` whose target is a known method that cannot honour a
delegate — but it dispatches
by name with runtime overload selection and *silently ignores* a caller-set
delegate, whereas
-`PackedClosure` binds a fixed `MethodHandle` and *rejects* one. The two are
worth _contract_-aligning
-(a shared "known-target, delegate-inert" marker and consistent delegate
handling), and both could
-share the per-arity handle-selection primitive that default-parameter packing
(see _Excluded and
-deferred features_) needs — the same mechanism that would close the remaining
dynamic-dispatch
-throughput gap (see _Performance_). Their dispatch models differ enough,
though — a fixed handle versus
-runtime overload selection — that fully merging them into one path is not a
goal; this is tracked as a
-separate initiative.
+`PackedClosure` dispatches through a fixed table entry and *rejects* one. The
two are worth
+_contract_-aligning (a shared "known-target, delegate-inert" marker and
consistent delegate
+handling), and statically resolved method pointers could claim ids in the same
per-class dispatch
+tables the packed adapter already uses — dispatching with the host class's own
bytecode rights,
+which also gives caller-sensitivity-correct semantics. Their dispatch models
differ enough, though —
+a fixed table entry versus runtime overload selection — that fully merging
them into one path is not
+a goal; this is tracked as a separate initiative.
=== Default-on hardening
Flip closure packing from experimental (opt-in `groovy.target.closure.pack`)
to default-on. This is
a policy decision gated on the remaining human-only tooling verification (see
_IDE and tooling
considerations_) and on migrating the tests that assert the old bytecode shape
— not on further
-mechanism work.
+mechanism work: the dispatch and MOP-transparency mechanisms are complete and
verified (interception,
+categories, and introspection behave identically to generated closure classes
on every path), and the
+remaining documented residuals are those listed under _The one that matters:
required capability_.
== Configuration and flags
@@ -620,16 +643,19 @@ stretch goal:
a scope out entirely. The automatic flag path stays lenient.
Also implemented and validated: the *capability analysis* (STC's
delegate-independence proof driving
-automatic packing under `@CompileStatic`, no annotation needed) and the
*dispatch path* — a
-statically-compiled, typed hoisted body reached through a `MethodHandle`
constant to the private
-hoisted method (`invokeExact`, no reflection) behind a lean `doCall` fast path
— exercised across the
-core module and several others. Measured per `call()` against the generated
closure class it replaces,
-a naive shared adapter is ~7–9× slower; the shipped adapter reaches
`@CompileStatic` parity and ~2×
-under dynamic compilation. (Marking the adapter `GeneratedClosure` for the
metaclass fast path was
-evaluated and found perf-neutral once `doCall` was lean, so it is not
applied.) This implemented slice
-is proposed as a Groovy 6.0 stretch goal (GROOVY-12151). The remaining Groovy
7 work is owner-correct
-retargeting so self-contained nested closures can hoist, S3 object elision,
unifying the closure and
-lambda writers, and hardening the automatic path from experimental (opt-in
flag) to default-on.
+automatic packing under `@CompileStatic`, no annotation needed); the *dispatch
path* — a
+statically-compiled, typed hoisted body reached through the per-class dispatch
tables described under
+_Groovy 6.0 deliverables_, behind lean adapter lanes sized to the JIT's
inlining budgets — exercised
+across the core module and several others; and the *MOP integration* — the
stock-metaclass/category
+guard shared with `Closure.call`'s own fast-path cache, and the registered
`PackedClosureMetaClass`
+(reflection-free MOP dispatch, instance-faithful `respondsTo`), with the
interception matrix verified
+identical to generated closure classes on both the dynamic and Java/GDK paths.
Measured with JMH
+against the generated classes it replaces, capturing shapes run 1.4–1.9×
faster packed and
+Grails-shaped in-situ workloads net +17–23%, with byte-identical corpus
transcripts. This implemented
+slice is proposed as a Groovy 6.0 stretch goal (GROOVY-12151). The remaining
Groovy 7 work is
+owner-correct retargeting so self-contained nested closures can hoist, S3
object elision, unifying
+the closure and lambda writers, and hardening the automatic path from
experimental (opt-in flag) to
+default-on.
=== Measured impact
@@ -643,13 +669,20 @@ totalling 80 KB* — more bytecode than the entire rest of
the class (1.19×).
Dispatch speed matters as much as class count, because a shared adapter is
invoked through the
`Closure` machinery rather than being its own class. A naive adapter
(reflective invoke plus per-call
-argument adaptation) measured ~7–9× slower to `call()` than the generated
closure class it replaces —
-smaller but slower. The shipped adapter closes that gap with a compile-time
`MethodHandle` constant
-invoked via `invokeExact` and a `doCall` fast path that skips adaptation on
the common exact-arity
-call: a packed `@CompileStatic` closure then dispatches at parity with a
normal closure, and a packed
-dynamic closure at ~2×. So `@CompileStatic` packing is strictly a win (fewer
classes, same call
-speed), while dynamic packing trades ~2× call cost for the class-count
reduction — which is why the
-dynamic path stays opt-in.
+argument adaptation) measures ~7–9× slower to `call()` than the generated
closure class it replaces —
+smaller but slower — and a per-instance `MethodHandle` fares little better
(the JIT cannot
+constant-fold it, so every call runs the method-handle invoker). The shipped
design avoids both: the
+per-class dispatch tables execute as ordinary bytecode the JIT inlines
through, the per-arity tables
+pass arguments as plain parameters (no array to allocate or escape), and
constant-dynamic
+parameter-type arrays remove the last per-creation allocation. Measured with
JMH on the same build,
+capturing closures — the shapes GEP-27 exists for — run **1.4–1.9× faster
packed than as generated
+classes**, holding under megamorphic dispatch; the tightest non-capturing
`collect` trails at ~0.6×,
+the cost being the MOP-transparency guard (loop-invariant checks the JIT
hoists when the chain
+inlines — the same checks the classic call-site caches make). On Grails-shaped
in-situ workloads the
+blend nets **+17–23% packed** with identical outputs, so the adverse
micro-shape is real but dilutes
+out under real work (further lane micro-tuning was assessed against those
shapes and found to have no
+in-situ effect). Ratios are measured against a current baseline that includes
the `Closure.call`
+fast-path improvements (GROOVY-12164/12165), i.e. against generated classes at
their fastest.
=== Grails artefact DSLs and nested closures
@@ -674,11 +707,11 @@ rebinding its free names to that owner, and emitting
static-vs-instance dispatch
owner-correct capability work already scoped to Groovy 7. This makes domain
classes the single
highest-value target for that phase, and the reason it is Groovy 7 and not a
cheap gate change.
-A supporting change surfaced by the closure spike is worth noting: hoisting
nested closures also
-requires the class visitor to visit methods added _during_ code generation to
a fixpoint (the
-current two-round `visitMethods` misses methods added while compiling a method
that was itself added
-during compilation). That change is general and low-risk but must be validated
against the full
-suite before it lands.
+A supporting change is worth noting: hoisting nested closures requires the
class visitor to visit
+methods added _during_ code generation to a fixpoint (a two-round
`visitMethods` misses methods
+added while compiling a method that was itself added during compilation). That
change is implemented
+in the packing slice — an identity-based fixpoint, also fixing an O(n²) scan
against
+method-generating visitors — and is validated against the full suite.
=== Phased delivery
@@ -688,7 +721,7 @@ suite before it lands.
|1 |Non-capturing SAM lambda class elimination + flag + test migration +
IDE/tooling check |Java-parity lambda codegen for the common SAM case; back-out
flag |Groovy 6.0 (merged)
|2 |Read-only-capturing SAM lambdas (metafactory-captured static impl)
|Removes the class, `Closure` alloc, and `Reference` wrapping for read-only
capturing SAM lambdas (mutated captures decline) |Groovy 6.0 (merged)
-|3 |Closure packing (S1) + capability/escape analysis + `MethodHandle`
dispatch + `@PackedClosures` |Sound closure packing: automatic under
`@CompileStatic` at call-parity dispatch, opt-in (with runtime guard + escape
decline) under dynamic |Groovy 6.0 (stretch, GROOVY-12151)
+|3 |Closure packing (S1) + capability/escape analysis + per-class dispatch
tables + MOP transparency + `@PackedClosures` |Sound closure packing: automatic
under `@CompileStatic`, faster than the classes it replaces on capturing
shapes, opt-in (with runtime guard + escape decline) under dynamic |Groovy 6.0
(stretch, GROOVY-12151)
|4 |Owner-correct retargeting so self-contained nested closures hoist |Grails
domain `validator` blocks and other nested closures pack |Groovy 7.0
|5 |Object elision (S3) + unify the writers + default-on hardening |No object
for non-escaping call-once bodies; one backend for closures and lambdas;
packing default-on |Groovy 7.0
|===
@@ -705,7 +738,7 @@ stretch goal) builds on the shared backend proven in phases
1–2; phases 4–5
|Changing closure/lambda _semantics_ |Not planned |This is a codegen/packaging
optimisation only. Eligible closures compute identical results and keep their
reachable `Closure` API; ineligible ones are compiled exactly as today.
|Packing closures that use externally-set `delegate`/`resolveStrategy` |Not
planned |These require a real per-closure representation; they are detected
(statically) or declined and fall back to S0.
|Serializable closure/lambda hoisting |Deferred |Kept on the class-based path
initially; hoisting them requires carrying the deserialisation machinery to the
enclosing class.
-|Packing default-parameter (multi-arity) closures |Deferred |A single hoisted
method cannot reproduce the arity-overloaded dispatch a default parameter
generates, so these decline to S0 today. A future slice would hoist one method
per arity (reusing the existing default-fill codegen) and have the adapter
select a `MethodHandle` per arity at call time — the same primitive the
_MethodClosure_ alignment above would share.
+|Packing default-parameter (multi-arity) closures |Deferred |A single hoisted
method cannot reproduce the arity-overloaded dispatch a default parameter
generates, so these decline to S0 today. A future slice would hoist one method
per arity (reusing the existing default-fill codegen) and claim one
dispatch-table id per arity — the per-arity tables the shipped adapter already
uses, and the same primitive the _MethodClosure_ alignment above would share.
|Object elision (S3) |Deferred (Groovy 7) |The most aggressive step; needs
escape proof and, for iteration, callee knowledge.
|Dynamic-mode automatic packing |Not planned |Cannot be proven sound without
types; dynamic packing is opt-in via `@PackedClosures` only.
|A user-visible switch between S1/S2/S3 per closure |Not planned |The compiler
picks the lightest sound representation; users influence it only via target
type, arrow syntax, and `@PackedClosures` — including opting a scope out
entirely with `@PackedClosures(mode = DISABLED)`, but never selecting *which*
packed representation is used.
@@ -736,23 +769,21 @@ the compatibility valve while tools are updated.
* Fewer generated classes: less classloading, verification, JIT profiling, and
metaspace.
* Non-capturing SAM lambdas keep their zero-allocation metafactory singleton
(the class removed
is dead weight, not an allocation).
-* Closure packing under `@CompileStatic` is a class-count win at dispatch
parity with a normal
- closure: the typed hoisted body compiles statically, and the adapter reaches
it through a
- `MethodHandle` constant (`invokeExact`) behind a lean `doCall` fast path.
Measured per `call()`, a
- naive shared adapter is ~7–9× slower; the shipped one is at parity under
`@CompileStatic` and ~2×
- under dynamic compilation (so the dynamic path stays opt-in).
-* Capturing closures (the common case, e.g. `{ it + base }`) used to pay an
extra per-call cost — the
- adapter allocated an argument array prepending the captures on every
invocation. Binding the captures
- into the handle once at construction removes that allocation (GROOVY-12151).
Measured per `call()`
- against the generated closure class it replaces, a capturing closure
improved from dynamic ~3.2× /
- `@CompileStatic` ~1.75× to dynamic ~1.9× / `@CompileStatic` ~0.78× (faster
than a normal closure under
- `@CompileStatic`), matching the non-capturing figures above.
-* The remaining ~2× dynamic gap is the argument-array spreader plus the
dynamic MOP `call`→`doCall`
- selection (`PackedClosure` is not a `GeneratedClosure`) — no longer
allocation. A typed per-arity
- `invokeExact` path would remove the spreader for the common `each`/`collect`
arities: the *same*
- per-arity handle-selection primitive that default-parameter packing and the
MethodClosure alignment
- need (see _Unify the two writers_ and _Excluded and deferred features_), so
that throughput work and
- those follow-ons build one shared mechanism.
+* Closure packing dispatches through per-class tables that execute as
ordinary, JIT-inlinable
+ bytecode; the per-arity tables pass the receiver, captures and arguments as
plain parameters, so
+ the hot shapes allocate no argument array at all, and allocation sits at or
below the
+ closure-class baseline on capturing shapes.
+* Measured with JMH against the generated classes on the same build: capturing
shapes 1.4–1.9×
+ faster packed (holding under megamorphic dispatch); tight non-capturing
`collect` ~0.6× — the
+ MOP-transparency guard's loop-invariant checks, hoisted to zero when the
chain inlines. On
+ Grails-shaped in-situ workloads the blend nets +17–23% packed with identical
outputs.
+* MOP transparency has the same cost model as the classic call-site caches:
one latched-boolean and
+ one category-counter read decide between direct dispatch and the full
`invokeMethod` route, so
+ interception, categories and `respondsTo` behave identically to generated
closure classes.
+* Under strict JPMS (named modules, no `--add-opens`), packed dispatch works
where reflective
+ dispatch of closure classes does not: the hosting class's own
`invokedynamic` bootstrap captures
+ its lookup — the capability model Java lambdas use — and handing the closure
across a module
+ boundary is the grant. This deliberately moves enforcement from invocation
time to creation time.
=== Security