Commit ed76499
Morebench marks, speed this time
Changed files (11)
modifiedbenchmarks/README.mdaddedbenchmarks/adapter.jsaddedbenchmarks/build-speed.shmodifiedbenchmarks/dioxus/index.htmladdedbenchmarks/index.htmlmodifiedbenchmarks/kaja/index.htmlmodifiedbenchmarks/leptos/index.htmladdedbenchmarks/runner.jsmodifiedbenchmarks/yew/index.htmlmodifiedbenchmarks/yew/src/lib.rsaddedexamples/main-component/push.sh
Diff
@@ -1,15 +1,33 @@-# Rust/WASM component size benchmark+# Rust/WASM component benchmarks -Each package implements the same click counter: a button labeled `Clicks: 0` that increments when clicked. Kaja registers a `<kaja-counter>` custom element; Yew, Leptos, and Dioxus render their framework component into the document body.+Each package implements the same counter: a button labeled `Clicks: 0` that increments once for every click. Kaja registers a `<kaja-counter>` custom element; Yew, Leptos, and Dioxus render their component into the document body. -## Build and measure+## Build and measure wasm size Requirements: Rust with the `wasm32-unknown-unknown` target, `wasm-pack`, and access to crates.io for dependencies. +From this directory, build all four packages and print the size of each generated WebAssembly module:++```sh+sh build.sh+```++This reports uncompressed `*_bg.wasm` byte counts. It excludes JavaScript glue and HTML and does not apply gzip or Brotli compression.++## Run the speed benchmark++Build the packages first, then serve this directory over HTTP (the runner and adapters must share an origin):+ ```sh-sh wasm-benchmarks/build.sh+python3 -m http.server 8000 ``` -The script builds each package in release mode and prints the byte length of the wasm-bindgen-generated `*_bg.wasm` file. The workspace uses the same size-focused release profile for all packages. The reported byte count is the uncompressed file size; it does not include JavaScript glue or HTML and does not apply gzip/Brotli compression.+Open <http://localhost:8000/> in a browser and select **Run benchmark**. The runner performs one warmup and five measured runs for each selected framework. Each measured run reloads the app, records time from navigation until the counter is mounted, dispatches the same number of synthetic button clicks, and waits until the DOM shows the final count. The table reports median startup time, median click-batch time, and median time per click.++The startup measurement includes page navigation and WebAssembly download/initialization, so later runs generally benefit from the browser cache. Click-batch time measures event handling through the DOM update; it does not measure paint time. Results depend on the browser, machine load, and browser cache state. Compare frameworks in the same browser session and treat small differences cautiously.++## Adding or changing an adapter++The shared runner in `runner.js` relies on a small DOM contract: each app renders a button with `id="counter"`, initially showing `Clicks: 0`, and each click updates it to `Clicks: N`. After its WASM module initializes, the page calls `signalBenchmarkReady()` from `adapter.js`; that helper waits for the button to render and notifies the runner. Keep the rendered markup and update behavior equivalent across adapters so the results stay comparable. -Each package has an `index.html` for manual browser testing after building. Serve a package directory over HTTP (for example, `python3 -m http.server 8000` from that directory) and open it in a browser.+The individual `index.html` files can also be opened directly for manual testing after the packages have been built.new file mode 100644@@ -0,0 +1,16 @@+const READY_TIMEOUT_MS = 30_000;++export async function signalBenchmarkReady() {+ const startedAt = performance.now();++ while (!document.getElementById("counter")) {+ if (performance.now() - startedAt > READY_TIMEOUT_MS) {+ throw new Error("Counter benchmark did not render within 30 seconds");+ }++ await new Promise((resolve) => requestAnimationFrame(resolve));+ }++ await new Promise((resolve) => requestAnimationFrame(resolve));+ window.parent.postMessage({ type: "benchmark:ready" }, window.location.origin);+}new file mode 100644@@ -0,0 +1,15 @@+#!/bin/sh+set -eu++script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)+cd "$script_dir"++build_package() {+ package=$1+ (cd "$package" && wasm-pack build --target web --release --out-dir pkg --no-opt)+}++build_package kaja+build_package yew+build_package leptos+build_package dioxus@@ -5,8 +5,10 @@ <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Dioxus counter benchmark</title> <script type="module">+ import { signalBenchmarkReady } from "../adapter.js"; import init from "./pkg/dioxus_component_benchmark.js"; await init();+ await signalBenchmarkReady(); </script> </head> <body></body>new file mode 100644@@ -0,0 +1,100 @@+<!doctype html>+<html lang="en">+ <head>+ <meta charset="utf-8" />+ <meta name="viewport" content="width=device-width, initial-scale=1" />+ <title>Rust/WASM component speed benchmarks</title>+ <style>+ body {+ font: 16px/1.5 system-ui, sans-serif;+ margin: 2rem auto;+ max-width: 900px;+ padding: 0 1rem;+ }++ form {+ align-items: end;+ display: flex;+ flex-wrap: wrap;+ gap: 1rem;+ margin: 1.5rem 0;+ }++ label {+ display: grid;+ gap: 0.25rem;+ }++ button,+ input,+ select {+ font: inherit;+ padding: 0.4rem 0.65rem;+ }++ table {+ border-collapse: collapse;+ margin: 1rem 0;+ width: 100%;+ }++ th,+ td {+ border-bottom: 1px solid #999;+ padding: 0.5rem;+ text-align: left;+ }++ #benchmark-frame {+ border: 1px solid #999;+ height: 100px;+ margin-top: 1rem;+ width: 100%;+ }++ #status {+ min-height: 1.5em;+ }+ </style>+ <script type="module" src="./runner.js"></script>+ </head>+ <body>+ <h1>Rust/WASM component speed benchmarks</h1>+ <p>+ Compares the same counter component in Kaja, Yew, Leptos, and Dioxus.+ Run this page in the same browser and machine for meaningful comparisons.+ </p>++ <form id="benchmark-form">+ <label>+ Framework+ <select id="framework">+ <option value="all">All frameworks</option>+ <option value="kaja">Kaja</option>+ <option value="yew">Yew</option>+ <option value="leptos">Leptos</option>+ <option value="dioxus">Dioxus</option>+ </select>+ </label>+ <label>+ Clicks per batch+ <input id="click-count" type="number" min="1" max="10000" value="250" required />+ </label>+ <button id="run-benchmark" type="submit">Run benchmark</button>+ </form>++ <p id="status" role="status" aria-live="polite">Build the packages, then run the benchmark.</p>+ <table>+ <thead>+ <tr>+ <th>Framework</th>+ <th>Startup median (ms)</th>+ <th>Click batch median (ms)</th>+ <th>Median per click (µs)</th>+ </tr>+ </thead>+ <tbody id="results"></tbody>+ </table>+ <iframe id="benchmark-frame" title="Component under benchmark"></iframe>+ </body>+</html>@@ -6,7 +6,9 @@ <title>Kaja counter benchmark</title> <script type="module"> import init from "./pkg/kaja_component_benchmark.js";+ import { signalBenchmarkReady } from "../adapter.js"; await init();+ await signalBenchmarkReady(); </script> </head> <body>@@ -5,8 +5,10 @@ <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Leptos counter benchmark</title> <script type="module">+ import { signalBenchmarkReady } from "../adapter.js"; import init from "./pkg/leptos_component_benchmark.js"; await init();+ await signalBenchmarkReady(); </script> </head> <body></body>new file mode 100644@@ -0,0 +1,151 @@+const FRAMEWORKS = [+ { id: "kaja", name: "Kaja" },+ { id: "yew", name: "Yew" },+ { id: "leptos", name: "Leptos" },+ { id: "dioxus", name: "Dioxus" },+];+const WARMUP_RUNS = 1;+const MEASURED_RUNS = 5;+const READY_TIMEOUT_MS = 30_000;++const form = document.getElementById("benchmark-form");+const frameworkSelect = document.getElementById("framework");+const clickCountInput = document.getElementById("click-count");+const runButton = document.getElementById("run-benchmark");+const status = document.getElementById("status");+const results = document.getElementById("results");+const frame = document.getElementById("benchmark-frame");+let navigationId = 0;++function nextFrame() {+ return new Promise((resolve) => requestAnimationFrame(resolve));+}++function median(values) {+ const sorted = [...values].sort((a, b) => a - b);+ return sorted[Math.floor(sorted.length / 2)];+}++function formatMs(value) {+ return value.toFixed(2);+}++function appendResult(name, startupSamples, interactionSamples, clickCount) {+ const row = document.createElement("tr");+ const startupMs = median(startupSamples);+ const interactionMs = median(interactionSamples);+ const perClickMicroseconds = (interactionMs * 1000) / clickCount;++ for (const value of [+ name,+ formatMs(startupMs),+ formatMs(interactionMs),+ perClickMicroseconds.toFixed(2),+ ]) {+ const cell = document.createElement("td");+ cell.textContent = value;+ row.append(cell);+ }++ results.append(row);+}++function loadFramework(framework) {+ return new Promise((resolve, reject) => {+ const startedAt = performance.now();+ const timeout = window.setTimeout(() => {+ window.removeEventListener("message", onMessage);+ reject(new Error(`${framework.name} did not signal that its counter rendered`));+ }, READY_TIMEOUT_MS);++ function onMessage(event) {+ if (+ event.source !== frame.contentWindow ||+ event.origin !== window.location.origin ||+ event.data?.type !== "benchmark:ready"+ ) {+ return;+ }++ window.clearTimeout(timeout);+ window.removeEventListener("message", onMessage);++ const button = frame.contentDocument?.getElementById("counter");+ if (!button || button.textContent.trim() !== "Clicks: 0") {+ reject(new Error(`${framework.name} does not match the counter adapter contract`));+ return;+ }++ resolve({ button, startupMs: performance.now() - startedAt });+ }++ window.addEventListener("message", onMessage);+ const url = new URL(`./${framework.id}/index.html`, window.location.href);+ url.searchParams.set("benchmark_run", String(++navigationId));+ frame.src = url.href;+ });+}++async function measureClickBatch(button, clickCount) {+ const startedAt = performance.now();++ for (let click = 0; click < clickCount; click += 1) {+ button.click();+ }++ const expectedText = `Clicks: ${clickCount}`;+ const deadline = performance.now() + READY_TIMEOUT_MS;+ while (button.textContent.trim() !== expectedText) {+ if (performance.now() > deadline) {+ throw new Error(`Counter did not reach ${expectedText}`);+ }++ await nextFrame();+ }++ return performance.now() - startedAt;+}++async function benchmarkFramework(framework, clickCount) {+ for (let run = 0; run < WARMUP_RUNS; run += 1) {+ status.textContent = `Warming up ${framework.name}…`;+ const warmup = await loadFramework(framework);+ await measureClickBatch(warmup.button, clickCount);+ }++ const startupSamples = [];+ const interactionSamples = [];++ for (let run = 0; run < MEASURED_RUNS; run += 1) {+ status.textContent = `Measuring ${framework.name}: run ${run + 1} of ${MEASURED_RUNS}…`;+ const { button, startupMs } = await loadFramework(framework);+ const interactionMs = await measureClickBatch(button, clickCount);+ startupSamples.push(startupMs);+ interactionSamples.push(interactionMs);+ }++ appendResult(framework.name, startupSamples, interactionSamples, clickCount);+}++form.addEventListener("submit", async (event) => {+ event.preventDefault();+ runButton.disabled = true;+ results.replaceChildren();++ const clickCount = Number.parseInt(clickCountInput.value, 10);+ const selected = frameworkSelect.value;+ const frameworks = selected === "all"+ ? FRAMEWORKS+ : FRAMEWORKS.filter((framework) => framework.id === selected);++ try {+ for (const framework of frameworks) {+ await benchmarkFramework(framework, clickCount);+ }+ status.textContent = `Done. Each framework used ${WARMUP_RUNS} warmup run and ${MEASURED_RUNS} measured runs.`;+ } catch (error) {+ status.textContent = `Benchmark failed: ${error.message}`;+ } finally {+ runButton.disabled = false;+ }+});@@ -6,7 +6,9 @@ <title>Yew counter benchmark</title> <script type="module"> import init from "./pkg/yew_component_benchmark.js";+ import { signalBenchmarkReady } from "../adapter.js"; await init();+ await signalBenchmarkReady(); </script> </head> <body></body>@@ -1,20 +1,31 @@ use wasm_bindgen::prelude::*; use yew::prelude::*; +#[derive(Clone, PartialEq)]+struct CounterState(u32);++impl Reducible for CounterState {+ type Action = ();++ fn reduce(self: std::rc::Rc<Self>, _action: Self::Action) -> std::rc::Rc<Self> {+ std::rc::Rc::new(Self(self.0.saturating_add(1)))+ }+}+ #[function_component(Counter)] fn counter() -> Html {- let count = use_state(|| 0_u32);+ let count = use_reducer(|| CounterState(0)); let onclick = { let count = count.clone();- Callback::from(move |_| count.set((*count).saturating_add(1)))+ Callback::from(move |_| count.dispatch(())) }; html! {- <button id="counter" {onclick}>{format!("Clicks: {}", *count)}</button>+ <button id="counter" {onclick}>{format!("Clicks: {}", count.0)}</button> } } #[wasm_bindgen(start)] pub fn main() {- Renderer::<Counter>::new().render();+ yew::Renderer::<Counter>::new().render(); }new file mode 100755@@ -0,0 +1,15 @@+#!/bin/sh+set -eu++./build.sh++: "${DEPLOY_USER:?Set DEPLOY_USER to the SSH user on git.kajacode.com}"+: "${DEPLOY_PATH:?Set DEPLOY_PATH to the Caddy-served folder}"++rsync -av --delete \+ pkg/ \+ "${DEPLOY_USER}@git.kajacode.com:${DEPLOY_PATH}/"++if [ -n "${GIT_REMOTE:-}" ]; then+ git push "${GIT_REMOTE}" "${GIT_BRANCH:-main}"+fi