Skip to content

Commit eaa4478

Browse files
jamesfredleypaulk-asert
authored andcommitted
GROOVY-10307: add JMH benchmarks for Grails-like invokedynamic pain points
Add 43 JMH benchmarks across 4 files targeting the metaclass invalidation patterns that cause performance regression in Grails applications under invokedynamic. These complement the general-purpose benchmarks from #2381 by exercising the specific dynamic dispatch patterns identified in #2374 and #2377. New benchmark files: MetaclassChangeBench (11 benchmarks) - ExpandoMetaClass method addition during method dispatch - Metaclass replacement cycles - Multi-class invalidation cascade (change on ServiceA invalidates ServiceB/C) - Burst-then-steady-state (simulates Grails startup then request handling) - Property access and closure dispatch under metaclass churn CategoryBench (10 benchmarks) - use(Category) blocks inside vs outside loops - Nested and simultaneous multi-category scopes - Collateral invalidation damage on non-category call sites - Category method shadowing existing methods DynamicDispatchBench (12 benchmarks) - methodMissing with single/rotating names (dynamic finders) - propertyMissing read/write (Grails params/session) - GroovyInterceptable invokeMethod interception (transactional services) - ExpandoMetaClass-injected method calls mixed with real methods - def-typed monomorphic and polymorphic dispatch GrailsLikePatternsBench (10 benchmarks) - Service chain: validation, CRUD, collection processing - Controller action: param binding, service call, model/view rendering - Domain validation with dynamic property access (this."$field") - Configuration DSL with nested @DelegatesTo closures - Markup builder with nested tag/closure rendering - Full request cycle simulation with and without metaclass churn Run with: ./gradlew perf:jmh -PbenchInclude=perf
1 parent 8ce1f51 commit eaa4478

4 files changed

Lines changed: 1442 additions & 0 deletions

File tree

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.groovy.perf
20+
21+
import org.openjdk.jmh.annotations.*
22+
import org.openjdk.jmh.infra.Blackhole
23+
24+
import java.util.concurrent.TimeUnit
25+
26+
/**
27+
* Tests the performance of Groovy category usage patterns. Categories
28+
* are a key metaclass mechanism used heavily in Grails and other Groovy
29+
* frameworks: each {@code use(Category)} block temporarily modifies
30+
* method dispatch for the current thread.
31+
*
32+
* Every entry into and exit from a {@code use} block triggers
33+
* {@code invalidateSwitchPoints()}, causing global SwitchPoint
34+
* invalidation. In tight loops or frequently called code, this
35+
* creates significant overhead as all invokedynamic call sites must
36+
* re-link after each category scope change.
37+
*
38+
* Grails uses categories for date utilities, collection enhancements,
39+
* validation helpers, and domain class extensions.
40+
*/
41+
@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
42+
@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
43+
@Fork(2)
44+
@BenchmarkMode(Mode.AverageTime)
45+
@OutputTimeUnit(TimeUnit.MILLISECONDS)
46+
@State(Scope.Thread)
47+
class CategoryBench {
48+
static final int ITERATIONS = 100_000
49+
50+
// Category that adds methods to String
51+
static class StringCategory {
52+
static String reverse(String self) {
53+
new StringBuilder(self).reverse().toString()
54+
}
55+
56+
static String shout(String self) {
57+
self.toUpperCase() + '!'
58+
}
59+
60+
static boolean isPalindrome(String self) {
61+
String reversed = new StringBuilder(self).reverse().toString()
62+
self == reversed
63+
}
64+
}
65+
66+
// Category that adds methods to Integer
67+
static class MathCategory {
68+
static int doubled(Integer self) {
69+
self * 2
70+
}
71+
72+
static boolean isEven(Integer self) {
73+
self % 2 == 0
74+
}
75+
76+
static int factorial(Integer self) {
77+
(1..self).inject(1) { acc, val -> acc * val }
78+
}
79+
}
80+
81+
// Category that adds methods to List
82+
static class CollectionCategory {
83+
static int sumAll(List self) {
84+
self.sum() ?: 0
85+
}
86+
87+
static List doubled(List self) {
88+
self.collect { it * 2 }
89+
}
90+
}
91+
92+
String testString
93+
List<Integer> testList
94+
95+
@Setup(Level.Trial)
96+
void setup() {
97+
testString = "hello"
98+
testList = (1..10).toList()
99+
}
100+
101+
// ===== BASELINE (no categories) =====
102+
103+
/**
104+
* Baseline: direct method calls without any category usage.
105+
* Establishes the cost of normal method dispatch for comparison.
106+
*/
107+
@Benchmark
108+
void baselineDirectCalls(Blackhole bh) {
109+
int sum = 0
110+
for (int i = 0; i < ITERATIONS; i++) {
111+
sum += testString.length()
112+
}
113+
bh.consume(sum)
114+
}
115+
116+
// ===== SINGLE CATEGORY =====
117+
118+
/**
119+
* Single category block wrapping many calls. The category scope
120+
* is entered once and all calls happen inside it. This is the
121+
* most efficient category usage pattern - one enter/exit pair
122+
* for many method invocations.
123+
*/
124+
@Benchmark
125+
void singleCategoryWrappingLoop(Blackhole bh) {
126+
int sum = 0
127+
use(StringCategory) {
128+
for (int i = 0; i < ITERATIONS; i++) {
129+
sum += testString.shout().length()
130+
}
131+
}
132+
bh.consume(sum)
133+
}
134+
135+
/**
136+
* Category block entered on every iteration - the worst case.
137+
* Each iteration enters and exits the category scope, triggering
138+
* two SwitchPoint invalidations per iteration.
139+
*
140+
* This pattern appears in Grails when category-enhanced methods
141+
* are called from within request-scoped code that repeatedly
142+
* enters category scope.
143+
*/
144+
@Benchmark
145+
void categoryInLoop(Blackhole bh) {
146+
int sum = 0
147+
for (int i = 0; i < ITERATIONS; i++) {
148+
use(StringCategory) {
149+
sum += testString.shout().length()
150+
}
151+
}
152+
bh.consume(sum)
153+
}
154+
155+
/**
156+
* Category enter/exit at moderate frequency - every 100 calls.
157+
* Simulates code where category scope is entered per-batch
158+
* rather than per-call.
159+
*/
160+
@Benchmark
161+
void categoryPerBatch(Blackhole bh) {
162+
int sum = 0
163+
for (int i = 0; i < ITERATIONS / 100; i++) {
164+
use(StringCategory) {
165+
for (int j = 0; j < 100; j++) {
166+
sum += testString.shout().length()
167+
}
168+
}
169+
}
170+
bh.consume(sum)
171+
}
172+
173+
// ===== NESTED CATEGORIES =====
174+
175+
/**
176+
* Nested category scopes - multiple categories active at once.
177+
* Each nesting level adds another enter/exit invalidation pair.
178+
* Grails applications often have multiple category layers active
179+
* simultaneously (e.g., date utilities inside collection utilities
180+
* inside validation helpers).
181+
*/
182+
@Benchmark
183+
void nestedCategories(Blackhole bh) {
184+
int sum = 0
185+
for (int i = 0; i < ITERATIONS; i++) {
186+
use(StringCategory) {
187+
use(MathCategory) {
188+
sum += testString.shout().length() + i.doubled()
189+
}
190+
}
191+
}
192+
bh.consume(sum)
193+
}
194+
195+
/**
196+
* Nested categories with the outer scope wrapping the loop.
197+
* Only the inner category enters/exits per iteration.
198+
*/
199+
@Benchmark
200+
void nestedCategoryOuterWrapping(Blackhole bh) {
201+
int sum = 0
202+
use(StringCategory) {
203+
for (int i = 0; i < ITERATIONS; i++) {
204+
use(MathCategory) {
205+
sum += testString.shout().length() + i.doubled()
206+
}
207+
}
208+
}
209+
bh.consume(sum)
210+
}
211+
212+
// ===== MULTIPLE SIMULTANEOUS CATEGORIES =====
213+
214+
/**
215+
* Multiple categories applied simultaneously via use(Cat1, Cat2).
216+
* Single enter/exit but with more method resolution complexity.
217+
*/
218+
@Benchmark
219+
void multipleCategoriesSimultaneous(Blackhole bh) {
220+
int sum = 0
221+
for (int i = 0; i < ITERATIONS; i++) {
222+
use(StringCategory, MathCategory) {
223+
sum += testString.shout().length() + i.doubled()
224+
}
225+
}
226+
bh.consume(sum)
227+
}
228+
229+
/**
230+
* Three categories simultaneously - heavier resolution load.
231+
*/
232+
@Benchmark
233+
void threeCategoriesSimultaneous(Blackhole bh) {
234+
int sum = 0
235+
for (int i = 0; i < ITERATIONS; i++) {
236+
use(StringCategory, MathCategory, CollectionCategory) {
237+
sum += testString.shout().length() + i.doubled() + testList.sumAll()
238+
}
239+
}
240+
bh.consume(sum)
241+
}
242+
243+
// ===== CATEGORY WITH OUTSIDE CALLS =====
244+
245+
/**
246+
* Method calls both inside and outside category scope.
247+
* The outside calls exercise call sites that were invalidated
248+
* when the category scope was entered/exited. This measures
249+
* the collateral damage of category usage on non-category code.
250+
*/
251+
@Benchmark
252+
void categoryWithOutsideCalls(Blackhole bh) {
253+
int sum = 0
254+
for (int i = 0; i < ITERATIONS; i++) {
255+
// Call outside category scope
256+
sum += testString.length()
257+
258+
// Enter/exit category scope (triggers invalidation)
259+
use(StringCategory) {
260+
sum += testString.shout().length()
261+
}
262+
263+
// Call outside again - call site was invalidated by use() above
264+
sum += testString.length()
265+
}
266+
bh.consume(sum)
267+
}
268+
269+
/**
270+
* Baseline for category-with-outside-calls: same work without
271+
* the category block. Shows how much the category enter/exit
272+
* overhead costs for the surrounding non-category calls.
273+
*/
274+
@Benchmark
275+
void baselineEquivalentWithoutCategory(Blackhole bh) {
276+
int sum = 0
277+
for (int i = 0; i < ITERATIONS; i++) {
278+
sum += testString.length()
279+
sum += testString.toUpperCase().length() + 1 // same work as shout()
280+
sum += testString.length()
281+
}
282+
bh.consume(sum)
283+
}
284+
285+
// ===== CATEGORY METHOD RESOLUTION =====
286+
287+
/**
288+
* Category method that shadows an existing method.
289+
* Tests the overhead of category method resolution when the
290+
* category method name matches a method already on the class.
291+
*/
292+
@Benchmark
293+
void categoryShadowingExistingMethod(Blackhole bh) {
294+
int sum = 0
295+
for (int i = 0; i < ITERATIONS; i++) {
296+
use(StringCategory) {
297+
// reverse() exists on String AND in StringCategory
298+
sum += testString.reverse().length()
299+
}
300+
}
301+
bh.consume(sum)
302+
}
303+
}

0 commit comments

Comments
 (0)