Skip to content

Commit 35a08fd

Browse files
committed
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 apache#2381 by exercising the specific dynamic dispatch patterns identified in apache#2374 and apache#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 f566658 commit 35a08fd

4 files changed

Lines changed: 1444 additions & 0 deletions

File tree

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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+
self.reverse()
54+
}
55+
56+
static String shout(String self) {
57+
self.toUpperCase() + '!'
58+
}
59+
60+
static boolean isPalindrome(String self) {
61+
self == self.reverse()
62+
}
63+
}
64+
65+
// Category that adds methods to Integer
66+
static class MathCategory {
67+
static int doubled(Integer self) {
68+
self * 2
69+
}
70+
71+
static boolean isEven(Integer self) {
72+
self % 2 == 0
73+
}
74+
75+
static int factorial(Integer self) {
76+
(1..self).inject(1) { acc, val -> acc * val }
77+
}
78+
}
79+
80+
// Category that adds methods to List
81+
static class CollectionCategory {
82+
static int sumAll(List self) {
83+
self.sum() ?: 0
84+
}
85+
86+
static List doubled(List self) {
87+
self.collect { it * 2 }
88+
}
89+
}
90+
91+
String testString
92+
List<Integer> testList
93+
94+
@Setup(Level.Trial)
95+
void setup() {
96+
testString = "hello"
97+
testList = (1..10).toList()
98+
}
99+
100+
// ===== BASELINE (no categories) =====
101+
102+
/**
103+
* Baseline: direct method calls without any category usage.
104+
* Establishes the cost of normal method dispatch for comparison.
105+
*/
106+
@Benchmark
107+
void baselineDirectCalls(Blackhole bh) {
108+
int sum = 0
109+
for (int i = 0; i < ITERATIONS; i++) {
110+
sum += testString.length()
111+
}
112+
bh.consume(sum)
113+
}
114+
115+
// ===== SINGLE CATEGORY =====
116+
117+
/**
118+
* Single category block wrapping many calls. The category scope
119+
* is entered once and all calls happen inside it. This is the
120+
* most efficient category usage pattern - one enter/exit pair
121+
* for many method invocations.
122+
*/
123+
@Benchmark
124+
void singleCategoryWrappingLoop(Blackhole bh) {
125+
int sum = 0
126+
use(StringCategory) {
127+
for (int i = 0; i < ITERATIONS; i++) {
128+
sum += testString.shout().length()
129+
}
130+
}
131+
bh.consume(sum)
132+
}
133+
134+
/**
135+
* Category block entered on every iteration - the worst case.
136+
* Each iteration enters and exits the category scope, triggering
137+
* two SwitchPoint invalidations per iteration.
138+
*
139+
* This pattern appears in Grails when category-enhanced methods
140+
* are called from within request-scoped code that repeatedly
141+
* enters category scope.
142+
*/
143+
@Benchmark
144+
void categoryInLoop(Blackhole bh) {
145+
int sum = 0
146+
for (int i = 0; i < ITERATIONS; i++) {
147+
use(StringCategory) {
148+
sum += testString.shout().length()
149+
}
150+
}
151+
bh.consume(sum)
152+
}
153+
154+
/**
155+
* Category enter/exit at moderate frequency - every 100 calls.
156+
* Simulates code where category scope is entered per-batch
157+
* rather than per-call.
158+
*/
159+
@Benchmark
160+
void categoryPerBatch(Blackhole bh) {
161+
int sum = 0
162+
for (int i = 0; i < ITERATIONS / 100; i++) {
163+
use(StringCategory) {
164+
for (int j = 0; j < 100; j++) {
165+
sum += testString.shout().length()
166+
}
167+
}
168+
}
169+
bh.consume(sum)
170+
}
171+
172+
// ===== NESTED CATEGORIES =====
173+
174+
/**
175+
* Nested category scopes - multiple categories active at once.
176+
* Each nesting level adds another enter/exit invalidation pair.
177+
* Grails applications often have multiple category layers active
178+
* simultaneously (e.g., date utilities inside collection utilities
179+
* inside validation helpers).
180+
*/
181+
@Benchmark
182+
void nestedCategories(Blackhole bh) {
183+
int sum = 0
184+
for (int i = 0; i < ITERATIONS; i++) {
185+
use(StringCategory) {
186+
use(MathCategory) {
187+
sum += testString.shout().length() + i.doubled()
188+
}
189+
}
190+
}
191+
bh.consume(sum)
192+
}
193+
194+
/**
195+
* Nested categories with the outer scope wrapping the loop.
196+
* Only the inner category enters/exits per iteration.
197+
*/
198+
@Benchmark
199+
void nestedCategoryOuterWrapping(Blackhole bh) {
200+
int sum = 0
201+
use(StringCategory) {
202+
for (int i = 0; i < ITERATIONS; i++) {
203+
use(MathCategory) {
204+
sum += testString.shout().length() + i.doubled()
205+
}
206+
}
207+
}
208+
bh.consume(sum)
209+
}
210+
211+
// ===== MULTIPLE SIMULTANEOUS CATEGORIES =====
212+
213+
/**
214+
* Multiple categories applied simultaneously via use(Cat1, Cat2).
215+
* Single enter/exit but with more method resolution complexity.
216+
*/
217+
@Benchmark
218+
void multipleCategoriesSimultaneous(Blackhole bh) {
219+
int sum = 0
220+
for (int i = 0; i < ITERATIONS; i++) {
221+
use(StringCategory, MathCategory) {
222+
sum += testString.shout().length() + i.doubled()
223+
}
224+
}
225+
bh.consume(sum)
226+
}
227+
228+
/**
229+
* Three categories simultaneously - heavier resolution load.
230+
*/
231+
@Benchmark
232+
void threeCategoriesSimultaneous(Blackhole bh) {
233+
int sum = 0
234+
for (int i = 0; i < ITERATIONS; i++) {
235+
use(StringCategory, MathCategory, CollectionCategory) {
236+
sum += testString.shout().length() + i.doubled() + testList.sumAll()
237+
}
238+
}
239+
bh.consume(sum)
240+
}
241+
242+
// ===== CATEGORY WITH OUTSIDE CALLS =====
243+
244+
/**
245+
* Method calls both inside and outside category scope.
246+
* The outside calls exercise call sites that were invalidated
247+
* when the category scope was entered/exited. This measures
248+
* the collateral damage of category usage on non-category code.
249+
*/
250+
@Benchmark
251+
void categoryWithOutsideCalls(Blackhole bh) {
252+
int sum = 0
253+
for (int i = 0; i < ITERATIONS; i++) {
254+
// Call outside category scope
255+
sum += testString.length()
256+
257+
// Enter/exit category scope (triggers invalidation)
258+
use(StringCategory) {
259+
sum += testString.shout().length()
260+
}
261+
262+
// Call outside again - call site was invalidated by use() above
263+
sum += testString.length()
264+
}
265+
bh.consume(sum)
266+
}
267+
268+
/**
269+
* Baseline for category-with-outside-calls: same work without
270+
* the category block. Shows how much the category enter/exit
271+
* overhead costs for the surrounding non-category calls.
272+
*/
273+
@Benchmark
274+
void baselineEquivalentWithoutCategory(Blackhole bh) {
275+
int sum = 0
276+
for (int i = 0; i < ITERATIONS; i++) {
277+
sum += testString.length()
278+
sum += testString.toUpperCase().length() + 1 // same work as shout()
279+
sum += testString.length()
280+
}
281+
bh.consume(sum)
282+
}
283+
284+
// ===== CATEGORY METHOD RESOLUTION =====
285+
286+
/**
287+
* Category method that shadows an existing method.
288+
* Tests the overhead of category method resolution when the
289+
* category method name matches a method already on the class.
290+
*/
291+
@Benchmark
292+
void categoryShadowingExistingMethod(Blackhole bh) {
293+
int sum = 0
294+
for (int i = 0; i < ITERATIONS; i++) {
295+
use(StringCategory) {
296+
// reverse() exists on String AND in StringCategory
297+
sum += testString.reverse().length()
298+
}
299+
}
300+
bh.consume(sum)
301+
}
302+
}

0 commit comments

Comments
 (0)