perfshield 0.0.1
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 +21 -0
- package/README.md +126 -0
- package/examples/simple/README.md +19 -0
- package/examples/simple/bench-source.js +13 -0
- package/examples/simple/build.mjs +17 -0
- package/examples/simple/perfshield.config.json +24 -0
- package/lib/artifacts.js +19 -0
- package/lib/build.js +27 -0
- package/lib/cli.js +74 -0
- package/lib/config.js +328 -0
- package/lib/engines/node-harness.js +217 -0
- package/lib/engines/node.js +214 -0
- package/lib/regression.js +17 -0
- package/lib/report/console.js +25 -0
- package/lib/report/index.js +13 -0
- package/lib/report/json.js +12 -0
- package/lib/runner.js +177 -0
- package/lib/stats.js +108 -0
- package/lib/types.js +0 -0
- package/package.json +43 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
const toBenchmarkFn = (value, label) => {
|
|
4
|
+
if (typeof value !== "function") {
|
|
5
|
+
throw new Error(`${label} must be a function.`);
|
|
6
|
+
}
|
|
7
|
+
return (...args) => {
|
|
8
|
+
return value(...args);
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
const ensureBenchmarkSpec = (spec, label) => {
|
|
12
|
+
if (spec == null || typeof spec !== "object" || Array.isArray(spec)) {
|
|
13
|
+
throw new Error(`${label} must be an object.`);
|
|
14
|
+
}
|
|
15
|
+
const candidate = spec;
|
|
16
|
+
const name = candidate.name;
|
|
17
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
18
|
+
throw new Error(`${label}.name must be a non-empty string.`);
|
|
19
|
+
}
|
|
20
|
+
const fn = toBenchmarkFn(candidate.fn, `${label}.fn`);
|
|
21
|
+
const setup = candidate.setup == null ? undefined : toBenchmarkFn(candidate.setup, `${label}.setup`);
|
|
22
|
+
const teardown = candidate.teardown == null ? undefined : toBenchmarkFn(candidate.teardown, `${label}.teardown`);
|
|
23
|
+
let unit;
|
|
24
|
+
if (candidate.unit != null) {
|
|
25
|
+
if (typeof candidate.unit !== "string") {
|
|
26
|
+
throw new Error(`${label}.unit must be a string.`);
|
|
27
|
+
}
|
|
28
|
+
unit = candidate.unit;
|
|
29
|
+
}
|
|
30
|
+
let iterations;
|
|
31
|
+
if (candidate.iterations != null) {
|
|
32
|
+
if (typeof candidate.iterations !== "number") {
|
|
33
|
+
throw new Error(`${label}.iterations must be a number.`);
|
|
34
|
+
}
|
|
35
|
+
iterations = candidate.iterations;
|
|
36
|
+
}
|
|
37
|
+
let metadata;
|
|
38
|
+
if (candidate.metadata != null) {
|
|
39
|
+
if (typeof candidate.metadata !== "object" || Array.isArray(candidate.metadata)) {
|
|
40
|
+
throw new Error(`${label}.metadata must be an object.`);
|
|
41
|
+
}
|
|
42
|
+
metadata = candidate.metadata;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
fn,
|
|
46
|
+
iterations,
|
|
47
|
+
metadata,
|
|
48
|
+
name,
|
|
49
|
+
setup,
|
|
50
|
+
teardown,
|
|
51
|
+
unit
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
const loadBenchmarkModule = async modulePath => {
|
|
55
|
+
const url = pathToFileURL(modulePath).href;
|
|
56
|
+
const loaded = await import(url);
|
|
57
|
+
const benchmarks = loaded.benchmarks;
|
|
58
|
+
if (!Array.isArray(benchmarks)) {
|
|
59
|
+
throw new Error("Benchmark module must export a benchmarks array.");
|
|
60
|
+
}
|
|
61
|
+
return benchmarks;
|
|
62
|
+
};
|
|
63
|
+
const normalizeBenchmarks = (benchmarks, label) => benchmarks.map((spec, index) => ensureBenchmarkSpec(spec, `${label}.benchmarks[${index}]`));
|
|
64
|
+
const describeBenchmarks = benchmarks => benchmarks.map(benchmark => ({
|
|
65
|
+
iterations: benchmark.iterations,
|
|
66
|
+
metadata: benchmark.metadata,
|
|
67
|
+
name: benchmark.name,
|
|
68
|
+
unit: benchmark.unit
|
|
69
|
+
}));
|
|
70
|
+
const validateBenchmarkPairs = (baseline, current) => {
|
|
71
|
+
if (baseline.length !== current.length) {
|
|
72
|
+
throw new Error(`Benchmark count mismatch: baseline=${baseline.length}, current=${current.length}`);
|
|
73
|
+
}
|
|
74
|
+
baseline.forEach((benchmark, index) => {
|
|
75
|
+
if (benchmark.name !== current[index].name) {
|
|
76
|
+
throw new Error(`Benchmark name mismatch at index ${index}: ` + `${benchmark.name} vs ${current[index].name}`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
const runBenchmarkSample = async (benchmark, iterationsOverride) => {
|
|
81
|
+
const iterations = Math.max(1, iterationsOverride ?? benchmark.iterations ?? 1);
|
|
82
|
+
let setupCompleted = false;
|
|
83
|
+
if (benchmark.setup) {
|
|
84
|
+
await benchmark.setup();
|
|
85
|
+
setupCompleted = true;
|
|
86
|
+
}
|
|
87
|
+
const start = process.hrtime.bigint();
|
|
88
|
+
try {
|
|
89
|
+
for (let i = 0; i < iterations; i += 1) {
|
|
90
|
+
await benchmark.fn();
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
if (benchmark.teardown && setupCompleted) {
|
|
94
|
+
await benchmark.teardown();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const end = process.hrtime.bigint();
|
|
98
|
+
const durationMs = Number(end - start) / 1e6;
|
|
99
|
+
return durationMs;
|
|
100
|
+
};
|
|
101
|
+
const state = {
|
|
102
|
+
current: null
|
|
103
|
+
};
|
|
104
|
+
const handleInit = async message => {
|
|
105
|
+
const baselinePath = message.baselinePath;
|
|
106
|
+
const currentPath = message.currentPath;
|
|
107
|
+
if (baselinePath == null || currentPath == null) {
|
|
108
|
+
throw new Error("init requires baselinePath and currentPath.");
|
|
109
|
+
}
|
|
110
|
+
const baselineBenchmarksRaw = await loadBenchmarkModule(baselinePath);
|
|
111
|
+
const currentBenchmarksRaw = await loadBenchmarkModule(currentPath);
|
|
112
|
+
const baselineBenchmarks = normalizeBenchmarks(baselineBenchmarksRaw, "baseline");
|
|
113
|
+
const currentBenchmarks = normalizeBenchmarks(currentBenchmarksRaw, "current");
|
|
114
|
+
validateBenchmarkPairs(baselineBenchmarks, currentBenchmarks);
|
|
115
|
+
state.current = {
|
|
116
|
+
baseline: baselineBenchmarks,
|
|
117
|
+
current: currentBenchmarks
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
ok: true
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
const handleList = () => {
|
|
124
|
+
if (!state.current) {
|
|
125
|
+
throw new Error("Harness not initialized.");
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
benchmarks: describeBenchmarks(state.current.baseline)
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
const handleRun = async message => {
|
|
132
|
+
const currentState = state.current;
|
|
133
|
+
if (!currentState) {
|
|
134
|
+
throw new Error("Harness not initialized.");
|
|
135
|
+
}
|
|
136
|
+
const version = message.version;
|
|
137
|
+
const index = message.index;
|
|
138
|
+
if (version !== "baseline" && version !== "current") {
|
|
139
|
+
throw new Error("run requires version = baseline|current.");
|
|
140
|
+
}
|
|
141
|
+
if (typeof index !== "number" || Number.isNaN(index)) {
|
|
142
|
+
throw new Error("run requires a numeric benchmark index.");
|
|
143
|
+
}
|
|
144
|
+
const list = currentState[version];
|
|
145
|
+
const benchmark = list[index];
|
|
146
|
+
if (!benchmark) {
|
|
147
|
+
throw new Error(`Unknown benchmark index ${index}.`);
|
|
148
|
+
}
|
|
149
|
+
const durationMs = await runBenchmarkSample(benchmark, message.iterations);
|
|
150
|
+
return {
|
|
151
|
+
durationMs
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
const handleMessage = async message => {
|
|
155
|
+
switch (message.type) {
|
|
156
|
+
case "init":
|
|
157
|
+
return await handleInit(message);
|
|
158
|
+
case "list":
|
|
159
|
+
return handleList();
|
|
160
|
+
case "run":
|
|
161
|
+
return await handleRun(message);
|
|
162
|
+
case "close":
|
|
163
|
+
return {
|
|
164
|
+
ok: true
|
|
165
|
+
};
|
|
166
|
+
default:
|
|
167
|
+
throw new Error(`Unknown message type: ${String(message.type)}`);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const sendResponse = payload => {
|
|
171
|
+
const serialized = JSON.stringify(payload);
|
|
172
|
+
if (serialized == null) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
process.stdout.write(`${serialized}\n`);
|
|
176
|
+
};
|
|
177
|
+
const rl = createInterface({
|
|
178
|
+
crlfDelay: Infinity,
|
|
179
|
+
input: process.stdin
|
|
180
|
+
});
|
|
181
|
+
rl.on("line", line => {
|
|
182
|
+
if (!line.trim()) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
let message;
|
|
186
|
+
try {
|
|
187
|
+
message = JSON.parse(line);
|
|
188
|
+
} catch {
|
|
189
|
+
sendResponse({
|
|
190
|
+
error: {
|
|
191
|
+
message: "Invalid JSON payload."
|
|
192
|
+
},
|
|
193
|
+
id: null,
|
|
194
|
+
ok: false
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
Promise.resolve(handleMessage(message)).then(result => {
|
|
199
|
+
sendResponse({
|
|
200
|
+
id: message.id,
|
|
201
|
+
ok: true,
|
|
202
|
+
...result
|
|
203
|
+
});
|
|
204
|
+
if (message.type === "close") {
|
|
205
|
+
rl.close();
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}
|
|
208
|
+
}).catch(error => {
|
|
209
|
+
sendResponse({
|
|
210
|
+
error: {
|
|
211
|
+
message: error instanceof Error ? error.message : String(error)
|
|
212
|
+
},
|
|
213
|
+
id: message.id,
|
|
214
|
+
ok: false
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
const coerceBenchmarkDescriptor = (value, index) => {
|
|
4
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
+
throw new Error(`Benchmark descriptor ${index} must be an object.`);
|
|
6
|
+
}
|
|
7
|
+
const descriptor = value;
|
|
8
|
+
const name = descriptor.name;
|
|
9
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
10
|
+
throw new Error(`Benchmark descriptor ${index}.name is required.`);
|
|
11
|
+
}
|
|
12
|
+
let unit;
|
|
13
|
+
if (descriptor.unit != null) {
|
|
14
|
+
if (typeof descriptor.unit !== "string") {
|
|
15
|
+
throw new Error(`Benchmark descriptor ${index}.unit must be a string.`);
|
|
16
|
+
}
|
|
17
|
+
unit = descriptor.unit;
|
|
18
|
+
}
|
|
19
|
+
let iterations;
|
|
20
|
+
if (descriptor.iterations != null) {
|
|
21
|
+
if (typeof descriptor.iterations !== "number") {
|
|
22
|
+
throw new Error(`Benchmark descriptor ${index}.iterations must be a number.`);
|
|
23
|
+
}
|
|
24
|
+
iterations = descriptor.iterations;
|
|
25
|
+
}
|
|
26
|
+
let metadata;
|
|
27
|
+
if (descriptor.metadata != null) {
|
|
28
|
+
if (typeof descriptor.metadata !== "object" || Array.isArray(descriptor.metadata)) {
|
|
29
|
+
throw new Error(`Benchmark descriptor ${index}.metadata must be an object.`);
|
|
30
|
+
}
|
|
31
|
+
metadata = descriptor.metadata;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
iterations,
|
|
35
|
+
metadata,
|
|
36
|
+
name,
|
|
37
|
+
unit
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
export const runNodeScript = async (engine, scriptPath, args = []) => {
|
|
41
|
+
const command = engine.command || "node";
|
|
42
|
+
const commandArgs = [...(engine.args ?? []), scriptPath, ...args];
|
|
43
|
+
return await new Promise((resolvePromise, reject) => {
|
|
44
|
+
const child = spawn(command, commandArgs, {
|
|
45
|
+
env: {
|
|
46
|
+
...process.env,
|
|
47
|
+
...(engine.env ?? {})
|
|
48
|
+
},
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
50
|
+
});
|
|
51
|
+
let stdout = "";
|
|
52
|
+
let stderr = "";
|
|
53
|
+
child.stdout.on("data", chunk => {
|
|
54
|
+
stdout += String(chunk);
|
|
55
|
+
});
|
|
56
|
+
child.stderr.on("data", chunk => {
|
|
57
|
+
stderr += String(chunk);
|
|
58
|
+
});
|
|
59
|
+
child.on("error", error => {
|
|
60
|
+
reject(error);
|
|
61
|
+
});
|
|
62
|
+
child.on("close", code => {
|
|
63
|
+
resolvePromise({
|
|
64
|
+
exitCode: code ?? 0,
|
|
65
|
+
stderr,
|
|
66
|
+
stdout
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
export const createNodeHarness = async (engine, harnessPath, baselinePath, currentPath) => {
|
|
72
|
+
const command = engine.command || "node";
|
|
73
|
+
const args = [...(engine.args ?? []), harnessPath];
|
|
74
|
+
const child = spawn(command, args, {
|
|
75
|
+
env: {
|
|
76
|
+
...process.env,
|
|
77
|
+
...(engine.env ?? {})
|
|
78
|
+
},
|
|
79
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
80
|
+
});
|
|
81
|
+
let closing = false;
|
|
82
|
+
let nextId = 1;
|
|
83
|
+
const pending = new Map();
|
|
84
|
+
let stderrBuffer = "";
|
|
85
|
+
const rejectAll = error => {
|
|
86
|
+
for (const request of pending.values()) {
|
|
87
|
+
request.reject(error);
|
|
88
|
+
}
|
|
89
|
+
pending.clear();
|
|
90
|
+
};
|
|
91
|
+
child.stderr.on("data", chunk => {
|
|
92
|
+
stderrBuffer += String(chunk);
|
|
93
|
+
});
|
|
94
|
+
child.on("exit", code => {
|
|
95
|
+
if (!closing) {
|
|
96
|
+
const message = stderrBuffer.trim();
|
|
97
|
+
const error = new Error(`Harness exited unexpectedly (code ${code ?? "unknown"}).` + (message ? ` ${message}` : ""));
|
|
98
|
+
rejectAll(error);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
const rl = createInterface({
|
|
102
|
+
crlfDelay: Infinity,
|
|
103
|
+
input: child.stdout
|
|
104
|
+
});
|
|
105
|
+
rl.on("line", line => {
|
|
106
|
+
if (!line.trim()) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
let payload;
|
|
110
|
+
try {
|
|
111
|
+
payload = JSON.parse(line);
|
|
112
|
+
} catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (payload == null || typeof payload.id !== "number") {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const request = pending.get(payload.id);
|
|
119
|
+
if (!request) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
pending.delete(payload.id);
|
|
123
|
+
if (payload.ok === false) {
|
|
124
|
+
const message = payload.error && typeof payload.error.message === "string" ? payload.error.message : "Harness error.";
|
|
125
|
+
request.reject(new Error(message));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
request.resolve(payload);
|
|
129
|
+
});
|
|
130
|
+
const send = async (type, body) => new Promise((resolve, reject) => {
|
|
131
|
+
const id = nextId;
|
|
132
|
+
nextId += 1;
|
|
133
|
+
pending.set(id, {
|
|
134
|
+
reject,
|
|
135
|
+
resolve
|
|
136
|
+
});
|
|
137
|
+
const payloadObject = {
|
|
138
|
+
id,
|
|
139
|
+
type
|
|
140
|
+
};
|
|
141
|
+
if (body != null) {
|
|
142
|
+
if (typeof body !== "object" || Array.isArray(body)) {
|
|
143
|
+
pending.delete(id);
|
|
144
|
+
reject(new Error("Harness payload must be an object."));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for (const [key, value] of Object.entries(body)) {
|
|
148
|
+
payloadObject[key] = value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const payload = JSON.stringify(payloadObject);
|
|
152
|
+
try {
|
|
153
|
+
if (!child.stdin.write(`${payload}\n`)) {
|
|
154
|
+
child.stdin.once("drain", () => {});
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
pending.delete(id);
|
|
158
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
await send("init", {
|
|
163
|
+
baselinePath,
|
|
164
|
+
currentPath
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
closing = true;
|
|
168
|
+
child.kill();
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
const listBenchmarks = async () => {
|
|
172
|
+
const response = await send("list");
|
|
173
|
+
if (response == null || typeof response !== "object") {
|
|
174
|
+
throw new Error("Invalid harness list response.");
|
|
175
|
+
}
|
|
176
|
+
const responseObject = response;
|
|
177
|
+
const benchmarks = responseObject.benchmarks;
|
|
178
|
+
if (!Array.isArray(benchmarks)) {
|
|
179
|
+
throw new Error("Harness list response missing benchmarks.");
|
|
180
|
+
}
|
|
181
|
+
return benchmarks.map(coerceBenchmarkDescriptor);
|
|
182
|
+
};
|
|
183
|
+
const runSample = async options => {
|
|
184
|
+
const payload = {
|
|
185
|
+
index: options.index,
|
|
186
|
+
iterations: options.iterations,
|
|
187
|
+
version: options.version
|
|
188
|
+
};
|
|
189
|
+
const response = await send("run", payload);
|
|
190
|
+
if (response == null || typeof response !== "object") {
|
|
191
|
+
throw new Error("Invalid harness run response.");
|
|
192
|
+
}
|
|
193
|
+
const durationMs = response.durationMs;
|
|
194
|
+
if (typeof durationMs !== "number") {
|
|
195
|
+
throw new Error("Harness run response missing durationMs.");
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
durationMs
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
const close = async () => {
|
|
202
|
+
closing = true;
|
|
203
|
+
try {
|
|
204
|
+
await send("close");
|
|
205
|
+
} finally {
|
|
206
|
+
child.stdin.end();
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
return {
|
|
210
|
+
close,
|
|
211
|
+
listBenchmarks,
|
|
212
|
+
runSample
|
|
213
|
+
};
|
|
214
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const isPositiveInterval = interval => interval.low > 0 && interval.high > 0;
|
|
2
|
+
export const getRegressions = results => {
|
|
3
|
+
const findings = [];
|
|
4
|
+
for (const result of results) {
|
|
5
|
+
for (const entry of result.benchmarks) {
|
|
6
|
+
if (isPositiveInterval(entry.difference.absolute.ci) || isPositiveInterval(entry.difference.relative.ci)) {
|
|
7
|
+
findings.push({
|
|
8
|
+
absolute: entry.difference.absolute.ci,
|
|
9
|
+
benchmark: entry.benchmark.name,
|
|
10
|
+
engine: result.engine.name,
|
|
11
|
+
relative: entry.difference.relative.ci
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return findings;
|
|
17
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const formatNumber = (value, decimals) => {
|
|
2
|
+
if (!Number.isFinite(value)) {
|
|
3
|
+
return String(value);
|
|
4
|
+
}
|
|
5
|
+
return value.toFixed(decimals);
|
|
6
|
+
};
|
|
7
|
+
const formatInterval = (interval, decimals, suffix = "") => `[${formatNumber(interval.low, decimals)}, ${formatNumber(interval.high, decimals)}]${suffix}`;
|
|
8
|
+
const formatRelativeInterval = (interval, decimals) => formatInterval({
|
|
9
|
+
high: interval.high * 100,
|
|
10
|
+
low: interval.low * 100
|
|
11
|
+
}, decimals, "%");
|
|
12
|
+
const formatRelativeValue = (value, decimals) => `${formatNumber(value * 100, decimals)}%`;
|
|
13
|
+
export const renderConsoleReport = results => {
|
|
14
|
+
const lines = [];
|
|
15
|
+
for (const result of results) {
|
|
16
|
+
lines.push(`Engine: ${result.engine.name}`);
|
|
17
|
+
for (const entry of result.benchmarks) {
|
|
18
|
+
const unit = entry.benchmark.unit != null ? ` ${entry.benchmark.unit}` : "";
|
|
19
|
+
const benchmarkLines = [` Benchmark: ${entry.benchmark.name}`, ` baseline mean=${formatNumber(entry.stats.baseline.mean, 4)}${unit} ci=${formatInterval(entry.stats.baseline.meanCI, 4)} sd=${formatNumber(entry.stats.baseline.standardDeviation, 4)}`, ` current mean=${formatNumber(entry.stats.current.mean, 4)}${unit} ci=${formatInterval(entry.stats.current.meanCI, 4)} sd=${formatNumber(entry.stats.current.standardDeviation, 4)}`, ` diff abs mean=${formatNumber(entry.difference.absolute.mean, 4)}${unit} ci=${formatInterval(entry.difference.absolute.ci, 4)}`, ` diff rel mean=${formatRelativeValue(entry.difference.relative.mean, 2)} ci=${formatRelativeInterval(entry.difference.relative.ci, 2)}`];
|
|
20
|
+
lines.push(...benchmarkLines);
|
|
21
|
+
}
|
|
22
|
+
lines.push("");
|
|
23
|
+
}
|
|
24
|
+
return lines.join("\n").trimEnd();
|
|
25
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { renderConsoleReport } from "./console.js";
|
|
2
|
+
import { renderJsonReport } from "./json.js";
|
|
3
|
+
export const renderReports = (results, formats) => {
|
|
4
|
+
const outputs = [];
|
|
5
|
+
for (const format of formats) {
|
|
6
|
+
if (format === "console") {
|
|
7
|
+
outputs.push(renderConsoleReport(results));
|
|
8
|
+
} else if (format === "json") {
|
|
9
|
+
outputs.push(renderJsonReport(results));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return outputs;
|
|
13
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const buildJsonReport = results => ({
|
|
2
|
+
engines: results.map(result => ({
|
|
3
|
+
benchmarks: result.benchmarks.map(entry => ({
|
|
4
|
+
benchmark: entry.benchmark,
|
|
5
|
+
difference: entry.difference,
|
|
6
|
+
samples: entry.samples,
|
|
7
|
+
stats: entry.stats
|
|
8
|
+
})),
|
|
9
|
+
engine: result.engine
|
|
10
|
+
}))
|
|
11
|
+
});
|
|
12
|
+
export const renderJsonReport = results => JSON.stringify(buildJsonReport(results), null, 2);
|
package/lib/runner.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { transformFileAsync } from "@babel/core";
|
|
6
|
+
import { createNodeHarness } from "./engines/node.js";
|
|
7
|
+
import { computeDifference, summaryStats } from "./stats.js";
|
|
8
|
+
const versions = ["baseline", "current"];
|
|
9
|
+
const autoSampleBatchSize = 10;
|
|
10
|
+
const harnessTempPrefix = "perfshield-harness-";
|
|
11
|
+
const getHarnessPath = () => {
|
|
12
|
+
const override = process.env.WEB_BENCHMARKER_HARNESS_PATH;
|
|
13
|
+
if (override != null) {
|
|
14
|
+
return override;
|
|
15
|
+
}
|
|
16
|
+
return fileURLToPath(new URL("./engines/node-harness.js", import.meta.url).toString());
|
|
17
|
+
};
|
|
18
|
+
const hasBabelConfig = async () => {
|
|
19
|
+
const configPath = resolve(process.cwd(), "babel.config.cjs");
|
|
20
|
+
try {
|
|
21
|
+
await access(configPath);
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const buildHarnessIfNeeded = async sourcePath => {
|
|
28
|
+
const contents = await readFile(sourcePath, "utf8");
|
|
29
|
+
if (!contents.includes("import type") && !contents.includes("@flow")) {
|
|
30
|
+
return {
|
|
31
|
+
cleanup: null,
|
|
32
|
+
path: sourcePath
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const usesConfig = await hasBabelConfig();
|
|
36
|
+
const result = await transformFileAsync(sourcePath, {
|
|
37
|
+
configFile: usesConfig ? resolve(process.cwd(), "babel.config.cjs") : false,
|
|
38
|
+
presets: usesConfig ? [] : ["@babel/preset-flow"]
|
|
39
|
+
});
|
|
40
|
+
if (!result || !result.code) {
|
|
41
|
+
throw new Error("Failed to compile node harness.");
|
|
42
|
+
}
|
|
43
|
+
const dir = await mkdtemp(join(tmpdir(), harnessTempPrefix));
|
|
44
|
+
const harnessPath = join(dir, "node-harness.js");
|
|
45
|
+
await writeFile(harnessPath, result.code, "utf8");
|
|
46
|
+
return {
|
|
47
|
+
cleanup: async () => {
|
|
48
|
+
await rm(dir, {
|
|
49
|
+
force: true,
|
|
50
|
+
recursive: true
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
path: harnessPath
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
const warmupBenchmarks = async (harness, benchmarks) => {
|
|
57
|
+
for (let index = 0; index < benchmarks.length; index += 1) {
|
|
58
|
+
const descriptor = benchmarks[index];
|
|
59
|
+
for (const version of versions) {
|
|
60
|
+
await harness.runSample({
|
|
61
|
+
index,
|
|
62
|
+
iterations: descriptor.iterations,
|
|
63
|
+
version
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const collectSamples = async (harness, benchmarks, minSamples) => {
|
|
69
|
+
const samples = benchmarks.map(() => ({
|
|
70
|
+
baseline: [],
|
|
71
|
+
current: []
|
|
72
|
+
}));
|
|
73
|
+
for (let iteration = 0; iteration < minSamples; iteration += 1) {
|
|
74
|
+
for (let index = 0; index < benchmarks.length; index += 1) {
|
|
75
|
+
const descriptor = benchmarks[index];
|
|
76
|
+
for (const version of versions) {
|
|
77
|
+
const result = await harness.runSample({
|
|
78
|
+
index,
|
|
79
|
+
iterations: descriptor.iterations,
|
|
80
|
+
version
|
|
81
|
+
});
|
|
82
|
+
if (version === "baseline") {
|
|
83
|
+
samples[index].baseline.push(result.durationMs);
|
|
84
|
+
} else {
|
|
85
|
+
samples[index].current.push(result.durationMs);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return samples;
|
|
91
|
+
};
|
|
92
|
+
const intervalContains = (interval, value) => interval.low <= value && value <= interval.high;
|
|
93
|
+
const autoSampleResolved = (samples, conditions) => samples.every(bucket => {
|
|
94
|
+
const baselineStats = summaryStats(bucket.baseline);
|
|
95
|
+
const currentStats = summaryStats(bucket.current);
|
|
96
|
+
const diff = computeDifference(baselineStats, currentStats);
|
|
97
|
+
for (const condition of conditions.absolute) {
|
|
98
|
+
if (intervalContains(diff.absolute.ci, condition)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const condition of conditions.relative) {
|
|
103
|
+
if (intervalContains(diff.relative.ci, condition)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
const autoSample = async (harness, benchmarks, samples, conditions, timeoutMs) => {
|
|
110
|
+
const startTime = Date.now();
|
|
111
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
112
|
+
if (autoSampleResolved(samples, conditions)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (let batch = 0; batch < autoSampleBatchSize; batch += 1) {
|
|
116
|
+
for (let index = 0; index < benchmarks.length; index += 1) {
|
|
117
|
+
const descriptor = benchmarks[index];
|
|
118
|
+
for (const version of versions) {
|
|
119
|
+
const result = await harness.runSample({
|
|
120
|
+
index,
|
|
121
|
+
iterations: descriptor.iterations,
|
|
122
|
+
version
|
|
123
|
+
});
|
|
124
|
+
if (version === "baseline") {
|
|
125
|
+
samples[index].baseline.push(result.durationMs);
|
|
126
|
+
} else {
|
|
127
|
+
samples[index].current.push(result.durationMs);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
export const runEngineComparison = async options => {
|
|
135
|
+
const {
|
|
136
|
+
baselinePath,
|
|
137
|
+
config,
|
|
138
|
+
currentPath,
|
|
139
|
+
engine
|
|
140
|
+
} = options;
|
|
141
|
+
const harnessArtifact = await buildHarnessIfNeeded(getHarnessPath());
|
|
142
|
+
const harness = await createNodeHarness(engine, harnessArtifact.path, resolve(baselinePath), resolve(currentPath));
|
|
143
|
+
try {
|
|
144
|
+
const benchmarks = await harness.listBenchmarks();
|
|
145
|
+
await warmupBenchmarks(harness, benchmarks);
|
|
146
|
+
const samples = await collectSamples(harness, benchmarks, config.sampling.minSamples);
|
|
147
|
+
await autoSample(harness, benchmarks, samples, config.sampling.conditions, config.sampling.timeoutMs);
|
|
148
|
+
const benchmarkResults = benchmarks.map((benchmark, index) => {
|
|
149
|
+
const baselineSamples = samples[index].baseline;
|
|
150
|
+
const currentSamples = samples[index].current;
|
|
151
|
+
const baselineStats = summaryStats(baselineSamples);
|
|
152
|
+
const currentStats = summaryStats(currentSamples);
|
|
153
|
+
const difference = computeDifference(baselineStats, currentStats);
|
|
154
|
+
return {
|
|
155
|
+
benchmark,
|
|
156
|
+
difference,
|
|
157
|
+
samples: {
|
|
158
|
+
baseline: baselineSamples,
|
|
159
|
+
current: currentSamples
|
|
160
|
+
},
|
|
161
|
+
stats: {
|
|
162
|
+
baseline: baselineStats,
|
|
163
|
+
current: currentStats
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
return {
|
|
168
|
+
benchmarks: benchmarkResults,
|
|
169
|
+
engine
|
|
170
|
+
};
|
|
171
|
+
} finally {
|
|
172
|
+
await harness.close();
|
|
173
|
+
if (harnessArtifact.cleanup) {
|
|
174
|
+
await harnessArtifact.cleanup();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|