diff --git a/.github/workflows/groovy-jmh-classic.yml b/.github/workflows/groovy-jmh-classic.yml new file mode 100644 index 00000000000..3aad200a077 --- /dev/null +++ b/.github/workflows/groovy-jmh-classic.yml @@ -0,0 +1,59 @@ +# 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. + +name: jmh-classic + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + include: + - suite: bench + pattern: '\\.bench\\.' + - suite: core + pattern: '\\.perf\\.[A-Z]' + - suite: grails + pattern: '\\.perf\\.grails\\.' + runs-on: ubuntu-latest + env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + check-latest: true + - uses: gradle/actions/setup-gradle@v5 + - name: JMH (${{ matrix.suite }} classic) + run: ./gradlew perf:jmh -PbenchInclude=${{ matrix.pattern }} -Pindy=false + timeout-minutes: 60 + - name: Rename JMH result file + run: | + mv subprojects/performance/build/results/jmh/results.txt \ + subprojects/performance/build/results/jmh/results-${{ matrix.suite }}.txt + + - name: Upload reports-jmh-classic-${{ matrix.suite }} + uses: actions/upload-artifact@v6 + with: + name: reports-jmh-classic-${{ matrix.suite }} + path: subprojects/performance/build/results/jmh/ + diff --git a/.github/workflows/groovy-jmh.yml b/.github/workflows/groovy-jmh.yml new file mode 100644 index 00000000000..d0ab763bced --- /dev/null +++ b/.github/workflows/groovy-jmh.yml @@ -0,0 +1,59 @@ +# 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. + +name: jmh + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + include: + - suite: bench + pattern: '\\.bench\\.' + - suite: core + pattern: '\\.perf\\.[A-Z]' + - suite: grails + pattern: '\\.perf\\.grails\\.' + runs-on: ubuntu-latest + env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + check-latest: true + - uses: gradle/actions/setup-gradle@v5 + - name: JMH (${{ matrix.suite }}) + run: ./gradlew perf:jmh -PbenchInclude=${{ matrix.pattern }} + timeout-minutes: 60 + - name: Rename JMH result file + run: | + mv subprojects/performance/build/results/jmh/results.txt \ + subprojects/performance/build/results/jmh/results-${{ matrix.suite }}.txt + + - name: Upload reports-jmh-${{ matrix.suite }} + uses: actions/upload-artifact@v6 + with: + name: reports-jmh-${{ matrix.suite }} + path: subprojects/performance/build/results/jmh/ + diff --git a/build-logic/src/main/groovy/org.apache.groovy-performance.gradle b/build-logic/src/main/groovy/org.apache.groovy-performance.gradle index a332ff7451a..c948066dec0 100644 --- a/build-logic/src/main/groovy/org.apache.groovy-performance.gradle +++ b/build-logic/src/main/groovy/org.apache.groovy-performance.gradle @@ -65,8 +65,12 @@ tasks.named('jmhClasses') { dependsOn tasks.named('clean') } +var indy = (project.findProperty('indy') ?: 'true').toBoolean() + tasks.named('compileJmhGroovy') { classpath += compileJmhJava.classpath + groovyOptions.optimizationOptions.indy = indy + inputs.property('indy', indy) } tasks.register('displayJmhResults') { @@ -81,3 +85,12 @@ tasks.register("performanceTests", PerformanceTestSummary) sonarqube { skipProject = true } + +tasks.named('jmh') { + inputs.property('benchInclude', project.findProperty('benchInclude') ?: '') + dependsOn tasks.named('jmhClasses') +} + +tasks.named('jmhJar') { + from(sourceSets.jmh.output) +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/ClosureBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/ClosureBench.groovy new file mode 100644 index 00000000000..77382f10546 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/ClosureBench.groovy @@ -0,0 +1,309 @@ +/* + * 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 + +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole + +import java.util.concurrent.TimeUnit + +/** + * Tests closure performance including creation, reuse, multi-parameter + * invocation, variable capture, delegation, nesting, method references, + * currying, composition, spread operator, trampoline recursion, and + * collection operations (each/collect/findAll/inject). + */ +@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 ClosureBench { + static final int ITERATIONS = 1_000_000 + + String instanceProperty = "instance" + + /** + * Benchmark: Simple closure creation and invocation + */ + @Benchmark + void simpleClosureCreation(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + Closure c = { it * 2 } + bh.consume(c(i)) + } + } + + /** + * Benchmark: Reuse same closure (no creation overhead) + */ + @Benchmark + void closureReuse(Blackhole bh) { + Closure c = { it * 2 } + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += c(i) + } + bh.consume(sum) + } + + /** + * Benchmark: Closure with multiple parameters + */ + @Benchmark + void closureMultiParams(Blackhole bh) { + Closure c = { a, b, x -> a + b + x } + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += c(i, i + 1, i + 2) + } + bh.consume(sum) + } + + /** + * Benchmark: Closure accessing local variable (captured variable) + */ + @Benchmark + void closureWithCapture(Blackhole bh) { + int captured = 100 + Closure c = { it + captured } + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += c(i) + } + bh.consume(sum) + } + + /** + * Benchmark: Closure modifying captured variable + */ + @Benchmark + void closureModifyCapture(Blackhole bh) { + int counter = 0 + Closure c = { counter++ } + for (int i = 0; i < ITERATIONS; i++) { + c() + } + bh.consume(counter) + } + + /** + * Benchmark: Closure with owner delegation + */ + @Benchmark + void closureDelegation(Blackhole bh) { + Closure c = { instanceProperty.length() } + c.delegate = this + c.resolveStrategy = Closure.DELEGATE_FIRST + + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += c() + } + bh.consume(sum) + } + + /** + * Benchmark: Nested closures + */ + @Benchmark + void nestedClosures(Blackhole bh) { + Closure outer = { x -> + Closure inner = { y -> x + y } + inner(x) + } + + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += outer(i) + } + bh.consume(sum) + } + + /** + * Benchmark: Method reference as closure + */ + @Benchmark + void methodReference(Blackhole bh) { + List list = [1, 2, 3, 4, 5] + Closure sizeRef = list.&size + + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += sizeRef() + } + bh.consume(sum) + } + + /** + * Benchmark: Curried closure + */ + @Benchmark + void curriedClosure(Blackhole bh) { + Closure add = { a, b -> a + b } + Closure addFive = add.curry(5) + + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += addFive(i) + } + bh.consume(sum) + } + + /** + * Benchmark: Right curried closure + */ + @Benchmark + void rightCurriedClosure(Blackhole bh) { + Closure subtract = { a, b -> a - b } + Closure subtractFive = subtract.rcurry(5) + + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += subtractFive(i) + } + bh.consume(sum) + } + + /** + * Benchmark: Closure composition (rightShift >>) + */ + @Benchmark + void closureComposition(Blackhole bh) { + Closure double_ = { it * 2 } + Closure addOne = { it + 1 } + Closure composed = double_ >> addOne + + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += composed(i) + } + bh.consume(sum) + } + + /** + * Benchmark: Closure as method parameter + */ + @Benchmark + void closureAsParameter(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += applyOperation(i) { it * 2 } + } + bh.consume(sum) + } + + static int applyOperation(int value, Closure operation) { + operation(value) + } + + /** + * Benchmark: Closure with spread operator + */ + @Benchmark + void closureSpread(Blackhole bh) { + Closure sum3 = { a, b, c -> a + b + c } + List args = [1, 2, 3] + + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += sum3(*args) + } + bh.consume(sum) + } + + /** + * Benchmark: Closure call vs doCall + */ + @Benchmark + void closureCallMethod(Blackhole bh) { + Closure c = { it * 2 } + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += c.call(i) + } + bh.consume(sum) + } + + /** + * Benchmark: Closure with trampoline (for recursion) + */ + @Benchmark + void closureTrampoline(Blackhole bh) { + Closure factorial + factorial = { n, acc = 1G -> + n <= 1 ? acc : factorial.trampoline(n - 1, n * acc) + }.trampoline() + + // Smaller iteration count due to computation cost + for (int i = 0; i < ITERATIONS / 100; i++) { + bh.consume(factorial(20)) + } + } + + /** + * Benchmark: each with closure (common pattern) + */ + @Benchmark + void eachWithClosure(Blackhole bh) { + List list = (1..10).toList() + int sum = 0 + for (int i = 0; i < ITERATIONS / 10; i++) { + list.each { sum += it } + } + bh.consume(sum) + } + + /** + * Benchmark: collect with closure + */ + @Benchmark + void collectWithClosure(Blackhole bh) { + List list = (1..10).toList() + for (int i = 0; i < ITERATIONS / 10; i++) { + bh.consume(list.collect { it * 2 }) + } + } + + /** + * Benchmark: findAll with closure + */ + @Benchmark + void findAllWithClosure(Blackhole bh) { + List list = (1..10).toList() + for (int i = 0; i < ITERATIONS / 10; i++) { + bh.consume(list.findAll { it > 5 }) + } + } + + /** + * Benchmark: inject/reduce with closure + */ + @Benchmark + void injectWithClosure(Blackhole bh) { + List list = (1..10).toList() + int sum = 0 + for (int i = 0; i < ITERATIONS / 10; i++) { + sum += list.inject(0) { acc, val -> acc + val } + } + bh.consume(sum) + } + +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/GStringBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/GStringBench.groovy new file mode 100644 index 00000000000..f998f71ecad --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/GStringBench.groovy @@ -0,0 +1,109 @@ +/* + * 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 + +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole + +import java.util.concurrent.TimeUnit + +/** + * Tests the performance of GString creation, interpolation, and + * calling methods on GString results — including simple and multi-value + * interpolation, comparison against plain String concatenation, use as + * Map keys, and repeated toString() evaluation. + */ +@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 GStringBench { + static final int ITERATIONS = 1_000_000 + + /** + * Simple GString with one interpolated value and a method call on the result. + */ + @Benchmark + void simpleInterpolation(Blackhole bh) { + String base = "Hello" + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += "${base}${i}".length() + } + bh.consume(sum) + } + + /** + * Multi-value GString with method call — tests cost of multiple + * interpolation expressions and a follow-on method dispatch. + */ + @Benchmark + void multiValueInterpolation(Blackhole bh) { + String a = "A" + String b = "B" + for (int i = 0; i < ITERATIONS; i++) { + bh.consume("${a}-${i}-${b}".toUpperCase()) + } + } + + /** + * GString compared to plain String concatenation — baseline to + * isolate the GString-specific overhead. + */ + @Benchmark + void stringConcatBaseline(Blackhole bh) { + String base = "Hello" + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += (base + i).length() + } + bh.consume(sum) + } + + /** + * GString used as a Map key — triggers toString() and hashCode(), + * testing lazy evaluation and method dispatch on the resulting String. + */ + @Benchmark + void gstringAsMapKey(Blackhole bh) { + Map map = [:] + String prefix = "key" + for (int i = 0; i < ITERATIONS; i++) { + map["${prefix}${i % 100}"] = i + } + bh.consume(map) + } + + /** + * Repeated toString() on the same GString — tests whether the + * GString caches its string representation. + */ + @Benchmark + void repeatedToString(Blackhole bh) { + String name = "World" + GString gs = "Hello ${name}!" + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += gs.toString().length() + } + bh.consume(sum) + } +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/GroovyIdiomBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/GroovyIdiomBench.groovy new file mode 100644 index 00000000000..e93ed861327 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/GroovyIdiomBench.groovy @@ -0,0 +1,277 @@ +/* + * 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 + +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole + +import java.util.concurrent.TimeUnit + +/** + * Tests performance of Groovy-specific language idioms: safe navigation + * (?.), spread-dot (*.), elvis (?:), with/tap scoping, range creation + * and iteration, and 'as' type coercion. + */ +@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 GroovyIdiomBench { + static final int ITERATIONS = 1_000_000 + + // Helper class for safe-nav / spread-dot / with tests + static class Person { + String name + Address address + } + + static class Address { + String city + String zip + } + + // Pre-built test data + Person personWithAddress + Person personNullAddress + List people + + @Setup(Level.Trial) + void setup() { + personWithAddress = new Person(name: "Alice", address: new Address(city: "Springfield", zip: "62704")) + personNullAddress = new Person(name: "Bob", address: null) + people = (1..100).collect { new Person(name: "Person$it", address: new Address(city: "City$it", zip: "${10000 + it}")) } + } + + // ===== SAFE NAVIGATION (?.) ===== + + /** + * Safe navigation on non-null chain — obj?.prop?.prop. + */ + @Benchmark + void safeNavNonNull(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += personWithAddress?.address?.city?.length() ?: 0 + } + bh.consume(sum) + } + + /** + * Safe navigation hitting null — tests the short-circuit path. + */ + @Benchmark + void safeNavNull(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += personNullAddress?.address?.city?.length() ?: 0 + } + bh.consume(sum) + } + + /** + * Safe navigation vs normal access — baseline for comparison. + */ + @Benchmark + void normalNavBaseline(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += personWithAddress.address.city.length() + } + bh.consume(sum) + } + + // ===== SPREAD-DOT (*.) ===== + + /** + * Spread-dot operator — list*.property collects a property from all elements. + */ + @Benchmark + void spreadDotProperty(Blackhole bh) { + for (int i = 0; i < ITERATIONS / 100; i++) { + bh.consume(people*.name) + } + } + + /** + * Spread-dot with method call — list*.method(). + */ + @Benchmark + void spreadDotMethod(Blackhole bh) { + for (int i = 0; i < ITERATIONS / 100; i++) { + bh.consume(people*.getName()) + } + } + + /** + * Spread-dot vs collect — baseline comparison. + */ + @Benchmark + void collectBaseline(Blackhole bh) { + for (int i = 0; i < ITERATIONS / 100; i++) { + bh.consume(people.collect { it.name }) + } + } + + // ===== ELVIS (?:) ===== + + /** + * Elvis operator with non-null value — takes the left side. + */ + @Benchmark + void elvisNonNull(Blackhole bh) { + String value = "hello" + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += (value ?: "default").length() + } + bh.consume(sum) + } + + /** + * Elvis operator with null value — takes the right side. + */ + @Benchmark + void elvisNull(Blackhole bh) { + String value = null + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += (value ?: "default").length() + } + bh.consume(sum) + } + + /** + * Elvis with empty string (Groovy truth: empty string is falsy). + */ + @Benchmark + void elvisEmptyString(Blackhole bh) { + String value = "" + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += (value ?: "default").length() + } + bh.consume(sum) + } + + // ===== WITH / TAP ===== + + /** + * with {} — executes closure with object as delegate, returns closure result. + */ + @Benchmark + void withScope(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += personWithAddress.with { + name.length() + address.city.length() + } + } + bh.consume(sum) + } + + /** + * tap {} — executes closure with object as delegate, returns the object. + */ + @Benchmark + void tapScope(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + bh.consume(new Person().tap { + name = "Test" + address = new Address(city: "City", zip: "12345") + }) + } + } + + // ===== RANGE ===== + + /** + * Range creation — (1..N) creates an IntRange object. + */ + @Benchmark + void rangeCreation(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + bh.consume(1..100) + } + } + + /** + * Range iteration with each — (1..N).each { }. + */ + @Benchmark + void rangeIteration(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS / 100; i++) { + (1..100).each { sum += it } + } + bh.consume(sum) + } + + /** + * Range contains check — (val in range) uses Range.containsWithinBounds. + */ + @Benchmark + void rangeContains(Blackhole bh) { + def range = 1..1000 + int count = 0 + for (int i = 0; i < ITERATIONS; i++) { + if ((i % 1500) in range) count++ + } + bh.consume(count) + } + + // ===== AS TYPE COERCION ===== + + /** + * 'as' coercion: list as Set. + */ + @Benchmark + void asListToSet(Blackhole bh) { + List list = [1, 2, 3, 4, 5, 1, 2, 3] + for (int i = 0; i < ITERATIONS; i++) { + bh.consume(list as Set) + } + } + + /** + * 'as' coercion: object as String. + */ + @Benchmark + void asToString(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += (i as String).length() + } + bh.consume(sum) + } + + /** + * 'as' coercion: String to Integer. + */ + @Benchmark + void asStringToInteger(Blackhole bh) { + String[] values = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"] + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += values[i % 10] as Integer + } + bh.consume(sum) + } +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/LoopsBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/LoopsBench.groovy new file mode 100644 index 00000000000..32f1aab5d6c --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/LoopsBench.groovy @@ -0,0 +1,104 @@ +/* + * 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 + +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole + +import java.util.concurrent.TimeUnit + +/** + * Tests the overhead of repeated closure and method invocation within + * tight loops. Focuses on loop-specific patterns: closure-in-loop vs + * method-in-loop, nested iteration, and minimal vs complex loop bodies. + * + * Collection operation benchmarks (each/collect/findAll/inject on lists) + * are in {@link ClosureBench}. + */ +@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 LoopsBench { + static final int LOOP_COUNT = 1_000_000 + + /** + * Loop with [1].each and toString() — exercises closure dispatch + * and virtual method call on each iteration. + */ + @Benchmark + void originalEachToString(Blackhole bh) { + for (int i = 0; i < LOOP_COUNT; i++) { + [1].each { bh.consume(it.toString()) } + } + } + + /** + * Minimal each loop — isolates closure dispatch overhead from toString() cost. + */ + @Benchmark + void eachIdentity(Blackhole bh) { + for (int i = 0; i < LOOP_COUNT; i++) { + [1].each { bh.consume(it) } + } + } + + /** + * Reused closure invoked in a loop via .call() — tests call site caching + * when the same closure is called repeatedly (no new closure allocation per iteration). + */ + @Benchmark + void reusedClosureInLoop(Blackhole bh) { + Closure c = { it.toString() } + for (int i = 0; i < LOOP_COUNT; i++) { + bh.consume(c.call(1)) + } + } + + /** + * Direct method call in a loop — baseline comparison against closure dispatch. + * Shows the overhead of closure invocation vs plain method invocation. + */ + @Benchmark + void methodCallInLoop(Blackhole bh) { + for (int i = 0; i < LOOP_COUNT; i++) { + bh.consume(doSomething(1)) + } + } + + static String doSomething(Object o) { + o.toString() + } + + /** + * Nested loops with closures — tests call site behavior when multiple + * closure call sites are active across nested iteration scopes. + */ + @Benchmark + void nestedLoopsWithClosure(Blackhole bh) { + int count = (int) Math.sqrt(LOOP_COUNT) + for (int i = 0; i < count; i++) { + for (int j = 0; j < count; j++) { + [i, j].each { bh.consume(it.toString()) } + } + } + } +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/MetaclassBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/MetaclassBench.groovy new file mode 100644 index 00000000000..b318dcea131 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/MetaclassBench.groovy @@ -0,0 +1,77 @@ +/* + * 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 + +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.BenchmarkMode +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Measurement +import org.openjdk.jmh.annotations.Mode +import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Warmup +import org.openjdk.jmh.infra.Blackhole + +import java.util.concurrent.TimeUnit + +/** + * Tests the overhead of dynamic method invocation and dispatch in Groovy in the presence of Metaclass changes. + */ +@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 MetaclassBench { + static final int ITERATIONS = 100_000 + + class Foo { + int m0() { 0 } + int m1() { 1 } + int m2() { 2 } + int m3() { 3 } + int m4() { 4 } + int m5() { 5 } + int m6() { 6 } + int m7() { 7 } + int m8() { 8 } + int m9() { 9 } + int m10() { 10 } + int m11() { 11 } + int m12() { 12 } + int m13() { 13 } + int m14() { 14 } + int m15() { 15 } + } + + @Benchmark + void methodCallsWithMetaclassChanges(Blackhole bh) { + long sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + Foo.metaClass."meth${i % 16}" = { -> i } + def foo = new Foo() + sum += foo.m0() + foo.m1() + foo.m2() + foo.m3() + foo.m4() + foo.m5() + foo.m6() + foo.m7() + + foo.m8() + foo.m9() + foo.m10() + foo.m11() + foo.m12() + foo.m13() + foo.m14() + foo.m15() + } + bh.consume(sum) + } + +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/MethodInvocationBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/MethodInvocationBench.groovy new file mode 100644 index 00000000000..f619f3aa203 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/MethodInvocationBench.groovy @@ -0,0 +1,187 @@ +/* + * 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 + +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole + +import java.util.concurrent.TimeUnit + +/** + * Tests the overhead of dynamic method invocation and dispatch in Groovy. + * Covers instance and static method calls, parameter passing, overloaded + * method resolution, monomorphic vs polymorphic call sites, interface + * dispatch, and dynamically-typed dispatch. + * + * Property access is in {@link PropertyAccessBench}. + * GString operations are in {@link GStringBench}. + * Method references as closures are in {@link ClosureBench}. + */ +@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 MethodInvocationBench { + static final int ITERATIONS = 1_000_000 + + // Simple fields for method tests + int instanceField = 42 + static int staticField = 100 + + // Simple instance method + int simpleMethod() { + instanceField + } + + // Method with parameters + int methodWithParams(int a, int b) { + a + b + } + + // Method with object parameter + String methodWithObject(Object obj) { + obj.toString() + } + + // Overloaded methods to test dispatch + String overloaded(String s) { "String: $s" } + String overloaded(Integer i) { "Integer: $i" } + String overloaded(Object o) { "Object: $o" } + + // Static methods + static int staticMethod() { + staticField + } + + static int staticMethodWithParams(int a, int b) { + a + b + } + + /** + * Benchmark: Simple instance method calls + */ + @Benchmark + void benchmarkSimpleMethodCalls(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += simpleMethod() + } + bh.consume(sum) + } + + /** + * Benchmark: Method calls with parameters + */ + @Benchmark + void benchmarkMethodWithParams(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += methodWithParams(i, 1) + } + bh.consume(sum) + } + + /** + * Benchmark: Method calls with object parameter + */ + @Benchmark + void benchmarkMethodWithObject(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + bh.consume(methodWithObject(i)) + } + } + + /** + * Benchmark: Static method calls + */ + @Benchmark + void benchmarkStaticMethodCalls(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += staticMethod() + } + bh.consume(sum) + } + + /** + * Benchmark: Static method calls with parameters + */ + @Benchmark + void benchmarkStaticMethodWithParams(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += staticMethodWithParams(i, 1) + } + bh.consume(sum) + } + + /** + * Benchmark: Monomorphic call site (same type every time) + * This should be fast with proper indy optimization + */ + @Benchmark + void benchmarkMonomorphicCallSite(Blackhole bh) { + String s = "test" + for (int i = 0; i < ITERATIONS; i++) { + bh.consume(overloaded(s)) + } + } + + /** + * Benchmark: Polymorphic call site (different types) + * This tests the call site cache effectiveness + */ + @Benchmark + void benchmarkPolymorphicCallSite(Blackhole bh) { + Object[] args = ["string", 42, new Object(), "another", 100, [1, 2, 3]] + + for (int i = 0; i < ITERATIONS; i++) { + bh.consume(overloaded(args[i % args.length])) + } + } + + /** + * Benchmark: Method calls through interface + */ + @Benchmark + void benchmarkInterfaceMethodCalls(Blackhole bh) { + List list = [1, 2, 3, 4, 5] + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += list.size() + } + bh.consume(sum) + } + + /** + * Benchmark: Method calls on dynamically typed variable + */ + @Benchmark + void benchmarkDynamicTypedCalls(Blackhole bh) { + def instance = this + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += instance.simpleMethod() + } + bh.consume(sum) + } + +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/OperatorBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/OperatorBench.groovy new file mode 100644 index 00000000000..1fdc0bcba83 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/OperatorBench.groovy @@ -0,0 +1,207 @@ +/* + * 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 + +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole + +import java.util.concurrent.TimeUnit + +/** + * Tests the performance of Groovy operator overloading. In Groovy every + * operator (+, -, *, /, [], <<, ==, <=>) compiles to a method call + * (plus, minus, multiply, div, getAt, leftShift, equals, compareTo) + * dispatched through invokedynamic. + */ +@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 OperatorBench { + static final int ITERATIONS = 1_000_000 + + /** + * Integer addition — dispatches to Integer.plus(Integer). + */ + @Benchmark + void integerPlus(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum = sum + i + } + bh.consume(sum) + } + + /** + * Integer multiplication — dispatches to Integer.multiply(Integer). + * Uses modulo to keep operands small and avoid overflow to zero. + */ + @Benchmark + void integerMultiply(Blackhole bh) { + int product = 1 + for (int i = 1; i < ITERATIONS; i++) { + product = (i % 100) * (i % 50) + } + bh.consume(product) + } + + /** + * BigDecimal arithmetic — common in financial/Grails apps, + * all operations go through operator method dispatch. + */ + @Benchmark + void bigDecimalArithmetic(Blackhole bh) { + BigDecimal sum = 0.0 + for (int i = 0; i < ITERATIONS; i++) { + sum = sum + 1.5 + } + bh.consume(sum) + } + + /** + * String multiply (repeat) — "abc" * 3 dispatches to String.multiply(Integer). + */ + @Benchmark + void stringMultiply(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += ("x" * 5).length() + } + bh.consume(sum) + } + + /** + * List subscript read — list[i] dispatches to List.getAt(int). + */ + @Benchmark + void listGetAt(Blackhole bh) { + List list = (0..99).toList() + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += list[i % 100] + } + bh.consume(sum) + } + + /** + * List subscript write — list[i] = val dispatches to List.putAt(int, Object). + */ + @Benchmark + void listPutAt(Blackhole bh) { + List list = (0..99).toList() + for (int i = 0; i < ITERATIONS; i++) { + list[i % 100] = i + } + bh.consume(list) + } + + /** + * Map subscript read/write — map[key] dispatches to getAt/putAt. + */ + @Benchmark + void mapGetAtPutAt(Blackhole bh) { + Map map = [a: 1, b: 2, c: 3, d: 4, e: 5] + String[] keys = ['a', 'b', 'c', 'd', 'e'] + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + map[keys[i % 5]] = i + sum += map[keys[i % 5]] + } + bh.consume(sum) + } + + /** + * Left shift operator — list << item dispatches to List.leftShift(Object). + */ + @Benchmark + void listLeftShift(Blackhole bh) { + List list = [] + for (int i = 0; i < ITERATIONS; i++) { + if (i % 1000 == 0) list = [] + list << i + } + bh.consume(list) + } + + /** + * Equals operator — == dispatches to Object.equals(Object) in Groovy + * (not reference equality like Java). + */ + @Benchmark + void equalsOperator(Blackhole bh) { + String a = "hello" + String b = "hello" + int count = 0 + for (int i = 0; i < ITERATIONS; i++) { + if (a == b) count++ + } + bh.consume(count) + } + + /** + * Spaceship operator — <=> dispatches to Comparable.compareTo(). + */ + @Benchmark + void spaceshipOperator(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += (i <=> (i + 1)) + } + bh.consume(sum) + } + + /** + * Comparison operators — <, >, <=, >= dispatch through compareTo(). + */ + @Benchmark + void comparisonOperators(Blackhole bh) { + int count = 0 + for (int i = 0; i < ITERATIONS; i++) { + if (i > 0 && i < ITERATIONS && i >= 0 && i <= ITERATIONS) count++ + } + bh.consume(count) + } + + /** + * Unary minus — dispatches to Number.unaryMinus(). + */ + @Benchmark + void unaryMinus(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += (-i) + } + bh.consume(sum) + } + + /** + * In operator — (item in collection) dispatches to Collection.isCase(Object). + */ + @Benchmark + void inOperator(Blackhole bh) { + List list = (0..99).toList() + int count = 0 + for (int i = 0; i < ITERATIONS; i++) { + if ((i % 100) in list) count++ + } + bh.consume(count) + } +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/PropertyAccessBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/PropertyAccessBench.groovy new file mode 100644 index 00000000000..c58f6065412 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/PropertyAccessBench.groovy @@ -0,0 +1,134 @@ +/* + * 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 + +import org.openjdk.jmh.annotations.* +import org.openjdk.jmh.infra.Blackhole + +import java.util.concurrent.TimeUnit + +/** + * Tests the performance of Groovy property access patterns including + * field read/write, getter/setter dispatch, dynamically-typed property + * access, map bracket and dot-property notation, and chained property + * resolution. + */ +@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 PropertyAccessBench { + static final int ITERATIONS = 1_000_000 + + int instanceField = 42 + String stringProperty = "hello" + + // Explicit getter/setter for comparison + private int _backingField = 10 + int getBackingField() { _backingField } + void setBackingField(int value) { _backingField = value } + + /** + * Read/write a Groovy property — in Groovy, {@code int instanceField} + * declares a property backed by a private field with generated getter/setter. + */ + @Benchmark + void fieldReadWrite(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + instanceField = i + sum += instanceField + } + bh.consume(sum) + } + + /** + * Read/write through explicit getter/setter methods — + * tests the overhead of Groovy's property-to-getter/setter dispatch. + */ + @Benchmark + void getterSetterAccess(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + backingField = i + sum += backingField + } + bh.consume(sum) + } + + /** + * Property access on a dynamically typed variable — + * tests the cost when the compiler cannot statically resolve the property. + */ + @Benchmark + void dynamicTypedPropertyAccess(Blackhole bh) { + def obj = this + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + obj.instanceField = i + sum += obj.instanceField + } + bh.consume(sum) + } + + /** + * Map bracket notation — {@code map['key']} dispatches to + * {@code Map.getAt()} / {@code Map.putAt()}. + */ + @Benchmark + void mapStyleAccess(Blackhole bh) { + Map map = [a: 1, b: 2, c: 3] + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + map['a'] = i + sum += map['a'] + } + bh.consume(sum) + } + + /** + * Dot-property access on a Map — Groovy allows map.key syntax. + */ + @Benchmark + void mapDotPropertyAccess(Blackhole bh) { + Map map = [a: 1, b: 2, c: 3] + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + map.a = i + sum += map.a + } + bh.consume(sum) + } + + /** + * Chained property access — tests multiple property resolutions + * in a single expression using nested maps. + */ + @Benchmark + void chainedPropertyAccess(Blackhole bh) { + Map root = [level1: [level2: [value: 42]]] + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += root.level1.level2.value + } + bh.consume(sum) + } +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/RunnerRegistryBench.java b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/RunnerRegistryBench.java new file mode 100644 index 00000000000..78cea6079e8 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/RunnerRegistryBench.java @@ -0,0 +1,69 @@ +/* + * 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; + +import org.apache.groovy.plugin.GroovyRunner; +import org.apache.groovy.plugin.GroovyRunnerRegistry; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) +@Fork(2) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Thread) +public class RunnerRegistryBench { + + static List control = new ArrayList<>(); + static GroovyRunnerRegistry registry = GroovyRunnerRegistry.getInstance(); + static { + control.add(new Object()); + control.add(new Object()); + control.add(new Object()); + registry.load(RunnerRegistryBench.class.getClassLoader()); + } + + @Benchmark + public void registryIterator(Blackhole bh) { + for (GroovyRunner runner : registry) { + bh.consume(runner); + } + } + + @Benchmark + public void listIterator(Blackhole bh) { + for (Object obj : control) { + bh.consume(obj); + } + } + +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/CallSiteInvalidationBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/CallSiteInvalidationBench.groovy new file mode 100644 index 00000000000..b239c443334 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/CallSiteInvalidationBench.groovy @@ -0,0 +1,224 @@ +/* + * 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 + +/** + * SwitchPoint invalidation overhead for Grails-like metaclass change patterns. + * + * @see GROOVY-10307 + */ +@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 CallSiteInvalidationBench { + static final int ITERATIONS = 100_000 + + static class HotTarget { + int value = 42 + int compute() { value * 2 } + String describe() { "v=$value" } + } + + static class HotTargetB { + int count = 10 + int getCount() { count } + } + + static class HotTargetC { + List items = [1, 2, 3] + int itemCount() { items.size() } + } + + static class ColdType { + String label = "cold" + } + + HotTarget hotTarget + HotTargetB hotTargetB + HotTargetC hotTargetC + List sampleList + + @Setup(Level.Iteration) + void setup() { + GroovySystem.metaClassRegistry.removeMetaClass(HotTarget) + GroovySystem.metaClassRegistry.removeMetaClass(HotTargetB) + GroovySystem.metaClassRegistry.removeMetaClass(HotTargetC) + GroovySystem.metaClassRegistry.removeMetaClass(ColdType) + hotTarget = new HotTarget() + hotTargetB = new HotTargetB() + hotTargetC = new HotTargetC() + sampleList = [1, 2, 3, 4, 5] + } + + /** Single method call in tight loop, no invalidation. */ + @Benchmark + void baselineHotLoop(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += hotTarget.compute() + } + bh.consume(sum) + } + + /** list.size() in tight loop, no invalidation. */ + @Benchmark + void baselineListSize(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += sampleList.size() + } + bh.consume(sum) + } + + /** Cross-type invalidation every 1000 calls. */ + @Benchmark + void crossTypeInvalidationEvery1000(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += hotTarget.compute() + if (i % 1000 == 0) { + ColdType.metaClass."dynamic${i % 5}" = { -> i } + } + } + bh.consume(sum) + } + + /** Cross-type invalidation every 100 calls. */ + @Benchmark + void crossTypeInvalidationEvery100(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += hotTarget.compute() + if (i % 100 == 0) { + ColdType.metaClass."dynamic${i % 5}" = { -> i } + } + } + bh.consume(sum) + } + + /** Cross-type invalidation every 10000 calls. */ + @Benchmark + void crossTypeInvalidationEvery10000(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += hotTarget.compute() + if (i % 10000 == 0) { + ColdType.metaClass."dynamic${i % 5}" = { -> i } + } + } + bh.consume(sum) + } + + /** list.size() with cross-type invalidation every 1000 calls. */ + @Benchmark + void listSizeWithCrossTypeInvalidation(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += sampleList.size() + if (i % 1000 == 0) { + ColdType.metaClass."dynamic${i % 5}" = { -> i } + } + } + bh.consume(sum) + } + + /** Same-type invalidation every 1000 calls. */ + @Benchmark + void sameTypeInvalidationEvery1000(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += hotTarget.compute() + if (i % 1000 == 0) { + HotTarget.metaClass."dynamic${i % 5}" = { -> i } + } + } + bh.consume(sum) + } + + /** Five method calls across three types, no invalidation. */ + @Benchmark + void baselineMultipleCallSites(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += hotTarget.compute() + sum += hotTarget.describe().length() + sum += hotTargetB.getCount() + sum += hotTargetC.itemCount() + sum += sampleList.size() + } + bh.consume(sum) + } + + /** Five call sites with cross-type invalidation every 1000 calls. */ + @Benchmark + void multipleCallSitesWithInvalidation(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += hotTarget.compute() + sum += hotTarget.describe().length() + sum += hotTargetB.getCount() + sum += hotTargetC.itemCount() + sum += sampleList.size() + if (i % 1000 == 0) { + ColdType.metaClass."dynamic${i % 5}" = { -> i } + } + } + bh.consume(sum) + } + + /** 100 metaclass changes then steady-state method calls. */ + @Benchmark + void burstThenSteadyState(Blackhole bh) { + // Phase 1: Burst of metaclass changes (framework startup) + for (int i = 0; i < 100; i++) { + ColdType.metaClass."startup${i % 20}" = { -> i } + } + + // Phase 2: Steady-state method calls (request handling) + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += hotTarget.compute() + sum += hotTargetB.getCount() + sum += sampleList.size() + } + bh.consume(sum) + } + + /** Steady-state method calls with no preceding burst. */ + @Benchmark + void baselineSteadyStateNoBurst(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + sum += hotTarget.compute() + sum += hotTargetB.getCount() + sum += sampleList.size() + } + bh.consume(sum) + } +} 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..00159b7c785 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/DynamicDispatchBench.groovy @@ -0,0 +1,344 @@ +/* + * 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 + +// Package-level classes: Groovy 4 does not support methodMissing/propertyMissing +// on static inner classes, so these are defined at package level. + +/** Class with methodMissing - like Grails domain class dynamic finders. */ +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. */ +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. */ +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" } +} + +/** + * 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: + *
    + *
  • {@code methodMissing} - dynamic finders (findByName, findAllByAge)
  • + *
  • {@code propertyMissing} - dynamic property injection (params, session)
  • + *
  • {@code invokeMethod} - method interception for transactions, security
  • + *
  • ExpandoMetaClass runtime injection - adding methods at framework startup
  • + *
+ * + * 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 + + // 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..187c2bfccef --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/GrailsLikePatternsBench.groovy @@ -0,0 +1,490 @@ +/* + * 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 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: + *
    + *
  • Service layer method chains with dependency injection
  • + *
  • Domain class CRUD with property access and validation
  • + *
  • Controller action dispatch with parameter binding
  • + *
  • Configuration DSL with nested delegation
  • + *
  • View rendering with builder patterns
  • + *
+ */ +@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, Closure body) { + tag(name, [:], body) + } + + 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/GrailsWorkloadBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/GrailsWorkloadBench.groovy new file mode 100644 index 00000000000..2efac25abc3 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/GrailsWorkloadBench.groovy @@ -0,0 +1,468 @@ +/* + * 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 + +/** + * Groovy collection and DSL patterns from the grails7-performance-regression demo app. + * + * @see Demo app + * @see GROOVY-10307 + */ +@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 GrailsWorkloadBench { + static final int ITERATIONS = 10_000 + + // Domain-like entities from the demo app + static class Employee { + Long id + String firstName + String lastName + String email + String jobTitle + String department + BigDecimal salary + boolean isActive + int performanceRating + List skills = [] + + String getFullName() { "$firstName $lastName" } + Map toMap() { + [id: id, name: getFullName(), email: email, title: jobTitle, + dept: department, salary: salary, active: isActive, + rating: performanceRating, skillCount: skills.size()] + } + } + + static class Project { + Long id + String name + String status + BigDecimal budget + String department + int priority + List tasks = [] + List milestones = [] + + Map toMap() { + [id: id, name: name, status: status, budget: budget, + taskCount: tasks.size(), milestoneCount: milestones.size()] + } + } + + static class Task { + Long id + String name + String status + int priority + int estimatedHours + String assignee + + Map toMap() { [id: id, name: name, status: status, priority: priority] } + } + + static class Milestone { + Long id + String name + boolean isCompleted + Map toMap() { [id: id, name: name, completed: isCompleted] } + } + + // Unrelated type for cross-type invalidation + static class PluginConfig { + String setting = "default" + } + + List employees + List projects + List tasks + int invalidationCounter + + @Setup(Level.Iteration) + void setup() { + GroovySystem.metaClassRegistry.removeMetaClass(Employee) + GroovySystem.metaClassRegistry.removeMetaClass(Project) + GroovySystem.metaClassRegistry.removeMetaClass(Task) + GroovySystem.metaClassRegistry.removeMetaClass(Milestone) + GroovySystem.metaClassRegistry.removeMetaClass(PluginConfig) + + def statuses = ['TODO', 'IN_PROGRESS', 'DONE', 'BLOCKED'] + def departments = ['Engineering', 'Marketing', 'Sales', 'Support', 'HR'] + def titles = ['Developer', 'Designer', 'Manager', 'Analyst', 'Lead'] + + // Sample data matching demo app scale + employees = (1..50).collect { i -> + new Employee( + id: i, + firstName: "First$i", + lastName: "Last$i", + email: "user${i}@example.com", + jobTitle: titles[i % titles.size()], + department: departments[i % departments.size()], + salary: 50000 + (i * 1000), + isActive: i % 5 != 0, + performanceRating: (i % 5) + 1, + skills: (1..(i % 4 + 1)).collect { s -> "Skill$s" } + ) + } + + tasks = (1..100).collect { i -> + new Task( + id: i, + name: "Task$i", + status: statuses[i % statuses.size()], + priority: (i % 10) + 1, + estimatedHours: (i % 8) + 1, + assignee: "First${(i % 50) + 1}" + ) + } + + projects = (1..20).collect { i -> + def projectTasks = tasks.subList( + (i - 1) * 5, Math.min(i * 5, tasks.size()) + ) + def milestones = (1..3).collect { m -> + new Milestone(id: (i * 3) + m, name: "M${i}-${m}", isCompleted: m <= 2) + } + new Project( + id: i, + name: "Project$i", + status: statuses[i % statuses.size()], + budget: 100000 + (i * 50000), + department: departments[i % departments.size()], + priority: (i % 10) + 1, + tasks: projectTasks, + milestones: milestones + ) + } + } + + /** Baseline: findAll/collect/groupBy/collectEntries closure chains. */ + @Benchmark + void baselineCollectionClosureChain(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def activeEmployees = employees.findAll { it.isActive } + def mapped = activeEmployees.collect { it.toMap() } + def byDept = mapped.groupBy { it.dept } + def deptStats = byDept.collectEntries { dept, emps -> + [dept, [count: emps.size(), avgRating: emps.sum { it.rating } / emps.size()]] + } + bh.consume(deptStats.size()) + } + } + + /** Collection closure chains with periodic cross-type invalidation. */ + @Benchmark + void collectionClosureChainWithInvalidation(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def activeEmployees = employees.findAll { it.isActive } + def mapped = activeEmployees.collect { it.toMap() } + def byDept = mapped.groupBy { it.dept } + def deptStats = byDept.collectEntries { dept, emps -> + [dept, [count: emps.size(), avgRating: emps.sum { it.rating } / emps.size()]] + } + bh.consume(deptStats.size()) + if (i % 100 == 0) { + PluginConfig.metaClass."helper${i % 5}" = { -> i } + } + } + } + + /** Baseline: spread operator (employees*.salary). */ + @Benchmark + void baselineSpreadOperator(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def names = employees*.firstName + def salaries = employees*.salary + def ratings = employees*.performanceRating + bh.consume(names.size() + salaries.size() + ratings.size()) + } + } + + /** Spread operator with periodic cross-type invalidation. */ + @Benchmark + void spreadOperatorWithInvalidation(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def names = employees*.firstName + def salaries = employees*.salary + def ratings = employees*.performanceRating + bh.consume(names.size() + salaries.size() + ratings.size()) + if (i % 100 == 0) { + PluginConfig.metaClass."helper${i % 5}" = { -> i } + } + } + } + + static class CriteriaBuilder { + Map result = [:] + + void eq(String field, Object value) { + result[field] = value + } + + void gt(String field, Object value) { + result["${field}_gt"] = value + } + + void nested(String name, @DelegatesTo(CriteriaBuilder) Closure cl) { + def inner = new CriteriaBuilder() + cl.delegate = inner + cl.resolveStrategy = Closure.DELEGATE_FIRST + cl() + result[name] = inner.result + } + + Map build() { result } + } + + /** Baseline: 3-level nested closure delegation (GORM criteria pattern). */ + @Benchmark + void baselineNestedClosureDelegation(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def builder = new CriteriaBuilder() + builder.nested('project') { + eq('status', 'IN_PROGRESS') + gt('priority', 5) + nested('department') { + eq('name', "Dept${i % 5}") + nested('company') { + eq('active', true) + } + } + } + bh.consume(builder.build().size()) + } + } + + /** Nested closure delegation with periodic cross-type invalidation. */ + @Benchmark + void nestedClosureDelegationWithInvalidation(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def builder = new CriteriaBuilder() + builder.nested('project') { + eq('status', 'IN_PROGRESS') + gt('priority', 5) + nested('department') { + eq('name', "Dept${i % 5}") + nested('company') { + eq('active', true) + } + } + } + bh.consume(builder.build().size()) + if (i % 100 == 0) { + PluginConfig.metaClass."helper${i % 5}" = { -> i } + } + } + } + + /** Baseline: GString interpolation with dynamic property access. */ + @Benchmark + void baselineGStringInterpolation(Blackhole bh) { + int totalLen = 0 + for (int i = 0; i < ITERATIONS; i++) { + def emp = employees[i % employees.size()] + String full = "${emp.firstName} ${emp.lastName}" + String detail = "${emp.jobTitle} at ${emp.department} - \$${emp.salary}" + String summary = "Employee #${emp.id}: ${full} (${emp.performanceRating}/5)" + totalLen += full.length() + detail.length() + summary.length() + } + bh.consume(totalLen) + } + + /** GString interpolation with periodic cross-type invalidation. */ + @Benchmark + void gstringInterpolationWithInvalidation(Blackhole bh) { + int totalLen = 0 + for (int i = 0; i < ITERATIONS; i++) { + def emp = employees[i % employees.size()] + String full = "${emp.firstName} ${emp.lastName}" + String detail = "${emp.jobTitle} at ${emp.department} - \$${emp.salary}" + String summary = "Employee #${emp.id}: ${full} (${emp.performanceRating}/5)" + totalLen += full.length() + detail.length() + summary.length() + if (i % 100 == 0) { + PluginConfig.metaClass."helper${i % 5}" = { -> i } + } + } + bh.consume(totalLen) + } + + /** Baseline: dynamic property access by name string. */ + @Benchmark + void baselineDynamicPropertyByName(Blackhole bh) { + String[] fields = ['firstName', 'lastName', 'email', 'jobTitle', 'department'] + int totalLen = 0 + for (int i = 0; i < ITERATIONS; i++) { + def emp = employees[i % employees.size()] + for (int f = 0; f < fields.length; f++) { + def val = emp."${fields[f]}" + totalLen += val?.toString()?.length() ?: 0 + } + } + bh.consume(totalLen) + } + + /** Dynamic property access with periodic cross-type invalidation. */ + @Benchmark + void dynamicPropertyByNameWithInvalidation(Blackhole bh) { + String[] fields = ['firstName', 'lastName', 'email', 'jobTitle', 'department'] + int totalLen = 0 + for (int i = 0; i < ITERATIONS; i++) { + def emp = employees[i % employees.size()] + for (int f = 0; f < fields.length; f++) { + def val = emp."${fields[f]}" + totalLen += val?.toString()?.length() ?: 0 + } + if (i % 100 == 0) { + PluginConfig.metaClass."helper${i % 5}" = { -> i } + } + } + bh.consume(totalLen) + } + + /** Baseline: project metrics aggregation (demo app's getProjectMetrics). */ + @Benchmark + void baselineProjectMetrics(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def project = projects[i % projects.size()] + def completedTasks = project.tasks.count { it.status == 'DONE' } + def totalHours = project.tasks.sum { it.estimatedHours } ?: 0 + def completedMilestones = project.milestones.count { it.isCompleted } + def completion = project.tasks.size() > 0 ? + (completedTasks / project.tasks.size() * 100) : 0 + def metrics = [ + name: project.name, + tasks: project.tasks.size(), + completed: completedTasks, + hours: totalHours, + milestones: completedMilestones, + completion: completion + ] + bh.consume(metrics.size()) + } + } + + /** Project metrics with periodic cross-type invalidation. */ + @Benchmark + void projectMetricsWithInvalidation(Blackhole bh) { + for (int i = 0; i < ITERATIONS; i++) { + def project = projects[i % projects.size()] + def completedTasks = project.tasks.count { it.status == 'DONE' } + def totalHours = project.tasks.sum { it.estimatedHours } ?: 0 + def completedMilestones = project.milestones.count { it.isCompleted } + def completion = project.tasks.size() > 0 ? + (completedTasks / project.tasks.size() * 100) : 0 + def metrics = [ + name: project.name, + tasks: project.tasks.size(), + completed: completedTasks, + hours: totalHours, + milestones: completedMilestones, + completion: completion + ] + bh.consume(metrics.size()) + if (i % 100 == 0) { + PluginConfig.metaClass."helper${i % 5}" = { -> i } + } + } + } + + /** Baseline: full analysis combining all patterns (demo app's runComplexAnalysis). */ + @Benchmark + void baselineFullAnalysis(Blackhole bh) { + // Employee analysis + def activeEmps = employees.findAll { it.isActive } + def empNames = activeEmps*.getFullName() + def byDept = activeEmps.groupBy { it.department } + def deptSummary = byDept.collectEntries { dept, emps -> + def avgSalary = emps.sum { it.salary } / emps.size() + def topPerformer = emps.max { it.performanceRating } + [dept, [count: emps.size(), avgSalary: avgSalary, + top: topPerformer.getFullName()]] + } + + // Project metrics + def projectSummary = projects.collect { proj -> + def done = proj.tasks.count { it.status == 'DONE' } + def blocked = proj.tasks.count { it.status == 'BLOCKED' } + [name: proj.name, status: proj.status, + done: done, blocked: blocked, budget: proj.budget] + } + + // Cross-entity: high-priority tasks by department + def highPriority = tasks.findAll { it.priority > 7 } + def taskSummary = highPriority.groupBy { it.status } + .collectEntries { status, tl -> + [status, tl.collect { "${it.name} (P${it.priority})" }] + } + + bh.consume(deptSummary.size() + projectSummary.size() + + taskSummary.size() + empNames.size()) + } + + /** Full analysis with cross-type invalidation before and during execution. */ + @Benchmark + void fullAnalysisWithInvalidation(Blackhole bh) { + // Ongoing framework metaclass activity + PluginConfig.metaClass."preRequest${invalidationCounter++ % 3}" = { -> 'init' } + + // Employee analysis + def activeEmps = employees.findAll { it.isActive } + def empNames = activeEmps*.getFullName() + def byDept = activeEmps.groupBy { it.department } + def deptSummary = byDept.collectEntries { dept, emps -> + def avgSalary = emps.sum { it.salary } / emps.size() + def topPerformer = emps.max { it.performanceRating } + [dept, [count: emps.size(), avgSalary: avgSalary, + top: topPerformer.getFullName()]] + } + + // Mid-request metaclass change + PluginConfig.metaClass."midRequest${invalidationCounter++ % 3}" = { -> 'lazy' } + + // Project metrics + def projectSummary = projects.collect { proj -> + def done = proj.tasks.count { it.status == 'DONE' } + def blocked = proj.tasks.count { it.status == 'BLOCKED' } + [name: proj.name, status: proj.status, + done: done, blocked: blocked, budget: proj.budget] + } + + // Cross-entity analysis + def highPriority = tasks.findAll { it.priority > 7 } + def taskSummary = highPriority.groupBy { it.status } + .collectEntries { status, tl -> + [status, tl.collect { "${it.name} (P${it.priority})" }] + } + + bh.consume(deptSummary.size() + projectSummary.size() + + taskSummary.size() + empNames.size()) + } +} 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) + } +} diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/MetaclassVariationBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/MetaclassVariationBench.groovy new file mode 100644 index 00000000000..2924c8f9a02 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/MetaclassVariationBench.groovy @@ -0,0 +1,259 @@ +/* + * 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 + +/** + * Per-instance metaclass variation overhead (GORM domain class enhancement pattern). + * + * @see GROOVY-10307 + */ +@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 MetaclassVariationBench { + static final int ITERATIONS = 100_000 + static final int INSTANCE_COUNT = 20 + + // Simulates a GORM domain class + static class DomainEntity { + Long id + String name + String email + boolean active = true + int version = 0 + + String getFullName() { name ?: 'Unknown' } + boolean isActive() { active } + int getVersion() { version } + + DomainEntity save() { + version++ + if (id == null) id = System.nanoTime() + this + } + + Map toMap() { + [id: id, name: name, email: email, active: active, version: version] + } + } + + // Additional domain class types + static class DomainTypeB { + String label = "dept" + int count = 5 + int getCount() { count } + } + + static class DomainTypeC { + String status = "ACTIVE" + BigDecimal budget = 100000.0 + String getStatus() { status } + } + + static class DomainTypeD { + int priority = 5 + String assignee = "unassigned" + int getPriority() { priority } + } + + // Unrelated type for cross-type invalidation + static class ServiceType { + String config = "default" + } + + List sharedMetaclassInstances + List perInstanceMetaclassInstances + DomainTypeB typeB + DomainTypeC typeC + DomainTypeD typeD + + @Setup(Level.Iteration) + void setup() { + GroovySystem.metaClassRegistry.removeMetaClass(DomainEntity) + GroovySystem.metaClassRegistry.removeMetaClass(DomainTypeB) + GroovySystem.metaClassRegistry.removeMetaClass(DomainTypeC) + GroovySystem.metaClassRegistry.removeMetaClass(DomainTypeD) + GroovySystem.metaClassRegistry.removeMetaClass(ServiceType) + + // Shared default class metaclass + sharedMetaclassInstances = (1..INSTANCE_COUNT).collect { i -> + new DomainEntity(id: i, name: "User$i", email: "user${i}@test.com") + } + + // Per-instance ExpandoMetaClass (GORM trait pattern) + perInstanceMetaclassInstances = (1..INSTANCE_COUNT).collect { i -> + def entity = new DomainEntity(id: i, name: "Enhanced$i", email: "e${i}@test.com") + def emc = new ExpandoMetaClass(DomainEntity, false, true) + // GORM-injected methods + emc.validate = { -> delegate.name != null && delegate.email != null } + emc.delete = { -> delegate.id = null; delegate } + emc.addToDependencies = { item -> delegate } + emc.initialize() + entity.metaClass = emc + entity + } + + typeB = new DomainTypeB() + typeC = new DomainTypeC() + typeD = new DomainTypeD() + } + + /** Method calls on instances sharing default class metaclass. */ + @Benchmark + void baselineSharedMetaclass(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + def entity = sharedMetaclassInstances[i % INSTANCE_COUNT] + sum += entity.getFullName().length() + sum += entity.getVersion() + } + bh.consume(sum) + } + + /** Method calls on instances each with their own ExpandoMetaClass. */ + @Benchmark + void perInstanceMetaclass(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + def entity = perInstanceMetaclassInstances[i % INSTANCE_COUNT] + sum += entity.getFullName().length() + sum += entity.getVersion() + } + bh.consume(sum) + } + + /** Calling GORM-injected methods on per-instance EMC objects. */ + @Benchmark + void perInstanceInjectedMethodCalls(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + def entity = perInstanceMetaclassInstances[i % INSTANCE_COUNT] + boolean valid = entity.validate() + sum += valid ? 1 : 0 + } + bh.consume(sum) + } + + /** GORM startup: enhance 4 domain types then steady-state calls. */ + @Benchmark + void multiClassStartupThenSteadyState(Blackhole bh) { + // Phase 1: Enhance 4 domain class types + DomainEntity.metaClass.static.findAllByName = { String n -> [] } + DomainEntity.metaClass.static.countByActive = { boolean a -> 0 } + + DomainTypeB.metaClass.static.findAllByLabel = { String l -> [] } + DomainTypeB.metaClass.static.countByCount = { int c -> 0 } + + DomainTypeC.metaClass.static.findAllByStatus = { String s -> [] } + DomainTypeC.metaClass.static.findByBudgetGreaterThan = { BigDecimal b -> null } + + DomainTypeD.metaClass.static.findAllByPriority = { int p -> [] } + DomainTypeD.metaClass.static.findByAssignee = { String a -> null } + + // Phase 2: Steady-state calls + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + def entity = sharedMetaclassInstances[i % INSTANCE_COUNT] + sum += entity.getFullName().length() + sum += typeB.getCount() + sum += typeC.getStatus().length() + sum += typeD.getPriority() + } + bh.consume(sum) + } + + /** Baseline: same steady-state work without preceding metaclass enhancements. */ + @Benchmark + void baselineMultiClassNoStartup(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + def entity = sharedMetaclassInstances[i % INSTANCE_COUNT] + sum += entity.getFullName().length() + sum += typeB.getCount() + sum += typeC.getStatus().length() + sum += typeD.getPriority() + } + bh.consume(sum) + } + + /** Injection + invocation of dynamic finders via static metaclass (models per-request GORM cost). */ + @Benchmark + void dynamicFinderCalls(Blackhole bh) { + // Inject dynamic finders + DomainEntity.metaClass.static.findByName = { String n -> + [new DomainEntity(name: n)] + } + DomainEntity.metaClass.static.findAllByActive = { boolean a -> + [new DomainEntity(active: a)] + } + + for (int i = 0; i < ITERATIONS / 10; i++) { + def result1 = DomainEntity.findByName("User${i % 10}") + def result2 = DomainEntity.findAllByActive(true) + bh.consume(result1) + bh.consume(result2) + } + } + + /** Mixed compiled method calls and dynamic finder injection + invocation (models per-request GORM cost). */ + @Benchmark + void mixedCompiledAndDynamicFinders(Blackhole bh) { + DomainEntity.metaClass.static.findByName = { String n -> + [new DomainEntity(name: n)] + } + + int sum = 0 + for (int i = 0; i < ITERATIONS / 10; i++) { + // Dynamic finder + def found = DomainEntity.findByName("User${i % 10}") + // Compiled methods + def entity = sharedMetaclassInstances[i % INSTANCE_COUNT] + sum += entity.getFullName().length() + sum += entity.getVersion() + bh.consume(found) + } + bh.consume(sum) + } + + /** Per-instance EMC access with ongoing cross-type metaclass churn. */ + @Benchmark + void perInstanceWithOngoingChurn(Blackhole bh) { + int sum = 0 + for (int i = 0; i < ITERATIONS; i++) { + def entity = perInstanceMetaclassInstances[i % INSTANCE_COUNT] + sum += entity.getFullName().length() + sum += entity.getVersion() + if (i % 1000 == 0) { + ServiceType.metaClass."helper${i % 5}" = { -> i } + } + } + bh.consume(sum) + } +}