-
Notifications
You must be signed in to change notification settings - Fork 1.9k
GROOVY-10307: add JMH benchmarks for Grails-like invokedynamic pain points #2385
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
paulk-asert
merged 5 commits into
apache:master
from
jamesfredley:groovy-10307-grails-like-benchmarks
Feb 27, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
aa5fdd3
GROOVY-10307: add JMH benchmarks for Grails-like invokedynamic pain p…
jamesfredley d1c3565
Merge remote-tracking branch 'origin/master' into groovy-10307-grails…
jamesfredley bc6d008
reduce fork count to 1 for CI timing budget
jamesfredley c684024
Reduce measurement iterations to 3 to fit within CI 60-minute timeout
jamesfredley 06137c7
Move grails benchmarks to subpackage and split perf workflows via matrix
jamesfredley File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
303 changes: 303 additions & 0 deletions
303
subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/CategoryBench.groovy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,303 @@ | ||
| /* | ||
| * Licensed to the Apache Software Foundation (ASF) under one | ||
| * or more contributor license agreements. See the NOTICE file | ||
| * distributed with this work for additional information | ||
| * regarding copyright ownership. The ASF licenses this file | ||
| * to you under the Apache License, Version 2.0 (the | ||
| * "License"); you may not use this file except in compliance | ||
| * with the License. You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, | ||
| * software distributed under the License is distributed on an | ||
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| * KIND, either express or implied. See the License for the | ||
| * specific language governing permissions and limitations | ||
| * under the License. | ||
| */ | ||
| package org.apache.groovy.perf.grails | ||
|
|
||
| import org.openjdk.jmh.annotations.* | ||
| import org.openjdk.jmh.infra.Blackhole | ||
|
|
||
| import java.util.concurrent.TimeUnit | ||
|
|
||
| /** | ||
| * Tests the performance of Groovy category usage patterns. Categories | ||
| * are a key metaclass mechanism used heavily in Grails and other Groovy | ||
| * frameworks: each {@code use(Category)} block temporarily modifies | ||
| * method dispatch for the current thread. | ||
| * | ||
| * Every entry into and exit from a {@code use} block triggers | ||
| * {@code invalidateSwitchPoints()}, causing global SwitchPoint | ||
| * invalidation. In tight loops or frequently called code, this | ||
| * creates significant overhead as all invokedynamic call sites must | ||
| * re-link after each category scope change. | ||
| * | ||
| * Grails uses categories for date utilities, collection enhancements, | ||
| * validation helpers, and domain class extensions. | ||
| */ | ||
| @Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS) | ||
| @Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) | ||
| @Fork(2) | ||
| @BenchmarkMode(Mode.AverageTime) | ||
| @OutputTimeUnit(TimeUnit.MILLISECONDS) | ||
| @State(Scope.Thread) | ||
| class CategoryBench { | ||
| static final int ITERATIONS = 100_000 | ||
|
|
||
| // Category that adds methods to String | ||
| static class StringCategory { | ||
| static String reverse(String self) { | ||
| new StringBuilder(self).reverse().toString() | ||
| } | ||
|
|
||
| static String shout(String self) { | ||
| self.toUpperCase() + '!' | ||
| } | ||
|
|
||
| static boolean isPalindrome(String self) { | ||
| String reversed = new StringBuilder(self).reverse().toString() | ||
| self == reversed | ||
| } | ||
|
jamesfredley marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // Category that adds methods to Integer | ||
| static class MathCategory { | ||
| static int doubled(Integer self) { | ||
| self * 2 | ||
| } | ||
|
|
||
| static boolean isEven(Integer self) { | ||
| self % 2 == 0 | ||
| } | ||
|
|
||
| static int factorial(Integer self) { | ||
| (1..self).inject(1) { acc, val -> acc * val } | ||
| } | ||
| } | ||
|
|
||
| // Category that adds methods to List | ||
| static class CollectionCategory { | ||
| static int sumAll(List self) { | ||
| self.sum() ?: 0 | ||
| } | ||
|
|
||
| static List doubled(List self) { | ||
| self.collect { it * 2 } | ||
| } | ||
| } | ||
|
|
||
| String testString | ||
| List<Integer> testList | ||
|
|
||
| @Setup(Level.Trial) | ||
| void setup() { | ||
| testString = "hello" | ||
| testList = (1..10).toList() | ||
| } | ||
|
|
||
| // ===== BASELINE (no categories) ===== | ||
|
|
||
| /** | ||
| * Baseline: direct method calls without any category usage. | ||
| * Establishes the cost of normal method dispatch for comparison. | ||
| */ | ||
| @Benchmark | ||
| void baselineDirectCalls(Blackhole bh) { | ||
| int sum = 0 | ||
| for (int i = 0; i < ITERATIONS; i++) { | ||
| sum += testString.length() | ||
| } | ||
| bh.consume(sum) | ||
| } | ||
|
|
||
| // ===== SINGLE CATEGORY ===== | ||
|
|
||
| /** | ||
| * Single category block wrapping many calls. The category scope | ||
| * is entered once and all calls happen inside it. This is the | ||
| * most efficient category usage pattern - one enter/exit pair | ||
| * for many method invocations. | ||
| */ | ||
| @Benchmark | ||
| void singleCategoryWrappingLoop(Blackhole bh) { | ||
| int sum = 0 | ||
| use(StringCategory) { | ||
| for (int i = 0; i < ITERATIONS; i++) { | ||
| sum += testString.shout().length() | ||
| } | ||
| } | ||
| bh.consume(sum) | ||
| } | ||
|
|
||
| /** | ||
| * Category block entered on every iteration - the worst case. | ||
| * Each iteration enters and exits the category scope, triggering | ||
| * two SwitchPoint invalidations per iteration. | ||
| * | ||
| * This pattern appears in Grails when category-enhanced methods | ||
| * are called from within request-scoped code that repeatedly | ||
| * enters category scope. | ||
| */ | ||
| @Benchmark | ||
| void categoryInLoop(Blackhole bh) { | ||
| int sum = 0 | ||
| for (int i = 0; i < ITERATIONS; i++) { | ||
| use(StringCategory) { | ||
| sum += testString.shout().length() | ||
| } | ||
| } | ||
| bh.consume(sum) | ||
| } | ||
|
|
||
| /** | ||
| * Category enter/exit at moderate frequency - every 100 calls. | ||
| * Simulates code where category scope is entered per-batch | ||
| * rather than per-call. | ||
| */ | ||
| @Benchmark | ||
| void categoryPerBatch(Blackhole bh) { | ||
| int sum = 0 | ||
| for (int i = 0; i < ITERATIONS / 100; i++) { | ||
| use(StringCategory) { | ||
| for (int j = 0; j < 100; j++) { | ||
| sum += testString.shout().length() | ||
| } | ||
| } | ||
| } | ||
| bh.consume(sum) | ||
| } | ||
|
|
||
| // ===== NESTED CATEGORIES ===== | ||
|
|
||
| /** | ||
| * Nested category scopes - multiple categories active at once. | ||
| * Each nesting level adds another enter/exit invalidation pair. | ||
| * Grails applications often have multiple category layers active | ||
| * simultaneously (e.g., date utilities inside collection utilities | ||
| * inside validation helpers). | ||
| */ | ||
| @Benchmark | ||
| void nestedCategories(Blackhole bh) { | ||
| int sum = 0 | ||
| for (int i = 0; i < ITERATIONS; i++) { | ||
| use(StringCategory) { | ||
| use(MathCategory) { | ||
| sum += testString.shout().length() + i.doubled() | ||
| } | ||
| } | ||
| } | ||
| bh.consume(sum) | ||
| } | ||
|
|
||
| /** | ||
| * Nested categories with the outer scope wrapping the loop. | ||
| * Only the inner category enters/exits per iteration. | ||
| */ | ||
| @Benchmark | ||
| void nestedCategoryOuterWrapping(Blackhole bh) { | ||
| int sum = 0 | ||
| use(StringCategory) { | ||
| for (int i = 0; i < ITERATIONS; i++) { | ||
| use(MathCategory) { | ||
| sum += testString.shout().length() + i.doubled() | ||
| } | ||
| } | ||
| } | ||
| bh.consume(sum) | ||
| } | ||
|
|
||
| // ===== MULTIPLE SIMULTANEOUS CATEGORIES ===== | ||
|
|
||
| /** | ||
| * Multiple categories applied simultaneously via use(Cat1, Cat2). | ||
| * Single enter/exit but with more method resolution complexity. | ||
| */ | ||
| @Benchmark | ||
| void multipleCategoriesSimultaneous(Blackhole bh) { | ||
| int sum = 0 | ||
| for (int i = 0; i < ITERATIONS; i++) { | ||
| use(StringCategory, MathCategory) { | ||
| sum += testString.shout().length() + i.doubled() | ||
| } | ||
| } | ||
| bh.consume(sum) | ||
| } | ||
|
|
||
| /** | ||
| * Three categories simultaneously - heavier resolution load. | ||
| */ | ||
| @Benchmark | ||
| void threeCategoriesSimultaneous(Blackhole bh) { | ||
| int sum = 0 | ||
| for (int i = 0; i < ITERATIONS; i++) { | ||
| use(StringCategory, MathCategory, CollectionCategory) { | ||
| sum += testString.shout().length() + i.doubled() + testList.sumAll() | ||
| } | ||
| } | ||
| bh.consume(sum) | ||
| } | ||
|
|
||
| // ===== CATEGORY WITH OUTSIDE CALLS ===== | ||
|
|
||
| /** | ||
| * Method calls both inside and outside category scope. | ||
| * The outside calls exercise call sites that were invalidated | ||
| * when the category scope was entered/exited. This measures | ||
| * the collateral damage of category usage on non-category code. | ||
| */ | ||
| @Benchmark | ||
| void categoryWithOutsideCalls(Blackhole bh) { | ||
| int sum = 0 | ||
| for (int i = 0; i < ITERATIONS; i++) { | ||
| // Call outside category scope | ||
| sum += testString.length() | ||
|
|
||
| // Enter/exit category scope (triggers invalidation) | ||
| use(StringCategory) { | ||
| sum += testString.shout().length() | ||
| } | ||
|
|
||
| // Call outside again - call site was invalidated by use() above | ||
| sum += testString.length() | ||
| } | ||
| bh.consume(sum) | ||
| } | ||
|
|
||
| /** | ||
| * Baseline for category-with-outside-calls: same work without | ||
| * the category block. Shows how much the category enter/exit | ||
| * overhead costs for the surrounding non-category calls. | ||
| */ | ||
| @Benchmark | ||
| void baselineEquivalentWithoutCategory(Blackhole bh) { | ||
| int sum = 0 | ||
| for (int i = 0; i < ITERATIONS; i++) { | ||
| sum += testString.length() | ||
| sum += testString.toUpperCase().length() + 1 // same work as shout() | ||
| sum += testString.length() | ||
| } | ||
| bh.consume(sum) | ||
| } | ||
|
|
||
| // ===== CATEGORY METHOD RESOLUTION ===== | ||
|
|
||
| /** | ||
| * Category method that shadows an existing method. | ||
| * Tests the overhead of category method resolution when the | ||
| * category method name matches a method already on the class. | ||
| */ | ||
| @Benchmark | ||
| void categoryShadowingExistingMethod(Blackhole bh) { | ||
| int sum = 0 | ||
| for (int i = 0; i < ITERATIONS; i++) { | ||
| use(StringCategory) { | ||
| // reverse() exists on String AND in StringCategory | ||
| sum += testString.reverse().length() | ||
| } | ||
| } | ||
| bh.consume(sum) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.