Skip to content

Commit 596a16a

Browse files
committed
cli: add local-bench to exbf
Output example > @expressjs/perf-wg@1.0.0 local-load > expf local-load Running autocannon load... Result Autocannon: 121610.67 Running wrk2 load... Result Wrk2: [ { percentile: 50, ms: 0.829 }, { percentile: 75, ms: 1.1 }, { percentile: 90, ms: 1.41 }, { percentile: 99, ms: 1.75 }, { percentile: 99.9, ms: 1.88 }, { percentile: 99.99, ms: 2 }, { percentile: 99.999, ms: 2.2 }, { percentile: 100, ms: 2.2 } ]
1 parent 2b4e10b commit 596a16a

File tree

3 files changed

+187
-0
lines changed

3 files changed

+187
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"scripts": {
66
"setup": "npm i",
77
"local-server": "expf local-server",
8+
"local-load": "expf local-load",
89
"test": "echo \"Error: no test specified\" && exit 1",
910
"load": "expf load",
1011
"test:load": "expf load --test=@expressjs/perf-load-example",

packages/cli/bin/expf.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ switch (positionals[2]) {
7070
console.error(e);
7171
}
7272
break;
73+
case 'local-load':
74+
try {
75+
await (await import('../local-load.mjs')).default(values);
76+
} catch (e) {
77+
console.error(e);
78+
}
79+
break;
7380
default:
7481
console.log(`
7582
Express Performance Testing CLI

packages/cli/local-load.mjs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { spawnSync, spawn } from 'node:child_process';
2+
import { availableParallelism } from 'node:os';
3+
import { styleText } from 'node:util';
4+
5+
// TODO: add options to load different servers
6+
export function help () {
7+
return `$ expf local-bench
8+
9+
Start Autocannon load to local HTTP server
10+
`
11+
}
12+
13+
class Wrk2Benchmarker {
14+
constructor() {
15+
this.name = 'wrk2';
16+
this.executable = 'wrk2';
17+
const result = spawnSync(this.executable, ['-h']);
18+
this.present = !(result.error && result.error.code === 'ENOENT');
19+
}
20+
21+
create(options) {
22+
const duration = typeof options.duration === 'number' ?
23+
Math.max(options.duration, 1) :
24+
options.duration;
25+
const scheme = options.scheme || 'http';
26+
const args = [
27+
'-d', duration,
28+
'-c', options.connections,
29+
'-R', options.rate,
30+
'--latency',
31+
'-t', Math.min(options.connections, availableParallelism() || 8),
32+
`${scheme}://127.0.0.1:${options.port}${options.path}`,
33+
];
34+
for (const field in options.headers) {
35+
args.push('-H', `${field}: ${options.headers[field]}`);
36+
}
37+
const child = spawn(this.executable, args);
38+
return child;
39+
}
40+
41+
processResults(output) {
42+
// Capture only the Latency Distribution block (until a blank line or "Detailed Percentile spectrum:")
43+
const blockRe =
44+
/Latency Distribution \(HdrHistogram - Recorded Latency\)\s*\n([\s\S]*?)(?:\n\s*\n|^ {0,2}Detailed Percentile spectrum:|\Z)/m;
45+
46+
const m = output.match(blockRe);
47+
if (!m) return undefined;
48+
49+
const lines = m[1].trim().split('\n');
50+
51+
// e.g.: " 50.000% 780.00us" or " 90.000% 1.23ms"
52+
const lineRe = /^\s*(\d{1,3}\.\d{3})%\s+([0-9.]+)\s*(ms|us)\s*$/;
53+
54+
const points = [];
55+
for (const line of lines) {
56+
const lm = line.match(lineRe);
57+
if (!lm) continue;
58+
const pct = parseFloat(lm[1]); // e.g. 99.900
59+
const val = parseFloat(lm[2]); // numeric value
60+
const unit = lm[3]; // "ms" | "us"
61+
const valueMs = unit === 'us' ? val / 1000 : val;
62+
if (Number.isFinite(pct) && Number.isFinite(valueMs)) {
63+
points.push({ percentile: pct, ms: valueMs });
64+
}
65+
}
66+
67+
return points.length ? points : undefined;
68+
}
69+
}
70+
71+
class AutocannonBenchmarker {
72+
constructor() {
73+
const shell = (process.platform === 'win32');
74+
this.name = 'autocannon';
75+
this.opts = { shell };
76+
this.executable = shell ? 'autocannon.cmd' : 'autocannon';
77+
const result = spawnSync(this.executable, ['-h'], this.opts);
78+
if (shell) {
79+
this.present = (result.status === 0);
80+
} else {
81+
this.present = !(result.error && result.error.code === 'ENOENT');
82+
}
83+
}
84+
85+
create(options) {
86+
const args = [
87+
'-d', options.duration,
88+
'-c', options.connections,
89+
'-j',
90+
'-n',
91+
];
92+
for (const field in options.headers) {
93+
if (this.opts.shell) {
94+
args.push('-H', `'${field}=${options.headers[field]}'`);
95+
} else {
96+
args.push('-H', `${field}=${options.headers[field]}`);
97+
}
98+
}
99+
const scheme = options.scheme || 'http';
100+
args.push(`${scheme}://127.0.0.1:${options.port}${options.path}`);
101+
const child = spawn(this.executable, args, this.opts);
102+
return child;
103+
}
104+
105+
processResults(output) {
106+
let result;
107+
try {
108+
result = JSON.parse(output);
109+
} catch {
110+
return undefined;
111+
}
112+
if (!result || !result.requests || !result.requests.average) {
113+
return undefined;
114+
}
115+
return result.requests.average;
116+
}
117+
}
118+
119+
function runBenchmarker(instance, options) {
120+
console.log(styleText(['blue'], `Running ${instance.name} load...`));
121+
return new Promise((resolve, reject) => {
122+
const proc = instance.create(options);
123+
proc.stderr.pipe(process.stderr);
124+
125+
let stdout = '';
126+
proc.stdout.setEncoding('utf8');
127+
proc.stdout.on('data', (chunk) => stdout += chunk);
128+
129+
proc.once('close', (code) => {
130+
if (code) {
131+
let error_message = `${instance.name} failed with ${code}.`;
132+
if (stdout !== '') {
133+
error_message += ` Output: ${stdout}`;
134+
}
135+
reject(new Error(error_message), code);
136+
return;
137+
}
138+
139+
const result = instance.processResults(stdout);
140+
resolve(result);
141+
});
142+
})
143+
144+
}
145+
146+
// TODO: accept args to decide which load tool use
147+
// For now, use both.
148+
export async function startLoad () {
149+
const autocannon = new AutocannonBenchmarker();
150+
if (!autocannon.present) {
151+
console.log(styleText(['bold', 'yellow'], 'Autocannon not found. Please install it with `npm i -g autocannon`'));
152+
} else {
153+
const result = await runBenchmarker(autocannon, {
154+
duration: 30,
155+
connections: 100,
156+
port: 3000,
157+
path: '/',
158+
});
159+
console.log('Result Autocannon:', result)
160+
}
161+
162+
const wrk2 = new Wrk2Benchmarker();
163+
if (!wrk2.present) {
164+
console.log(styleText(['bold', 'yellow'], 'Wrk2 not found. Please install it'));
165+
} else {
166+
const result = await runBenchmarker(wrk2, {
167+
duration: 30,
168+
connections: 100,
169+
rate: 2000,
170+
port: 3000,
171+
path: '/'
172+
});
173+
console.log('Result Wrk2:', result);
174+
}
175+
}
176+
177+
export default async function main () {
178+
await startLoad();
179+
}

0 commit comments

Comments
 (0)