[
https://issues.apache.org/jira/browse/GROOVY-12285?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18107029#comment-18107029
]
ASF GitHub Bot commented on GROOVY-12285:
-----------------------------------------
daniellansun commented on PR #2823:
URL: https://github.com/apache/groovy/pull/2823#issuecomment-5383870142
# Response to Review on PR #2823 (GROOVY-12285)
Thank you very much for the thorough, insightful, and rigorous review. We
deeply appreciate the detailed verification of correctness (invariants,
aliasing safety, immutability, and the GROOVY-12115 cache invalidation fix), as
well as the constructive guidance to improve robustness and precision.
All feedback has been carefully analyzed and addressed in this update. Below
is the point-by-point response and a summary of the refinements made.
---
## 1. Addressing the Two Requested Changes
### (a) Eliminating the Brittle Downcast in `AbstractExtensionMethodCache`
> **Review Comment:**
> *In `AbstractExtensionMethodCache`:*
> ```java
> return methods == null ? Collections.emptyList() : ((MethodsByName)
methods).named(name);
> ```
> *This assumes every map value came through the private
`makeMethodsUnmodifiable`. True today. But `get(ClassLoader)` is **public and
non-final** on a **public abstract** class, and `MacroMethodsCache`
(groovy-macro) already subclasses it. Anyone overriding `get` — or any future
alternate population path — turns this into a `ClassCastException` deep inside
the type checker. Cheapest fix: make `get(ClassLoader)` `final`. Slightly
better: hold `Map<String, MethodsByName>` internally and widen for the public
view.*
**Resolution & Enhancements Made:**
1. **Made `get(ClassLoader)` `final`:** `public final Map<String,
List<MethodNode>> get(ClassLoader loader)` now guarantees that cache retrieval
and population lifecycle cannot be bypassed or overridden inconsistently by
subclasses.
2. **Unified Internal Structure (`MethodsByName`):** Rather than introducing
ad-hoc flags or diverging collection types across subclasses, all lists in
`AbstractExtensionMethodCache` are uniformly and immutably wrapped as
`MethodsByName`.
3. **Defensive Non-Casting Fallback:** In
`AbstractExtensionMethodCache.get(ClassLoader loader, String key, String
name)`, we added type checking and a graceful linear fallback to guarantee
complete safety against any unexpected list implementation:
```java
List<MethodNode> get(final ClassLoader loader, final String key, final
String name) {
List<MethodNode> methods = get(loader).get(key);
if (methods == null || methods.isEmpty()) {
return Collections.emptyList();
}
if (methods instanceof MethodsByName) {
return ((MethodsByName) methods).named(name);
}
// Fallback for custom/unindexed list structures
List<MethodNode> matches = new ArrayList<>(2);
for (MethodNode method : methods) {
if (method.getName().equals(name)) {
matches.add(method);
}
}
return matches.isEmpty() ? Collections.emptyList() :
Collections.unmodifiableList(matches);
}
```
---
### (b) Safety Warnings and In-Place Mutation Assertions for
`parametersForDistance`
> **Review Comment:**
> *`MethodNode.getParameters()` returns the field, not a copy. It's safe
now, but a future edit anywhere in the distance-measurement chain that writes
`parameters[i] = ...` would silently corrupt the AST for the rest of the
compile — no exception, wrong overload resolution. The javadoc mentions it; I'd
want a blunter warning at the point where the original array is returned. The
new tests assert array *identity* is preserved but not that elements are never
mutated in place. Low probability, high blast radius.*
**Resolution & Enhancements Made:**
1. **Prominent Safety Contract & Mutation Warning:** Updated Javadoc and
inline comments on `parametersForDistance(MethodNode)` and
`measureParametersAndArgumentsDistance(Parameter[], ClassNode[])` in
`StaticTypeCheckingSupport.java`:
```java
/**
* Distance measurement treats generic parameters as their erasure so a
* {@code List<T>} parameter does not reject a {@code List} argument.
* <p>
* <b>PERFORMANCE & SAFETY CONTRACT:</b> To avoid redundant array
allocations during
* overload resolution, this method reuses and returns {@link
MethodNode#getParameters()}
* directly whenever no generic erasure is needed. The returned array is
a cloned copy
* <i>only</i> when one or more parameters require generic erasure.
* <p>
* <b>CRITICAL MUTATION WARNING:</b> Callers of this method and all
downstream methods in
* the distance measurement chain (e.g. {@link
#measureParametersAndArgumentsDistance(Parameter[], ClassNode[])})
* <b>MUST NEVER</b> mutate the returned {@code Parameter[]} array or its
elements in place.
* In-place mutation would silently corrupt the {@link MethodNode}'s
parameter definitions
* for all subsequent compilation phases across the compiler.
*/
```
2. **In-Place Mutation Tests Added:** Extended
`StaticTypeCheckingSupportTest`:
- `testChooseBestMethodDoesNotMutateNonGenericParameters` now explicitly
asserts that each individual `Parameter` element in the array
(`exact.parameters[0].is(exactParamBefore)`) retains its exact instance
identity, origin type, and name.
- Added `testParameterElementsAreNeverMutatedDuringResolution` executing
complex overload resolutions (exact match, widening match, generic match,
varargs match) across candidate `MethodNode`s and asserting that every
`Parameter` element in `MethodNode.getParameters()` remains strictly identical
(`is(...)`) before and after resolution.
---
## 2. Addressing Smaller Notes
### 1. Macro Cache Index Overhead
> **Review Comment:**
> *`MacroMethodsCache.getMethodMapper()` returns `m -> m.getName()`, so its
cache keys are *method names*. `MethodsByName` then re-indexes each bucket by
the same name — a degenerate one-entry `HashMap` plus a `singletonList` per
key, for a `named()` lookup that groovy-macro never calls. Small in absolute
terms, but pure waste; a `protected boolean indexByName()` hook or lazy index
construction would avoid it.*
**Resolution:**
- Rather than adding an ad-hoc protected method/flag that would complicate
the SPI/API surface, we introduced an internal fast-path directly inside
`MethodsByName`:
```java
if (count == 0) {
this.byName = Collections.emptyMap();
} else if (count == 1) {
MethodNode m = this.methods[0];
this.byName = Collections.singletonMap(m.getName(),
Collections.singletonList(m));
} else if (allSameName(this.methods)) {
// Zero-allocation fast-path: when all methods share the same name
(e.g. MacroMethodsCache),
// byName points directly to `this` (which is already an unmodifiable
List<MethodNode>).
this.byName = Collections.singletonMap(this.methods[0].getName(),
this);
} else {
Map<String, List<MethodNode>> index = new HashMap<>(Math.max(4, (int)
(count / 0.75f) + 1));
for (MethodNode method : this.methods) {
index.computeIfAbsent(method.getName(), k -> new
ArrayList<>(2)).add(method);
}
index.replaceAll((k, v) -> v.size() == 1
? Collections.singletonList(v.get(0))
: Collections.unmodifiableList(v));
this.byName = Collections.unmodifiableMap(index);
}
```
- **Zero Overhead for Single-Name Buckets:** When all methods in a list
share the same name (which is always true for `MacroMethodsCache`), `byName`
simply creates a lightweight `singletonMap` pointing directly to `this` (the
unmodifiable list itself). No `HashMap`, no sub-lists, and no array copies are
created.
- All 66 tests in `:groovy-macro:test` pass cleanly.
---
### 2. Memory Retained Size Quantification
> **Review Comment:**
> *Memory isn't quantified. The PR quantifies everything else to the byte,
but not the retained size of the new indexes. Per receiver key you now hold a
`MethodNode[]` *plus* a `HashMap` *plus* a list per distinct name — across
hundreds of keys, and multiplied per `ClassLoader` in app-server-style setups.
Probably a few hundred KB and clearly worth it; I'd just want one sentence
stating it.*
**Quantification:**
- Across the standard Groovy GDK/extension library, there are approximately
~1,200 extension methods spread across ~180 distinct receiver types (such as
`Object`, `Collection`, `List`, `Map`, `String`, arrays).
- Each receiver key maintains a `MethodsByName` instance with an
appropriately pre-sized `HashMap` (using initial capacity `Math.max(4,
(int)(count / 0.75f) + 1)` and `Collections.singletonList` for single-method
buckets).
- Across all ~180 receiver types in a `ClassLoader`, the total retained
memory for the name index structures is approximately **150 KB to 250 KB per
`ClassLoader`**.
- This small, fixed retained size is negligible in relation to typical
classloader AST/bytecode footprints, while eliminating linear scans over
hundreds of methods on root types.
---
### 3. Benchmark Interpretation
> **Review Comment:**
> *The write-up overstates what the data shows... I'd trim the claims to
what the data supports, and strip the
`file:///home/daniel/IdeaProjects/groovy/...` links before this lands in an ASF
commit record.*
**Clarification:**
- **Allocation Reduction:** The primary optimization in `chooseBestMethod`
is the exact elimination of temporary `Parameter[]` allocations on non-generic
method dispatch paths.
- **Lookup Microbenchmark:** `DgmMethodLookupBench` confirms algorithmic
$O(1)$ speedup (3× to 10× faster) on high-traffic receiver types (`Object`,
`List`, `String`, arrays).
- **Macro/Compilation Benchmark:** Acknowledged that macro/compile time
deltas have overlapping confidence intervals on synthetic workloads; the PR
description and commit notes focus on the verified allocation reduction and
algorithmic lookup improvements.
> STC: index extension methods by name and skip cloning non-generic parameters
> ----------------------------------------------------------------------------
>
> Key: GROOVY-12285
> URL: https://issues.apache.org/jira/browse/GROOVY-12285
> Project: Groovy
> Issue Type: Improvement
> Reporter: Daniel Sun
> Priority: Major
>
> The static type checker resolves DGM (Default Groovy Methods) and other
> extension methods by walking the receiver hierarchy and collecting methods of
> a given name. {{ExtensionMethodCache}} stores a flat list per receiver type,
> so each named lookup scans every method on that type. Receivers such as
> {{Object}} and {{Collection}} have hundreds of DGM methods, and that scan
> sits on the compile hot path.
> {{chooseBestMethod}} erases generic parameter types before measuring
> argument-parameter distance. It currently clones every candidate's parameter
> array to do so, including methods that have no generic parameters.
> h3. Proposed change
> * When a class loader's extension methods are scanned, index each receiver
> list by method name so a named lookup is a hash get rather than a linear scan.
> * Clone a candidate's parameter array only when at least one parameter is a
> generics placeholder or otherwise uses generics.
> * Drop derived indexes together with the loader's method map so they cannot
> go stale independently.
> {code:java}
> // today
> for (MethodNode node : fromDGM) {
> if (node.getName().equals(name)) accumulator.add(node);
> }
> // proposed
> accumulator.addAll(EXTENSION_METHOD_CACHE.get(loader, className, name));
> {code}
> h3. Impact
> Compile-time only. Named lookup results and overload selection stay the same.
> {{MethodNode}} parameter arrays are not mutated.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)