diff --git a/.github/workflows/groovy-jmh-perf-classic.yml b/.github/workflows/groovy-jmh-perf-classic.yml index d3f7d7159fd..276c4e11b83 100644 --- a/.github/workflows/groovy-jmh-perf-classic.yml +++ b/.github/workflows/groovy-jmh-perf-classic.yml @@ -24,6 +24,12 @@ jobs: test: strategy: fail-fast: false + matrix: + include: + - suite: core + pattern: '\\.perf\\.[A-Z]' + - suite: grails + pattern: '\\.perf\\.grails\\.' runs-on: ubuntu-latest env: DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} @@ -35,11 +41,11 @@ jobs: java-version: 21 check-latest: true - uses: gradle/actions/setup-gradle@v5 - - name: Benchmarks (perf classic) - run: ./gradlew perf:jmh -PbenchInclude=\\.perf\\. -Pindy=false + - name: Benchmarks (perf classic ${{ matrix.suite }}) + run: ./gradlew perf:jmh -PbenchInclude=${{ matrix.pattern }} -Pindy=false timeout-minutes: 60 - - name: Upload reports-jmh-perf-classic + - name: Upload reports-jmh-perf-classic-${{ matrix.suite }} uses: actions/upload-artifact@v6 with: - name: reports-jmh-perf-classic + name: reports-jmh-perf-classic-${{ matrix.suite }} path: subprojects/performance/build/results/jmh/ diff --git a/.github/workflows/groovy-jmh-perf.yml b/.github/workflows/groovy-jmh-perf.yml index 359535509b2..b60b596b6d0 100644 --- a/.github/workflows/groovy-jmh-perf.yml +++ b/.github/workflows/groovy-jmh-perf.yml @@ -24,6 +24,12 @@ jobs: test: strategy: fail-fast: false + matrix: + include: + - suite: core + pattern: '\\.perf\\.[A-Z]' + - suite: grails + pattern: '\\.perf\\.grails\\.' runs-on: ubuntu-latest env: DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} @@ -35,12 +41,12 @@ jobs: java-version: 21 check-latest: true - uses: gradle/actions/setup-gradle@v5 - - name: Benchmarks (perf) - run: ./gradlew perf:jmh -PbenchInclude=\\.perf\\. + - name: Benchmarks (perf ${{ matrix.suite }}) + run: ./gradlew perf:jmh -PbenchInclude=${{ matrix.pattern }} timeout-minutes: 60 - - name: Upload reports-jmh-perf + - name: Upload reports-jmh-perf-${{ matrix.suite }} uses: actions/upload-artifact@v6 with: - name: reports-jmh-perf + name: reports-jmh-perf-${{ matrix.suite }} path: subprojects/performance/build/results/jmh/ diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/CategoryBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/CategoryBench.groovy new file mode 100644 index 00000000000..68aa32b0f5c --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/CategoryBench.groovy @@ -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 + } + } + + // 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 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) + } +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/DynamicDispatchBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/DynamicDispatchBench.groovy new file mode 100644 index 00000000000..37b22d2c807 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/DynamicDispatchBench.groovy @@ -0,0 +1,341 @@ +/* + * 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 groovy.lang.GroovySystem + +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole + +import java.util.concurrent.TimeUnit + +/** + * Tests the performance of Groovy's dynamic method dispatch mechanisms: + * {@code methodMissing}, {@code propertyMissing}, {@code invokeMethod}, + * and {@link GroovyInterceptable}. These are the building blocks of + * Grails' convention-based programming model. + * + * Grails uses these patterns extensively: + * + * + * These patterns interact with the invokedynamic call site cache differently + * than normal method calls: methodMissing/propertyMissing cause cache misses + * on every distinct method name, while invokeMethod intercepts all calls + * regardless of caching. + */ +@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 DynamicDispatchBench { + static final int ITERATIONS = 100_000 + + // Class with methodMissing - like Grails domain class dynamic finders + static class DynamicFinder { + Map storage = [:] + + def methodMissing(String name, args) { + if (name.startsWith('findBy')) { + String field = name.substring(6).toLowerCase() + return storage[field] + } + if (name.startsWith('saveTo')) { + String field = name.substring(6).toLowerCase() + storage[field] = args[0] + return args[0] + } + throw new MissingMethodException(name, DynamicFinder, args) + } + } + + // Class with propertyMissing - like Grails controller params/session + static class DynamicProperties { + Map attributes = [name: "test", age: 25, active: true, role: "admin"] + + def propertyMissing(String name) { + attributes[name] + } + + def propertyMissing(String name, value) { + attributes[name] = value + } + } + + // Class with invokeMethod override - like Grails interceptors + static class MethodInterceptor implements GroovyInterceptable { + int callCount = 0 + int realValue = 42 + + def invokeMethod(String name, args) { + callCount++ + def metaMethod = metaClass.getMetaMethod(name, args) + if (metaMethod) { + return metaMethod.invoke(this, args) + } + return null + } + + int compute() { realValue * 2 } + String describe() { "value=$realValue" } + } + + // Plain class for baseline comparison + static class PlainService { + int value = 42 + int compute() { value * 2 } + } + + DynamicFinder finder + DynamicProperties props + MethodInterceptor interceptor + PlainService plain + + @Setup(Level.Iteration) + void setup() { + GroovySystem.metaClassRegistry.removeMetaClass(DynamicFinder) + GroovySystem.metaClassRegistry.removeMetaClass(DynamicProperties) + GroovySystem.metaClassRegistry.removeMetaClass(MethodInterceptor) + GroovySystem.metaClassRegistry.removeMetaClass(PlainService) + // Inject expando method once per iteration for injected-method benchmarks + PlainService.metaClass.injectedMethod = { -> delegate.value * 3 } + finder = new DynamicFinder() + finder.storage = [name: "Alice", age: 30, city: "Springfield"] + props = new DynamicProperties() + interceptor = new MethodInterceptor() + plain = new PlainService() + } + + // ===== BASELINE ===== + + /** + * Baseline: normal method calls on a plain class. + * Control for all dynamic dispatch variants. + */ + @Benchmark + void baselinePlainMethodCalls(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += plain.compute() + } + bh.consume(sum) + } + + // ===== methodMissing ===== + + /** + * Single dynamic finder name called repeatedly. + * The call site sees the same method name every time, but + * methodMissing must still handle it since there is no real + * method to cache. + */ + @Benchmark + void methodMissingSingleName(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + bh.consume(finder.findByName()) + } + } + + /** + * Rotating dynamic finder names - exercises call site cache with + * multiple missing method names at the same call site. + * Simulates Grails code calling different dynamic finders in + * sequence: findByName, findByAge, findByCity. + */ + @Benchmark + void methodMissingRotatingNames(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + switch (i % 3) { + case 0: bh.consume(finder.findByName()); break + case 1: bh.consume(finder.findByAge()); break + case 2: bh.consume(finder.findByCity()); break + } + } + } + + /** + * methodMissing for write operations - save pattern. + */ + @Benchmark + void methodMissingSavePattern(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + bh.consume(finder.saveToName("name_$i")) + } + } + + /** + * Mix of methodMissing and real method calls. + * Simulates Grails service code that mixes dynamic finders + * with normal method calls on the same object. + */ + @Benchmark + void methodMissingMixedWithReal(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + if (i % 2 == 0) { + bh.consume(finder.findByName()) + } else { + bh.consume(finder.storage.size()) + } + } + } + + // ===== propertyMissing ===== + + /** + * Single dynamic property access repeated. + */ + @Benchmark + void propertyMissingSingleName(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + bh.consume(props.name) + } + } + + /** + * Rotating dynamic property names - multiple property accesses + * at the same call site location. + */ + @Benchmark + void propertyMissingRotatingNames(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + switch (i % 4) { + case 0: bh.consume(props.name); break + case 1: bh.consume(props.age); break + case 2: bh.consume(props.active); break + case 3: bh.consume(props.role); break + } + } + } + + /** + * Dynamic property read/write cycle. + */ + @Benchmark + void propertyMissingReadWrite(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + props.name = "user_$i" + bh.consume(props.name) + } + } + + // ===== invokeMethod (GroovyInterceptable) ===== + + /** + * Method calls through invokeMethod interception. + * Every call, even to existing methods, goes through invokeMethod. + * This is the pattern used by Grails for transactional services + * and security interceptors. + */ + @Benchmark + void invokeMethodInterception(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += interceptor.compute() + } + bh.consume(sum) + } + + /** + * Alternating method calls through invokeMethod. + * Different method names at the same interception point. + */ + @Benchmark + void invokeMethodAlternating(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + if (i % 2 == 0) { + bh.consume(interceptor.compute()) + } else { + bh.consume(interceptor.describe()) + } + } + } + + // ===== EXPANDO METACLASS RUNTIME INJECTION ===== + + /** + * Calling a method that was injected at runtime via ExpandoMetaClass. + * Grails injects many methods at startup (save, delete, validate, + * dynamic finders) that are then called frequently during request + * processing. + */ + @Benchmark + void expandoInjectedMethodCall(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += plain.injectedMethod() + } + bh.consume(sum) + } + + /** + * Mix of real and expando-injected method calls. + * This is the typical Grails runtime pattern: domain classes have + * both compiled methods and dynamically injected GORM methods. + */ + @Benchmark + void mixedRealAndInjectedCalls(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + if (i % 2 == 0) { + sum += plain.compute() // real method + } else { + sum += plain.injectedMethod() // injected method + } + } + bh.consume(sum) + } + + // ===== DYNAMIC DISPATCH ON def-TYPED REFERENCES ===== + + /** + * Method calls on {@code def}-typed variable - the compiler + * cannot statically resolve the method, forcing full dynamic + * dispatch through invokedynamic on every call. + */ + @Benchmark + void defTypedDispatch(Blackhole bh) { + def service = plain + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += service.compute() + } + bh.consume(sum) + } + + /** + * Polymorphic dispatch through {@code def}-typed variable. + * Different receiver types flow through the same call site, + * testing the LRU cache effectiveness. + */ + @Benchmark + void defTypedPolymorphicDispatch(Blackhole bh) { + Object[] services = [plain, interceptor] + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += services[i % 2].compute() + } + bh.consume(sum) + } +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/GrailsLikePatternsBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/GrailsLikePatternsBench.groovy new file mode 100644 index 00000000000..17028e579b5 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/GrailsLikePatternsBench.groovy @@ -0,0 +1,488 @@ +/* + * 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 groovy.lang.GroovySystem + +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole + +import java.util.concurrent.TimeUnit + +/** + * Tests composite patterns that simulate real Grails application behavior. + * These benchmarks combine multiple Groovy features (closures, dynamic + * dispatch, metaclass modifications, property access, delegation) in + * patterns that mirror actual Grails framework usage. + * + * Unlike the focused single-feature benchmarks, these exercise the + * interaction effects between features - particularly how metaclass + * changes in one component cascade to affect call sites in other + * components through global SwitchPoint invalidation. + * + * Each benchmark simulates a specific Grails application pattern: + * + */ +@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 GrailsLikePatternsBench { + static final int ITERATIONS = 100_000 + + // ===== DOMAIN CLASS SIMULATION ===== + + static class DomainObject { + Long id + String name + String email + int version = 0 + Map errors = [:] + Map constraints = [name: [nullable: false, maxSize: 255], email: [nullable: false, email: true]] + + boolean validate() { + errors.clear() + constraints.each { field, rules -> + def value = this."$field" + if (!rules.nullable && value == null) { + errors[field] = 'nullable' + } + if (rules.maxSize && value?.toString()?.length() > rules.maxSize) { + errors[field] = 'maxSize.exceeded' + } + } + errors.isEmpty() + } + + DomainObject save() { + if (validate()) { + version++ + if (id == null) id = System.nanoTime() + } + this + } + + Map toMap() { + [id: id, name: name, email: email, version: version] + } + } + + // ===== SERVICE SIMULATION ===== + + static class ValidationService { + boolean validateEmail(String email) { + email != null && email.contains('@') && email.contains('.') + } + + boolean validateName(String name) { + name != null && name.length() >= 2 && name.length() <= 255 + } + } + + static class DomainService { + ValidationService validationService + + DomainObject create(Map params) { + def obj = new DomainObject() + params.each { key, value -> + obj."$key" = value + } + if (validationService.validateName(obj.name) && + validationService.validateEmail(obj.email)) { + obj.save() + } + obj + } + + List list(List objects) { + objects.findAll { it.id != null }.collect { it.toMap() } + } + } + + // ===== CONTROLLER SIMULATION ===== + + static class ControllerContext { + Map params = [:] + Map model = [:] + String viewName + List flash = [] + + void render(Map args) { + viewName = args.view ?: 'default' + if (args.model) model.putAll(args.model) + } + } + + // ===== CONFIGURATION DSL SIMULATION ===== + + static class ConfigBuilder { + Map config = [:] + + void dataSource(@DelegatesTo(DataSourceConfig) Closure cl) { + def dsc = new DataSourceConfig() + cl.delegate = dsc + cl.resolveStrategy = Closure.DELEGATE_FIRST + cl() + config.dataSource = dsc.toMap() + } + + void server(@DelegatesTo(ServerConfig) Closure cl) { + def sc = new ServerConfig() + cl.delegate = sc + cl.resolveStrategy = Closure.DELEGATE_FIRST + cl() + config.server = sc.toMap() + } + } + + static class DataSourceConfig { + String url = 'jdbc:h2:mem:default' + String driverClassName = 'org.h2.Driver' + String username = 'sa' + String password = '' + Map pool = [maxActive: 10] + + Map toMap() { [url: url, driverClassName: driverClassName, username: username, pool: pool] } + } + + static class ServerConfig { + int port = 8080 + String host = 'localhost' + Map ssl = [enabled: false] + + Map toMap() { [port: port, host: host, ssl: ssl] } + } + + // ===== BUILDER / VIEW SIMULATION ===== + + static class MarkupContext { + StringBuilder buffer = new StringBuilder() + int depth = 0 + + void tag(String name, Map attrs = [:], Closure body = null) { + buffer.append(' ' * depth).append("<$name") + attrs.each { k, v -> buffer.append(" $k=\"$v\"") } + if (body) { + buffer.append('>') + depth++ + body.delegate = this + body.resolveStrategy = Closure.DELEGATE_FIRST + body() + depth-- + buffer.append("") + } else { + buffer.append('/>') + } + } + + void text(String content) { + buffer.append(content) + } + + String render() { buffer.toString() } + } + + ValidationService validationService + DomainService domainService + List sampleData + + @Setup(Level.Iteration) + void setup() { + GroovySystem.metaClassRegistry.removeMetaClass(DomainObject) + GroovySystem.metaClassRegistry.removeMetaClass(DomainService) + GroovySystem.metaClassRegistry.removeMetaClass(ValidationService) + GroovySystem.metaClassRegistry.removeMetaClass(ControllerContext) + GroovySystem.metaClassRegistry.removeMetaClass(ConfigBuilder) + + validationService = new ValidationService() + domainService = new DomainService(validationService: validationService) + + sampleData = (1..20).collect { i -> + new DomainObject(name: "User$i", email: "user${i}@example.com").save() + } + } + + // ===== SERVICE CHAIN PATTERNS ===== + + /** + * Service method chain - simulates a Grails service calling + * another service, which accesses domain objects. Multiple layers + * of dynamic dispatch through Groovy property access and method + * calls. + */ + @Benchmark + void serviceChainCreateAndList(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def obj = domainService.create( + name: "User${i % 100}", + email: "user${i % 100}@example.com" + ) + bh.consume(obj.id) + } + } + + /** + * Service chain with collection processing - findAll, collect, + * inject patterns typical of Grails service layer. + */ + @Benchmark + void serviceChainWithCollections(Blackhole bh) { + for (int i = 0; i < ITERATIONS / 10; i++) { + def listed = domainService.list(sampleData) + bh.consume(listed.size()) + } + } + + // ===== CONTROLLER ACTION PATTERNS ===== + + /** + * Controller-like action dispatch - simulates a Grails controller + * handling a request: reading params, calling service, building + * model, rendering view. + */ + @Benchmark + void controllerActionPattern(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def ctx = new ControllerContext() + ctx.params = [name: "User${i % 100}", email: "u${i % 100}@test.com"] + + // Simulate action body + def obj = domainService.create(ctx.params) + if (obj.errors.isEmpty()) { + ctx.render(view: 'show', model: [item: obj.toMap()]) + } else { + ctx.flash << "Validation failed" + ctx.render(view: 'create', model: [item: obj, errors: obj.errors]) + } + bh.consume(ctx.viewName) + } + } + + /** + * Controller action pattern with metaclass changes. + * Simulates a Grails app where framework components are still + * being initialized (metaclass modifications) while requests + * are already being served. + */ + @Benchmark + void controllerActionDuringMetaclassChurn(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def ctx = new ControllerContext() + ctx.params = [name: "User${i % 100}", email: "u${i % 100}@test.com"] + + def obj = domainService.create(ctx.params) + ctx.render(view: 'show', model: [item: obj.toMap()]) + bh.consume(ctx.viewName) + + // Periodic metaclass changes (framework initialization) + if (i % 1000 == 0) { + DomainObject.metaClass."helper${i % 5}" = { -> delegate.name } + } + } + } + + // ===== DOMAIN VALIDATION PATTERNS ===== + + /** + * Domain object validation cycle - create, validate, check errors. + * Exercises dynamic property access (this."$field"), map operations, + * and closure iteration - all through invokedynamic. + */ + @Benchmark + void domainValidationCycle(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def obj = new DomainObject( + name: (i % 10 == 0) ? null : "User$i", + email: (i % 7 == 0) ? null : "user${i}@test.com" + ) + boolean valid = obj.validate() + bh.consume(valid) + if (!valid) { + bh.consume(obj.errors.size()) + } + } + } + + // ===== CONFIGURATION DSL PATTERNS ===== + + /** + * Configuration DSL - simulates Grails application.groovy style + * configuration with nested closures and delegation. + * Each closure uses DELEGATE_FIRST strategy, which requires + * dynamic method resolution. + */ + @Benchmark + void configurationDsl(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def builder = new ConfigBuilder() + builder.dataSource { + url = "jdbc:h2:mem:db${i % 10}" + driverClassName = 'org.h2.Driver' + username = 'sa' + password = '' + pool = [maxActive: 20 + (i % 10)] + } + builder.server { + port = 8080 + (i % 100) + host = 'localhost' + ssl = [enabled: i % 2 == 0] + } + bh.consume(builder.config.size()) + } + } + + // ===== BUILDER / VIEW RENDERING PATTERNS ===== + + /** + * Markup builder pattern - simulates GSP/Groovy template rendering + * with nested closure delegation. Each tag() call uses a closure + * with DELEGATE_FIRST, requiring dynamic method resolution at + * each nesting level. + */ + @Benchmark + void markupBuilderPattern(Blackhole bh) { + for (int i = 0; i < ITERATIONS / 10; i++) { + def markup = new MarkupContext() + markup.tag('div', [class: 'container']) { + tag('h1') { text("Item ${i}") } + tag('ul') { + for (int j = 0; j < 5; j++) { + tag('li', [class: j % 2 == 0 ? 'even' : 'odd']) { + text("Entry $j") + } + } + } + tag('footer') { text('End') } + } + bh.consume(markup.render()) + } + } + + // ===== DYNAMIC PROPERTY MAP ACCESS ===== + + /** + * Dynamic property access pattern - accessing properties by + * name string (this."$fieldName"). Common in Grails data binding, + * GORM field access, and controller parameter processing. + */ + @Benchmark + void dynamicPropertyByName(Blackhole bh) { + String[] fields = ['name', 'email', 'version'] + def obj = sampleData[0] + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + def val = obj."${fields[i % 3]}" + sum += val?.toString()?.length() ?: 0 + } + bh.consume(sum) + } + + /** + * Dynamic property access with metaclass churn. + * Combines the dynamic property pattern with metaclass changes + * happening elsewhere in the application. + */ + @Benchmark + void dynamicPropertyDuringMetaclassChurn(Blackhole bh) { + String[] fields = ['name', 'email', 'version'] + def obj = sampleData[0] + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + def val = obj."${fields[i % 3]}" + sum += val?.toString()?.length() ?: 0 + if (i % 1000 == 0) { + // Metaclass change on a different class affects this call site too + ValidationService.metaClass."helper${i % 3}" = { -> 'help' } + } + } + bh.consume(sum) + } + + // ===== COMPOSITE: FULL REQUEST CYCLE ===== + + /** + * Full simulated request cycle combining controller dispatch, + * service calls, domain validation, and view rendering. + * This is the closest approximation to what a real Grails + * request handler exercises. + */ + @Benchmark + void fullRequestCycleSimulation(Blackhole bh) { + for (int i = 0; i < ITERATIONS / 10; i++) { + // Controller receives request + def ctx = new ControllerContext() + ctx.params = [name: "User${i % 50}", email: "user${i % 50}@test.com"] + + // Service layer processes + def obj = domainService.create(ctx.params) + + // Build response model + if (obj.errors.isEmpty()) { + def markup = new MarkupContext() + markup.tag('div') { + tag('span', [class: 'name']) { text(obj.name) } + tag('span', [class: 'email']) { text(obj.email) } + } + ctx.render(view: 'show', model: [html: markup.render()]) + } else { + ctx.render(view: 'edit', model: [errors: obj.errors]) + } + bh.consume(ctx.model) + } + } + + /** + * Full request cycle with metaclass churn - the worst-case + * Grails scenario where framework initialization overlaps with + * request handling, causing constant SwitchPoint invalidation. + */ + @Benchmark + void fullRequestCycleDuringMetaclassChurn(Blackhole bh) { + for (int i = 0; i < ITERATIONS / 10; i++) { + def ctx = new ControllerContext() + ctx.params = [name: "User${i % 50}", email: "user${i % 50}@test.com"] + + def obj = domainService.create(ctx.params) + + if (obj.errors.isEmpty()) { + def markup = new MarkupContext() + markup.tag('div') { + tag('span') { text(obj.name) } + } + ctx.render(view: 'show', model: [html: markup.render()]) + } else { + ctx.render(view: 'edit', model: [errors: obj.errors]) + } + bh.consume(ctx.model) + + // Metaclass churn from framework components + if (i % 100 == 0) { + DomainObject.metaClass."grailsHelper${i % 5}" = { -> delegate.name } + } + } + } +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/MetaclassChangeBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/MetaclassChangeBench.groovy new file mode 100644 index 00000000000..edd840f5ca9 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/MetaclassChangeBench.groovy @@ -0,0 +1,310 @@ +/* + * 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 groovy.lang.ExpandoMetaClass +import groovy.lang.GroovySystem + +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole + +import java.util.concurrent.TimeUnit + +/** + * Tests the performance impact of metaclass modifications on invokedynamic + * call sites. These benchmarks exercise the key pain point identified in + * GROOVY-10307: when any metaclass changes, the global SwitchPoint is + * invalidated, causing ALL call sites across the application to fall back + * and re-link their method handles. + * + * In Grails applications, metaclass modifications happen frequently during + * framework startup (loading controllers, services, domain classes) and + * during request processing (dynamic finders, runtime mixins). This causes + * severe performance degradation under invokedynamic because every metaclass + * change triggers a global invalidation cascade. + * + * Compare baseline benchmarks (no metaclass changes) against the metaclass + * modification variants to measure the invalidation overhead. + */ +@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 MetaclassChangeBench { + static final int ITERATIONS = 100_000 + + // Helper classes for benchmarks - each represents a different + // component that might have its metaclass modified + static class ServiceA { + int value = 42 + int compute() { value * 2 } + } + + static class ServiceB { + String name = "test" + int nameLength() { name.length() } + } + + static class ServiceC { + List items = [1, 2, 3] + int itemCount() { items.size() } + } + + ServiceA serviceA + ServiceB serviceB + ServiceC serviceC + + @Setup(Level.Iteration) + void setup() { + // Reset metaclasses to avoid accumulation across iterations + GroovySystem.metaClassRegistry.removeMetaClass(ServiceA) + GroovySystem.metaClassRegistry.removeMetaClass(ServiceB) + GroovySystem.metaClassRegistry.removeMetaClass(ServiceC) + serviceA = new ServiceA() + serviceB = new ServiceB() + serviceC = new ServiceC() + } + + // ===== BASELINE (no metaclass changes) ===== + + /** + * Baseline: method calls with no metaclass changes. + * Establishes the cost of normal invokedynamic dispatch when + * call sites remain stable. Compare against metaclass-modifying + * benchmarks to measure invalidation overhead. + */ + @Benchmark + void baselineNoMetaclassChanges(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += serviceA.compute() + } + bh.consume(sum) + } + + /** + * Baseline: multi-class method calls with no metaclass changes. + * Control for {@link #multiClassMetaclassChurn}. + */ + @Benchmark + void baselineMultiClassNoChanges(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += serviceA.compute() + sum += serviceB.nameLength() + sum += serviceC.itemCount() + } + bh.consume(sum) + } + + // ===== EXPANDO METACLASS MODIFICATIONS ===== + + /** + * ExpandoMetaClass method addition interleaved with method calls. + * Every 1000 calls, a method is added to the metaclass, triggering + * SwitchPoint invalidation. + * + * This simulates the Grails startup pattern where metaclasses are + * modified as controllers, services, and domain classes are loaded + * while other call sites are already active. + */ + @Benchmark + void expandoMethodAddition(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += serviceA.compute() + if (i % 1000 == 0) { + // Add method via ExpandoMetaClass - triggers invalidateSwitchPoints() + // Reuse a small set of names to avoid unbounded metaclass growth + ServiceA.metaClass."dynamic${i % 5}" = { -> i } + } + } + bh.consume(sum) + } + + /** + * Frequent metaclass changes - every 100 calls instead of 1000. + * Simulates frameworks that modify metaclasses more aggressively + * during request processing. + */ + @Benchmark + void frequentExpandoChanges(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += serviceA.compute() + if (i % 100 == 0) { + ServiceA.metaClass."frequent${i % 5}" = { -> i } + } + } + bh.consume(sum) + } + + // ===== METACLASS REPLACEMENT ===== + + /** + * Repeated metaclass replacement - the most extreme invalidation + * pattern. Replacing the entire metaclass triggers a full + * invalidation cycle each time. + */ + @Benchmark + void metaclassReplacement(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += serviceA.compute() + if (i % 1000 == 0) { + def mc = new ExpandoMetaClass(ServiceA, false, true) + mc.initialize() + ServiceA.metaClass = mc + } + } + bh.consume(sum) + } + + // ===== MULTI-CLASS INVALIDATION CASCADE ===== + + /** + * Multi-class metaclass modification - simulates Grails loading + * multiple components, each triggering metaclass changes that + * invalidate call sites for ALL classes, not just the modified one. + * + * This is the core Grails pain point: changing ServiceA's metaclass + * invalidates call sites for ServiceB and ServiceC too. + */ + @Benchmark + void multiClassMetaclassChurn(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += serviceA.compute() + sum += serviceB.nameLength() + sum += serviceC.itemCount() + + if (i % 1000 == 0) { + // Rotate metaclass changes across different classes + switch (i % 3000) { + case 0: + ServiceA.metaClass."dynamic${i % 3}" = { -> i } + break + case 1000: + ServiceB.metaClass."dynamic${i % 3}" = { -> i } + break + case 2000: + ServiceC.metaClass."dynamic${i % 3}" = { -> i } + break + } + } + } + bh.consume(sum) + } + + // ===== BURST THEN STEADY STATE ===== + + /** + * Burst metaclass changes followed by steady-state calls. + * Simulates Grails application startup (many metaclass changes + * during bootstrap) followed by request handling (stable dispatch). + * Measures how quickly call sites recover after invalidation stops. + */ + @Benchmark + void burstThenSteadyState(Blackhole bh) { + // Phase 1: Burst of metaclass changes (startup/bootstrap) + for (int i = 0; i < 50; i++) { + ServiceA.metaClass."startup${i % 10}" = { -> i } + } + + // Phase 2: Steady-state method calls (request handling) + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += serviceA.compute() + } + bh.consume(sum) + } + + // ===== PROPERTY ACCESS UNDER METACLASS CHURN ===== + + /** + * Property access interleaved with metaclass changes. + * Property get/set dispatches through invokedynamic and is also + * invalidated by SwitchPoint changes. Grails uses extensive + * property access for domain class fields, controller parameters, + * and service injection. + */ + @Benchmark + void propertyAccessDuringMetaclassChurn(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + serviceA.value = i + sum += serviceA.value + if (i % 1000 == 0) { + ServiceA.metaClass."prop${i % 5}" = { -> i } + } + } + bh.consume(sum) + } + + /** + * Baseline: property access with no metaclass changes. + * Control for {@link #propertyAccessDuringMetaclassChurn}. + */ + @Benchmark + void baselinePropertyAccessNoChanges(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + serviceA.value = i + sum += serviceA.value + } + bh.consume(sum) + } + + // ===== CLOSURE DISPATCH UNDER METACLASS CHURN ===== + + /** + * Closure dispatch during metaclass changes. + * Closure call sites are also invalidated by SwitchPoint changes. + * Grails uses closures extensively in GORM criteria queries, + * controller actions, and configuration DSLs. + */ + @Benchmark + void closureDispatchDuringMetaclassChurn(Blackhole bh) { + Closure compute = { int x -> x * 2 } + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += compute(i) + if (i % 1000 == 0) { + ServiceA.metaClass."cl${i % 5}" = { -> i } + } + } + bh.consume(sum) + } + + /** + * Baseline: closure dispatch with no metaclass changes. + * Control for {@link #closureDispatchDuringMetaclassChurn}. + */ + @Benchmark + void baselineClosureDispatchNoChanges(Blackhole bh) { + Closure compute = { int x -> x * 2 } + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += compute(i) + } + bh.consume(sum) + } +}