Copilot commented on code in PR #2390: URL: https://github.com/apache/groovy/pull/2390#discussion_r2878081190
########## subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/CallSiteInvalidationBench.groovy: ########## @@ -0,0 +1,225 @@ +/* + * 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 Review Comment: Unused import: `groovy.lang.ExpandoMetaClass` isn't referenced in this benchmark. Removing it will avoid compiler warnings and keep the benchmark focused on SwitchPoint invalidation via `metaClass` changes. ```suggestion ``` ########## 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 <a href="https://issues.apache.org/jira/browse/GROOVY-10307">GROOVY-10307</a> + */ +@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<DomainEntity> sharedMetaclassInstances + List<DomainEntity> 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) + } + + /** Calling dynamic finders injected via static metaclass. */ + @Benchmark + void dynamicFinderCalls(Blackhole bh) { + // Inject dynamic finders + DomainEntity.metaClass.static.findByName = { String n -> + [new DomainEntity(name: n)] + } + DomainEntity.metaClass.static.findAllByActive = { boolean a -> Review Comment: `dynamicFinderCalls` injects/overwrites static metaclass methods inside the benchmark method, so the reported time includes the injection + invalidation cost on every JMH invocation (and repeatedly invalidates call sites during measurement). If the intent is to measure call overhead after a single injection event, move the metaclass injection to `@Setup` (e.g., `Level.Invocation` or `Level.Iteration`) and keep the benchmark body focused on the calls. ########## 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 <a href="https://issues.apache.org/jira/browse/GROOVY-10307">GROOVY-10307</a> + */ +@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<DomainEntity> sharedMetaclassInstances + List<DomainEntity> 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) + } + + /** Calling dynamic finders injected via static metaclass. */ + @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 calls. */ + @Benchmark + void mixedCompiledAndDynamicFinders(Blackhole bh) { + DomainEntity.metaClass.static.findByName = { String n -> + [new DomainEntity(name: n)] + } Review Comment: Same issue as `dynamicFinderCalls`: this benchmark redefines `DomainEntity.metaClass.static.findByName` inside the benchmark method, which makes results sensitive to the metaclass update cost rather than steady-state mixed dispatch. Consider injecting the dynamic finder in `@Setup` and benchmarking only the mixed call pattern here (or split into separate “inject” and “call” benchmarks). ########## subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/GrailsWorkloadBench.groovy: ########## @@ -0,0 +1,467 @@ +/* + * 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 <a href="https://github.com/jglapa/grails7-performance-regression">Demo app</a> + * @see <a href="https://issues.apache.org/jira/browse/GROOVY-10307">GROOVY-10307</a> + */ +@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<String> 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<Task> tasks = [] + List<Milestone> 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<Employee> employees + List<Project> projects + List<Task> tasks + + @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${System.nanoTime() % 3}" = { -> 'init' } + Review Comment: Using `System.nanoTime()` to vary the metaclass property name adds non-trivial extra work and noise that’s unrelated to SwitchPoint invalidation, and makes runs harder to compare. Prefer a cheap deterministic counter stored in the benchmark state (e.g., an `int` incremented/modded) to select among a small set of property names. ########## subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/grails/GrailsWorkloadBench.groovy: ########## @@ -0,0 +1,467 @@ +/* + * 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 <a href="https://github.com/jglapa/grails7-performance-regression">Demo app</a> + * @see <a href="https://issues.apache.org/jira/browse/GROOVY-10307">GROOVY-10307</a> + */ +@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<String> 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<Task> tasks = [] + List<Milestone> 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<Employee> employees + List<Project> projects + List<Task> tasks + + @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${System.nanoTime() % 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${System.nanoTime() % 3}" = { -> 'lazy' } + Review Comment: Same concern as the pre-request invalidation: `System.nanoTime()` here adds unrelated overhead and run-to-run variance. Use a state counter (or reuse the loop index if you want per-iteration variation) to pick among a bounded set of metaclass property names. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
