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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions benchmarks/compare-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { SuperJSONCtor } from './fixtures.js';
import { isDevalue, prepare, runSteps, selectCases, type Case, type Op } from './suite.js';

interface LibMeta {
id: string;
label: string;
url: string;
sha: string;
ref: string;
kind: string;
subject: string;
}

const $ = (id: string) => {
const el = document.getElementById(id);
if (!el) throw new Error(`missing #${id}`);

return el;
};

const params = new URLSearchParams(location.search);

function calibrate() {
const start = performance.now();
let x = 0;
for (let i = 0; i < 3e7; i++) {
x = (x + (i & 7)) | 0;
}
(globalThis as { __sink?: number }).__sink = x;

return performance.now() - start;
}

/** null when the library can run the case, otherwise the first line of its error. */
function refusal(Lib: SuperJSONCtor, c: Case): string | null {
try {
prepare(Lib, c)();

return null;
} catch (error) {
return (error as Error).message.split('\n')[0];
}
}

async function runCompare(opts: { filter: string; ops: Op[]; timeMs: number; throttle: number; series?: string; run?: string }) {
$('status').textContent = 'loading versions…';
const libs: LibMeta[] = await (await fetch('/api/compare/libs')).json();
const versions: SuperJSONCtor[] = await Promise.all(
libs.map(async l => (await import(/* @vite-ignore */ l.url)).default)
);
const selected = selectCases(opts.filter, opts.ops);
const calibrationMs = calibrate();
const started = performance.now();
const cases = [];
for (const [i, c] of selected.entries()) {
$('status').textContent = `${i + 1}/${selected.length} ${c.id}`;
const refused = versions.map(v => (isDevalue(v) ? refusal(v, c) : null));
const timed = versions.flatMap((_, k) => (refused[k] === null ? [k] : []));
const { bytes, results } = await runSteps(
timed.map(k => versions[k]),
0,
c,
{ timeMs: opts.timeMs, minSampleMs: 25 }
);
const full = versions.map((_, k) => {
const at = timed.indexOf(k);
if (at < 0) {
return { ms: null, rme: null, samples: 0, identical: false, refused: refused[k] };
}

return { ...results[at], refused: null };
});
cases.push({ id: c.id, fixture: c.fixture.name, group: c.fixture.group, op: c.op, bytes, results: full });
}

const next = {
createdAt: new Date().toISOString(),
engine: navigator.userAgent,
throttle: opts.throttle,
calibrationMs,
crossOriginIsolated,
timeMs: opts.timeMs,
series: opts.series,
run: opts.run,
steps: libs.map(l => ({ id: l.id, sha: l.sha, ref: l.ref, kind: l.kind, subject: l.subject, body: '', parent: null, files: [], diff: '' })),
cases,
};
const saved = await (await fetch('/api/compare', { method: 'POST', body: JSON.stringify(next) })).json();
$('status').textContent = `done in ${((performance.now() - started) / 1000).toFixed(0)} s, saved as ${saved.name}`;
}

if (params.get('autorun') === '1') {
runCompare({
filter: params.get('filter') ?? '',
ops: (params.get('ops') ?? 'stringify,parse,serialize,deserialize').split(',').map(s => s.trim()) as Op[],
timeMs: Number(params.get('time') ?? 6000),
throttle: Number(params.get('throttle') ?? 1),
series: params.get('series') ?? undefined,
run: params.get('run') ?? undefined,
});
}
16 changes: 16 additions & 0 deletions benchmarks/compare.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>superjson vs devalue</title>
<style>
body { font: 14px/1.45 ui-sans-serif, system-ui, sans-serif; margin: 24px; }
pre { white-space: pre-wrap; }
</style>
</head>
<body>
<h1>superjson (base and PR) against devalue, interleaved</h1>
<pre id="status">loading…</pre>
<script type="module" src="/compare.js"></script>
</body>
</html>
83 changes: 83 additions & 0 deletions benchmarks/devalue-features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Round-trips one value per feature through superjson (current src) and devalue, and prints ok, lossy or throws.
* Run: bun benchmarks/devalue-features.ts
*/
import { isDeepStrictEqual } from 'node:util';
import { currentEntry, writeBundle } from './build.js';
import { parse, stringify } from 'devalue';

const SuperJSON = (await import(await writeBundle('probe-features', currentEntry, 'bun'))).default;

class Point {
constructor(public x: number, public y: number) {}
}

const cyclic: Record<string, unknown> = { name: 'loop' };
cyclic.self = cyclic;
const shared = { id: 1 };

const cases: [string, unknown][] = [
['Date', new Date('2026-10-22T10:00:00.000Z')],
['undefined in object and array', { a: undefined, b: [undefined, 1] }],
['NaN, Infinity, -0', { n: NaN, i: Infinity, z: -0 }],
['BigInt', { big: 12345678901234567890n }],
['Map and Set', { m: new Map([[1, 'a']]), s: new Set([1, 2]) }],
['RegExp', /ab+c/gi],
['URL', new URL('https://example.com/a?b=1')],
['URLSearchParams', new URLSearchParams('a=1&b=2')],
['ArrayBuffer', new ArrayBuffer(4)],
['Uint8Array and Float64Array', { u: new Uint8Array([1, 2]), f: new Float64Array([1.5]) }],
['BigInt64Array', new BigInt64Array([1n, -1n])],
['Error with message', new Error('boom')],
['Error with cause', new Error('outer', { cause: new Error('inner') })],
['class instance (not registered)', new Point(1, 2)],
['symbol (unregistered)', { s: Symbol.for('app.key') }],
['cycle', cyclic],
['shared references', { a: shared, b: shared }],
['own __proto__ key', JSON.parse('{"__proto__": {"polluted": true}, "x": 1}')],
['sparse array', [1, , 3]],
['Promise', Promise.resolve(1)],
['Temporal.Instant', globalThis.Temporal ? globalThis.Temporal.Instant.from('2026-10-22T10:00:00Z') : 'no Temporal in this runtime'],
];

const rows: string[] = [];
for (const [name, value] of cases) {
let sj: string;
try {
const sjs = new SuperJSON({ dedupe: true });
const back = sjs.parse(sjs.stringify(value));
sj = isDeepStrictEqual(back, value) ? 'ok' : 'lossy';
} catch (error) {
sj = `throws: ${(error as Error).message.split('\n')[0].slice(0, 70)}`;
}
let dv: string;
try {
const back = parse(stringify(value));
dv = isDeepStrictEqual(back, value) ? 'ok' : 'lossy';
} catch (error) {
dv = `throws: ${(error as Error).message.split('\n')[0].slice(0, 70)}`;
}
rows.push(`${name}\t${sj}\t${dv}`);
}

const custom = { p: new Point(3, 4) };
let dvCustom: string;
try {
const text = stringify(custom, { Point: (v: unknown) => v instanceof Point && [v.x, v.y] });
const back = parse(text, { Point: ([x, y]: number[]) => new Point(x, y) });
dvCustom = isDeepStrictEqual(back, custom) ? 'ok (reducer and reviver)' : 'lossy';
} catch (error) {
dvCustom = `throws: ${(error as Error).message.split('\n')[0].slice(0, 70)}`;
}
let sjCustom: string;
try {
const sjs = new SuperJSON();
sjs.registerClass(Point);
const back = sjs.parse(sjs.stringify(custom));
sjCustom = isDeepStrictEqual(back, custom) ? 'ok (registerClass)' : 'lossy';
} catch (error) {
sjCustom = `throws: ${(error as Error).message.split('\n')[0].slice(0, 70)}`;
}
rows.push(`custom class with registration (superjson) or reducer and reviver (devalue)\t${sjCustom}\t${dvCustom}`);

console.log(rows.join('\n'));
27 changes: 27 additions & 0 deletions benchmarks/devalue-lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { parse, stringify } from 'devalue';

/**
* devalue behind the SuperJSON call shape, so the harness can time it next to superjson.
* devalue has no class, symbol or custom-type registry, so the harness skips fixture setup for it.
*/
export default class DevalueCodec {
static readonly format = 'devalue';

constructor(_options?: { dedupe?: boolean }) {}

serialize(value: unknown) {
return { json: stringify(value) };
}

deserialize(payload: { json: string }) {
return parse(payload.json);
}

stringify(value: unknown) {
return stringify(value);
}

parse(text: string) {
return parse(text);
}
}
6 changes: 4 additions & 2 deletions benchmarks/ledger-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const { values } = parseArgs({
time: { type: 'string', default: '6000' },
headed: { type: 'boolean', default: false },
repeat: { type: 'string', default: '1' },
page: { type: 'string', default: '/steps' },
},
});

Expand Down Expand Up @@ -103,7 +104,7 @@ async function runOnce(run: number): Promise<any> {
`# ${values.browser}: fixed loop ${unthrottled.toFixed(0)} ms unthrottled, ${throttled.toFixed(0)} ms at rate ${rate} (${(throttled / unthrottled).toFixed(2)}× measured)`
);

const url = new URL('/steps', server.url);
const url = new URL(values.page!, server.url);
url.search = new URLSearchParams({
autorun: '1',
throttle: String(rate),
Expand Down Expand Up @@ -138,8 +139,9 @@ for (const c of cases) {
console.error(`\n${c.fixture} · ${c.op}`);
steps.forEach((s: any, i: number) => {
const r = c.results[i];
const shown = r.ms === null ? `${'refused'.padStart(8)} ` : `${r.ms.toFixed(2).padStart(8)} ms ${(c.results[base].ms / r.ms).toFixed(2)}×`;
console.error(
` ${String(i).padStart(2)} ${s.kind.padEnd(9)} ${s.sha} ${r.ms.toFixed(2).padStart(8)} ms ${(c.results[base].ms / r.ms).toFixed(2)}×${r.identical ? '' : ' ✗ OUTPUT DIFFERS'} ${s.subject.slice(0, 70)}`
` ${String(i).padStart(2)} ${s.kind.padEnd(9)} ${s.sha} ${shown}${r.identical ? '' : ' ✗ OUTPUT DIFFERS'} ${s.subject.slice(0, 70)}`
);
});
}
32 changes: 32 additions & 0 deletions benchmarks/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export async function startServer({ port, baselineRef, onResults, onLedger }: Se
const baseline = await ensureBaseline(baselineRef);
const resultsDir = join(BENCH, '.results');
const ledgersDir = join(resultsDir, 'ledgers');
const compareDir = join(resultsDir, 'compare');
mkdirSync(ledgersDir, { recursive: true });
const savedFile = async (dir: string, encoded: string) => {
const name = basename(decodeURIComponent(encoded));
Expand Down Expand Up @@ -61,6 +62,37 @@ export async function startServer({ port, baselineRef, onResults, onLedger }: Se
return new Response(Bun.file(join(BENCH, 'steps.html')), { headers });
case '/steps.js':
return js(await bundle(join(BENCH, 'steps-app.ts'), 'browser'));
case '/compare':
return new Response(Bun.file(join(BENCH, 'compare.html')), { headers });
case '/compare.js':
return js(await bundle(join(BENCH, 'compare-app.ts'), 'browser'));
case '/lib/devalue.js':
return js(await bundle(join(BENCH, 'devalue-lib.ts'), 'browser'));
case '/api/compare/libs': {
const ours = (await $`git -C ${ROOT} rev-parse perf/faster-serialize`.text()).trim();
const raw = (await $`git -C ${ROOT} rev-list -n 1 v2.2.3`.text()).trim();
const devalueVersion = JSON.parse(await Bun.file(join(ROOT, 'node_modules/devalue/package.json')).text()).version;

return Response.json(
[
{ id: 'raw', label: 'superjson 2.2.3 (col-web)', url: `/lib/ref/${raw}.js`, sha: raw, ref: 'v2.2.3', kind: 'baseline', subject: 'superjson 2.2.3, the version col-web ships' },
{ id: 'base', label: 'superjson origin/main', url: `/lib/ref/${baseline.sha}.js`, sha: baseline.sha, ref: baseline.ref, kind: 'reference', subject: 'superjson origin/main (2.2.5)' },
{ id: 'ours', label: 'superjson PR #364', url: `/lib/ref/${ours}.js`, sha: ours, ref: 'perf/faster-serialize', kind: 'step', subject: 'superjson perf/faster-serialize (PR #364)' },
{ id: 'devalue', label: `devalue ${devalueVersion}`, url: '/lib/devalue.js', sha: `devalue@${devalueVersion}`, ref: `npm devalue ${devalueVersion}`, kind: 'lib', subject: `devalue ${devalueVersion} through a SuperJSON-shaped adapter` },
],
{ headers }
);
}
case '/api/compare': {
const body = await req.json();
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const name = `${stamp}-x${body.throttle ?? 1}.json`;
mkdirSync(compareDir, { recursive: true });
await Bun.write(join(compareDir, name), JSON.stringify(body));
onLedger?.(body);

return Response.json({ ok: true, name }, { headers });
}
case '/api/steps':
return Response.json(await resolveSteps(), { headers });
case '/api/ledgers': {
Expand Down
24 changes: 22 additions & 2 deletions benchmarks/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,31 @@ const dataOf = (fixture: Fixture) => {
return dataCache.get(fixture);
};

/** devalue exposes `format = 'devalue'` and has no registries, so it skips fixture setup and fidelity is checked instead of bytes. */
export const isDevalue = (Lib: SuperJSONCtor): boolean => 'format' in Lib && Lib.format === 'devalue';

const instance = (Lib: SuperJSONCtor, fixture: Fixture) => {
const sj = new Lib({ dedupe: fixture.dedupe });
fixture.setup?.(sj);
if (!isDevalue(Lib)) {
fixture.setup?.(sj);
}

return sj;
};

/** devalue's text differs from superjson's, so it counts as identical when its round trip keeps superjson's canonical form. */
export function fidelityMatches(reference: SuperJSONCtor, lib: SuperJSONCtor, c: Case): boolean {
const ref = instance(reference, c.fixture);
const sj = instance(lib, c.fixture);
const data = dataOf(c.fixture) as any;
const canon = (v: any) => JSON.stringify(ref.serialize(v), stableBigint);
try {
return canon(sj.parse(sj.stringify(data))) === canon(data);
} catch {
return false;
}
}

/** Returns the function to time. All inputs are prepared up front so only the op itself is measured. */
export function prepare(Lib: SuperJSONCtor, c: Case): () => unknown {
const sj = instance(Lib, c.fixture);
Expand Down Expand Up @@ -215,7 +233,9 @@ export async function runSteps(
): Promise<{ bytes: number; results: StepResult[] }> {
const reference = versions[referenceIndex];
const expected = outputSignature(reference, reference, c);
const identical = versions.map(v => outputSignature(reference, v, c) === expected);
const identical = versions.map(v =>
isDevalue(v) ? fidelityMatches(reference, v, c) : outputSignature(reference, v, c) === expected
);
const fns = versions.map(v => prepare(v, c));
fns.forEach(fn => timeIt(fn, 2));
const counts = fns.map(fn => calibrate(fn, opts.minSampleMs));
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"benchmark": "^2.1.4",
"copy-anything": "4.0.5",
"decimal.js": "^10.3.1",
"devalue": "^6.0.3",
"mongodb": "^3.6.6",
"tsdx": "^0.14.1",
"typescript": "^5.9.3",
Expand Down