Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions MODEL_BENCH_MARK.md

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@papegaaij This document contains the numbers of the new benchmarks

Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# What registering additional models costs

Measured comparison of the two ways a component can hold models beside its default model:

| Variant | Extra models held in | Detached by |
| --- | --- | --- |
| `FIELDS` | one field each | a hand-written `onDetach()` |
| `REGISTERED` | one field each | `addAdditionalModel` |
| `REGISTERED_NO_FIELDS` | nothing | `addAdditionalModel` |

All three carry the same default model and the same extra models, so every difference below
is bookkeeping, never the models themselves.

**Environment:** OpenJDK 24.0.1, `-Xmx1g`, 1,000 children per tree, JOL 0.17, JMH 1.37.
JMH run with `-f 1 -wi 3 -i 3 -r 1 -w 1` — quick mode, so treat `ns/op` as indicative and
`B/op` as exact (allocation is counted, not sampled).

Reproduce with `wicket-benchmarks/run-model-benchmarks.sh`.

---

## Memory — retained heap, bytes per component

Deltas against the same component carrying only a default model.

| Extra models | `FIELDS` | `REGISTERED` | `REGISTERED_NO_FIELDS` | registering costs |
| ---: | ---: | ---: | ---: | ---: |
| 1 | 64.0 | 136.0 | 120.0 | **+72.0** |
| 2 | 128.0 | 200.0 | 184.0 | **+72.0** |
| 3 | 192.0 | 272.0 | 256.0 | **+80.0** |

With `-XX:+UseCompactObjectHeaders` (every object 4 bytes smaller):

| Extra models | `FIELDS` | `REGISTERED` | `REGISTERED_NO_FIELDS` | registering costs |
| ---: | ---: | ---: | ---: | ---: |
| 1 | 63.9 | 119.9 | 111.9 | **+56.0** |
| 2 | 127.8 | 191.8 | 183.8 | **+64.0** |
| 3 | 191.8 | 255.8 | 247.8 | **+64.0** |

## Memory — serialized bytes per component

What the page store pays.

| Extra models | `FIELDS` | `REGISTERED` | `REGISTERED_NO_FIELDS` | registering costs |
| ---: | ---: | ---: | ---: | ---: |
| 1 | 13.9 | 47.2 | 40.2 | **+33.3** |
| 2 | 27.8 | 66.1 | 55.1 | **+38.3** |
| 3 | 41.7 | 85.0 | 70.0 | **+43.3** |

Unaffected by compact headers, as expected — the wire format does not carry object headers.

## Where the bytes go

JOL breakdown for `REGISTERED` with 3 extra models, 1,000 components:

| Object | Count | Avg | Sum |
| --- | ---: | ---: | ---: |
| `AdditionalModelsShapes$RegisteredModels` | 1000 | 72 | 72,000 |
| `[Lorg.apache.wicket.model.IModel;` | 1000 | 32 | 32,000 |
| `org.apache.wicket.ComponentState` | 1000 | 24 | 24,000 |
| `org.apache.wicket.MetaDataEntry` | 1000 | 24 | 24,000 |
| `org.apache.wicket.model.Model` | 4000 | 16 | 64,000 |

The three objects a registered component pays for are exactly `ComponentState` (24) +
`MetaDataEntry` (24) + the `IModel[]` (32) = **80 bytes** — the measured `+80.0` at three
models. The array is the only part that grows: 24 bytes at one model, 32 at three.

## Time and allocation

`build` — constructing the component, where registering does its extra work:

| Extra models | Variant | ns/op | B/op |
| ---: | --- | ---: | ---: |
| 1 | `FIELDS` | 26.2 ± 2.2 | 152 |
| 1 | `REGISTERED` | 33.9 ± 12.6 | 224 |
| 1 | `REGISTERED_NO_FIELDS` | 32.2 ± 2.0 | 208 |
| 2 | `FIELDS` | 35.6 ± 2.0 | 216 |
| 2 | `REGISTERED` | 54.0 ± 4.7 | 312 |
| 2 | `REGISTERED_NO_FIELDS` | 54.4 ± 3.9 | 296 |
| 3 | `FIELDS` | 41.9 ± 1.9 | 280 |
| 3 | `REGISTERED` | 75.6 ± 10.7 | 408 |
| 3 | `REGISTERED_NO_FIELDS` | 76.5 ± 15.8 | 392 |

`buildAndDetach` — the whole per-request cycle:

| Extra models | Variant | ns/op | B/op |
| ---: | --- | ---: | ---: |
| 1 | `FIELDS` | 31.8 ± 2.8 | 152 |
| 1 | `REGISTERED` | 39.8 ± 11.0 | 224 |
| 1 | `REGISTERED_NO_FIELDS` | 38.8 ± 1.2 | 208 |
| 2 | `FIELDS` | 39.1 ± 2.0 | 216 |
| 2 | `REGISTERED` | 60.4 ± 1.7 | 312 |
| 2 | `REGISTERED_NO_FIELDS` | 61.2 ± 2.1 | 296 |
| 3 | `FIELDS` | 47.0 ± 5.6 | 280 |
| 3 | `REGISTERED` | 82.5 ± 5.4 | 408 |
| 3 | `REGISTERED_NO_FIELDS` | 88.4 ± 3.6 | 392 |

---

## Conclusions

**1. The documented "50 to 80 bytes" is right, and slightly conservative at the top end.**
Retained heap costs **72 to 80 bytes per component** with default headers, **56 to 64** with
compact ones. The guide and the `addAdditionalModel` javadoc can stand as they are.

**2. The cost is near-flat in the number of models, as claimed — but not exactly flat.**
72, 72, 80 bytes for one, two and three models. The fixed part is the `MetaDataEntry` plus
the `ComponentState` a component needs once it has more than one kind of state; only the
`IModel[]` grows, and it grows by 8 bytes per few models, not per model. Wording like
"whatever their number" is fair for heap. It is weaker for the wire: **+33, +38, +43
bytes**, about 5 bytes per model, because serialization writes the array and its class
descriptor.

**3. Serialized cost is the one that bites.** A registered component is **3.4× to 2.0×**
the serialized size of the hand-detached one (47.2 vs 13.9 bytes at one model). Small in
absolute terms, but the page store pays it on every page write, for every instance.

**4. Construction is measurably slower, and that is the real per-instance price.**
`build` goes from 41.9 to 75.6 ns/op at three models — roughly **+80%**, well outside the
error bars. Allocation confirms it exactly: **+128 B/op** at three models. This is the
number to weigh for a component rendered in the thousands, and it is a stronger argument
than the heap figure that the components shipped with Wicket should keep detaching their
own models.

**5. Detaching itself is cheaper when registered, which partly offsets construction.**
`buildAndDetach` minus `build` is ~5 ns for `FIELDS` against ~4–7 ns for `REGISTERED`: the
framework's loop over the model array costs about what three hand-written null-checked
`detach()` calls cost. Registering buys its convenience at construction time, not at detach
time.

**6. Dropping the fields is worth 16 bytes of heap and 11–15 bytes on the wire.**
`REGISTERED_NO_FIELDS` beats `REGISTERED` at every model count, and the time difference is
within the noise. A component that only passes models to its children should use the
`(String, IModel, IModel...)` constructor rather than keeping fields it never reads — an
option the hand-detached approach cannot offer at all, since it needs the fields to reach
the models from `onDetach()`.

**Where the trade stops paying.** At ~80 bytes of heap and ~43 of wire per component, a
panel used 50 times on a page costs 4KB of heap — irrelevant. The same feature on a cell
rendered 10,000 times in a `DataTable` costs 800KB of heap, 430KB per serialized page, and
roughly 0.34ms of extra construction per render. The existing guidance — register in
application components, detach by hand in components rendered in the thousands — is
supported by these numbers.
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,23 +67,27 @@ You will find the source code here:
|-- wicket
|-- wicket-auth-roles
|-- wicket-bean-validation
|-- wicket-benchmarks
|-- wicket-cdi
|-- wicket-cdi-tests
|-- wicket-core
|-- wicket-tester
|-- wicket-core-tests
|-- wicket-coverage
|-- wicket-devutils
|-- wicket-eclipse-settings
|-- wicket-examples
|-- wicket-experimental
| |-- wicket-metrics
| |-- wicket-http2
|-- wicket-extensions
|-- wicket-extensions-tester
|-- wicket-guice
|-- wicket-ioc
|-- wicket-jmx
|-- wicket-migration
|-- wicket-native-websocket
|-- wicket-objectssizeof-agent
|-- wicket-objectsizeof-agent
|-- wicket-request
|-- wicket-spring
|-- wicket-util
Expand All @@ -100,7 +104,7 @@ Here is a list of projects in the distribution and what they do.
- wicket-auth-roles: a basic authorization package based on roles;
- wicket-jmx: registers JMX beans for managing things like your Wicket
configuration and markup cache;
- wicket-objectssizeof-agent: utility for making better estimates of object
- wicket-objectsizeof-agent: utility for making better estimates of object
sizes in the JVM - most people probably never need this;
- wicket-ioc: base project for IoC (aka DI) implementations such as
Spring and Guice;
Expand Down Expand Up @@ -129,6 +133,14 @@ Here is a list of projects in the distribution and what they do.
- wicket-user-guide: the user guide of wicket
- wicket-metrics: collects data of a running wicket application
- wicket-http2: http/2 push support
- wicket-extensions-tester: contains test cases for the wicket-extensions module;
- wicket-migration: OpenRewrite recipes which migrate an application to the current
version of wicket;
- wicket-benchmarks: JMH benchmarks used during development. Never released and it
contains no unit tests; it stays in the build so the benchmarks keep compiling
against the current API;
- wicket-coverage: aggregates the JaCoCo coverage of the other modules into a single
report. It produces no artifact of its own.

Dependencies
------------
Expand Down
69 changes: 69 additions & 0 deletions wicket-benchmarks/run-model-benchmarks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
#
# 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.
#
# Runs the additional-models footprint and JMH benchmarks and prints the raw output.
# bash run-model-benchmarks.sh quick (~4 min, 1 fork)
# bash run-model-benchmarks.sh full full rigor (~15 min, 3 forks)

# re-exec under bash when started with sh: dash has no pipefail
if [ -z "${BASH_VERSION:-}" ]; then exec bash "$0" "$@"; fi

set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")/.."

MODE="${1:-quick}"
OUT=target/model-benchmarks.txt
mkdir -p target

echo "### building (quiet) ..." >&2
mvn -o -q clean -pl wicket-core
mvn -o -q -pl wicket-benchmarks -am compile
# Copy the dependency jars into the module, rather than referencing ~/.m2: a confined
# process (Claude Code's snap) can read the repo but not hidden directories under $HOME.
if [ ! -d wicket-benchmarks/target/deps ]; then
mvn -o -q -pl wicket-benchmarks dependency:copy-dependencies \
-DoutputDirectory=wicket-benchmarks/target/deps \
|| mvn -q -pl wicket-benchmarks dependency:copy-dependencies \
-DoutputDirectory=wicket-benchmarks/target/deps
fi

CP="wicket-benchmarks/target/classes:wicket-core/target/classes:wicket-util/target/classes:\
wicket-request/target/classes:wicket-tester/target/classes:wicket-benchmarks/target/deps/*"

{
echo "===== JOL FOOTPRINT, default headers ====="
java --add-opens java.base/java.lang=ALL-UNNAMED -cp "$CP" \
org.apache.wicket.benchmarks.AdditionalModelsFootprint

echo
echo "===== JOL FOOTPRINT, -XX:+UseCompactObjectHeaders ====="
java -XX:+UseCompactObjectHeaders --add-opens java.base/java.lang=ALL-UNNAMED -cp "$CP" \
org.apache.wicket.benchmarks.AdditionalModelsFootprint 2>&1 || \
echo "(compact object headers not supported by this JDK)"

echo
echo "===== JMH, mode=$MODE ====="
if [ "$MODE" = full ]; then
java -cp "$CP" org.openjdk.jmh.Main AdditionalModelsBenchmark -prof gc -jvmArgs "-Xmx1g"
else
java -cp "$CP" org.openjdk.jmh.Main AdditionalModelsBenchmark \
-prof gc -jvmArgs "-Xmx1g" -f 1 -wi 3 -i 3 -r 1 -w 1
fi
} 2>&1 | tee "$OUT"

echo >&2
echo "### raw output also saved to $OUT" >&2
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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.benchmarks.AdditionalModelsShapes.Variant;
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.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;
import org.openjdk.jmh.infra.Blackhole;

/**
* What it costs in time and allocation to let {@code Component} detach a component's extra models
* instead of detaching them by hand, for the shapes of {@link AdditionalModelsShapes}.
* <p>
* Two operations, because they answer different questions:
* <ul>
* <li>{@code build} - constructing the component. This is where registering does its extra work:
* a meta data write and a copy of the model array per model registered.
* <li>{@code buildAndDetach} - construct and detach, the whole per request cycle. Detaching
* mutates state, so it cannot be measured repeatedly against the same instance; folding
* construction into the operation keeps every invocation doing real work.
* </ul>
* Always run with {@code -prof gc}: {@code gc.alloc.rate.norm} is the number that decides whether
* the convenience is affordable for a component rendered in large numbers, and it is far steadier
* than throughput. For the memory a component keeps, rather than the garbage it makes, see
* {@link AdditionalModelsFootprint}.
*/
@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.Thread)
public class AdditionalModelsBenchmark
{
@Param
public Variant variant;

@Param({ "1", "2", "3" })
public int extraModels;

@Setup
public void setUp()
{
WicketContext.attach();
}

@TearDown
public void tearDown()
{
WicketContext.detach();
}

@Benchmark
public Component build()
{
return variant.newComponent("c", extraModels);
}

@Benchmark
public void buildAndDetach(Blackhole blackhole)
{
Component component = variant.newComponent("c", extraModels);
component.detach();
blackhole.consume(component);
}
}
Loading
Loading