This is an automated email from the ASF dual-hosted git repository. papegaaij pushed a commit to branch wicket-6774 in repository https://gitbox.apache.org/repos/asf/wicket.git
commit 8c5407b2eea79f8af95bd41d85312e82ffd45bbb Author: Emond Papegaaij <[email protected]> AuthorDate: Sat Sep 5 14:18:02 2026 +0200 WICKET-6774: benchmark the empty and populated model paths separately getDefaultModel() behaves differently depending on whether a model is actually there, and only the populated case was being measured. Inferring the empty case by subtracting the all-model array from the mixed array is not valid: the arrays differ in length and in the types seen at the unpacking call site, and doing so pointed an investigation at the wrong cause. Reading each case from its own array instead: readModelAllHaveModel only shapes that carry a model readModelNoneHaveModel only shapes that do not Each read benchmark also gets a baseline twin that walks the same array with the same blackhole and reads a plain field, so the loop and blackhole overhead can be subtracted and what is left is the accessor. That overhead is a large part of these numbers: over eleven components it is roughly 4ns of a 12ns measurement. The README gains the two things that cost the most time to learn: measure a case rather than deriving it from others, and set the fork heap deliberately. Too large is machine dependent, too small is GC noise - the render benchmark reports 274 +- 437 us/op in 1GB and 102 +- 2 us/op in 4GB. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- wicket-benchmarks/README.md | 19 ++++ .../wicket/benchmarks/ComponentStateBenchmark.java | 120 +++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/wicket-benchmarks/README.md b/wicket-benchmarks/README.md index 899833fce5..92125ba863 100644 --- a/wicket-benchmarks/README.md +++ b/wicket-benchmarks/README.md @@ -32,6 +32,17 @@ actually loaded before believing any number. 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. @@ -64,6 +75,14 @@ dispatches on shape. Real pages interleave shapes. A large gap between `readMeta `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 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 index b44b5a66af..79914dc282 100644 --- a/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/ComponentStateBenchmark.java +++ b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/ComponentStateBenchmark.java @@ -16,6 +16,8 @@ */ package org.apache.wicket.benchmarks; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.TimeUnit; import org.apache.wicket.AttributeModifier; @@ -134,6 +136,11 @@ public class ComponentStateBenchmark this.ajax = ajax; } + boolean hasModel() + { + return model; + } + Component newComponent(String id) { Component c = new WebMarkupContainer(id); @@ -241,6 +248,70 @@ public class ComponentStateBenchmark } } + /** + * 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 @@ -320,6 +391,55 @@ public class ComponentStateBenchmark } } + /** + * 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
