Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ jobs:

- name: Run tests
run: bun run test

- name: Benchmark smoke test
run: bun run bench:smoke
35 changes: 35 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Framework performance benchmarks

Install the locked dependencies and build the framework before running:

```sh
npm ci
bun run res:build
bun run test
bun run bench
bun run bench -- --filter html/ --output /tmp/rendering.json
bun run bench:smoke
```

The workloads cover escaping, attributes, styles, JSX construction, synchronous and asynchronous rendering, UTF-8 encoding, provider contexts, AsyncLocalStorage, request controllers, complete in-process requests, and routing with 1,000 registered routes.

Correctness tests verify each fixture's expected output twice and exercise the runner's statistics and output validation. CI runs a short smoke benchmark without timing thresholds, since shared CI scheduling and JIT behavior make them unreliable.

## Measurement method

Each workload receives at least one second of warmup, adaptive batch calibration, and nine approximately 60 ms samples. The runner reports the median time per operation. Synchronous workloads use a synchronous inner loop; asynchronous workloads await each operation. Expected output is checked before and after timing.

Rendering-only workloads reuse trees; `create-and-render` includes tree construction. The UTF-8 workload forces byte encoding because HTML strings may remain ropes internally. Request workloads create a fresh Request, call the framework handler, and consume Response.text(); they include no sockets, TLS, database, or external I/O. These measure in-process costs, not HTTP throughput. AsyncLocalStorage workloads are runtime controls and may be heavily optimized by the JIT.

## Comparing changes

Run the same harness against separately built checkouts with identical dependencies. `BENCH_ROOT` selects the framework checkout; `BENCH_CASE` selects one exact workload, avoiding preceding workloads affecting its JIT profile:

```sh
BENCH_ROOT=../baseline BENCH_CASE=html/list-100 bun bench/runner.mjs --output /tmp/baseline.json
BENCH_ROOT=../candidate BENCH_CASE=html/list-100 bun bench/runner.mjs --output /tmp/candidate.json
```

Repeat each version in fresh processes at least three times, alternating order, and compare the medians of process medians. Speedup is baseline time divided by candidate time. Use fixed source revisions and an otherwise idle machine; do not run benchmarks concurrently. Small differences and overlapping ranges are not evidence of improvement. Measurements do not cover cold startup or memory usage.

Optional JSON output includes raw samples, runtime/CPU information, source revision and hashes, and the shared workload hash. Keep results outside the repository and summarize relevant measurements in the PR description. `BENCH_EXCLUDE=stream` excludes streaming cases; their output correctness remains covered by tests.
87 changes: 87 additions & 0 deletions bench/fixtures.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import {resolve} from 'node:path';
import {pathToFileURL} from 'node:url';
import {AsyncLocalStorage} from 'node:async_hooks';

// Load the same workloads against either checkout, including its compiled framework.
const root = resolve(process.env.BENCH_ROOT || new URL('..', import.meta.url).pathname);
const load = path => import(pathToFileURL(resolve(root, path)).href);
export const renderer = await load('src/vendor/hyperons.js');
export const handlers = await load('src/Handlers.js');
const controllers = await load('src/RequestController.js');
const h = renderer.jsx ?? renderer.h;
const {Fragment, createRaw, render, renderSync, createContext, useContext} = renderer;
const rowHTML = i => `<li class="item" data-index="${i}"><a href="/items/${i}">Item ${i} &amp; details</a><span>${i}</span></li>`;
export const listHTML = count => `<ul>${Array.from({length: count}, (_, i) => rowHTML(i)).join('')}</ul>`;
export const list = count => h('ul', {children: Array.from({length: count}, (_, i) => h('li', {
className: 'item', 'data-index': i, children: [h('a', {href: `/items/${i}`, children: `Item ${i} & details`}), h('span', {children: [i]})],
}))});
const page = body => h('html', {children: [h('head', {}), h('body', {children: body})]});
const pageHTML = body => `<html><head></head><body>${body}</body></html>`;
const sync = (name, run, expected) => ({name, run, expected, async: false});
const asynchronous = (name, run, expected) => ({name, run, expected, async: true});

export function createCases() {
const tree = list(100);
const encoder = new TextEncoder();
const text = 'Safe plain text. '.repeat(256);
const escaped = '<>&"\''.repeat(256);
const attrs = h('input', {className: 'field', disabled: true, defaultValue: '<&', tabIndex: 2,
'aria-hidden': false, __rawProps: {'data-json': {x: '<'}, 'bad name': 'ignored'}});
const styled = h('div', {style: {backgroundColor: 'red', marginTop: 10, opacity: 0.5, msTransition: 'all', '--gap': '2em'}, children: 'styled'});
const ctx = createContext('default');
const Consumer = () => h('span', {children: useContext(ctx)});
let contextTree = h(Consumer, {});
for (let i = 0; i < 20; i++) contextTree = h(ctx.Provider, {value: `level-${i}`, children: contextTree});
const componentTree = h(Fragment, {children: Array.from({length: 100}, (_, i) => h(({value}) => h('b', {children: [value]}), {value: i}))});
const componentHTML = Array.from({length: 100}, (_, i) => `<b>${i}</b>`).join('');
const sparse = () => h('main', {children: [list(100), Promise.resolve(h('footer', {children: 'done'}))]});
const manyAsync = () => h('ul', {children: Array.from({length: 100}, (_, i) => Promise.resolve(h('li', {children: [i]})))});
const manyHTML = `<ul>${Array.from({length: 100}, (_, i) => `<li>${i}</li>`).join('')}</ul>`;
const storage = new AsyncLocalStorage();
const store = {value: 7};
const handler = handlers.make(async request => ({id: new URL(request.url).searchParams.get('id')}));
const request = (body, extras = {}) => handler.handleRequest({
request: new Request('http://localhost/bench/items?id=42'), render: async () => body(), ...extras,
}).then(response => response.text());
const allow = async () => ({TAG: 'Allow', _0: undefined});
const routed = handlers.make(async () => null);
for (let i = 0; i < 1000; i++) routed.hxGet(`/item-${i}`, allow, async () => h('p', {children: 'route'}), false);
const route = path => routed.handleRequest({request: new Request(`http://localhost${path}`), render: async () => 'fallback'}).then(r => r.text());
return [
sync('html/text-safe-4k', () => renderSync(text), text),
sync('html/text-escaped', () => renderSync(escaped), '&lt;&gt;&amp;&quot;&#x27;'.repeat(256)),
sync('html/attributes', () => renderSync(attrs), '<input class="field" disabled value="&lt;&amp;" tabindex="2" aria-hidden="false" data-json="{&quot;x&quot;:&quot;&lt;&quot;}"/>'),
sync('html/styles', () => renderSync(styled), '<div style="background-color:red;margin-top:10px;opacity:0.5;-ms-transition:all;--gap:2em;">styled</div>'),
sync('html/list-100', () => renderSync(tree), listHTML(100)),
sync('html/list-100-utf8', () => encoder.encode(renderSync(tree)).byteLength, Buffer.byteLength(listHTML(100))),
sync('html/create-and-render-100', () => renderSync(list(100)), listHTML(100)),
sync('html/components-100', () => renderSync(componentTree), componentHTML),
sync('html/context-depth-20', () => renderSync(contextTree), '<span>level-0</span>'),
sync('html/raw-4k', () => renderSync(createRaw(text)), text),
asynchronous('html/small-sync-async-api', () => render(h('p', {children: 'hello'})), '<p>hello</p>'),
asynchronous('html/single-promise', () => render(Promise.resolve('hello')), 'hello'),
asynchronous('html/async-api-sync-tree', () => render(tree), listHTML(100)),
asynchronous('html/async-sparse', () => render(sparse()), `<main>${listHTML(100)}<footer>done</footer></main>`),
asynchronous('html/stream-async-tail', async () => {const chunks = []; await render(sparse(), c => chunks.push(c)); return chunks.join('');}, `<main>${listHTML(100)}<footer>done</footer></main>`),
asynchronous('html/async-dense-100', () => render(manyAsync()), manyHTML),
asynchronous('html/async-nested', () => render(Promise.resolve(h('div', {children: Promise.resolve(tree)}))), `<div>${listHTML(100)}</div>`),
asynchronous('html/stream-sync-tree', async () => {const chunks = []; await render(tree, c => chunks.push(c)); return chunks.join('');}, listHTML(100)),
sync('context/als-run', () => storage.run(store, () => storage.getStore().value), 7),
sync('context/als-read-100', () => storage.run(store, () => {let total = 0; for (let i = 0; i < 100; i++) total += storage.getStore().value; return total;}), 700),
asynchronous('context/als-await', () => storage.run(store, async () => {await Promise.resolve(); return storage.getStore().value;}), 7),
sync('request/controller', () => {const c = controllers.make(); c.setStatus(201); c.appendTitleSegment('A'); c.prependTitleSegment('B'); return `${c.getCurrentStatus()}:${c.getTitleSegments().join('|')}`;}, '201:B|A'),
asynchronous('request/minimal', () => request(() => 'hello'), '<!DOCTYPE html>hello'),
asynchronous('request/page-100', () => request(() => page(list(100))), `<!DOCTYPE html>${pageHTML(listHTML(100))}`),
asynchronous('request/context-components-100', () => request(() => h(Fragment, {children: Array.from({length: 100}, () => h(() => handler.useContext().context.id, {}))})), '<!DOCTYPE html>' + '42'.repeat(100)),
asynchronous('request/head-title-body', () => request(() => page('body'), {onAfterBuildResponse: async ({requestController: c}) => {
c.appendTitleSegment('A & B'); c.appendToHead(h('meta', {name: 'test'})); c.appendBeforeBodyEnd(h('script', {src: '/app.js'}));
}}), '<!DOCTYPE html><html><head><meta name="test"/><title>A &amp; B</title></head><body>body<script src="/app.js"></script></body></html>'),
asynchronous('request/route-hit-1000', () => route('/_api/item-999'), '<!DOCTYPE html><p>route</p>'),
asynchronous('request/route-miss-1000', () => route('/missing'), '<!DOCTYPE html>fallback'),
];
}

export async function verifyCase(benchmark) {
assert.deepEqual(await benchmark.run(), benchmark.expected, benchmark.name);
}
73 changes: 73 additions & 0 deletions bench/runner.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {cpus, platform, arch} from 'node:os';
import {execFileSync} from 'node:child_process';
import {mkdirSync, writeFileSync, readFileSync} from 'node:fs';
import {createHash} from 'node:crypto';
import {dirname, resolve} from 'node:path';
import {createCases, verifyCase} from './fixtures.mjs';

export function summarize(samples) {
const sorted = [...samples].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
return {medianNs: median, minNs: sorted[0], maxNs: sorted.at(-1), opsPerSecond: 1e9 / median};
}
let sink = 0;
function consume(value) { sink = (sink + (typeof value === 'string' ? value.length : value)) | 0; }
function syncBatch(run, count) {
const start = performance.now();
for (let i = 0; i < count; i++) consume(run());
return performance.now() - start;
}
async function asyncBatch(run, count) {
const start = performance.now();
for (let i = 0; i < count; i++) consume(await run());
return performance.now() - start;
}

export async function measure(benchmark, {samples = 9, targetMs = 60, warmupMs = 1000} = {}) {
await verifyCase(benchmark);
const batch = benchmark.async ? asyncBatch : syncBatch;
let count = 1;
let elapsed;
const start = performance.now();
do {
elapsed = await batch(benchmark.run, count);
if (elapsed < targetMs / 2) count = Math.min(count * 2, 1 << 20);
} while (performance.now() - start < warmupMs || elapsed < targetMs / 2 && count < 1 << 20);
count = Math.max(1, Math.min(1 << 20, Math.round(count * targetMs / Math.max(elapsed, 0.001))));
const timings = [];
for (let i = 0; i < samples; i++) timings.push(await batch(benchmark.run, count) * 1e6 / count);
await verifyCase(benchmark);
return {name: benchmark.name, iterationsPerSample: count, samplesNs: timings, ...summarize(timings)};
}

if (import.meta.main) {
const smoke = process.argv.includes('--smoke');
const outputIndex = process.argv.indexOf('--output');
const filterIndex = process.argv.indexOf('--filter');
const filter = filterIndex < 0 ? '' : process.argv[filterIndex + 1];
const root = resolve(process.env.BENCH_ROOT || new URL('..', import.meta.url).pathname);
const results = [];
const exclude = process.env.BENCH_EXCLUDE;
const exactCase = process.env.BENCH_CASE;
for (const benchmark of createCases().filter(c => c.name.includes(filter) && (!exclude || !c.name.includes(exclude)) && (!exactCase || c.name === exactCase))) {
const result = await measure(benchmark, smoke ? {samples: 3, targetMs: 2, warmupMs: 5} : {});
results.push(result);
console.log(`${result.name.padEnd(38)} ${(result.medianNs / 1000).toFixed(3).padStart(10)} us/op`);
}
if (!results.length) throw new Error('No benchmarks matched');
const report = {
schemaVersion: 1, timestamp: new Date().toISOString(), runtime: Bun.version,
platform: platform(), arch: arch(), cpu: cpus()[0]?.model,
revision: execFileSync('git', ['rev-parse', 'HEAD'], {cwd: root, encoding: 'utf8'}).trim(),
dirty: Boolean(execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], {cwd: root, encoding: 'utf8'}).trim()),
workloadHash: createHash('sha256').update(readFileSync(new URL('./fixtures.mjs', import.meta.url))).update(readFileSync(import.meta.filename)).digest('hex'),
sourceHashes: Object.fromEntries(['src/vendor/hyperons.js', 'src/Handlers.js', 'src/RequestController.js', 'src/Hjsx.js'].map(path => [path, createHash('sha256').update(readFileSync(resolve(root, path))).digest('hex')])),
isolation: exactCase ? 'workload' : 'suite',
mode: smoke ? 'smoke' : 'measurement', results, sink,
};
if (outputIndex >= 0) {
const file = process.argv[outputIndex + 1];
mkdirSync(dirname(file), {recursive: true});
writeFileSync(file, JSON.stringify(report, null, 2) + '\n');
}
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
"res:build": "rescript",
"res:clean": "rescript clean",
"res:dev": "rescript watch",
"test": "bun test ./test/*.test.js"
"test": "bun test ./test/*.test.js",
"bench": "bun bench/runner.mjs",
"bench:smoke": "bun bench/runner.mjs --smoke"
},
"keywords": [
"rescript"
Expand Down
Loading
Loading