[ 
https://issues.apache.org/jira/browse/GROOVY-12227?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Paul King updated GROOVY-12227:
-------------------------------
    Description: 
Packed closures (GROOVY-12151, GEP-27) cannot be used in a GraalVM native 
image. A class compiled with {{groovy.target.closure.pack=true}} links its 
dispatch tables on first closure creation, and that linkage fails inside an 
image — in two layers.

Without agent-recorded metadata, the bootstrap's method lookup fails:

{noformat}
java.lang.BootstrapMethodError: java.lang.NoSuchMethodException:
    no such method: B.$packedDispatch$(int,Object[])Object/invokeStatic
{noformat}

With agent-recorded metadata the lookup succeeds, and the real wall appears — 
the bootstrap spins hidden classes at run time, which an image forbids:

{noformat}
Exception in thread "main" java.lang.BootstrapMethodError: 
java.lang.InternalError:
com.oracle.svm.core.jdk.UnsupportedFeatureError: Classes cannot be defined at 
runtime
by default when using ahead-of-time Native Image compilation. Tried to define 
class:

    B$$LambdadLbm0tKUrZ8kVMiSpulnyK
{noformat}

The failure mode is the problem as much as the failure: packing compiles 
cleanly, the image builds cleanly, and the process then dies on the first 
closure created. There is no diagnostic pointing at the flag that caused it, 
and the guidance cannot be enforced at compile time — the class files are 
identical whether or not a native image is built later. The realistic path into 
this is enabling packing globally for its JVM benefit and adding a native build 
afterwards. It also matters for GEP-27's plan to flip packing default-on in 
Groovy 7: at that point this stops being an opt-in corner and becomes every 
Groovy native image with a closure in it.

h2. Reproducing

{code:java}
import groovy.transform.CompileStatic

@CompileStatic
class B {
    static int work(int n) {
        def xs = (1..n).toList()
        int total = 0
        total += xs.collect { int v -> v * 2 }.size()
        total += xs.findAll { int v -> v % 2 == 0 }.size()
        total += (xs.inject(0) { int a, int b -> a + b } as int)
        total
    }
    static void main(String[] args) { println "result=${work(20)}" }
}
{code}

{noformat}
JAVA_OPTS="-Dgroovy.target.indy=false -Dgroovy.target.closure.pack=true" \
    groovyc -cp groovy-callsite.jar B.groovy       # emits a single class: 
B.class

java -agentlib:native-image-agent=config-output-dir=cfg -cp 
"groovy.jar:groovy-callsite.jar:." B

native-image --no-fallback -H:ConfigurationFileDirectories=cfg \
    
-H:IncludeResources='META-INF/dgminfo|META-INF/groovy/.*|META-INF/services/.*' \
    -cp "groovy.jar:groovy-callsite.jar:." B
./b   # fails as above
{noformat}

Reproduced with GraalVM CE 25.2.4 (native-image 25.0.4 — the first release 
whose Groovy substitution guard is fixed for Groovy 5+, see oracle/graal#13096; 
earlier releases fail before reaching this).

Note that indy must currently be off, with {{groovy-callsite}} on the *compile* 
classpath: Groovy's own {{IndyInterface}} dispatch uses mutable call sites, 
which native image does not support, and it fails upstream of anything here. 
Whether that larger hurdle can also be removed is under separate investigation; 
either way, closure dispatch has to link natively for any of it to matter.

h2. Root cause

{{GeneratedDispatcher.bootstrap}} does two things an image cannot support:

# {{caller.findStatic(host, "$packedDispatch$", ...)}} — a *runtime* 
method-handle lookup, which under native image needs per-user-class reflection 
metadata. Groovy's own jar cannot ship that, so every user would need an agent 
run.
# Three *programmatic* 
{{LambdaMetafactory.metafactory(...).getTarget().invokeExact()}} calls, each 
defining a hidden class at run time. Bytecode-level LMF invokedynamic is 
pre-processed at image build time and works fine; programmatic metafactory 
calls inside a bootstrap are runtime class definition, which is forbidden.

The surrounding design is already image-friendly: the result is wrapped in a 
{{ConstantCallSite}} and never re-linked.

h2. Fix (prototyped)

Move the linkage from the bootstrap into the class file. {{ClosureWriter}} 
emits one extra synthetic method into the hosting class:

{noformat}
private static synthetic Object $packedDispatchersFactory$() {
    return new Bundle(
        indy LMF[Host::$packedDispatch$],     // GeneratedDispatcher
        indy LMF[Host::$packedDispatch1$],    // Arity1
        indy LMF[Host::$packedDispatch2$]);   // Arity2
}
{noformat}

and the accessor's {{invokedynamic}} passes that factory to the bootstrap as a 
single {{CONSTANT_MethodHandle}} bootstrap argument. The bootstrap becomes one 
line — invoke the factory, wrap the bundle in a {{ConstantCallSite}}.

Why this solves both layers at once:

* The three {{LambdaMetafactory}} sites are ordinary *bytecode-level* 
invokedynamic, so native image pre-processes them at build time — no class is 
defined at run time. On a regular JVM the VM spins the same hidden classes it 
always did, at site-link time instead of from the bootstrap; the JIT-inlining 
rationale for the hidden-class adapters (see the {{GeneratedDispatcher}} 
javadoc) is fully preserved.
* Because the factory lives in the hosting class, its method references reach 
that class's own private dispatch tables directly — no {{Lookup.findStatic}}, 
and hence no per-class reflection metadata. Verified: the tracing agent records 
*zero* {{packedDispatch}} entries for the new bytecode.

There is deliberately *one* code path for JVM and native — no platform 
detection, no fallback machinery, no flags. (An earlier iteration of this 
prototype kept the programmatic-LMF path and added a method-handle-wrapper 
fallback under native image; it worked, but cost a ~1.4 MB image-size pull-in 
of {{jdk.internal.classfile.impl}} via the retained metafactory reference and 
~2x dispatch through uninlined {{invokeExact}}. The factory emission eliminates 
the fallback and both costs, so that machinery has been removed. A 
{{MethodHandles.tableSwitch}}-based dispatch was also evaluated and rejected: 
~75x slower than even the wrapper path under AOT, where the switch combinator 
runs interpreted.)

The previous three-argument bootstrap is retained for class files emitted by 
earlier 6.0 pre-releases; verified by compiling a packed workload with 
6.0.0-beta-1 and running the resulting class files against the patched runtime.

h2. Opt-in / opt-out / permanent?

Permanent and automatic — no user-facing flag. There is a single linkage path 
whose observable behaviour matches the old one on the JVM (same hidden-class 
adapters, same dispatch), and the gate already exists one level up: packing is 
itself opt-in ({{groovy.target.closure.pack}} / {{@PackedClosures}}) and 
experimental under GEP-27.

h2. Correctness verification

||check||result||
|{{PackedDispatcherFactoryTest}} (new): every dispatch shape (array, arity-1, 
arity-2, GDK) links and dispatches through the emitted factory; undeclared 
checked exceptions propagate unchanged|2/2 pass|
|existing packed/closure suites ({{PackedClosuresTransformTest}}, 
{{ClosurePackCapabilityTest}}, {{PackedClosureBoundariesTest}}, 
{{PackedClosureMetaClassTest}}, {{PackedClosureDebugMetadataTest}}, 
{{ClosureAndInnerClassNodeStructureTest}}, {{ClosureCallMopGuardTest}})|57 
tests green|
|wider closure/lambda sweep ({{--tests *Closure* *Lambda*}})|3,147 tests, 0 
failures|
|repro, unpatched master, native|{{Classes cannot be defined at runtime}}|
|repro, patched, native|runs correctly, exit 0|
|agent metadata for the dispatcher|0 entries (was: per-class method 
registrations)|
|class files compiled by 6.0.0-beta-1 (legacy bootstrap), patched runtime, 
JVM|link and run correctly|

h2. Performance

GraalVM CE 25.2.4 / native-image 25.0.4; three workloads (5 closure classes, 
120 closure classes, a dispatch-heavy loop); startup medians over 41 
interleaved A/B runs to cancel machine drift.

*Image size.* Packing costs a fixed ~16 KB (+0.06%), independent of closure 
count:

||workload||unpacked||packed||delta||
|5 closures|29,072,496|29,089,056|+16,560|
|120 closures|29,138,784|29,155,296|+16,512|

*Steady-state dispatch* (dispatch-heavy loop, native): unpacked median 21.1 ms, 
packed 19.5 ms — parity.

*Startup* scales with how many closure classes collapse:

||closure classes||unpacked||packed||change||
|5 -> 1|16.4 ms|17.1 ms|wash (p5 13.9 vs 14.1)|
|120 -> 1|29.3 ms|*15.7 ms*|*+46%*|

Net: with this fix, packing in a native image is a startup win that grows with 
closure-class count, at negligible size cost and no dispatch cost. (Without it, 
packing is a crash.)

h2. Follow-up (not this ticket)

* *Ship Groovy's own reachability metadata* (VMPluginFactory reflection, 
{{dgm$NNN}} classes, {{META-INF/dgminfo}}) in the jar, so the agent step 
disappears for everyone. App-independent and pre-existing; unrelated to packing.


  was:
Packed closures (GROOVY-12151, GEP-27) cannot be used in a GraalVM native 
image. A class compiled with {{groovy.target.closure.pack=true}} links its 
dispatch tables on first closure creation, and that linkage fails inside an 
image — in two layers.

Without agent-recorded metadata, the bootstrap's method lookup fails:

{noformat}
java.lang.BootstrapMethodError: java.lang.NoSuchMethodException:
    no such method: B.$packedDispatch$(int,Object[])Object/invokeStatic
{noformat}

With agent-recorded metadata the lookup succeeds, and the real wall appears — 
the bootstrap spins hidden classes at run time, which an image forbids:

{noformat}
Exception in thread "main" java.lang.BootstrapMethodError: 
java.lang.InternalError:
com.oracle.svm.core.jdk.UnsupportedFeatureError: Classes cannot be defined at 
runtime
by default when using ahead-of-time Native Image compilation. Tried to define 
class:

    B$$LambdadLbm0tKUrZ8kVMiSpulnyK
{noformat}

The failure mode is the problem as much as the failure: packing compiles 
cleanly, the image builds cleanly, and the process then dies on the first 
closure created. There is no diagnostic pointing at the flag that caused it.

"Just don't pack in native images" is reasonable advice for some workloads (see 
Performance below), but it cannot be the whole answer: the compiler has no way 
to know a native image will be built later — the class files are identical 
either way — so the guidance cannot be enforced at compile time. The realistic 
path into this is enabling packing globally for its JVM benefit and adding a 
native build afterwards. That leaves only a better runtime error (same 
detection point, comparable effort, strictly less capability) or the fix below.

h2. Reproducing

{code:java}
import groovy.transform.CompileStatic

@CompileStatic
class B {
    static int work(int n) {
        def xs = (1..n).toList()
        int total = 0
        total += xs.collect { int v -> v * 2 }.size()
        total += xs.findAll { int v -> v % 2 == 0 }.size()
        total += (xs.inject(0) { int a, int b -> a + b } as int)
        total
    }
    static void main(String[] args) { println "result=${work(20)}" }
}
{code}

{noformat}
JAVA_OPTS="-Dgroovy.target.indy=false -Dgroovy.target.closure.pack=true" \
    groovyc -cp groovy-callsite.jar B.groovy       # emits a single class: 
B.class

java -agentlib:native-image-agent=config-output-dir=cfg -cp 
"groovy.jar:groovy-callsite.jar:." B

native-image --no-fallback -H:ConfigurationFileDirectories=cfg \
    
-H:IncludeResources='META-INF/dgminfo|META-INF/groovy/.*|META-INF/services/.*' \
    -cp "groovy.jar:groovy-callsite.jar:." B
./b   # fails as above
{noformat}

Reproduced with GraalVM CE 25.2.4 (native-image 25.0.4 — the first release 
whose Groovy substitution guard is fixed for Groovy 5+, see oracle/graal#13096; 
earlier releases fail before reaching this).

Note that indy must already be off, with {{groovy-callsite}} on the *compile* 
classpath: Groovy's own {{IndyInterface}} dispatch uses mutable call sites, 
which native image does not support, and it fails upstream of anything here. So 
this ticket only affects users who have already crossed that larger hurdle.

h2. Root cause

{{GeneratedDispatcher.bootstrap}} does two things an image cannot support:

# {{caller.findStatic(host, "$packedDispatch$", ...)}} — a *runtime* 
method-handle lookup, which under native image needs per-user-class reflection 
metadata. Groovy's own jar cannot ship that, so every user would need an agent 
run.
# Three programmatic 
{{LambdaMetafactory.metafactory(...).getTarget().invokeExact()}} calls, each 
defining a hidden class at run time. Bytecode-level LMF invokedynamic is 
pre-processed at image build time and works fine; *programmatic* metafactory 
calls inside a bootstrap are runtime class definition, which is forbidden.

The surrounding design is already image-friendly: the result is wrapped in a 
{{ConstantCallSite}} and never re-linked.

h2. Proposed fix (prototyped)

Two layered changes. JVM semantics are unchanged.

*1. Constant bootstrap arguments (ClosureWriter).* Emit the three dispatch 
tables as {{CONSTANT_MethodHandle}} bootstrap arguments, as javac does for 
LMF's {{implMethod}}:

{noformat}
invokedynamic packedDispatchers()Object
    bsm  = IndyInterface.packedDispatchers(Lookup, String, MethodType,
                                           MethodHandle, MethodHandle, 
MethodHandle)
    args = [ MH(invokestatic host.$packedDispatch$), MH(...1$), MH(...2$) ]
{noformat}

Constant-pool method handles are resolved by the VM (and pre-resolved at image 
build time), so linking needs no {{findStatic}} and no metadata. Verified: the 
tracing agent records *zero* {{packedDispatch}} entries for the new bytecode. 
The existing 3-arg bootstrap is kept for class files emitted by earlier 6.0 
snapshots.

*2. Method-handle bundle fallback (GeneratedDispatcher).* On a regular JVM the 
{{LambdaMetafactory}} hidden-class adapters are kept — their interface call 
inlines under the JIT, which is the documented rationale. Where classes cannot 
be defined at run time, the tables are wrapped in method-handle-invoking 
adapters instead: ordinary Java lambdas of {{GeneratedDispatcher}} itself, 
whose bytecode-level LMF sites are AOT-compiled into the image, so nothing is 
defined at run time.

Detection is 
{{"runtime".equals(System.getProperty("org.graalvm.nativeimage.imagecode"))}}, 
evaluated *per link* rather than cached in a static — under native image the 
class may be build-time initialized, where the property reports {{buildtime}}, 
and caching would bake the wrong answer into the image heap. A catch-based 
fallback around the metafactory calls covers AOT runtimes the property probe 
misses.

One subtlety: dispatch targets may throw checked exceptions the dispatcher 
interfaces do not declare, and the hidden-class path propagates them 
transparently. The wrapper path rethrows via {{UncheckedThrow}} to match; a 
dedicated test covers this.

h2. Opt-in / opt-out / permanent?

Permanent and automatic — no user-facing flag:
* JVM behaviour is byte-identical; the fallback engages only where the 
hidden-class path *cannot work*. A flag would choose between "works" and 
"crash".
* The gate already exists one level up: packing is itself opt-in 
({{groovy.target.closure.pack}} / {{@PackedClosures}}) and experimental under 
GEP-27.
* {{-Dgroovy.packed.dispatch.handles=true}} exists as a *diagnostic* knob only, 
forcing the wrapper path on a JVM so CI can assert parity without a native 
build.

h2. Correctness verification

||check||result||
|{{PackedDispatcherHandleBundleTest}} (new): parity across all three dispatch 
shapes and the GDK; undeclared checked-exception propagation|2/2 pass|
|existing packed/closure suites ({{PackedClosuresTransformTest}}, 
{{ClosurePackCapabilityTest}}, {{PackedClosureBoundariesTest}}, 
{{PackedClosureMetaClassTest}}, {{PackedClosureDebugMetadataTest}}, 
{{ClosureAndInnerClassNodeStructureTest}}, {{ClosureCallMopGuardTest}})|57 
tests green|
|repro, unpatched master, native|{{Classes cannot be defined at runtime}}|
|repro, patched, native|runs correctly, exit 0, no exceptions|
|agent metadata for the dispatcher|0 entries (was: per-class method 
registrations)|

h2. Performance

Measured on GraalVM CE 25.2.4 / native-image 25.0.4 across three workloads: a 
5-closure program, a 120-closure program, and a dispatch-heavy loop. *The 
honest summary: this fix is cheap, but packing in a native image is a 
startup-versus-dispatch trade that only pays off above a closure-class 
threshold. This ticket makes that choice available rather than fatal; it does 
not make packing universally advisable.*

*Cost of the fix itself.* Isolated by building the same packed sources against 
unpatched master (which builds, then fails at run time) and against the patch:

||build||image size||delta||
|unpacked|29,138,784 bytes| |
|packed, unpatched master (does not run)|30,528,208 bytes|+1.39 MB|
|packed, patched (runs)|30,643,936 bytes|+1.51 MB|

So the fix adds *~113 KB (+0.4%)*. The larger +1.39 MB is pre-existing packing 
overhead, not introduced here: {{jdk.internal.classfile.impl}} (561 KiB) is 
pulled in by the {{LambdaMetafactory}} reference that master already had.

*Image size.* Packing makes the image ~1.4 MB *bigger*, not smaller. The 
121-to-1 class collapse does not translate into image savings — native image 
already handles many small classes efficiently, and the {{PackedClosure}} 
runtime is a fixed cost. (An earlier draft of this ticket asserted the 
opposite; the measurements above correct it.)

The overhead is *fixed, not proportional* — identical at both ends of the 
range, which matters for the threshold below:

||closure classes||packing delta||
|5|+1.44 MB|
|120|+1.44 MB|

*Steady-state dispatch.*

||workload||native||JVM||
|unpacked|20–21 ms|18–21 ms|
|packed|*35–41 ms (~2x slower)*|19–20 ms|
|packed, MH path forced on JVM|—|20.2–20.4 ms (~7% over hidden-class)|

The JVM is unaffected: the wrapper costs ~7% there and is not used anyway. In 
an image there is no JIT to fold the {{invokeExact}}, so the indirection shows 
up in full on this deliberately dispatch-heavy loop; ordinary code will see 
less.

*Startup, and the crossover.* Packing's win comes from eliminating class 
initializations, so it scales with how many closure classes collapse. Process 
wall-clock, median of 41 runs, A/B interleaved to cancel machine drift:

||closure classes||unpacked||packed||change||
|5 -> 1|13.6 ms|13.7 ms|-1.4% (noise; p5 12.7 vs 12.8)|
|120 -> 1|23.9 ms|*13.2 ms*|*+44.8%*|

In-process work time was identical (6 ms) in the 5-closure case, confirming 
that difference is image init rather than dispatch.

*What this means in practice.* The two effects run opposite ways, and the fixed 
+1.44 MB sits on the cost side regardless:

* *Few closure classes* — no startup win to collect, so you pay the dispatch 
overhead and the image size for nothing. Do not pack.
* *Many closure classes* — class-init savings dominate and startup nearly 
halves, which is the metric native images exist to optimize. Packing is worth 
it unless the process is long-lived and dispatch-bound.

The threshold is workload-specific; the honest advice is to measure both ways 
rather than assume. Note also that the dispatch cost is provisional — see the 
{{tableSwitch}} follow-up below, which would remove the cost side of this trade 
entirely.

h2. Follow-ups (not this ticket)

* *Drop the LMF reference on the native path* so 
{{jdk.internal.classfile.impl}} (561 KiB) falls out of the image, removing most 
of packing's size overhead.
* *A class-free dispatch that is not method-handle-based* — for example 
{{MethodHandles.tableSwitch}}, or a generated switch — to close the ~2x 
steady-state gap. That would turn native packing from a trade into a win.
* *Ship Groovy's own reachability metadata* (VMPluginFactory reflection, 
{{dgm$NNN}} classes, {{META-INF/dgminfo}}) in the jar, so the agent step 
disappears for everyone. App-independent and pre-existing; unrelated to packing.



> GeneratedDispatcher: avoid runtime class definition so packed closures work 
> in native images 
> ---------------------------------------------------------------------------------------------
>
>                 Key: GROOVY-12227
>                 URL: https://issues.apache.org/jira/browse/GROOVY-12227
>             Project: Groovy
>          Issue Type: Improvement
>            Reporter: Paul King
>            Assignee: Paul King
>            Priority: Major
>
> Packed closures (GROOVY-12151, GEP-27) cannot be used in a GraalVM native 
> image. A class compiled with {{groovy.target.closure.pack=true}} links its 
> dispatch tables on first closure creation, and that linkage fails inside an 
> image — in two layers.
> Without agent-recorded metadata, the bootstrap's method lookup fails:
> {noformat}
> java.lang.BootstrapMethodError: java.lang.NoSuchMethodException:
>     no such method: B.$packedDispatch$(int,Object[])Object/invokeStatic
> {noformat}
> With agent-recorded metadata the lookup succeeds, and the real wall appears — 
> the bootstrap spins hidden classes at run time, which an image forbids:
> {noformat}
> Exception in thread "main" java.lang.BootstrapMethodError: 
> java.lang.InternalError:
> com.oracle.svm.core.jdk.UnsupportedFeatureError: Classes cannot be defined at 
> runtime
> by default when using ahead-of-time Native Image compilation. Tried to define 
> class:
>     B$$LambdadLbm0tKUrZ8kVMiSpulnyK
> {noformat}
> The failure mode is the problem as much as the failure: packing compiles 
> cleanly, the image builds cleanly, and the process then dies on the first 
> closure created. There is no diagnostic pointing at the flag that caused it, 
> and the guidance cannot be enforced at compile time — the class files are 
> identical whether or not a native image is built later. The realistic path 
> into this is enabling packing globally for its JVM benefit and adding a 
> native build afterwards. It also matters for GEP-27's plan to flip packing 
> default-on in Groovy 7: at that point this stops being an opt-in corner and 
> becomes every Groovy native image with a closure in it.
> h2. Reproducing
> {code:java}
> import groovy.transform.CompileStatic
> @CompileStatic
> class B {
>     static int work(int n) {
>         def xs = (1..n).toList()
>         int total = 0
>         total += xs.collect { int v -> v * 2 }.size()
>         total += xs.findAll { int v -> v % 2 == 0 }.size()
>         total += (xs.inject(0) { int a, int b -> a + b } as int)
>         total
>     }
>     static void main(String[] args) { println "result=${work(20)}" }
> }
> {code}
> {noformat}
> JAVA_OPTS="-Dgroovy.target.indy=false -Dgroovy.target.closure.pack=true" \
>     groovyc -cp groovy-callsite.jar B.groovy       # emits a single class: 
> B.class
> java -agentlib:native-image-agent=config-output-dir=cfg -cp 
> "groovy.jar:groovy-callsite.jar:." B
> native-image --no-fallback -H:ConfigurationFileDirectories=cfg \
>     
> -H:IncludeResources='META-INF/dgminfo|META-INF/groovy/.*|META-INF/services/.*'
>  \
>     -cp "groovy.jar:groovy-callsite.jar:." B
> ./b   # fails as above
> {noformat}
> Reproduced with GraalVM CE 25.2.4 (native-image 25.0.4 — the first release 
> whose Groovy substitution guard is fixed for Groovy 5+, see 
> oracle/graal#13096; earlier releases fail before reaching this).
> Note that indy must currently be off, with {{groovy-callsite}} on the 
> *compile* classpath: Groovy's own {{IndyInterface}} dispatch uses mutable 
> call sites, which native image does not support, and it fails upstream of 
> anything here. Whether that larger hurdle can also be removed is under 
> separate investigation; either way, closure dispatch has to link natively for 
> any of it to matter.
> h2. Root cause
> {{GeneratedDispatcher.bootstrap}} does two things an image cannot support:
> # {{caller.findStatic(host, "$packedDispatch$", ...)}} — a *runtime* 
> method-handle lookup, which under native image needs per-user-class 
> reflection metadata. Groovy's own jar cannot ship that, so every user would 
> need an agent run.
> # Three *programmatic* 
> {{LambdaMetafactory.metafactory(...).getTarget().invokeExact()}} calls, each 
> defining a hidden class at run time. Bytecode-level LMF invokedynamic is 
> pre-processed at image build time and works fine; programmatic metafactory 
> calls inside a bootstrap are runtime class definition, which is forbidden.
> The surrounding design is already image-friendly: the result is wrapped in a 
> {{ConstantCallSite}} and never re-linked.
> h2. Fix (prototyped)
> Move the linkage from the bootstrap into the class file. {{ClosureWriter}} 
> emits one extra synthetic method into the hosting class:
> {noformat}
> private static synthetic Object $packedDispatchersFactory$() {
>     return new Bundle(
>         indy LMF[Host::$packedDispatch$],     // GeneratedDispatcher
>         indy LMF[Host::$packedDispatch1$],    // Arity1
>         indy LMF[Host::$packedDispatch2$]);   // Arity2
> }
> {noformat}
> and the accessor's {{invokedynamic}} passes that factory to the bootstrap as 
> a single {{CONSTANT_MethodHandle}} bootstrap argument. The bootstrap becomes 
> one line — invoke the factory, wrap the bundle in a {{ConstantCallSite}}.
> Why this solves both layers at once:
> * The three {{LambdaMetafactory}} sites are ordinary *bytecode-level* 
> invokedynamic, so native image pre-processes them at build time — no class is 
> defined at run time. On a regular JVM the VM spins the same hidden classes it 
> always did, at site-link time instead of from the bootstrap; the JIT-inlining 
> rationale for the hidden-class adapters (see the {{GeneratedDispatcher}} 
> javadoc) is fully preserved.
> * Because the factory lives in the hosting class, its method references reach 
> that class's own private dispatch tables directly — no {{Lookup.findStatic}}, 
> and hence no per-class reflection metadata. Verified: the tracing agent 
> records *zero* {{packedDispatch}} entries for the new bytecode.
> There is deliberately *one* code path for JVM and native — no platform 
> detection, no fallback machinery, no flags. (An earlier iteration of this 
> prototype kept the programmatic-LMF path and added a method-handle-wrapper 
> fallback under native image; it worked, but cost a ~1.4 MB image-size pull-in 
> of {{jdk.internal.classfile.impl}} via the retained metafactory reference and 
> ~2x dispatch through uninlined {{invokeExact}}. The factory emission 
> eliminates the fallback and both costs, so that machinery has been removed. A 
> {{MethodHandles.tableSwitch}}-based dispatch was also evaluated and rejected: 
> ~75x slower than even the wrapper path under AOT, where the switch combinator 
> runs interpreted.)
> The previous three-argument bootstrap is retained for class files emitted by 
> earlier 6.0 pre-releases; verified by compiling a packed workload with 
> 6.0.0-beta-1 and running the resulting class files against the patched 
> runtime.
> h2. Opt-in / opt-out / permanent?
> Permanent and automatic — no user-facing flag. There is a single linkage path 
> whose observable behaviour matches the old one on the JVM (same hidden-class 
> adapters, same dispatch), and the gate already exists one level up: packing 
> is itself opt-in ({{groovy.target.closure.pack}} / {{@PackedClosures}}) and 
> experimental under GEP-27.
> h2. Correctness verification
> ||check||result||
> |{{PackedDispatcherFactoryTest}} (new): every dispatch shape (array, arity-1, 
> arity-2, GDK) links and dispatches through the emitted factory; undeclared 
> checked exceptions propagate unchanged|2/2 pass|
> |existing packed/closure suites ({{PackedClosuresTransformTest}}, 
> {{ClosurePackCapabilityTest}}, {{PackedClosureBoundariesTest}}, 
> {{PackedClosureMetaClassTest}}, {{PackedClosureDebugMetadataTest}}, 
> {{ClosureAndInnerClassNodeStructureTest}}, {{ClosureCallMopGuardTest}})|57 
> tests green|
> |wider closure/lambda sweep ({{--tests *Closure* *Lambda*}})|3,147 tests, 0 
> failures|
> |repro, unpatched master, native|{{Classes cannot be defined at runtime}}|
> |repro, patched, native|runs correctly, exit 0|
> |agent metadata for the dispatcher|0 entries (was: per-class method 
> registrations)|
> |class files compiled by 6.0.0-beta-1 (legacy bootstrap), patched runtime, 
> JVM|link and run correctly|
> h2. Performance
> GraalVM CE 25.2.4 / native-image 25.0.4; three workloads (5 closure classes, 
> 120 closure classes, a dispatch-heavy loop); startup medians over 41 
> interleaved A/B runs to cancel machine drift.
> *Image size.* Packing costs a fixed ~16 KB (+0.06%), independent of closure 
> count:
> ||workload||unpacked||packed||delta||
> |5 closures|29,072,496|29,089,056|+16,560|
> |120 closures|29,138,784|29,155,296|+16,512|
> *Steady-state dispatch* (dispatch-heavy loop, native): unpacked median 21.1 
> ms, packed 19.5 ms — parity.
> *Startup* scales with how many closure classes collapse:
> ||closure classes||unpacked||packed||change||
> |5 -> 1|16.4 ms|17.1 ms|wash (p5 13.9 vs 14.1)|
> |120 -> 1|29.3 ms|*15.7 ms*|*+46%*|
> Net: with this fix, packing in a native image is a startup win that grows 
> with closure-class count, at negligible size cost and no dispatch cost. 
> (Without it, packing is a crash.)
> h2. Follow-up (not this ticket)
> * *Ship Groovy's own reachability metadata* (VMPluginFactory reflection, 
> {{dgm$NNN}} classes, {{META-INF/dgminfo}}) in the jar, so the agent step 
> disappears for everyone. App-independent and pre-existing; unrelated to 
> packing.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to