[ 
https://issues.apache.org/jira/browse/GROOVY-12263?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18104999#comment-18104999
 ] 

ASF GitHub Bot commented on GROOVY-12263:
-----------------------------------------

daniellansun opened a new pull request, #2790:
URL: https://github.com/apache/groovy/pull/2790

   https://issues.apache.org/jira/browse/GROOVY-12263
   
   # Performance Verification Report
   
   **Subject.** `1c3820bff71419b5c40e73142bbf3e82210ea317` — *Invoke cached 
Closure doCall targets via MethodHandle*
   
   **Baseline.** `9bb195dee52e82518bb5f4e5cda9ddbf08c16d39` — immediate parent 
of the subject. The only production delta is 
`src/main/java/groovy/lang/Closure.java`. The accompanying unit-test file is 
not on the JMH hot path.
   
   **Verdict.** The change delivers a statistically significant, host-stable, 
and *path-specific* improvement on the Java/GDK `Closure.call` entry that the 
commit claims to accelerate.
   
   On the four GDK iteration benches that actually execute 
`DefaultGroovyMethods` → `Closure.call(Object)` → `call(Object...)`, wall-clock 
time falls by **13–21%** (geometric mean **1.168×**, about **4–6 ns per 
callback**). Groovy `invokedynamic` call sites that already bind straight to 
`doCall` are unchanged (geomean **0.990×**). A `MethodClosure` negative control 
is unchanged (**0.994×**). Host-calibration rulers sit at **0.998×**, so the 
GDK movement is not a host-speed artifact.
   
   ---
   
   ## 1. What was compared
   
   `git diff --stat 9bb195dee5 1c3820bff7`:
   
   | File | Role |
   |---|---|
   | `src/main/java/groovy/lang/Closure.java` | Production: cached `doCall` / 
`call` targets are invoked via `MethodHandle.invokeExact` instead of 
`Method.invoke`. |
   | `src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy` | Tests only. 
Not loaded by the JMH measurement loops. |
   
   No other module, Gradle flag, or benchmark source differs between the two 
worktrees.
   
   **Isolated bytecode check** (same class extracted from each JMH fat JAR):
   
   | Artifact | `Closure.class` SHA-256 | `invokeExact` count | `Method.invoke` 
count |
   |---|---|---|---|
   | Baseline JAR | `7426d0a0…5461d609` | **0** | **2** |
   | Target JAR | `2475a3f1…3e76a198` | **6** | **1** |
   
   The six `invokeExact` sites are the specialized 0–4-arity cases plus the 
defensive spreader. The remaining `Method.invoke` is the documented fallback 
when `unreflect` fails. The two JARs therefore implement the two dispatch 
strategies under test and nothing else.
   
   ---
   
   ## 2. Measurement protocol
   
   The experiment is a *paired, same-host, same-JVM, sequential* comparison of 
two isolated builds. It is deliberately *not* a comparison against the 90-day 
gh-pages history: that history is dominated by runner-hardware noise (see 
`subprojects/performance/README.adoc`).
   
   ### 2.1 Isolation
   
   - Separate git worktrees at the two SHAs (`/tmp/groovy-base-9bb195d`, 
`/tmp/groovy-mh-1c3820b`).
   - Each worktree built with `./gradlew :performance:jmhJar --offline`.
   - JMH executed against that worktree’s own fat JAR 
(`performance-6.0.0-SNAPSHOT-jmh.jar`).
   - Runs were **serial**, not concurrent, so they did not contend for the six 
vCPUs.
   
   ### 2.2 JMH configuration
   
   These flags override the `@Fork(2)` annotations on the benchmark classes.
   
   | Parameter | Value | Rationale |
   |---|---|---|
   | Benchmarks | `org.apache.groovy.perf.ClosureBench` (19 methods) + 
`org.apache.groovy.perf.HostCalibrationBench` (3 rulers) | The project’s own 
closure suite plus the core-hz hardware rulers |
   | Mode / unit | `AverageTime`, as declared by the benches (`ms/op` for 
`ClosureBench`, `us/op` for rulers) | Lower is better |
   | Forks | **4** independent JVMs | Between-fork variance is visible; one 
noisy fork cannot dominate |
   | Warmup | 4 × 2 s | Past the C1/C2 transition on these loops |
   | Measurement | 5 × 2 s | 20 samples per bench (4 × 5) |
   | Heap | `-Xms2g -Xmx2g -XX:+AlwaysPreTouch` | Removes heap-resize and 
first-touch noise |
   | Confidence | JMH default **99.9%** CI | Primary significance criterion: 
non-overlapping CIs |
   | Order | Baseline first (23:49–00:19), target second (00:19–00:48), same 
host | Calibration rulers quantify any thermal or neighbor drift |
   
   ### 2.3 Host
   
   | Item | Value |
   |---|---|
   | Host | `hera` |
   | CPU | AMD EPYC 7763, 6 vCPUs, 1 thread/core |
   | Memory | 23 GiB |
   | OS | Linux 6.15.5 x86_64 |
   | JDK | Amazon Corretto **25.0.2+10-LTS** (`25.0.2-amzn`) |
   | Frequency governor | not exposed (`cpufreq` n/a) |
   
   ### 2.4 How “faster” is defined
   
   For `AverageTime`, **speedup = baseline / target**. Values greater than 1 
mean the target is faster. A result is labeled **faster** or **slower** only 
when the two 99.9% CIs do not overlap; otherwise **inconclusive**.
   
   A Welch two-sample *t* on the 20 raw iteration samples is reported as a 
secondary check (`t`, Welch–Satterthwaite `df`). Two-sided *p*-values are not 
tabulated: SciPy is not installed on this host, and every GDK *t* exceeds 11 on 
`df > 22`, which is `p ≪ 0.001` under any reasonable tail model.
   
   ---
   
   ## 3. Path analysis: which benches *must* move
   
   The production change lives **only** in `Closure.call(Object...)`. A bench 
can improve only if its steady-state work actually enters that method.
   
   Generated closures declare `doCall(...)` and do **not** override 
`call(Object)`. Two distinct caller shapes then arise:
   
   1. **Java / GDK entry.** `DefaultGroovyMethods.each` / `collect` / `findAll` 
/ `inject` compile as Java `closure.call(item)` (or `call(acc, val)`). That 
resolves to `Closure.call(Object)` / `call(Object, Object)`, which wrap into 
`call(Object...)`. This is the path named in the commit comment (the `each` / 
`collect` hot path). **Primary treatment.**
   
   2. **Groovy `invokedynamic` entry.** `c(i)` and `c.call(i)` in 
`ClosureBench` compile to the same indy site, 
`invoke:(Lgroovy/lang/Closure;I)`. After warmup the site binds **directly to 
`doCall(Object)`** and never enters `Closure.call(Object...)`. **Must not 
move.** Confirmed by disassembly of `ClosureBench.closureCallMethod` and 
`closureReuse` in the target JAR, and by the generated class 
`ClosureBench$_closureCallMethod_closure14` exposing only `doCall` / `doCall()`.
   
   3. **Adapters that re-enter a generated closure.** `CurriedClosure` and 
`MethodClosure` are explicitly `CallOverride.NONE`. 
`ComposedClosure.doCall(Object[])` is array-typed and likewise uncached. Their 
*outer* call stays on the metaclass. The *inner* generated bodies, however, are 
invoked via `call(...)` after uncurry or composition, so they **can** pick up 
the `MethodHandle` path. These are **secondary / indirect**, not clean 
negatives.
   
   4. **True negative control.** `list.&size` is a `MethodClosure`: 
`CallOverride.lookup` returns `NONE`, and there is no generated `doCall` body 
to re-enter. **Must not move.**
   
   5. **Hardware rulers.** `HostCalibrationBench.{cpuIntegerOps, 
memoryPointerChase, allocationChurn}` are pure Java and Groovy-independent. 
Their geometric mean is the **calibration factor**. A factor near 1 means the 
two 29-minute windows ran at equivalent host speed.
   
   ```
                       Groovy indy  ──►  doCall            
(ClosureBench.closureReuse / .closureCallMethod)
                                            ▲
   Java/GDK call(Object)                    │
       └─► Closure.call(Object...) ──► Method.invoke   [baseline]
                                   └─► invokeExact     [target]   ◄── this 
commit
                                            │
   Curried / Composed outer ──► metaclass ──┘ (re-enters inner call(...))
   MethodClosure            ──► metaclass, no generated doCall
   ```
   
   ---
   
   ## 4. Results
   
   ### 4.1 Hardware calibration (must be ~1.0×)
   
   | Ruler | Baseline | Target | Speedup | 99.9% CIs | Verdict |
   |---|---:|---:|---:|---|---|
   | `cpuIntegerOps` | 407.502 ± 1.914 µs/op | 405.945 ± 1.898 µs/op | 1.004× | 
overlap | inconclusive |
   | `memoryPointerChase` | 1419.758 ± 52.048 µs/op | 1451.855 ± 19.503 µs/op | 
0.978× | overlap | inconclusive |
   | `allocationChurn` | 96.799 ± 4.399 µs/op | 95.504 ± 3.902 µs/op | 1.014× | 
overlap | inconclusive |
   | **Geomean** |  |  | **0.998×** |  | **no host drift** |
   
   The second window is 0.2% slower on the geometric mean of the rulers — well 
inside JMH noise, and in the *opposite* direction of the GDK result. A 17% GDK 
movement cannot be attributed to the machine speeding up.
   
   ### 4.2 Primary treatment — GDK Java callbacks (must improve if the claim is 
true)
   
   Each of these methods performs **1 000 000** closure invocations per JMH op 
(`ITERATIONS/10` outer loops × a 10-element list, or 2-arg `inject` over the 
same list). Scores are therefore also **nanoseconds per callback**, including 
iterator and DGM overhead.
   
   | Benchmark | Baseline (ms/op) | Target (ms/op) | Speedup | Δ per call | 
99.9% CIs | Welch *t* (df) | Verdict |
   |---|---:|---:|---:|---:|---|---:|---|
   | `eachWithClosure` | 34.438 ± 0.666 | 29.404 ± 0.385 | **1.171×** | **−5.03 
ns** | disjoint | 25.41 (30.4) | **faster** |
   | `collectWithClosure` | 36.576 ± 1.598 | 31.454 ± 0.481 | **1.163×** | 
**−5.12 ns** | disjoint | 11.92 (22.4) | **faster** |
   | `findAllWithClosure` | 34.202 ± 0.986 | 30.313 ± 0.906 | **1.128×** | 
**−3.89 ns** | disjoint | 11.28 (37.7) | **faster** |
   | `injectWithClosure` | 34.052 ± 1.019 | 28.095 ± 0.677 | **1.212×** | 
**−5.96 ns** | disjoint | 18.90 (33.0) | **faster** |
   | **Geomean** |  |  | **1.168×** | **≈ −5.0 ns** |  |  | **all four faster** 
|
   
   Per-fork means (ms/op) — every fork of every GDK bench moves in the same 
direction; this is not a single lucky fork:
   
   | Bench | Baseline forks | Target forks |
   |---|---|---|
   | `each` | 34.28, 34.05, 34.51, 34.92 | 29.12, 29.54, 29.54, 29.42 |
   | `collect` | 36.04, 37.11, 35.90, 37.25 | 31.67, 31.37, 31.44, 31.34 |
   | `findAll` | 34.46, 34.72, 33.66, 33.97 | 30.34, 30.65, 30.24, 30.02 |
   | `inject` | 34.41, 34.46, 33.62, 33.73 | 27.47, 28.61, 28.18, 28.12 |
   
   Relative CI half-width stays in the 1.3–4.4% band on both sides; the target 
is if anything tighter. `findAll` saves a little less (~3.9 ns) than `each` / 
`collect` / `inject` (~5–6 ns), which is consistent with 
`BooleanClosureWrapper` adding a fixed cost that the `MethodHandle` change does 
not touch.
   
   **Reading the 5 ns.** A Groovy-indy `doCall` of `{ it * 2 }` is ~2.2 ns in 
the same process (`closureCallMethod`). The GDK benches spend ~34 ns per 
callback, of which iterator + DGM + `call(Object)` array wrap + `doCall` body 
account for the rest. Removing `Method.invoke`’s reflective wrapper from that 
mix and replacing it with `invokeExact` is expected to save a handful of 
nanoseconds, not tens. The measured −5 ns/call matches that model. It is 
**not** a 17% reduction inside `doCall` itself; it is a 17% reduction in the 
*GDK callback round-trip*, which is exactly the surface the commit optimizes.
   
   ### 4.3 Groovy indy sites (must *not* improve)
   
   | Benchmark | Baseline | Target | Speedup | Verdict |
   |---|---:|---:|---:|---|
   | `closureCallMethod` | 2.202 ± 0.094 | 2.200 ± 0.073 | 1.001× | 
inconclusive |
   | `closureReuse` | 2.220 ± 0.105 | 2.182 ± 0.059 | 1.017× | inconclusive |
   | `closureWithCapture` | 2.601 ± 0.062 | 2.580 ± 0.067 | 1.008× | 
inconclusive |
   | `closureModifyCapture` | 4.813 ± 0.168 | 4.862 ± 0.184 | 0.990× | 
inconclusive |
   | `closureMultiParams` | 14.942 ± 0.316 | 14.855 ± 0.443 | 1.006× | 
inconclusive |
   | `simpleClosureCreation` | 29.528 ± 0.691 | 29.288 ± 0.807 | 1.008× | 
inconclusive |
   | `closureAsParameter` | 28.606 ± 2.925 | 29.563 ± 3.470 | 0.968× | 
inconclusive |
   | `closureDelegation` | 50.361 ± 1.603 | 46.799 ± 2.949 | 1.076× | 
inconclusive |
   | `nestedClosures` | 33.478 ± 5.876 | 39.171 ± 7.665 | 0.855× | inconclusive 
|
   | **Geomean** |  |  | **0.990×** | **flat** |
   
   `nestedClosures` and `closureDelegation` have wide CIs (inner allocation and 
property-dispatch work dominate) and still overlap. The near-2.2 ns 
`closureCallMethod` / `closureReuse` pair is identical to 0.1%. This group is 
the **specificity check**: if the whole JVM had simply gotten faster, these 
would have moved with the GDK benches. They did not.
   
   ### 4.4 Adapters and the true negative control
   
   | Benchmark | Path | Baseline | Target | Speedup | Verdict |
   |---|---|---:|---:|---:|---|
   | `methodReference` | `MethodClosure` → `NONE` | 87.927 ± 3.022 | 88.473 ± 
3.352 | **0.994×** | inconclusive |
   | `curriedClosure` | outer `NONE`, inner re-enters `call` | 24.920 ± 0.757 | 
20.975 ± 0.936 | 1.188× | faster |
   | `rightCurriedClosure` | same | 25.006 ± 0.564 | 20.072 ± 0.690 | 1.246× | 
faster |
   | `closureComposition` | `doCall(Object[])` uncached; inners via `call` | 
39.456 ± 0.952 | 33.935 ± 1.119 | 1.163× | faster |
   
   `methodReference` is the clean negative and does not move (*t* = −0.47). 
Curry, rcurry, and compose **do** move, in the same 16–25% band as the GDK 
group. That is expected once the inner generated closures are taken into 
account: `CurriedClosure` is excluded from the cache *so that* `MetaClassImpl` 
can uncurry and re-enter, and that re-entry is a `call(...)` on a generated 
closure. Treating them as proof of a general, unspecified speedup would be 
wrong; treating them as a contradiction would also be wrong. They are a 
**consistent secondary effect**.
   
   ### 4.5 Context benches (not a test of this commit)
   
   | Benchmark | Baseline | Target | Speedup | Verdict |
   |---|---:|---:|---:|---|
   | `closureTrampoline` | 44.738 ± 2.324 | 44.550 ± 2.327 | 1.004× | 
inconclusive |
   | `closureSpread` | 1786.532 ± 64.833 | 1832.367 ± 94.159 | 0.975× | 
inconclusive |
   
   Trampoline is dominated by `TrampolineClosure` machinery. Spread 
(`sum3(*args)`) is dominated by argument packing. CIs overlap.
   
   ---
   
   ## 5. Why this is the MethodHandle change, not a confound
   
   | Alternative explanation | Why it is rejected |
   |---|---|
   | Host sped up between the two 29-minute windows | Calibration geomean 
**0.998×**; integer-ruler CIs overlap; the 0.4% `cpuIntegerOps` tick is smaller 
than, and opposite in direction to, a 17% GDK claim. |
   | Different sources, flags, or dependency versions | `git diff` is two 
files; both JARs built `--offline` from worktrees pinned at the two SHAs; 
`Closure.class` hashes and `invokeExact` counts match the intended 
implementations. |
   | JIT / fork noise | 4 forks; every GDK fork moves the same way; 20 samples; 
99.9% CIs disjoint; Welch *t* ∈ [11, 25]. |
   | “Everything that uses a closure got faster” | Indy `doCall` sites and 
`MethodClosure` did **not** move. The improvement is confined to callers that 
enter `Closure.call(Object...)`. |
   | The test-file change affected the benches | `ClosureCallHandleTest` is not 
referenced from `ClosureBench`. |
   | Packed closures hiding the path | Packing is opt-in (`@PackedClosures` / 
`groovy.target.closure.pack`). `ClosureBench` is compiled without it; the 
generated classes extend `Closure` and implement `GeneratedClosure` with 
`doCall` only. |
   
   ---
   
   ## 6. What this report does *not* claim
   
   - It does **not** claim that Groovy dynamic dispatch in general is 17% 
faster. Groovy-indy `c(i)` was already ~2.2 ns/call and stays there.
   - It does **not** claim a 17% reduction inside `doCall`. The ~5 ns is the 
reflective-invoke overhead disappearing from the Java `call` wrapper around 
`doCall`.
   - It does **not** compare against the gh-pages 90-day dashboard. That 
comparison is hardware-dominated; this one is a same-host A/B of two commits.
   - It does **not** measure cold start, allocation (`-prof gc` was not 
attached, to keep the timing run clean), or packed-closure dispatch 
(`PackedClosure` overrides `call` and never uses this cache).
   - Frequency-governor data is unavailable on this VM; calibration rulers are 
the substitute.
   
   ---
   
   ## 7. Conclusion
   
   On a same-host, 4-fork, 99.9%-CI JMH comparison of the isolated parent and 
the MethodHandle commit:
   
   1. **The advertised path is faster.** GDK `each` / `collect` / `findAll` / 
`inject` improve by **1.13–1.21×** (geomean **1.168×**, **−4 to −6 ns per 
callback**). All four 99.9% CIs are disjoint; all four fork sets move in the 
same direction.
   2. **The improvement is specific.** Groovy-indy sites that bind to `doCall` 
(geomean **0.990×**) and `MethodClosure` (**0.994×**) do not move.
   3. **The host did not move.** Calibration geomean **0.998×**.
   4. **Secondary adapters behave as the architecture predicts.** Curry and 
compose improve because they re-enter generated `call(...)`; they are not 
counterexamples.
   
   The performance claim of `1c3820b` is therefore **confirmed** for the 
Java/GDK `Closure.call` hot path, at a magnitude that matches the cost of 
`Method.invoke` being removed, and with no detectable regression on the paths 
the change is designed to leave alone.
   
   ---
   
   ## Appendix A — Reproducing this run
   
   ```bash
   git worktree add /tmp/groovy-base-9bb195d 
9bb195dee52e82518bb5f4e5cda9ddbf08c16d39
   git worktree add /tmp/groovy-mh-1c3820b   
1c3820bff71419b5c40e73142bbf3e82210ea317
   
   (cd /tmp/groovy-base-9bb195d && ./gradlew :performance:jmhJar --offline)
   (cd /tmp/groovy-mh-1c3820b   && ./gradlew :performance:jmhJar --offline)
   
   java -jar 
/tmp/groovy-base-9bb195d/subprojects/performance/build/libs/performance-6.0.0-SNAPSHOT-jmh.jar
 \
     org.apache.groovy.perf.ClosureBench 
org.apache.groovy.perf.HostCalibrationBench \
     -f 4 -wi 4 -i 5 -w 2s -r 2s -rf json -foe true \
     -jvmArgsAppend '-Xms2g -Xmx2g -XX:+AlwaysPreTouch' \
     -rff jmh-base.json -o jmh-base.txt
   
   java -jar 
/tmp/groovy-mh-1c3820b/subprojects/performance/build/libs/performance-6.0.0-SNAPSHOT-jmh.jar
 \
     org.apache.groovy.perf.ClosureBench 
org.apache.groovy.perf.HostCalibrationBench \
     -f 4 -wi 4 -i 5 -w 2s -r 2s -rf json -foe true \
     -jvmArgsAppend '-Xms2g -Xmx2g -XX:+AlwaysPreTouch' \
     -rff jmh-target.json -o jmh-target.txt
   ```
   
   Raw JMH JSON from this run: `/tmp/grok-1000/jmh-base.json`, 
`/tmp/grok-1000/jmh-target.json`.
   
   ## Appendix B — Artifact hashes
   
   | Item | Value |
   |---|---|
   | Baseline SHA | `9bb195dee52e82518bb5f4e5cda9ddbf08c16d39` |
   | Target SHA | `1c3820bff71419b5c40e73142bbf3e82210ea317` |
   | Baseline JMH JAR SHA-256 | 
`e321d268c7e099519e3f9e8b1b38d9bba5e11bf8151ee928f4a12156d4e40016` |
   | Target JMH JAR SHA-256 | 
`4cd0b63d1ca9a7f9cb383bae2b594adfc3cb079790260d3415aa2308d5f02b17` |
   | Baseline window | 2026-08-15 23:49:53 – 2026-08-16 00:19:05 +09 |
   | Target window | 2026-08-16 00:19:15 – 2026-08-16 00:48:23 +09 |
   




> Invoke cached Closure doCall targets via MethodHandle
> -----------------------------------------------------
>
>                 Key: GROOVY-12263
>                 URL: https://issues.apache.org/jira/browse/GROOVY-12263
>             Project: Groovy
>          Issue Type: Improvement
>            Reporter: Daniel Sun
>            Priority: Major
>
> The {{Closure.call(Object...)}} fast path (GROOVY-11911, GROOVY-12164, 
> GROOVY-12165) already caches a per-arity {{doCall}} / {{call}} {{Method}} and 
> invokes it with {{Method.invoke}}. That still puts a reflective invoke — 
> access check, argument boxing, {{InvocationTargetException}} wrap — on every 
> GDK {{each}} / {{collect}} / {{findAll}} / {{inject}} callback from Java.
> h2. Proposal
> At cache-build time, {{MethodHandles.unreflect}} the cached {{Method}} and 
> adapt it to {{genericMethodType(arity+1)}}. {{call(Object...)}} then prefers 
> {{invokeExact}} on that handle (specialized for arities 0–4). 
> {{Method.invoke}} remains only when the method cannot be adapted, so the 
> GROOVY-11911 {{call()}} / {{call(Object)}} carve-out still works if unreflect 
> fails.
> Exception contracts stay as they were on the reflective path: a body-thrown 
> throwable surfaces unwrapped. The handle path must not treat a body-thrown 
> {{InvocationTargetException}} or {{IllegalAccessException}} as a reflection 
> wrapper.
> Guards, {{CallOverride.NONE}} for {{MethodClosure}} / {{CurriedClosure}}, and 
> the metaclass fallback for coercion (GROOVY-12164) are unchanged.
> h2. Why this path
> Java callers such as {{DefaultGroovyMethods}} resolve {{closure.call(item)}} 
> to {{Closure.call(Object)}}, which wraps into {{call(Object...)}}. Groovy 
> {{invokedynamic}} sites typically bind straight to {{doCall}} after warmup 
> and never enter this method — they are out of scope.
> h2. Verification
> Same-host JMH, 4 forks, 99.9% CI, parent {{9bb195dee5}} vs {{1c3820bff7}}, 
> JDK 25. Host-calibration geomean 0.998x.
> || bench || speedup ||
> | {{eachWithClosure}} | 1.171x |
> | {{collectWithClosure}} | 1.163x |
> | {{findAllWithClosure}} | 1.128x |
> | {{injectWithClosure}} | 1.212x |
> | GDK geomean | 1.168x (~5 ns/callback) |
> | Groovy-indy {{doCall}} sites | 0.990x (flat) |
> | {{MethodClosure}} ({{list.&size}}) | 0.994x (flat) |
> All four GDK 99.9% CIs are disjoint. Full write-up: 
> {{docs/closure-call-methodhandle-perf-report.md}}.
> h2. Related
> GROOVY-11911 introduced the reflective cache. GROOVY-12164 / GROOVY-12165 
> extended it with typed and multi-arity guards. This change keeps that 
> selection and only replaces the invoke.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to