GROOVY-12234: Indy aot spike - #2766
Draft
paulk-asert wants to merge 3 commits into
Draft
Conversation
…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.
✅ All tests passed ✅🏷️ Commit: f0ecc57 Learn more about TestLens at testlens.app. |
paulk-asert
marked this pull request as draft
August 4, 2026 12:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
https://issues.apache.org/jira/browse/GROOVY-12234