This is an automated email from the ASF dual-hosted git repository. papegaaij pushed a commit to branch performance-improvements-10.x in repository https://gitbox.apache.org/repos/asf/wicket.git
commit 92c69f8828daf9b171c1b2d25779e276ade92eaf Author: Emond Papegaaij <[email protected]> AuthorDate: Fri Sep 11 22:04:16 2026 +0200 Add wicket-benchmarks module A development aid, kept in the default reactor so the benchmarks keep compiling against the current API. It is never released and has no unit tests; the README covers how to run it. The component benchmarks used to investigate WICKET-6774 only ever existed as attachments on the issue, and they no longer compile: WicketTester has moved to its own module, and since JDK 23 javac no longer runs annotation processors found on the classpath, so JMH silently produces no BenchmarkList and the run executes nothing. Keeping them in the reactor means they keep compiling. It holds JMH benchmarks for component state, page rendering, resource name iteration and page serialization, a ComponentFootprint tool that reports retained heap through JOL and serialized size per state shape, and WicketContext as the shared harness. Backported from master. PageEncryptionBenchmark is left out: it measures SchemeCrypt and ICryptScheme, which this branch does not have. Locale.of() is Java 19, so the benchmark uses new Locale() here. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- pom.xml | 19 + wicket-benchmarks/README.md | 111 +++++ wicket-benchmarks/pom.xml | 86 ++++ .../wicket/benchmarks/ComponentFootprint.java | 122 ++++++ .../wicket/benchmarks/ComponentStateBenchmark.java | 467 +++++++++++++++++++++ .../wicket/benchmarks/PageRenderBenchmark.java | 118 ++++++ .../benchmarks/PageSerializationBenchmark.java | 124 ++++++ .../benchmarks/ResourceNameIteratorBenchmark.java | 126 ++++++ .../apache/wicket/benchmarks/WicketContext.java | 53 +++ 9 files changed, 1226 insertions(+) diff --git a/pom.xml b/pom.xml index 4dcdbce56d..c4a58a4924 100644 --- a/pom.xml +++ b/pom.xml @@ -124,6 +124,7 @@ <module>wicket-migration</module> <module>wicket-tester</module> <module>wicket-extensions-tester</module> + <module>wicket-benchmarks</module> </modules> <properties> <!-- Encoding --> @@ -168,6 +169,8 @@ <jakarta.servlet-api.version>6.0.0</jakarta.servlet-api.version> <jdk-serializable-functional.version>1.9.0</jdk-serializable-functional.version> <jetty.version>11.0.24</jetty.version> + <jmh.version>1.37</jmh.version> + <jol.version>0.17</jol.version> <junit.version>5.13.4</junit.version> <jsr305.version>3.0.2</jsr305.version> <logback.version>1.2.7</logback.version> @@ -275,6 +278,12 @@ <version>${hamcrest.version}</version> <scope>provided</scope> </dependency> + <dependency> + <groupId>org.openjdk.jmh</groupId> + <artifactId>jmh-generator-annprocess</artifactId> + <version>${jmh.version}</version> + <scope>provided</scope> + </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> @@ -505,6 +514,16 @@ <artifactId>objenesis</artifactId> <version>${objenesis.version}</version> </dependency> + <dependency> + <groupId>org.openjdk.jmh</groupId> + <artifactId>jmh-core</artifactId> + <version>${jmh.version}</version> + </dependency> + <dependency> + <groupId>org.openjdk.jol</groupId> + <artifactId>jol-core</artifactId> + <version>${jol.version}</version> + </dependency> <dependency> <groupId>org.ow2.asm</groupId> <artifactId>asm</artifactId> diff --git a/wicket-benchmarks/README.md b/wicket-benchmarks/README.md new file mode 100644 index 0000000000..92125ba863 --- /dev/null +++ b/wicket-benchmarks/README.md @@ -0,0 +1,111 @@ +# wicket-benchmarks + +Development aid. Never released, contains no unit tests. It lives in the reactor so the +benchmarks keep compiling against the current API — the previous set of component benchmarks was +only ever attached to [WICKET-6774] and had rotted by the time anyone wanted to re-run them. + +## Running + +Benchmarks are in `src/main/java`, so a plain `compile` is enough: + +```bash +# the JS resource optimizer re-minifies its own output, so wicket-core needs a clean first +mvn -o clean -pl wicket-core +mvn -o -pl wicket-benchmarks -am compile + +# classpath for the freshly built classes (not the jars in ~/.m2) +mvn -o -pl wicket-benchmarks dependency:build-classpath \ + -Dmdep.outputFile=wicket-benchmarks/target/ext-cp.txt +CP="wicket-benchmarks/target/classes:wicket-core/target/classes:wicket-util/target/classes:\ +wicket-request/target/classes:wicket-tester/target/classes:\ +$(cat wicket-benchmarks/target/ext-cp.txt)" +``` + +Beware: `dependency:build-classpath` lists the **`~/.m2` jars** for `wicket-core` and friends. +If you leave them on the classpath you are measuring whatever was last installed, not your working +copy. Put the `target/classes` directories first, as above, and confirm which implementation you +actually loaded before believing any number. + +### Time and allocation + +```bash +java -cp "$CP" org.openjdk.jmh.Main ComponentStateBenchmark -prof gc +``` + +**Set the heap deliberately, and size it to the benchmark.** JMH forks inherit the default maximum +heap — a quarter of physical memory, so ~15GB on a 64GB machine — which is both larger than needed +and machine dependent. Pass an explicit `-jvmArgs "-Xmx..."` so a run means the same thing on +another machine. + +Do not simply make it small. Too little heap shows up as GC noise, and it can be severe enough to +swamp the measurement entirely: at 1GB `PageRenderBenchmark`, which allocates a page per +invocation, reported 274 ± 437 us/op — an error bar larger than the mean. The same measurement at +4GB is 102 ± 2 us/op. When a result looks noisy, suspect the heap before you believe the noise. +The accessor benchmarks are content with 1GB; the render benchmark wants 4GB. + +`-prof gc` is not optional in practice: `gc.alloc.rate.norm` (bytes per operation) is the number +that matters for a framework that keeps many pages in memory, and it is far steadier than +throughput. + +### Footprint and serialized size + +```bash +java --add-opens java.base/java.lang=ALL-UNNAMED -cp "$CP" \ + org.apache.wicket.benchmarks.ComponentFootprint +``` + +JOL needs the `--add-opens` to walk the graph. Run it a second time with +`-XX:+UseCompactObjectHeaders`: that flag moves every object by 4 bytes and can change which +layout wins, so a footprint claim without it is only half the story. + +## What is here, and what each part is for + +| | measures | use it for | +|---|---|---| +| `ComponentStateBenchmark` | ns/op and bytes/op of the per-request state accessors | attributing a change to state handling | +| `ComponentFootprint` | retained heap and serialized bytes per state shape | anything about memory | +| `PageRenderBenchmark` | µs/op of a full 50-child panel render | catching an end-to-end regression | + +## Reading the results + +**Mixed shapes are the interesting ones.** `Component.data` holds a different kind of object +depending on which of model, behaviors and meta data are present. Feed one shape at a time and the +call sites that unpack it are monomorphic and inline, which flatters any implementation that +dispatches on shape. Real pages interleave shapes. A large gap between `readMetaData` and +`readMetaDataMixedShapes` is the signature of dispatch that stopped inlining, and it is invisible +to a per-shape benchmark. + +**Measure the case, do not derive it.** Every `read*` benchmark has a `baseline*` twin that walks +the same array with the same blackhole and reads a plain field instead of the state; subtract it to +get the accessor's own cost. And where a path has distinct cases, benchmark each directly rather +than subtracting one array from another: `readModelAllHaveModel` and `readModelNoneHaveModel` exist +because inferring the empty case by subtracting the populated one from the mixed one gave an answer +that sent an investigation after the wrong cause. The arrays differ in length and in type profile, +so the subtraction is not valid. + +**Do not measure a mutation repeatedly against one instance.** `detach()` is not idempotent: the +first call detaches models, drops temporary behaviors and compacts the behavior array, so every +later call exercises the already-detached path. The original benchmark did exactly this and so +measured the cheap case with great precision. `buildAndDetach` folds construction into the +operation instead, which keeps every invocation doing real work without paying +`Level.Invocation` overhead. + +**Measure the shape whose cost you are actually arguing about.** The shapes are not equally +interesting, and the difference is not proportional to how exotic they look. `STABLE_ID_BEHAVIOR` +is the case every link and ajax-enabled component hits, and it is where the storage layout makes by +far the largest difference — master keeps a `BehaviorIdList` plus two `Object[]` per component to +record one behavior id, which is ~72 bytes of heap and ~32 bytes of serialized form per component. +A benchmark built only from `AttributeModifier` never creates that structure and will report a few +percent where the real figure is tens of percent. WICKET-6774's own comments named this as the +main win; the first version of this file missed it entirely. + +**Weight results by what real pages contain.** From a production app measured on WICKET-6774 +(548,285 components across 2,635 pages): 39% carry a model, 35% at least one behavior, 8% any meta +data, and 0.03% more than one meta data entry. An 80% win on a shape that is 1% of components is +worth less than a 5% win on models. + +**Comparing two implementations** means running the same benchmark source against both, because +these deliberately use public API only. Build each tree separately and keep the classpaths +straight; a git worktree per side is the least error-prone way. + +[WICKET-6774]: https://issues.apache.org/jira/browse/WICKET-6774 diff --git a/wicket-benchmarks/pom.xml b/wicket-benchmarks/pom.xml new file mode 100644 index 0000000000..101617d0ad --- /dev/null +++ b/wicket-benchmarks/pom.xml @@ -0,0 +1,86 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>org.apache.wicket</groupId> + <artifactId>wicket-parent</artifactId> + <version>10.12.0-SNAPSHOT</version> + <relativePath>../pom.xml</relativePath> + </parent> + <artifactId>wicket-benchmarks</artifactId> + <packaging>jar</packaging> + <name>Wicket Benchmarks</name> + <description> + JMH benchmarks and footprint tools for Wicket internals. This module is a development + aid: it is never released and contains no unit tests. It is part of the reactor so that + the benchmarks keep compiling against the current API instead of rotting outside the + repository. + </description> + <properties> + <!-- development aid, never published --> + <maven.deploy.skip>true</maven.deploy.skip> + <osgi.export.package /> + </properties> + <dependencies> + <dependency> + <groupId>org.apache.wicket</groupId> + <artifactId>wicket-core</artifactId> + </dependency> + <dependency> + <groupId>org.apache.wicket</groupId> + <artifactId>wicket-tester</artifactId> + <scope>compile</scope> + </dependency> + <dependency> + <groupId>org.openjdk.jmh</groupId> + <artifactId>jmh-core</artifactId> + </dependency> + <dependency> + <groupId>org.openjdk.jol</groupId> + <artifactId>jol-core</artifactId> + </dependency> + </dependencies> + <build> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + <executions> + <execution> + <!-- + JDK 23 and later no longer run annotation processors found on the + classpath, so JMH's generator has to be named explicitly or no + BenchmarkList is produced and the jar runs zero benchmarks. + --> + <id>default-compile</id> + <configuration> + <annotationProcessorPaths> + <path> + <groupId>org.openjdk.jmh</groupId> + <artifactId>jmh-generator-annprocess</artifactId> + <version>${jmh.version}</version> + </path> + </annotationProcessorPaths> + </configuration> + </execution> + </executions> + </plugin> + </plugins> + </build> +</project> diff --git a/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/ComponentFootprint.java b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/ComponentFootprint.java new file mode 100644 index 0000000000..e949bf0488 --- /dev/null +++ b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/ComponentFootprint.java @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.benchmarks; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectOutputStream; + +import org.apache.wicket.Component; +import org.apache.wicket.benchmarks.ComponentStateBenchmark.Shape; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.openjdk.jol.info.GraphLayout; + +/** + * Reports what component state actually costs, which is what the single-field packing in + * {@code Component.data} exists to minimise and what no throughput benchmark can tell you. + * <p> + * Two numbers per state shape, both measured against an identical tree whose components carry no + * state at all, so the difference isolates the state itself: + * <ul> + * <li><b>retained heap</b>, via JOL's graph walk - what a live page costs in the page cache. + * <li><b>serialized bytes</b>, via Java serialization - what it costs in the page store, and the + * constraint that any extra wrapper class also pays for its class descriptor. + * </ul> + * Run it twice, with and without {@code -XX:+UseCompactObjectHeaders}: that flag shifts every + * object by 4 bytes and can change which layout wins. + * <p> + * Not a JMH benchmark - it measures size, not time, so it is a plain main. + */ +public final class ComponentFootprint +{ + private static final int CHILDREN = 1_000; + + private ComponentFootprint() + { + } + + public static void main(String[] args) throws Exception + { + WicketContext.attach(); + try + { + System.out.printf("Component state footprint, %d children per tree%n", CHILDREN); + System.out.printf("compact object headers: %s%n%n", compactHeaders()); + + long baseHeap = retained(tree(Shape.NONE)); + long baseWire = serialized(tree(Shape.NONE)); + + System.out.printf("%-24s %12s %12s %10s %12s %12s %10s%n", "shape", "heap", "heap-Δ", + "Δ/comp", "wire", "wire-Δ", "Δ/comp"); + System.out.println("-".repeat(98)); + + for (Shape shape : Shape.values()) + { + long heap = retained(tree(shape)); + long wire = serialized(tree(shape)); + System.out.printf("%-24s %12d %12d %10.1f %12d %12d %10.1f%n", shape, heap, + heap - baseHeap, (heap - baseHeap) / (double)CHILDREN, wire, wire - baseWire, + (wire - baseWire) / (double)CHILDREN); + } + + Shape detail = args.length > 0 ? Shape.valueOf(args[0]) + : Shape.MODEL_BEHAVIOR_METADATA; + System.out.printf("%n%nWhere the bytes are, for %s:%n%n", detail); + System.out.println(GraphLayout.parseInstance(tree(detail)).toFootprint()); + } + finally + { + WicketContext.detach(); + } + } + + /** A parent with {@link #CHILDREN} children, each carrying the given state shape. */ + private static WebMarkupContainer tree(Shape shape) + { + WebMarkupContainer parent = new WebMarkupContainer("parent"); + for (int i = 0; i < CHILDREN; i++) + { + Component child = new WebMarkupContainer("c" + i); + shape.populate(child); + parent.add(child); + } + return parent; + } + + private static long retained(Object root) + { + return GraphLayout.parseInstance(root).totalSize(); + } + + private static long serialized(Component root) throws IOException + { + root.detach(); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) + { + out.writeObject(root); + } + return bytes.size(); + } + + private static String compactHeaders() + { + // a plain Object is 16 bytes with 12-byte headers, 8 with compact ones + long size = GraphLayout.parseInstance(new Object()).totalSize(); + return size <= 8 ? "on" : "off"; + } +} diff --git a/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/ComponentStateBenchmark.java b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/ComponentStateBenchmark.java new file mode 100644 index 0000000000..063662137f --- /dev/null +++ b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/ComponentStateBenchmark.java @@ -0,0 +1,467 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.benchmarks; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import org.apache.wicket.AttributeModifier; +import org.apache.wicket.Component; +import org.apache.wicket.MetaDataKey; +import org.apache.wicket.ajax.AjaxEventBehavior; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.behavior.Behavior; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.model.Model; +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.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Benchmarks the per-request accessors on {@link Component}'s flexible state (model, behaviors and + * meta data) plus the mutate-and-detach cycle. + * <p> + * Deliberately written against public Wicket API only, so that the exact same source can be run + * against different implementations of the state storage and compared. + * <p> + * Three things are measured separately, because they answer different questions: + * <ul> + * <li>{@code read*} - the cost of reading state, per state shape. Reads do not mutate, so a + * trial-scoped component is correct here and no per-invocation harness overhead is paid. + * <li>{@code read*MixedShapes} - the same reads, but over a component array holding every shape at + * once. This is the interesting one: with a single shape the call sites inside the state lookup are + * monomorphic and inline, which flatters any implementation that dispatches on the shape. Real + * pages interleave shapes. A large gap between the per-shape and mixed numbers is the signature of + * dispatch that stopped inlining. + * <li>{@code buildAndDetach} - construct a component, populate its state and detach it, as one + * operation. Detaching mutates state (temporary behaviors are removed, arrays are compacted), so it + * cannot be measured repeatedly against the same instance; folding construction into the operation + * keeps every invocation doing the real work without resorting to {@code Level.Invocation}. + * </ul> + * Single threaded on purpose: component state is per component and never contended, so extra + * threads measure nothing new while making the Wicket thread-local setup harder to get right. + * <p> + * Always run with {@code -prof gc}: {@code gc.alloc.rate.norm} (bytes per operation) is the number + * that matters for a framework that has to keep many pages in memory, and it is far more stable + * than throughput. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(3) +@Threads(1) +@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) +public class ComponentStateBenchmark +{ + static final MetaDataKey<String> KEY = new MetaDataKey<>() + { + private static final long serialVersionUID = 1L; + }; + + /** One ingredient of a {@link Shape}. */ + enum Trait + { + /** A default model. */ + MODEL, + /** A behavior that never needs an id, the way an {@link AttributeModifier} does not. */ + BEHAVIOR, + /** A single meta data entry. */ + METADATA, + /** + * A behavior whose id has been handed out, as every link and ajax-enabled component has. + * Master keeps those ids in a {@code BehaviorIdList} held in the component's meta data; + * storing the id as the behavior's own array index removes that list, which WICKET-6774 + * claimed as its biggest saving. {@link #BEHAVIOR} does not exercise it. + */ + STABLE_ID, + /** + * Makes {@link #STABLE_ID} carry a real {@link AjaxEventBehavior} rather than a bare one, + * so the figure is comparable to the -36.2% serialized saving reported on WICKET-6774. A + * bare behavior isolates the id storage but carries almost nothing of its own, which + * flatters the percentage. + */ + AJAX; + } + + /** The shapes the flexible state of a component can take. */ + public enum Shape + { + NONE, + MODEL(Trait.MODEL), + BEHAVIOR(Trait.BEHAVIOR), + METADATA(Trait.METADATA), + MODEL_BEHAVIOR(Trait.MODEL, Trait.BEHAVIOR), + MODEL_METADATA(Trait.MODEL, Trait.METADATA), + BEHAVIOR_METADATA(Trait.BEHAVIOR, Trait.METADATA), + MODEL_BEHAVIOR_METADATA(Trait.MODEL, Trait.BEHAVIOR, Trait.METADATA), + STABLE_ID_BEHAVIOR(Trait.STABLE_ID), + MODEL_STABLE_ID_BEHAVIOR(Trait.MODEL, Trait.STABLE_ID), + AJAX_BEHAVIOR(Trait.STABLE_ID, Trait.AJAX); + + private final Set<Trait> traits; + + Shape(Trait... traits) + { + this.traits = EnumSet.noneOf(Trait.class); + Collections.addAll(this.traits, traits); + } + + boolean hasModel() + { + return traits.contains(Trait.MODEL); + } + + Component newComponent(String id) + { + Component c = new WebMarkupContainer(id); + populate(c); + return c; + } + + void populate(Component c) + { + if (traits.contains(Trait.MODEL)) + { + c.setDefaultModel(Model.of(c.getId())); + } + if (traits.contains(Trait.BEHAVIOR)) + { + c.add(AttributeModifier.replace("class", "a")); + } + if (traits.contains(Trait.METADATA)) + { + c.setMetaData(KEY, "v"); + } + if (traits.contains(Trait.STABLE_ID)) + { + Behavior stable = traits.contains(Trait.AJAX) ? new AjaxTestBehavior() + : new StableIdBehavior(); + c.add(stable); + // rendering a callback url does this; it is what materialises the id storage + c.getBehaviorId(stable); + } + } + } + + /** A real ajax behavior, with the fields and callback machinery that implies. */ + private static class AjaxTestBehavior extends AjaxEventBehavior + { + private static final long serialVersionUID = 1L; + + AjaxTestBehavior() + { + super("change"); + } + + @Override + protected void onEvent(AjaxRequestTarget target) + { + } + } + + /** Requires a stable behavior id, the way an ajax behavior or link does. */ + private static class StableIdBehavior extends Behavior + { + private static final long serialVersionUID = 1L; + + @Override + public boolean getStatelessHint(Component component) + { + return false; + } + } + + /** One component of the shape under test: the state lookup sees a single shape. */ + @State(Scope.Benchmark) + public static class OneShape + { + @Param + public Shape shape; + + Component component; + + @Setup(Level.Trial) + public void setUp() + { + WicketContext.attach(); + component = shape.newComponent("c"); + } + + @TearDown(Level.Trial) + public void tearDown() + { + WicketContext.detach(); + } + } + + /** Every shape at once: the state lookup sees all of them, as it does on a real page. */ + @State(Scope.Benchmark) + public static class AllShapes + { + Component[] components; + + @Setup(Level.Trial) + public void setUp() + { + WicketContext.attach(); + Shape[] shapes = Shape.values(); + components = new Component[shapes.length]; + for (int i = 0; i < shapes.length; i++) + { + components[i] = shapes[i].newComponent("c" + i); + } + } + + @TearDown(Level.Trial) + public void tearDown() + { + WicketContext.detach(); + } + } + + /** + * Only the shapes that carry a model, so every {@code data} seen at the model lookup is a + * wrapper. Separates a type-profile effect from the cost of the lookup itself: if the mixed + * shape penalty disappears here, it was profile pollution at the type check. + */ + @State(Scope.Benchmark) + public static class ModelShapes + { + Component[] components; + + @Setup(Level.Trial) + public void setUp() + { + WicketContext.attach(); + List<Component> cs = new ArrayList<>(); + for (Shape shape : Shape.values()) + { + if (shape.hasModel()) + { + cs.add(shape.newComponent("c" + cs.size())); + } + } + components = cs.toArray(new Component[0]); + } + + @TearDown(Level.Trial) + public void tearDown() + { + WicketContext.detach(); + } + } + + /** + * Only the shapes with no model, so the model lookup always comes up empty. Measures the + * absent path directly instead of inferring it by subtracting the all-model case, which uses + * a different array and a different type profile. + */ + @State(Scope.Benchmark) + public static class NoModelShapes + { + Component[] components; + + @Setup(Level.Trial) + public void setUp() + { + WicketContext.attach(); + List<Component> cs = new ArrayList<>(); + for (Shape shape : Shape.values()) + { + if (!shape.hasModel()) + { + cs.add(shape.newComponent("c" + cs.size())); + } + } + components = cs.toArray(new Component[0]); + } + + @TearDown(Level.Trial) + public void tearDown() + { + WicketContext.detach(); + } + } + + /** A component that definitely carries behaviors, for the Ajax id lookup path. */ + @State(Scope.Benchmark) + public static class WithBehaviors + { + Component component; + + @Setup(Level.Trial) + public void setUp() + { + WicketContext.attach(); + component = new WebMarkupContainer("c"); + component.setDefaultModel(Model.of("m")); + // ids have to be handed out before they can be looked up: master only builds its + // BehaviorIdList when getBehaviorId is called, and throws + // InvalidBehaviorIdException otherwise. Rendering a callback url does this. + for (int i = 0; i < 3; i++) + { + Behavior stable = new StableIdBehavior(); + component.add(stable); + component.getBehaviorId(stable); + } + } + + @TearDown(Level.Trial) + public void tearDown() + { + WicketContext.detach(); + } + } + + // ---------------------------------------------------------------- reads, one shape at a time + + @Benchmark + public Object readMetaData(OneShape ctx) + { + return ctx.component.getMetaData(KEY); + } + + @Benchmark + public Object readModel(OneShape ctx) + { + return ctx.component.getDefaultModel(); + } + + @Benchmark + public Object readBehaviors(OneShape ctx) + { + return ctx.component.getBehaviors(Behavior.class); + } + + // ------------------------------------------------------------------- reads, shapes interleaved + + @Benchmark + public void readMetaDataMixedShapes(AllShapes ctx, Blackhole bh) + { + for (Component c : ctx.components) + { + bh.consume(c.getMetaData(KEY)); + } + } + + @Benchmark + public void readModelMixedShapes(AllShapes ctx, Blackhole bh) + { + for (Component c : ctx.components) + { + bh.consume(c.getDefaultModel()); + } + } + + @Benchmark + public void readBehaviorsMixedShapes(AllShapes ctx, Blackhole bh) + { + for (Component c : ctx.components) + { + bh.consume(c.getBehaviors(Behavior.class)); + } + } + + /** + * The floor for the mixed shape benchmarks: same array, same loop, same blackhole, reading a + * plain field instead of the state. Subtract this to get the cost of the accessor alone. + */ + @Benchmark + public void baselineMixedShapes(AllShapes ctx, Blackhole bh) + { + for (Component c : ctx.components) + { + bh.consume(c.getId()); + } + } + + @Benchmark + public void readModelAllHaveModel(ModelShapes ctx, Blackhole bh) + { + for (Component c : ctx.components) + { + bh.consume(c.getDefaultModel()); + } + } + + @Benchmark + public void readModelNoneHaveModel(NoModelShapes ctx, Blackhole bh) + { + for (Component c : ctx.components) + { + bh.consume(c.getDefaultModel()); + } + } + + @Benchmark + public void baselineNoneHaveModel(NoModelShapes ctx, Blackhole bh) + { + for (Component c : ctx.components) + { + bh.consume(c.getId()); + } + } + + @Benchmark + public void baselineAllHaveModel(ModelShapes ctx, Blackhole bh) + { + for (Component c : ctx.components) + { + bh.consume(c.getId()); + } + } + + // ------------------------------------------------------------------------- the Ajax id lookup + + @Benchmark + public Object readBehaviorById(WithBehaviors ctx) + { + return ctx.component.getBehaviorById(1); + } + + // -------------------------------------------------------------------- mutate, then detach + + @Benchmark + public Object buildAndDetach(OneShape ctx) + { + Component c = ctx.shape.newComponent("c"); + c.detach(); + return c; + } + + @Benchmark + public Object buildOnly(OneShape ctx) + { + return ctx.shape.newComponent("c"); + } +} diff --git a/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/PageRenderBenchmark.java b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/PageRenderBenchmark.java new file mode 100644 index 0000000000..4ef5039ac3 --- /dev/null +++ b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/PageRenderBenchmark.java @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.benchmarks; + +import java.util.concurrent.TimeUnit; + +import org.apache.wicket.Component; +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.benchmarks.ComponentStateBenchmark.Shape; +import org.apache.wicket.markup.IMarkupResourceStreamProvider; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.panel.Panel; +import org.apache.wicket.markup.repeater.RepeatingView; +import org.apache.wicket.mock.MockApplication; +import org.apache.wicket.util.resource.IResourceStream; +import org.apache.wicket.util.resource.StringResourceStream; +import org.apache.wicket.util.tester.WicketTester; +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.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** + * End-to-end render of a panel with 50 stateful children, one shape at a time. + * <p> + * This is a regression guard, not a measurement of state access: markup parsing, hierarchy + * traversal and response writing dominate a render, so a change worth a few nanoseconds per field + * read disappears into the noise here. Its job is to catch a change that made rendering as a whole + * worse. Use {@link ComponentStateBenchmark} to attribute a difference to state handling, and + * {@link ComponentFootprint} for anything about memory. + * <p> + * Replaces the seven near-identical methods of the original benchmark with one parameterised over + * {@link Shape}, which also makes the missing combinations measurable. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Fork(3) +@Threads(1) +@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) +@State(Scope.Thread) +public class PageRenderBenchmark +{ + private static final int CHILDREN = 50; + + @Param + public Shape shape; + + private WicketTester tester; + + @Setup(Level.Trial) + public void setUp() + { + tester = new WicketTester(new MockApplication()); + } + + @TearDown(Level.Trial) + public void tearDown() + { + tester.destroy(); + } + + @Benchmark + public Object renderPanel() + { + return tester.startComponentInPage(new StatefulPanel("panel", shape)); + } + + private static class StatefulPanel extends Panel implements IMarkupResourceStreamProvider + { + private static final long serialVersionUID = 1L; + + StatefulPanel(String id, Shape shape) + { + super(id); + RepeatingView view = new RepeatingView("rv"); + for (int i = 0; i < CHILDREN; i++) + { + Component child = new WebMarkupContainer(view.newChildId()); + shape.populate(child); + view.add(child); + } + add(view); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class< ? > containerClass) + { + return new StringResourceStream( + "<wicket:panel><div wicket:id=\"rv\"></div></wicket:panel>"); + } + } +} diff --git a/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/PageSerializationBenchmark.java b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/PageSerializationBenchmark.java new file mode 100644 index 0000000000..9914fde122 --- /dev/null +++ b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/PageSerializationBenchmark.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.benchmarks; + +import java.util.concurrent.TimeUnit; + +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.markup.IMarkupResourceStreamProvider; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.repeater.RepeatingView; +import org.apache.wicket.mock.MockApplication; +import org.apache.wicket.model.Model; +import org.apache.wicket.serialize.java.JavaSerializer; +import org.apache.wicket.util.resource.IResourceStream; +import org.apache.wicket.util.resource.StringResourceStream; +import org.apache.wicket.util.tester.WicketTester; +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.Param; +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.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Measures writing a page to bytes, which is what {@code SerializingPageStore} does for every page + * at the end of a request. + * <p> + * Parameterised by component count because the cost tracks the number of objects in the graph + * rather than the size of the result: {@code ObjectOutputStream} keeps a handle table per stream + * and grows it as it walks, so a page of many small components costs more than its byte count + * suggests. + * <p> + * {@code -prof gc} is the point of this one. {@code gc.alloc.rate.norm} covers both the serialized + * bytes and everything the stream allocated to produce them, so read it against the result size: + * 50 components serialize to about 5kB, 500 components to about 37kB. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Fork(3) +@Threads(1) +@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) +@State(Scope.Thread) +public class PageSerializationBenchmark +{ + @Param({"50", "500"}) + public int components; + + private WicketTester tester; + + private JavaSerializer serializer; + + private WebPage page; + + @Setup(Level.Trial) + public void setUp() + { + tester = new WicketTester(new MockApplication()); + serializer = new JavaSerializer("benchmarks"); + page = new BenchmarkPage(components); + tester.startPage(page); + } + + @TearDown(Level.Trial) + public void tearDown() + { + tester.destroy(); + } + + @Benchmark + public byte[] serializePage() + { + return serializer.serialize(page); + } + + private static class BenchmarkPage extends WebPage implements IMarkupResourceStreamProvider + { + private static final long serialVersionUID = 1L; + + BenchmarkPage(int components) + { + RepeatingView view = new RepeatingView("rv"); + for (int i = 0; i < components; i++) + { + WebMarkupContainer child = new WebMarkupContainer(view.newChildId()); + child.add(new Label("label", Model.of("value " + i))); + view.add(child); + } + add(view); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class< ? > containerClass) + { + return new StringResourceStream( + "<html><body><div wicket:id=\"rv\"><span wicket:id=\"label\"></span></div></body></html>"); + } + } +} diff --git a/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/ResourceNameIteratorBenchmark.java b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/ResourceNameIteratorBenchmark.java new file mode 100644 index 0000000000..0bec6b11c7 --- /dev/null +++ b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/ResourceNameIteratorBenchmark.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.benchmarks; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import org.apache.wicket.core.util.resource.locator.ResourceNameIterator; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Benchmarks the candidate-filename walk that {@code ResourceStreamLocator} performs for every + * property and markup lookup, once per registered properties loader. + * <p> + * The walk dominates i18n lookup cost on a miss, and misses are the common case: a key is resolved + * by climbing the component hierarchy, so every class above the one that actually declares the key + * contributes a full traversal that finds nothing. + * <p> + * The two cases are benchmarked directly rather than derived from one another, because they do + * different amounts of work. {@link #walkAllCandidates} is the miss - every combination of style, + * variation, locale and extension is produced. {@link #firstCandidate} is the hit, where the + * locator stops at the first name and then reads back the locale, style and variation to stamp on + * the resource stream; that read-back is the only caller of {@code getLocale()}, so a benchmark + * that never performs it would miss the cost. + * <p> + * Written against public API only, so the same source can be run against two implementations and + * compared. {@code -prof gc} is the point of this one: {@code gc.alloc.rate.norm} is what the + * change being measured actually moves. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(3) +@Threads(1) +@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) +@State(Scope.Benchmark) +public class ResourceNameIteratorBenchmark +{ + /** + * The locale shapes differ in how many candidates they produce, which is the main driver of + * both time and allocation: a language+country locale yields three rounds, language-only two, + * and no locale one. + */ + public enum LocaleShape + { + LANGUAGE_COUNTRY(new Locale("nl", "NL")), + LANGUAGE_ONLY(new Locale("nl")), + WITH_VARIANT(new Locale("nl", "NL", "vlaams")), + NONE(null); + + private final Locale locale; + + LocaleShape(Locale locale) + { + this.locale = locale; + } + } + + // PropertiesFactory hands the locator a path with the extension already appended, and the + // iterator splits it back off. + private static final String PATH = "org/example/app/pages/group/GroupsPage.properties"; + + private static final List<String> ONE_EXTENSION = Arrays.asList("properties"); + + @Param + public LocaleShape localeShape; + + @Param({"false", "true"}) + public boolean styled; + + @Benchmark + public void walkAllCandidates(Blackhole blackhole) + { + ResourceNameIterator names = newIterator(); + while (names.hasNext()) + { + blackhole.consume(names.next()); + } + } + + @Benchmark + public void firstCandidate(Blackhole blackhole) + { + ResourceNameIterator names = newIterator(); + if (names.hasNext()) + { + blackhole.consume(names.next()); + blackhole.consume(names.getLocale()); + blackhole.consume(names.getStyle()); + blackhole.consume(names.getVariation()); + } + } + + private ResourceNameIterator newIterator() + { + return new ResourceNameIterator(PATH, styled ? "mystyle" : null, + styled ? "myvariation" : null, localeShape.locale, ONE_EXTENSION, false); + } +} diff --git a/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/WicketContext.java b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/WicketContext.java new file mode 100644 index 0000000000..858f0fdfa2 --- /dev/null +++ b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/WicketContext.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.benchmarks; + +import org.apache.wicket.Session; +import org.apache.wicket.ThreadContext; +import org.apache.wicket.mock.MockApplication; +import org.apache.wicket.mock.MockWebRequest; +import org.apache.wicket.protocol.http.WebSession; +import org.apache.wicket.protocol.http.mock.MockServletContext; +import org.apache.wicket.request.Url; + +/** + * Minimal Wicket runtime for benchmarks: enough application and session context for components to + * be constructed, read and detached, without the cost of a full {@code WicketTester} request cycle. + */ +final class WicketContext +{ + private WicketContext() + { + } + + static void attach() + { + MockApplication application = new MockApplication(); + application.setName("benchmarks-" + System.nanoTime()); + application.setServletContext(new MockServletContext(application, null)); + ThreadContext.setApplication(application); + application.initApplication(); + + Session session = new WebSession(new MockWebRequest(Url.parse("/"))); + ThreadContext.setSession(session); + } + + static void detach() + { + ThreadContext.detach(); + } +}
