Skip to content

GROOVY-12151 closure packing with per-class dispatch tables and full MOP integration (GEP-27 S1)#2709

Draft
paulk-asert wants to merge 3 commits into
apache:masterfrom
paulk-asert:groovy12151b
Draft

GROOVY-12151 closure packing with per-class dispatch tables and full MOP integration (GEP-27 S1)#2709
paulk-asert wants to merge 3 commits into
apache:masterfrom
paulk-asert:groovy12151b

Conversation

@paulk-asert

@paulk-asert paulk-asert commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

GROOVY-12151: closure packing (GEP-27 S1) with per-class dispatch tables and full MOP integration

Under @PackedClosures (dynamic trust path) or -Dgroovy.target.closure.pack=true under @CompileStatic (where the type checker PROVES delegate-independence), an eligible closure literal's body is hoisted into a synthetic private method on the enclosing class and the literal is replaced by a shared PackedClosure adapter — removing the per-closure generated class (and the nested $_closure1$_closure2 name explosion) while the value stays a real groovy.lang.Closure. Ineligible closures (escaping, delegate-dependent, default-param, nested-in-generated-function) keep their class exactly as today; opt-out via @PackedClosures(mode = DISABLED).

Dispatch architecture

Each hosting class gets compiler-generated dispatch tables: a general $packedDispatch$(int id, Object[]) covering every hoisted target, plus array-free per-arity tables ($packedDispatch1$/$packedDispatch2$) for targets taking one or two values beyond the receiver (captures count as values — the overwhelmingly common shapes), each case casting to the target's declared parameter types and invoking directly. Tables observe the JIT's inline budget via two-level chunking. One invokedynamic accessor per class links all three through GeneratedDispatcher.bootstrap into a constant Bundle, lazily on first adapter creation; each literal's parameter-type Class[] is a condy constant resolved once per site. The whole chain is ordinary bytecode — no reflection, and no per-instance MethodHandle (not constant-foldable, hence many times slower). The id binds the exact hoisted method, so an inherited packed closure can never misdispatch to a subclass's same-named method.

MOP integration (from the dev@ closure-dispatch discussion)

  • Packed dispatch is guarded by the same mechanism as Closure.call's cache (GROOVY-12165): the direct path is taken only when the instance's metaclass is stock and no category is active; anything MOP-relevant routes through invokeMethod. Interception via a per-instance metaclass is honoured identically on the dynamic and Java/GDK paths (pinned by tests, matching generated closure classes).
  • PackedClosureMetaClass is the registered stock metaclass for the adapter (the analog of ClosureMetaClass for generated closures): reflection-free call/doCall dispatch, respondsTo faithful to the instance's declared parameter types, and the same category stance as ClosureMetaClass (which also fixes a latent packed-vs-classed divergence under use{}).
  • Runtime guard: the dynamic trust path fails fast if a delegate or delegate-consulting resolveStrategy is set on a packed closure; the statically proven path stores-and-ignores, like a @CompileStatic closure class.

Documented residual semantic differences (all packed closures share one adapter class): per-literal class identity is gone (closure.getClass() distinctness, serialization); class-level PackedClosure.metaClass changes are global to packed closures (per-instance setMetaClass is fully honoured via the guard).

JPMS note

Packed dispatch uses the capability model Java lambdas use: the hosting class's own invokedynamic bootstrap captures its lookup, and handing the closure across a module boundary is the grant — so packed closures work under strict encapsulation with no opens, where reflective dispatch of closure classes does not. This moves enforcement from invocation time to creation time, deliberately.

Performance (JMH ClosurePackBench, 2 forks, ops/ms, vs current master incl. GROOVY-12164/12165)

benchmark classes packed ratio
sum_mono (written-capture each) 9,103 ± 362 12,347 ± 321 1.36x
sum_mega (16 classes, megamorphic) 6,353 ± 1,071 12,228 ± 408 1.92x
squared_mono (non-capturing collect) 19,123 ± 684 11,503 ± 117 0.60x
squared_mega 18,127 ± 771 12,145 ± 602 0.67x

Capturing shapes are faster packed — and the win holds under megamorphism; the tight non-capturing collect trails, the cost being the MOP-transparency guard (loop-invariant loads the JIT hoists when the chain inlines; the same checks the classic call-site caches make) plus the per-creation adapter instance (a per-literal singleton is unsound: Closure carries mutable per-instance state). Allocation is at or below the closure-class baseline on capturing shapes (e.g. times-accumulator 160 vs 368 bytes/call) and within one adapter instance of it on non-capturing ones. Earlier figures posted in this thread predate the MOP guard and the 12164/12165 baseline improvements; this table supersedes them.

In-situ check: Grails-shaped workloads

To verify the micro table translates to realistic code, a Grails-patterned fixture (mirroring the GrailsWorkloadBench shapes: an employee findAll/collect/groupBy/collectEntries pipeline and a map-iteration validation cycle with a written-capture accumulator, @PackedClosures trust path — every literal packs) was measured classes-vs-packed on the same build, interleaved rounds:

workload classes packed delta
domain pipeline 219-222 ops/ms 272-273 ops/ms +23%
validation cycle 1,962-2,001 ops/ms 2,298-2,428 ops/ms +17-21%
classes emitted 10 (8 closure classes) 2

(Both compilations produce identical outputs for these workloads, so the comparison is two implementations of the same computation; the broader behavioural-equivalence evidence is the corpus parity and test suite below.) The blend is the point: realistic pipelines mix capturing, multi-argument and non-capturing dispatches with real body work, and the mix nets strongly positive — the micro table's adverse non-capturing row is real but dilutes out in situ (further lane micro-tuning was assessed against these shapes and found to have no in-situ effect, so it is deliberately not pursued).

Code size on the 29-closure measurement corpus: 30 -> 2 classes, -73% bytecode bytes; corpus transcripts byte-identical with the flag on and off; full suite 15,780 green.

Commits

Packing base (capability analysis, hoisting, escape/decline rules, @PackedClosures modes) -> per-class-table optimisation (general + per-arity tables, condy parameter types, ClosurePackBench) -> MOP integration slices (dispatch guard; doCall alignment; PackedClosureMetaClass). Rebased on master after GROOVY-12165 merged; the Closure.java delta of this PR is a single line (the stock-metaclass set gains PackedClosureMetaClass).

@paulk-asert paulk-asert changed the title Groovy12151b GROOVY-12151 closure-packing performance: fix D (per-class dispatch table) Jul 14, 2026
@paulk-asert
paulk-asert marked this pull request as draft July 14, 2026 06:13
@paulk-asert
paulk-asert requested a review from Copilot July 14, 2026 06:52

This comment was marked as outdated.

@codecov-commenter

codecov-commenter commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.26087% with 170 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.1628%. Comparing base (538f618) to head (dd8f600).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
...ava/org/codehaus/groovy/runtime/PackedClosure.java 55.1724% 53 Missing and 12 partials ⚠️
...rg/codehaus/groovy/classgen/asm/ClosureWriter.java 88.2918% 19 Missing and 42 partials ⚠️
...oovy/classgen/asm/sc/StaticTypesClosureWriter.java 41.5094% 22 Missing and 9 partials ⚠️
...oovy/runtime/metaclass/PackedClosureMetaClass.java 57.1429% 1 Missing and 5 partials ⚠️
...ovy/classgen/asm/sc/StaticTypesCallSiteWriter.java 0.0000% 0 Missing and 3 partials ⚠️
...g/codehaus/groovy/runtime/GeneratedDispatcher.java 86.9565% 3 Missing ⚠️
...c/main/java/org/codehaus/groovy/ast/ClassNode.java 90.0000% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #2709        +/-   ##
==================================================
+ Coverage     69.1087%   69.1628%   +0.0542%     
- Complexity      34271      34452       +181     
==================================================
  Files            1537       1542         +5     
  Lines          129483     130252       +769     
  Branches        23552      23735       +183     
==================================================
+ Hits            89484      90086       +602     
- Misses          31969      32064        +95     
- Partials         8030       8102        +72     
Files with missing lines Coverage Δ
src/main/java/groovy/lang/Closure.java 79.6143% <100.0000%> (ø)
src/main/java/groovy/lang/MetaClassRegistry.java 69.5652% <100.0000%> (+2.8986%) ⬆️
src/main/java/groovy/transform/PackedClosures.java 100.0000% <100.0000%> (ø)
...rg/codehaus/groovy/classgen/AsmClassGenerator.java 85.0725% <100.0000%> (+0.0108%) ⬆️
...org/codehaus/groovy/classgen/asm/PackStrategy.java 100.0000% <100.0000%> (ø)
...c/main/java/org/codehaus/groovy/ast/ClassNode.java 87.7219% <90.0000%> (ø)
...ovy/classgen/asm/sc/StaticTypesCallSiteWriter.java 78.2222% <0.0000%> (-0.5724%) ⬇️
...g/codehaus/groovy/runtime/GeneratedDispatcher.java 86.9565% <86.9565%> (ø)
...oovy/runtime/metaclass/PackedClosureMetaClass.java 57.1429% <57.1429%> (ø)
...oovy/classgen/asm/sc/StaticTypesClosureWriter.java 66.3717% <41.5094%> (-21.9617%) ⬇️
... and 2 more

... and 7 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@paulk-asert paulk-asert changed the title GROOVY-12151 closure-packing performance: fix D (per-class dispatch table) GROOVY-12151 closure packing with per-class dispatch tables and full MOP integration (GEP-27 S1) Jul 15, 2026
@paulk-asert
paulk-asert force-pushed the groovy12151b branch 7 times, most recently from c9f5f77 to 393a578 Compare July 16, 2026 09:10
…les and full MOP integration

Under @PackedClosures (dynamic trust path) or -Dgroovy.target.closure.pack=true under @CompileStatic (where the type checker PROVES delegate-independence), an eligible closure literal's body is hoisted into a synthetic private method on the enclosing class and the literal is replaced by a shared PackedClosure adapter — removing the per-closure generated class (and the nested $_closure1$_closure2 name explosion) while the value stays a real groovy.lang.Closure. Ineligible closures (escaping, delegate-dependent, default-param, nested-in-generated-function) keep their class exactly as today; opt-out via @PackedClosures(mode = DISABLED).
One branch to an out-of-line callGeneral keeps the hot lane small (the
argument-wrapping fallbacks allocate, so they leave the lane) and its
standalone compilation lean. Assessed against Grails-shaped packed
workloads (findAll/collect/groupBy pipelines, map-iteration validation):
no measurable in-situ effect -- per-dispatch cost is already invisible
under real body work -- and ~5-9% on the adverse non-capturing
microbenchmark rows with the capturing rows unchanged. Kept as lane
hygiene; further lane tuning is demonstrably not worth pursuing.
@testlens-app

testlens-app Bot commented Jul 16, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: dd8f600
▶️ Tests: 99557 executed
⚪️ Checks: 31/31 completed


Learn more about TestLens at testlens.app.

// zero-parameter form ({ -> ... }), which must report arity 0 (GString's writer-vs-call
// branch and SAM matching key on it), exactly as its generated class would.
Parameter[] declared = expression.getParameters();
boolean implicitParam = (declared != null && declared.length == 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is declared==null? Why not boolean implicitParam = (declared == null || declared.length == 0);

// types, so the body's load then carries the type the use sites were compiled
// against — where a plain Object parameter loads unverifiably wide (the classed
// emission gets the equivalent cast on its shared-Reference dereference).
ClassNode capturedType = typedBody ? capturedTypes.get(cn) : ClassHelper.OBJECT_TYPE;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am using this line to attach the comment, but it is not limited to this. I see here still a mix of static and dynamic compiler code. For me the dynamic case should be the general one and static compilation should be a specialization, separated from the dynamic code generator. Why like this? Because if we change something for static compilation it is in one class and if we change something for dynamic compilation it is in another class. Andy dynamic>static because that is how we do it in other places as well, just trying to keep the style.

ClassNode capturedType = typedBody ? capturedTypes.get(cn) : ClassHelper.OBJECT_TYPE;
Variable v = capturedVars.get(cn);
if (typedBody && v instanceof org.codehaus.groovy.ast.ASTNode) {
Object inferred = ((org.codehaus.groovy.ast.ASTNode) v)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 Points here... Why the fully qualified name? (This is in other places as well) and can we not use x instanceof T t for this? It is a minor point. Just mentioning it because the long lines stick out.

if (implicitParam) pc = pc + "$FixedIt";
else if (arity <= 4 && !varargShape) pc = pc + "$Fixed" + arity;
else pc = pc + "$FixedN";
mv.visitTypeInsn(NEW, pc);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we start supporting default parameters, this kind of logic will no work. Not a blocker, just a note.

os.box();
}
mv.visitInsn(AASTORE);
os.remove(3);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don`t think we need "os" here, except for loading the variable and boxing it. But you could directly remove 1 after that and otherwise remove the os-code. That is only 4 lines of code less of course. Just irritates me a bit the way it is here.

typesDescriptor.append(")V");
mv.visitLdcInsn(new org.objectweb.asm.ConstantDynamic("paramTypes", "[Ljava/lang/Class;",
new org.objectweb.asm.Handle(org.objectweb.asm.Opcodes.H_INVOKESTATIC, DISPATCHER_TYPE, "paramTypes",
"(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;Ljava/lang/String;)[Ljava/lang/Class;",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should consider the interface surface with bytecode. Right now we have SBC, DTT and IndyInterface mostly, but not only. I would prefer this in a method in IndyInterface, to have a central point of methods we have to keep caring about. The same about the classes we expose in bytecode... would it be a problem to have a closureFactory on one of the already exposed classes instead? I mean to produce instances of Bundle, PackedClosure, PackedClosure.Fixed and so on. It is slightly less efficient I imagine, but I doubt the impact would be major compared to the object creation cost.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants