This is an automated email from the ASF dual-hosted git repository.
benweidig pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git
The following commit(s) were added to refs/heads/master by this push:
new 0552a26bb TAP5-2839: Introduce JMH (#69)
0552a26bb is described below
commit 0552a26bb88695984dcf9c5f92966e629f9788a4
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 9b34d28ac..83403fe7a 100755
--- a/build.gradle
+++ b/build.gradle
@@ -295,6 +295,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 156cdb007..35432f06b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -79,6 +79,10 @@ easymock = "5.6.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"
@@ -206,6 +210,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 8b919ee45..a43eb4e13 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 & 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 ba8d0d8b2..12f62c0d9 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;
+ }
+}