Skip to content

GROOVY-12234: Indy aot spike - #2766

Draft
paulk-asert wants to merge 3 commits into
apache:masterfrom
paulk-asert:indy-aot-spike
Draft

GROOVY-12234: Indy aot spike#2766
paulk-asert wants to merge 3 commits into
apache:masterfrom
paulk-asert:indy-aot-spike

Conversation

@paulk-asert

@paulk-asert paulk-asert commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

…packed closures work in native images

Two layered changes to the GROOVY-12151 packed-closure machinery (GEP-27):

1. ClosureWriter now passes the three dispatch tables as constant bootstrap
   arguments (CONSTANT_MethodHandle), resolved by the VM's constant pool
   rather than a runtime Lookup.findStatic -- which under GraalVM native
   image demanded per-class reflection metadata. Verified: the tracing
   agent records zero packedDispatch entries for the new bytecode.

2. GeneratedDispatcher.bootstrap keeps the LambdaMetafactory hidden-class
   adapters on a regular JVM (the JIT-inlining rationale in the class
   javadoc), but where classes cannot be defined at run time -- native
   image, detected per link so build-time class init cannot bake in the
   wrong answer -- it adapts the tables with method-handle-invoking
   wrappers instead: ordinary bytecode of this class, AOT-compiled into
   the image. A catch-based fallback covers AOT runtimes the property
   probe misses. -Dgroovy.packed.dispatch.handles=true forces the wrapper
   path on a JVM, for parity testing.

Permanent behaviour, no user-facing flag: JVM semantics are unchanged and
the fallback engages only where the hidden-class path cannot work. The
old 3-arg bootstrap remains for class files from earlier 6.0 snapshots.

Before/after on GraalVM 25.2.4 (native-image 25.0.4): the packed repro
previously failed with 'Classes cannot be defined at runtime ...
M$$Lambda...'; it now runs correctly (single emitted class, 30MB image,
~12ms total run time). Parity + undeclared-checked-exception propagation
covered by PackedDispatcherHandleBundleTest; all existing packed-closure
suites green.
…native images (spike)

Native image supports every java.lang.invoke building block Groovy's indy
runtime uses except retargeting an existing call site: MutableCallSite.setTarget
and SwitchPoint.invalidateAll both fail with UnsupportedFeatureError
(setCallSiteTargetNormal). In this design those primitives only ever install or
invalidate caches -- dispatch semantics live in method selection -- so under AOT
(imagecode == runtime, or -Dgroovy.indy.aot.link=true on a JVM for testing):

* bootstrap links each site once, permanently, to its cache-consulting default
  path via ConstantCallSite; the CacheableCallSite becomes a state carrier
  (PIC, fallback target) and is never installed or retargeted -- its setTarget
  now fails fast in AOT mode so a missed gate surfaces on the JVM too
* cache freshness moves from SwitchPoint guards (which can never fire natively)
  to a global AotDispatch stamp: bumped by every invalidation path (all three
  SwitchPoint.invalidateAll call sites now funnel through
  AotDispatch.invalidateAll), captured per MethodHandleWrapper at selection,
  compared on every PIC hit; mismatch re-selects
* the reflective cold tier (GROOVY-12137) is the AOT steady state: promotion
  to full MH chains is gated off, since chains run in the native MH interpreter
  while reflective dispatch uses AOT-compiled invocation stubs

Also works around a GraalVM linkage gap found en route: the runtime
invokedynamic path invokes a bootstrap method without running its declaring
class's <clinit> (observed CE 25.2.4; ordinary getstatic barriers work, and
Class.forName(initialize=true) does not recover). Every BSM entry calls
ensureInitialized(), which triggers initialization through a cross-class read.

JVM behaviour is unchanged: mode is decided per link (never cached in statics,
which native image may initialize at build time), the stamp is written but
never read outside AOT mode, and no per-invocation platform checks exist --
hot paths read a site-local flag captured at link time.

Verified: 20-scenario dynamic gauntlet (EMC change seen by a hot site,
per-instance metaclass, category enter/leave transitions, polymorphic dispatch,
property read/write, GDK/closures, operators) passes on JVM normal mode, JVM
AOT mode, and in a native image built from stock-indy class files with only
agent-recorded metadata -- no extra flags. Category/EMC/registry/indy suites:
1529 tests, 0 failures. Native startup for the gauntlet: 19.5 ms vs 320.7 ms
on the JVM. Known limitation: steady-state dynamic dispatch is ~12 us/call
natively (vs ~25 ns JVM JIT; ~120 ns JVM AOT-mode control) -- dominated by the
per-call boot-handle combinator chain in the native MH interpreter; a shallow
constant target is the identified follow-up.
…dispatch floor

Replace the AOT-linked site's target -- previously the JVM path's deep boot
handle (bind + fold + exactInvoker + collector + asType) -- with a single
bound handle into aotDispatch(), a plain-Java dispatcher doing PIC lookup
(new allocation-free CacheableCallSite.getIfPresent), AotDispatch stamp
freshness, sentinel semantics matching fromCacheHandle, and direct invocation
of the reflective cold tier without re-entering the method-handle machinery.
The now-dead AOT stamp branch in fromCacheHandle is removed.

Measurement drove the design and corrected its own premise. The deep chain
was suspected of costing microseconds per node; a layer-by-layer native
bisection shows the truth is a per-ENTRY cost: invoking any runtime-created
MethodHandle under native image costs ~4.5us (interpreter entry), regardless
of chain depth, invokeExact vs invoke, or adapter count, while AOT-compiled
reflection stubs run at ~10ns. Every dynamic call site's invokedynamic hop
into its runtime-linked target pays that entry once per call, so a dynamic
Groovy method with N call sites on its path costs ~N x 4us natively:
the 2M-call loop (3 sites: call, plus, compound assign) measures ~12us/call
before and after this change.

The shallow target is kept for what it does deliver: no per-call
FallbackSupplier/value-provider allocation, everything past the entry in
compiled code, and the right substrate for the two real escapes -- an
upstream runtime-MH compilation tier (Ristretto/Crema), or build-time
rewriting of indy sites to invokestatic aotDispatch, which would remove the
runtime method-handle boundary entirely.

Verified: 20-scenario gauntlet passes on JVM normal, JVM AOT-knob, and
native; dispatch-heavy loop correctness identical across all three.
@paulk-asert paulk-asert changed the title Indy aot spike GROOVY-12234: Indy aot spike Aug 4, 2026
@testlens-app

testlens-app Bot commented Aug 4, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: f0ecc57
▶️ Tests: 108774 executed
⚪️ Checks: 23/23 completed


Learn more about TestLens at testlens.app.

@paulk-asert
paulk-asert marked this pull request as draft August 4, 2026 12:51
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.

1 participant