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![x](y)"
+        report = jmh_compare.render_report(compared([fixture(name, 100)], 
[fixture(name, 100)]))
+
+        self.assertNotIn("![", report)
+        self.assertNotIn("](", report)
+
+    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',

Reply via email to