This is an automated email from the ASF dual-hosted git repository.

benweidig pushed a commit to branch javax
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git

commit 4c18f846491bd75814e0d5ac5cd3efb9eaef0d48
Author: Ben Weidig <[email protected]>
AuthorDate: Sat Oct 10 12:32:56 2026 +0200

    TAP5-2839: Introduce JMH (#69)
    
    Adds JMH as a new Gradle convention and also a few sensible baseline 
benchmarks for things I might want to work on in the future.
    
    Renamed the IntegrationTestMutex to be reusable for JMH, as it also doesn't 
like running in parallel.
---
 BENCHMARKING.md                                    | 123 ++++++++++
 build.gradle                                       |  10 +
 buildSrc/README.md                                 |  19 +-
 .../groovy/t5build/IntegrationTestMutex.groovy     |  20 --
 buildSrc/src/main/groovy/t5build/Mutex.groovy      |  32 +++
 .../src/main/groovy/tapestry.jmh-convention.gradle | 236 ++++++++++++++++++
 .../main/groovy/tapestry.testng-convention.gradle  |   6 +-
 commons/build.gradle                               |   1 +
 .../benchmarks/AnonymousVsLambdaBenchmark.java     | 271 +++++++++++++++++++++
 .../benchmarks/CaseInsensitiveMapBenchmark.java    | 121 +++++++++
 .../commons/benchmarks/CoercionStyleBenchmark.java | 165 +++++++++++++
 .../commons/benchmarks/CoercionTupleCollector.java |  60 +++++
 .../commons/benchmarks/LambdaTypeCoercions.java    | 152 ++++++++++++
 .../benchmarks/MessageFormatterBenchmark.java      | 122 ++++++++++
 .../commons/benchmarks/MessagesBenchmark.java      | 210 ++++++++++++++++
 .../benchmarks/StringCoercionBenchmark.java        | 152 ++++++++++++
 .../commons/benchmarks/TypeCoercerBenchmark.java   | 129 ++++++++++
 gradle/libs.versions.toml                          |   9 +
 plastic/build.gradle                               |   1 +
 .../benchmarks/PlasticTransformationBenchmark.java | 133 ++++++++++
 .../subjects/BenchmarkSubject.java                 |  79 ++++++
 tapestry-core/build.gradle                         |   1 +
 .../benchmarks/DocumentRenderBenchmark.java        | 115 +++++++++
 .../benchmarks/MarkupEscapingBenchmark.java        | 155 ++++++++++++
 .../tapestry5/benchmarks/PageLoadBenchmark.java    | 247 +++++++++++++++++++
 .../tapestry5/benchmarks/URLEncoderBenchmark.java  | 107 ++++++++
 .../tapestry5/benchmarks/app/pages/RichPage.java   |  55 +++++
 .../tapestry5/benchmarks/app/pages/SimplePage.java |  28 +++
 tapestry-core/src/jmh/webapp/RichPage.tml          |  40 +++
 tapestry-core/src/jmh/webapp/SimplePage.tml        |  11 +
 tapestry-ioc/build.gradle                          |   1 +
 .../LocalizedNameGeneratorBenchmark.java           |  93 +++++++
 .../ioc/benchmarks/RegistryStartupBenchmark.java   | 124 ++++++++++
 .../ioc/benchmarks/ServiceProxyBenchmark.java      | 100 ++++++++
 .../ioc/benchmarks/services/BenchmarkModule.java   |  23 ++
 .../tapestry5/ioc/benchmarks/services/Greeter.java |  25 ++
 .../ioc/benchmarks/services/GreeterImpl.java       |  32 +++
 37 files changed, 3181 insertions(+), 27 deletions(-)

diff --git a/BENCHMARKING.md b/BENCHMARKING.md
new file mode 100644
index 000000000..0ef8f3bca
--- /dev/null
+++ b/BENCHMARKING.md
@@ -0,0 +1,123 @@
+<!--
+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.
+-->
+
+# Benchmarking Tapestry
+
+Tapestry provides a [JMH](https://github.com/openjdk/jmh) Gradle convention so 
that performance claims can be checked instead of argued about.
+They should live in a `src/jmh/java` source set in a module.
+
+Benchmarks are **not** part of `check` or `build`, as JMH might take minutes 
or even longer and has no place in CI.
+`./gradlew jmhCompileAll` compiles every benchmark source set, which is enough 
to keep them from rotting silently.
+
+## How to Run Benchmarks
+
+```bash
+./gradlew :tapestry-core:jmhList                                        # what 
is there
+./gradlew :tapestry-core:jmh                                            # all 
of it, properly (slow)
+./gradlew :commons:jmh -Pjmh.include=TypeCoercer                        # one 
group
+./gradlew :commons:jmh -Pjmh.include=Coercion -Pjmh.quick               # a 
rough number, fast
+./gradlew :tapestry-ioc:jmh -Pjmh.include=Localized -Pjmh.profilers=gc  # with 
allocation rates
+```
+
+Every option is documented at the top of 
[`buildSrc/src/main/groovy/tapestry.jmh-convention.gradle`](buildSrc/src/main/groovy/tapestry.jmh-convention.gradle).
+
+A benchmark measures the machine it runs on, so the `jmh` tasks hold a single 
build-service permit and run one at a time, even under a parallel build.
+`./gradlew jmh` across several modules therefore takes as long as all of them 
added up, instead of producing numbers measured while the other modules fought 
for the same cores.
+
+The two that matter most:
+
+- **`-Pjmh.quick`**\
+  One fork, three short warmup and three short measurement iterations.
+  Use this while iterating on a change.
+  Do not quote its numbers, and **do not** use it on the _cold/startup_ 
benchmarks!
+  Those measure a single shot in a fresh JVM, and forcing three measurement 
iterations warms two of the three. Shorten those with `-Pjmh.forks` instead.
+
+- **`-Pjmh.profilers=gc`**\
+  Allocation rate per operation.
+  If an operation runs _too fast_ (e.g., nanoseconds). allocation rate might 
be the primary concern.
+
+Results are written as JSON to `<module>/build/reports/jmh/results.json`, so a 
before and after run can be diffed.
+THe [JMH Visualizer](https://jmh.morethan.io) helps interpreting results, or 
your favorite AI can, too.
+
+## What is Measured, and Why
+
+Benchmarks should be grouped by which cost they belong to.
+
+Cold benchmarks run single-shot in fresh JVMs (interpreted, unlinked, 
JIT-free) because that is genuinely the code path a starting application takes.
+Their spread is wide, so read the min and max, not only the mean.
+
+### Startup and page loading
+
+| Benchmark                        | Module        | Question                  
                                                           |
+|----------------------------------|---------------|--------------------------------------------------------------------------------------|
+| `RegistryStartupBenchmark`       | tapestry-ioc  | What does building the 
IoC registry cost, and how much of it is eager instantiation? |
+| `PlasticTransformationBenchmark` | plastic       | What does one class 
transformation cost? One service proxy?                          |
+| `PageLoadBenchmark`              | tapestry-core | Registry startup, first 
page, second page, and warm re-assembly, separated.          |
+
+### Per request
+
+This is where a long-running application spends its time, and where most of 
the current benchmarks are pointed.
+Several of them are really about allocation rather than nanoseconds, so run 
them with
+`-Pjmh.profilers=gc`.
+
+| Benchmark                         | Module        | Question                 
                                                                             |
+|-----------------------------------|---------------|-------------------------------------------------------------------------------------------------------|
+| `TypeCoercerBenchmark`            | commons       | How much of a warm 
coercion is bookkeeping? Does the read lock scale?                              
   |
+| `StringCoercionBenchmark`         | commons       | What do the coercions a 
request actually performs cost, per target type?                              |
+| `CaseInsensitiveMapBenchmark`     | commons       | What does recomputing a 
case-insensitive hash per lookup cost?                                        |
+| `MessagesBenchmark`               | commons       | What does a message 
lookup cost, and what does adding arguments add?                                
  |
+| `ServiceProxyBenchmark`           | tapestry-ioc  | What does the service 
proxy add over a plain call?                                                    
|
+| `LocalizedNameGeneratorBenchmark` | tapestry-ioc  | What does a localized 
resource lookup allocate?                                                       
|
+| `URLEncoderBenchmark`             | tapestry-core | What does encoding an 
event context value cost when nothing needs encoding — and when something does? 
|
+| `DocumentRenderBenchmark`         | tapestry-core | Building the DOM against 
serialising it, over a page-sized tree.                                      |
+| `MarkupEscapingBenchmark`         | tapestry-core | Which escaper design in 
`AbstractMarkupModel` wins, on markup that escapes and markup that does not?  |
+
+### Anonymous classes against lambdas
+
+| Benchmark                    | Module  | Question                            
                                                                    |
+|------------------------------|---------|---------------------------------------------------------------------------------------------------------|
+| `CoercionStyleBenchmark`     | commons | The real refactoring, on real code: 
`BasicTypeCoercions` as written against a lambda translation of it. |
+| `AnonymousVsLambdaBenchmark` | commons | The same comparison isolated into 
invocation, creation and megamorphic dispatch.                        |
+
+The two styles differ in three ways that pull in opposite directions, which is 
why this is worth
+measuring rather than assuming:
+
+- **Allocation.**\
+  A non-capturing lambda is linked once and returned as a singleton.
+  A non-capturing anonymous class allocates every time it is evaluated.
+  A _capturing_ lambda allocates too, so the win exists only where the 
anonymous class captured nothing.
+
+- **Startup.**\
+  An anonymous class is one more class file to find, read, define and verify.
+  A lambda is an `invokedynamic` bootstrap that spins a hidden class on first 
execution.
+  That cost lands on exactly the paths that make startup and first page load 
slow.
+
+- **Steady state.**\
+  Both end up as a virtual call on an object. Expected to be a wash.
+  But `AnonymousVsLambdaBenchmark.invoke*` actually measures it.
+
+## Writing a new one
+
+Add `id 'tapestry.jmh-convention'` to the module's `plugins` block and put the 
benchmark in `src/jmh/java`.
+
+Two things worth copying from the existing benchmarks:
+
+- **Say what the benchmark is asking**, not what it calls.
+  A class comment that names the hypothesis and the number that would confirm 
or refute it is what makes a result usable six months later.
+
+- **Give every comparison a control.**
+  `AnonymousVsLambdaBenchmark.invokeAnonymous` is only meaningful next to 
`invokeLambda`.
+  `TypeCoercerBenchmark.alreadyAssignable` exists so the other coercion 
numbers have a floor to be read against.
diff --git a/build.gradle b/build.gradle
index a6a36661e..62f038c49 100755
--- a/build.gradle
+++ b/build.gradle
@@ -297,6 +297,16 @@ tasks.register('aggregateJavadoc', Javadoc) {
     }
 }
 
+// TAP5-2839: Add JMH
+tasks.register('jmhCompileAll') {
+    group = 'benchmark'
+    description = 'Compiles the JMH benchmark sources of every module that has 
them'
+
+    dependsOn provider {
+        rootProject.getTasksByName('jmhClasses', true)
+    }
+}
+
 tasks.register('typeScriptDocs') {
     group = 'documentation'
     description = 'Builds typedoc documentation for all TypeScript sources'
diff --git a/buildSrc/README.md b/buildSrc/README.md
index e09ca7200..ee11ca66d 100644
--- a/buildSrc/README.md
+++ b/buildSrc/README.md
@@ -142,15 +142,22 @@ The `testng.xml` suite file for these modules should 
contain **only** integratio
 Unit tests are handled by the `test` task via testng-engine by Jupiter and 
must not appear in `testng.xml`.
 
 The task **must** be named `testNG`: the convention gives every task with that 
name a permit on the
-`integrationTestMutex` build service, described below.
+`mutex` build service, described below.
 A differently named task gets no permit and will race the others for the ports.
 
-#### Serializing integration tests
+#### Serializing tasks that need the machine to themselves
 
 Every Selenium suite binds the same ports, so no two may run at once.
 Rather than disabling parallel builds (not a simple flag on project-level), 
the convention registers
-an `IntegrationTestMutex` build service with `maxParallelUsages = 1` and 
declares it on each `testNG` task.
-Gradle then admits one integration test task at a time while compilation, 
asset generation and the unit test suites still run in parallel.
+a `Mutex` build service with `maxParallelUsages = 1` and declares it on each 
`testNG` task.
+Gradle then admits one such task at a time while compilation, asset generation 
and the unit test suites still run in parallel.
+
+`tapestry.jmh-convention` registers the same service, under the same name, for 
its `jmh` task.
+A benchmark measures the machine it runs on, so a second benchmark or a 
Selenium suite running beside it does not merely slow the run down, it makes 
the numbers meaningless.
+Sharing one permit across both conventions keeps benchmarks apart from each 
other **and** from the integration tests.
+
+The permit only holds back tasks that ask for it, so a compilation or a unit 
test suite can still land next to a benchmark.
+Use `--max-workers=1` when a run has to be quiet.
 
 ---
 
@@ -158,3 +165,7 @@ Gradle then admits one integration test task at a time 
while compilation, asset
 
 **`GenerateChecksums`**: custom Gradle task type that generates MD5/SHA-256 
checksum
 files for release archives.
+
+**`Mutex`**: build service without behaviour, registered with one permit so 
that the tasks
+declaring it run one at a time — the Selenium integration tests, which share 
fixed ports, and
+the JMH benchmarks, which share the machine.
diff --git a/buildSrc/src/main/groovy/t5build/IntegrationTestMutex.groovy 
b/buildSrc/src/main/groovy/t5build/IntegrationTestMutex.groovy
deleted file mode 100644
index 245bcc743..000000000
--- a/buildSrc/src/main/groovy/t5build/IntegrationTestMutex.groovy
+++ /dev/null
@@ -1,20 +0,0 @@
-package t5build
-
-import org.gradle.api.services.BuildService
-import org.gradle.api.services.BuildServiceParameters
-
-/**
- * A build service with no behaviour, used purely as a mutex.
- *
- * <p>Selenium integration tests bind fixed ports (9090/8443 by default, see
- * {@code @TapestryTestConfiguration}), so two modules testing at the same 
time fight over one
- * socket and the loser dies with "Failed to bind".
- * <p>Registering this service with {@code maxParallelUsages = 1} and 
declaring it on
- * the integration test tasks tells Gradle to allow only one of them at a 
time, whereas
- * compilation, asset generation and the unit test suites carry on in parallel.
- *
- * @see tapestry.testng-convention.gradle
- */
-abstract class IntegrationTestMutex implements 
BuildService<BuildServiceParameters.None>
-{
-}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/t5build/Mutex.groovy 
b/buildSrc/src/main/groovy/t5build/Mutex.groovy
new file mode 100644
index 000000000..b55a6078c
--- /dev/null
+++ b/buildSrc/src/main/groovy/t5build/Mutex.groovy
@@ -0,0 +1,32 @@
+package t5build
+
+import org.gradle.api.services.BuildService
+import org.gradle.api.services.BuildServiceParameters
+
+/**
+ * A build service with no behaviour, used purely as a mutex.
+ *
+ * <p>Registered with {@code maxParallelUsages = 1} and declared on a task with
+ * {@code usesService}, it makes Gradle admit one such task at a time, whereas 
compilation,
+ * asset generation and the unit test suites carry on in parallel.
+ *
+ * <p>Two kinds of task need this, and they share the single permit:
+ *
+ * <ul>
+ * <li><b>Selenium integration tests</b> bind fixed ports (9090/8443 by 
default, see
+ * {@code @TapestryTestConfiguration}), so two modules testing at the same 
time fight over one
+ * socket and the loser dies with "Failed to bind".</li>
+ * <li><b>JMH benchmarks</b> measure the machine they run on. A second 
benchmark task, or a
+ * Selenium suite, running beside one competes for the same cores and makes 
the numbers
+ * meaningless rather than merely slow.</li>
+ * </ul>
+ *
+ * <p>The permit only holds back tasks that ask for it. It cannot keep a 
compilation or a unit
+ * test suite off the machine while a benchmark runs; {@code --max-workers=1} 
does that.
+ *
+ * @see tapestry.testng-convention.gradle
+ * @see tapestry.jmh-convention.gradle
+ */
+abstract class Mutex implements BuildService<BuildServiceParameters.None>
+{
+}
diff --git a/buildSrc/src/main/groovy/tapestry.jmh-convention.gradle 
b/buildSrc/src/main/groovy/tapestry.jmh-convention.gradle
new file mode 100644
index 000000000..d7fbd96e0
--- /dev/null
+++ b/buildSrc/src/main/groovy/tapestry.jmh-convention.gradle
@@ -0,0 +1,236 @@
+// Use for modules that carry JMH microbenchmarks under src/jmh/java.
+//
+// Adds a 'jmh' source set that sees the module's own classes and its compile
+// dependencies (including the 'provided' configuration), wires the JMH 
annotation
+// processor that generates the benchmark harness, and registers two tasks:
+//
+//   ./gradlew :tapestry-core:jmhList     lists the benchmarks of the module
+//   ./gradlew :tapestry-core:jmh         runs them
+//
+// Benchmarks are deliberately NOT part of 'check' or 'build': a JMH run takes
+// minutes or longer and has no place in CI.
+//
+// A benchmark measures the machine it runs on, so the 'jmh' tasks hold a 
one-permit
+// build service (t5build.Mutex) and run one at a time. './gradlew jmh' over 
several
+// modules therefore takes as long as the sum of them, rather than producing 
numbers
+// taken while the other modules fought for the cores.
+//
+// `./gradlew jmhCompileAll` (root project) compiles every benchmark source set
+// so they cannot silently rot.
+//
+// Run options, all passed as -P properties:
+//
+//   -Pjmh.include=Regex     benchmarks to run, matched against the fully 
qualified
+//                           method name (default: all of them)
+//   -Pjmh.exclude=Regex     benchmarks to skip
+//   -Pjmh.quick             one fork, 3x1s warmup, 3x1s measurement. Good 
enough to
+//                           see a 2x difference, NOT good enough to publish. 
Do not use it
+//                           on the cold/startup benchmarks: those measure a 
single shot in a
+//                           fresh JVM, and forcing three measurement 
iterations means two of
+//                           the three run against an already-warmed JVM. 
Override -Pjmh.forks
+//                           on its own instead. Any of the properties below 
overrides the
+//                           matching -Pjmh.quick value, the rest of it stays 
in effect.
+//   -Pjmh.forks=N           JVM forks per benchmark (JMH default: 5)
+//   -Pjmh.warmups=N         warmup iterations per fork
+//   -Pjmh.iterations=N      measurement iterations per fork
+//   -Pjmh.warmupTime=1s     duration of a warmup iteration
+//   -Pjmh.time=1s           duration of a measurement iteration
+//   -Pjmh.threads=N         threads per benchmark ('max' for one per core)
+//   -Pjmh.mode=avgt         benchmark mode override (thrpt|avgt|sample|ss|all)
+//   -Pjmh.profilers=gc,stack   comma-separated JMH profilers; 'gc' reports the
+//                           allocation rate, which is what most Tapestry
+//                           benchmarks are actually about
+//   -Pjmh.jvmArgs=-Xmx2g    extra JVM arguments for the forked JVMs
+//   -Pjmh.args=...          raw arguments appended verbatim, for anything 
above
+//                           that this convention does not expose
+//   -Pjmh.resultFile=path   where to write the JSON results, relative to the 
root project
+//                           instead of the module's build directory. Use it 
to keep a
+//                           before/after pair side by side, see 
benchmark-results/README.md
+//
+// Results are always written as JSON to build/reports/jmh/results.json, so two
+// runs can be diffed (for instance with https://jmh.morethan.io/).
+//
+// Example, comparing anonymous classes against lambdas with allocation 
numbers:
+//
+//   ./gradlew :tapestry-ioc:jmh -Pjmh.include=Lambda -Pjmh.profilers=gc
+
+import t5build.Mutex
+
+plugins {
+    id 'tapestry.java-convention'
+}
+
+sourceSets {
+    jmh {
+        compileClasspath += sourceSets.main.output + configurations.provided
+        runtimeClasspath += sourceSets.main.output + configurations.provided
+    }
+}
+
+configurations {
+    // Benchmarks need everything the module itself needs
+    jmhImplementation.extendsFrom api, implementation
+    jmhRuntimeOnly.extendsFrom runtimeOnly
+}
+
+dependencies {
+    jmhImplementation libs.jmh.core
+    jmhAnnotationProcessor libs.jmh.core
+    jmhAnnotationProcessor libs.jmh.generator.annprocess
+
+    // Benchmarks that boot an IoC registry log during setup.
+    // Without a binding SLF4J prints its warning into the middle of the 
measurement output.
+    jmhRuntimeOnly libs.slf4j.simple
+}
+
+idea.module.testSources.from(sourceSets.jmh.java.srcDirs)
+eclipse.classpath.plusConfigurations += [configurations.jmhRuntimeClasspath]
+
+def jmhResults = layout.buildDirectory.file('reports/jmh/results.json')
+
+// Everything below translates the -P properties documented above into JMH 
command line
+// arguments, one closure per group of arguments.
+
+// The value of -Pjmh.<name>, trimmed, or null when the property is unset or 
blank.
+def jmhProperty = { String name ->
+    project.findProperty("jmh.${name}")?.toString()?.trim() ?: null
+}
+
+// The JMH flag of every property that carries a single value, in the order 
they are passed.
+def valueFlags = [
+        forks     : '-f',
+        warmups   : '-wi',
+        iterations: '-i',
+        warmupTime: '-w',
+        time      : '-r',
+        threads   : '-t',
+        mode      : '-bm']
+
+// What -Pjmh.quick stands for: "I am iterating on an optimization and want a 
number now".
+def quickValues = [
+        forks     : '1',
+        warmups   : '3',
+        iterations: '3',
+        warmupTime: '1s',
+        time      : '1s']
+
+// Which benchmarks to touch.
+// JMH takes the include pattern as its positional argument.
+def selectionArguments = {
+
+    def args = [jmhProperty('include') ?: '.*']
+
+    def exclude = jmhProperty('exclude')
+    if (exclude)
+    {
+        args += ['-e', exclude]
+    }
+
+    return args
+}
+
+// How to run them.
+// JMH refuses a flag it sees twice ("Found multiple arguments for option f"),
+// so an explicit property has to replace its -Pjmh.quick counterpart rather 
than follow it.
+def measurementArguments = {
+
+    def quick = project.hasProperty('jmh.quick')
+    def args = []
+
+    valueFlags.each { property, flag ->
+        def value = jmhProperty(property) ?: (quick ? quickValues[property] : 
null)
+        if (value)
+        {
+            args += [flag, value]
+        }
+    }
+
+    jmhProperty('profilers')?.split(',')?.each { profiler ->
+        args += ['-prof', profiler.trim()]
+    }
+
+    return args
+}
+
+// Benchmarks that boot a registry or load a page log a line per operation at 
INFO, which
+// would be measured along with the work and would drown the results.
+// JMH splits a single -jvmArgsAppend on spaces, so everything has to go into 
one value.
+def jvmArguments = {
+
+    def jvmArgs = ['-Dorg.slf4j.simpleLogger.defaultLogLevel=warn']
+
+    def extraJvmArgs = jmhProperty('jvmArgs')
+    if (extraJvmArgs)
+    {
+        jvmArgs << extraJvmArgs
+    }
+
+    return ['-jvmArgsAppend', jvmArgs.join(' ')]
+}
+
+// Where the JSON results go: the module's own build directory unless 
-Pjmh.resultFile
+// points somewhere else, relative to the root project.
+def jmhResultFile = {
+
+    def override = jmhProperty('resultFile')
+
+    return override ? project.rootProject.file(override) : 
jmhResults.get().asFile
+}
+
+// -Pjmh.args, appended verbatim for anything this convention does not expose.
+def rawArguments = {
+    jmhProperty('args')?.split('\\s+')?.toList() ?: []
+}
+
+def jmhListArguments = {
+    selectionArguments() + ['-l']
+}
+
+def jmhRunArguments = { File resultFile ->
+    selectionArguments() +
+            measurementArguments() +
+            jvmArguments() +
+            ['-rf', 'json', '-rff', resultFile.absolutePath] +
+            rawArguments()
+}
+
+// A benchmark has the machine to itself, or it measures the rest of the build 
instead.
+def mutex = gradle.sharedServices.registerIfAbsent('mutex', Mutex) {
+    maxParallelUsages = 1
+}
+
+tasks.register('jmh', JavaExec) {
+    group = 'benchmark'
+    description = 'Runs this module\'s JMH benchmarks. Use -Pjmh.include=Regex 
to pick some, -Pjmh.quick for a rough number.'
+
+    mainClass = 'org.openjdk.jmh.Main'
+    classpath = sourceSets.jmh.runtimeClasspath
+
+    usesService(mutex)
+
+    // A benchmark result is never up to date: the point of re-running it is 
that
+    // the machine, the JDK or the code under measurement may have changed.
+    outputs.upToDateWhen { false }
+
+    doFirst {
+        def resultFile = jmhResultFile()
+        resultFile.parentFile.mkdirs()
+
+        args = jmhRunArguments(resultFile)
+        logger.lifecycle "JMH: ${args.join(' ')}"
+    }
+}
+
+tasks.register('jmhList', JavaExec) {
+    group = 'benchmark'
+    description = 'Lists the JMH benchmarks of this module without running 
them.'
+
+    mainClass = 'org.openjdk.jmh.Main'
+    classpath = sourceSets.jmh.runtimeClasspath
+
+    outputs.upToDateWhen { false }
+
+    doFirst {
+        args = jmhListArguments()
+    }
+}
diff --git a/buildSrc/src/main/groovy/tapestry.testng-convention.gradle 
b/buildSrc/src/main/groovy/tapestry.testng-convention.gradle
index 70d0c7636..f568a0770 100644
--- a/buildSrc/src/main/groovy/tapestry.testng-convention.gradle
+++ b/buildSrc/src/main/groovy/tapestry.testng-convention.gradle
@@ -14,7 +14,7 @@
 //   tapestry.testing-base-convention       platform runner, BOM, logging
 //     └─ tapestry.testng-convention          + TestNG + EasyMock + 
testng-engine (this file)
 
-import t5build.IntegrationTestMutex
+import t5build.Mutex
 
 plugins {
     id 'tapestry.testing-base-convention'
@@ -29,12 +29,12 @@ dependencies {
 // Every Selenium suite binds the same ports, so no two of them may run at 
once.
 // Gradle's build services express exactly that: one permit, handed to one 
integration
 // test task at a time, while everything else in the build still runs in 
parallel.
-def integrationTestMutex = 
gradle.sharedServices.registerIfAbsent('integrationTestMutex', 
IntegrationTestMutex) {
+def mutex = gradle.sharedServices.registerIfAbsent('mutex', Mutex) {
     maxParallelUsages = 1
 }
 
 tasks.withType(Test).configureEach {
     if (name == 'testNG') {
-        usesService(integrationTestMutex)
+        usesService(mutex)
     }
 }
diff --git a/commons/build.gradle b/commons/build.gradle
index ecc9bf194..6cdf655fd 100644
--- a/commons/build.gradle
+++ b/commons/build.gradle
@@ -1,5 +1,6 @@
 plugins {
     id 'tapestry.junit5-spock-convention'
+    id 'tapestry.jmh-convention'
 }
 
 description = 'Project including common classes for tapestry-core, 
tapestry-ioc and beanmodel.'
diff --git 
a/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/AnonymousVsLambdaBenchmark.java
 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/AnonymousVsLambdaBenchmark.java
new file mode 100644
index 000000000..e4ad9d0e9
--- /dev/null
+++ 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/AnonymousVsLambdaBenchmark.java
@@ -0,0 +1,271 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.commons.benchmarks;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.func.Mapper;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+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.infra.Blackhole;
+
+/**
+ * The controlled counterpart to {@link CoercionStyleBenchmark}: instead of 
one real contribution
+ * method, this isolates the three things that actually differ between an 
anonymous inner class
+ * and a lambda.
+ *
+ * <ol>
+ * <li><b>Invocation</b> of an already-created instance.</li>
+ * <li><b>Creation</b>. A non-capturing lambda is linked once and handed back 
as a singleton
+ * forever after. A non-capturing anonymous class allocates every time it is 
evaluated. A capturing
+ * lambda allocates too, so the win exists only where the anonymous class 
captured nothing.
+ * Run with {@code -Pjmh.profilers=gc}; the allocation rate is the result 
here, not the time.</li>
+ * <li><b>Call-site shape</b>. Tapestry rarely calls one implementation 
through one site. It calls
+ * a contributed list of them — coercions, render commands, page-assembly 
actions — through a
+ * single megamorphic site, where the JIT can no longer inline the target. If 
the two styles differ
+ * anywhere non-obviously, it is here.</li>
+ * </ol>
+ *
+ * <pre>
+ * ./gradlew :commons:jmh -Pjmh.include=AnonymousVsLambda -Pjmh.profilers=gc
+ * </pre>
+ */
+@State(Scope.Thread)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+public class AnonymousVsLambdaBenchmark
+{
+    // Non-constant, so the JIT cannot fold the results away
+    private int input;
+
+    private Mapper<Integer, Integer> anonymous;
+    private Mapper<Integer, Integer> lambda;
+    private Mapper<Integer, Integer> methodReference;
+
+    private Mapper<Integer, Integer>[] anonymousMegamorphic;
+    private Mapper<Integer, Integer>[] lambdaMegamorphic;
+
+    @Setup
+    public void setup()
+    {
+        input = 42;
+
+        anonymous = new Mapper<Integer, Integer>()
+        {
+            @Override
+            public Integer map(Integer element)
+            {
+                return element + 1;
+            }
+        };
+
+        lambda = element -> element + 1;
+
+        methodReference = AnonymousVsLambdaBenchmark::increment;
+
+        anonymousMegamorphic = anonymousMappers();
+        lambdaMegamorphic = lambdaMappers();
+    }
+
+    private static Integer increment(Integer element)
+    {
+        return element + 1;
+    }
+
+    // 
---------------------------------------------------------------------------------------
+    // 1. Invocation of an existing instance, monomorphic call site.
+    // 
---------------------------------------------------------------------------------------
+
+    @Benchmark
+    public Integer invokeAnonymous()
+    {
+        return anonymous.map(input);
+    }
+
+    @Benchmark
+    public Integer invokeLambda()
+    {
+        return lambda.map(input);
+    }
+
+    @Benchmark
+    public Integer invokeMethodReference()
+    {
+        return methodReference.map(input);
+    }
+
+    // 
---------------------------------------------------------------------------------------
+    // 2. Creation. The point of interest is allocation, so read these with 
-Pjmh.profilers=gc.
+    // 
---------------------------------------------------------------------------------------
+
+    @Benchmark
+    public Mapper<Integer, Integer> createAnonymousNonCapturing()
+    {
+        return new Mapper<Integer, Integer>()
+        {
+            @Override
+            public Integer map(Integer element)
+            {
+                return element + 1;
+            }
+        };
+    }
+
+    /**
+     * Expected to allocate nothing at all: the JVM hands back the same linked 
instance
+     */
+    @Benchmark
+    public Mapper<Integer, Integer> createLambdaNonCapturing()
+    {
+        return element -> element + 1;
+    }
+
+    @Benchmark
+    public Mapper<Integer, Integer> createAnonymousCapturing()
+    {
+        final int captured = input;
+
+        return new Mapper<Integer, Integer>()
+        {
+            @Override
+            public Integer map(Integer element)
+            {
+                return element + captured;
+            }
+        };
+    }
+
+    /**
+     * Capturing removes the singleton advantage: this allocates, like the 
anonymous class does
+     */
+    @Benchmark
+    public Mapper<Integer, Integer> createLambdaCapturing()
+    {
+        final int captured = input;
+
+        return element -> element + captured;
+    }
+
+    // 
---------------------------------------------------------------------------------------
+    // 3. Megamorphic dispatch, the shape Tapestry's contributed collections 
actually have.
+    // 
---------------------------------------------------------------------------------------
+
+    @Benchmark
+    public void invokeMegamorphicAnonymous(Blackhole blackhole)
+    {
+        for (Mapper<Integer, Integer> mapper : anonymousMegamorphic)
+        {
+            blackhole.consume(mapper.map(input));
+        }
+    }
+
+    @Benchmark
+    public void invokeMegamorphicLambda(Blackhole blackhole)
+    {
+        for (Mapper<Integer, Integer> mapper : lambdaMegamorphic)
+        {
+            blackhole.consume(mapper.map(input));
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Mapper<Integer, Integer>[] anonymousMappers()
+    {
+        return new Mapper[]{
+                new Mapper<Integer, Integer>()
+                {
+                    @Override
+                    public Integer map(Integer element)
+                    {
+                        return element + 1;
+                    }
+                },
+                new Mapper<Integer, Integer>()
+                {
+                    @Override
+                    public Integer map(Integer element)
+                    {
+                        return element - 1;
+                    }
+                },
+                new Mapper<Integer, Integer>()
+                {
+                    @Override
+                    public Integer map(Integer element)
+                    {
+                        return element * 2;
+                    }
+                },
+                new Mapper<Integer, Integer>()
+                {
+                    @Override
+                    public Integer map(Integer element)
+                    {
+                        return element / 2;
+                    }
+                },
+                new Mapper<Integer, Integer>()
+                {
+                    @Override
+                    public Integer map(Integer element)
+                    {
+                        return element ^ 0x5f;
+                    }
+                },
+                new Mapper<Integer, Integer>()
+                {
+                    @Override
+                    public Integer map(Integer element)
+                    {
+                        return -element;
+                    }
+                },
+                new Mapper<Integer, Integer>()
+                {
+                    @Override
+                    public Integer map(Integer element)
+                    {
+                        return element % 7;
+                    }
+                },
+                new Mapper<Integer, Integer>()
+                {
+                    @Override
+                    public Integer map(Integer element)
+                    {
+                        return Integer.reverse(element);
+                    }
+                }
+        };
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Mapper<Integer, Integer>[] lambdaMappers()
+    {
+        return new Mapper[]{
+                (Mapper<Integer, Integer>) element -> element + 1,
+                (Mapper<Integer, Integer>) element -> element - 1,
+                (Mapper<Integer, Integer>) element -> element * 2,
+                (Mapper<Integer, Integer>) element -> element / 2,
+                (Mapper<Integer, Integer>) element -> element ^ 0x5f,
+                (Mapper<Integer, Integer>) element -> -element,
+                (Mapper<Integer, Integer>) element -> element % 7,
+                (Mapper<Integer, Integer>) Integer::reverse
+        };
+    }
+}
diff --git 
a/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/CaseInsensitiveMapBenchmark.java
 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/CaseInsensitiveMapBenchmark.java
new file mode 100644
index 000000000..3d9dbe247
--- /dev/null
+++ 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/CaseInsensitiveMapBenchmark.java
@@ -0,0 +1,121 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.commons.benchmarks;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.commons.util.CaseInsensitiveMap;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+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;
+
+/**
+ * {@link CaseInsensitiveMap}, which carries request parameters, headers, 
component parameters and
+ * every string-to-enum coercion in the framework through {@code 
StringToEnumCoercion}.
+ *
+ * It does not lower-case keys and cache the result. Instead every lookup 
walks the key character
+ * by character through {@code Character.toLowerCase} to compute a 
case-insensitive hash, then
+ * binary-searches and confirms with {@code equalsIgnoreCase}. That trades 
allocation for
+ * repeated work, which is the right trade for a map written once and read 
once, and the wrong
+ * one for a map read on every request with the same handful of keys.
+ *
+ * The {@link HashMap} benchmarks are the control. They are not a fair 
substitute, as they are
+ * case-sensitive, which is the whole point of the class. But they establish 
what the lookup
+ * would cost without the case handling, and therefore what the 
case-insensitivity is being paid
+ * for.
+ *
+ * Key length matters here in a way it does not for a {@code HashMap}, whose 
hash is cached on the
+ * {@link String}. {@link #getLongKey()} against {@link #getShortKey()} shows 
how steeply.
+ *
+ * <pre>
+ * ./gradlew :commons:jmh -Pjmh.include=CaseInsensitiveMap
+ * </pre>
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+public class CaseInsensitiveMapBenchmark
+{
+    private static final String SHORT_KEY = "id";
+
+    private static final String LONG_KEY = 
"content-security-policy-report-only";
+
+    private static final String MIXED_CASE_KEY = "Content-Type";
+
+    private Map<String, String> caseInsensitive;
+
+    private Map<String, String> hashMap;
+
+    @SuppressWarnings("unchecked")
+    @Setup
+    public void setup()
+    {
+        caseInsensitive = new CaseInsensitiveMap<>();
+        hashMap = new HashMap<>();
+
+        // Roughly the size of a request's header or parameter map.
+        for (Map<String, String> map : new Map[]{ caseInsensitive, hashMap })
+        {
+            map.put(SHORT_KEY, "value");
+            map.put(LONG_KEY, "value");
+            map.put(MIXED_CASE_KEY, "text/html");
+            map.put("accept", "*/*");
+            map.put("accept-encoding", "gzip");
+            map.put("user-agent", "benchmark");
+            map.put("host", "localhost");
+            map.put("connection", "keep-alive");
+        }
+    }
+
+    @Benchmark
+    public String getShortKey()
+    {
+        return caseInsensitive.get(SHORT_KEY);
+    }
+
+    @Benchmark
+    public String getLongKey()
+    {
+        return caseInsensitive.get(LONG_KEY);
+    }
+
+    @Benchmark
+    public String getMixedCaseKey()
+    {
+        return caseInsensitive.get("content-type");
+    }
+
+    @Benchmark
+    public String getMissingKey()
+    {
+        return caseInsensitive.get("x-not-present");
+    }
+
+    @Benchmark
+    public String hashMapShortKey()
+    {
+        return hashMap.get(SHORT_KEY);
+    }
+
+    @Benchmark
+    public String hashMapLongKey()
+    {
+        return hashMap.get(LONG_KEY);
+    }
+}
diff --git 
a/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/CoercionStyleBenchmark.java
 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/CoercionStyleBenchmark.java
new file mode 100644
index 000000000..70f13a2f1
--- /dev/null
+++ 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/CoercionStyleBenchmark.java
@@ -0,0 +1,165 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.commons.benchmarks;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.commons.internal.BasicTypeCoercions;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+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.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * Anonymous inner classes against lambdas, on real Tapestry code rather than 
a synthetic fixture.
+ *
+ * {@link BasicTypeCoercions#provideBasicTypeCoercions} contributes over two 
dozens of coercions
+ * as anonymous inner classes, and {@link LambdaTypeCoercions} is the same 
method written with
+ * lambdas and method references.
+ *
+ * The two styles differ in three ways that pull in opposite directions, which 
is the whole reason
+ * to measure rather than assume:
+ *
+ * <dl>
+ * <dt>Cold</dt>
+ * <dd>An anonymous class costs one class file to find, read, define and 
verify. A lambda costs an
+ * {@code invokedynamic} bootstrap, which spins a hidden class through {@code 
LambdaMetafactory} on
+ * first execution. Historically the bootstrap is the more expensive of the 
two, and it is paid on
+ * exactly the paths that make startup and first page load feel slow. Hence 
{@code cold*} below.</dd>
+ *
+ * <dt>Warm</dt>
+ * <dd>Once linked, a non-capturing lambda is a singleton: re-running the 
contribution method
+ * allocates nothing for it. A non-capturing anonymous class allocates a fresh 
instance every time.
+ * Run with {@code -Pjmh.profilers=gc} to see that as an allocation rate, 
which is the number that
+ * matters here, not the nanoseconds.</dd>
+ *
+ * <dt>Footprint</dt>
+ * <dd>Not measurable from inside the JVM: ~27 fewer class files in the jar, 
against the same amount
+ * of hidden classes created at runtime. Compare {@code build/classes} 
directory sizes for that.</dd>
+ * </dl>
+ *
+ * <pre>
+ * ./gradlew :commons:jmh -Pjmh.include=CoercionStyle -Pjmh.profilers=gc
+ * </pre>
+ */
+@State(Scope.Thread)
+public class CoercionStyleBenchmark
+{
+    private CoercionTupleCollector collector;
+
+    @Setup(Level.Iteration)
+    public void reset()
+    {
+        collector = new CoercionTupleCollector();
+    }
+
+    // 
---------------------------------------------------------------------------------------
+    // Cold: one measured invocation in a JVM that has never executed this 
code.
+    //
+    // Twenty forks because a single shot of interpreted, unlinked code is 
noisy.
+    // Treat the spread as seriously as the mean.
+    // JMH forks each benchmark method separately, so coldAnonymousClasses and 
coldLambdas
+    // never share a JVM.
+    // 
---------------------------------------------------------------------------------------
+
+    @Benchmark
+    @BenchmarkMode(Mode.SingleShotTime)
+    @OutputTimeUnit(TimeUnit.MICROSECONDS)
+    @Fork(value = 20, warmups = 0)
+    @Warmup(iterations = 0)
+    @Measurement(iterations = 1)
+    public Object coldAnonymousClasses()
+    {
+        BasicTypeCoercions.provideBasicTypeCoercions(collector);
+        return collector.getTuples();
+    }
+
+    @Benchmark
+    @BenchmarkMode(Mode.SingleShotTime)
+    @OutputTimeUnit(TimeUnit.MICROSECONDS)
+    @Fork(value = 20, warmups = 0)
+    @Warmup(iterations = 0)
+    @Measurement(iterations = 1)
+    public Object coldLambdas()
+    {
+        LambdaTypeCoercions.provideBasicTypeCoercions(collector);
+        return collector.getTuples();
+    }
+
+    /**
+     * Same as {@link #coldLambdas()}, but executing a throwaway lambda first.
+     *
+     * This was written to separate two costs: bootstrapping the method-handle 
machinery, which
+     * the first lambda in a JVM pays for once, from linking an individual 
call site, which every
+     * lambda pays. It does not succeed, and the reason is worth keeping 
rather than deleting the
+     * benchmark: JMH's own harness executes lambdas before it ever calls a 
benchmark method, so
+     * {@code LambdaMetafactory} is already initialised in {@link 
#coldLambdas()} too. The two
+     * scores come out within noise of each other.
+     *
+     * What that leaves is still the number that matters. Both measure the 
same amount of call
+     * sites linking in a JVM whose method-handle machinery is already up, 
which is exactly the
+     * situation a real application is in by the time its registry starts. 
Isolating the one-time
+     * initialisation would need a harness that does not itself use lambdas, 
and it would answer
+     * a question nobody is asking.
+     */
+    @Benchmark
+    @BenchmarkMode(Mode.SingleShotTime)
+    @OutputTimeUnit(TimeUnit.MICROSECONDS)
+    @Fork(value = 20, warmups = 0)
+    @Warmup(iterations = 0)
+    @Measurement(iterations = 1)
+    public Object coldLambdasMetafactoryPrimed(Blackhole blackhole)
+    {
+        blackhole.consume(primeMetafactory());
+
+        LambdaTypeCoercions.provideBasicTypeCoercions(collector);
+        return collector.getTuples();
+    }
+
+    private static Runnable primeMetafactory()
+    {
+        return () -> { };
+    }
+
+    // 
---------------------------------------------------------------------------------------
+    // Warm: steady state, where the interesting axis is allocation rather 
than time.
+    // 
---------------------------------------------------------------------------------------
+
+    @Benchmark
+    @BenchmarkMode(Mode.AverageTime)
+    @OutputTimeUnit(TimeUnit.MICROSECONDS)
+    public Object warmAnonymousClasses()
+    {
+        CoercionTupleCollector fresh = new CoercionTupleCollector();
+        BasicTypeCoercions.provideBasicTypeCoercions(fresh);
+        return fresh.getTuples();
+    }
+
+    @Benchmark
+    @BenchmarkMode(Mode.AverageTime)
+    @OutputTimeUnit(TimeUnit.MICROSECONDS)
+    public Object warmLambdas()
+    {
+        CoercionTupleCollector fresh = new CoercionTupleCollector();
+        LambdaTypeCoercions.provideBasicTypeCoercions(fresh);
+        return fresh.getTuples();
+    }
+}
diff --git 
a/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/CoercionTupleCollector.java
 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/CoercionTupleCollector.java
new file mode 100644
index 000000000..b79bd5e42
--- /dev/null
+++ 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/CoercionTupleCollector.java
@@ -0,0 +1,60 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.commons.benchmarks;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.tapestry5.commons.MappedConfiguration;
+import org.apache.tapestry5.commons.services.CoercionTuple;
+
+/**
+ * Collects coercion contributions into a plain map, so that benchmarks can 
build a
+ * {@link org.apache.tapestry5.commons.internal.services.TypeCoercerImpl} 
without an IoC registry.
+ *
+ * Mirrors BeanModelSourceBuilder.CoercionTupleConfiguration, which is private.
+ */
+@SuppressWarnings("rawtypes")
+public class CoercionTupleCollector implements 
MappedConfiguration<CoercionTuple.Key, CoercionTuple>
+{
+    private final Map<CoercionTuple.Key, CoercionTuple> tuples = new 
HashMap<>();
+
+    @Override
+    public void add(CoercionTuple.Key key, CoercionTuple value)
+    {
+        tuples.put(key, value);
+    }
+
+    @Override
+    public void override(CoercionTuple.Key key, CoercionTuple value)
+    {
+        tuples.put(key, value);
+    }
+
+    @Override
+    public void addInstance(CoercionTuple.Key key, Class<? extends 
CoercionTuple> clazz)
+    {
+        throw new UnsupportedOperationException("Not needed by benchmarks.");
+    }
+
+    @Override
+    public void overrideInstance(CoercionTuple.Key key, Class<? extends 
CoercionTuple> clazz)
+    {
+        throw new UnsupportedOperationException("Not needed by benchmarks.");
+    }
+
+    public Map<CoercionTuple.Key, CoercionTuple> getTuples()
+    {
+        return tuples;
+    }
+}
diff --git 
a/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/LambdaTypeCoercions.java
 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/LambdaTypeCoercions.java
new file mode 100644
index 000000000..a2d3ef8ff
--- /dev/null
+++ 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/LambdaTypeCoercions.java
@@ -0,0 +1,152 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.commons.benchmarks;
+
+import java.io.File;
+import java.lang.reflect.Array;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.tapestry5.commons.MappedConfiguration;
+import org.apache.tapestry5.commons.internal.BasicTypeCoercions;
+import org.apache.tapestry5.commons.services.Coercion;
+import org.apache.tapestry5.commons.services.CoercionTuple;
+import org.apache.tapestry5.commons.util.TimeInterval;
+import org.apache.tapestry5.func.Flow;
+
+/**
+ * A lambda-for-lambda translation of
+ * {@link BasicTypeCoercions#provideBasicTypeCoercions(MappedConfiguration)}, 
which contributes
+ * 27 coercions as anonymous inner classes.
+ *
+ * The two are behaviourally identical; what differs is how the JVM 
materialises the
+ * {@link Coercion} instances. The anonymous version loads one class file per 
coercion. This
+ * version emits one {@code invokedynamic} call site per coercion, each of 
which spins a hidden
+ * class through {@code LambdaMetafactory} the first time it is reached.
+ *
+ * {@link CoercionStyleBenchmark} measures the difference. Keep this file in 
sync with
+ * {@code BasicTypeCoercions} when coercions are added or removed there, or 
the comparison
+ * stops being apples to apples.
+ */
+@SuppressWarnings({ "unchecked", "rawtypes" })
+public class LambdaTypeCoercions
+{
+    public static void 
provideBasicTypeCoercions(MappedConfiguration<CoercionTuple.Key, CoercionTuple> 
configuration)
+    {
+        add(configuration, Object.class, String.class, Object::toString);
+
+        add(configuration, Object.class, Boolean.class, (Object input) -> 
input != null);
+
+        add(configuration, String.class, Double.class, Double::valueOf);
+
+        // String to BigDecimal is important, as String->Double->BigDecimal 
would lose
+        // precision.
+
+        add(configuration, String.class, BigDecimal.class, BigDecimal::new);
+
+        add(configuration, BigDecimal.class, Double.class, 
BigDecimal::doubleValue);
+
+        add(configuration, String.class, BigInteger.class, BigInteger::new);
+
+        add(configuration, String.class, Long.class, Long::valueOf);
+
+        add(configuration, String.class, Integer.class, Integer::valueOf);
+
+        add(configuration, Long.class, Byte.class, Long::byteValue);
+
+        add(configuration, Long.class, Short.class, Long::shortValue);
+
+        add(configuration, Long.class, Integer.class, Long::intValue);
+
+        add(configuration, Number.class, Long.class, Number::longValue);
+
+        add(configuration, Double.class, Float.class, Double::floatValue);
+
+        add(configuration, Long.class, Double.class, Long::doubleValue);
+
+        add(configuration, String.class, Boolean.class, (String input) ->
+        {
+            String trimmed = input == null ? "" : input.trim();
+
+            if (trimmed.equalsIgnoreCase("false") || trimmed.length() == 0)
+                return false;
+
+            // Any non-blank string but "false"
+
+            return true;
+        });
+
+        add(configuration, Number.class, Boolean.class, (Number input) -> 
input.longValue() != 0);
+
+        add(configuration, Void.class, Boolean.class, (Void input) -> false);
+
+        add(configuration, Collection.class, Boolean.class, (Collection input) 
-> !input.isEmpty());
+
+        add(configuration, Object.class, List.class, (Object input) -> 
Collections.singletonList(input));
+
+        add(configuration, Object[].class, List.class, (Object[] input) -> 
Arrays.asList(input));
+
+        add(configuration, Object[].class, Boolean.class, (Object[] input) -> 
input != null && input.length > 0);
+
+        add(configuration, Float.class, Double.class, Float::doubleValue);
+
+        // Shared by the eight primitive array types below, exactly as in 
BasicTypeCoercions:
+        // one implementation, eight contributions.
+        Coercion primitiveArrayCoercion = (Coercion<Object, List>) (Object 
input) ->
+        {
+            int length = Array.getLength(input);
+            Object[] array = new Object[length];
+            for (int i = 0; i < length; i++)
+            {
+                array[i] = Array.get(input, i);
+            }
+            return Arrays.asList(array);
+        };
+
+        add(configuration, byte[].class, List.class, primitiveArrayCoercion);
+        add(configuration, short[].class, List.class, primitiveArrayCoercion);
+        add(configuration, int[].class, List.class, primitiveArrayCoercion);
+        add(configuration, long[].class, List.class, primitiveArrayCoercion);
+        add(configuration, float[].class, List.class, primitiveArrayCoercion);
+        add(configuration, double[].class, List.class, primitiveArrayCoercion);
+        add(configuration, char[].class, List.class, primitiveArrayCoercion);
+        add(configuration, boolean[].class, List.class, 
primitiveArrayCoercion);
+
+        add(configuration, String.class, File.class, File::new);
+
+        add(configuration, String.class, TimeInterval.class, 
TimeInterval::new);
+
+        add(configuration, TimeInterval.class, Long.class, 
TimeInterval::milliseconds);
+
+        add(configuration, Object.class, Object[].class, (Object input) -> new 
Object[]{ input });
+
+        add(configuration, Collection.class, Object[].class, (Collection 
input) -> input.toArray());
+
+        CoercionTuple<Flow, List> flowToListCoercion = 
CoercionTuple.create(Flow.class, List.class, Flow::toList);
+        configuration.add(flowToListCoercion.getKey(), flowToListCoercion);
+
+        CoercionTuple<Flow, Boolean> flowToBooleanCoercion = 
CoercionTuple.create(Flow.class, Boolean.class, (i) -> !i.isEmpty());
+        configuration.add(flowToBooleanCoercion.getKey(), 
flowToBooleanCoercion);
+    }
+
+    private static <S, T> void add(MappedConfiguration<CoercionTuple.Key, 
CoercionTuple> configuration, Class<S> sourceType,
+                                   Class<T> targetType, Coercion<S, T> 
coercion)
+    {
+        CoercionTuple<S, T> tuple = CoercionTuple.create(sourceType, 
targetType, coercion);
+        configuration.add(tuple.getKey(), tuple);
+    }
+}
diff --git 
a/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/MessageFormatterBenchmark.java
 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/MessageFormatterBenchmark.java
new file mode 100644
index 000000000..46ac9df86
--- /dev/null
+++ 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/MessageFormatterBenchmark.java
@@ -0,0 +1,122 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.commons.benchmarks;
+
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.commons.MessageFormatter;
+import org.apache.tapestry5.commons.internal.util.MessageFormatterImpl;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+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;
+
+/**
+ * What {@code MessageFormatterImpl.format} costs, and what it could cost at 
best.
+ *
+ * This is a hotter path than it looks. Validators build their client-side 
message during
+ * <em>render</em>, not only on failure (see {@code Max.render}), which calls
+ * {@code buildMessage} to fill in {@code data-max-message}. Every validated 
field on every
+ * rendered form formats at least one message per validator.
+ *
+ * The formatter caches nothing: the format string is final, yet {@code 
String.format} re-parses it
+ * on every call, allocating an {@code ArrayList} plus a {@code FixedString} or
+ * {@code FormatSpecifier} per chunk, on top of the {@code Formatter} and its 
{@code StringBuilder}.
+ *
+ * The question is whether pre-parsing at construction is worth it, and the 
two message shapes here
+ * are chosen to answer it honestly rather than flatteringly:
+ *
+ * <ul>
+ * <li>{@link #currentSimple()} uses {@code required}, a bare {@code %s} 
message. 58% of the
+ * shipped catalog looks like this, and it is locale-independent, so a 
hand-rolled formatter could
+ * reproduce it exactly.</li>
+ * <li>{@link #currentValidator()} uses {@code max-integer}, which is
+ * {@code %2$s requires a value no larger than %1$d} — argument indices 
<em>and</em> a numeric
+ * conversion. 35% of the catalog contains {@code %d}, and critically that 35% 
includes the
+ * validator messages, which are the hot ones. {@code %d} applies the locale's 
zero digit, so it
+ * cannot be replaced with {@code Long.toString} without changing output in 
locales that do not use
+ * ASCII digits.</li>
+ * </ul>
+ *
+ * The {@code ceiling*} benchmarks are hand-written concatenation producing 
the identical string.
+ * They are not a proposed implementation — they are the floor, showing the 
most any amount of
+ * pre-parsing could possibly recover. If the gap is small, the optimization 
is not worth the risk
+ * of diverging from {@code String.format} across 24 shipped locales.
+ *
+ * <pre>
+ * ./gradlew :commons:jmh -Pjmh.include=MessageFormatterBenchmark 
-Pjmh.profilers=gc
+ * </pre>
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+public class MessageFormatterBenchmark
+{
+    // core.properties: {@code required}
+    private static final String SIMPLE = "You must provide a value for %s.";
+
+    // core.properties: {@code max-integer}
+    private static final String VALIDATOR = "%2$s requires a value no larger 
than %1$d.";
+
+    private MessageFormatter simple;
+
+    private MessageFormatter validator;
+
+    private String label;
+
+    private Long constraint;
+
+    @Setup
+    public void setup()
+    {
+        simple = new MessageFormatterImpl(SIMPLE, Locale.ENGLISH);
+        validator = new MessageFormatterImpl(VALIDATOR, Locale.ENGLISH);
+
+        label = "Order quantity";
+        constraint = 250L;
+    }
+
+    @Benchmark
+    public String currentSimple()
+    {
+        return simple.format(label);
+    }
+
+    @Benchmark
+    public String currentValidator()
+    {
+        return validator.format(constraint, label);
+    }
+
+    /**
+     * Hand-written equivalent of {@link #currentSimple()}: the floor, not a 
proposal
+     */
+    @Benchmark
+    public String ceilingSimple()
+    {
+        return "You must provide a value for " + label + ".";
+    }
+
+    /**
+     * Hand-written equivalent of {@link #currentValidator()}: the floor, not 
a proposal
+     */
+    @Benchmark
+    public String ceilingValidator()
+    {
+        return label + " requires a value no larger than " + constraint + ".";
+    }
+}
diff --git 
a/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/MessagesBenchmark.java
 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/MessagesBenchmark.java
new file mode 100644
index 000000000..c170e2803
--- /dev/null
+++ 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/MessagesBenchmark.java
@@ -0,0 +1,210 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.commons.benchmarks;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.tapestry5.commons.MessageFormatter;
+import org.apache.tapestry5.commons.Messages;
+import org.apache.tapestry5.commons.util.AbstractMessages;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+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;
+
+/**
+ * Message lookup, which happens for every {@code ${message:key}} in every 
template on every
+ * render, and for every validation message a form produces.
+ *
+ * Two things about {@link AbstractMessages} are worth a number:
+ *
+ * <ul>
+ * <li>{@code get(key)} resolves the key once. It used to be written as
+ * {@code if (contains(key)) return valueForKey(key)} with {@code contains} 
itself being
+ * {@code valueForKey(key) != null}, so every successful lookup resolved the 
key twice — two map
+ * lookups where one would do for a property-file catalog, and worse for a 
subclass whose
+ * {@code valueForKey} is more than a map lookup. {@link #setup()} asserts the 
single resolution
+ * so that a regression fails loudly rather than quietly costing every 
template a second
+ * lookup.</li>
+ * <li>{@code format(key, args)} goes through {@code String.format} on every 
call. The
+ * {@link MessageFormatter} is cached, but the format string it holds is 
re-parsed each time,
+ * because {@code Formatter} is not thread-safe and so cannot be cached 
alongside it.</li>
+ * </ul>
+ *
+ * {@link #getUnparameterised()} against {@link #formatWithArguments()} is the 
interesting pair:
+ * it says how much a message costs once it takes arguments, and therefore 
whether pre-parsing the
+ * format would be worth the complexity.
+ *
+ * The catalog here is backed by a plain {@link HashMap} so that the numbers 
describe
+ * {@code AbstractMessages} itself rather than property-file loading.
+ *
+ * <pre>
+ * ./gradlew :commons:jmh -Pjmh.include=Messages -Pjmh.profilers=gc
+ * </pre>
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+public class MessagesBenchmark
+{
+    // Counts how often the catalog is actually consulted, for countingGet()
+    static class CountingMessages extends AbstractMessages
+    {
+        final AtomicInteger resolutions = new AtomicInteger();
+
+        private final Map<String, String> values;
+
+        CountingMessages(Locale locale, Map<String, String> values)
+        {
+            super(locale);
+
+            this.values = values;
+        }
+
+        @Override
+        protected String valueForKey(String key)
+        {
+            resolutions.incrementAndGet();
+
+            return values.get(key);
+        }
+
+        @Override
+        public Set<String> getKeys()
+        {
+            return values.keySet();
+        }
+    }
+
+    static class MapMessages extends AbstractMessages
+    {
+        private final Map<String, String> values;
+
+        MapMessages(Locale locale, Map<String, String> values)
+        {
+            super(locale);
+
+            this.values = values;
+        }
+
+        @Override
+        protected String valueForKey(String key)
+        {
+            return values.get(key);
+        }
+
+        @Override
+        public Set<String> getKeys()
+        {
+            return values.keySet();
+        }
+    }
+
+    private Messages messages;
+
+    private CountingMessages counting;
+
+    private String plainKey;
+
+    private String parameterisedKey;
+
+    private String missingKey;
+
+    private Object[] arguments;
+
+    @Setup
+    public void setup()
+    {
+        Map<String, String> values = new HashMap<>();
+
+        values.put("page-title", "Customer overview");
+        values.put("greeting", "Hello, %s. You have %d unread messages.");
+
+        messages = new MapMessages(Locale.ENGLISH, values);
+        counting = new CountingMessages(Locale.ENGLISH, values);
+
+        plainKey = "page-title";
+        parameterisedKey = "greeting";
+        missingKey = "no-such-key";
+
+        arguments = new Object[]{ "Ben", 3 };
+
+        verifySingleResolution();
+    }
+
+    /**
+     * Pins down the claim in this class's comment: one {@code get} of a 
present key consults the
+     * catalog exactly once.
+     * A regression to the old two-lookup form fails here and says so, rather 
than leaving a stale
+     * comment behind and a benchmark that quietly measures something else.
+     */
+    private void verifySingleResolution()
+    {
+        counting.resolutions.set(0);
+
+        counting.get(plainKey);
+
+        int resolutions = counting.resolutions.get();
+
+        if (resolutions != 1)
+        {
+            throw new IllegalStateException(String.format(
+                    "Expected AbstractMessages.get() to resolve the key once, 
but it resolved it %d time(s).",
+                    resolutions));
+        }
+    }
+
+    /**
+     * The common case: a message with no arguments, pulled straight out of a 
template
+     */
+    @Benchmark
+    public String getUnparameterised()
+    {
+        return messages.get(plainKey);
+    }
+
+    /** 
+     * The miss path, which templates hit for every optional message
+     */
+    @Benchmark
+    public String getMissing()
+    {
+        return messages.get(missingKey);
+    }
+
+    /**
+     * Cached formatter lookup on its own, without the formatting
+     */
+    @Benchmark
+    public MessageFormatter getFormatter()
+    {
+        return messages.getFormatter(parameterisedKey);
+    }
+
+    /** 
+     * Formatter lookup plus String.format
+     */
+    @Benchmark
+    public String formatWithArguments()
+    {
+        return messages.format(parameterisedKey, arguments);
+    }
+}
diff --git 
a/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/StringCoercionBenchmark.java
 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/StringCoercionBenchmark.java
new file mode 100644
index 000000000..a180ceea7
--- /dev/null
+++ 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/StringCoercionBenchmark.java
@@ -0,0 +1,152 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.commons.benchmarks;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.commons.internal.BasicTypeCoercions;
+import org.apache.tapestry5.commons.internal.services.TypeCoercerImpl;
+import org.apache.tapestry5.commons.services.TypeCoercer;
+import org.apache.tapestry5.commons.util.TimeInterval;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+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;
+
+/**
+ * Coercion from strings, which is what a request actually does.
+ *
+ * Almost everything entering a Tapestry application arrives as text: 
component parameters out of
+ * a template, event context values out of a URL, form submissions, query 
parameters.
+ * It then gets coerced to a target type on the way in. {@link 
TypeCoercerBenchmark} measures the
+ * machinery around a coercion; this measures the coercions themselves, on the 
types a page really binds.
+ *
+ * The pairing to watch is {@link #stringToEnum()}. It runs through
+ * {@code StringToEnumCoercion}, which is backed by a
+ * {@code CaseInsensitiveMap}, so an enum-typed component parameter pays for 
the character-by-
+ * character case-insensitive hash measured in {@link 
CaseInsensitiveMapBenchmark} on top of the
+ * coercion lookup itself. Two layers of overhead around what could be one map 
hit.
+ *
+ * {@link #stringToTimeInterval()} is the outlier by design: it parses a 
string with a regular
+ * expression on every call. It is used for cache and expiry settings, so it 
is not on a hot path
+ * today, but it is what a genuinely expensive coercion looks like next to the 
cheap ones.
+ *
+ * <pre>
+ * ./gradlew :commons:jmh -Pjmh.include=StringCoercion -Pjmh.profilers=gc
+ * </pre>
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+public class StringCoercionBenchmark
+{
+    public enum Position
+    {
+        ABOVE, BELOW, LEFT, RIGHT, CENTER
+    }
+
+    private TypeCoercer coercer;
+
+    private String number;
+
+    private String decimal;
+
+    private String bool;
+
+    private String enumValue;
+
+    private String interval;
+
+    private Integer boxed;
+
+    @Setup
+    public void setup()
+    {
+        CoercionTupleCollector collector = new CoercionTupleCollector();
+
+        BasicTypeCoercions.provideBasicTypeCoercions(collector);
+        BasicTypeCoercions.provideJSR310TypeCoercions(collector);
+
+        coercer = new TypeCoercerImpl(collector.getTuples());
+
+        number = "12345";
+        decimal = "1234.5678";
+        bool = "true";
+        enumValue = "center";
+        interval = "2 h 30 m";
+        boxed = 12345;
+
+        // Warm the coercion search, which is a startup cost and not what is 
being measured.
+        stringToInteger();
+        stringToLong();
+        stringToDouble();
+        stringToBoolean();
+        stringToEnum();
+        stringToTimeInterval();
+        objectToString();
+    }
+
+    @Benchmark
+    public Object stringToInteger()
+    {
+        return coercer.coerce(number, Integer.class);
+    }
+
+    @Benchmark
+    public Object stringToLong()
+    {
+        return coercer.coerce(number, Long.class);
+    }
+
+    @Benchmark
+    public Object stringToDouble()
+    {
+        return coercer.coerce(decimal, Double.class);
+    }
+
+    @Benchmark
+    public Object stringToBoolean()
+    {
+        return coercer.coerce(bool, Boolean.class);
+    }
+
+    /**
+     * Goes through StringToEnumCoercion, and therefore through a 
CaseInsensitiveMap
+     */
+    @Benchmark
+    public Object stringToEnum()
+    {
+        return coercer.coerce(enumValue, Position.class);
+    }
+
+    /**
+     * Parses with a regular expression on every call
+     */
+    @Benchmark
+    public Object stringToTimeInterval()
+    {
+        return coercer.coerce(interval, TimeInterval.class);
+    }
+
+    /**
+     * The other direction, which every rendered parameter value takes
+     */
+    @Benchmark
+    public Object objectToString()
+    {
+        return coercer.coerce(boxed, String.class);
+    }
+}
diff --git 
a/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/TypeCoercerBenchmark.java
 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/TypeCoercerBenchmark.java
new file mode 100644
index 000000000..247253043
--- /dev/null
+++ 
b/commons/src/jmh/java/org/apache/tapestry5/commons/benchmarks/TypeCoercerBenchmark.java
@@ -0,0 +1,129 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.commons.benchmarks;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.commons.internal.BasicTypeCoercions;
+import org.apache.tapestry5.commons.internal.services.TypeCoercerImpl;
+import org.apache.tapestry5.commons.services.Coercion;
+import org.apache.tapestry5.commons.services.TypeCoercer;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+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;
+
+/**
+ * The warm coercion path, which every request walks many times over: 
component parameter binding,
+ * event context values and property conduits all funnel through {@link 
TypeCoercer#coerce}.
+ *
+ * This benchmark was written to investigate a lock in
+ * {@link TypeCoercerImpl}{@code .getTargetCoercion}, which took a {@code 
ReentrantReadWriteLock}
+ * read lock on every call, hit or not, because {@code typeToTargetCoercion} 
was a
+ * {@code WeakHashMap}, and a WeakHashMap read is a write, since {@code get} 
expunges cleared
+ * entries. At eight threads that lock cost 1690 ns per lookup against 9 ns 
single-threaded. It has
+ * since been replaced by a {@code ConcurrentHashMap}; see
+ * {@code ISSUE_TYPECOERCER_LOCK_CONTENTION.md} and {@code 
benchmark-results/type-coercer-*.json}.
+ *
+ * It is kept as a regression guard, and answers two questions:
+ *
+ * <ul>
+ * <li>How much of a warm coercion is bookkeeping rather than conversion? 
Compare
+ * {@link #lookupOnly()} against {@link #stringToInteger()}.</li>
+ * <li>Does the lookup still scale? Re-run with {@code -Pjmh.threads=max} and 
compare against the
+ * single-threaded numbers. They should stay close. If a future change 
reintroduces
+ * synchronisation on this path, this is where it will show up.</li>
+ * </ul>
+ *
+ * {@link #alreadyAssignable()} is the control: it returns before reaching the 
lock at all, so it
+ * measures the floor that the other numbers should be read against.
+ *
+ * <pre>
+ * ./gradlew :commons:jmh -Pjmh.include=TypeCoercer
+ * ./gradlew :commons:jmh -Pjmh.include=TypeCoercer -Pjmh.threads=max
+ * </pre>
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+public class TypeCoercerBenchmark
+{
+    private TypeCoercer coercer;
+
+    private String numericString;
+    private String booleanString;
+    private Integer number;
+
+    @Setup
+    public void setup()
+    {
+        CoercionTupleCollector collector = new CoercionTupleCollector();
+
+        BasicTypeCoercions.provideBasicTypeCoercions(collector);
+        BasicTypeCoercions.provideJSR310TypeCoercions(collector);
+
+        coercer = new TypeCoercerImpl(collector.getTuples());
+
+        numericString = "12345";
+        booleanString = "true";
+        number = 12345;
+
+        // Warm every cache the benchmarks below rely on, so that the 
measurement never
+        // includes the coercion search. That path is a startup cost, not a 
per-request one.
+        stringToInteger();
+        stringToBoolean();
+        integerToString();
+        lookupOnly();
+    }
+
+    // Cache hit: two map lookups and one Integer.valueOf
+    @Benchmark
+    public Object stringToInteger()
+    {
+        return coercer.coerce(numericString, Integer.class);
+    }
+
+    @Benchmark
+    public Object stringToBoolean()
+    {
+        return coercer.coerce(booleanString, Boolean.class);
+    }
+
+    @Benchmark
+    public Object integerToString()
+    {
+        return coercer.coerce(number, String.class);
+    }
+
+    /**
+     * The lookup without the conversion: two map reads, one per cache layer.
+     * Whatever this costs is pure overhead on every coercion.
+     */
+    @Benchmark
+    public Coercion<String, Integer> lookupOnly()
+    {
+        return coercer.getCoercion(String.class, Integer.class);
+    }
+
+    /**
+     * Control: the early return in the coerce method, before either cache is 
consulted
+     */
+    @Benchmark
+    public Object alreadyAssignable()
+    {
+        return coercer.coerce(numericString, String.class);
+    }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7a770e615..fab1d1938 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -80,6 +80,10 @@ easymock = "5.4.0"
 
 spock = "2.4-groovy-4.0"
 
+# BENCHMARKING
+
+jmh = "1.37"
+
 selenium-java    = "4.12.1"
 selenium-legrc   = "4.5.0"
 webdrivermanager = "5.5.2"
@@ -209,6 +213,11 @@ easymock = { module = "org.easymock:easymock", version.ref 
= "easymock" }
 spock-bom  = { module = "org.spockframework:spock-bom", version.ref = "spock" }
 spock-core = { module = "org.spockframework:spock-core" }
 
+# BENCHMARKING
+
+jmh-core                 = { module = "org.openjdk.jmh:jmh-core", version.ref 
= "jmh" }
+jmh-generator-annprocess = { module = 
"org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" }
+
 selenium-java    = { module = "org.seleniumhq.selenium:selenium-java", 
version.ref = "selenium-java" }
 selenium-legrc   = { module = "org.seleniumhq.selenium:selenium-leg-rc", 
version.ref = "selenium-legrc" }
 webdrivermanager = { module = "io.github.bonigarcia:webdrivermanager", 
version.ref = "webdrivermanager" }
diff --git a/plastic/build.gradle b/plastic/build.gradle
index 35f5d8103..a143048a7 100644
--- a/plastic/build.gradle
+++ b/plastic/build.gradle
@@ -1,5 +1,6 @@
 plugins {
     id 'tapestry.junit5-spock-convention'
+    id 'tapestry.jmh-convention'
 }
 
 description = 'High-level runtime transformations of Java classes'
diff --git 
a/plastic/src/jmh/java/org/apache/tapestry5/plastic/benchmarks/PlasticTransformationBenchmark.java
 
b/plastic/src/jmh/java/org/apache/tapestry5/plastic/benchmarks/PlasticTransformationBenchmark.java
new file mode 100644
index 000000000..db0c51239
--- /dev/null
+++ 
b/plastic/src/jmh/java/org/apache/tapestry5/plastic/benchmarks/PlasticTransformationBenchmark.java
@@ -0,0 +1,133 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.plastic.benchmarks;
+
+import java.util.Collections;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.internal.plastic.StandardDelegate;
+import org.apache.tapestry5.plastic.ClassInstantiator;
+import org.apache.tapestry5.plastic.MethodAdvice;
+import org.apache.tapestry5.plastic.MethodInvocation;
+import org.apache.tapestry5.plastic.PlasticClass;
+import org.apache.tapestry5.plastic.PlasticClassTransformer;
+import org.apache.tapestry5.plastic.PlasticField;
+import org.apache.tapestry5.plastic.PlasticManager;
+import org.apache.tapestry5.plastic.PlasticMethod;
+import org.apache.tapestry5.plastic.PropertyAccessType;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.State;
+
+/**
+ * Class transformation, which is where a cold Tapestry application spends 
most of its time.
+ *
+ * {@code PageLoadBenchmark} in tapestry-core shows a page costing tens of 
milliseconds to load
+ * cold against tens of microseconds to assemble warm. Practically all of that 
gap is here:
+ * reading class bytes, parsing them with ASM, rewriting them, and defining 
the result. Every page
+ * class, component class and mixin goes through it once, and every IoC 
service gets a generated
+ * proxy the same way.
+ *
+ * Plastic caches per class name, and its cache lives in the {@code 
PlasticManager}'s class loader,
+ * so a class can only be transformed once per manager. Rather than rebuilding 
state inside the
+ * measurement with an invocation-level fixture — which JMH rightly warns 
about — each benchmark
+ * builds its own manager and {@link #createManager()} measures that cost on 
its own. Subtract it
+ * to get the transformation cost:
+ *
+ * <pre>
+ * transformClass - createManager   ~ cost of transforming one page-sized class
+ * createProxy    - createManager   ~ cost of one IoC service proxy
+ * </pre>
+ *
+ * <pre>
+ * ./gradlew :plastic:jmh -Pjmh.include=PlasticTransformation 
-Pjmh.profilers=gc
+ * </pre>
+ */
+@State(Scope.Thread)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+public class PlasticTransformationBenchmark
+{
+    private static final String SUBJECT = 
"plasticbenchmarks.subjects.BenchmarkSubject";
+
+    private static final String SUBJECT_PACKAGE = "plasticbenchmarks.subjects";
+
+    /**
+     * Roughly what Tapestry does to a component class: turn every field into 
a conduit-backed
+     * property and advise every method.
+     */
+    private static final PlasticClassTransformer COMPONENT_LIKE = new 
PlasticClassTransformer()
+    {
+        @Override
+        public void transform(PlasticClass plasticClass)
+        {
+            for (PlasticField field : plasticClass.getAllFields())
+            {
+                field.createAccessors(PropertyAccessType.READ_WRITE);
+            }
+
+            for (PlasticMethod method : plasticClass.getMethods())
+            {
+                method.addAdvice(PROCEED);
+            }
+        }
+    };
+
+    private static final MethodAdvice PROCEED = new MethodAdvice()
+    {
+        @Override
+        public void advise(MethodInvocation invocation)
+        {
+            invocation.proceed();
+        }
+    };
+
+    /**
+     * The baseline to subtract: building a manager and its class loader, 
transforming nothing.
+     */
+    @Benchmark
+    public PlasticManager createManager()
+    {
+        return newManager();
+    }
+
+    @Benchmark
+    public ClassInstantiator<Object> transformClass()
+    {
+        return newManager().getClassInstantiator(SUBJECT);
+    }
+
+    /**
+     * What tapestry-ioc pays for every service it proxies during registry 
startup. 
+     */
+    @Benchmark
+    public ClassInstantiator<Runnable> createProxy()
+    {
+        return newManager().createProxy(Runnable.class, plasticClass ->
+                
plasticClass.introduceMethod(RUNNABLE_RUN).changeImplementation(builder -> 
builder.returnDefaultValue()));
+    }
+
+    private static final org.apache.tapestry5.plastic.MethodDescription 
RUNNABLE_RUN =
+            new org.apache.tapestry5.plastic.MethodDescription("void", "run");
+
+    private static PlasticManager newManager()
+    {
+        return PlasticManager.withContextClassLoader()
+                .delegate(new StandardDelegate(COMPONENT_LIKE))
+                .packages(Collections.singletonList(SUBJECT_PACKAGE))
+                .create();
+    }
+}
diff --git 
a/plastic/src/jmh/java/plasticbenchmarks/subjects/BenchmarkSubject.java 
b/plastic/src/jmh/java/plasticbenchmarks/subjects/BenchmarkSubject.java
new file mode 100644
index 000000000..83cc02e31
--- /dev/null
+++ b/plastic/src/jmh/java/plasticbenchmarks/subjects/BenchmarkSubject.java
@@ -0,0 +1,79 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package plasticbenchmarks.subjects;
+
+/**
+ * A stand-in for a Tapestry page class: private fields that a real 
transformation would turn into
+ * conduit-backed properties, and methods that it would advise.
+ *
+ * The package is controlled by the {@code PlasticManager} of
+ * {@code 
org.apache.tapestry5.plastic.benchmarks.PlasticTransformationBenchmark}, so 
every class
+ * in it is transformed on load. It sits outside {@code org.apache.tapestry5} 
for the same reason
+ * the existing Plastic tests use {@code testsubjects}: the controlled package 
must not overlap
+ * with Plastic's own.
+ */
+public class BenchmarkSubject
+{
+    private String name;
+
+    private int count;
+
+    private boolean active;
+
+    private long timestamp;
+
+    private Object payload;
+
+    // No getters or setters here on purpose: the transformation generates 
accessors for every
+    // field, and Plastic refuses to overwrite one that already exists. The 
methods below are
+    // the ones the transformation advises.
+
+    public String describe()
+    {
+        return name + ":" + count;
+    }
+
+    public int increment(int amount)
+    {
+        count += amount;
+        return count;
+    }
+
+    public boolean toggle()
+    {
+        active = !active;
+        return active;
+    }
+
+    public long stamp(long value)
+    {
+        timestamp = value;
+        return timestamp;
+    }
+
+    public Object exchange(Object value)
+    {
+        Object previous = payload;
+        payload = value;
+        return previous;
+    }
+
+    public void reset()
+    {
+        name = null;
+        count = 0;
+        active = false;
+        timestamp = 0L;
+        payload = null;
+    }
+}
diff --git a/tapestry-core/build.gradle b/tapestry-core/build.gradle
index fdf09e1b9..7bbf4de2f 100644
--- a/tapestry-core/build.gradle
+++ b/tapestry-core/build.gradle
@@ -3,6 +3,7 @@ import t5build.NpmTask
 plugins {
     id 'tapestry.junit5-convention'
     id 'tapestry.testng-convention'
+    id 'tapestry.jmh-convention'
 }
 
 description = 'Central module for Tapestry, containing all core services and 
components'
diff --git 
a/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/DocumentRenderBenchmark.java
 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/DocumentRenderBenchmark.java
new file mode 100644
index 000000000..af54687e0
--- /dev/null
+++ 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/DocumentRenderBenchmark.java
@@ -0,0 +1,115 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.benchmarks;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.dom.Document;
+import org.apache.tapestry5.dom.Element;
+import org.apache.tapestry5.dom.Html5MarkupModel;
+import org.apache.tapestry5.dom.MarkupModel;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+
+/**
+ * The second half of every request: Tapestry renders components into a {@link 
Document} and then
+ * serialises that tree to markup. Both halves are paid per response, so both 
are measured here.
+ *
+ * {@link #build()} is dominated by allocation, {@link #render()} by string 
handling and by
+ * {@code MarkupModel} escaping (see {@link MarkupEscapingBenchmark}). Run with
+ * {@code -Pjmh.profilers=gc} to see which of the two actually drives a page's 
allocation
+ * footprint.
+ *
+ * <pre>
+ * ./gradlew :tapestry-core:jmh -Pjmh.include=DocumentRender -Pjmh.profilers=gc
+ * </pre>
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+public class DocumentRenderBenchmark
+{
+    @Param({"10", "200"})
+    public int rows;
+
+    private MarkupModel model;
+
+    /** Built once; {@link #render()} must not mutate it. */
+    private Document prebuilt;
+
+    @Setup
+    public void setup()
+    {
+        model = new Html5MarkupModel();
+        prebuilt = buildDocument();
+    }
+
+    @Benchmark
+    public Document build()
+    {
+        return buildDocument();
+    }
+
+    @Benchmark
+    public String render()
+    {
+        return prebuilt.toString();
+    }
+
+    private Document buildDocument()
+    {
+        Document document = new Document(model);
+
+        Element html = document.newRootElement("html");
+        Element body = html.element("body", "class", "t-page");
+
+        Element table = body.element("table",
+                "class", "table table-striped table-hover",
+                "id", "grid",
+                "data-container-type", "zone");
+
+        Element head = table.element("thead").element("tr");
+
+        for (int column = 0; column < 6; column++)
+        {
+            head.element("th", "scope", "col", "class", 
"t-sort-column").text("Column " + column);
+        }
+
+        Element tbody = table.element("tbody");
+
+        for (int row = 0; row < rows; row++)
+        {
+            Element tr = tbody.element("tr", "class", row % 2 == 0 ? "t-even" 
: "t-odd");
+
+            // A mix of plain text, text needing escaping, and a link with 
attributes, which is
+            // roughly what a Grid of business objects produces.
+            tr.element("td").text("Row " + row);
+            tr.element("td").text("Bread & butter");
+            tr.element("td").text("<not markup>");
+            tr.element("td", "class", "numeric").text(Integer.toString(row * 
37));
+            tr.element("td").element("a",
+                    "href", "/app/item/" + row,
+                    "title", "Item \"" + row + "\"",
+                    "class", "t-link").text("Edit");
+            tr.element("td").text("");
+        }
+
+        return document;
+    }
+}
diff --git 
a/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/MarkupEscapingBenchmark.java
 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/MarkupEscapingBenchmark.java
new file mode 100644
index 000000000..b994ce67c
--- /dev/null
+++ 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/MarkupEscapingBenchmark.java
@@ -0,0 +1,155 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.benchmarks;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.dom.AbstractMarkupModel;
+import org.apache.tapestry5.dom.DefaultMarkupModel;
+import org.apache.tapestry5.dom.MarkupModel;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+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;
+
+/**
+ * Output escaping, which runs over every text node and every attribute value 
of every response.
+ *
+ * {@link AbstractMarkupModel} holds two escapers written to different 
designs, and the comparison
+ * between them is the point of this class:
+ *
+ * <ul>
+ * <li>{@code encode}, used for text nodes, scans for a special character and, 
on finding one,
+ * allocates a {@code StringBuilder} of twice the input length and copies the 
prefix in with
+ * {@code substring} — a second allocation. From then on it appends character 
by character,
+ * including all the characters that needed no escaping at all.</li>
+ * <li>{@code encodeQuoted}, used for attribute values, appends into a buffer 
the caller already
+ * owns and copies unescaped runs wholesale via {@code subSequence}, so it 
allocates nothing per
+ * call and never walks a run one character at a time.</li>
+ * </ul>
+ *
+ * If {@code encodeQuoted}'s design wins here, {@code encode} should adopt it.
+ *
+ * Comparing the two fairly takes care, and a first pass at this benchmark got 
it wrong twice:
+ *
+ * <ul>
+ * <li>They do not escape the same characters. {@code encodeQuoted} also 
escapes the quote
+ * character, so any input containing one gives it strictly more work to do 
and the comparison
+ * silently stops being about the strategy. Every input below is therefore 
restricted to
+ * {@code < > &}, which both escape identically.</li>
+ * <li>They do not produce the same thing. On input needing no escaping at 
all, {@code encode}
+ * returns the argument untouched while {@code encodeQuoted} still copies it 
into the buffer. The
+ * {@code clean} pair is a comparison of two different jobs, and is here as 
context for the
+ * escaping cases rather than as a race to be won.</li>
+ * </ul>
+ *
+ * The input shapes matter more than the absolute numbers. Real markup is 
overwhelmingly
+ * {@link #encodeClean()}: no special character at all, where {@code encode} 
allocates nothing and
+ * only the scan costs anything. Escaping-heavy content is where the two 
designs diverge, and
+ * {@link #encodeSparse()} against {@link #encodeQuotedSparse()} is the pair 
to read first,
+ * because prose with the occasional ampersand is what most pages are made of.
+ *
+ * <pre>
+ * ./gradlew :tapestry-core:jmh -Pjmh.include=MarkupEscaping -Pjmh.profilers=gc
+ * </pre>
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+public class MarkupEscapingBenchmark
+{
+    private MarkupModel model;
+
+    // None of these contains a quote or an apostrophe, so that encode and 
encodeQuoted escape
+    // exactly the same characters and the two can be compared.
+
+    /** No character needs escaping: the common case, and the one worth 
optimising for. */
+    private String clean;
+
+    /** Prose with an ampersand in it: the realistic escaping case. */
+    private String sparse;
+
+    /** Embedded markup that has to be shown rather than interpreted: the 
worst case. */
+    private String dense;
+
+    private StringBuilder buffer;
+
+    @Setup
+    public void setup()
+    {
+        model = new DefaultMarkupModel();
+
+        clean = repeat("The quick brown fox jumps over the lazy dog. ", 4);
+        sparse = repeat("Bread & butter, sold at less than 5 units. ", 4);
+        dense = repeat("<span class=x>a &amp; b</span> ", 4);
+
+        buffer = new StringBuilder(1024);
+    }
+
+    private static String repeat(String text, int times)
+    {
+        StringBuilder builder = new StringBuilder(text.length() * times);
+
+        for (int i = 0; i < times; i++)
+        {
+            builder.append(text);
+        }
+
+        return builder.toString();
+    }
+
+    @Benchmark
+    public String encodeClean()
+    {
+        return model.encode(clean);
+    }
+
+    @Benchmark
+    public String encodeSparse()
+    {
+        return model.encode(sparse);
+    }
+
+    @Benchmark
+    public String encodeDense()
+    {
+        return model.encode(dense);
+    }
+
+    @Benchmark
+    public StringBuilder encodeQuotedClean()
+    {
+        buffer.setLength(0);
+        model.encodeQuoted(clean, buffer);
+        return buffer;
+    }
+
+    @Benchmark
+    public StringBuilder encodeQuotedSparse()
+    {
+        buffer.setLength(0);
+        model.encodeQuoted(sparse, buffer);
+        return buffer;
+    }
+
+    @Benchmark
+    public StringBuilder encodeQuotedDense()
+    {
+        buffer.setLength(0);
+        model.encodeQuoted(dense, buffer);
+        return buffer;
+    }
+}
diff --git 
a/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/PageLoadBenchmark.java
 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/PageLoadBenchmark.java
new file mode 100644
index 000000000..898b45c1f
--- /dev/null
+++ 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/PageLoadBenchmark.java
@@ -0,0 +1,247 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.benchmarks;
+
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.internal.services.PageLoader;
+import org.apache.tapestry5.internal.structure.Page;
+import org.apache.tapestry5.services.pageload.ComponentResourceSelector;
+import org.apache.tapestry5.test.PageTester;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+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.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Page loading, decomposed.
+ *
+ * "Page loading is slow" is really four costs stacked on top of each other, 
and they have
+ * different fixes, so the benchmarks below separate them:
+ *
+ * <ol>
+ * <li>{@link #coldRegistryStartup} — building the IoC registry. Paid once per 
application, but it
+ * is what a developer waits for on every restart.</li>
+ * <li>{@link #coldFirstPageLoad} — the first page through a fresh registry. 
On top of the page
+ * itself this realises the whole service graph behind {@link PageLoader}, and 
transforms the core
+ * component classes through Plastic.</li>
+ * <li>{@link #coldSecondPageLoad} — a different page, once that machinery is 
up. This is the
+ * honest "how expensive is one more page" number, and the one to watch when 
optimising
+ * {@code PageLoaderImpl}.</li>
+ * <li>{@link #warmRichPageAssembly} / {@link #warmSimplePageAssembly} — 
repeated assembly in a
+ * long-lived registry, where templates are parsed and component classes 
transformed already. This
+ * is what a page-cache miss costs a running application, and the difference 
between the two pages
+ * is the per-component cost of assembly.</li>
+ * </ol>
+ *
+ * The cold benchmarks are single-shot in fresh JVMs: interpreted, unlinked, 
JIT-free.
+ * That is the code path a cold application actually takes. Their spread is 
wide: read the min/max,
+ * not just the mean.
+ *
+ * The benchmark application lives in {@code src/jmh/java/.../benchmarks/app} 
with its templates
+ * in {@code src/jmh/webapp}. {@link PageTester} resolves the context path 
relative to the working
+ * directory, which Gradle sets to the module directory.
+ *
+ * Do not run the cold benchmarks with {@code -Pjmh.quick}: it forces three 
measurement
+ * iterations per fork, and only the first of them is cold. Shorten them with 
{@code -Pjmh.forks}
+ * instead.
+ *
+ * <pre>
+ * ./gradlew :tapestry-core:jmh -Pjmh.include=PageLoad
+ * ./gradlew :tapestry-core:jmh -Pjmh.include='PageLoad.cold' -Pjmh.forks=3
+ * ./gradlew :tapestry-core:jmh -Pjmh.include='PageLoad.warm' 
-Pjmh.profilers=gc
+ * </pre>
+ */
+public class PageLoadBenchmark
+{
+    private static final String APP_PACKAGE = 
"org.apache.tapestry5.benchmarks.app";
+
+    private static final String APP_NAME = "";
+
+    private static final String CONTEXT_PATH = "src/jmh/webapp";
+
+    private static final String SIMPLE_PAGE = "SimplePage";
+
+    private static final String RICH_PAGE = "RichPage";
+
+    private static final ComponentResourceSelector SELECTOR = new 
ComponentResourceSelector(Locale.ENGLISH);
+
+    private static PageTester newTester()
+    {
+        return new PageTester(APP_PACKAGE, APP_NAME, CONTEXT_PATH);
+    }
+
+    // 
---------------------------------------------------------------------------------------
+    // Cold. Each of these lives in its own state class, because JMH runs a 
state's @Setup for
+    // every benchmark that references it — sharing one state would start a 
registry inside the
+    // very measurement that is supposed to be timing the startup.
+    // 
---------------------------------------------------------------------------------------
+
+    /** 
+     * Holds nothing up front; the benchmark builds the registry and teardown 
disposes of it.
+     */
+    @State(Scope.Thread)
+    public static class NoRegistry
+    {
+        PageTester tester;
+
+        @TearDown(Level.Iteration)
+        public void shutdown()
+        {
+            if (tester != null)
+            {
+                tester.shutdown();
+                tester = null;
+            }
+        }
+    }
+
+    /** 
+     * A registry that is up but has never loaded a page.
+     */
+    @State(Scope.Thread)
+    public static class FreshRegistry
+    {
+        PageTester tester;
+
+        PageLoader loader;
+
+        @Setup(Level.Iteration)
+        public void start()
+        {
+            tester = newTester();
+            loader = tester.getService(PageLoader.class);
+        }
+
+        @TearDown(Level.Iteration)
+        public void shutdown()
+        {
+            tester.shutdown();
+        }
+    }
+
+    /** 
+     * A registry that has already loaded one page, so the loading machinery 
is realised.
+     */
+    @State(Scope.Thread)
+    public static class UsedRegistry
+    {
+        PageTester tester;
+
+        PageLoader loader;
+
+        @Setup(Level.Iteration)
+        public void start()
+        {
+            tester = newTester();
+            loader = tester.getService(PageLoader.class);
+            loader.loadPage(SIMPLE_PAGE, SELECTOR);
+        }
+
+        @TearDown(Level.Iteration)
+        public void shutdown()
+        {
+            tester.shutdown();
+        }
+    }
+
+    @Benchmark
+    @BenchmarkMode(Mode.SingleShotTime)
+    @OutputTimeUnit(TimeUnit.MILLISECONDS)
+    @Fork(value = 10, warmups = 0)
+    @Warmup(iterations = 0)
+    @Measurement(iterations = 1)
+    public Object coldRegistryStartup(NoRegistry state)
+    {
+        state.tester = newTester();
+
+        return state.tester.getRegistry();
+    }
+
+    @Benchmark
+    @BenchmarkMode(Mode.SingleShotTime)
+    @OutputTimeUnit(TimeUnit.MILLISECONDS)
+    @Fork(value = 10, warmups = 0)
+    @Warmup(iterations = 0)
+    @Measurement(iterations = 1)
+    public Page coldFirstPageLoad(FreshRegistry state)
+    {
+        return state.loader.loadPage(RICH_PAGE, SELECTOR);
+    }
+
+    @Benchmark
+    @BenchmarkMode(Mode.SingleShotTime)
+    @OutputTimeUnit(TimeUnit.MILLISECONDS)
+    @Fork(value = 10, warmups = 0)
+    @Warmup(iterations = 0)
+    @Measurement(iterations = 1)
+    public Page coldSecondPageLoad(UsedRegistry state)
+    {
+        return state.loader.loadPage(RICH_PAGE, SELECTOR);
+    }
+
+    // 
---------------------------------------------------------------------------------------
+    // Warm: one registry for the whole trial, assembling the same page over 
and over.
+    // 
---------------------------------------------------------------------------------------
+
+    @State(Scope.Benchmark)
+    public static class WarmRegistry
+    {
+        PageTester tester;
+
+        PageLoader loader;
+
+        @Setup(Level.Trial)
+        public void start()
+        {
+            tester = newTester();
+            loader = tester.getService(PageLoader.class);
+
+            // Take the one-off costs — service realisation, class 
transformation, template
+            // parsing — outside the measurement, which is the point of the 
warm benchmarks.
+            loader.loadPage(SIMPLE_PAGE, SELECTOR);
+            loader.loadPage(RICH_PAGE, SELECTOR);
+        }
+
+        @TearDown(Level.Trial)
+        public void shutdown()
+        {
+            tester.shutdown();
+        }
+    }
+
+    @Benchmark
+    @BenchmarkMode(Mode.AverageTime)
+    @OutputTimeUnit(TimeUnit.MICROSECONDS)
+    public Page warmSimplePageAssembly(WarmRegistry state)
+    {
+        return state.loader.loadPage(SIMPLE_PAGE, SELECTOR);
+    }
+
+    @Benchmark
+    @BenchmarkMode(Mode.AverageTime)
+    @OutputTimeUnit(TimeUnit.MICROSECONDS)
+    public Page warmRichPageAssembly(WarmRegistry state)
+    {
+        return state.loader.loadPage(RICH_PAGE, SELECTOR);
+    }
+}
diff --git 
a/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/URLEncoderBenchmark.java
 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/URLEncoderBenchmark.java
new file mode 100644
index 000000000..b13c69fcf
--- /dev/null
+++ 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/URLEncoderBenchmark.java
@@ -0,0 +1,107 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.benchmarks;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.internal.services.URLEncoderImpl;
+import org.apache.tapestry5.services.URLEncoder;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+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;
+
+/**
+ * URL-safe encoding of event context values, which runs for every value in 
every link a page
+ * renders. A grid of a hundred rows with an action link per row encodes at 
least a hundred times.
+ *
+ * {@code URLEncoderImpl.encode} has the same shape as the escaping problem in
+ * {@link MarkupEscapingBenchmark}, and slightly worse:
+ *
+ * <ul>
+ * <li>It allocates a {@code StringBuilder} of twice the input length 
<em>unconditionally</em>,
+ * before it knows whether anything needs encoding at all — and then, if 
nothing did, throws it
+ * away and returns the original string. {@code AbstractMarkupModel.encode} at 
least waits until
+ * it has seen a character that needs escaping.</li>
+ * <li>Each character that does need encoding goes through
+ * {@code String.format("$%04x", chAsInt)}, which parses a format string, 
allocates a
+ * {@code Formatter} and boxes the argument, to produce four hex digits.</li>
+ * </ul>
+ *
+ * {@link #encodeClean()} is the case that matters. Page names, event names 
and identifier-like
+ * context values are overwhelmingly made of safe characters, so the common 
path is the one that
+ * allocates a buffer it does not use. Whatever that costs is pure waste, and
+ * {@link #encodeClean()} against {@link #encodeDirty()} says how much of the 
total it is.
+ *
+ * Read this one with {@code -Pjmh.profilers=gc}; the allocation per operation 
is the finding.
+ *
+ * <pre>
+ * ./gradlew :tapestry-core:jmh -Pjmh.include=URLEncoder -Pjmh.profilers=gc
+ * </pre>
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+public class URLEncoderBenchmark
+{
+    private URLEncoder encoder;
+
+    // Nothing to encode: a page name, an event name, a numeric id
+    private String clean;
+
+    // A handful of characters need encoding: a name, a path, a formatted date
+    private String dirty;
+
+    private String encodedClean;
+
+    private String encodedDirty;
+
+    @Setup
+    public void setup()
+    {
+        encoder = new URLEncoderImpl();
+
+        clean = "customer.overview:activate-4711";
+        dirty = "Müller & Sons, Ltd. / Hamburg";
+
+        encodedClean = encoder.encode(clean);
+        encodedDirty = encoder.encode(dirty);
+    }
+
+    @Benchmark
+    public String encodeClean()
+    {
+        return encoder.encode(clean);
+    }
+
+    @Benchmark
+    public String encodeDirty()
+    {
+        return encoder.encode(dirty);
+    }
+
+    @Benchmark
+    public String decodeClean()
+    {
+        return encoder.decode(encodedClean);
+    }
+
+    @Benchmark
+    public String decodeDirty()
+    {
+        return encoder.decode(encodedDirty);
+    }
+}
diff --git 
a/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/app/pages/RichPage.java
 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/app/pages/RichPage.java
new file mode 100644
index 000000000..7402b9326
--- /dev/null
+++ 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/app/pages/RichPage.java
@@ -0,0 +1,55 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.benchmarks.app.pages;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.tapestry5.annotations.Property;
+
+/**
+ * A page with enough embedded components to make page assembly, rather than 
page instantiation,
+ * the dominant cost.
+ *
+ * Deliberately built from core components only, so that the benchmark 
measures Tapestry's
+ * loading machinery and not a fixture of our own. The difference between 
loading this and
+ * {@link SimplePage} is the per-component cost of assembly.
+ */
+public class RichPage
+{
+    @Property
+    private String item;
+
+    @Property
+    private String text;
+
+    @Property
+    private boolean flag;
+
+    public List<String> getItems()
+    {
+        List<String> items = new ArrayList<>();
+
+        for (int i = 0; i < 20; i++)
+        {
+            items.add("Item " + i);
+        }
+
+        return items;
+    }
+
+    public String getTitle()
+    {
+        return "Rich";
+    }
+}
diff --git 
a/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/app/pages/SimplePage.java
 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/app/pages/SimplePage.java
new file mode 100644
index 000000000..c244e01e7
--- /dev/null
+++ 
b/tapestry-core/src/jmh/java/org/apache/tapestry5/benchmarks/app/pages/SimplePage.java
@@ -0,0 +1,28 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.benchmarks.app.pages;
+
+/**
+ * The floor of the page-load benchmarks: markup and one expansion, no 
embedded components.
+ *
+ * Whatever this page costs to load is what Tapestry charges for a page before 
any component
+ * work happens at all — class transformation, template parse, page assembly. 
Read
+ * {@link RichPage} against it.
+ */
+public class SimplePage
+{
+    public String getTitle()
+    {
+        return "Simple";
+    }
+}
diff --git a/tapestry-core/src/jmh/webapp/RichPage.tml 
b/tapestry-core/src/jmh/webapp/RichPage.tml
new file mode 100644
index 000000000..09b69818c
--- /dev/null
+++ b/tapestry-core/src/jmh/webapp/RichPage.tml
@@ -0,0 +1,40 @@
+<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd"; 
xmlns:p="tapestry:parameter">
+    <head>
+        <title>${title}</title>
+    </head>
+    <body>
+        <h1>${title}</h1>
+
+        <t:if test="flag">
+            <p>Shown when the flag is set.</p>
+            <p:else>
+                <p>Shown otherwise.</p>
+            </p:else>
+        </t:if>
+
+        <ul class="items">
+            <li t:type="loop" source="items" value="item">
+                <span class="label">${item}</span>
+                <t:if test="item">
+                    <em>present</em>
+                </t:if>
+            </li>
+        </ul>
+
+        <t:form t:id="form">
+            <t:label for="text"/>
+            <t:textfield t:id="text" value="text"/>
+
+            <t:label for="flagField"/>
+            <t:checkbox t:id="flagField" value="flag"/>
+
+            <t:submit t:id="submit" value="literal:Save"/>
+        </t:form>
+
+        <div class="footer">
+            <t:any element="p" class="note">
+                Assembled from ${componentResources.pageName}.
+            </t:any>
+        </div>
+    </body>
+</html>
diff --git a/tapestry-core/src/jmh/webapp/SimplePage.tml 
b/tapestry-core/src/jmh/webapp/SimplePage.tml
new file mode 100644
index 000000000..03ceeefc7
--- /dev/null
+++ b/tapestry-core/src/jmh/webapp/SimplePage.tml
@@ -0,0 +1,11 @@
+<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_0_0.xsd";>
+    <head>
+        <title>${title}</title>
+    </head>
+    <body>
+        <h1>${title}</h1>
+        <p>
+            A page with no embedded components, used as the baseline of the 
page-load benchmarks.
+        </p>
+    </body>
+</html>
diff --git a/tapestry-ioc/build.gradle b/tapestry-ioc/build.gradle
index ce2e31c76..0c3b70404 100644
--- a/tapestry-ioc/build.gradle
+++ b/tapestry-ioc/build.gradle
@@ -1,5 +1,6 @@
 plugins {
     id 'tapestry.junit5-spock-convention'
+    id 'tapestry.jmh-convention'
 }
 description = 'A code-centric, high-performance, simple Inversion of Control 
container'
 
diff --git 
a/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/LocalizedNameGeneratorBenchmark.java
 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/LocalizedNameGeneratorBenchmark.java
new file mode 100644
index 000000000..57ea139ca
--- /dev/null
+++ 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/LocalizedNameGeneratorBenchmark.java
@@ -0,0 +1,93 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.ioc.benchmarks;
+
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.ioc.util.LocalizedNameGenerator;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+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.infra.Blackhole;
+
+/**
+ * Localized resource name generation, which sits underneath message catalogs, 
templates and
+ * assets. Every lookup for a locale walks the whole variant chain ({@code 
Base_de_DE_x.ext},
+ * {@code Base_de_DE.ext}, {@code Base_de.ext}, {@code Base.ext}) until 
something is found, and
+ * a miss walks all of it.
+ *
+ * The generator allocates a {@code StringBuilder} per instance and a {@code 
String} per variant,
+ * so the cost scales with how specific the locale is. That is the point of 
the three benchmarks:
+ * an application serving {@code de_DE} does strictly more work per lookup 
than one serving
+ * {@code en}, and {@link #fullVariant()} is what the worst case actually 
costs.
+ *
+ * Run this one with {@code -Pjmh.profilers=gc}, as the nanoseconds are small, 
and allocations
+ * can be an indicator if it's worth to cache.
+ *
+ * <pre>
+ * ./gradlew :tapestry-ioc:jmh -Pjmh.include=LocalizedNameGenerator 
-Pjmh.profilers=gc
+ * </pre>
+ */
+@State(Scope.Thread)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+public class LocalizedNameGeneratorBenchmark
+{
+    private static final String PATH = "org/example/pages/Index.properties";
+
+    private Locale language;
+
+    private Locale languageAndCountry;
+
+    private Locale full;
+
+    @Setup
+    public void setup()
+    {
+        language = new Locale("en");
+        languageAndCountry = new Locale("de", "DE");
+        full = new Locale("de", "DE", "bavarian");
+    }
+
+    @Benchmark
+    public void languageOnly(Blackhole blackhole)
+    {
+        for (String name : new LocalizedNameGenerator(PATH, language))
+        {
+            blackhole.consume(name);
+        }
+    }
+
+    @Benchmark
+    public void languageAndCountry(Blackhole blackhole)
+    {
+        for (String name : new LocalizedNameGenerator(PATH, 
languageAndCountry))
+        {
+            blackhole.consume(name);
+        }
+    }
+
+    @Benchmark
+    public void fullVariant(Blackhole blackhole)
+    {
+        for (String name : new LocalizedNameGenerator(PATH, full))
+        {
+            blackhole.consume(name);
+        }
+    }
+}
diff --git 
a/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/RegistryStartupBenchmark.java
 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/RegistryStartupBenchmark.java
new file mode 100644
index 000000000..81a098008
--- /dev/null
+++ 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/RegistryStartupBenchmark.java
@@ -0,0 +1,124 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.ioc.benchmarks;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.commons.services.TypeCoercer;
+import org.apache.tapestry5.ioc.Registry;
+import org.apache.tapestry5.ioc.RegistryBuilder;
+import org.apache.tapestry5.ioc.modules.TapestryIOCModule;
+import org.apache.tapestry5.ioc.services.SymbolSource;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+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.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Building the IoC registry, which every application restart pays for before 
it can serve
+ * anything.
+ *
+ * Only tapestry-ioc's own module is loaded here, so this is the floor: module 
class scanning,
+ * service definition, decorator and contribution wiring, and the 
eager-loading of whatever
+ * declares itself eager. Loading a page and its component classes on top of 
it is measured by
+ * {@code PageLoadBenchmark} in tapestry-core.
+ *
+ * Single-shot in a fresh JVM, because that is the only honest way to measure 
startup: half of
+ * what it costs is class loading and interpretation that never happens again. 
Do not run this
+ * with {@code -Pjmh.quick}, which would force three measurement iterations 
per fork and warm
+ * two of them; use {@code -Pjmh.forks} to shorten it instead.
+ *
+ * {@link #buildOnly()} against {@link #buildAndStartup()} separates defining 
the services from
+ * eagerly instantiating them, and {@link #buildAndResolveOneService()} shows 
what the first
+ * service realisation adds on top — that is the proxy-and-construct path 
every later service
+ * repeats.
+ *
+ * <pre>
+ * ./gradlew :tapestry-ioc:jmh -Pjmh.include=RegistryStartup -Pjmh.forks=5
+ * </pre>
+ */
+@State(Scope.Thread)
+@BenchmarkMode(Mode.SingleShotTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@Fork(value = 10, warmups = 0)
+@Warmup(iterations = 0)
+@Measurement(iterations = 1)
+public class RegistryStartupBenchmark
+{
+    private Registry registry;
+
+    @TearDown(Level.Iteration)
+    public void shutdown()
+    {
+        if (registry != null)
+        {
+            registry.shutdown();
+            registry = null;
+        }
+    }
+
+    /**
+     * Definitions only: no service is instantiated.
+     */
+    @Benchmark
+    public Registry buildOnly()
+    {
+        registry = new RegistryBuilder().add(TapestryIOCModule.class).build();
+
+        return registry;
+    }
+
+    @Benchmark
+    public Registry buildAndStartup()
+    {
+        registry = 
RegistryBuilder.buildAndStartupRegistry(TapestryIOCModule.class);
+
+        return registry;
+    }
+
+    /**
+     * Adds the first real service realisation: a Plastic proxy, constructor 
injection, and the
+     * decorator stack around it.
+     */
+    @Benchmark
+    public Object buildAndResolveOneService()
+    {
+        registry = 
RegistryBuilder.buildAndStartupRegistry(TapestryIOCModule.class);
+
+        TypeCoercer coercer = registry.getService(TypeCoercer.class);
+
+        // getService returns a proxy; the service is not built until it is 
called.
+        return coercer.coerce("42", Integer.class);
+    }
+
+    /**
+     * A second, unrelated service, to show whether the first one carried 
shared cost.
+     */
+    @Benchmark
+    public Object buildAndResolveTwoServices()
+    {
+        registry = 
RegistryBuilder.buildAndStartupRegistry(TapestryIOCModule.class);
+
+        Object first = registry.getService(TypeCoercer.class).coerce("42", 
Integer.class);
+        Object second = 
registry.getService(SymbolSource.class).valueForSymbol("tapestry.version");
+
+        return first.hashCode() + second.hashCode();
+    }
+}
diff --git 
a/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/ServiceProxyBenchmark.java
 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/ServiceProxyBenchmark.java
new file mode 100644
index 000000000..107335fdc
--- /dev/null
+++ 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/ServiceProxyBenchmark.java
@@ -0,0 +1,100 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.ioc.benchmarks;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.tapestry5.ioc.Registry;
+import org.apache.tapestry5.ioc.RegistryBuilder;
+import org.apache.tapestry5.ioc.benchmarks.services.Greeter;
+import org.apache.tapestry5.ioc.benchmarks.services.GreeterImpl;
+import org.apache.tapestry5.ioc.benchmarks.services.BenchmarkModule;
+import org.apache.tapestry5.ioc.modules.TapestryIOCModule;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Level;
+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.TearDown;
+
+/**
+ * What a service call costs once the registry is warm.
+ *
+ * Nothing in Tapestry calls a service implementation directly. Every call 
goes through a Plastic
+ * proxy that resolves the service on first use and forwards from then on, and 
a request touches
+ * a great many of them. This measures that indirection against a plain 
virtual call on the same
+ * implementation, so the overhead can be stated as a number rather than 
assumed to be free.
+ *
+ * {@link #lookupAndInvoke()} adds the registry lookup to the call. Code that 
calls
+ * {@code registry.getService(...)} per request rather than injecting once is 
paying that, and
+ * this says how much.
+ *
+ * <pre>
+ * ./gradlew :tapestry-ioc:jmh -Pjmh.include=ServiceProxy
+ * </pre>
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+public class ServiceProxyBenchmark
+{
+    private Registry registry;
+
+    private Greeter proxy;
+
+    private Greeter direct;
+
+    private String name;
+
+    @Setup(Level.Trial)
+    public void startup()
+    {
+        registry = 
RegistryBuilder.buildAndStartupRegistry(TapestryIOCModule.class, 
BenchmarkModule.class);
+
+        proxy = registry.getService(Greeter.class);
+        direct = new GreeterImpl();
+
+        name = "Tapestry";
+
+        // Realise the service, so that the first measured call is not the one 
that builds it.
+        proxy.greet(name);
+    }
+
+    @TearDown(Level.Trial)
+    public void shutdown()
+    {
+        registry.shutdown();
+    }
+
+    /** The control: a plain interface call, no container involved. */
+    @Benchmark
+    public String directCall()
+    {
+        return direct.greet(name);
+    }
+
+    @Benchmark
+    public String proxyCall()
+    {
+        return proxy.greet(name);
+    }
+
+    @Benchmark
+    public String lookupAndInvoke()
+    {
+        return registry.getService(Greeter.class).greet(name);
+    }
+}
diff --git 
a/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/services/BenchmarkModule.java
 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/services/BenchmarkModule.java
new file mode 100644
index 000000000..ea05baf45
--- /dev/null
+++ 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/services/BenchmarkModule.java
@@ -0,0 +1,23 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.ioc.benchmarks.services;
+
+import org.apache.tapestry5.ioc.ServiceBinder;
+
+public class BenchmarkModule
+{
+    public static void bind(ServiceBinder binder)
+    {
+        binder.bind(Greeter.class, GreeterImpl.class);
+    }
+}
diff --git 
a/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/services/Greeter.java
 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/services/Greeter.java
new file mode 100644
index 000000000..614f9a2d5
--- /dev/null
+++ 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/services/Greeter.java
@@ -0,0 +1,25 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.ioc.benchmarks.services;
+
+/**
+ * A service whose implementation does almost nothing, so that
+ * {@code org.apache.tapestry5.ioc.benchmarks.ServiceProxyBenchmark} measures 
the cost of getting
+ * to it rather than the cost of the work.
+ */
+public interface Greeter
+{
+    String greet(String name);
+
+    int count();
+}
diff --git 
a/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/services/GreeterImpl.java
 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/services/GreeterImpl.java
new file mode 100644
index 000000000..7a8f6132c
--- /dev/null
+++ 
b/tapestry-ioc/src/jmh/java/org/apache/tapestry5/ioc/benchmarks/services/GreeterImpl.java
@@ -0,0 +1,32 @@
+// 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
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.ioc.benchmarks.services;
+
+public class GreeterImpl implements Greeter
+{
+    private int count;
+
+    @Override
+    public String greet(String name)
+    {
+        count++;
+
+        return name;
+    }
+
+    @Override
+    public int count()
+    {
+        return count;
+    }
+}

Reply via email to