zbench-js 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +157 -0
- package/README.md +195 -0
- package/dist/capabilities.d.ts +15 -0
- package/dist/capabilities.js +47 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +278 -0
- package/dist/compare.d.ts +51 -0
- package/dist/compare.js +80 -0
- package/dist/config.d.ts +110 -0
- package/dist/config.js +175 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/isolate.d.ts +27 -0
- package/dist/isolate.js +74 -0
- package/dist/measure.d.ts +59 -0
- package/dist/measure.js +111 -0
- package/dist/pool.d.ts +5 -0
- package/dist/pool.js +15 -0
- package/dist/refs.d.ts +36 -0
- package/dist/refs.js +107 -0
- package/dist/report.d.ts +14 -0
- package/dist/report.js +156 -0
- package/dist/runner.d.ts +40 -0
- package/dist/runner.js +121 -0
- package/dist/stats.d.ts +19 -0
- package/dist/stats.js +30 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.js +2 -0
- package/dist/units.d.ts +44 -0
- package/dist/units.js +78 -0
- package/package.json +76 -0
package/dist/report.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
2
|
+
import * as io from 'ioium/node';
|
|
3
|
+
import { styleText } from 'node:util';
|
|
4
|
+
import { columns, matrices, timing } from './measure.js';
|
|
5
|
+
import { duration, sig } from './units.js';
|
|
6
|
+
const dim = (text) => styleText('gray', text);
|
|
7
|
+
const bold = (text) => styleText('bold', text);
|
|
8
|
+
/** `io.table` declares an `indent` option but does not apply it, so the first column carries it. */
|
|
9
|
+
const indent = ' ';
|
|
10
|
+
/** Heading for one test, with its file path when that isn't already the name. */
|
|
11
|
+
function heading(test) {
|
|
12
|
+
return bold(test.name) + (test.name == test.path ? '' : dim(' ' + test.path));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* What a cell shows when there is no measurement.
|
|
16
|
+
* The reason is kept out of the cell so one failure cannot stretch every column in the table.
|
|
17
|
+
*/
|
|
18
|
+
function na(result) {
|
|
19
|
+
return result.error ? styleText('red', 'N/A') : dim('N/A');
|
|
20
|
+
}
|
|
21
|
+
/** Why some rows have no numbers, one line per distinct reason. */
|
|
22
|
+
function notes(cases) {
|
|
23
|
+
const byReason = new Map();
|
|
24
|
+
for (const result of cases) {
|
|
25
|
+
const reason = result.skipped ?? result.error?.split('\n')[0].trim();
|
|
26
|
+
if (!reason)
|
|
27
|
+
continue;
|
|
28
|
+
if (!byReason.has(reason))
|
|
29
|
+
byReason.set(reason, []);
|
|
30
|
+
byReason.get(reason).push(result.configuration);
|
|
31
|
+
}
|
|
32
|
+
return [...byReason].map(([reason, where]) => `${indent}${dim('N/A: ' + where.join(', ') + ' — ' + reason)}`);
|
|
33
|
+
}
|
|
34
|
+
/** Print one matrix as a table, followed by its aggregate measurements. */
|
|
35
|
+
export function reportMatrix(matrix) {
|
|
36
|
+
if (matrix.label)
|
|
37
|
+
console.log(' ' + dim(matrix.label));
|
|
38
|
+
const all = columns(matrix);
|
|
39
|
+
const perRow = all.filter(c => !c.aggregate);
|
|
40
|
+
const aggregates = all.filter(c => c.aggregate);
|
|
41
|
+
io.table([
|
|
42
|
+
{ name: indent + 'configuration', text: result => indent + result.configuration, grow: 0 },
|
|
43
|
+
{ name: 'setup', text: result => (result.setup ? duration(result.setup) : ''), padStart: true, grow: 0 },
|
|
44
|
+
{
|
|
45
|
+
name: 'total',
|
|
46
|
+
text: result => (result.samples.length ? duration(timing(result).total) : na(result)),
|
|
47
|
+
padStart: true,
|
|
48
|
+
grow: 0,
|
|
49
|
+
},
|
|
50
|
+
...perRow.map(column => ({
|
|
51
|
+
name: column.label,
|
|
52
|
+
text: (result) => {
|
|
53
|
+
const value = column.value(result);
|
|
54
|
+
return value === null ? na(result) : column.format(value);
|
|
55
|
+
},
|
|
56
|
+
padStart: true,
|
|
57
|
+
grow: 0,
|
|
58
|
+
})),
|
|
59
|
+
{
|
|
60
|
+
name: '±',
|
|
61
|
+
text: (result) => (result.samples.length ? sig(timing(result).rsd, 2) + '%' : ''),
|
|
62
|
+
padStart: true,
|
|
63
|
+
grow: 0,
|
|
64
|
+
},
|
|
65
|
+
], { formatHead: dim }, matrix.cases);
|
|
66
|
+
for (const note of notes(matrix.cases))
|
|
67
|
+
console.log(note);
|
|
68
|
+
if (!aggregates.length)
|
|
69
|
+
return;
|
|
70
|
+
const totals = aggregates
|
|
71
|
+
.map(column => {
|
|
72
|
+
const value = column.total?.(matrix.cases) ?? null;
|
|
73
|
+
return value === null ? null : `${column.format(value)} ${column.label}`;
|
|
74
|
+
})
|
|
75
|
+
.filter(text => text !== null);
|
|
76
|
+
if (totals.length)
|
|
77
|
+
console.log(' ' + dim('aggregate') + ' ' + totals.join(dim(', ')));
|
|
78
|
+
}
|
|
79
|
+
/** Print every test's tables for a single state. */
|
|
80
|
+
export function reportRun(suite, cases) {
|
|
81
|
+
for (const test of suite.tests) {
|
|
82
|
+
const found = matrices(test, cases);
|
|
83
|
+
if (!found.length)
|
|
84
|
+
continue;
|
|
85
|
+
console.log();
|
|
86
|
+
console.log(heading(test));
|
|
87
|
+
for (const matrix of found)
|
|
88
|
+
reportMatrix(matrix);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** `1.35x` when better, `0.74x` when worse, colored only when the change beats the noise. */
|
|
92
|
+
function factorText(row, index) {
|
|
93
|
+
const { factor, significant, better } = row.deltas[index];
|
|
94
|
+
if (factor === null)
|
|
95
|
+
return '';
|
|
96
|
+
const text = sig(factor, 3) + 'x';
|
|
97
|
+
if (!significant)
|
|
98
|
+
return dim(text);
|
|
99
|
+
return styleText(better ? 'green' : 'red', text);
|
|
100
|
+
}
|
|
101
|
+
/** Print the delta tables that follow each state's own tables. */
|
|
102
|
+
export function reportComparison(comparisons, refs) {
|
|
103
|
+
const names = refs.map(ref => ref.ref);
|
|
104
|
+
let heading = null;
|
|
105
|
+
for (const comparison of comparisons) {
|
|
106
|
+
const rows = comparison.aggregate ? [comparison.aggregate] : comparison.rows;
|
|
107
|
+
if (!rows.length)
|
|
108
|
+
continue;
|
|
109
|
+
const label = comparison.test.name + (comparison.label ? dim(' ' + comparison.label) : '');
|
|
110
|
+
if (label != heading) {
|
|
111
|
+
console.log();
|
|
112
|
+
console.log(bold((heading = label)));
|
|
113
|
+
}
|
|
114
|
+
console.log(' ' + dim(comparison.column.label) + (comparison.aggregate ? dim(' (aggregate)') : ''));
|
|
115
|
+
io.table([
|
|
116
|
+
{ name: indent + 'configuration', text: row => indent + row.configuration, grow: 0 },
|
|
117
|
+
{
|
|
118
|
+
name: names[0],
|
|
119
|
+
text: row => row.cells[0].value === null ? dim('N/A') : comparison.column.format(row.cells[0].value),
|
|
120
|
+
padStart: true,
|
|
121
|
+
grow: 0,
|
|
122
|
+
},
|
|
123
|
+
...names.slice(1).flatMap((name, i) => [
|
|
124
|
+
{
|
|
125
|
+
name,
|
|
126
|
+
text: (row) => {
|
|
127
|
+
const cell = row.cells[i + 1];
|
|
128
|
+
return cell.value === null ? dim('N/A') : comparison.column.format(cell.value);
|
|
129
|
+
},
|
|
130
|
+
padStart: true,
|
|
131
|
+
grow: 0,
|
|
132
|
+
},
|
|
133
|
+
{ name: '', text: (row) => factorText(row, i + 1), padStart: true, grow: 0 },
|
|
134
|
+
]),
|
|
135
|
+
], { formatHead: dim }, rows);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** A one-line note about what the numbers were produced under. */
|
|
139
|
+
export function reportEnvironment(cpu, mem, iterations, warmup, jobs) {
|
|
140
|
+
console.log(dim(`${process.version} on ${process.platform}/${process.arch}`
|
|
141
|
+
+ ` cpu=${cpu} mem=${mem} ${iterations} iterations, ${warmup} warmup`
|
|
142
|
+
+ (jobs > 1 ? `, ${jobs} at a time` : '')));
|
|
143
|
+
}
|
|
144
|
+
/** List the tests and configurations that would run, without running them. */
|
|
145
|
+
export function reportList(suite) {
|
|
146
|
+
for (const test of suite.tests) {
|
|
147
|
+
console.log();
|
|
148
|
+
console.log(heading(test));
|
|
149
|
+
for (const config of test.configurations) {
|
|
150
|
+
const needs = [config.cpu && `cpu ${config.cpu}`, config.mem && `mem ${config.mem}`].filter(Boolean);
|
|
151
|
+
console.log(' ' + config.name + (needs.length ? dim(' needs ' + needs.join(', ')) : ''));
|
|
152
|
+
}
|
|
153
|
+
for (const [name, values] of Object.entries(test.flags))
|
|
154
|
+
console.log(' ' + dim(`flag ${name}: ${values.map(v => JSON.stringify(v)).join(', ')}`));
|
|
155
|
+
}
|
|
156
|
+
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Test } from './config.js';
|
|
2
|
+
import type { Amounts, TestModule } from './types.js';
|
|
3
|
+
/** The measurements taken for one test at one point in its matrix. */
|
|
4
|
+
export interface CaseResult {
|
|
5
|
+
/** The test's id, i.e. its path as written in the config */
|
|
6
|
+
test: string;
|
|
7
|
+
flags: Record<string, unknown>;
|
|
8
|
+
configuration: string;
|
|
9
|
+
value: Record<string, number>;
|
|
10
|
+
/** Milliseconds `setup` took */
|
|
11
|
+
setup: number;
|
|
12
|
+
/** Milliseconds each timed iteration took */
|
|
13
|
+
samples: number[];
|
|
14
|
+
/** Raw amounts summed over every timed iteration, keyed by quantity */
|
|
15
|
+
amounts: Amounts;
|
|
16
|
+
/** Present when the configuration was not run */
|
|
17
|
+
skipped?: string;
|
|
18
|
+
/** Present when the test threw */
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface RunOptions {
|
|
22
|
+
/** Overrides the test's own iteration count */
|
|
23
|
+
iterations?: number;
|
|
24
|
+
warmup?: number;
|
|
25
|
+
/** The machine's budget. Configurations asking for more are skipped. */
|
|
26
|
+
cpu?: number;
|
|
27
|
+
mem?: number;
|
|
28
|
+
/** Run every configuration, whatever the budget says */
|
|
29
|
+
all?: boolean;
|
|
30
|
+
/** Restricts a flag to a subset of its declared values */
|
|
31
|
+
flags?: Record<string, unknown[]>;
|
|
32
|
+
/** Called before each configuration so callers can show progress */
|
|
33
|
+
onCase?(test: Test, flags: Record<string, unknown>, configuration: string, index: number, total: number): void;
|
|
34
|
+
}
|
|
35
|
+
/** `import()` the test module and check that it is one. */
|
|
36
|
+
export declare function loadTest(test: Test): Promise<TestModule>;
|
|
37
|
+
/** Whether a configuration fits in the budget, and why not when it doesn't. */
|
|
38
|
+
export declare function skipReason(configuration: Test['configurations'][number], options: RunOptions): string | undefined;
|
|
39
|
+
/** Run every configuration of a test, once per flag combination. */
|
|
40
|
+
export declare function runTest(test: Test, options?: RunOptions): Promise<CaseResult[]>;
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { flagCombinations } from './config.js';
|
|
5
|
+
/** `import()` the test module and check that it is one. */
|
|
6
|
+
export async function loadTest(test) {
|
|
7
|
+
const module = (await import(pathToFileURL(test.file).href));
|
|
8
|
+
if (typeof module.test != 'function')
|
|
9
|
+
throw new Error(`${test.path} does not export a \`test\` function`);
|
|
10
|
+
return module;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Time one configuration.
|
|
14
|
+
* `setup`/`teardown` run once, `before`/`after` run around each iteration, and only `test` is timed.
|
|
15
|
+
*/
|
|
16
|
+
async function runCase(module, test, flags, configuration, options) {
|
|
17
|
+
const config = { ...flags, ...configuration.value };
|
|
18
|
+
const iterations = options.iterations ?? test.iterations;
|
|
19
|
+
const warmup = options.warmup ?? test.warmup;
|
|
20
|
+
const result = {
|
|
21
|
+
test: test.id,
|
|
22
|
+
flags,
|
|
23
|
+
configuration: configuration.name,
|
|
24
|
+
value: configuration.value,
|
|
25
|
+
setup: 0,
|
|
26
|
+
samples: [],
|
|
27
|
+
amounts: {},
|
|
28
|
+
};
|
|
29
|
+
// By key, not by metric: a key with both a per-configuration and an aggregate metric is one quantity
|
|
30
|
+
const quantities = [...new Set(test.metrics.map(metric => metric.key))];
|
|
31
|
+
const setupStart = performance.now();
|
|
32
|
+
let state;
|
|
33
|
+
try {
|
|
34
|
+
state = await module.setup?.(config);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
result.error = `setup: ${e?.stack ?? e}`;
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
result.setup = performance.now() - setupStart;
|
|
41
|
+
try {
|
|
42
|
+
for (let i = 0; i < warmup + iterations; i++) {
|
|
43
|
+
const timed = i >= warmup;
|
|
44
|
+
await module.before?.(config, state);
|
|
45
|
+
globalThis.gc?.();
|
|
46
|
+
const start = performance.now();
|
|
47
|
+
const amounts = await module.test(config, state);
|
|
48
|
+
const elapsed = performance.now() - start;
|
|
49
|
+
await module.after?.(config, state);
|
|
50
|
+
if (!timed)
|
|
51
|
+
continue;
|
|
52
|
+
result.samples.push(elapsed);
|
|
53
|
+
for (const key of quantities) {
|
|
54
|
+
const amount = amounts?.[key] ?? configuration.value[key];
|
|
55
|
+
if (amount === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
result.amounts[key] = (result.amounts[key] ?? 0) + amount;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
result.error = String(e?.stack ?? e);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
try {
|
|
66
|
+
await module.teardown?.(config, state);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
result.error ??= `teardown: ${e?.stack ?? e}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
/** Whether a configuration fits in the budget, and why not when it doesn't. */
|
|
75
|
+
export function skipReason(configuration, options) {
|
|
76
|
+
if (options.all)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (options.cpu !== undefined && configuration.cpu > options.cpu)
|
|
79
|
+
return `needs cpu ${configuration.cpu}`;
|
|
80
|
+
if (options.mem !== undefined && configuration.mem > options.mem)
|
|
81
|
+
return `needs mem ${configuration.mem}`;
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
/** Narrow a test's flags to the values the caller asked for, dropping the rest. */
|
|
85
|
+
function selectFlags(test, options) {
|
|
86
|
+
const flags = {};
|
|
87
|
+
for (const [name, values] of Object.entries(test.flags)) {
|
|
88
|
+
const wanted = options.flags?.[name];
|
|
89
|
+
flags[name] = wanted ? values.filter(v => wanted.some(w => Object.is(w, v))) : values;
|
|
90
|
+
}
|
|
91
|
+
return flagCombinations(flags);
|
|
92
|
+
}
|
|
93
|
+
/** Run every configuration of a test, once per flag combination. */
|
|
94
|
+
export async function runTest(test, options = {}) {
|
|
95
|
+
const module = await loadTest(test);
|
|
96
|
+
const combinations = selectFlags(test, options);
|
|
97
|
+
const results = [];
|
|
98
|
+
const total = combinations.length * test.configurations.length;
|
|
99
|
+
let index = 0;
|
|
100
|
+
for (const flags of combinations) {
|
|
101
|
+
for (const configuration of test.configurations) {
|
|
102
|
+
options.onCase?.(test, flags, configuration.name, index++, total);
|
|
103
|
+
const skipped = skipReason(configuration, options);
|
|
104
|
+
if (skipped) {
|
|
105
|
+
results.push({
|
|
106
|
+
test: test.id,
|
|
107
|
+
flags,
|
|
108
|
+
configuration: configuration.name,
|
|
109
|
+
value: configuration.value,
|
|
110
|
+
setup: 0,
|
|
111
|
+
samples: [],
|
|
112
|
+
amounts: {},
|
|
113
|
+
skipped,
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
results.push(await runCase(module, test, flags, configuration, options));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return results;
|
|
121
|
+
}
|
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Summary of a set of timing samples, in whatever unit the samples were taken in. */
|
|
2
|
+
export interface Stats {
|
|
3
|
+
n: number;
|
|
4
|
+
total: number;
|
|
5
|
+
mean: number;
|
|
6
|
+
median: number;
|
|
7
|
+
min: number;
|
|
8
|
+
max: number;
|
|
9
|
+
/** Sample standard deviation */
|
|
10
|
+
stddev: number;
|
|
11
|
+
/** Standard deviation as a percentage of the mean. This is the noise floor for comparisons. */
|
|
12
|
+
rsd: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function stats(samples: number[]): Stats;
|
|
15
|
+
/**
|
|
16
|
+
* The combined noise of two independent measurements, as a percentage.
|
|
17
|
+
* A change smaller than this is indistinguishable from run-to-run variance.
|
|
18
|
+
*/
|
|
19
|
+
export declare function combinedNoise(a: number, b: number): number;
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
2
|
+
export function stats(samples) {
|
|
3
|
+
const n = samples.length;
|
|
4
|
+
if (!n)
|
|
5
|
+
return { n, total: 0, mean: 0, median: 0, min: 0, max: 0, stddev: 0, rsd: 0 };
|
|
6
|
+
const total = samples.reduce((sum, v) => sum + v, 0);
|
|
7
|
+
const mean = total / n;
|
|
8
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
9
|
+
const mid = n >> 1;
|
|
10
|
+
// Bessel's correction: a single sample has no spread to estimate
|
|
11
|
+
const variance = n < 2 ? 0 : samples.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (n - 1);
|
|
12
|
+
const stddev = Math.sqrt(variance);
|
|
13
|
+
return {
|
|
14
|
+
n,
|
|
15
|
+
total,
|
|
16
|
+
mean,
|
|
17
|
+
median: n % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2,
|
|
18
|
+
min: sorted[0],
|
|
19
|
+
max: sorted[n - 1],
|
|
20
|
+
stddev,
|
|
21
|
+
rsd: mean ? (stddev / mean) * 100 : 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The combined noise of two independent measurements, as a percentage.
|
|
26
|
+
* A change smaller than this is indistinguishable from run-to-run variance.
|
|
27
|
+
*/
|
|
28
|
+
export function combinedNoise(a, b) {
|
|
29
|
+
return Math.sqrt(a ** 2 + b ** 2);
|
|
30
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much work one iteration did, keyed by quantity.
|
|
3
|
+
* Returning this from `test` overrides the amounts taken from the configuration,
|
|
4
|
+
* which matters whenever the real amount is not known until the test runs.
|
|
5
|
+
*/
|
|
6
|
+
export type Amounts = Record<string, number>;
|
|
7
|
+
/**
|
|
8
|
+
* What a test file exports.
|
|
9
|
+
* Only `test` is required; everything else fills in the gaps around it.
|
|
10
|
+
*
|
|
11
|
+
* @typeParam C The configuration, i.e. one entry's `value` merged with the active flags
|
|
12
|
+
* @typeParam S Whatever `setup` hands to the rest of the lifecycle
|
|
13
|
+
*/
|
|
14
|
+
export interface TestModule<C = any, S = any> {
|
|
15
|
+
/** Run once per configuration, before any iteration. Its time is reported separately and never counted as the test's. */
|
|
16
|
+
setup?(config: C, ...args: never[]): S | Promise<S>;
|
|
17
|
+
/** Run before each iteration, untimed. Use it to restore state the test consumes. */
|
|
18
|
+
before?(config: C, state: S): unknown;
|
|
19
|
+
/** The timed operation. Return {@link Amounts} to report what it actually processed. */
|
|
20
|
+
test(config: C, state: S): Amounts | void | Promise<Amounts | void>;
|
|
21
|
+
/** Run after each iteration, untimed. */
|
|
22
|
+
after?(config: C, state: S): unknown;
|
|
23
|
+
/** Run once per configuration, after every iteration. */
|
|
24
|
+
teardown?(config: C, state: S): unknown;
|
|
25
|
+
}
|
package/dist/types.js
ADDED
package/dist/units.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** A unit of time a measurement can be expressed in. */
|
|
2
|
+
export type Timespan = 'ns' | 'us' | 'ms' | 's';
|
|
3
|
+
/** Milliseconds in each timespan, since every duration is measured in milliseconds. */
|
|
4
|
+
export declare const timespans: {
|
|
5
|
+
readonly ns: 0.000001;
|
|
6
|
+
readonly us: 0.001;
|
|
7
|
+
readonly ms: 1;
|
|
8
|
+
readonly s: 1000;
|
|
9
|
+
};
|
|
10
|
+
/** Timespans from shortest to longest. */
|
|
11
|
+
export declare const timespanOrder: readonly ["ns", "us", "ms", "s"];
|
|
12
|
+
export declare function isTimespan(value: unknown): value is Timespan;
|
|
13
|
+
/**
|
|
14
|
+
* Units whose amounts are counted in bytes, so `unit: "MB"` on a byte count reports MB/s without a `scale`.
|
|
15
|
+
* Both decimal and binary prefixes are recognized; an explicit `scale` overrides this.
|
|
16
|
+
*/
|
|
17
|
+
export declare const byteUnits: {
|
|
18
|
+
readonly B: 1;
|
|
19
|
+
readonly kB: 1000;
|
|
20
|
+
readonly KB: 1000;
|
|
21
|
+
readonly MB: 1000000;
|
|
22
|
+
readonly GB: 1000000000;
|
|
23
|
+
readonly TB: 1000000000000;
|
|
24
|
+
readonly KiB: number;
|
|
25
|
+
readonly MiB: number;
|
|
26
|
+
readonly GiB: number;
|
|
27
|
+
readonly TiB: number;
|
|
28
|
+
};
|
|
29
|
+
/** The multiplier that converts a raw amount into `unit`. */
|
|
30
|
+
export declare function unitScale(unit: string): number;
|
|
31
|
+
/**
|
|
32
|
+
* Round to `digits` significant figures, then render without trailing zeroes.
|
|
33
|
+
* Exponential notation is kept for values too small to read otherwise.
|
|
34
|
+
*/
|
|
35
|
+
export declare function sig(value: number, digits?: number): string;
|
|
36
|
+
/** Render a duration in milliseconds using whichever timespan keeps it readable. */
|
|
37
|
+
export declare function duration(ms: number): string;
|
|
38
|
+
/**
|
|
39
|
+
* Pick the timespan that makes `amount` per timespan readable.
|
|
40
|
+
* Throughput grows with the timespan, so the search runs from longest to shortest.
|
|
41
|
+
*/
|
|
42
|
+
export declare function inferThroughputSpan(amount: number, ms: number): Timespan;
|
|
43
|
+
/** Pick the timespan that makes the time per unit readable. Cost shrinks with the timespan. */
|
|
44
|
+
export declare function inferCostSpan(amount: number, ms: number): Timespan;
|
package/dist/units.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
2
|
+
/** Milliseconds in each timespan, since every duration is measured in milliseconds. */
|
|
3
|
+
export const timespans = { ns: 1e-6, us: 1e-3, ms: 1, s: 1e3 };
|
|
4
|
+
/** Timespans from shortest to longest. */
|
|
5
|
+
export const timespanOrder = ['ns', 'us', 'ms', 's'];
|
|
6
|
+
export function isTimespan(value) {
|
|
7
|
+
return typeof value == 'string' && value in timespans;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Units whose amounts are counted in bytes, so `unit: "MB"` on a byte count reports MB/s without a `scale`.
|
|
11
|
+
* Both decimal and binary prefixes are recognized; an explicit `scale` overrides this.
|
|
12
|
+
*/
|
|
13
|
+
export const byteUnits = {
|
|
14
|
+
B: 1,
|
|
15
|
+
kB: 1e3,
|
|
16
|
+
KB: 1e3,
|
|
17
|
+
MB: 1e6,
|
|
18
|
+
GB: 1e9,
|
|
19
|
+
TB: 1e12,
|
|
20
|
+
KiB: 2 ** 10,
|
|
21
|
+
MiB: 2 ** 20,
|
|
22
|
+
GiB: 2 ** 30,
|
|
23
|
+
TiB: 2 ** 40,
|
|
24
|
+
};
|
|
25
|
+
/** The multiplier that converts a raw amount into `unit`. */
|
|
26
|
+
export function unitScale(unit) {
|
|
27
|
+
return unit in byteUnits ? 1 / byteUnits[unit] : 1;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Round to `digits` significant figures, then render without trailing zeroes.
|
|
31
|
+
* Exponential notation is kept for values too small to read otherwise.
|
|
32
|
+
*/
|
|
33
|
+
export function sig(value, digits = 4) {
|
|
34
|
+
if (!Number.isFinite(value))
|
|
35
|
+
return '-';
|
|
36
|
+
if (value == 0)
|
|
37
|
+
return '0';
|
|
38
|
+
return Number(value.toPrecision(digits)).toString();
|
|
39
|
+
}
|
|
40
|
+
/** Render a duration in milliseconds using whichever timespan keeps it readable. */
|
|
41
|
+
export function duration(ms) {
|
|
42
|
+
if (!Number.isFinite(ms))
|
|
43
|
+
return '-';
|
|
44
|
+
if (ms == 0)
|
|
45
|
+
return '0';
|
|
46
|
+
const span = pick(timespanOrder, s => ms / timespans[s]);
|
|
47
|
+
return sig(ms / timespans[span]) + span;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Choose the first candidate whose value lands in `[1, 1000)`.
|
|
51
|
+
* When none does, the closest one is used, so the result is always readable rather than empty.
|
|
52
|
+
*/
|
|
53
|
+
function pick(candidates, value) {
|
|
54
|
+
let best = candidates[0];
|
|
55
|
+
let distance = Infinity;
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
const v = Math.abs(value(candidate));
|
|
58
|
+
if (v >= 1 && v < 1000)
|
|
59
|
+
return candidate;
|
|
60
|
+
const from1 = v > 0 && Number.isFinite(v) ? Math.abs(Math.log10(v)) : Infinity;
|
|
61
|
+
if (from1 < distance) {
|
|
62
|
+
distance = from1;
|
|
63
|
+
best = candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return best;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Pick the timespan that makes `amount` per timespan readable.
|
|
70
|
+
* Throughput grows with the timespan, so the search runs from longest to shortest.
|
|
71
|
+
*/
|
|
72
|
+
export function inferThroughputSpan(amount, ms) {
|
|
73
|
+
return pick([...timespanOrder].reverse(), span => (amount * timespans[span]) / ms);
|
|
74
|
+
}
|
|
75
|
+
/** Pick the timespan that makes the time per unit readable. Cost shrinks with the timespan. */
|
|
76
|
+
export function inferCostSpan(amount, ms) {
|
|
77
|
+
return pick(timespanOrder, span => ms / timespans[span] / amount);
|
|
78
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zbench-js",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Data-driven, reproducible performance testing",
|
|
5
|
+
"funding": {
|
|
6
|
+
"type": "individual",
|
|
7
|
+
"url": "https://github.com/sponsors/james-pre"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"benchmark",
|
|
14
|
+
"performance",
|
|
15
|
+
"testing"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./dist/index.js",
|
|
19
|
+
"./*": "./dist/*.js"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"zbench": "dist/cli.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"LICENSE.md"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"format:check": "prettier --check .",
|
|
34
|
+
"format": "prettier --write .",
|
|
35
|
+
"lint": "tsc --noEmit && eslint src",
|
|
36
|
+
"build": "tsc -b",
|
|
37
|
+
"test": "node --test test/**/*.test.ts",
|
|
38
|
+
"prepublishOnly": "npx tsc"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/james-pre/zbench.git"
|
|
43
|
+
},
|
|
44
|
+
"author": "James Prevett <jp@jamespre.dev> (https://jamespre.dev)",
|
|
45
|
+
"license": "LGPL-3.0-or-later",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/james-pre/zbench/issues"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/james-pre/zbench#readme",
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@eslint/js": "^10.0.1",
|
|
52
|
+
"@types/node": "^26.0.0",
|
|
53
|
+
"eslint": "^10.1.0",
|
|
54
|
+
"globals": "^17.8.0",
|
|
55
|
+
"prettier": "^3.2.5",
|
|
56
|
+
"typedoc": "^0.28.18",
|
|
57
|
+
"typescript": "^6.0.0",
|
|
58
|
+
"typescript-eslint": "^8.58.0"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"ioium": "^1.8.0",
|
|
62
|
+
"utilium": "^3.0.0"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=22.18.0"
|
|
66
|
+
},
|
|
67
|
+
"prettier": {
|
|
68
|
+
"singleQuote": true,
|
|
69
|
+
"useTabs": true,
|
|
70
|
+
"trailingComma": "es5",
|
|
71
|
+
"tabWidth": 4,
|
|
72
|
+
"printWidth": 120,
|
|
73
|
+
"arrowParens": "avoid",
|
|
74
|
+
"experimentalOperatorPosition": "start"
|
|
75
|
+
}
|
|
76
|
+
}
|