This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch perf/8.0.x-jmh-pr-benchmarks
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 9cf6166eaa23895154eb9dd2a8ebe2c14d9ae956
Author: t <t@t>
AuthorDate: Thu Jul 30 14:28:49 2026 -0400
Add JMH performance regression reporting for pull requests
Grails has no per-PR performance signal today, so a regression in a hot
path ships and only surfaces later as a general "upgrading made us
slower" report, with no way to attribute it to a change.
This adds an advisory JMH benchmark suite and an opt-in workflow that
measures the pull request against its base commit and posts the
comparison as a sticky comment.
The comparison is paired on a single runner rather than against a stored
historical baseline: both revisions are built first, then measured
back-to-back, in a two shard matrix where the shards run the two
revisions in opposite orders. Only complete same-shard base/head pairs
are pooled, and pooling spans the shards' confidence intervals rather
than narrowing them, so shards that disagree widen uncertainty.
A benchmark is only reported as regressed or improved when the effect is
at least 10 percent and the JMH confidence intervals are disjoint;
everything else is reported as no clear change. Bootstrap resampling and
significance testing were deliberately not used, because JMH's iteration
samples share a JVM and its compilation state and are not independent
observations.
Pure JDK "ruler" benchmarks detect a runner that was unstable between
the two halves of a run. Each ruler is evaluated per shard pair, since
pooling first lets opposite movements cancel and hide the instability
they exist to surface.
The check never fails a build and runs only on pull requests labelled
performance.
grails-benchmarks is build-time only and is not published. JMH is
GPLv2 with Classpath Exception, which is ASF Category X, so the module
deliberately omits the publish, sbom, vulnerability-scan, jacoco and
dependency-validator conventions and is absent from the BOM.
Assisted-by: claude-code:claude-opus-5
---
.github/scripts/jmh_compare.py | 594 +++++++++++++++++++++
.github/scripts/test_jmh_compare.py | 555 +++++++++++++++++++
.github/workflows/benchmark.yml | 404 ++++++++++++++
.gitignore | 2 +
grails-benchmarks/README.adoc | 139 +++++
grails-benchmarks/build.gradle | 238 +++++++++
.../databinding/SimpleDataBinderBenchmark.java | 141 +++++
.../benchmarks/gsp/GroovyPageParserBenchmark.java | 82 +++
.../interceptors/UrlMappingMatcherBenchmark.java | 144 +++++
.../grails/benchmarks/ruler/CpuRulerBenchmark.java | 58 ++
.../benchmarks/ruler/MemoryRulerBenchmark.java | 63 +++
.../urlmappings/UrlMappingsBenchmark.java | 109 ++++
.../views/ViewTemplateRenderingBenchmark.java | 78 +++
.../interceptors/InterceptorFixture.groovy | 31 ++
.../urlmappings/UrlMappingsFixture.groovy | 41 ++
.../benchmarks/views/ViewTemplateFixture.groovy | 52 ++
settings.gradle | 1 +
17 files changed, 2732 insertions(+)
diff --git a/.github/scripts/jmh_compare.py b/.github/scripts/jmh_compare.py
new file mode 100644
index 0000000000..99352339dd
--- /dev/null
+++ b/.github/scripts/jmh_compare.py
@@ -0,0 +1,594 @@
+# 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
+#
+# https://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.
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import re
+import sys
+import urllib.error
+import urllib.request
+from dataclasses import dataclass, replace
+from pathlib import Path
+from typing import Final, Sequence
+
+
+MARKER: Final = "<!-- grails-jmh-benchmark -->"
+DASH: Final = "—"
+RULER_PACKAGE: Final = "org.apache.grails.benchmarks.ruler."
+ALLOCATION_METRIC: Final = "gc.alloc.rate.norm"
+BENCHMARK_PACKAGE: Final = "org.apache.grails.benchmarks."
+VERDICT_ORDER: Final = {"REGRESSED": 0, "IMPROVED": 1}
+
+
+@dataclass(frozen=True, slots=True)
+class Benchmark:
+ identity: str
+ score: float | None
+ error: float | None
+ confidence: tuple[float, float] | None
+ unit: str
+ mode: str
+ allocation: float | None
+
+
+@dataclass(frozen=True, slots=True)
+class ComparisonRow:
+ identity: str
+ base: Benchmark
+ head: Benchmark
+ speedup: float | None
+ verdict: str
+ allocation_delta: float | None
+ allocation_percent: float | None
+ allocation_candidate: bool
+
+
+@dataclass(frozen=True, slots=True)
+class RulerDeviation:
+ shard: str
+ identity: str
+ speedup: float
+
+
+@dataclass(frozen=True, slots=True)
+class Comparison:
+ rows: tuple[ComparisonRow, ...]
+ only_head: tuple[str, ...]
+ only_base: tuple[str, ...]
+ malformed: int
+ group_speedups: dict[str, tuple[float, int]]
+ ruler_speedups: tuple[tuple[str, float], ...]
+ dropped: tuple[str, ...] = ()
+ ruler_deviations: tuple[RulerDeviation, ...] = ()
+ ruler_incomplete: tuple[str, ...] = ()
+ missing_shards: tuple[str, ...] = ()
+ paired_shards: tuple[str, ...] = ()
+
+
+def finite_number(value: float | str | int | None) -> float | None:
+ try:
+ number = float(value) if value is not None else None
+ except (TypeError, ValueError):
+ return None
+ return number if number is not None and math.isfinite(number) else None
+
+
+def confidence_interval(metric: dict) -> tuple[float, float] | None:
+ confidence = metric.get("scoreConfidence")
+ if isinstance(confidence, (list, tuple)) and len(confidence) == 2:
+ lower, upper = finite_number(confidence[0]),
finite_number(confidence[1])
+ if lower is not None and upper is not None:
+ return min(lower, upper), max(lower, upper)
+ score, error = finite_number(metric.get("score")),
finite_number(metric.get("scoreError"))
+ if score is None or error is None or error < 0:
+ return None
+ return score - error, score + error
+
+
+def benchmark_identity(name: str, params: dict | None) -> str:
+ if not params:
+ return name
+ rendered = ",".join(f"{key}={params[key]}" for key in sorted(params,
key=str))
+ return f"{name}[{rendered}]"
+
+
+def parse_entries(entries: list) -> dict[str, Benchmark]:
+ benchmarks: dict[str, Benchmark] = {}
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ name, metric = entry.get("benchmark"), entry.get("primaryMetric")
+ if not isinstance(name, str) or not isinstance(metric, dict):
+ continue
+ params = entry.get("params") if isinstance(entry.get("params"), dict)
else None
+ secondary = entry.get("secondaryMetrics")
+ allocation_metric = secondary.get(ALLOCATION_METRIC) if
isinstance(secondary, dict) else None
+ allocation = None
+ if isinstance(allocation_metric, dict) and
allocation_metric.get("scoreUnit") == "B/op":
+ allocation = finite_number(allocation_metric.get("score"))
+ identity = benchmark_identity(name, params)
+ mode = entry.get("mode")
+ benchmarks[identity] = Benchmark(
+ identity=identity,
+ score=finite_number(metric.get("score")),
+ error=finite_number(metric.get("scoreError")),
+ confidence=confidence_interval(metric),
+ unit=str(metric.get("scoreUnit", "")),
+ mode=mode.strip() if isinstance(mode, str) else "",
+ allocation=allocation,
+ )
+ return benchmarks
+
+
+def mean_of(values: Sequence[float | None]) -> float | None:
+ present = [value for value in values if value is not None]
+ return math.fsum(present) / len(present) if present else None
+
+
+# Pooling spans the observed intervals instead of narrowing them, so shards
that disagree widen
+# the uncertainty and make a verdict harder to reach, never easier.
+def pool_benchmarks(samples: Sequence[Benchmark]) -> Benchmark:
+ if len(samples) == 1:
+ return samples[0]
+ first = samples[0]
+ if any(sample.unit != first.unit or sample.mode != first.mode for sample
in samples):
+ return Benchmark(first.identity, None, None, None, first.unit,
first.mode, None)
+ intervals = [sample.confidence for sample in samples if sample.confidence
is not None]
+ confidence = (
+ (min(interval[0] for interval in intervals), max(interval[1] for
interval in intervals))
+ if len(intervals) == len(samples)
+ else None
+ )
+ return Benchmark(
+ identity=first.identity,
+ score=mean_of([sample.score for sample in samples]),
+ error=mean_of([sample.error for sample in samples]),
+ confidence=confidence,
+ unit=first.unit,
+ mode=first.mode,
+ allocation=mean_of([sample.allocation for sample in samples]),
+ )
+
+
+def result_paths(location: str) -> list[tuple[str, Path]]:
+ path = Path(location)
+ return [(result_path.name, result_path) for result_path in
sorted(path.glob("*.json"))] if path.is_dir() else [("anonymous shard", path)]
+
+
+def read_shards(location: str) -> dict[str, dict[str, Benchmark]]:
+ shards: dict[str, dict[str, Benchmark]] = {}
+ for shard, result_path in result_paths(location):
+ with result_path.open(encoding="utf-8") as source:
+ parsed = json.load(source)
+ if not isinstance(parsed, list):
+ raise ValueError(f"JMH JSON must be an array: {result_path}")
+ shards[shard] = parse_entries(parsed)
+ return shards
+
+
+def pool_shards(shards: dict[str, dict[str, Benchmark]]) -> dict[str,
Benchmark]:
+ collected: dict[str, list[Benchmark]] = {}
+ for shard in shards.values():
+ for identity, benchmark in shard.items():
+ collected.setdefault(identity, []).append(benchmark)
+ return {identity: pool_benchmarks(samples) for identity, samples in
collected.items()}
+
+
+def read_results(location: str) -> dict[str, Benchmark]:
+ return pool_shards(read_shards(location))
+
+
+def comparable_units(head: Benchmark, base: Benchmark) -> bool:
+ return (
+ bool(head.mode)
+ and bool(base.mode)
+ and head.mode.lower() == base.mode.lower()
+ and head.unit.strip().lower() == base.unit.strip().lower()
+ )
+
+
+# A revision that changes or omits a benchmark mode, or changes its output
unit, makes the scores
+# incommensurable, so dividing them would manufacture a speedup out of
unrelated measurements.
+def speedup_for(head: Benchmark, base: Benchmark) -> float | None:
+ if head.score is None or base.score is None or head.score <= 0 or
base.score <= 0:
+ return None
+ if not comparable_units(head, base):
+ return None
+ return head.score / base.score if head.unit.lower().startswith("ops") else
base.score / head.score
+
+
+def intervals_disjoint(head: Benchmark, base: Benchmark) -> bool:
+ if head.confidence is None or base.confidence is None:
+ return False
+ return head.confidence[1] < base.confidence[0] or base.confidence[1] <
head.confidence[0]
+
+
+def group_for(identity: str) -> str:
+ name = identity.split("[", 1)[0]
+ parts = name.split(".")
+ class_name = parts[-2] if len(parts) >= 2 else parts[-1]
+ return parts[-3] if len(parts) >= 3 else class_name
+
+
+def geometric_mean(values: Sequence[float]) -> float | None:
+ if not values or any(value <= 0 or not math.isfinite(value) for value in
values):
+ return None
+ return math.exp(math.fsum(math.log(value) for value in values) /
len(values))
+
+
+def compare_benchmarks(
+ head: dict[str, Benchmark], base: dict[str, Benchmark], threshold: float
+) -> Comparison:
+ common = sorted(head.keys() & base.keys())
+ rows: list[ComparisonRow] = []
+ malformed = 0
+ groups: dict[str, list[float]] = {}
+ rulers: list[tuple[str, float]] = []
+ regression_limit = 1 / (1 + threshold)
+ for identity in common:
+ head_benchmark, base_benchmark = head[identity], base[identity]
+ speedup = speedup_for(head_benchmark, base_benchmark)
+ if speedup is None or not math.isfinite(speedup) or speedup <= 0:
+ malformed += 1
+ continue
+ is_ruler = identity.split("[", 1)[0].startswith(RULER_PACKAGE)
+ allocation_delta, allocation_percent =
allocation_change(head_benchmark, base_benchmark)
+ allocation_candidate = (
+ allocation_delta is not None
+ and allocation_percent is not None
+ and allocation_delta > 16
+ and allocation_percent > 0.05
+ )
+ if is_ruler:
+ verdict = "ruler - excluded"
+ rulers.append((identity, speedup))
+ elif head_benchmark.confidence is None or base_benchmark.confidence is
None:
+ verdict = "insufficient data"
+ elif speedup <= regression_limit and
intervals_disjoint(head_benchmark, base_benchmark):
+ verdict = "REGRESSED"
+ elif speedup >= 1 + threshold and intervals_disjoint(head_benchmark,
base_benchmark):
+ verdict = "IMPROVED"
+ else:
+ verdict = "no clear change"
+ if not is_ruler:
+ groups.setdefault(group_for(identity), []).append(speedup)
+ rows.append(
+ ComparisonRow(
+ identity, base_benchmark, head_benchmark, speedup, verdict,
+ allocation_delta, allocation_percent, allocation_candidate,
+ )
+ )
+ grouped = {
+ group: (mean, len(values))
+ for group, values in sorted(groups.items())
+ if (mean := geometric_mean(values)) is not None
+ }
+ return Comparison(
+ tuple(rows), tuple(sorted(head.keys() - base.keys())),
tuple(sorted(base.keys() - head.keys())),
+ malformed, grouped, tuple(sorted(rulers)),
+ )
+
+
+def ruler_deviation(speedup: float) -> float:
+ return max(speedup, 1 / speedup) - 1
+
+
+# A shard that failed outright uploads no result file at all, so it cannot be
discovered from the
+# inputs. Without the expected set such a shard vanishes silently and a
half-strength comparison
+# reads as healthy, so callers pass the shard names the CI matrix was supposed
to produce.
+def compare_shards(
+ head_shards: dict[str, dict[str, Benchmark]], base_shards: dict[str,
dict[str, Benchmark]], threshold: float,
+ expected_shards: Sequence[str] = (),
+) -> Comparison:
+ head_samples: dict[str, list[Benchmark]] = {}
+ base_samples: dict[str, list[Benchmark]] = {}
+ dropped: set[str] = set()
+ expected_rulers: set[str] = set()
+ paired_shards = sorted(head_shards.keys() & base_shards.keys())
+ for shard in paired_shards:
+ head, base = head_shards[shard], base_shards[shard]
+ common = head.keys() & base.keys()
+ dropped.update(head.keys() ^ base.keys())
+ expected_rulers.update(
+ identity for identity in head.keys() | base.keys()
+ if identity.split("[", 1)[0].startswith(RULER_PACKAGE)
+ )
+ for identity in common:
+ head_samples.setdefault(identity, []).append(head[identity])
+ base_samples.setdefault(identity, []).append(base[identity])
+ for shards in (head_shards, base_shards):
+ for shard in shards.keys() - set(paired_shards):
+ dropped.update(shards[shard])
+ deviations: list[RulerDeviation] = []
+ incomplete: list[str] = []
+ for shard in paired_shards:
+ head, base = head_shards[shard], base_shards[shard]
+ for identity in sorted(expected_rulers):
+ head_benchmark, base_benchmark = head.get(identity),
base.get(identity)
+ speedup = (
+ speedup_for(head_benchmark, base_benchmark)
+ if head_benchmark is not None and base_benchmark is not None
+ else None
+ )
+ if speedup is None or not math.isfinite(speedup) or speedup <= 0:
+ incomplete.append(f"{shard}: {identity}")
+ else:
+ deviations.append(RulerDeviation(shard, identity, speedup))
+ comparison = compare_benchmarks(
+ {identity: pool_benchmarks(samples) for identity, samples in
head_samples.items()},
+ {identity: pool_benchmarks(samples) for identity, samples in
base_samples.items()},
+ threshold,
+ )
+ missing = {shard for shard in expected_shards if shard not in
paired_shards}
+ missing.update(
+ shard for shard in head_shards.keys() ^ base_shards.keys() if shard
not in paired_shards
+ )
+ return replace(
+ comparison,
+ dropped=tuple(sorted(dropped)),
+ ruler_deviations=tuple(sorted(deviations, key=lambda item:
(item.shard, item.identity))),
+ ruler_incomplete=tuple(sorted(incomplete)),
+ missing_shards=tuple(sorted(missing)),
+ paired_shards=tuple(paired_shards),
+ )
+
+
+def allocation_change(head: Benchmark, base: Benchmark) -> tuple[float | None,
float | None]:
+ if head.allocation is None or base.allocation is None or base.allocation
<= 0:
+ return None, None
+ delta = head.allocation - base.allocation
+ return delta, delta / base.allocation
+
+
+def safe_markdown(value: str) -> str:
+ return re.sub(r"[`|<>\[\]\(\)!\r\n]", "", value)
+
+
+def format_number(value: float | None) -> str:
+ return f"{value:.3g}" if value is not None and math.isfinite(value) else
DASH
+
+
+def format_speedup(value: float | None) -> str:
+ return f"{value:.2f}x" if value is not None and math.isfinite(value) else
DASH
+
+
+def display_identity(identity: str) -> str:
+ return safe_markdown(identity.removeprefix(BENCHMARK_PACKAGE))
+
+
+def format_score(benchmark: Benchmark) -> str:
+ score = format_number(benchmark.score)
+ return f"{score} {safe_markdown(benchmark.unit)}" if score != DASH else
DASH
+
+
+# Sub-byte deltas are allocation-counter noise, not a real change in bytes
allocated.
+def format_allocation(row: ComparisonRow) -> str:
+ if row.allocation_delta is None or row.allocation_percent is None:
+ return DASH
+ if abs(row.allocation_delta) < 1:
+ return "~0 B/op"
+ candidate = " **candidate**" if row.allocation_candidate else ""
+ return f"{row.allocation_delta:+,.0f} B/op ({row.allocation_percent *
100:+.1f}%){candidate}"
+
+
+def render_report(comparison: Comparison) -> str:
+ regressions = sum(row.verdict == "REGRESSED" for row in comparison.rows)
+ improvements = sum(row.verdict == "IMPROVED" for row in comparison.rows)
+ deviations = comparison.ruler_deviations or tuple(
+ RulerDeviation("", identity, speedup) for identity, speedup in
comparison.ruler_speedups
+ )
+ worst_ruler = max(deviations, key=lambda item:
ruler_deviation(item.speedup), default=None)
+ if comparison.ruler_incomplete:
+ health = "INCOMPLETE/unreliable (missing or non-finite ruler
measurements)"
+ elif worst_ruler is None:
+ health = "not measured"
+ else:
+ location = f" in {safe_markdown(worst_ruler.shard)}" if
worst_ruler.shard else ""
+ ruler = safe_markdown(worst_ruler.identity.removeprefix(RULER_PACKAGE))
+ health = f"worst ruler deviation:
{ruler_deviation(worst_ruler.speedup):.1%}{location} ({ruler})"
+ lines = [
+ "### JMH Benchmark Report",
+ "",
+ f"**Regressions:** {regressions}",
+ f"**Improvements:** {improvements}",
+ f"**Runner health:** {health}",
+ "Ruler benchmarks are excluded from verdicts and group summaries;
runner health is a stability check, not a calibration factor.",
+ ]
+ if comparison.missing_shards:
+ lines.extend([
+ "",
+ "> **Warning:** No usable base/head pair was produced by "
+ + ", ".join(safe_markdown(shard) for shard in
comparison.missing_shards)
+ + f". This comparison rests on {len(comparison.paired_shards)}
shard pair(s) instead of the expected "
+ + f"{len(comparison.paired_shards) +
len(comparison.missing_shards)}, so the alternating measurement "
+ + "order did not fully cancel and the result is weaker than a
normal run.",
+ ])
+ if deviations:
+ lines.append(
+ "**Ruler movements:** " + ", ".join(
+ (f"{safe_markdown(deviation.shard)}: " if deviation.shard else
"")
+ +
f"{safe_markdown(deviation.identity.removeprefix(RULER_PACKAGE))}: "
+ + format_speedup(deviation.speedup)
+ for deviation in deviations
+ )
+ )
+ if comparison.ruler_incomplete:
+ lines.extend([
+ "",
+ "> **Warning:** Runner health is INCOMPLETE because expected ruler
measurements were "
+ "missing or non-finite. Treat results as unreliable.",
+ "",
+ "**Incomplete ruler measurements:** " + ", ".join(
+ safe_markdown(issue) for issue in comparison.ruler_incomplete
+ ),
+ ])
+ if any(max(deviation.speedup, 1 / deviation.speedup) > 1.05 for deviation
in deviations):
+ lines.extend([
+ "",
+ "> **Warning:** The runner was unstable BETWEEN the two halves of
the A/B run. "
+ "Treat results as unreliable. Runner health is a stability check,
not a calibration factor.",
+ ])
+ lines.extend(["", "Group geometric means are descriptive only, not
verdicts.", ""])
+ if comparison.group_speedups:
+ lines.extend(["| Group | Descriptive geomean speedup | n |", "| --- |
---: | ---: |"])
+ lines.extend(
+ f"| {safe_markdown(group)} | {format_speedup(speedup)} | {count} |"
+ for group, (speedup, count) in comparison.group_speedups.items()
+ )
+ else:
+ lines.append("No comparable non-ruler benchmarks were available for
group summaries.")
+ lines.extend(["", "<details>", "<summary>Per-benchmark results</summary>",
""])
+ lines.extend([
+ "| Benchmark | Base score | Head score | Speedup | Verdict |
Allocation delta (ADVISORY) |",
+ "| --- | ---: | ---: | ---: | --- | ---: |",
+ ])
+ lines.extend(
+ f"| {display_identity(row.identity)} | {format_score(row.base)} |
{format_score(row.head)} | "
+ f"{format_speedup(row.speedup)} | {row.verdict} |
{format_allocation(row)} |"
+ for row in sorted(comparison.rows, key=lambda item:
(VERDICT_ORDER.get(item.verdict, 2), item.identity))
+ )
+ lines.extend(["", "</details>"])
+ if comparison.only_head:
+ lines.extend(["", "**Only in head:** " + ", ".join(safe_markdown(name)
for name in comparison.only_head)])
+ if comparison.only_base:
+ lines.extend(["", "**Only in base:** " + ", ".join(safe_markdown(name)
for name in comparison.only_base)])
+ if comparison.dropped:
+ lines.extend([
+ "",
+ f"**Dropped unpaired shard samples ({len(comparison.dropped)}):** "
+ + ", ".join(safe_markdown(name) for name in comparison.dropped),
+ ])
+ if comparison.malformed:
+ lines.extend(["", f"**Malformed comparisons skipped:**
{comparison.malformed}"])
+ lines.extend(["", MARKER])
+ return "\n".join(lines)
+
+
+def render_head_only(head: dict[str, Benchmark]) -> str:
+ lines = [
+ "### JMH Benchmark Report",
+ "",
+ "**Regressions:** 0 (no base)",
+ "**Improvements:** 0 (no base)",
+ "**Runner health:** not measured (no base)",
+ "",
+ "No comparison was possible because the base revision has no benchmark
harness.",
+ "",
+ "| Benchmark | Head score | Error | Unit |",
+ "| --- | ---: | ---: | --- |",
+ ]
+ lines.extend(
+ f"| {display_identity(benchmark.identity)} |
{format_number(benchmark.score)} | "
+ f"{format_number(benchmark.error)} | {safe_markdown(benchmark.unit)} |"
+ for benchmark in sorted(head.values(), key=lambda item: item.identity)
+ )
+ lines.extend(["", MARKER])
+ return "\n".join(lines)
+
+
+def github_request(url: str, token: str, method: str, body: str | None = None)
-> list | dict:
+ data = body.encode("utf-8") if body is not None else None
+ request = urllib.request.Request(
+ url, data=data, method=method,
+ headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ )
+ with urllib.request.urlopen(request, timeout=15) as response:
+ return json.loads(response.read().decode("utf-8"))
+
+
+def post_comment(report: str, repo: str, pr_number: str, token: str) -> None:
+ try:
+ comment_id: int | None = None
+ for page in range(1, 101):
+ response = github_request(
+
f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments?per_page=100&page={page}",
+ token, "GET",
+ )
+ if not isinstance(response, list):
+ break
+ for comment in response:
+ if isinstance(comment, dict) and
isinstance(comment.get("body"), str) and MARKER in comment["body"]:
+ identifier = comment.get("id")
+ if isinstance(identifier, int):
+ comment_id = identifier
+ break
+ if comment_id is not None or len(response) < 100:
+ break
+ payload = json.dumps({"body": report})
+ if comment_id is None:
+
github_request(f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments",
token, "POST", payload)
+ else:
+
github_request(f"https://api.github.com/repos/{repo}/issues/comments/{comment_id}",
token, "PATCH", payload)
+ except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError,
OSError, ValueError, json.JSONDecodeError) as error:
+ print(f"warning: unable to post JMH report: {error}", file=sys.stderr)
+
+
+def arguments(argv: Sequence[str] | None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Compare JMH JSON results for
a pull request.")
+ parser.add_argument("--head", required=True, help="JMH JSON file or
directory for the PR head")
+ parser.add_argument("--base", help="JMH JSON file or directory for the PR
base")
+ parser.add_argument("--threshold", type=float, default=0.10, help="Effect
threshold, default: 0.10")
+ parser.add_argument("--repo", help="GitHub repository as owner/name")
+ parser.add_argument("--pr-number", help="Pull request number for optional
comment posting")
+ parser.add_argument(
+ "--expected-shards",
+ default="",
+ help="Comma-separated shard result file names the CI matrix should
have produced, "
+ "so a shard that failed without uploading anything is reported
rather than ignored",
+ )
+ parser.add_argument(
+ "--fail-on-regression", action="store_true",
+ help="Reserved for future use; regressions currently never fail the
build",
+ )
+ parsed = parser.parse_args(argv)
+ if parsed.threshold <= 0:
+ parser.error("--threshold must be greater than zero")
+ return parsed
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parsed = arguments(argv)
+ head_shards = read_shards(parsed.head)
+ head = pool_shards(head_shards)
+ base_exists = parsed.base is not None and Path(parsed.base).exists()
+ report = (
+ render_report(compare_shards(
+ head_shards,
+ read_shards(parsed.base),
+ parsed.threshold,
+ [shard for shard in parsed.expected_shards.split(",") if
shard.strip()],
+ ))
+ if base_exists
+ else render_head_only(head)
+ )
+ sys.stdout.write(report + "\n")
+ pr_number = (parsed.pr_number or "").strip()
+ token = os.getenv("GITHUB_TOKEN")
+ if pr_number and pr_number != "null" and parsed.repo and token:
+ post_comment(report, parsed.repo, pr_number, token)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/scripts/test_jmh_compare.py
b/.github/scripts/test_jmh_compare.py
new file mode 100644
index 0000000000..2b949fca1a
--- /dev/null
+++ b/.github/scripts/test_jmh_compare.py
@@ -0,0 +1,555 @@
+# 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
+#
+# https://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.
+
+import contextlib
+import io
+import json
+import os
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+import jmh_compare
+
+
+def fixture(
+ name: str,
+ score: float,
+ confidence: list[float] | None = None,
+ error: float | str | None = 1.0,
+ unit: str = "ns/op",
+ allocation: float | None = None,
+ params: dict[str, str] | None = None,
+ mode: str | None = "avgt",
+) -> dict:
+ metric = {"score": score, "scoreUnit": unit}
+ if confidence is not None:
+ metric["scoreConfidence"] = confidence
+ if error is not None:
+ metric["scoreError"] = error
+ result = {"benchmark": name, "primaryMetric": metric}
+ if mode is not None:
+ result["mode"] = mode
+ if params is not None:
+ result["params"] = params
+ if allocation is not None:
+ result["secondaryMetrics"] = {
+ "gc.alloc.rate.norm": {"score": allocation, "scoreUnit": "B/op"}
+ }
+ return result
+
+
+def compared(head: list[dict], base: list[dict]) -> jmh_compare.Comparison:
+ return jmh_compare.compare_benchmarks(
+ jmh_compare.parse_entries(head), jmh_compare.parse_entries(base), 0.10
+ )
+
+
+def compared_shards(
+ head: dict[str, list[dict]], base: dict[str, list[dict]], expected:
list[str] | None = None
+) -> jmh_compare.Comparison:
+ return jmh_compare.compare_shards(
+ {name: jmh_compare.parse_entries(entries) for name, entries in
head.items()},
+ {name: jmh_compare.parse_entries(entries) for name, entries in
base.items()},
+ 0.10,
+ expected or [],
+ )
+
+
+class JmhComparisonTest(unittest.TestCase):
+ def test_speedup_direction_for_throughput_and_latency(self) -> None:
+ result = compared(
+ [
+ fixture("sample.Throughput.run", 120, [119, 121],
unit="ops/s"),
+ fixture("sample.Latency.run", 100, [99, 101]),
+ ],
+ [
+ fixture("sample.Throughput.run", 100, [99, 101], unit="ops/s"),
+ fixture("sample.Latency.run", 120, [119, 121]),
+ ],
+ )
+
+ self.assertEqual([row.speedup for row in result.rows], [1.2, 1.2])
+
+ def
test_clear_regression_requires_large_effect_and_disjoint_intervals(self) ->
None:
+ result = compared(
+ [fixture("sample.Regression.run", 120, [119, 121])],
+ [fixture("sample.Regression.run", 100, [99, 101])],
+ )
+
+ self.assertEqual(result.rows[0].verdict, "REGRESSED")
+
+ def test_large_effect_with_overlapping_intervals_has_no_clear_change(self)
-> None:
+ result = compared(
+ [fixture("sample.Overlap.run", 120, [95, 125])],
+ [fixture("sample.Overlap.run", 100, [90, 121])],
+ )
+
+ self.assertEqual(result.rows[0].verdict, "no clear change")
+
+ def test_small_effect_with_disjoint_intervals_has_no_clear_change(self) ->
None:
+ result = compared(
+ [fixture("sample.Small.run", 105, [104, 106])],
+ [fixture("sample.Small.run", 100, [99, 101])],
+ )
+
+ self.assertEqual(result.rows[0].verdict, "no clear change")
+
+ def test_score_error_is_used_when_confidence_is_missing(self) -> None:
+ result = compared(
+ [fixture("sample.Fallback.run", 120, error=1)],
+ [fixture("sample.Fallback.run", 100, error=1)],
+ )
+
+ self.assertEqual(result.rows[0].verdict, "REGRESSED")
+
+ def test_missing_interval_and_error_is_insufficient_not_regressed(self) ->
None:
+ result = compared(
+ [fixture("sample.Insufficient.run", 120, error=None)],
+ [fixture("sample.Insufficient.run", 100, error=None)],
+ )
+
+ self.assertEqual(result.rows[0].verdict, "insufficient data")
+
+ def test_nan_score_error_is_treated_as_unavailable(self) -> None:
+ result = compared(
+ [fixture("sample.NonFinite.run", 120, error="NaN")],
+ [fixture("sample.NonFinite.run", 100, error="NaN")],
+ )
+
+ self.assertEqual(result.rows[0].verdict, "insufficient data")
+ self.assertNotIn("nan", jmh_compare.render_report(result).lower())
+
+ def test_benchmarks_only_on_one_side_are_excluded(self) -> None:
+ result = compared(
+ [fixture("sample.Shared.run", 100), fixture("sample.HeadOnly.run",
100)],
+ [fixture("sample.Shared.run", 100), fixture("sample.BaseOnly.run",
100)],
+ )
+
+ self.assertEqual(len(result.rows), 1)
+ self.assertEqual(result.only_head, ("sample.HeadOnly.run",))
+ self.assertEqual(result.only_base, ("sample.BaseOnly.run",))
+
+ def test_identity_sorts_parameters_and_invalid_scores_are_malformed(self)
-> None:
+ head = jmh_compare.parse_entries(
+ [fixture("sample.Param.run", 0, params={"z": "2", "a": "1"})]
+ )
+ base = jmh_compare.parse_entries(
+ [fixture("sample.Param.run", 100, params={"a": "1", "z": "2"})]
+ )
+ result = jmh_compare.compare_benchmarks(head, base, 0.10)
+
+ self.assertEqual(list(head), ["sample.Param.run[a=1,z=2]"])
+ self.assertEqual(result.malformed, 1)
+
+ def test_rulers_are_excluded_from_groups_and_warn_when_unstable(self) ->
None:
+ result = compared(
+ [
+ fixture("sample.feature.Subject.run", 100, [99, 101]),
+
fixture("org.apache.grails.benchmarks.ruler.CpuRulerBenchmark.run", 120, [119,
121]),
+ ],
+ [
+ fixture("sample.feature.Subject.run", 100, [99, 101]),
+
fixture("org.apache.grails.benchmarks.ruler.CpuRulerBenchmark.run", 100, [99,
101]),
+ ],
+ )
+ report = jmh_compare.render_report(result)
+
+ self.assertEqual(result.group_speedups, {"feature": (1.0, 1)})
+ self.assertEqual(
+ result.ruler_speedups,
+ (("org.apache.grails.benchmarks.ruler.CpuRulerBenchmark.run", 1 /
1.2),),
+ )
+ self.assertIn("runner was unstable BETWEEN", report)
+ self.assertEqual(
+ next(row.verdict for row in result.rows if "Ruler" in
row.identity),
+ "ruler - excluded",
+ )
+
+ def test_opposite_ruler_movements_warn_without_geomean_cancellation(self)
-> None:
+ result = compared(
+ [
+ fixture("org.apache.grails.benchmarks.ruler.FastRuler.run",
80, [79, 81]),
+ fixture("org.apache.grails.benchmarks.ruler.SlowRuler.run",
125, [124, 126]),
+ ],
+ [
+ fixture("org.apache.grails.benchmarks.ruler.FastRuler.run",
100, [99, 101]),
+ fixture("org.apache.grails.benchmarks.ruler.SlowRuler.run",
100, [99, 101]),
+ ],
+ )
+ report = jmh_compare.render_report(result)
+
+ self.assertIn("runner was unstable BETWEEN", report)
+ self.assertIn("FastRuler.run: 1.25x", report)
+ self.assertIn("SlowRuler.run: 0.80x", report)
+ self.assertNotIn("**Runner health:** 1x", report)
+
+ def test_single_ruler_outside_stability_threshold_warns(self) -> None:
+ result = compared(
+ [fixture("org.apache.grails.benchmarks.ruler.CpuRuler.run", 100,
[99, 101])],
+ [fixture("org.apache.grails.benchmarks.ruler.CpuRuler.run", 90,
[89, 91])],
+ )
+ report = jmh_compare.render_report(result)
+
+ self.assertIn("runner was unstable BETWEEN", report)
+ self.assertIn("CpuRuler.run: 0.90x", report)
+
+ def test_rulers_within_stability_threshold_do_not_warn(self) -> None:
+ result = compared(
+ [
+ fixture("org.apache.grails.benchmarks.ruler.FirstRuler.run",
103, [102, 104]),
+ fixture("org.apache.grails.benchmarks.ruler.SecondRuler.run",
96, [95, 97]),
+ ],
+ [
+ fixture("org.apache.grails.benchmarks.ruler.FirstRuler.run",
100, [99, 101]),
+ fixture("org.apache.grails.benchmarks.ruler.SecondRuler.run",
100, [99, 101]),
+ ],
+ )
+ report = jmh_compare.render_report(result)
+
+ self.assertNotIn("runner was unstable BETWEEN", report)
+ self.assertIn("FirstRuler.run: 0.97x", report)
+ self.assertIn("SecondRuler.run: 1.04x", report)
+
+ def test_allocation_needs_both_percentage_and_absolute_thresholds(self) ->
None:
+ result = compared(
+ [
+ fixture("sample.Allocation.flag", 100, allocation=117),
+ fixture("sample.Allocation.small", 100, allocation=115),
+ fixture("sample.Allocation.percent", 100, allocation=1010),
+ ],
+ [
+ fixture("sample.Allocation.flag", 100, allocation=100),
+ fixture("sample.Allocation.small", 100, allocation=100),
+ fixture("sample.Allocation.percent", 100, allocation=1000),
+ ],
+ )
+
+ self.assertTrue(result.rows[0].allocation_candidate)
+ self.assertFalse(result.rows[1].allocation_candidate)
+ self.assertFalse(result.rows[2].allocation_candidate)
+
+ def test_head_only_mode_renders_results_and_exits_zero(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ head_path = Path(directory) / "head.json"
+ head_path.write_text(json.dumps([fixture("sample.New.run", 42)]),
encoding="utf-8")
+ output = io.StringIO()
+ with contextlib.redirect_stdout(output):
+ exit_code = jmh_compare.main(["--head", str(head_path)])
+
+ self.assertEqual(exit_code, 0)
+ self.assertIn("no comparison was possible", output.getvalue().lower())
+ self.assertIn("sample.New.run", output.getvalue())
+
+ def test_missing_base_file_uses_head_only_mode(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ head_path = Path(directory) / "head.json"
+ head_path.write_text(json.dumps([fixture("sample.New.run", 42)]),
encoding="utf-8")
+ output = io.StringIO()
+ with contextlib.redirect_stdout(output):
+ exit_code = jmh_compare.main(
+ ["--head", str(head_path), "--base", str(Path(directory) /
"absent.json")]
+ )
+
+ self.assertEqual(exit_code, 0)
+ self.assertIn("no comparison was possible", output.getvalue().lower())
+
+ def test_directory_input_merges_direct_json_files(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root /
"first.json").write_text(json.dumps([fixture("sample.First.run", 1)]),
encoding="utf-8")
+ (root /
"second.json").write_text(json.dumps([fixture("sample.Second.run", 2)]),
encoding="utf-8")
+
+ entries = jmh_compare.read_results(directory)
+
+ self.assertEqual(set(entries), {"sample.First.run",
"sample.Second.run"})
+
+ def test_repeated_benchmark_across_shards_is_pooled_not_overwritten(self)
-> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root / "shard-a.json").write_text(
+ json.dumps([fixture("sample.Paired.run", 100, [90, 110],
allocation=200)]), encoding="utf-8"
+ )
+ (root / "shard-b.json").write_text(
+ json.dumps([fixture("sample.Paired.run", 200, [190, 210],
allocation=400)]), encoding="utf-8"
+ )
+
+ entries = jmh_compare.read_results(directory)
+
+ pooled = entries["sample.Paired.run"]
+ self.assertEqual(pooled.score, 150)
+ self.assertEqual(pooled.allocation, 300)
+ self.assertEqual(pooled.confidence, (90, 210))
+
+ def test_cross_shard_unpaired_benchmark_is_dropped(self) -> None:
+ name = "sample.CrossShard.run"
+ result = compared_shards(
+ {
+ "shard-a.json": [],
+ "shard-b.json": [fixture(name, 100, [99, 101])],
+ },
+ {
+ "shard-a.json": [fixture(name, 100, [99, 101])],
+ "shard-b.json": [],
+ },
+ )
+
+ self.assertEqual(result.rows, ())
+ self.assertEqual(result.dropped, (name,))
+ self.assertIn("Dropped unpaired shard samples (1):**
sample.CrossShard.run", jmh_compare.render_report(result))
+
+ def test_shard_that_produced_no_files_is_reported_as_missing(self) -> None:
+ name = "sample.Only.run"
+ result = compared_shards(
+ {"shard-a.json": [fixture(name, 100, [99, 101])]},
+ {"shard-a.json": [fixture(name, 100, [99, 101])]},
+ expected=["shard-a.json", "shard-b.json"],
+ )
+
+ self.assertEqual(result.missing_shards, ("shard-b.json",))
+ self.assertEqual(result.paired_shards, ("shard-a.json",))
+ report = jmh_compare.render_report(result)
+ self.assertIn("shard-b.json", report)
+ self.assertIn("1 shard pair(s) instead of the expected 2", report)
+
+ def test_all_expected_shards_present_reports_no_missing_warning(self) ->
None:
+ name = "sample.Both.run"
+ result = compared_shards(
+ {shard: [fixture(name, 100, [99, 101])] for shard in
("shard-a.json", "shard-b.json")},
+ {shard: [fixture(name, 100, [99, 101])] for shard in
("shard-a.json", "shard-b.json")},
+ expected=["shard-a.json", "shard-b.json"],
+ )
+
+ self.assertEqual(result.missing_shards, ())
+ self.assertNotIn("instead of the expected",
jmh_compare.render_report(result))
+
+ def test_benchmark_paired_within_a_shard_is_compared(self) -> None:
+ result = compared_shards(
+ {"shard-a.json": [fixture("sample.Paired.run", 120, [119, 121])]},
+ {"shard-a.json": [fixture("sample.Paired.run", 100, [99, 101])]},
+ )
+
+ self.assertEqual(result.rows[0].verdict, "REGRESSED")
+
+ def test_opposite_ruler_movements_in_separate_shards_warn(self) -> None:
+ name = "org.apache.grails.benchmarks.ruler.CpuRuler.run"
+ result = compared_shards(
+ {
+ "shard-a.json": [fixture(name, 100 / 1.12, [88, 90])],
+ "shard-b.json": [fixture(name, 112, [111, 113])],
+ },
+ {
+ "shard-a.json": [fixture(name, 100, [99, 101])],
+ "shard-b.json": [fixture(name, 100, [99, 101])],
+ },
+ )
+ report = jmh_compare.render_report(result)
+
+ self.assertIn("runner was unstable BETWEEN", report)
+ self.assertIn("shard-a.json: CpuRuler.run", report)
+ self.assertIn("shard-b.json: CpuRuler.run", report)
+
+ def test_ruler_stability_boundary_is_strict_in_both_directions(self) ->
None:
+ name = "org.apache.grails.benchmarks.ruler.CpuRuler.run"
+ exact_faster = compared_shards(
+ {"shard.json": [fixture(name, 100, [99, 101])]},
+ {"shard.json": [fixture(name, 105, [104, 106])]},
+ )
+ exact_slower = compared_shards(
+ {"shard.json": [fixture(name, 105, [104, 106])]},
+ {"shard.json": [fixture(name, 100, [99, 101])]},
+ )
+ faster = compared_shards(
+ {"shard.json": [fixture(name, 100, [99, 101])]},
+ {"shard.json": [fixture(name, 106, [105, 107])]},
+ )
+ slower = compared_shards(
+ {"shard.json": [fixture(name, 106, [105, 107])]},
+ {"shard.json": [fixture(name, 100, [99, 101])]},
+ )
+
+ self.assertNotIn("runner was unstable BETWEEN",
jmh_compare.render_report(exact_faster))
+ self.assertNotIn("runner was unstable BETWEEN",
jmh_compare.render_report(exact_slower))
+ self.assertIn("runner was unstable BETWEEN",
jmh_compare.render_report(faster))
+ self.assertIn("runner was unstable BETWEEN",
jmh_compare.render_report(slower))
+
+ def test_missing_or_nonfinite_ruler_marks_runner_health_incomplete(self)
-> None:
+ name = "org.apache.grails.benchmarks.ruler.CpuRuler.run"
+ missing = compared_shards(
+ {"shard.json": []},
+ {"shard.json": [fixture(name, 100, [99, 101])]},
+ )
+ nonfinite = compared_shards(
+ {"shard.json": [fixture(name, float("nan"), [99, 101])]},
+ {"shard.json": [fixture(name, 100, [99, 101])]},
+ )
+
+ self.assertTrue(missing.ruler_incomplete)
+ self.assertTrue(nonfinite.ruler_incomplete)
+ self.assertIn("**Runner health:** INCOMPLETE",
jmh_compare.render_report(missing))
+ self.assertIn("**Runner health:** INCOMPLETE",
jmh_compare.render_report(nonfinite))
+
+ def
test_pooled_interval_spans_shards_so_disagreement_suppresses_a_verdict(self) ->
None:
+ name = "sample.Noisy.run"
+ shards = [
+ jmh_compare.parse_entries([fixture(name, 200, [199, 201])])[name],
+ jmh_compare.parse_entries([fixture(name, 90, [89, 91])])[name],
+ ]
+ base = jmh_compare.parse_entries([fixture(name, 100, [99, 101])])
+
+ result = jmh_compare.compare_benchmarks(
+ {name: jmh_compare.pool_benchmarks(shards)}, base, 0.10
+ )
+
+ self.assertEqual(result.rows[0].verdict, "no clear change")
+
+ def test_pooling_incompatible_units_yields_no_score(self) -> None:
+ pooled = jmh_compare.pool_benchmarks(
+ [
+ jmh_compare.parse_entries([fixture("sample.Mixed.run", 10, [9,
11])])["sample.Mixed.run"],
+ jmh_compare.parse_entries(
+ [fixture("sample.Mixed.run", 10, [9, 11], unit="ops/s")]
+ )["sample.Mixed.run"],
+ ]
+ )
+ self.assertIsNone(pooled.score)
+
+ def test_unit_change_between_revisions_is_not_comparable(self) -> None:
+ result = compared(
+ [fixture("sample.Switched.run", 5000, [4900, 5100], unit="ops/s")],
+ [fixture("sample.Switched.run", 200, [199, 201], unit="ns/op")],
+ )
+
+ self.assertEqual(result.rows, ())
+ self.assertEqual(result.malformed, 1)
+
+ def test_mode_change_with_the_same_unit_is_not_comparable(self) -> None:
+ result = compared(
+ [fixture("sample.Switched.run", 100, [99, 101], mode="sample")],
+ [fixture("sample.Switched.run", 100, [99, 101], mode="avgt")],
+ )
+
+ self.assertEqual(result.rows, ())
+ self.assertEqual(result.malformed, 1)
+
+ def test_matching_mode_and_unit_are_comparable(self) -> None:
+ result = compared(
+ [fixture("sample.SameMode.run", 120, [119, 121], mode="avgt")],
+ [fixture("sample.SameMode.run", 100, [99, 101], mode="avgt")],
+ )
+
+ self.assertEqual(result.rows[0].verdict, "REGRESSED")
+
+ def test_summary_file_is_owned_by_the_workflow(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ head_path = root / "head.json"
+ summary_path = root / "summary.md"
+ head_path.write_text(json.dumps([fixture("sample.New.run", 42)]),
encoding="utf-8")
+ with mock.patch.dict(os.environ, {"GITHUB_STEP_SUMMARY":
str(summary_path)}):
+ with contextlib.redirect_stdout(io.StringIO()):
+ jmh_compare.main(["--head", str(head_path)])
+
+ self.assertFalse(summary_path.exists())
+
+ def test_fail_on_regression_remains_zero_exit(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ head_path, base_path = root / "head.json", root / "base.json"
+ head_path.write_text(json.dumps([fixture("sample.Run.run", 120)]),
encoding="utf-8")
+ base_path.write_text(json.dumps([fixture("sample.Run.run", 100)]),
encoding="utf-8")
+ with contextlib.redirect_stdout(io.StringIO()):
+ exit_code = jmh_compare.main(
+ ["--head", str(head_path), "--base", str(base_path),
"--fail-on-regression"]
+ )
+
+ self.assertEqual(exit_code, 0)
+
+ def test_null_pr_number_skips_comment_posting(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ head_path = Path(directory) / "head.json"
+ head_path.write_text(json.dumps([fixture("sample.New.run", 42)]),
encoding="utf-8")
+ with mock.patch.dict(os.environ, {"GITHUB_TOKEN": "token"}):
+ with mock.patch.object(jmh_compare, "post_comment") as
post_comment:
+ with contextlib.redirect_stdout(io.StringIO()):
+ jmh_compare.main(
+ ["--head", str(head_path), "--repo",
"apache/grails-core", "--pr-number", "null"]
+ )
+
+ post_comment.assert_not_called()
+
+ def test_markdown_injection_in_benchmark_name_is_neutralised(self) -> None:
+ name = "sample.<b>|`injected`.run"
+ result = compared([fixture(name, 100)], [fixture(name, 100)])
+ report = jmh_compare.render_report(result)
+
+ self.assertNotIn("<b>", report)
+ self.assertNotIn("|`", report)
+ self.assertIn("sample.binjected.run", report)
+
+ def test_markdown_link_in_benchmark_name_is_neutralised(self) -> None:
+ name = "sample.Evil.run[label=[x](http://example.com)]"
+ report = jmh_compare.render_report(compared([fixture(name, 100)],
[fixture(name, 100)]))
+
+ self.assertNotIn("](", report)
+ self.assertNotRegex(report, r"\[[^]]+\]\([^)]*\)")
+
+ def test_markdown_image_in_benchmark_name_is_neutralised(self) -> None:
+ name = "sample.Evil.run"
+ report = jmh_compare.render_report(compared([fixture(name, 100)],
[fixture(name, 100)]))
+
+ self.assertNotIn("
+
+ def test_ruler_text_in_parameter_value_is_not_excluded(self) -> None:
+ name = "org.apache.grails.benchmarks.feature.Subject.run"
+ result = compared(
+ [fixture(name, 80, [79, 81], params={"label": "Ruler"})],
+ [fixture(name, 100, [99, 101], params={"label": "Ruler"})],
+ )
+
+ self.assertEqual(result.rows[0].verdict, "IMPROVED")
+ self.assertEqual(result.group_speedups, {"feature": (1.25, 1)})
+ self.assertEqual(result.ruler_speedups, ())
+
+ def
test_ruler_package_benchmark_is_excluded_from_groups_and_verdicts(self) -> None:
+ name = "org.apache.grails.benchmarks.ruler.CpuRulerBenchmark.run"
+ result = compared(
+ [fixture(name, 80, [79, 81])],
+ [fixture(name, 100, [99, 101])],
+ )
+
+ self.assertEqual(result.rows[0].verdict, "ruler - excluded")
+ self.assertEqual(result.group_speedups, {})
+ self.assertEqual(result.ruler_speedups, ((name, 1.25),))
+
+ def test_geomean_is_correct_for_known_speedups(self) -> None:
+ result = compared(
+ [
+ fixture("sample.group.First.run", 200),
+ fixture("sample.group.Second.run", 50),
+ ],
+ [
+ fixture("sample.group.First.run", 100),
+ fixture("sample.group.Second.run", 100),
+ ],
+ )
+
+ self.assertEqual(result.group_speedups, {"group": (1.0, 2)})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
new file mode 100644
index 0000000000..301c086c2a
--- /dev/null
+++ b/.github/workflows/benchmark.yml
@@ -0,0 +1,404 @@
+# 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
+#
+# https://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 Benchmark Comparison"
+
+# SECURITY: This workflow deliberately uses pull_request, never
pull_request_target.
+# Pull requests run untrusted code, and Apache Infra policy forbids exposing
tokens
+# to that code through a privileged pull_request_target workflow.
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, labeled]
+ paths-ignore:
+ - '**/*.md'
+ - '**/*.adoc'
+ - 'grails-doc/**'
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number ||
github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+ contents: read
+
+jobs:
+ # Each shard builds both revisions before measuring either one on the same
runner.
+ # Building first avoids CPU, IO, thermal, cache, and frequency state biasing
a measurement.
+ # Shard a measures BASE then HEAD, while shard b measures HEAD then BASE.
+ # Alternating the order cancels first-versus-second ordering bias without
doubling runtime.
+ # Both shards use the PR merge commit as HEAD, so they measure what would
actually land.
+ # Results are advisory: a detected regression never fails this workflow.
+ benchmark:
+ name: "Paired JMH benchmarks (${{ matrix.shard }})"
+ if: ${{ github.event_name == 'workflow_dispatch' ||
contains(github.event.pull_request.labels.*.name, 'performance') }}
+ runs-on: ubuntu-24.04
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [a, b]
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.sha }}
+ JMH_INCLUDE: '.*'
+ PR_NUMBER: ${{ github.event.pull_request.number || 0 }}
+ REPOSITORY: ${{ github.repository }}
+ RESULT_DIR: ${{ github.workspace }}/jmh-results/${{ matrix.shard }}
+ REPORT_DIR: ${{ github.workspace }}/jmh-reports/${{ matrix.shard }}
+ SHARD: ${{ matrix.shard }}
+ steps:
+ - name: "📥 Checkout repository"
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #
v6.0.2
+ with:
+ fetch-depth: 0
+ - name: "☕️ Setup JDK"
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #
v5.2.0
+ with:
+ distribution: liberica
+ java-version: 21
+ - name: "🐘 Setup Gradle"
+ uses:
gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ with:
+ cache-provider: basic # 'basic' uses the MIT-licensed, open-source
cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle
commercial Terms of Use)
+ develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+ - name: "🌳 Prepare paired worktrees"
+ run: |
+ WORKTREE_ROOT="$RUNNER_TEMP/jmh-worktrees/$SHARD"
+ mkdir -p "$WORKTREE_ROOT" "$RESULT_DIR" "$REPORT_DIR"
+ if [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}"
2>/dev/null; then
+ RESOLVED_BASE_SHA="$BASE_SHA"
+ elif RESOLVED_BASE_SHA="$(git merge-base "$HEAD_SHA^1" "$HEAD_SHA^2"
2>/dev/null)"; then
+ echo "Configured base commit is unreachable; using merge-base
$RESOLVED_BASE_SHA."
+ elif RESOLVED_BASE_SHA="$(git rev-parse "$HEAD_SHA^" 2>/dev/null)";
then
+ echo "Configured base commit is unreachable; using HEAD parent
$RESOLVED_BASE_SHA."
+ else
+ RESOLVED_BASE_SHA=""
+ echo "No base commit could be resolved; comparison will use
HEAD-only mode."
+ fi
+ git worktree add --detach "$WORKTREE_ROOT/head" "$HEAD_SHA"
+ {
+ printf 'HEAD_DIR=%s\n' "$WORKTREE_ROOT/head"
+ printf 'WORKTREE_ROOT=%s\n' "$WORKTREE_ROOT"
+ } >> "$GITHUB_ENV"
+ if [ -n "$RESOLVED_BASE_SHA" ] && git worktree add --detach
"$WORKTREE_ROOT/base" "$RESOLVED_BASE_SHA"; then
+ {
+ printf 'BASE_DIR=%s\n' "$WORKTREE_ROOT/base"
+ printf 'RESOLVED_BASE_SHA=%s\n' "$RESOLVED_BASE_SHA"
+ } >> "$GITHUB_ENV"
+ echo "Using base commit $RESOLVED_BASE_SHA."
+ else
+ echo "BASE_BENCHMARKS_AVAILABLE=false" >> "$GITHUB_ENV"
+ fi
+ # Build both JMH jars before measuring either revision. Builds are CPU-
and IO-heavy,
+ # so building and measuring one revision at a time would bias results
with different
+ # thermal, cache, and CPU-frequency state on the shared runner.
+ - name: "🔨 Build paired JMH jars"
+ timeout-minutes: 60
+ run: |
+ base_build_ok=false
+ head_build_ok=false
+ if [ "${BASE_BENCHMARKS_AVAILABLE:-true}" = "true" ] && [ -f
"$BASE_DIR/grails-benchmarks/build.gradle" ]; then
+ if (
+ cd "$BASE_DIR"
+ ./gradlew :grails-benchmarks:jmhJar --max-workers=4
+ ); then
+ base_build_ok=true
+ echo "Built base benchmark at $RESOLVED_BASE_SHA."
+ else
+ echo "BASE benchmark JAR build failed."
+ fi
+ else
+ echo "BASE does not contain grails-benchmarks; comparison will use
HEAD-only mode."
+ fi
+ if (
+ cd "$HEAD_DIR"
+ ./gradlew :grails-benchmarks:jmhJar --max-workers=4
+ ); then
+ head_build_ok=true
+ echo "Built HEAD benchmark at $HEAD_SHA."
+ else
+ echo "HEAD benchmark JAR build failed."
+ fi
+ {
+ printf 'BASE_BUILD_OK=%s\n' "$base_build_ok"
+ printf 'HEAD_BUILD_OK=%s\n' "$head_build_ok"
+ } >> "$GITHUB_ENV"
+ # Two forks, three warmup iterations, and five measurement iterations
balance PR latency
+ # against confidence. Reversing shard order cancels first-versus-second
runner-state bias.
+ - name: "🌡️ Run paired JMH benchmarks"
+ timeout-minutes: 60
+ run: |
+ # JMH writes results incrementally, so a run that dies partway can
leave a file that
+ # parses perfectly while describing only some of the benchmarks. The
report job decides
+ # completeness from which files exist, so a partial file would be
indistinguishable from
+ # a good one. Write to a staging path and publish it only on
success, so a failed run
+ # leaves NO file rather than a plausible one.
+ run_benchmark() {
+ local revision_dir="$1"
+ local result_file="$2"
+ local staging_file="$result_file.partial"
+ rm -f "$staging_file" "$result_file"
+ if (
+ cd "$revision_dir" && ./gradlew :grails-benchmarks:jmh \
+ -Pjmh.include="$JMH_INCLUDE" \
+ -Pjmh.forks=2 \
+ -Pjmh.warmupIterations=3 \
+ -Pjmh.iterations=5 \
+ -Pjmh.resultFile="$staging_file" \
+ -Pjmh.profilers=gc \
+ --max-workers=4
+ ) && [ -s "$staging_file" ] && mv "$staging_file" "$result_file";
then
+ return 0
+ fi
+ rm -f "$staging_file"
+ return 1
+ }
+
+ base_run_failed=false
+ head_run_failed=false
+ run_base_benchmark() {
+ if [ "${BASE_BUILD_OK:-false}" != "true" ]; then
+ echo "BASE benchmark execution skipped because its JAR was not
built."
+ elif ! run_benchmark "$BASE_DIR" "$RESULT_DIR/base.json"; then
+ base_run_failed=true
+ echo "BASE benchmark execution failed."
+ fi
+ }
+ run_head_benchmark() {
+ if [ "${HEAD_BUILD_OK:-false}" != "true" ]; then
+ echo "HEAD benchmark execution skipped because its JAR was not
built."
+ elif ! run_benchmark "$HEAD_DIR" "$RESULT_DIR/head.json"; then
+ head_run_failed=true
+ echo "HEAD benchmark execution failed."
+ fi
+ }
+
+ if [ "$SHARD" = "a" ]; then
+ run_base_benchmark
+ run_head_benchmark
+ else
+ run_head_benchmark
+ run_base_benchmark
+ fi
+ {
+ printf 'BASE_RUN_FAILED=%s\n' "$base_run_failed"
+ printf 'HEAD_RUN_FAILED=%s\n' "$head_run_failed"
+ } >> "$GITHUB_ENV"
+ # The comparison is advisory. jmh_compare.py exits successfully for
regressions, and this
+ # step is non-blocking even if results are incomplete because a
benchmark execution failed.
+ # No --pr-number is passed here on purpose: this job renders the report
only. The separate
+ # report job owns comment posting, so a two-shard matrix cannot produce
duplicate comments.
+ - name: "📊 Compare JMH results"
+ if: always()
+ continue-on-error: true
+ run: |
+ report_file="$REPORT_DIR/comparison.md"
+ {
+ printf '### JMH shard `%s`\n\n' "$SHARD"
+ if [ "${BASE_RUN_FAILED:-false}" = "true" ]; then
+ printf 'BASE benchmark execution failed.\n\n'
+ fi
+ if [ "${BASE_BUILD_OK:-false}" != "true" ]; then
+ printf 'BASE benchmark JAR was not built.\n\n'
+ fi
+ if [ "${HEAD_RUN_FAILED:-false}" = "true" ]; then
+ printf 'HEAD benchmark execution failed.\n\n'
+ fi
+ if [ "${HEAD_BUILD_OK:-false}" != "true" ]; then
+ printf 'HEAD benchmark JAR was not built.\n\n'
+ fi
+ } > "$report_file"
+ if [ ! -f "$RESULT_DIR/head.json" ]; then
+ printf 'HEAD benchmark result was not produced.\n' >>
"$report_file"
+ elif [ "${BASE_BUILD_OK:-false}" = "true" ] && [ -f
"$RESULT_DIR/base.json" ]; then
+ python3 .github/scripts/jmh_compare.py --head
"$RESULT_DIR/head.json" --base "$RESULT_DIR/base.json" >> "$report_file"
+ else
+ python3 .github/scripts/jmh_compare.py --head
"$RESULT_DIR/head.json" >> "$report_file"
+ fi
+ - name: "📋 Publish JMH report in job summary"
+ if: always()
+ run: |
+ if [ -f "$REPORT_DIR/comparison.md" ]; then
+ cat "$REPORT_DIR/comparison.md" >> "$GITHUB_STEP_SUMMARY"
+ else
+ printf '## JMH benchmark comparison\n\nNo comparison report was
produced.\n' >> "$GITHUB_STEP_SUMMARY"
+ fi
+ - name: "📤 Upload JMH artifacts"
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
# v7.0.1
+ with:
+ name: jmh-results-${{ matrix.shard }}
+ path: |
+ jmh-results/${{ matrix.shard }}/
+ jmh-reports/${{ matrix.shard }}/
+ if-no-files-found: warn
+ - name: "🧹 Remove paired worktrees"
+ if: always()
+ run: |
+ WORKTREE_ROOT="$RUNNER_TEMP/jmh-worktrees/$SHARD"
+ git worktree remove --force "$WORKTREE_ROOT/base" || true
+ git worktree remove --force "$WORKTREE_ROOT/head" || true
+
+ report:
+ name: "Publish JMH benchmark comparison"
+ needs: benchmark
+ if: ${{ always() && github.event_name == 'pull_request' &&
contains(github.event.pull_request.labels.*.name, 'performance') &&
github.event.pull_request.head.repo.full_name == github.repository }}
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ pull-requests: write
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ REPOSITORY: ${{ github.repository }}
+ EXPECTED_SHARDS: "a b"
+ steps:
+ # This job runs only for same-repository pull requests, whose authors
already hold push
+ # access, so checking out the merge commit to obtain jmh_compare.py is
not a privilege
+ # escalation. Fork pull requests never reach this job: their
pull_request token is
+ # read-only and commenting would fail with 403, so they receive the
per-shard job summary
+ # and the uploaded artifacts instead.
+ - name: "📥 Checkout repository"
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #
v6.0.2
+ - name: "📥 Download JMH artifacts"
+ uses:
actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ path: benchmark-artifacts
+ # Both shards measured the same two revisions in opposite orders. Only
complete same-shard
+ # base/head pairs are pooled, preserving runner identity while letting
the alternating order
+ # cancel ordering bias. Incomplete shards are reported and never
cross-paired.
+ - name: "📊 Pool shard results and compare"
+ id: compare
+ run: |
+ collected_base="$RUNNER_TEMP/collected-base"
+ collected_head="$RUNNER_TEMP/collected-head"
+ pooled_base="$RUNNER_TEMP/pooled-base"
+ pooled_head="$RUNNER_TEMP/pooled-head"
+ head_only="$RUNNER_TEMP/head-only"
+ mkdir -p "$collected_base" "$collected_head" "$pooled_base"
"$pooled_head" "$head_only"
+ # Seeded from the matrix rather than from discovered files: a shard
that failed before
+ # uploading anything has no file to discover, and would otherwise be
omitted from the
+ # report entirely instead of being named as missing.
+ declare -A observed_shards=()
+ expected_shard_files=""
+ for shard in $EXPECTED_SHARDS; do
+ observed_shards["$shard"]=true
+
expected_shard_files="${expected_shard_files:+$expected_shard_files,}shard-$shard.json"
+ done
+ while IFS= read -r -d '' result_file; do
+ shard=""
+ if [[ "$result_file" =~ /jmh-results-([^/]+)/ ]]; then
+ shard="${BASH_REMATCH[1]}"
+ elif [[ "$result_file" =~ /jmh-results/([^/]+)/ ]]; then
+ shard="${BASH_REMATCH[1]}"
+ else
+ printf 'Unable to identify shard for result %s; ignoring it.\n'
"$result_file"
+ continue
+ fi
+ observed_shards["$shard"]=true
+ case "$(basename "$result_file")" in
+ base.json) cp "$result_file" "$collected_base/shard-$shard.json"
;;
+ head.json) cp "$result_file" "$collected_head/shard-$shard.json"
;;
+ esac
+ done < <(find benchmark-artifacts -type f \( -name 'base.json' -o
-name 'head.json' \) -print0)
+
+ complete_shards=()
+ dropped_shards=()
+ if [ "${#observed_shards[@]}" -gt 0 ]; then
+ for shard in "${!observed_shards[@]}"; do
+ if [ -f "$collected_base/shard-$shard.json" ] && [ -f
"$collected_head/shard-$shard.json" ]; then
+ cp "$collected_base/shard-$shard.json"
"$pooled_base/shard-$shard.json"
+ cp "$collected_head/shard-$shard.json"
"$pooled_head/shard-$shard.json"
+ complete_shards+=("$shard")
+ else
+ dropped_shards+=("$shard")
+ fi
+ if [ -f "$collected_head/shard-$shard.json" ]; then
+ cp "$collected_head/shard-$shard.json"
"$head_only/shard-$shard.json"
+ fi
+ done
+ fi
+
+ report_file="$RUNNER_TEMP/pooled-comparison.md"
+ {
+ printf '### JMH Shard Pairing\n\n'
+ printf '**Complete shard pairs used:** %s\n'
"${#complete_shards[@]}"
+ if [ "${#dropped_shards[@]}" -gt 0 ]; then
+ printf '**Dropped incomplete shards:**'
+ printf ' `%s`' "${dropped_shards[@]}"
+ printf '\n'
+ fi
+ printf '\n'
+ if [ "${#complete_shards[@]}" -gt 0 ]; then
+ python3 .github/scripts/jmh_compare.py --head "$pooled_head"
--base "$pooled_base" --expected-shards "$expected_shard_files"
+ elif compgen -G "$head_only/*.json" > /dev/null; then
+ printf 'No complete same-runner base and HEAD shard pair was
available. Reporting available HEAD results only.\n\n'
+ python3 .github/scripts/jmh_compare.py --head "$head_only"
+ else
+ printf '### JMH Benchmark Report\n\nNo HEAD benchmark results
were produced.\n\n%s\n' \
+ '<!-- grails-jmh-benchmark -->'
+ fi
+ } > "$report_file"
+ cat "$report_file" >> "$GITHUB_STEP_SUMMARY"
+ printf 'report_file=%s\n' "$report_file" >> "$GITHUB_OUTPUT"
+ - name: "💬 Post or update JMH PR comment"
+ continue-on-error: true
+ env:
+ REPORT_FILE: ${{ steps.compare.outputs.report_file }}
+ run: |
+ payload_file="$RUNNER_TEMP/jmh-comment.json"
+ comments_file="$RUNNER_TEMP/jmh-comments.json"
+ python3 - "$REPORT_FILE" "$payload_file" <<'PY'
+ import json
+ import pathlib
+ import sys
+
+ report = {"body": pathlib.Path(sys.argv[1]).read_text()}
+ body = report.get("body", "")
+ marker = "<!-- grails-jmh-benchmark -->"
+ if marker not in body:
+ body = f"{body}\n\n{marker}"
+ if len(body) > 65000:
+ suffix = f"\n\n_Report truncated. The full report is available
in the workflow artifacts._\n\n{marker}"
+ body = f"{body[:65000 - len(suffix)]}{suffix}"
+ pathlib.Path(sys.argv[2]).write_text(json.dumps({"body": body}))
+ PY
+ gh api --paginate --slurp
"repos/$REPOSITORY/issues/$PR_NUMBER/comments" > "$comments_file"
+ comment_id="$(python3 - "$comments_file" <<'PY'
+ import json
+ import pathlib
+ import sys
+
+ comments = [
+ comment
+ for page in json.loads(pathlib.Path(sys.argv[1]).read_text())
+ if isinstance(page, list)
+ for comment in page
+ if isinstance(comment, dict)
+ ]
+ for comment in reversed(comments):
+ user = comment.get("user") or {}
+ body = comment.get("body") or ""
+ comment_id = comment.get("id")
+ if isinstance(user, dict) and user.get("login", "") ==
"github-actions[bot]" and isinstance(body, str) and "<!-- grails-jmh-benchmark
-->" in body and comment_id is not None:
+ print(comment_id)
+ break
+ PY
+ )"
+ if [ -n "$comment_id" ]; then
+ gh api --method PATCH
"repos/$REPOSITORY/issues/comments/$comment_id" --input "$payload_file"
+ else
+ gh api --method POST
"repos/$REPOSITORY/issues/$PR_NUMBER/comments" --input "$payload_file"
+ fi
diff --git a/.gitignore b/.gitignore
index be3f3c371d..c324c099d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,3 +70,5 @@ etc/bin/results
node_modules/
local-tasks.gradle
local-init.gradle
+__pycache__/
+*.pyc
diff --git a/grails-benchmarks/README.adoc b/grails-benchmarks/README.adoc
new file mode 100644
index 0000000000..73e7a82a9b
--- /dev/null
+++ b/grails-benchmarks/README.adoc
@@ -0,0 +1,139 @@
+////
+SPDX-License-Identifier: Apache-2.0
+
+Licensed 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
+
+ https://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.
+////
+
+= Grails Benchmarks
+
+JMH microbenchmarks for Grails framework hot paths -- the code that runs on
every request or
+every bound object, where a regression directly costs users latency.
+
+This module is a build-time-only harness. It is *not published*, is not part
of the BOM, and
+nothing at runtime depends on it.
+
+JMH is licensed under GPLv2 with the Classpath Exception, an ASF Category X
license. Its use is
+acceptable here because this build-time-only module is never published or
included in Grails runtime
+artifacts.
+
+== Running locally
+
+The repository baseline is JDK 21, so point `JAVA_HOME` at a JDK 21 if it is
not your default.
+Run everything from the repository root:
+
+[source,bash]
+----
+JAVA_HOME=/path/to/jdk-21 ./gradlew :grails-benchmarks:jmh
+----
+
+A full run at default settings takes several minutes. While iterating, narrow
it to one class
+and cut the iteration counts:
+
+[source,bash]
+----
+JAVA_HOME=/path/to/jdk-21 ./gradlew :grails-benchmarks:jmh \
+ -Pjmh.include=UrlMappingsBenchmark \
+ -Pjmh.forks=1 -Pjmh.warmupIterations=1 -Pjmh.iterations=1
+----
+
+Add allocation profiling with `-Pjmh.profilers=gc`, which populates the
`gc.alloc.rate.norm`
+(bytes per operation) metric the CI report shows as an advisory column.
+
+== Gradle properties
+
+|===
+| Property | Default | Purpose
+
+| `jmh.include` | `.*` | Comma-separated benchmark include patterns (regex)
+| `jmh.forks` | `1` | JVM forks per benchmark. CI uses `2`
+| `jmh.warmupIterations` | `1` | Warmup iterations. CI uses `3`
+| `jmh.iterations` | `1` | Measurement iterations. CI uses `5`
+| `jmh.warmupTime` | `1s` | Duration of each warmup iteration
+| `jmh.iterationTime` | `1s` | Duration of each measurement iteration
+| `jmh.resultFormat` | `JSON` | JMH result format
+| `jmh.resultFile` | `build/results/jmh/results.json` | Absolute path for the
result file
+| `jmh.profilers` | none | Comma-separated JMH profilers, for example `gc`
+| `jmh.failOnError` | `true` | Fail the build when a benchmark throws
+|===
+
+`jmh.failOnError` defaults to `true` deliberately. JMH otherwise drops a
benchmark that throws
+during `@Setup` from the results file while still reporting build success,
which would silently
+shrink the comparison set in CI.
+
+== Layout
+
+Benchmarks are Java, under `src/jmh/java`. Setup fixtures are Groovy, under
`src/main/groovy`.
+
+That split is intentional. A benchmark written in Groovy measures Groovy's
dynamic call-site
+machinery as much as the Grails API under test, so the measured method stays
in Java. Fixtures
+are Groovy only where the Grails API genuinely requires it -- the URL mappings
DSL, the
+`Validateable` trait, and view templates are all closure-based. Fixtures live
in `main` rather
+than `src/jmh/groovy` because the `me.champeau.jmh` plugin puts the main
source set's output on
+the jmh compile classpath, which keeps the benchmark source set pure Java and
preserves normal
+JMH annotation processing.
+
+Benchmarks are grouped by package, and the CI report aggregates per group:
+
+|===
+| Package | Covers
+
+| `urlmappings` | Request URI matching (warm and cold cache) and reverse URL
creation
+| `databinding` | Binding a map onto an object, with and without type
conversion
+| `gsp` | GSP parsing (template text to generated Groovy source)
+| `interceptors` | Interceptor URI match decisions
+| `views` | JSON and markup view rendering
+| `ruler` | Pure-JDK probes used to detect an unstable CI runner
+|===
+
+The `ruler` benchmarks touch no Grails or Groovy code. Because CI measures the
base and head
+revisions on the *same* runner, a ruler that moves between the two halves of a
run means the
+runner itself was unstable and the whole comparison is untrustworthy. They are
excluded from
+verdicts and group summaries.
+
+== Known gap: validation
+
+`Validateable.validate()` is a genuine hot path and belongs in this suite, but
it is not here yet.
+A benchmark was written and then withdrawn because it was too unstable to be
useful: measured at
+2 forks x 5 iterations it showed 80-160% relative error, against 15-20% for
the pure-JDK rulers on
+the same otherwise-idle machine. At that spread the confidence intervals can
never separate, so the
+comparison would report `no clear change` no matter what happened to
validation performance, while
+still consuming CI time and contributing a meaningless number to a group
geometric mean.
+
+Two things were ruled out. Object construction was moved into `@Setup`, so the
map constructor is
+not being measured. Errors were reset between invocations, because
`doValidate()` copies an
+object's existing errors into the new error set and repeated validation of a
failing object
+otherwise grows that set without bound. Neither brought the variance down to a
usable level, which
+points at bimodal behaviour across forks rather than at the fixture.
+
+Anyone picking this up should start by raising the fork count to see whether
the distribution is
+bimodal, and by checking whether `findConstraintsEvaluator()` and
`evaluator.evaluate()` are
+re-doing work on every call.
+
+== Adding a benchmark
+
+Prefer paths that are hot per request, cheap to construct without a Spring
context or servlet
+container, and meaningful to users. Anything with an internal cache is a good
candidate, since
+cache-effectiveness regressions are exactly what this suite is meant to catch.
+
+Keep the total benchmark count modest. CI runs the whole suite twice per
shard, so every added
+benchmark costs roughly double its runtime on every performance-labelled pull
request.
+
+Every `@Benchmark` method must return its result or consume it through a
`Blackhole`, otherwise
+the JIT can eliminate the work being measured.
+
+== CI
+
+`.github/workflows/benchmark.yml` runs this suite on pull requests labelled
`performance`,
+measuring the merge commit against the pull request's base commit on a single
runner, and posts
+the comparison produced by `.github/scripts/jmh_compare.py`. The check is
advisory and never
+fails a build.
diff --git a/grails-benchmarks/build.gradle b/grails-benchmarks/build.gradle
new file mode 100644
index 0000000000..86a7e09f2e
--- /dev/null
+++ b/grails-benchmarks/build.gradle
@@ -0,0 +1,238 @@
+/*
+ * 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
+ *
+ * https://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.
+ */
+
+import org.gradle.api.file.DuplicatesStrategy
+import org.gradle.api.tasks.PathSensitivity
+
+import java.util.Properties
+import java.util.zip.ZipFile
+
+plugins {
+ id 'groovy'
+ id 'java-library'
+ id 'me.champeau.jmh' version '0.7.3'
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.compile'
+}
+
+version = projectVersion
+group = 'org.apache.grails'
+
+// This build-time-only benchmark harness is never published, so it
deliberately omits
+// org.apache.grails.buildsrc.publish, org.apache.grails.buildsrc.sbom,
+// org.apache.grails.buildsrc.vulnerability-scan,
org.apache.grails.gradle.grails-jacoco,
+// org.apache.grails.buildsrc.dependency-validator, and
org.apache.grails.gradle.grails-code-style.
+// The latter remains omitted until existing JMH source import-order and
fixture-formatting
+// violations can be remediated together.
+
+// The Groovy fixtures under src/main/groovy build the framework objects whose
APIs are
+// closure-based (the URL mappings DSL, the Validateable trait, view
templates). The JMH
+// benchmarks themselves live in src/jmh/java and only *call* those fixtures,
so that the
+// measured code path is plain Java and does not include Groovy's dynamic
dispatch.
+// me.champeau.jmh puts the main source set's output on the jmh compile
classpath, which is
+// why the fixtures are in main rather than src/jmh/groovy: keeping the
benchmarks in a pure
+// Java source set preserves normal JMH annotation processing.
+dependencies {
+ implementation platform(project(':grails-bom'))
+
+ implementation project(':grails-web-url-mappings')
+ implementation project(':grails-databinding-core')
+ implementation project(':grails-gsp-core')
+ implementation project(':grails-interceptors')
+ implementation project(':grails-views-gson')
+ implementation project(':grails-views-markup')
+ implementation project(':grails-core')
+ implementation 'org.apache.groovy:groovy'
+
+ // The framework modules declare these as compileOnly, so they are absent
from the runtime
+ // classpath a benchmark actually executes on. The modules' own test
suites add them back
+ // the same way. Without them, URL mapping, interceptor and view
benchmarks fail at @Setup
+ // with NoClassDefFoundError: jakarta/servlet/ServletContext.
+ implementation 'jakarta.servlet:jakarta.servlet-api'
+ implementation 'org.springframework:spring-test'
+}
+
+// The jmh source set compiles and runs against everything main depends on.
+configurations.jmh.extendsFrom(configurations.implementation)
+
+// Every JMH knob the CI workflow needs to override is exposed as a Gradle
property so that
+// .github/workflows/benchmark.yml can run a fast PR profile and a slower
scheduled profile
+// from the same build. Defaults here are the quick local-development profile.
+jmh {
+ jmhVersion = '1.37'
+ includes = providers.gradleProperty('jmh.include')
+ .map { it.split(',') as List<String> }
+ .orElse(['.*'])
+ .get()
+ fork = providers.gradleProperty('jmh.forks').map { it as Integer
}.orElse(1).get()
+ warmupIterations = providers.gradleProperty('jmh.warmupIterations').map {
it as Integer }.orElse(1).get()
+ iterations = providers.gradleProperty('jmh.iterations').map { it as
Integer }.orElse(1).get()
+ warmup = providers.gradleProperty('jmh.warmupTime').orElse('1s').get()
+ timeOnIteration =
providers.gradleProperty('jmh.iterationTime').orElse('1s').get()
+ resultFormat =
providers.gradleProperty('jmh.resultFormat').orElse('JSON').get()
+ // A benchmark that throws during @Setup is otherwise dropped from the
results file while
+ // the build still reports success, which would silently shrink the
comparison set.
+ failOnError = providers.gradleProperty('jmh.failOnError').map {
it.toBoolean() }.orElse(true).get()
+ resultsFile = file(providers.gradleProperty('jmh.resultFile')
+
.orElse(layout.buildDirectory.file('results/jmh/results.json').get().asFile.absolutePath)
+ .get())
+ // Comma-separated JMH profilers, e.g. -Pjmh.profilers=gc for allocation
reporting.
+ profilers = providers.gradleProperty('jmh.profilers')
+ .map { it.split(',') as List<String> }
+ .orElse([])
+ .get()
+}
+
+def mergeJmhClasspathMetadata = tasks.register('mergeJmhClasspathMetadata') {
+ def mergedMetadataDirectory =
layout.buildDirectory.dir('generated/jmh-classpath-metadata')
+ def lineMetadataPrefixes = ['META-INF/services/', 'META-INF/groovy/']
+ def extensionModuleSuffix =
'org.codehaus.groovy.runtime.ExtensionModule'
+ def propertiesMetadataPaths = [
+ 'META-INF/spring.factories',
+ 'META-INF/spring.handlers',
+ 'META-INF/spring.schemas'
+ ]
+
+ inputs.files(configurations.jmhRuntimeClasspath)
+ .withPropertyName('jmhRuntimeClasspath')
+ .withPathSensitivity(PathSensitivity.RELATIVE)
+ outputs.dir(mergedMetadataDirectory)
+ outputs.cacheIf { true }
+
+ doLast {
+ File outputDirectory = mergedMetadataDirectory.get().asFile
+ project.delete(outputDirectory)
+
+ Map<String, Set<String>> lineEntries = new TreeMap<>()
+ Map<String, Map<String, Set<String>>> extensionModuleEntries = new
TreeMap<>()
+ Map<String, Map<String, Set<String>>> propertyEntries = new
TreeMap<>()
+
+ configurations.jmhRuntimeClasspath.files
+ .findAll { File artifact -> artifact.name.endsWith('.jar')
}
+ .sort { File artifact -> artifact.absolutePath }
+ .each { File artifact ->
+ new ZipFile(artifact).withCloseable { ZipFile zipFile
->
+ zipFile.entries().each { entry ->
+ if (entry.directory) {
+ return
+ }
+
+ String path = entry.name
+ if (path.endsWith(extensionModuleSuffix)) {
+ Properties properties = new Properties()
+
zipFile.getInputStream(entry).withCloseable { input ->
+ properties.load(input)
+ }
+ Map<String, Set<String>> entries =
extensionModuleEntries.computeIfAbsent(path) {
+ new TreeMap<>()
+ }
+ ['extensionClasses',
'staticExtensionClasses'].each { String key ->
+ Set<String> values =
entries.computeIfAbsent(key) { new TreeSet<>() }
+ properties.getProperty(key,
'').split(',').each { String value ->
+ if (value) {
+ values.add(value.trim())
+ }
+ }
+ }
+ } else if (lineMetadataPrefixes.any { String
prefix -> path.startsWith(prefix) }) {
+ Set<String> lines =
lineEntries.computeIfAbsent(path) { new TreeSet<>() }
+
zipFile.getInputStream(entry).getText('UTF-8').readLines().each { String line ->
+ if (line) {
+ lines.add(line)
+ }
+ }
+ } else if
(propertiesMetadataPaths.contains(path)) {
+ Properties properties = new Properties()
+
zipFile.getInputStream(entry).withCloseable { input ->
+ properties.load(input)
+ }
+ Map<String, Set<String>> entries =
propertyEntries.computeIfAbsent(path) {
+ new TreeMap<>()
+ }
+ properties.stringPropertyNames().each {
String key ->
+ Set<String> values =
entries.computeIfAbsent(key) { new TreeSet<>() }
+
properties.getProperty(key).split(',').each { String value ->
+ if (value) {
+ values.add(value.trim())
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ lineEntries.each { String path, Set<String> lines ->
+ File outputFile = new File(outputDirectory, path)
+ outputFile.parentFile.mkdirs()
+ outputFile.setText("${lines.join('\n')}\n", 'UTF-8')
+ }
+ extensionModuleEntries.each { String path, Map<String,
Set<String>> entries ->
+ File outputFile = new File(outputDirectory, path)
+ outputFile.parentFile.mkdirs()
+ String moduleName = path.startsWith('META-INF/groovy/')
+ ? 'grails-benchmark-groovy-extension-modules'
+ : 'grails-benchmark-service-extension-modules'
+ outputFile.withWriter('UTF-8') { writer ->
+ writer.write("moduleName=${moduleName}\n")
+ writer.write('moduleVersion=1.0\n')
+ entries.each { String key, Set<String> values ->
+ writer.write("${key}=${values.join(',')}\n")
+ }
+ }
+ }
+ propertyEntries.each { String path, Map<String, Set<String>>
entries ->
+ File outputFile = new File(outputDirectory, path)
+ outputFile.parentFile.mkdirs()
+ outputFile.withWriter('UTF-8') { writer ->
+ entries.each { String key, Set<String> values ->
+ String escapedKey = key.replace('\\',
'\\\\').replace('=', '\\=').replace(':', '\\:')
+ writer.write("${escapedKey}=${values.join(',')}\n")
+ }
+ }
+ }
+ }
+}
+
+tasks.named('jmhJar') {
+ dependsOn mergeJmhClasspathMetadata
+ def mergedMetadataDirectory =
layout.buildDirectory.dir('generated/jmh-classpath-metadata')
+ def mergedMetadataPath = mergedMetadataDirectory.get().asFile.absolutePath
+ File.separator
+ def lineMetadataPrefixes = ['META-INF/services/', 'META-INF/groovy/']
+ def propertiesMetadataPaths = [
+ 'META-INF/spring.factories',
+ 'META-INF/spring.handlers',
+ 'META-INF/spring.schemas'
+ ]
+
+ from(mergedMetadataDirectory)
+ eachFile { details ->
+ boolean mergeable = lineMetadataPrefixes.any { String prefix ->
details.path.startsWith(prefix) } ||
+ propertiesMetadataPaths.contains(details.path)
+ if (mergeable &&
!details.file.absolutePath.startsWith(mergedMetadataPath)) {
+ details.exclude()
+ }
+ }
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+tasks.named('check') {
+ // Benchmarks are not otherwise compiled by the standard lifecycle tasks.
+ dependsOn tasks.named('jmhClasses')
+}
diff --git
a/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/databinding/SimpleDataBinderBenchmark.java
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/databinding/SimpleDataBinderBenchmark.java
new file mode 100644
index 0000000000..dbebc9198a
--- /dev/null
+++
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/databinding/SimpleDataBinderBenchmark.java
@@ -0,0 +1,141 @@
+/*
+ * 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
+ *
+ * https://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.grails.benchmarks.databinding;
+
+import java.util.Date;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import grails.databinding.SimpleDataBinder;
+import grails.databinding.SimpleMapDataBindingSource;
+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.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Measures form and request-map binding into a command-style object. Binding
overhead affects
+ * controller actions and command objects on every submitted request.
+ */
+@State(Scope.Benchmark)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 2, jvmArgsAppend = {"-Xms1g", "-Xmx1g", "-XX:+UseG1GC"})
+public class SimpleDataBinderBenchmark {
+
+ private SimpleDataBinder binder;
+ private SimpleMapDataBindingSource flatSource;
+ private SimpleMapDataBindingSource conversionSource;
+
+ @Setup
+ public void setup() {
+ binder = new SimpleDataBinder();
+ flatSource = new SimpleMapDataBindingSource(Map.of(
+ "name", "Ada",
+ "age", 42,
+ "accountId", 9_001L,
+ "status", Status.ACTIVE,
+ "createdAt", new Date(1_700_000_000_000L)
+ ));
+ conversionSource = new SimpleMapDataBindingSource(Map.of(
+ "name", "Ada",
+ "age", "42",
+ "accountId", "9001",
+ "status", "ACTIVE",
+ "createdAt", new Date(1_700_000_000_000L)
+ ));
+ }
+
+ @Benchmark
+ public BindingTarget bindFlatMap() {
+ BindingTarget target = new BindingTarget();
+ binder.bind(target, flatSource);
+ return target;
+ }
+
+ @Benchmark
+ public BindingTarget bindMapWithTypeConversion() {
+ BindingTarget target = new BindingTarget();
+ binder.bind(target, conversionSource);
+ return target;
+ }
+
+ public enum Status {
+ ACTIVE,
+ INACTIVE
+ }
+
+ public static final class BindingTarget {
+ private String name;
+ private Integer age;
+ private Long accountId;
+ private Status status;
+ private Date createdAt;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public Integer getAge() {
+ return age;
+ }
+
+ public void setAge(Integer age) {
+ this.age = age;
+ }
+
+ public Long getAccountId() {
+ return accountId;
+ }
+
+ public void setAccountId(Long accountId) {
+ this.accountId = accountId;
+ }
+
+ public Status getStatus() {
+ return status;
+ }
+
+ public void setStatus(Status status) {
+ this.status = status;
+ }
+
+ public Date getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(Date createdAt) {
+ this.createdAt = createdAt;
+ }
+ }
+}
diff --git
a/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/gsp/GroovyPageParserBenchmark.java
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/gsp/GroovyPageParserBenchmark.java
new file mode 100644
index 0000000000..b42dbd21d9
--- /dev/null
+++
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/gsp/GroovyPageParserBenchmark.java
@@ -0,0 +1,82 @@
+/*
+ * 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
+ *
+ * https://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.grails.benchmarks.gsp;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+
+import org.grails.gsp.compiler.GroovyPageParser;
+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.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Measures GSP source parsing into generated Groovy source, the work
performed when GSP templates
+ * are prepared. Parser regressions slow application startup and template
reloads.
+ */
+@State(Scope.Benchmark)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 2, jvmArgsAppend = {"-Xms1g", "-Xmx1g", "-XX:+UseG1GC"})
+public class GroovyPageParserBenchmark {
+
+ private static final String SMALL_TEMPLATE = "<div>Hello ${name}</div>";
+ private static final String TAGGED_TEMPLATE = """
+ <%@ page expressionCodec=\"HTML\" %>
+ <section class=\"${cssClass}\">
+ <g:link controller=\"book\" action=\"show\"
id=\"${bookId}\">${title}</g:link>
+ <g:each in=\"${books}\"
var=\"book\"><p>${book.name}</p></g:each>
+ </section>
+ """;
+
+ @Benchmark
+ public byte[] parseSmallTemplate() throws IOException {
+ return parse("small.gsp", SMALL_TEMPLATE);
+ }
+
+ @Benchmark
+ public byte[] parseTemplateWithTagsAndExpressions() throws IOException {
+ return parse("tagged.gsp", TAGGED_TEMPLATE);
+ }
+
+ private byte[] parse(String uri, String template) throws IOException {
+ GroovyPageParser parser = new GroovyPageParser(
+ uri,
+ uri,
+ uri,
+ new
ByteArrayInputStream(template.getBytes(StandardCharsets.UTF_8)),
+ StandardCharsets.UTF_8.name(),
+ "HTML",
+ null
+ );
+ return parser.parse().readAllBytes();
+ }
+}
diff --git
a/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/interceptors/UrlMappingMatcherBenchmark.java
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/interceptors/UrlMappingMatcherBenchmark.java
new file mode 100644
index 0000000000..f33fea1547
--- /dev/null
+++
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/interceptors/UrlMappingMatcherBenchmark.java
@@ -0,0 +1,144 @@
+/*
+ * 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
+ *
+ * https://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.grails.benchmarks.interceptors;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import grails.web.mapping.UrlMappingData;
+import grails.web.mapping.UrlMappingInfo;
+import org.grails.plugins.web.interceptors.UrlMappingMatcher;
+import org.grails.web.servlet.mvc.GrailsWebRequest;
+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.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Measures URI matcher decisions that select Grails interceptors during
request dispatch. Both
+ * matching and rejected paths matter because every interceptor evaluates
incoming requests.
+ */
+@State(Scope.Benchmark)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 2, jvmArgsAppend = {"-Xms1g", "-Xmx1g", "-XX:+UseG1GC"})
+public class UrlMappingMatcherBenchmark {
+
+ private UrlMappingMatcher matcher;
+ private UrlMappingInfo mappingInfo;
+
+ @Setup
+ public void setup() {
+ matcher = new
UrlMappingMatcher(InterceptorFixture.createInterceptor());
+ matcher.matches(Map.of("uri", "/orders/**"));
+ mappingInfo = new BenchmarkUrlMappingInfo();
+ }
+
+ @Benchmark
+ public boolean matchUriPattern() {
+ return matcher.doesMatch("/orders/42", mappingInfo);
+ }
+
+ @Benchmark
+ public boolean rejectNonMatchingUriPattern() {
+ return matcher.doesMatch("/catalog/42", mappingInfo);
+ }
+
+ private static final class BenchmarkUrlMappingInfo implements
UrlMappingInfo {
+ @Override
+ public String getURI() {
+ return null;
+ }
+
+ @Override
+ public String getHttpMethod() {
+ return "GET";
+ }
+
+ @Override
+ public String getVersion() {
+ return null;
+ }
+
+ @Override
+ public String getControllerName() {
+ return "orders";
+ }
+
+ @Override
+ public String getActionName() {
+ return "show";
+ }
+
+ @Override
+ public String getNamespace() {
+ return null;
+ }
+
+ @Override
+ public String getPluginName() {
+ return null;
+ }
+
+ @Override
+ public String getViewName() {
+ return null;
+ }
+
+ @Override
+ public String getId() {
+ return "42";
+ }
+
+ @Override
+ public Map<?, ?> getParameters() {
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public void configure(GrailsWebRequest webRequest) {
+ }
+
+ @Override
+ public boolean isParsingRequest() {
+ return false;
+ }
+
+ @Override
+ public Object getRedirectInfo() {
+ return null;
+ }
+
+ @Override
+ public UrlMappingData getUrlData() {
+ return null;
+ }
+ }
+}
diff --git
a/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/ruler/CpuRulerBenchmark.java
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/ruler/CpuRulerBenchmark.java
new file mode 100644
index 0000000000..497c82bc87
--- /dev/null
+++
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/ruler/CpuRulerBenchmark.java
@@ -0,0 +1,58 @@
+/*
+ * 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
+ *
+ * https://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.grails.benchmarks.ruler;
+
+import java.util.concurrent.TimeUnit;
+
+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.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Runner-health probe: base and head run on the same runner, so if this ruler
moves between the
+ * two halves, the runner was unstable and the benchmark comparison is
untrustworthy.
+ */
+@State(Scope.Benchmark)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 2, jvmArgsAppend = {"-Xms1g", "-Xmx1g", "-XX:+UseG1GC"})
+public class CpuRulerBenchmark {
+
+ private int state = 0x13579bdf;
+
+ @Benchmark
+ public long integerArithmetic() {
+ long result = state;
+ for (int index = 1; index <= 1_024; index++) {
+ result = (result * 1_103_515_245L + index) ^ (result >>> 13);
+ }
+ state = (int) result;
+ return result;
+ }
+}
diff --git
a/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/ruler/MemoryRulerBenchmark.java
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/ruler/MemoryRulerBenchmark.java
new file mode 100644
index 0000000000..c5b572d8d4
--- /dev/null
+++
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/ruler/MemoryRulerBenchmark.java
@@ -0,0 +1,63 @@
+/*
+ * 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
+ *
+ * https://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.grails.benchmarks.ruler;
+
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
+
+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.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Runner-health probe: base and head run on the same runner, so if this ruler
moves between the
+ * two halves, the runner was unstable and the benchmark comparison is
untrustworthy.
+ */
+@State(Scope.Benchmark)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 2, jvmArgsAppend = {"-Xms1g", "-Xmx1g", "-XX:+UseG1GC"})
+public class MemoryRulerBenchmark {
+
+ private byte[] source;
+
+ @Setup
+ public void setup() {
+ source = new byte[8_192];
+ for (int index = 0; index < source.length; index++) {
+ source[index] = (byte) index;
+ }
+ }
+
+ @Benchmark
+ public byte[] allocateAndCopyArray() {
+ return Arrays.copyOf(source, source.length);
+ }
+}
diff --git
a/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/urlmappings/UrlMappingsBenchmark.java
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/urlmappings/UrlMappingsBenchmark.java
new file mode 100644
index 0000000000..8e17aaf8ec
--- /dev/null
+++
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/urlmappings/UrlMappingsBenchmark.java
@@ -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
+ *
+ * https://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.grails.benchmarks.urlmappings;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import grails.web.mapping.UrlCreator;
+import grails.web.mapping.UrlMappingInfo;
+import org.grails.web.mapping.DefaultUrlMappingsHolder;
+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.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Measures request URI matching and reverse URL creation, which are performed
for every routed
+ * Grails request and generated link. Cache regressions here directly increase
request latency.
+ */
+@State(Scope.Benchmark)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 2, jvmArgsAppend = {"-Xms1g", "-Xmx1g", "-XX:+UseG1GC"})
+public class UrlMappingsBenchmark {
+
+ private static final String WARM_URI = "/catalog/books/42";
+ private static final String EXPECTED_REVERSE_URL = "/catalog/books/42";
+ private static final int COLD_URI_COUNT = 16_384;
+
+ private DefaultUrlMappingsHolder holder;
+ private String[] coldUris;
+ private Map<String, Object> reverseParameters;
+ private int coldUriIndex;
+
+ @Setup
+ public void setup() {
+ holder = UrlMappingsFixture.createHolder();
+ coldUris = new String[COLD_URI_COUNT];
+ for (int index = 0; index < COLD_URI_COUNT; index++) {
+ coldUris[index] = "/catalog/books/" + index;
+ }
+ reverseParameters = new HashMap<>();
+ reverseParameters.put("category", "books");
+ reverseParameters.put("id", "42");
+ assertFixtureMatches();
+ holder.match(WARM_URI);
+ }
+
+ // A mappings DSL mistake makes match() return null rather than raise,
which would leave these
+ // benchmarks quietly timing failed lookups. Fail the run instead of
publishing wrong numbers.
+ private void assertFixtureMatches() {
+ UrlMappingInfo info = holder.match(WARM_URI);
+ if (info == null) {
+ throw new IllegalStateException("URL mappings fixture does not
match " + WARM_URI);
+ }
+ if (!"catalog".equals(info.getControllerName()) ||
!"show".equals(info.getActionName())) {
+ throw new IllegalStateException("Unexpected mapping for " +
WARM_URI + ": " + info);
+ }
+ String reverse = holder.getReverseMapping("catalog", "show",
reverseParameters)
+ .createRelativeURL("catalog", "show", reverseParameters,
"UTF-8");
+ if (!EXPECTED_REVERSE_URL.equals(reverse)) {
+ throw new IllegalStateException("Unexpected reverse URL: " +
reverse);
+ }
+ }
+
+ @Benchmark
+ public UrlMappingInfo matchWarmCache() {
+ return holder.match(WARM_URI);
+ }
+
+ @Benchmark
+ public UrlMappingInfo matchColdVariedKeys() {
+ String uri = coldUris[coldUriIndex++ & (COLD_URI_COUNT - 1)];
+ return holder.match(uri);
+ }
+
+ @Benchmark
+ public String reverseMappingAndCreateRelativeUrl() {
+ UrlCreator creator = holder.getReverseMapping("catalog", "show",
reverseParameters);
+ return creator.createRelativeURL("catalog", "show", reverseParameters,
"UTF-8");
+ }
+}
diff --git
a/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/views/ViewTemplateRenderingBenchmark.java
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/views/ViewTemplateRenderingBenchmark.java
new file mode 100644
index 0000000000..ce134e7761
--- /dev/null
+++
b/grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/views/ViewTemplateRenderingBenchmark.java
@@ -0,0 +1,78 @@
+/*
+ * 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
+ *
+ * https://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.grails.benchmarks.views;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import groovy.text.Template;
+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.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Measures rendering of already-created JSON and markup view templates, which
is the per-request
+ * response path. Regressions here directly delay API and server-rendered view
responses.
+ */
+@State(Scope.Benchmark)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 2, jvmArgsAppend = {"-Xms1g", "-Xmx1g", "-XX:+UseG1GC"})
+public class ViewTemplateRenderingBenchmark {
+
+ private Template jsonTemplate;
+ private Template markupTemplate;
+ private Map<String, Object> jsonModel;
+ private Map<String, Object> markupModel;
+
+ @Setup
+ public void setup() {
+ jsonTemplate = ViewTemplateFixture.createJsonTemplate();
+ markupTemplate = ViewTemplateFixture.createMarkupTemplate();
+ jsonModel = Map.of("name", "Ada", "count", 42);
+ markupModel = Map.of("make", "Audi", "trim", "A5");
+ }
+
+ @Benchmark
+ public String renderJsonTemplate() throws IOException {
+ StringWriter writer = new StringWriter();
+ jsonTemplate.make(jsonModel).writeTo(writer);
+ return writer.toString();
+ }
+
+ @Benchmark
+ public String renderMarkupTemplate() throws IOException {
+ StringWriter writer = new StringWriter();
+ markupTemplate.make(markupModel).writeTo(writer);
+ return writer.toString();
+ }
+}
diff --git
a/grails-benchmarks/src/main/groovy/org/apache/grails/benchmarks/interceptors/InterceptorFixture.groovy
b/grails-benchmarks/src/main/groovy/org/apache/grails/benchmarks/interceptors/InterceptorFixture.groovy
new file mode 100644
index 0000000000..b0f14371ce
--- /dev/null
+++
b/grails-benchmarks/src/main/groovy/org/apache/grails/benchmarks/interceptors/InterceptorFixture.groovy
@@ -0,0 +1,31 @@
+/*
+ * 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
+ *
+ * https://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.grails.benchmarks.interceptors
+
+import grails.artefact.Interceptor
+
+class InterceptorFixture {
+
+ static Interceptor createInterceptor() {
+ new BenchmarkInterceptor()
+ }
+}
+
+class BenchmarkInterceptor implements Interceptor {
+}
diff --git
a/grails-benchmarks/src/main/groovy/org/apache/grails/benchmarks/urlmappings/UrlMappingsFixture.groovy
b/grails-benchmarks/src/main/groovy/org/apache/grails/benchmarks/urlmappings/UrlMappingsFixture.groovy
new file mode 100644
index 0000000000..d02dcb2bae
--- /dev/null
+++
b/grails-benchmarks/src/main/groovy/org/apache/grails/benchmarks/urlmappings/UrlMappingsFixture.groovy
@@ -0,0 +1,41 @@
+/*
+ * 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
+ *
+ * https://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.grails.benchmarks.urlmappings
+
+import grails.web.mapping.UrlMapping
+import org.grails.web.mapping.DefaultUrlMappingEvaluator
+import org.grails.web.mapping.DefaultUrlMappingsHolder
+
+class UrlMappingsFixture {
+
+ static DefaultUrlMappingsHolder createHolder() {
+ DefaultUrlMappingEvaluator evaluator = new
DefaultUrlMappingEvaluator(null)
+ // These MUST be double-quoted GStrings, exactly as a real
UrlMappings.groovy is written.
+ // The DSL captures variables by letting its delegate resolve
$category and friends; with
+ // single quotes they stay literal text, every mapping becomes a fixed
path, and match()
+ // silently returns null so the benchmark measures failed lookups.
+ List<UrlMapping> mappings = evaluator.evaluateMappings {
+ "/catalog/$category/$id"(controller: 'catalog', action: 'show')
+ "/catalog/search/$query"(controller: 'catalog', action: 'search')
+ "/articles/$year/$month?/$slug"(controller: 'article', action:
'show')
+ "/$controller/$action?/$id?"()
+ }
+ new DefaultUrlMappingsHolder(mappings)
+ }
+}
diff --git
a/grails-benchmarks/src/main/groovy/org/apache/grails/benchmarks/views/ViewTemplateFixture.groovy
b/grails-benchmarks/src/main/groovy/org/apache/grails/benchmarks/views/ViewTemplateFixture.groovy
new file mode 100644
index 0000000000..87d8bc9155
--- /dev/null
+++
b/grails-benchmarks/src/main/groovy/org/apache/grails/benchmarks/views/ViewTemplateFixture.groovy
@@ -0,0 +1,52 @@
+/*
+ * 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
+ *
+ * https://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.grails.benchmarks.views
+
+import grails.plugin.json.view.JsonViewTemplateEngine
+import grails.plugin.markup.view.MarkupViewTemplateEngine
+import groovy.text.Template
+
+class ViewTemplateFixture {
+
+ static Template createJsonTemplate() {
+ new JsonViewTemplateEngine().createTemplate('''
+model {
+ String name
+ Integer count
+}
+json {
+ name name
+ count count
+}
+''')
+ }
+
+ // A model property named `model` fails to compile: the generated template
extends
+ // groovy.text.markup.BaseTemplate, whose getModel() returns Map, so
`String model`
+ // becomes an incompatible override.
+ static Template createMarkupTemplate() {
+ new MarkupViewTemplateEngine().createTemplate('''
+model {
+ String make
+ String trim
+}
+car(make: make, trim: trim)
+''')
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index 1b218ba6ce..086f3a2437 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -106,6 +106,7 @@ def buildJdkSupportsMicronaut = Runtime.version().feature()
>= 25
def skipMicronautProjects = explicitlySkipMicronaut ||
(!buildJdkSupportsMicronaut && !explicitlyIncludeMicronaut)
include(
+ 'grails-benchmarks',
'grails-bootstrap',
'grails-cache',
'grails-codecs-core',