[
https://issues.apache.org/jira/browse/GROOVY-12258?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Paul King updated GROOVY-12258:
-------------------------------
Description:
GROOVY-12191 scoped indy SwitchPoint invalidation to per-MetaClass domains, a
large net win for indy dispatch (the Grails indy suite improved roughly 2x
geomean on the benchmark dashboard). One piece of collateral: process-wide
invalidation events now retire domains by walking every loaded class.
h3. Problem
{{GroovyCategorySupport.newScope()}} / {{endScope()}} call
{{VMPlugin.invalidateCallSites()}} unconditionally — the runtime cannot know
which bytecode flavour invoked it. Since GROOVY-12191 that lands in
{{IndyInvalidation.invalidateCategory()}} → {{retireAllLoadedDomains()}}, which
iterates {{ClassInfo.getAllClassInfo()}} — O(all loaded classes) — *twice per
{{use}} block* (enter and leave). Previously this invalidated a single
process-wide SwitchPoint.
Classic (non-invokedynamic) bytecode never installs indy MOP guards, so a
classic-only process pays the walk for nothing. The benchmark dashboard shows
the cost from the 2026-08-01 run onwards: the grails classic {{CategoryBench}}
methods are 2.4–3.8x slower (e.g. {{categoryInLoop}} ~183 → ~650 ms/op), with
smaller knock-on effects (1.1–1.4x) on the metaclass-change and
dynamic-dispatch benches.
h3. Proposed fix
Add a process-global monotonic flag to {{SwitchPointInvalidator}}, set in
{{getSwitchPoint()}} *before* the CAS publishes a newly allocated SwitchPoint,
and skip {{retireAllLoadedDomains()}} when the flag has never been set (covers
{{invalidateCategory}}, {{invalidateBulk}} and {{invalidateUnscoped}} in one
place).
Safety argument: every live SwitchPoint — MetaClass domains and ClassInfo
pending domains alike — is allocated through {{getSwitchPoint()}}, and the flag
write precedes publication. So a {{false}} read proves no guard chain anywhere
holds a SwitchPoint, i.e. the full walk would provably have collected an empty
batch; the skip is observationally identical. Races with concurrent linking are
no worse than the existing walk, which can equally miss a domain allocated
mid-walk; correctness there rests on link-time selection reading the current
category state, which is untouched. Stats counters still increment, so
observability semantics are unchanged.
Indy processes are unaffected: the flag flips at the first indy link, after
which all paths behave exactly as today.
h3. Measurements
JMH classic mode ({{-Pindy=false -PbenchInclude=CategoryBench.categoryInLoop
:perf:jmh}}, 2 forks x 5 iterations, JDK 21):
||build||categoryInLoop||
|master (unpatched)|365.9 ± 18.5 ms/op|
|patched|59.9 ± 1.2 ms/op|
~6x, restoring (locally, better than) the pre-2026-08-01 level. The flag
empirically stays {{false}} for the whole classic run even though groovy.jar
itself contains indy-compiled Groovy code. All
{{org.apache.groovy.runtime.indy}} / GROOVY-12191 tests and the category
runtime tests pass.
h3. Possible follow-up
Mixed and indy processes still pay the O(loaded classes) walk on each category
enter/leave. A registry of invalidators currently holding a live SwitchPoint
would make bulk retirement O(live domains) and would subsume this flag; that is
left out of scope here.
> category/bulk invalidation walks all loaded classes even when no indy site
> was ever linked
> ------------------------------------------------------------------------------------------
>
> Key: GROOVY-12258
> URL: https://issues.apache.org/jira/browse/GROOVY-12258
> Project: Groovy
> Issue Type: Improvement
> Reporter: Paul King
> Priority: Major
>
> GROOVY-12191 scoped indy SwitchPoint invalidation to per-MetaClass domains, a
> large net win for indy dispatch (the Grails indy suite improved roughly 2x
> geomean on the benchmark dashboard). One piece of collateral: process-wide
> invalidation events now retire domains by walking every loaded class.
> h3. Problem
> {{GroovyCategorySupport.newScope()}} / {{endScope()}} call
> {{VMPlugin.invalidateCallSites()}} unconditionally — the runtime cannot know
> which bytecode flavour invoked it. Since GROOVY-12191 that lands in
> {{IndyInvalidation.invalidateCategory()}} → {{retireAllLoadedDomains()}},
> which iterates {{ClassInfo.getAllClassInfo()}} — O(all loaded classes) —
> *twice per {{use}} block* (enter and leave). Previously this invalidated a
> single process-wide SwitchPoint.
> Classic (non-invokedynamic) bytecode never installs indy MOP guards, so a
> classic-only process pays the walk for nothing. The benchmark dashboard shows
> the cost from the 2026-08-01 run onwards: the grails classic
> {{CategoryBench}} methods are 2.4–3.8x slower (e.g. {{categoryInLoop}} ~183 →
> ~650 ms/op), with smaller knock-on effects (1.1–1.4x) on the metaclass-change
> and dynamic-dispatch benches.
> h3. Proposed fix
> Add a process-global monotonic flag to {{SwitchPointInvalidator}}, set in
> {{getSwitchPoint()}} *before* the CAS publishes a newly allocated
> SwitchPoint, and skip {{retireAllLoadedDomains()}} when the flag has never
> been set (covers {{invalidateCategory}}, {{invalidateBulk}} and
> {{invalidateUnscoped}} in one place).
> Safety argument: every live SwitchPoint — MetaClass domains and ClassInfo
> pending domains alike — is allocated through {{getSwitchPoint()}}, and the
> flag write precedes publication. So a {{false}} read proves no guard chain
> anywhere holds a SwitchPoint, i.e. the full walk would provably have
> collected an empty batch; the skip is observationally identical. Races with
> concurrent linking are no worse than the existing walk, which can equally
> miss a domain allocated mid-walk; correctness there rests on link-time
> selection reading the current category state, which is untouched. Stats
> counters still increment, so observability semantics are unchanged.
> Indy processes are unaffected: the flag flips at the first indy link, after
> which all paths behave exactly as today.
> h3. Measurements
> JMH classic mode ({{-Pindy=false -PbenchInclude=CategoryBench.categoryInLoop
> :perf:jmh}}, 2 forks x 5 iterations, JDK 21):
> ||build||categoryInLoop||
> |master (unpatched)|365.9 ± 18.5 ms/op|
> |patched|59.9 ± 1.2 ms/op|
> ~6x, restoring (locally, better than) the pre-2026-08-01 level. The flag
> empirically stays {{false}} for the whole classic run even though groovy.jar
> itself contains indy-compiled Groovy code. All
> {{org.apache.groovy.runtime.indy}} / GROOVY-12191 tests and the category
> runtime tests pass.
> h3. Possible follow-up
> Mixed and indy processes still pay the O(loaded classes) walk on each
> category enter/leave. A registry of invalidators currently holding a live
> SwitchPoint would make bulk retirement O(live domains) and would subsume this
> flag; that is left out of scope here.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)