Skip to content

Commit 226c289

Browse files
authored
internal: refactor benchmark runner structure (#4677)
...into multiple files.
1 parent a653ecd commit 226c289

10 files changed

Lines changed: 468 additions & 433 deletions

File tree

resources/benchmark.ts

Lines changed: 1 addition & 432 deletions
Large diffs are not rendered by default.

resources/benchmark/args.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
import { localRepoPath } from '../utils.js';
5+
6+
import { LOCAL } from './config.js';
7+
import { bold } from './output.js';
8+
9+
export interface BenchmarkArguments {
10+
benchmarks: Array<string>;
11+
revisions: Array<string>;
12+
}
13+
14+
export function getArguments(argv: ReadonlyArray<string>): BenchmarkArguments {
15+
const revsIndex = argv.indexOf('--revs');
16+
const revisions = revsIndex === -1 ? [] : argv.slice(revsIndex + 1);
17+
const benchmarks = revsIndex === -1 ? [...argv] : argv.slice(0, revsIndex);
18+
19+
switch (revisions.length) {
20+
case 0:
21+
revisions.unshift('HEAD');
22+
// fall through
23+
case 1: {
24+
revisions.unshift(LOCAL);
25+
26+
const assumeArgv = ['benchmark', ...benchmarks, '--revs', ...revisions];
27+
console.warn('Assuming you meant: ' + bold(assumeArgv.join(' ')));
28+
break;
29+
}
30+
}
31+
32+
if (benchmarks.length === 0) {
33+
benchmarks.push(...findAllBenchmarks());
34+
}
35+
36+
return { benchmarks, revisions };
37+
}
38+
39+
function findAllBenchmarks(): Array<string> {
40+
return fs
41+
.readdirSync(localRepoPath('benchmark'), { withFileTypes: true })
42+
.filter((dirent) => dirent.isFile())
43+
.map((dirent) => dirent.name)
44+
.filter((name) => name.endsWith('-benchmark.js'))
45+
.map((name) => path.join('benchmark', name));
46+
}

resources/benchmark/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const NS_PER_SEC = 1e9;
2+
export const LOCAL = 'local';
3+
4+
// The maximum time in seconds a benchmark is allowed to run before finishing.
5+
export const maxTime = 5;
6+
// The minimum sample size required to perform statistical analysis.
7+
export const minSamples = 5;

resources/benchmark/output.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { BenchmarkResult } from './types.js';
2+
3+
export function printBenchmarkResults(
4+
results: ReadonlyArray<BenchmarkResult>,
5+
): void {
6+
const nameMaxLen = maxBy(results, ({ name }) => name.length);
7+
const opsTop = maxBy(results, ({ ops }) => ops);
8+
const opsMaxLen = maxBy(results, ({ ops }) => beautifyNumber(ops).length);
9+
const memPerOpMaxLen = maxBy(
10+
results,
11+
({ memPerOp }) => beautifyBytes(memPerOp).length,
12+
);
13+
14+
for (const result of results) {
15+
printBench(result);
16+
}
17+
18+
function printBench(bench: BenchmarkResult): void {
19+
const { name, memPerOp, ops, deviation, numSamples } = bench;
20+
console.log(
21+
' ' +
22+
nameStr() +
23+
grey(' x ') +
24+
opsStr() +
25+
' ops/sec ' +
26+
grey('\xb1') +
27+
deviationStr() +
28+
cyan('%') +
29+
grey(' x ') +
30+
memPerOpStr() +
31+
'/op' +
32+
grey(' (' + numSamples + ' runs sampled)'),
33+
);
34+
35+
function nameStr(): string {
36+
const nameFmt = name.padEnd(nameMaxLen);
37+
return ops === opsTop ? green(nameFmt) : nameFmt;
38+
}
39+
40+
function opsStr(): string {
41+
const percent = ops / opsTop;
42+
const colorFn = percent > 0.95 ? green : percent > 0.8 ? yellow : red;
43+
return colorFn(beautifyNumber(ops).padStart(opsMaxLen));
44+
}
45+
46+
function deviationStr(): string {
47+
const colorFn = deviation > 5 ? red : deviation > 2 ? yellow : green;
48+
return colorFn(deviation.toFixed(2));
49+
}
50+
51+
function memPerOpStr(): string {
52+
return beautifyBytes(memPerOp).padStart(memPerOpMaxLen);
53+
}
54+
}
55+
}
56+
57+
function beautifyBytes(bytes: number): string {
58+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
59+
const i = Math.floor(Math.log2(bytes) / 10);
60+
return beautifyNumber(bytes / 2 ** (i * 10)) + ' ' + sizes[i];
61+
}
62+
63+
function beautifyNumber(num: number): string {
64+
return Number(num.toFixed(num > 100 ? 0 : 2)).toLocaleString();
65+
}
66+
67+
function maxBy<T>(array: ReadonlyArray<T>, fn: (obj: T) => number): number {
68+
return Math.max(...array.map(fn));
69+
}
70+
71+
export function bold(str: number | string): string {
72+
return '\u001b[1m' + str + '\u001b[0m';
73+
}
74+
75+
export function red(str: number | string): string {
76+
return '\u001b[31m' + str + '\u001b[0m';
77+
}
78+
79+
export function green(str: number | string): string {
80+
return '\u001b[32m' + str + '\u001b[0m';
81+
}
82+
83+
export function yellow(str: number | string): string {
84+
return '\u001b[33m' + str + '\u001b[0m';
85+
}
86+
87+
export function cyan(str: number | string): string {
88+
return '\u001b[36m' + str + '\u001b[0m';
89+
}
90+
91+
export function grey(str: number | string): string {
92+
return '\u001b[90m' + str + '\u001b[0m';
93+
}

resources/benchmark/projects.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
import { git, localRepoPath, makeTmpDir, npm } from '../utils.js';
5+
6+
import { LOCAL } from './config.js';
7+
import type { BenchmarkProject } from './types.js';
8+
9+
// Build a benchmark-friendly environment for each revision.
10+
export function prepareBenchmarkProjects(
11+
revisionList: ReadonlyArray<string>,
12+
): Array<BenchmarkProject> {
13+
const { tmpDirPath } = makeTmpDir('graphql-js-benchmark');
14+
15+
return revisionList.map((revision) => {
16+
console.log(`\u{1F373} Preparing ${revision}...`);
17+
const projectPath = tmpDirPath('setup', revision);
18+
fs.rmSync(projectPath, { recursive: true, force: true });
19+
fs.mkdirSync(projectPath, { recursive: true });
20+
21+
fs.cpSync(localRepoPath('benchmark'), path.join(projectPath, 'benchmark'), {
22+
recursive: true,
23+
});
24+
25+
fs.writeFileSync(
26+
path.join(projectPath, 'package.json'),
27+
JSON.stringify(
28+
{
29+
private: true,
30+
type: 'module',
31+
dependencies: {
32+
graphql: prepareNPMPackage(revision),
33+
},
34+
},
35+
null,
36+
2,
37+
),
38+
);
39+
npm({ cwd: projectPath, quiet: true }).install('--ignore-scripts');
40+
41+
return { revision, projectPath };
42+
});
43+
44+
function prepareNPMPackage(revision: string): string {
45+
if (revision === LOCAL) {
46+
const repoDir = localRepoPath();
47+
const archivePath = tmpDirPath('graphql-local.tgz');
48+
fs.renameSync(buildNPMArchive(repoDir), archivePath);
49+
return archivePath;
50+
}
51+
52+
// Returns the complete git hash for a given git revision reference.
53+
const hash = git().revParse(revision);
54+
55+
const archivePath = tmpDirPath(`graphql-${hash}.tgz`);
56+
if (fs.existsSync(archivePath)) {
57+
return archivePath;
58+
}
59+
60+
const repoDir = tmpDirPath(hash);
61+
fs.rmSync(repoDir, { recursive: true, force: true });
62+
fs.mkdirSync(repoDir);
63+
git({ quiet: true }).clone(localRepoPath(), repoDir);
64+
git({ cwd: repoDir, quiet: true }).checkout('--detach', hash);
65+
npm({ cwd: repoDir, quiet: true }).ci('--ignore-scripts');
66+
fs.renameSync(buildNPMArchive(repoDir), archivePath);
67+
fs.rmSync(repoDir, { recursive: true });
68+
return archivePath;
69+
}
70+
71+
function buildNPMArchive(repoDir: string): string {
72+
npm({ cwd: repoDir, quiet: true }).run('build:npm');
73+
74+
const distDir = path.join(repoDir, 'npmDist');
75+
const archiveName = npm({ cwd: repoDir, quiet: true }).pack(distDir);
76+
return path.join(repoDir, archiveName);
77+
}
78+
}

resources/benchmark/run.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import path from 'node:path';
2+
3+
import { getArguments } from './args.js';
4+
import { cyan, printBenchmarkResults, red } from './output.js';
5+
import { prepareBenchmarkProjects } from './projects.js';
6+
import { collectSamples, sampleModule } from './sampling.js';
7+
import { computeStats } from './statistics.js';
8+
import type { BenchmarkProject, BenchmarkResult } from './types.js';
9+
10+
export function runBenchmarks(): void {
11+
// Get the revisions and make things happen!
12+
const { benchmarks, revisions } = getArguments(process.argv.slice(2));
13+
const benchmarkProjects = prepareBenchmarkProjects(revisions);
14+
15+
for (const benchmark of benchmarks) {
16+
runBenchmark(benchmark, benchmarkProjects);
17+
}
18+
}
19+
20+
// Prepare all revisions and run benchmarks matching a pattern against them.
21+
function runBenchmark(
22+
benchmark: string,
23+
benchmarkProjects: ReadonlyArray<BenchmarkProject>,
24+
): void {
25+
const results: Array<BenchmarkResult> = [];
26+
for (let i = 0; i < benchmarkProjects.length; ++i) {
27+
const { revision, projectPath } = benchmarkProjects[i];
28+
const modulePath = path.join(projectPath, benchmark);
29+
30+
if (i === 0) {
31+
const { name } = sampleModule(modulePath);
32+
console.log('\u23F1 ' + name);
33+
}
34+
35+
try {
36+
const samples = collectSamples(modulePath);
37+
38+
results.push(computeStats(revision, samples));
39+
process.stdout.write(' ' + cyan(i + 1) + ' tests completed.\u000D');
40+
} catch (error) {
41+
const errorMessage =
42+
error instanceof Error ? error.message : String(error);
43+
console.log(' ' + revision + ': ' + red(errorMessage));
44+
}
45+
}
46+
console.log('\n');
47+
48+
printBenchmarkResults(results);
49+
console.log('');
50+
}

resources/benchmark/sampling.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import assert from 'node:assert';
2+
import cp from 'node:child_process';
3+
import url from 'node:url';
4+
5+
import { maxTime, minSamples } from './config.js';
6+
import { yellow } from './output.js';
7+
import type { BenchmarkSample } from './types.js';
8+
9+
export function collectSamples(modulePath: string): Array<BenchmarkSample> {
10+
let numOfConsequentlyRejectedSamples = 0;
11+
const samples: Array<BenchmarkSample> = [];
12+
13+
// If time permits, increase sample size to reduce the margin of error.
14+
const start = Date.now();
15+
while (samples.length < minSamples || (Date.now() - start) / 1e3 < maxTime) {
16+
const sample = sampleModule(modulePath);
17+
18+
if (sample.involuntaryContextSwitches > 0) {
19+
numOfConsequentlyRejectedSamples++;
20+
if (numOfConsequentlyRejectedSamples === 5) {
21+
console.error(
22+
yellow(
23+
' Five or more consequent runs beings rejected because of context switching.\n' +
24+
' Measurement can take a significantly longer time and its correctness can also be impacted.',
25+
),
26+
);
27+
}
28+
continue;
29+
}
30+
numOfConsequentlyRejectedSamples = 0;
31+
32+
assert(sample.clocked > 0);
33+
assert(sample.memUsed > 0);
34+
samples.push(sample);
35+
}
36+
return samples;
37+
}
38+
39+
export function sampleModule(modulePath: string): BenchmarkSample {
40+
// To support Windows we need to use URL instead of path
41+
const moduleURL = url.pathToFileURL(modulePath);
42+
43+
const sampleCode = `
44+
import fs from 'node:fs';
45+
46+
import { benchmark } from '${moduleURL}';
47+
48+
// warm up, it looks like 7 is a magic number to reliably trigger JIT
49+
await benchmark.measure();
50+
await benchmark.measure();
51+
await benchmark.measure();
52+
await benchmark.measure();
53+
await benchmark.measure();
54+
await benchmark.measure();
55+
await benchmark.measure();
56+
57+
const memBaseline = process.memoryUsage().heapUsed;
58+
59+
const resourcesStart = process.resourceUsage();
60+
const startTime = process.hrtime.bigint();
61+
for (let i = 0; i < benchmark.count; ++i) {
62+
await benchmark.measure();
63+
}
64+
const timeDiff = Number(process.hrtime.bigint() - startTime);
65+
const resourcesEnd = process.resourceUsage();
66+
67+
const result = {
68+
name: benchmark.name,
69+
clocked: timeDiff / benchmark.count,
70+
memUsed: (process.memoryUsage().heapUsed - memBaseline) / benchmark.count,
71+
involuntaryContextSwitches:
72+
resourcesEnd.involuntaryContextSwitches - resourcesStart.involuntaryContextSwitches,
73+
};
74+
fs.writeFileSync(3, JSON.stringify(result));
75+
`;
76+
77+
const result = cp.spawnSync(
78+
process.execPath,
79+
[
80+
// V8 flags
81+
'--predictable',
82+
'--no-concurrent-sweeping',
83+
'--no-minor-gc-task',
84+
'--min-semi-space-size=1280', // 1.25GB
85+
'--max-semi-space-size=1280', // 1.25GB
86+
'--trace-gc', // no gc calls should happen during benchmark, so trace them
87+
88+
// Node.js flags
89+
'--input-type=module',
90+
'--eval',
91+
sampleCode,
92+
],
93+
{
94+
stdio: ['inherit', 'inherit', 'inherit', 'pipe'],
95+
env: { NODE_ENV: 'production' },
96+
},
97+
);
98+
99+
if (result.status !== 0) {
100+
throw new Error(`Benchmark failed with "${result.status}" status.`);
101+
}
102+
103+
const resultStr = result.output[3]?.toString();
104+
assert(resultStr != null);
105+
return JSON.parse(resultStr);
106+
}

0 commit comments

Comments
 (0)