summaryrefslogtreecommitdiff
path: root/preact/benches
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2021-08-23 16:46:06 -0300
committerSebastian <sebasjm@gmail.com>2021-08-23 16:48:30 -0300
commit38acabfa6089ab8ac469c12b5f55022fb96935e5 (patch)
tree453dbf70000cc5e338b06201af1eaca8343f8f73 /preact/benches
parentf26125e039143b92dc0d84e7775f508ab0cdcaa8 (diff)
downloadnode-vendor-master.tar.gz
node-vendor-master.tar.bz2
node-vendor-master.zip
added web vendorsHEADmaster
Diffstat (limited to 'preact/benches')
-rw-r--r--preact/benches/.gitignore6
-rw-r--r--preact/benches/LICENSE21
-rw-r--r--preact/benches/README.md72
-rw-r--r--preact/benches/TODO.md10
-rw-r--r--preact/benches/jsconfig.json8
-rw-r--r--preact/benches/package.json30
-rw-r--r--preact/benches/proxy-packages/preact-local-proxy/index.js18
-rw-r--r--preact/benches/proxy-packages/preact-local-proxy/package.json10
-rw-r--r--preact/benches/proxy-packages/preact-master-proxy/index.js18
-rw-r--r--preact/benches/proxy-packages/preact-master-proxy/package.json10
-rw-r--r--preact/benches/proxy-packages/preact-v8-proxy/index.js23
-rw-r--r--preact/benches/proxy-packages/preact-v8-proxy/package.json10
-rw-r--r--preact/benches/scripts/analyze.js363
-rw-r--r--preact/benches/scripts/bench.js70
-rw-r--r--preact/benches/scripts/config.js317
-rw-r--r--preact/benches/scripts/deopts.js253
-rw-r--r--preact/benches/scripts/global.d.ts15
-rw-r--r--preact/benches/scripts/index.js110
-rw-r--r--preact/benches/scripts/prepare.js44
-rw-r--r--preact/benches/scripts/tracing.d.ts224
-rw-r--r--preact/benches/scripts/utils.js65
-rw-r--r--preact/benches/src/02_replace1k.html67
-rw-r--r--preact/benches/src/03_update10th1k_x16.html78
-rw-r--r--preact/benches/src/07_create10k.html49
-rw-r--r--preact/benches/src/filter_list.html92
-rw-r--r--preact/benches/src/hydrate1k.html145
-rw-r--r--preact/benches/src/keyed-children/components.js152
-rw-r--r--preact/benches/src/keyed-children/index.js29
-rw-r--r--preact/benches/src/keyed-children/store.js119
-rw-r--r--preact/benches/src/many_updates.html112
-rw-r--r--preact/benches/src/text_update.html34
-rw-r--r--preact/benches/src/util.js98
32 files changed, 2672 insertions, 0 deletions
diff --git a/preact/benches/.gitignore b/preact/benches/.gitignore
new file mode 100644
index 0000000..507e011
--- /dev/null
+++ b/preact/benches/.gitignore
@@ -0,0 +1,6 @@
+dist/
+results/
+logs/
+logs-saved/
+node_modules/
+proxy-packages/*/package-lock.json
diff --git a/preact/benches/LICENSE b/preact/benches/LICENSE
new file mode 100644
index 0000000..da5389a
--- /dev/null
+++ b/preact/benches/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015-present Jason Miller
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/preact/benches/README.md b/preact/benches/README.md
new file mode 100644
index 0000000..066b5fa
--- /dev/null
+++ b/preact/benches/README.md
@@ -0,0 +1,72 @@
+# Preact Benchmarks
+
+This directory contains benchmarks for Preact, run using the [`polymer/tachometer`](https://github.com/polymer/tachometer) project.
+
+## Getting Started
+
+To run benchmark suite, use `npm bench`.
+
+To debug benches locally, use `npm start`.
+
+### bench
+
+Use the `npm bench` command to run some (or all, the default) benchmarks locally.
+
+```text
+> node ./scripts bench "--help"
+
+ Description
+ Run the benchmarks matching the given globs.
+ The root for the globs is the "src" directory.
+ Specify "all" to run all benchmarks.
+ To get more help on options, see polymer/tachometer help.
+ Result table is printed to stdout and written to a csv and json file in the results directory.
+
+ Usage
+ $ ./scripts bench [globs] [options]
+
+ Options
+ -n, --sample-size Minimum number of times to run each benchmark (default 50)
+ --horizon The degrees of difference to try and resolve when auto-sampling ("N%" or "Nms", comma-delimited) (default 10%)
+ --timeout The maximum number of minutes to spend auto-sampling (default 3)
+ -h, --help Displays this message
+
+ Examples
+ $ ./scripts bench text*
+ $ ./scripts bench **/*.html
+ $ ./scripts bench all
+
+```
+
+The `bench` command generates a tachometer config file for each benchmark matching the given globs. It then runs tachometer on each config file. If you would create one config file containing all the benchmarks, then Tachometer would produce one table comparing all package versions to each other across benchmarks which wouldn't be as useful. By generating a config per benchmark, Tachometer will output a table per benchmark comparing how each package version performed on just that benchmark.
+
+### start
+
+Use the `npm start` command to start the benchmark server but not run the benchmarks. This command is useful to debug or profile how a benchmark is doing. Tachometer starts a web server for each package version we run (note the port difference per package version in the sample output below). It provides a sample URL at which to point your browser to run a benchmark. You can run any benchmark in the `src` directory, not just the one in the sample URL.
+
+```bash
+> npm start
+
+...snipped some debug output...
+
+Visit these URLs in any browser:
+
+text_update [@preact8]
+http://127.0.0.1:8080/src/text_update.html
+
+text_update [@preact10]
+http://127.0.0.1:8081/src/text_update.html
+
+text_update [@preactLocal]
+http://127.0.0.1:8082/src/text_update.html
+```
+
+## Contributing
+
+To contribute a new benchmark, look at the existing benchmarks (the HTML files in `src`) to get an idea of what a benchmark can look like. Then read up on how [`polymer/tachometer`](https://github.com/polymer/tachometer) works to understand some of the options available to you.
+
+Add an HTML file containing the benchmark you'd like to run. Use `npm start` (documented above) to test and debug your benchmark. Then run `npm bench YOUR_BENCH.html` to run it. Note while initialling developing it may be easier to limit the amount of samples taken while benching. Use the options documented for the `npm bench` command to customize the sample size and auto-sample timeout.
+
+Currently this infra is only setup to run benchmarks against different preact versions and requires that your benchmark use the `bench.start()` and `bench.stop()` methods.
+
+The `src/util.js` file contains some utility functions for running your benchmark. For, example the `afterFrame/afterFrameAsync` functions can be used to run `bench.stop()` after the browser as painted the next frame. The `testElement/testElementText` functions can be used to verify that the benchmark implementation rendered the expected result properly.
diff --git a/preact/benches/TODO.md b/preact/benches/TODO.md
new file mode 100644
index 0000000..ec9430f
--- /dev/null
+++ b/preact/benches/TODO.md
@@ -0,0 +1,10 @@
+* Add `preact-release` proxy
+ - to capture slowdowns overtime
+* Report `initial-run` metric to PR
+ - to capture unoptimized runtime which would be an important metric to understand perf characteristic before optimizations kick in
+* Add warmup reporting to all benchmarks
+* Add `preact-compat` proxy
+* Add UIBench
+* Add bench mimicking speedometer
+* Add a realworld-like bench?
+* Add a specialized bench that hits certain code paths other's miss (e.g. style attribute handling?)
diff --git a/preact/benches/jsconfig.json b/preact/benches/jsconfig.json
new file mode 100644
index 0000000..28c8242
--- /dev/null
+++ b/preact/benches/jsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "target": "es2020",
+ "checkJs": true,
+ "moduleResolution": "node"
+ },
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/preact/benches/package.json b/preact/benches/package.json
new file mode 100644
index 0000000..e3bdd07
--- /dev/null
+++ b/preact/benches/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "preact-benchmarks",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "description": "Benchmarks for Preact",
+ "scripts": {
+ "start": "node ./scripts config many_updates.html && tach --force-clean-npm-install --config dist/many_updates.config.json --manual",
+ "analyze": "node ./scripts analyze",
+ "bench": "node ./scripts bench",
+ "deopts": "node ./scripts deopts",
+ "help": "node ./scripts --help"
+ },
+ "license": "MIT",
+ "dependencies": {
+ "afterframe": "^1.0.1"
+ },
+ "devDependencies": {
+ "@kristoferbaxter/async": "^1.0.0",
+ "del": "^6.0.0",
+ "escalade": "^3.0.2",
+ "escape-string-regexp": "^4.0.0",
+ "globby": "^11.0.0",
+ "prompts": "^2.4.0",
+ "sade": "^1.7.3",
+ "strip-ansi": "^6.0.0",
+ "tachometer": "^0.5.7",
+ "v8-deopt-viewer": "^0.2.1"
+ }
+}
diff --git a/preact/benches/proxy-packages/preact-local-proxy/index.js b/preact/benches/proxy-packages/preact-local-proxy/index.js
new file mode 100644
index 0000000..1b3d7b2
--- /dev/null
+++ b/preact/benches/proxy-packages/preact-local-proxy/index.js
@@ -0,0 +1,18 @@
+import { render, hydrate } from 'preact';
+
+export * from 'preact';
+
+/**
+ * @param {HTMLElement} rootDom
+ * @returns {{ render(vnode: JSX.Element): void; hydrate(vnode: JSX.Element): void; }}
+ */
+export function createRoot(rootDom) {
+ return {
+ render(vnode) {
+ render(vnode, rootDom);
+ },
+ hydrate(vnode) {
+ hydrate(vnode, rootDom);
+ }
+ };
+}
diff --git a/preact/benches/proxy-packages/preact-local-proxy/package.json b/preact/benches/proxy-packages/preact-local-proxy/package.json
new file mode 100644
index 0000000..344d845
--- /dev/null
+++ b/preact/benches/proxy-packages/preact-local-proxy/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "preact-local-proxy",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "main": "index.js",
+ "dependencies": {
+ "preact": "file:../../../"
+ }
+}
diff --git a/preact/benches/proxy-packages/preact-master-proxy/index.js b/preact/benches/proxy-packages/preact-master-proxy/index.js
new file mode 100644
index 0000000..1b3d7b2
--- /dev/null
+++ b/preact/benches/proxy-packages/preact-master-proxy/index.js
@@ -0,0 +1,18 @@
+import { render, hydrate } from 'preact';
+
+export * from 'preact';
+
+/**
+ * @param {HTMLElement} rootDom
+ * @returns {{ render(vnode: JSX.Element): void; hydrate(vnode: JSX.Element): void; }}
+ */
+export function createRoot(rootDom) {
+ return {
+ render(vnode) {
+ render(vnode, rootDom);
+ },
+ hydrate(vnode) {
+ hydrate(vnode, rootDom);
+ }
+ };
+}
diff --git a/preact/benches/proxy-packages/preact-master-proxy/package.json b/preact/benches/proxy-packages/preact-master-proxy/package.json
new file mode 100644
index 0000000..a73c202
--- /dev/null
+++ b/preact/benches/proxy-packages/preact-master-proxy/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "preact-proxy",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "main": "index.js",
+ "dependencies": {
+ "preact": "file:../../../preact.tgz"
+ }
+}
diff --git a/preact/benches/proxy-packages/preact-v8-proxy/index.js b/preact/benches/proxy-packages/preact-v8-proxy/index.js
new file mode 100644
index 0000000..8c75330
--- /dev/null
+++ b/preact/benches/proxy-packages/preact-v8-proxy/index.js
@@ -0,0 +1,23 @@
+import { render } from 'preact';
+
+export * from 'preact';
+
+/**
+ * @param {HTMLElement} rootDom
+ * @returns {{ render(vnode: JSX.Element): void; hydrate(vnode: JSX.Element): void; }}
+ */
+export function createRoot(rootDom) {
+ let result;
+ return {
+ render(vnode) {
+ if (result) {
+ result = render(vnode, rootDom, result);
+ } else {
+ result = render(vnode, rootDom);
+ }
+ },
+ hydrate(vnode) {
+ render(vnode, rootDom, rootDom.firstElementChild);
+ }
+ };
+}
diff --git a/preact/benches/proxy-packages/preact-v8-proxy/package.json b/preact/benches/proxy-packages/preact-v8-proxy/package.json
new file mode 100644
index 0000000..1ac098d
--- /dev/null
+++ b/preact/benches/proxy-packages/preact-v8-proxy/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "preact-v8-proxy",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "main": "index.js",
+ "dependencies": {
+ "preact": "^8.5.3"
+ }
+}
diff --git a/preact/benches/scripts/analyze.js b/preact/benches/scripts/analyze.js
new file mode 100644
index 0000000..b57f9d4
--- /dev/null
+++ b/preact/benches/scripts/analyze.js
@@ -0,0 +1,363 @@
+import { existsSync } from 'fs';
+import { readFile, readdir } from 'fs/promises';
+import prompts from 'prompts';
+import { baseTraceLogDir, frameworks } from './config.js';
+
+// @ts-ignore
+import tachometerStats from 'tachometer/lib/stats.js';
+// @ts-ignore
+import tachometerFormat from 'tachometer/lib/format.js';
+
+/**
+ * @typedef {import('./tracing').TraceEvent} TraceEvent
+ * @typedef {import('tachometer/lib/stats').SummaryStats} SummaryStats
+ * @typedef {import('tachometer/lib/stats').ResultStats} ResultStats
+ * @typedef {import('tachometer/lib/stats').ResultStatsWithDifferences} ResultStatsWithDifferences
+ * @type {import('tachometer/lib/stats')}
+ */
+const statsLib = tachometerStats;
+const { summaryStats, computeDifferences } = statsLib;
+/** @type {import('tachometer/lib/format')} */
+const formatLib = tachometerFormat;
+const { automaticResultTable, verticalTermResultTable } = formatLib;
+
+const toTrack = new Set([
+ // 'V8.CompileCode', // Might be tachometer code?? But maybe not?
+ 'V8.MarkCandidatesForOptimization',
+ 'V8.OptimizeCode',
+ 'V8.OptimizeConcurrentPrepare',
+ 'V8.OptimizeNonConcurrent',
+ // 'V8.OptimizeBackground', // Runs on background thread
+ 'V8.InstallOptimizedFunctions',
+ 'V8.DeoptimizeCode',
+ 'MinorGC',
+ 'V8.GCDeoptMarkedAllocationSites'
+]);
+
+/**
+ * @template T
+ * @param {Map<string, T[]>} grouping
+ * @param {Map<string, T | T[]>} results
+ */
+function addToGrouping(grouping, results) {
+ for (let [group, data] of results.entries()) {
+ if (grouping.has(group)) {
+ if (Array.isArray(data)) {
+ grouping.get(group).push(...data);
+ } else {
+ grouping.get(group).push(data);
+ }
+ } else {
+ if (Array.isArray(data)) {
+ grouping.set(group, data);
+ } else {
+ grouping.set(group, [data]);
+ }
+ }
+ }
+}
+
+/**
+ * @template K
+ * @template V
+ * @param {Map<K, V[]>} map
+ * @param {K} key
+ * @param {...V} values
+ */
+function addToMapArray(map, key, ...values) {
+ if (map.has(key)) {
+ map.get(key).push(...values);
+ } else {
+ map.set(key, values);
+ }
+}
+
+/**
+ * @template K
+ * @template V
+ * @param {Map<K, V[]>} map
+ * @param {K} key
+ * @param {number} index
+ * @param {V} value
+ */
+function setInMapArray(map, key, index, value) {
+ if (map.has(key)) {
+ map.get(key)[index] = value;
+ } else {
+ map.set(key, [value]);
+ }
+}
+
+/**
+ * @param {ResultStats[]} results
+ */
+function logDifferences(key, results) {
+ let withDifferences = computeDifferences(results);
+ console.log();
+ let { fixed, unfixed } = automaticResultTable(withDifferences);
+ // console.log(horizontalTermResultTable(fixed));
+ console.log(key);
+ console.log(verticalTermResultTable(unfixed));
+}
+
+/**
+ * @param {string} version
+ * @param {string[]} logPaths
+ * @param {(logs: TraceEvent[], logFilePath: string) => number} [getThreadId]
+ * @param {(log: TraceEvent) => boolean} [trackEventsIn]
+ * @returns {Promise<Map<string, ResultStats>>}
+ */
+async function getStatsFromLogs(version, logPaths, getThreadId, trackEventsIn) {
+ /** @type {Map<string, number[]>} Sums for each function for each file */
+ const data = new Map();
+ for (let logPath of logPaths) {
+ /** @type {TraceEvent[]} */
+ const logs = JSON.parse(await readFile(logPath, 'utf8'));
+
+ let tid = getThreadId ? getThreadId(logs, logPath) : null;
+
+ /** @type {Array<{ id: string; start: number; end: number; }>} Determine what durations to track events under */
+ const parentLogs = [];
+ for (let log of logs) {
+ if (trackEventsIn && trackEventsIn(log)) {
+ if (log.ph == 'X') {
+ parentLogs.push({
+ id: log.name,
+ start: log.ts,
+ end: log.ts + log.dur
+ });
+ } else if (log.ph == 'b') {
+ parentLogs.push({
+ id: log.name,
+ start: log.ts,
+ end: log.ts
+ });
+ } else if (log.ph == 'e') {
+ parentLogs.find(l => l.id == log.name).end = log.ts;
+ } else {
+ throw new Error(`Unsupported parent log type: ${log.ph}`);
+ }
+ }
+ }
+
+ /** @type {Map<string, import('./tracing').AsyncEvent>} */
+ const durationBeginEvents = new Map();
+
+ /** @type {Map<string, number[]>} Sum of time spent in each function for this log file */
+ const sumsForFile = new Map();
+ for (let log of logs) {
+ if (tid != null && log.tid !== tid) {
+ // if (toTrack.has(log.name)) {
+ // console.log(
+ // `Skipping ${log.name} on tid ${log.tid} (expected ${tid}) in ${logPath}`
+ // );
+ // }
+
+ continue;
+ }
+
+ if (log.ph == 'X') {
+ // Track duration event
+ if (toTrack.has(log.name)) {
+ let key = `Sum of ${log.name} time`;
+ let sum = sumsForFile.get(key)?.[0] ?? 0;
+ // sumsForFile.set(log.name, sum + log.dur / 1000);
+ setInMapArray(sumsForFile, key, 0, sum + log.dur / 1000);
+
+ key = `Count of ${log.name}`;
+ sum = sumsForFile.get(key)?.[0] ?? 0;
+ // sumsForFile.set(key, sum + 1);
+ setInMapArray(sumsForFile, key, 0, sum + 1);
+
+ key = `Sum of V8 runtime`;
+ sum = sumsForFile.get(key)?.[0] ?? 0;
+ // sumsForFile.set(key, sum + log.dur / 1000);
+ setInMapArray(sumsForFile, key, 0, sum + log.dur / 1000);
+
+ for (let parentLog of parentLogs) {
+ if (
+ parentLog.start <= log.ts &&
+ log.ts + log.dur <= parentLog.end
+ ) {
+ key = `In ${parentLog.id}, Sum of V8 runtime`;
+ sum = sumsForFile.get(key)?.[0] ?? 0;
+ setInMapArray(sumsForFile, key, 0, sum + log.dur / 1000);
+ }
+ }
+ }
+
+ if (log.name == 'MinorGC' || log.name == 'MajorGC') {
+ let key = `${log.name} usedHeapSizeBefore`;
+ addToMapArray(sumsForFile, key, log.args.usedHeapSizeBefore / 1e6);
+
+ key = `${log.name} usedHeapSizeAfter`;
+ addToMapArray(sumsForFile, key, log.args.usedHeapSizeAfter / 1e6);
+ }
+ } else if (
+ (log.ph == 'b' || log.ph == 'e') &&
+ log.cat == 'blink.user_timing' &&
+ log.scope == 'blink.user_timing'
+ ) {
+ // TODO: Doesn't handle nested events of same name. Oh well.
+ if (log.ph == 'b') {
+ durationBeginEvents.set(log.name, log);
+ } else {
+ const beginEvent = durationBeginEvents.get(log.name);
+ const endEvent = log;
+ durationBeginEvents.delete(log.name);
+
+ let key = beginEvent.name;
+ let duration = (endEvent.ts - beginEvent.ts) / 1000;
+ addToMapArray(sumsForFile, key, duration);
+
+ if (key.startsWith('run-') && key !== 'run-warmup-0') {
+ // Skip run-warmup-0 since it doesn't do unmounting
+ addToMapArray(sumsForFile, 'average run duration', duration);
+ }
+ }
+ }
+ }
+
+ addToGrouping(data, sumsForFile);
+ }
+
+ const stats = new Map();
+ for (let [key, sums] of data) {
+ stats.set(key, {
+ result: {
+ name: '02_replace1k',
+ version: version,
+ measurement: {
+ name: key,
+ mode: 'expression',
+ expression: key,
+ unit: key.startsWith('Count')
+ ? ''
+ : key.includes('usedHeapSize')
+ ? 'MB'
+ : null
+ },
+ browser: {
+ name: 'chrome'
+ },
+ millis: sums
+ },
+ stats: summaryStats(sums)
+ });
+ }
+
+ return stats;
+}
+
+/**
+ * @param {import('./tracing').TraceEvent[]} logs
+ * @param {string} logFilePath
+ * @returns {number}
+ */
+function getDurationThread(logs, logFilePath) {
+ let log = logs.find(isDurationLog);
+
+ if (log == null) {
+ throw new Error(
+ `Could not find blink.user_timing log for "run-final" or "duration" in ${logFilePath}.`
+ );
+ } else {
+ return log.tid;
+ }
+}
+
+/**
+ * @param {TraceEvent} log
+ */
+function isDurationLog(log) {
+ return (
+ (log.ph == 'b' || log.ph == 'e') &&
+ log.cat == 'blink.user_timing' &&
+ log.scope == 'blink.user_timing' &&
+ // Tachometer may kill the tab after seeing the duration measure before
+ // the tab can log it to the trace file
+ (log.name == 'run-final' || log.name == 'duration')
+ );
+}
+
+export async function analyze() {
+ // const frameworkNames = await readdir(p('logs'));
+ const frameworkNames = frameworks.map(f => f.label);
+ const listAtEnd = [
+ 'average run duration',
+ 'Sum of V8 runtime',
+ 'In run-final, Sum of V8 runtime',
+ 'In duration, Sum of V8 runtime',
+ 'duration'
+ ];
+
+ if (!existsSync(baseTraceLogDir())) {
+ console.log(
+ `Could not find log directory: "${baseTraceLogDir()}". Did you run the benchmarks?`
+ );
+ return;
+ }
+
+ const benchmarkNames = await readdir(baseTraceLogDir());
+ let selectedBench;
+ if (benchmarkNames.length == 0) {
+ console.log(`No benchmarks or results found in "${baseTraceLogDir()}".`);
+ return;
+ } else if (benchmarkNames.length == 1) {
+ selectedBench = benchmarkNames[0];
+ } else {
+ selectedBench = (
+ await prompts({
+ type: 'select',
+ name: 'value',
+ message: "Which benchmark's results would you like to analyze?",
+ choices: benchmarkNames.map(name => ({
+ title: name,
+ value: name
+ }))
+ })
+ ).value;
+ }
+
+ /** @type {Map<string, ResultStats[]>} */
+ const resultStatsMap = new Map();
+ for (let framework of frameworkNames) {
+ const logDir = baseTraceLogDir(selectedBench, framework);
+
+ let logFilePaths;
+ try {
+ logFilePaths = (await readdir(logDir)).map(fn =>
+ baseTraceLogDir(selectedBench, framework, fn)
+ );
+ } catch (e) {
+ // If directory doesn't exist or we fail to read it, just skip
+ continue;
+ }
+
+ const resultStats = await getStatsFromLogs(
+ framework,
+ logFilePaths,
+ getDurationThread,
+ isDurationLog
+ );
+ addToGrouping(resultStatsMap, resultStats);
+
+ // console.log(`${framework}:`);
+ // console.log(resultStats);
+ }
+
+ // Compute differences and print table
+ for (let [key, results] of resultStatsMap.entries()) {
+ if (listAtEnd.includes(key)) {
+ continue;
+ }
+
+ logDifferences(key, results);
+ }
+
+ for (let key of listAtEnd) {
+ if (resultStatsMap.has(key)) {
+ logDifferences(key, resultStatsMap.get(key));
+ }
+ }
+}
diff --git a/preact/benches/scripts/bench.js b/preact/benches/scripts/bench.js
new file mode 100644
index 0000000..eb7d464
--- /dev/null
+++ b/preact/benches/scripts/bench.js
@@ -0,0 +1,70 @@
+import { spawnSync } from 'child_process';
+import { mkdir } from 'fs/promises';
+import {
+ globSrc,
+ benchesRoot,
+ allBenches,
+ resultsPath,
+ IS_CI
+} from './utils.js';
+import { generateConfig } from './config.js';
+
+export const defaultBenchOptions = {
+ browser: 'chrome-headless',
+ // Tachometer default is 50, but locally let's only do 10
+ 'sample-size': !IS_CI ? 10 : 50,
+ // Tachometer default is 10% but let's do 5% to save some GitHub action
+ // minutes by reducing the likelihood of needing auto-sampling. See
+ // https://github.com/Polymer/tachometer#auto-sampling
+ horizon: '5%',
+ // Tachometer default is 3 minutes, but let's shrink it to 1 here to save some
+ // GitHub Action minutes
+ timeout: 1,
+ 'window-size': '1024,768',
+ framework: IS_CI ? ['preact-master', 'preact-local'] : null,
+ trace: false
+};
+
+/**
+ * @param {string} bench1
+ * @param {{ _: string[]; } & TachometerOptions} opts
+ */
+export async function runBenches(bench1 = 'all', opts) {
+ const globs = bench1 === 'all' ? allBenches : [bench1].concat(opts._);
+ const benchesToRun = await globSrc(globs);
+
+ if (benchesToRun.length == 0) {
+ console.log('No benchmarks found matching patterns:', globs);
+ } else {
+ console.log('Running benchmarks:', benchesToRun.join(', '));
+ console.log();
+ }
+
+ const configFileTasks = benchesToRun.map(async (benchPath, i) => {
+ return generateConfig(benchesRoot('src', benchPath), {
+ ...opts,
+ prepare: i === 0 // Only run prepare script for first config
+ });
+ });
+
+ await mkdir(resultsPath(), { recursive: true });
+
+ const configFiles = await Promise.all(configFileTasks);
+ for (const { name, configPath } of configFiles) {
+ const args = [
+ benchesRoot('node_modules/tachometer/bin/tach.js'),
+ '--force-clean-npm-install',
+ '--config',
+ configPath,
+ '--json-file',
+ benchesRoot('results', name + '.json')
+ ];
+
+ console.log('\n$', process.execPath, ...args);
+
+ spawnSync(process.execPath, args, {
+ cwd: benchesRoot(),
+ stdio: 'inherit'
+ });
+ }
+}
diff --git a/preact/benches/scripts/config.js b/preact/benches/scripts/config.js
new file mode 100644
index 0000000..cb8f582
--- /dev/null
+++ b/preact/benches/scripts/config.js
@@ -0,0 +1,317 @@
+import * as path from 'path';
+import del from 'del';
+import { writeFile, stat, mkdir } from 'fs/promises';
+import { repoRoot, benchesRoot, toUrl } from './utils.js';
+import { defaultBenchOptions } from './bench.js';
+import { prepare } from './prepare.js';
+
+const measureName = 'duration'; // Must match measureName in '../src/util.js'
+const warnings = new Set([]);
+const TACH_SCHEMA =
+ 'https://raw.githubusercontent.com/Polymer/tachometer/master/config.schema.json';
+
+export const baseTraceLogDir = (...args) =>
+ path.join(benchesRoot('logs'), ...args);
+
+/**
+ * @param {ConfigFileBenchmark["packageVersions"]["dependencies"]["framework"]} framework
+ * @returns {Promise<boolean>}
+ */
+async function validateFileDep(framework) {
+ try {
+ if (typeof framework === 'string') {
+ await stat(framework.replace(/^file:/, ''));
+ return true;
+ } else {
+ return false;
+ }
+ } catch (e) {
+ console.log('Stat error:', e);
+ return false;
+ }
+}
+
+/**
+ * @typedef {ConfigFileBenchmark["packageVersions"]} ConfigFilePackageVersion
+ * @typedef {ConfigFilePackageVersion & { isValid(): Promise<boolean>; }} BenchConfig
+ * @type {BenchConfig[]}
+ */
+export const frameworks = [
+ {
+ label: 'preact-v8',
+ dependencies: {
+ framework: 'file:' + repoRoot('benches/proxy-packages/preact-v8-proxy')
+ },
+ isValid() {
+ return validateFileDep(this.dependencies.framework);
+ }
+ },
+ {
+ label: 'preact-master',
+ dependencies: {
+ framework:
+ 'file:' + repoRoot('benches/proxy-packages/preact-master-proxy')
+ },
+ async isValid() {
+ try {
+ await stat(repoRoot('preact.tgz'));
+ return validateFileDep(this.dependencies.framework);
+ } catch (e) {
+ return false;
+ }
+ }
+ },
+ {
+ label: 'preact-local',
+ dependencies: {
+ framework: 'file:' + repoRoot('benches/proxy-packages/preact-local-proxy')
+ },
+ isValid() {
+ return validateFileDep(this.dependencies.framework);
+ }
+ }
+];
+
+/**
+ * @param {string} benchPath
+ * @returns {Pick<ConfigFileBenchmark, "name" | "url" | "measurement">}
+ */
+function getBaseBenchmarkConfig(benchPath) {
+ let name = path.basename(benchPath).replace('.html', '');
+ let url = path.posix.relative(toUrl(benchesRoot()), toUrl(benchPath));
+
+ /** @type {ConfigFileBenchmark["measurement"]} */
+ let measurement;
+ if (name == '02_replace1k') {
+ // MUST BE KEPT IN SYNC WITH WARMUP COUNT IN 02_replace1k.html
+ const WARMUP_COUNT = 5;
+
+ // For 02_replace1k, collect additional measurements focusing on the JS
+ // clock time for each warmup and the final duration.
+ measurement = [
+ {
+ name: 'duration',
+ mode: 'performance',
+ entryName: measureName
+ },
+ {
+ name: 'usedJSHeapSize',
+ mode: 'expression',
+ expression: 'window.usedJSHeapSize'
+ }
+ ];
+
+ for (let i = 0; i < WARMUP_COUNT; i++) {
+ const entryName = `run-warmup-${i}`;
+ measurement.push({
+ name: entryName,
+ mode: 'performance',
+ entryName
+ });
+ }
+
+ measurement.push({
+ name: 'run-final',
+ mode: 'performance',
+ entryName: 'run-final'
+ });
+ } else {
+ // Default measurements
+ measurement = [
+ {
+ name: 'duration',
+ mode: 'performance',
+ entryName: measureName
+ },
+ {
+ name: 'usedJSHeapSize',
+ mode: 'expression',
+ expression: 'window.usedJSHeapSize'
+ }
+ ];
+ }
+
+ return { name, url, measurement };
+}
+
+export async function generateSingleConfig(benchFile, opts) {
+ const benchPath = await benchesRoot('src', benchFile);
+ const results = await stat(benchPath);
+ if (!results.isFile) {
+ throw new Error(`Given path is not a file: ${benchPath}`);
+ }
+
+ await generateConfig(benchPath, { ...defaultBenchOptions, ...opts });
+}
+
+/**
+ * @typedef {import('tachometer/lib/configfile').ConfigFile} ConfigFile Expected
+ * format of a top-level tachometer JSON config file.
+ * @typedef {ConfigFile["benchmarks"][0]} ConfigFileBenchmark
+ * @typedef {{ name: string; configPath: string; config: ConfigFile; }} ConfigData
+ * @param {string} benchPath
+ * @param {TachometerOptions & { prepare?: boolean }} options
+ * @returns {Promise<ConfigData>}
+ */
+export async function generateConfig(benchPath, options) {
+ /** @type {ConfigFileBenchmark["expand"]} */
+ let expand;
+ /** @type {BrowserConfigs} */
+ let browser;
+
+ const baseBenchConfig = getBaseBenchmarkConfig(benchPath);
+
+ // See https://www.npmjs.com/package/tachometer#browsers
+ // and https://www.npmjs.com/package/tachometer#config-file
+ if (Array.isArray(options.browser)) {
+ expand = options.browser.map(browserOpt => ({
+ browser: parseBrowserOption(browserOpt)
+ }));
+ } else {
+ browser = parseBrowserOption(options.browser);
+ }
+
+ if (browser.name == 'chrome' && options.trace) {
+ const traceLogDir = baseTraceLogDir(baseBenchConfig.name);
+ await del('**/*', { cwd: traceLogDir });
+ await mkdir(traceLogDir, { recursive: true });
+
+ browser.trace = {
+ logDir: traceLogDir
+ };
+ }
+
+ /** @type {BenchConfig[]} */
+ let frameworksToRun;
+ if (!options.framework) {
+ frameworksToRun = frameworks;
+ } else if (typeof options.framework === 'string') {
+ const match = frameworks.find(f => f.label == options.framework);
+ frameworksToRun = match ? [match] : [];
+ } else if (Array.isArray(options.framework)) {
+ frameworksToRun = frameworks.filter(f =>
+ options.framework.includes(f.label)
+ );
+ } else {
+ throw new Error(`Unrecognized framework option: ${options.framework}`);
+ }
+
+ if (frameworksToRun.length == 0) {
+ console.error(
+ `Framework options did not match any configured frameworks:\n` +
+ `\tProvided option: ${options.framework}\n` +
+ `\tAvailable frameworks: [${frameworks
+ .map(f => JSON.stringify(f.label))
+ .join(', ')}]\n`
+ );
+
+ throw new Error(
+ `Framework option did not match any configured frameworks: ${options.framework}`
+ );
+ }
+
+ /** @type {ConfigFile["benchmarks"]} */
+ const benchmarks = [];
+ for (let framework of frameworksToRun) {
+ let frameworkPath = framework.dependencies.framework;
+ if (typeof frameworkPath !== 'string') {
+ throw new Error(
+ 'Only string/npm dependencies are supported at this time'
+ );
+ }
+
+ if (!(await framework.isValid())) {
+ const warnMsg = `Could not locate path for ${framework.label}: ${framework.dependencies.framework}. \nSkipping...`;
+ if (!warnings.has(warnMsg)) {
+ console.warn(warnMsg);
+ warnings.add(warnMsg);
+ }
+
+ continue;
+ }
+
+ benchmarks.push({
+ ...baseBenchConfig,
+ packageVersions: framework,
+ browser,
+ expand
+ });
+ }
+
+ if (options.prepare !== false) {
+ await prepare(benchmarks.map(b => b.packageVersions.label));
+ }
+
+ /** @type {ConfigFile} */
+ const config = {
+ $schema: TACH_SCHEMA,
+ sampleSize: options['sample-size'],
+ timeout: options.timeout,
+ horizons: options.horizon.split(','),
+ benchmarks
+ };
+
+ if (config.benchmarks.length == 0) {
+ if (options.framework) {
+ const configuredFrameworks = frameworks.map(f => f.label).join(', ');
+ throw new Error(
+ `No benchmarks created. Does the specified framework match one of the configured frameworks? ${configuredFrameworks}`
+ );
+ } else {
+ throw new Error(
+ `Unknown failure: no benchmarks created. frameworksToRun: ${frameworksToRun}`
+ );
+ }
+ }
+
+ const configPath = await writeConfig(baseBenchConfig.name, config);
+
+ return { name: baseBenchConfig.name, configPath, config };
+}
+
+async function writeConfig(name, config) {
+ const configPath = benchesRoot('dist', name + '.config.json');
+ await mkdir(path.dirname(configPath), { recursive: true });
+ await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
+
+ return configPath;
+}
+
+/**
+ * @typedef {Exclude<ConfigFileBenchmark["browser"], string>} BrowserConfigs
+ * @param {string} str
+ * @returns {BrowserConfigs}
+ */
+function parseBrowserOption(str) {
+ // Source: https://github.com/Polymer/tachometer/blob/d4d5116acb2d7df18035ddc36f0a3a1730841a23/src/browser.ts#L100
+ let remoteUrl;
+ const at = str.indexOf('@');
+ if (at !== -1) {
+ remoteUrl = str.substring(at + 1);
+ str = str.substring(0, at);
+ }
+ const headless = str.endsWith('-headless');
+ if (headless === true) {
+ str = str.replace(/-headless$/, '');
+ }
+
+ /** @type {import('tachometer/lib/browser').BrowserName} */
+ // @ts-ignore
+ const name = str;
+
+ /** @type {BrowserConfigs} */
+ const config = { name, headless };
+ if (remoteUrl !== undefined) {
+ config.remoteUrl = remoteUrl;
+ }
+
+ // Custom browser options
+ if (config.name == 'chrome') {
+ config.addArguments = [
+ '--js-flags=--expose-gc',
+ '--enable-precise-memory-info'
+ ];
+ }
+
+ return config;
+}
diff --git a/preact/benches/scripts/deopts.js b/preact/benches/scripts/deopts.js
new file mode 100644
index 0000000..990a6cc
--- /dev/null
+++ b/preact/benches/scripts/deopts.js
@@ -0,0 +1,253 @@
+import * as path from 'path';
+import { mkdir } from 'fs/promises';
+import { spawn } from 'child_process';
+import { Transform } from 'stream';
+import escapeRe from 'escape-string-regexp';
+import stripAnsi from 'strip-ansi';
+import { pool } from '@kristoferbaxter/async';
+import {
+ globSrc,
+ benchesRoot,
+ getPkgBinPath,
+ resultsPath,
+ IS_CI
+} from './utils.js';
+import { generateConfig } from './config.js';
+import { defaultBenchOptions } from './bench.js';
+
+export const defaultDeoptsOptions = {
+ framework: 'preact-local',
+ timeout: 5,
+ open: IS_CI ? false : true
+};
+
+const getResultDir = (benchmark, framework) =>
+ resultsPath('v8-deopt-viewer', benchmark, framework);
+
+/**
+ * @param {string} pkgName
+ * @param {string[]} args
+ * @param {"pipe" | "inherit"} [stdio]
+ * @returns {Promise<import('child_process').ChildProcess>}
+ */
+async function runPackage(pkgName, args, stdio) {
+ const binPath = await getPkgBinPath(pkgName);
+ args.unshift(binPath);
+
+ return spawn(process.execPath, args, { stdio });
+}
+
+/**
+ * @param {import('child_process').ChildProcess} childProcess
+ */
+async function onExit(childProcess) {
+ return new Promise((resolve, reject) => {
+ childProcess.once('exit', (code, signal) => {
+ if (code === 0 || signal == 'SIGINT') {
+ resolve();
+ } else {
+ reject(new Error('Exit with error code: ' + code));
+ }
+ });
+
+ childProcess.once('error', err => {
+ reject(err);
+ });
+ });
+}
+
+/**
+ * @typedef {{ benchName: string; framework: string; url: string; }} TachURL
+ * @param {import('child_process').ChildProcess} tachProcess
+ * @param {import('./config').ConfigData} tachConfig
+ * @param {number} timeoutMs
+ * @returns {Promise<TachURL[]>}
+ */
+async function getTachometerURLs(tachProcess, tachConfig, timeoutMs = 60e3) {
+ return new Promise(async (resolve, reject) => {
+ let timeout;
+ if (timeoutMs > 0) {
+ timeout = setTimeout(() => {
+ reject(
+ new Error(
+ 'Timed out waiting for Tachometer to get set up. Did it output a URL?'
+ )
+ );
+ }, timeoutMs);
+ }
+
+ // Look for lines like:
+ // many_updates [@preact]
+ // http://127.0.0.1:56536/src/many_updates.html
+ const benchesToSearch = tachConfig.config.benchmarks.map(bench => ({
+ benchName: bench.name,
+ framework: bench.packageVersions.label,
+ regex: new RegExp(
+ escapeRe(`${bench.name} [@${bench.packageVersions.label}]`) +
+ `\\s+(http:\\/\\/.*)`,
+ 'im'
+ ),
+ url: null
+ }));
+
+ /** @type {TachURL[]} */
+ const results = [];
+ let output = '';
+ tachProcess.stdout.on('data', function onStdOutChunk(chunk) {
+ output += stripAnsi(chunk.toString('utf8'));
+
+ for (let bench of benchesToSearch) {
+ if (bench.url) {
+ continue;
+ }
+
+ let match = output.match(bench.regex);
+ if (match) {
+ bench.url = match[1];
+ results.push(bench);
+ }
+ }
+
+ if (results.length == benchesToSearch.length) {
+ // All URLs found, removeEventListener
+ tachProcess.off('data', onStdOutChunk);
+
+ clearTimeout(timeout);
+ resolve(results);
+ }
+ });
+ });
+}
+
+function createPrefixTransform(prefix) {
+ return new Transform({
+ transform(chunk, encoding, callback) {
+ try {
+ // @ts-ignore
+ chunk = encoding == 'buffer' ? chunk.toString() : chunk;
+ const lines = chunk.split('\n');
+
+ for (let line of lines) {
+ if (line) {
+ line = `[${prefix}] ${line}`;
+ this.push(line + '\n');
+ }
+ }
+
+ callback();
+ } catch (error) {
+ return callback(error);
+ }
+ }
+ });
+}
+
+/**
+ * @param {TachURL} tachURL
+ * @param {DeoptOptions} options
+ */
+async function runV8DeoptViewer(tachURL, options) {
+ const deoptOutputDir = getResultDir(tachURL.benchName, tachURL.framework);
+ await mkdir(deoptOutputDir, { recursive: true });
+
+ const deoptArgs = [
+ tachURL.url,
+ '-o',
+ deoptOutputDir,
+ '-t',
+ (options.timeout * 1000).toString()
+ ];
+
+ if (options.open) {
+ deoptArgs.push('--open');
+ }
+
+ const deoptProcess = await runPackage('v8-deopt-viewer', deoptArgs);
+ deoptProcess.stdout
+ .pipe(createPrefixTransform(tachURL.framework))
+ .pipe(process.stdout);
+ deoptProcess.stderr
+ .pipe(createPrefixTransform(tachURL.framework))
+ .pipe(process.stderr);
+
+ await onExit(deoptProcess);
+}
+
+/**
+ * @param {string} benchGlob
+ * @param {DeoptOptions} options
+ */
+export async function runDeopts(benchGlob, options) {
+ // TODO:
+ // * Handle multiple benchmarks
+
+ const frameworks = options.framework;
+ if (!benchGlob) {
+ benchGlob = 'many_updates.html';
+ }
+
+ const benchesToRun = await globSrc(benchGlob);
+ if (benchesToRun.length > 1) {
+ console.error('Matched multiple benchmarks. Only running the first one.');
+ }
+
+ const benchPath = benchesRoot('src', benchesToRun[0]);
+ const tachConfig = await generateConfig(benchPath, {
+ ...defaultBenchOptions,
+ ...defaultDeoptsOptions,
+ framework: frameworks
+ });
+
+ console.log('Benchmarks running:', benchPath);
+ console.log('Frameworks running:', frameworks);
+
+ /** @type {Promise<void>} */
+ let onTachExit;
+ /** @type {import('child_process').ChildProcess} */
+ let tachProcess;
+ try {
+ // Run tachometer in manual mode with generated config
+ const tachArgs = ['--config', tachConfig.configPath, '--manual'];
+ tachProcess = await runPackage('tachometer', tachArgs);
+ tachProcess.stdout.pipe(process.stdout);
+ tachProcess.stderr.pipe(process.stderr);
+ onTachExit = onExit(tachProcess);
+
+ // Parse URL from tachometer stdout
+ const tachURLs = await getTachometerURLs(tachProcess, tachConfig);
+
+ // Run v8-deopt-viewer against tachometer URL
+ console.log();
+ await pool(tachURLs, tachURL =>
+ runV8DeoptViewer(tachURL, {
+ ...options,
+ open: options.open && tachURLs.length == 1
+ })
+ );
+
+ if (tachURLs.length > 1) {
+ const rootResultDir = getResultDir('', '');
+ console.log(`\nOpen your browser to ${rootResultDir} to view results.`);
+
+ if (options.open) {
+ // TODO: Figure out how to open a directory in the user's default browser
+ }
+ }
+ } finally {
+ if (tachProcess) {
+ tachProcess.kill('SIGINT');
+
+ // Log a message is Tachometer takes a while to close
+ let logMsg = () => console.log('Waiting for Tachometer to exit...');
+ let t = setTimeout(logMsg, 2e3);
+
+ try {
+ await onTachExit;
+ } catch (error) {
+ console.error('Error waiting for Tachometer to exit:', error);
+ } finally {
+ clearTimeout(t);
+ }
+ }
+ }
+}
diff --git a/preact/benches/scripts/global.d.ts b/preact/benches/scripts/global.d.ts
new file mode 100644
index 0000000..0fb67a3
--- /dev/null
+++ b/preact/benches/scripts/global.d.ts
@@ -0,0 +1,15 @@
+interface TachometerOptions {
+ browser: string | string[];
+ framework: string | string[];
+ 'window-size': string;
+ 'sample-size': number;
+ horizon: string;
+ timeout: number;
+ trace: boolean;
+}
+
+interface DeoptOptions {
+ framework: string;
+ timeout: number;
+ open: boolean;
+}
diff --git a/preact/benches/scripts/index.js b/preact/benches/scripts/index.js
new file mode 100644
index 0000000..4eeb21a
--- /dev/null
+++ b/preact/benches/scripts/index.js
@@ -0,0 +1,110 @@
+import sade from 'sade';
+import { generateSingleConfig } from './config.js';
+import { defaultDeoptsOptions, runDeopts } from './deopts.js';
+import { defaultBenchOptions, runBenches } from './bench.js';
+import { analyze } from './analyze.js';
+
+const prog = sade('./scripts');
+
+// Tests:
+// - npm start
+prog
+ .command('config [bench]')
+ .describe('Generate the config for the given benchmark HTML file.')
+ .option(
+ '--trace',
+ 'Enable perf tracing for browsers that support it',
+ defaultBenchOptions.trace
+ )
+ .action(generateSingleConfig);
+
+// Tests:
+// - many* -n 2 -t 0
+// - many* -n 2 -t 0 -f preact-local -f preact-v8
+// - many* -n 2 -t 0 -f preact-local -f preact-v8 -b chrome
+prog
+ .command('bench [globs]')
+ .describe(
+ 'Run the benchmarks matching the given globs. The root for the globs is the "src" directory. Specify "all" to run all benchmarks (default). To get more help on options, see polymer/tachometer help. Result table is printed to stdout and written to a csv and json file in the results directory.'
+ )
+ .example('bench text*')
+ .example('bench *.html')
+ .example('bench all')
+ .example('bench many* -f preact-local -f preact-master')
+ .option(
+ '--browser, -b',
+ 'Which browsers to launch in automatic mode, comma-delimited (chrome, chrome-headless, firefox, firefox-headless, safari, edge, ie)',
+ defaultBenchOptions.browser
+ )
+ // TODO: Consider parsing and adding to configs
+ // .option(
+ // '--window-size',
+ // '"width,height" in pixels of the browser windows that will be created',
+ // defaultOptions['window-size']
+ // )
+ .option(
+ '--sample-size, -n',
+ 'Minimum number of times to run each benchmark',
+ defaultBenchOptions['sample-size']
+ )
+ .option(
+ '--horizon, -h',
+ 'The degrees of difference to try and resolve when auto-sampling ("N%" or "Nms", comma-delimited)',
+ defaultBenchOptions.horizon
+ )
+ .option(
+ '--timeout, -t',
+ 'The maximum number of minutes to spend auto-sampling',
+ defaultBenchOptions.timeout
+ )
+ .option(
+ '--framework, -f',
+ 'Which framework(s) to bench. Specify the flag multiple times to compare specific frameworks. Default is all frameworks',
+ defaultBenchOptions.framework
+ )
+ .option(
+ '--trace',
+ 'Enable perf tracing for browsers that support it',
+ defaultBenchOptions.trace
+ )
+ .action(runBenches);
+
+// Tests:
+// - (no args)
+// - many*
+// - many* -f preact-local -f preact-master
+prog
+ .command('deopts [benchmark]')
+ .describe(
+ 'Run v8-deopt-viewer against the specified benchmark file (defaults to many_updates.html). If a glob is given, only the first matching file will be run'
+ )
+ .example('deopts many_updates.html')
+ .example('deopts many*')
+ .example('deopts many* -f preact-local')
+ .example('deopts many* -f preact-local -f preact-master')
+ .option(
+ '--framework, -f',
+ 'The framework to run the benchmark with.',
+ defaultDeoptsOptions.framework
+ )
+ .option(
+ '--timeout, -t',
+ 'How long in seconds to keep the browser open while the benchmark runs. Passed to v8-deopt-viewer.',
+ defaultDeoptsOptions.timeout
+ )
+ .option(
+ '--open',
+ 'Open the resulting v8-deopt-viewer result in the browser upon completion',
+ defaultDeoptsOptions.open
+ )
+ .action(runDeopts);
+
+prog
+ .command('analyze')
+ .describe(
+ 'Analyze the trace logs created by running benchmarks with the --trace flag'
+ )
+ .example('analyze')
+ .action(analyze);
+
+prog.parse(process.argv);
diff --git a/preact/benches/scripts/prepare.js b/preact/benches/scripts/prepare.js
new file mode 100644
index 0000000..da1b7f3
--- /dev/null
+++ b/preact/benches/scripts/prepare.js
@@ -0,0 +1,44 @@
+import { readdir } from 'fs/promises';
+import path from 'path';
+import { execFileSync } from 'child_process';
+import del from 'del';
+import { repoRoot } from './utils.js';
+
+const npmCmd = process.platform == 'win32' ? 'npm.cmd' : 'npm';
+
+/**
+ * @param {string[]} frameworks
+ */
+export async function prepare(frameworks) {
+ const proxyRoot = repoRoot('benches/proxy-packages');
+ const proxyDirs = (await readdir(proxyRoot)).map(dirname =>
+ dirname.replace(/-proxy$/, '')
+ );
+
+ for (let framework of frameworks) {
+ const dirname = proxyDirs.find(dir => dir == framework);
+ if (dirname == null) {
+ continue;
+ }
+
+ const proxyDir = (...args) =>
+ path.join(proxyRoot, dirname + '-proxy', ...args);
+
+ // It appears from ad-hoc testing (npm v6.14.9 on Windows), npm will cache
+ // any locally referenced tarball files (e.g. "file:../../../preact.tgz") in
+ // its global cache.
+ //
+ // If a package-lock is present and the `npm ci` or `npm i` command is used,
+ // then npm will pull the tarball from the cache and not use the local
+ // tarball file even if the local reference has changed or is deleted.
+ //
+ // Because of the above behavior, we'll always delete the package-lock file
+ // and node_modules folder and use `npm i` to ensure we always get the
+ // latest packages
+ console.log(`Preparing ${dirname}: Cleaning ${proxyDir()}...`);
+ await del(['package-lock.json', 'node_modules'], { cwd: proxyDir() });
+
+ console.log(`Preparing ${dirname}: Running "npm i" in ${proxyDir()}...`);
+ execFileSync(npmCmd, ['i'], { cwd: proxyDir(), stdio: 'inherit' });
+ }
+}
diff --git a/preact/benches/scripts/tracing.d.ts b/preact/benches/scripts/tracing.d.ts
new file mode 100644
index 0000000..cc3f271
--- /dev/null
+++ b/preact/benches/scripts/tracing.d.ts
@@ -0,0 +1,224 @@
+// From: https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview
+
+export type TraceEvent =
+ | DurationEvent
+ | CompleteEvent
+ | InstantEvent
+ | AsyncEvent
+ | FlowEvent
+ | SampleEvent
+ | ProcessNameEvent
+ | ProcessLabelsEvent
+ | ProcessSortIndexEvent
+ | ThreadNameEvent
+ | ThreadSortIndexEvent
+ | MarkEvent
+ | ContextEvent
+ | ObjectCreatedEvent
+ | ObjectSnapshotEvent
+ | ObjectDestroyedEvent;
+
+interface BaseEvent {
+ /** The name of the event */
+ name: string;
+ /**
+ * The event categories. This is a comma separated list of categories for the
+ * event.
+ */
+ cat: string;
+ /** The event type (phase?) */
+ ph: string;
+ /** The tracing clock timestamp (microseconds) */
+ ts: number;
+ /** The thread clock timestamp of the event (microseconds) */
+ tts?: number;
+ /** Process ID */
+ pid: number;
+ /** Thread ID */
+ tid: number;
+ /** Any args provided for the event */
+ args: Record<string, any>;
+}
+
+interface StackData {
+ /**
+ * Stack frame at the start of the event. ID pointing the corresponding stack
+ * in the stackFrames map
+ */
+ sf?: number;
+ /**
+ * Stack at the start of the event. Usually contains program counter addresses
+ * as hex strings
+ */
+ stack?: string[];
+}
+
+/** Mark the beginning or end of a duration of work on a given thread */
+interface DurationEvent extends BaseEvent, StackData {
+ ph: 'B' | 'E';
+}
+
+/** Represents a duration of work on a given thread */
+interface CompleteEvent extends BaseEvent, StackData {
+ ph: 'X';
+ /** Tracing clock duration (microseconds) */
+ dur: number;
+ /** Thread clock duration? (microseconds) */
+ tdur?: number;
+ /** Stack frame at the end of this event */
+ esf?: number;
+ /** Stack at the end of this event */
+ estack?: string[];
+}
+
+/**
+ * Mark something happened but has no duration associated with it. Only threaded
+ * scoped events can have stack data associated with them.
+ */
+interface InstantEvent extends BaseEvent, StackData {
+ ph: 'i' | 'I';
+ /** Scope of the event. g = global, p = process, t = thread (default) */
+ s?: 'g' | 'p' | 't';
+}
+
+/**
+ * Async operations (e.g. frames in a game, network I/O). b = start, n =
+ * instant, e = end. Events with the same category, id, and scope (if provided)
+ * are considered events from the same event tree. Nested async events should
+ * have the same category and id as its parent (but perhaps a different name).
+ */
+interface AsyncEvent extends BaseEvent {
+ ph: 'b' | 'n' | 'e';
+ id: string;
+ scope?: string;
+}
+
+/**
+ * Similar to Async events but allows a duration to be associated with each
+ * other across threads/processes. Visually, think of a flow event as an arrow
+ * between two duration events. With flow events, each event will be drawn in
+ * the thread it is emitted from. The events will be linked together visually
+ * using lines and arrows.
+ *
+ * TODO: Finish filling out
+ */
+interface FlowEvent extends BaseEvent {
+ ph: 's' | 't' | 'f';
+}
+
+interface SampleEvent extends BaseEvent, StackData {
+ ph: 'P';
+}
+
+interface ProcessNameEvent extends BaseEvent {
+ ph: 'M';
+ name: 'process_name';
+ args: {
+ name: string;
+ };
+}
+
+interface ProcessLabelsEvent extends BaseEvent {
+ ph: 'M';
+ name: 'process_labels';
+ args: {
+ labels: string;
+ };
+}
+
+interface ProcessSortIndexEvent extends BaseEvent {
+ ph: 'M';
+ name: 'process_sort_index';
+ args: {
+ sort_index: number;
+ };
+}
+
+interface ProcessUptimeEvent extends BaseEvent {
+ ph: 'M';
+ name: 'process_uptime_seconds';
+ args: {
+ uptime: number;
+ };
+}
+
+interface ThreadNameEvent extends BaseEvent {
+ ph: 'M';
+ name: 'thread_name';
+ args: {
+ name: string;
+ };
+}
+
+interface ThreadSortIndexEvent extends BaseEvent {
+ ph: 'M';
+ name: 'thread_sort_index';
+ args: {
+ sort_index: number;
+ };
+}
+
+interface NumCPUsEvent extends BaseEvent {
+ ph: 'M';
+ name: 'num_cpus';
+ args: {
+ number: number;
+ };
+}
+
+/**
+ * Mark events are created whenever a corresponding navigation timing API mark
+ * is created
+ */
+interface MarkEvent extends BaseEvent {
+ ph: 'R';
+}
+
+/**
+ * Context events are used to mark sequences of trace events as belonging to a
+ * particular context (or a tree of contexts). "(" = enter context, ")" = exit
+ * context. The enter event adds a context to all following trace events on the
+ * same thread until a corresponding leave event exits that context. Context ids
+ * refer to context object snapshots.
+ */
+interface ContextEvent extends BaseEvent {
+ ph: '(' | ')';
+ id?: string;
+}
+
+/** Object was created. Time is inclusive */
+interface ObjectCreatedEvent extends BaseEvent {
+ ph: 'N';
+ id: string;
+ scope?: string;
+ args: undefined;
+}
+
+interface ObjectSnapshotEvent extends BaseEvent {
+ ph: 'O';
+ id: string;
+ scope?: string;
+ args: {
+ /**
+ * By default, an object snapshot inherits the category of its containing
+ * trace event. However, sometimes the object being snapshotted needs its
+ * own category. This happens because the place that creates an object
+ * snapshot's values is often separate form where the objects' constructor
+ * and destructor is called. Categories for the object creation and deletion
+ * commands must match the snapshot commands. Thus, the category of any
+ * object snapshot may be provided with the snapshot itself
+ */
+ cat?: string;
+ /** Name of base type object */
+ base_type?: string;
+ snapshot: any;
+ };
+}
+
+/** Object was destroyed. Time is exclusive */
+interface ObjectDestroyedEvent extends BaseEvent {
+ ph: 'D';
+ id: string;
+ scope?: string;
+ args: undefined;
+}
diff --git a/preact/benches/scripts/utils.js b/preact/benches/scripts/utils.js
new file mode 100644
index 0000000..f8404c0
--- /dev/null
+++ b/preact/benches/scripts/utils.js
@@ -0,0 +1,65 @@
+import { fileURLToPath } from 'url';
+import { stat, readFile } from 'fs/promises';
+import * as path from 'path';
+import escalade from 'escalade';
+import globby from 'globby';
+
+// TODO: Replace with import.meta.resolve when stable
+import { createRequire } from 'module';
+// @ts-ignore
+const require = createRequire(import.meta.url);
+
+export const IS_CI = process.env.CI === 'true';
+
+// @ts-ignore
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+export const repoRoot = (...args) => path.join(__dirname, '..', '..', ...args);
+export const benchesRoot = (...args) => repoRoot('benches', ...args);
+export const resultsPath = (...args) => benchesRoot('results', ...args);
+
+export const toUrl = str => str.replace(/^[A-Za-z]+:/, '/').replace(/\\/g, '/');
+
+export const allBenches = '**/*.html';
+export function globSrc(patterns) {
+ return globby(patterns, { cwd: benchesRoot('src') });
+}
+
+export async function getPkgBinPath(pkgName) {
+ /** @type {string | void} */
+ let packageJsonPath;
+ try {
+ // TODO: Replace with import.meta.resolve when stable
+ const pkgMainPath = require.resolve(pkgName);
+ packageJsonPath = await escalade(pkgMainPath, (dir, names) => {
+ if (names.includes('package.json')) {
+ return 'package.json';
+ }
+ });
+ } catch (e) {
+ // Tachometer doesn't have a valid 'main' entry
+ packageJsonPath = benchesRoot('node_modules', pkgName, 'package.json');
+ }
+
+ if (!packageJsonPath || !(await stat(packageJsonPath)).isFile()) {
+ throw new Error(
+ `Could not locate "${pkgName}" package.json at "${packageJsonPath}".`
+ );
+ }
+
+ const pkg = JSON.parse(await readFile(packageJsonPath, 'utf8'));
+ if (!pkg.bin) {
+ throw new Error(`${pkgName} package.json does not contain a "bin" entry.`);
+ }
+
+ let binSubPath = pkg.bin;
+ if (typeof pkg.bin == 'object') {
+ binSubPath = pkg.bin[pkgName];
+ }
+
+ const binPath = path.join(path.dirname(packageJsonPath), binSubPath);
+ if (!(await stat(binPath)).isFile()) {
+ throw new Error(`Bin path for ${pkgName} is not a file: ${binPath}`);
+ }
+
+ return binPath;
+}
diff --git a/preact/benches/src/02_replace1k.html b/preact/benches/src/02_replace1k.html
new file mode 100644
index 0000000..e6b6270
--- /dev/null
+++ b/preact/benches/src/02_replace1k.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>replace all rows</title>
+ <meta name="description" content="updating all 1,000 rows" />
+ <style>
+ .preloadicon {
+ display: none;
+ }
+ .glyphicon-remove:before {
+ content: '⨯';
+ }
+ </style>
+ </head>
+ <body>
+ <div id="main"></div>
+ <script type="module">
+ import {
+ measureName,
+ measureMemory,
+ testElementText,
+ afterFrame,
+ afterFrameAsync,
+ markRunStart,
+ markRunEnd
+ } from './util.js';
+ import * as framework from 'framework';
+ import { render } from '../src/keyed-children/index.js';
+
+ const { run } = render(framework, document.getElementById('main'));
+
+ async function main() {
+ const elementSelector = 'tr:first-child > td:first-child';
+
+ // MUST BE KEPT IN SYNC WITH WARMUP COUNT IN benches/scripts/config.js
+ const WARMUP_COUNT = 5;
+ for (let i = 0; i < WARMUP_COUNT; i++) {
+ markRunStart(`warmup-${i}`);
+ run();
+ await markRunEnd(`warmup-${i}`);
+
+ await afterFrameAsync();
+ testElementText(elementSelector, (i * 1000 + 1).toFixed());
+ }
+
+ await afterFrameAsync();
+
+ afterFrame(function () {
+ testElementText(elementSelector, WARMUP_COUNT + '001');
+ performance.mark('stop');
+ performance.measure(measureName, 'start', 'stop');
+
+ measureMemory();
+ });
+
+ markRunStart('final');
+ performance.mark('start');
+ run();
+ await markRunEnd('final');
+ }
+
+ afterFrame(main);
+ </script>
+ </body>
+</html>
diff --git a/preact/benches/src/03_update10th1k_x16.html b/preact/benches/src/03_update10th1k_x16.html
new file mode 100644
index 0000000..9de8fdb
--- /dev/null
+++ b/preact/benches/src/03_update10th1k_x16.html
@@ -0,0 +1,78 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>partial update</title>
+ <meta
+ name="description"
+ content="updating every 10th row for 1,000 rows (3 warmup runs). 16x CPU slowdown."
+ />
+ <style>
+ .preloadicon {
+ display: none;
+ }
+ .glyphicon-remove:before {
+ content: "⨯";
+ }
+ </style>
+ </head>
+ <body>
+ <div id="main"></div>
+ <script type="module">
+ import {
+ measureName,
+ measureMemory,
+ afterFrame,
+ afterFrameAsync,
+ getRowLinkSel,
+ testElement,
+ testElementTextContains
+ } from './util.js';
+ import * as framework from 'framework';
+ import { render } from '../src/keyed-children/index.js';
+
+ const { run: mount, update } = render(
+ framework,
+ document.getElementById('main')
+ );
+
+ function repeat(pattern, repeats) {
+ let result = '';
+ for (let i = 0; i < repeats; i++) {
+ result += pattern;
+ }
+
+ return result;
+ }
+
+ async function init() {
+ mount();
+
+ await afterFrameAsync();
+ testElement(getRowLinkSel(1000));
+
+ for (let i = 0; i < 3; i++) {
+ update();
+
+ await afterFrameAsync();
+ testElementTextContains(getRowLinkSel(991), repeat(' !!!', i + 1));
+ }
+ }
+
+ async function run() {
+ performance.mark('start');
+ update();
+
+ await afterFrameAsync();
+ testElementTextContains(getRowLinkSel(991), repeat(' !!!', 3 + 1));
+ performance.mark('stop');
+ performance.measure(measureName, 'start', 'stop');
+
+ measureMemory();
+ }
+
+ init().then(run);
+ </script>
+ </body>
+</html>
diff --git a/preact/benches/src/07_create10k.html b/preact/benches/src/07_create10k.html
new file mode 100644
index 0000000..728d472
--- /dev/null
+++ b/preact/benches/src/07_create10k.html
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>create many rows</title>
+ <meta name="description" content="creating 10,000 rows" />
+ <style>
+ .preloadicon {
+ display: none;
+ }
+ .glyphicon-remove:before {
+ content: '⨯';
+ }
+ </style>
+ </head>
+ <body>
+ <div id="main"></div>
+ <script type="module">
+ import {
+ measureName,
+ measureMemory,
+ testElementText,
+ afterFrame
+ } from './util.js';
+ import * as framework from 'framework';
+ import { render } from '../src/keyed-children/index.js';
+
+ const { runLots } = render(framework, document.getElementById('main'));
+
+ async function main() {
+ const elementSelector = 'tr:last-child > td:first-child';
+
+ performance.mark('start');
+ runLots();
+
+ afterFrame(() => {
+ testElementText(elementSelector, '10000');
+ performance.mark('stop');
+ performance.measure(measureName, 'start', 'stop');
+
+ measureMemory();
+ });
+ }
+
+ main();
+ </script>
+ </body>
+</html>
diff --git a/preact/benches/src/filter_list.html b/preact/benches/src/filter_list.html
new file mode 100644
index 0000000..d31e1af
--- /dev/null
+++ b/preact/benches/src/filter_list.html
@@ -0,0 +1,92 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Patching HTML</title>
+ <style>
+ .items {
+ margin: 1em 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px;
+ }
+
+ .items > * {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 3em;
+ height: 2em;
+ margin: 0;
+ padding: 0;
+ background: #eee;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="app"></div>
+ <script type="module">
+ import { measureName, measureMemory } from './util.js';
+ import { createRoot, createElement as h, Component } from 'framework';
+
+ function Row(props) {
+ return h('article', null, props.children);
+ }
+
+ function App(props) {
+ return h('div', {}, [
+ h(
+ 'div',
+ { class: 'items' },
+ props.items.map(id => h(Row, { key: id }, id))
+ )
+ ]);
+ }
+
+ const count = 1000;
+ const start = 20;
+ const end = 600;
+
+ const newItems = () =>
+ Array(count)
+ .fill(0)
+ .map((item, i) => i);
+ let items = newItems();
+ let currentItems = items;
+
+ const root = createRoot(document.getElementById('app'));
+ root.render(h(App, { items }));
+
+ function runPatch() {
+ items = newItems().filter(id => {
+ const isVisible = currentItems.includes(id);
+ return id >= start && id <= end ? !isVisible : isVisible;
+ });
+ currentItems = items;
+
+ root.render(h(App, { items }));
+ }
+
+ async function warmup() {
+ const count = 25;
+
+ for (let i = 0; i < count; i++) {
+ runPatch();
+ await new Promise(r => requestAnimationFrame(r));
+ }
+ }
+
+ warmup().then(async () => {
+ performance.mark('start');
+ runPatch();
+ await new Promise(r => requestAnimationFrame(r));
+ performance.mark('stop');
+ performance.measure(measureName, 'start', 'stop');
+
+ measureMemory();
+ });
+ </script>
+ </body>
+</html>
diff --git a/preact/benches/src/hydrate1k.html b/preact/benches/src/hydrate1k.html
new file mode 100644
index 0000000..bf43448
--- /dev/null
+++ b/preact/benches/src/hydrate1k.html
@@ -0,0 +1,145 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>hydrate 1k table rows</title>
+ <meta name="description" content="hydrating 1,000 rows" />
+ <style>
+ .preloadicon {
+ display: none;
+ }
+ .glyphicon-remove:before {
+ content: '⨯';
+ }
+ </style>
+ </head>
+ <body>
+ <template id="template"></template>
+ <script type="module">
+ import {
+ measureName,
+ measureMemory,
+ testElementText,
+ afterFrame,
+ afterFrameAsync
+ } from './util.js';
+ import * as framework from 'framework';
+ import { getComponents } from '../src/keyed-children/components.js';
+ import { Store } from '../src/keyed-children/store.js';
+
+ /** @type {HTMLTemplateElement} */
+ const template = document.getElementById('template');
+ const { Main } = getComponents(framework);
+ const { createRoot, createElement } = framework;
+
+ const firstRowSel = 'tr:first-child > td:first-child';
+ const lastRowSel = 'tr:last-child > td:first-child';
+
+ const baseStore = new Store();
+ baseStore.run();
+
+ /**
+ * Delete the old hydrate root and create a new one with a clone of the
+ * template's content
+ */
+ function setupHydrateRoot() {
+ const hydrateRootId = 'hydrate-root';
+ let hydrateRoot = document.getElementById(hydrateRootId);
+ if (hydrateRoot) {
+ hydrateRoot.remove();
+ }
+
+ hydrateRoot = document.createElement('div');
+ hydrateRoot.id = hydrateRootId;
+ hydrateRoot.appendChild(template.content.cloneNode(true));
+ document.body.appendChild(hydrateRoot);
+ return hydrateRoot;
+ }
+
+ /** Render the app inside the template tag */
+ async function initializeTemplate() {
+ // Initialize template
+ createRoot(template.content).render(
+ createElement(Main, { store: baseStore })
+ );
+ await afterFrameAsync();
+ }
+
+ /**
+ * Click the second row's remove link and ensure the number of rows before
+ * and after the click are as expected
+ */
+ async function clickRemove(root, label, expectedBefore, expectedAfter) {
+ let rowCount = root.querySelectorAll('tr').length;
+ if (rowCount !== expectedBefore) {
+ throw new Error(
+ `${label}: Incorrect number of rows before remove click. Expected ${expectedBefore} but got ${rowCount}`
+ );
+ }
+
+ const removeLink = root.querySelector(
+ 'tr:nth-child(2) td:nth-child(3) a'
+ );
+ removeLink.click();
+ await afterFrameAsync();
+
+ rowCount = root.querySelectorAll('tr').length;
+ if (rowCount !== expectedAfter) {
+ throw new Error(
+ `${label}: Incorrect number of rows after after remove click. Expected ${expectedAfter} but got ${rowCount}`
+ );
+ }
+ }
+
+ async function warmupRun(i) {
+ // Test out hydrate and ensure it works
+ const hydrateRoot = setupHydrateRoot();
+
+ // Verify initial hydrate root isn't already hydrated and is static
+ testElementText(firstRowSel, '1');
+ testElementText(lastRowSel, '1000');
+ await clickRemove(hydrateRoot, `WARMUP ${i} - prehydrate`, 1000, 1000);
+
+ const store = new Store();
+ store.data = baseStore.data.slice();
+ createRoot(hydrateRoot).hydrate(createElement(Main, { store }));
+
+ // Verify hydrate has correct markup and is properly hydrated
+ testElementText(firstRowSel, '1');
+ testElementText(lastRowSel, '1000');
+ await clickRemove(hydrateRoot, `WARMUP ${i} - posthydrate`, 1000, 999);
+ }
+
+ function timedRun() {
+ afterFrame(function () {
+ performance.mark('stop');
+ performance.measure(measureName, 'start', 'stop');
+
+ measureMemory();
+ });
+
+ const hydrateRoot = setupHydrateRoot();
+ const store = new Store();
+ store.data = baseStore.data.slice();
+
+ performance.mark('start');
+ createRoot(hydrateRoot).hydrate(createElement(Main, { store }));
+ }
+
+ async function main() {
+ // const WARMUP_COUNT = 5;
+ const WARMUP_COUNT = 5;
+ for (let i = 0; i < WARMUP_COUNT; i++) {
+ await warmupRun(i);
+ }
+
+ await afterFrameAsync();
+
+ timedRun();
+ }
+
+ initializeTemplate().then(main);
+ </script>
+ </body>
+</html>
diff --git a/preact/benches/src/keyed-children/components.js b/preact/benches/src/keyed-children/components.js
new file mode 100644
index 0000000..0960b89
--- /dev/null
+++ b/preact/benches/src/keyed-children/components.js
@@ -0,0 +1,152 @@
+import { Store } from './store.js';
+
+/**
+ * @param {import('./index').Framework} framework
+ */
+export function getComponents({ createElement, Component }) {
+ class Row extends Component {
+ constructor(props) {
+ super(props);
+ this.onDelete = this.onDelete.bind(this);
+ this.onClick = this.onClick.bind(this);
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ nextProps.data !== this.props.data ||
+ nextProps.styleClass !== this.props.styleClass
+ );
+ }
+
+ onDelete() {
+ this.props.onDelete(this.props.data.id);
+ }
+
+ onClick() {
+ this.props.onClick(this.props.data.id);
+ }
+
+ render() {
+ let { styleClass, onClick, onDelete, data } = this.props;
+ return createElement(
+ 'tr',
+ {
+ className: styleClass
+ },
+ createElement(
+ 'td',
+ {
+ className: 'col-md-1'
+ },
+ data.id
+ ),
+ createElement(
+ 'td',
+ {
+ className: 'col-md-4'
+ },
+ createElement(
+ 'a',
+ {
+ onClick: this.onClick
+ },
+ data.label
+ )
+ ),
+ createElement(
+ 'td',
+ {
+ className: 'col-md-1'
+ },
+ createElement(
+ 'a',
+ {
+ onClick: this.onDelete
+ },
+ createElement('span', {
+ className: 'glyphicon glyphicon-remove',
+ 'aria-hidden': 'true'
+ })
+ )
+ ),
+ createElement('td', {
+ className: 'col-md-6'
+ })
+ );
+ }
+ }
+
+ class Main extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { store: props.store ?? new Store() };
+ this.select = this.select.bind(this);
+ this.delete = this.delete.bind(this);
+
+ // @ts-ignore
+ window.app = this;
+ }
+ run() {
+ this.state.store.run();
+ this.setState({ store: this.state.store });
+ }
+ add() {
+ this.state.store.add();
+ this.setState({ store: this.state.store });
+ }
+ update() {
+ this.state.store.update();
+ this.setState({ store: this.state.store });
+ }
+ select(id) {
+ this.state.store.select(id);
+ this.setState({ store: this.state.store });
+ }
+ delete(id) {
+ this.state.store.delete(id);
+ this.setState({ store: this.state.store });
+ }
+ runLots() {
+ this.state.store.runLots();
+ this.setState({ store: this.state.store });
+ }
+ clear() {
+ this.state.store.clear();
+ this.setState({ store: this.state.store });
+ }
+ swapRows() {
+ this.state.store.swapRows();
+ this.setState({ store: this.state.store });
+ }
+ render() {
+ let rows = this.state.store.data.map((d, i) => {
+ return createElement(Row, {
+ key: d.id,
+ data: d,
+ onClick: this.select,
+ onDelete: this.delete,
+ styleClass: d.id === this.state.store.selected ? 'danger' : ''
+ });
+ });
+ return createElement(
+ 'div',
+ {
+ className: 'container'
+ },
+ createElement(
+ 'table',
+ {
+ className: 'table table-hover table-striped test-data'
+ },
+ createElement('tbody', {}, rows)
+ ),
+ createElement('span', {
+ className: 'preloadicon glyphicon glyphicon-remove',
+ 'aria-hidden': 'true'
+ })
+ );
+ }
+ }
+
+ return { Main, Row };
+}
diff --git a/preact/benches/src/keyed-children/index.js b/preact/benches/src/keyed-children/index.js
new file mode 100644
index 0000000..ece1265
--- /dev/null
+++ b/preact/benches/src/keyed-children/index.js
@@ -0,0 +1,29 @@
+import { getComponents } from './components.js';
+
+/**
+ * @typedef Framework
+ * @property {(type: any, props?: any, ...children: any) => JSX.Element} createElement
+ * @property {(root: HTMLElement) => ({ render(vnode: JSX.Element): void; hydrate(vnode: JSX.Element): void; })} createRoot
+ * @property {any} Component
+ *
+ * @param {Framework} framework
+ * @param {HTMLElement} rootDom
+ */
+export function render(framework, rootDom) {
+ const { Main } = getComponents(framework);
+ framework.createRoot(rootDom).render(framework.createElement(Main));
+
+ /** @type {Main} */
+ // @ts-ignore
+ const app = window.app;
+ return {
+ run: app.run.bind(app),
+ add: app.add.bind(app),
+ update: app.update.bind(app),
+ select: app.select.bind(app),
+ delete: app.delete.bind(app),
+ runLots: app.runLots.bind(app),
+ clear: app.clear.bind(app),
+ swapRows: app.swapRows.bind(app)
+ };
+}
diff --git a/preact/benches/src/keyed-children/store.js b/preact/benches/src/keyed-children/store.js
new file mode 100644
index 0000000..413208d
--- /dev/null
+++ b/preact/benches/src/keyed-children/store.js
@@ -0,0 +1,119 @@
+function _random(max) {
+ return Math.round(Math.random() * 1000) % max;
+}
+
+export class Store {
+ constructor() {
+ this.data = [];
+ this.selected = undefined;
+ this.id = 1;
+ }
+ buildData(count = 1000) {
+ var adjectives = [
+ 'pretty',
+ 'large',
+ 'big',
+ 'small',
+ 'tall',
+ 'short',
+ 'long',
+ 'handsome',
+ 'plain',
+ 'quaint',
+ 'clean',
+ 'elegant',
+ 'easy',
+ 'angry',
+ 'crazy',
+ 'helpful',
+ 'mushy',
+ 'odd',
+ 'unsightly',
+ 'adorable',
+ 'important',
+ 'inexpensive',
+ 'cheap',
+ 'expensive',
+ 'fancy'
+ ];
+ var colours = [
+ 'red',
+ 'yellow',
+ 'blue',
+ 'green',
+ 'pink',
+ 'brown',
+ 'purple',
+ 'brown',
+ 'white',
+ 'black',
+ 'orange'
+ ];
+ var nouns = [
+ 'table',
+ 'chair',
+ 'house',
+ 'bbq',
+ 'desk',
+ 'car',
+ 'pony',
+ 'cookie',
+ 'sandwich',
+ 'burger',
+ 'pizza',
+ 'mouse',
+ 'keyboard'
+ ];
+ var data = [];
+ for (var i = 0; i < count; i++)
+ data.push({
+ id: this.id++,
+ label:
+ adjectives[_random(adjectives.length)] +
+ ' ' +
+ colours[_random(colours.length)] +
+ ' ' +
+ nouns[_random(nouns.length)]
+ });
+ return data;
+ }
+ updateData(mod = 10) {
+ for (let i = 0; i < this.data.length; i += 10) {
+ this.data[i] = Object.assign({}, this.data[i], {
+ label: this.data[i].label + ' !!!'
+ });
+ }
+ }
+ delete(id) {
+ var idx = this.data.findIndex(d => d.id === id);
+ this.data.splice(idx, 1);
+ }
+ run() {
+ this.data = this.buildData();
+ this.selected = undefined;
+ }
+ add() {
+ this.data = this.data.concat(this.buildData(1000));
+ }
+ update() {
+ this.updateData();
+ }
+ select(id) {
+ this.selected = id;
+ }
+ runLots() {
+ this.data = this.buildData(10000);
+ this.selected = undefined;
+ }
+ clear() {
+ this.data = [];
+ this.selected = undefined;
+ }
+ swapRows() {
+ if (this.data.length > 998) {
+ var a = this.data[1];
+ this.data[1] = this.data[998];
+ this.data[998] = a;
+ }
+ }
+}
diff --git a/preact/benches/src/many_updates.html b/preact/benches/src/many_updates.html
new file mode 100644
index 0000000..e59b304
--- /dev/null
+++ b/preact/benches/src/many_updates.html
@@ -0,0 +1,112 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Patching HTML</title>
+ <style>
+ .hello {
+ color: red;
+ }
+
+ .bye {
+ color: blue;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module">
+ import { measureName, measureMemory } from './util.js';
+ import { createRoot, createElement as h } from 'framework';
+
+ const state = {
+ msg: 'hello',
+ list: new Array(1000).fill(0).map((_, i) => ({
+ i,
+ text: 'foobar' + i
+ }))
+ };
+
+ let counter = 0;
+ function App() {
+ return h(
+ 'div',
+ { id: 'app' },
+ h('p', null, '> ', ++counter, ' <'),
+ h('p', null, state.msg),
+ ...state.list.map((obj, i) =>
+ h(
+ 'div',
+ { key: i, title: state.msg + i },
+ h('span', { className: state.msg }, obj.text),
+ h('span', { className: 'baz' }, 'one'),
+ h('span', { className: 'qux' }, 'two'),
+ h(
+ 'div',
+ null,
+ h('span', { className: 'qux' }, 'three'),
+ h('span', { className: 'qux' }, 'four'),
+ h('span', { className: 'baz' }, 'five'),
+ h(
+ 'div',
+ null,
+ h('span', { className: 'qux' }, 'six'),
+ h('span', { className: 'baz' }, 'seven'),
+ h('span', { className: state.msg }, 'eight')
+ )
+ )
+ )
+ )
+ );
+ }
+
+ const root = createRoot(document.getElementById('root'));
+
+ // const p = performance.now();
+ root.render(h(App));
+ // console.log(`mount: ${(performance.now() - p).toFixed(2)}ms`);
+
+ // const patchResults = [];
+
+ function runPatch() {
+ // const s = performance.now();
+ state.msg = state.msg === 'hello' ? 'bye' : 'hello';
+ state.list[0].text = state.msg;
+ root.render(h(App));
+ // patchResults.push(performance.now() - s);
+ }
+
+ async function warmup() {
+ // const count = 100;
+ const count = 25;
+
+ for (let i = 0; i < count; i++) {
+ runPatch();
+ await new Promise(r => requestAnimationFrame(r));
+ }
+
+ // let fastest = Infinity;
+ // const total = patchResults.reduce((all, cur) => {
+ // if (cur < fastest) {
+ // fastest = cur;
+ // }
+ // return all + cur;
+ // }, 0);
+
+ // console.log(`${count} runs average: ${(total / count).toFixed(2)}ms`);
+ // console.log(`fastest run: ${fastest.toFixed(2)}ms`);
+ }
+
+ warmup().then(async () => {
+ performance.mark('start');
+ runPatch();
+ await new Promise(r => requestAnimationFrame(r));
+ performance.mark('stop');
+ performance.measure(measureName, 'start', 'stop');
+
+ measureMemory();
+ });
+ </script>
+ </body>
+</html>
diff --git a/preact/benches/src/text_update.html b/preact/benches/src/text_update.html
new file mode 100644
index 0000000..6874558
--- /dev/null
+++ b/preact/benches/src/text_update.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Text Updates</title>
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module">
+ import { measureName, measureMemory } from './util.js';
+ import { createRoot, createElement } from 'framework';
+
+ const root = createRoot(document.getElementById('root'));
+
+ function component({ randomValue }) {
+ return createElement('div', {}, [
+ createElement('h2', {}, 'Test ' + randomValue),
+ createElement('h1', {}, `===${randomValue}===`)
+ ]);
+ }
+
+ let result;
+
+ performance.mark('start');
+ for (let i = 0; i < 100; i++) {
+ root.render(createElement(component, { randomValue: i }));
+ }
+ performance.mark('stop');
+ performance.measure(measureName, 'start', 'stop');
+ measureMemory();
+ </script>
+ </body>
+</html>
diff --git a/preact/benches/src/util.js b/preact/benches/src/util.js
new file mode 100644
index 0000000..64c4765
--- /dev/null
+++ b/preact/benches/src/util.js
@@ -0,0 +1,98 @@
+// import afterFrame from "../node_modules/afterframe/dist/afterframe.module.js";
+import afterFrame from 'afterframe';
+
+export { afterFrame };
+
+export const measureName = 'duration';
+
+let promise = null;
+export function afterFrameAsync() {
+ if (promise === null) {
+ promise = new Promise(resolve =>
+ afterFrame(time => {
+ promise = null;
+ resolve(time);
+ })
+ );
+ }
+
+ return promise;
+}
+
+export function measureMemory() {
+ if ('gc' in window && 'memory' in performance) {
+ // Report results in MBs
+ window.gc();
+ window.usedJSHeapSize = performance.memory.usedJSHeapSize / 1e6;
+ } else {
+ window.usedJSHeapSize = 0;
+ }
+}
+
+export function markRunStart(runId) {
+ performance.mark(`run-${runId}-start`);
+}
+
+let staticPromise = Promise.resolve();
+export function markRunEnd(runId) {
+ return staticPromise.then(() => {
+ performance.mark(`run-${runId}-end`);
+ performance.measure(
+ `run-${runId}`,
+ `run-${runId}-start`,
+ `run-${runId}-end`
+ );
+ });
+}
+
+export function getRowIdSel(index) {
+ return `tbody > tr:nth-child(${index}) > td:first-child`;
+}
+
+export function getRowLinkSel(index) {
+ return `tbody > tr:nth-child(${index}) > td:nth-child(2) > a`;
+}
+
+/**
+ * @param {string} selector
+ * @returns {Element}
+ */
+export function getBySelector(selector) {
+ const element = document.querySelector(selector);
+ if (element == null) {
+ throw new Error(`Could not find element matching selector: ${selector}`);
+ }
+
+ return element;
+}
+
+export function testElement(selector) {
+ const testElement = document.querySelector(selector);
+ if (testElement == null) {
+ throw new Error(
+ 'Test failed. Rendering after one paint was not successful'
+ );
+ }
+}
+
+export function testElementText(selector, expectedText) {
+ const elm = document.querySelector(selector);
+ if (elm == null) {
+ throw new Error('Could not find element matching selector: ' + selector);
+ }
+
+ if (elm.textContent != expectedText) {
+ throw new Error(
+ `Element did not have expected text. Expected: '${expectedText}' Actual: '${elm.textContent}'`
+ );
+ }
+}
+
+export function testElementTextContains(selector, expectedText) {
+ const elm = getBySelector(selector);
+ if (!elm.textContent.includes(expectedText)) {
+ throw new Error(
+ `Element did not include expected text. Expected to include: '${expectedText}' Actual: '${elm.textContent}'`
+ );
+ }
+}