Skip to content

Commit f487c76

Browse files
committed
test(NODE-6705): isolate and warmup benchmarks
1 parent 5d99661 commit f487c76

File tree

8 files changed

+478
-0
lines changed

8 files changed

+478
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
results.json
2+
results_*.json
3+
package-lock.json
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "driver_bench",
3+
"version": "1.0.0",
4+
"scripts": {
5+
"prestart": "tsc",
6+
"start": "node lib/main.js"
7+
},
8+
"devDependencies": {
9+
"@types/node": "^22.13.0"
10+
}
11+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import child_process from 'node:child_process';
2+
import fs from 'node:fs/promises';
3+
import module from 'node:module';
4+
import path from 'node:path';
5+
import process from 'node:process';
6+
7+
const __dirname = import.meta.dirname;
8+
const require = module.createRequire(__dirname);
9+
10+
/**
11+
* The path to the MongoDB Node.js driver.
12+
* This MUST be set to the directory the driver is installed in
13+
* NOT the file "lib/index.js" that is the driver's export.
14+
*/
15+
export const MONGODB_DRIVER_PATH = (() => {
16+
let driverPath = process.env.MONGODB_DRIVER_PATH;
17+
if (!driverPath?.length) {
18+
driverPath = path.resolve(__dirname, '../../../..');
19+
}
20+
return driverPath;
21+
})();
22+
23+
/** Grab the version from the package.json */
24+
export const { version: MONGODB_DRIVER_VERSION } = require(
25+
path.join(MONGODB_DRIVER_PATH, 'package.json')
26+
);
27+
28+
/**
29+
* Use git to optionally determine the git revision,
30+
* but the benchmarks could be run against an npm installed version so this should be allowed to fail
31+
*/
32+
export const MONGODB_DRIVER_REVISION = (() => {
33+
try {
34+
return child_process
35+
.execSync('git rev-parse --short HEAD', {
36+
cwd: MONGODB_DRIVER_PATH,
37+
encoding: 'utf8'
38+
})
39+
.trim();
40+
} catch {
41+
return 'unknown revision';
42+
}
43+
})();
44+
45+
/**
46+
* Find the BSON dependency inside the driver PATH given and grab the version from the package.json.
47+
*/
48+
export const MONGODB_BSON_PATH = path.join(MONGODB_DRIVER_PATH, 'node_modules', 'bson');
49+
export const { version: MONGODB_BSON_VERSION } = require(
50+
path.join(MONGODB_BSON_PATH, 'package.json')
51+
);
52+
53+
/**
54+
* If you need to test BSON changes, you should clone, checkout and build BSON.
55+
* run: `npm link` with no arguments to register the link.
56+
* Then in the driver you are testing run `npm link bson` to use your local build.
57+
*
58+
* This will symlink the BSON into the driver's node_modules directory. So here
59+
* we can find the revision of the BSON we are testing against if .git exists.
60+
*/
61+
export const MONGODB_BSON_REVISION = await (async () => {
62+
const bsonGitExists = await fs.access(path.join(MONGODB_BSON_PATH, '.git')).then(
63+
() => true,
64+
() => false
65+
);
66+
if (!bsonGitExists) {
67+
return 'installed from npm';
68+
}
69+
try {
70+
return child_process
71+
.execSync('git rev-parse --short HEAD', {
72+
cwd: path.join(MONGODB_BSON_PATH),
73+
encoding: 'utf8'
74+
})
75+
.trim();
76+
} catch {
77+
return 'unknown revision';
78+
}
79+
})();
80+
81+
export const MONGODB_CLIENT_OPTIONS = (() => {
82+
const optionsString = process.env.MONGODB_CLIENT_OPTIONS;
83+
let options = undefined;
84+
if (optionsString?.length) {
85+
options = JSON.parse(optionsString);
86+
}
87+
return { ...options };
88+
})();
89+
90+
export const MONGODB_URI = (() => {
91+
if (process.env.MONGODB_URI?.length) return process.env.MONGODB_URI;
92+
return 'mongodb://127.0.0.1:27017';
93+
})();
94+
95+
export function snakeToCamel(name: string) {
96+
return name
97+
.split('_')
98+
.map((s, i) => (i !== 0 ? s[0].toUpperCase() + s.slice(1) : s))
99+
.join('');
100+
}
101+
102+
import type mongodb from '../../../../mongodb.js';
103+
export type { mongodb };
104+
105+
const { MongoClient /* GridFSBucket */ } = require(path.join(MONGODB_DRIVER_PATH));
106+
107+
const DB_NAME = 'perftest';
108+
const COLLECTION_NAME = 'corpus';
109+
110+
const SPEC_DIRECTORY = path.resolve(__dirname, '..', '..', 'driverBench', 'spec');
111+
112+
export function metrics(test_name: string, result: number, count: number) {
113+
return {
114+
info: {
115+
test_name,
116+
// Args can only be a map of string -> int32. So if its a number leave it be,
117+
// if it is anything else test for truthiness and set to 1 or 0.
118+
args: Object.fromEntries(
119+
Object.entries(MONGODB_CLIENT_OPTIONS).map(([key, value]) => [
120+
key,
121+
typeof value === 'number' ? value : value ? 1 : 0
122+
])
123+
)
124+
},
125+
metrics: [
126+
{ name: 'megabytes_per_second', value: result },
127+
{ name: 'count', value: count }
128+
]
129+
} as const;
130+
}
131+
132+
/**
133+
* This class exists to abstract some of the driver API so we can gloss over version differences.
134+
* For use in setup/teardown mostly.
135+
*/
136+
export class DriverTester {
137+
private utilClient: mongodb.MongoClient;
138+
private client: mongodb.MongoClient;
139+
constructor() {
140+
this.utilClient = new MongoClient(MONGODB_URI, MONGODB_CLIENT_OPTIONS);
141+
this.client = new MongoClient(MONGODB_URI, MONGODB_CLIENT_OPTIONS);
142+
}
143+
144+
get ns() {
145+
return this.utilClient.db(DB_NAME).collection(COLLECTION_NAME);
146+
}
147+
148+
async drop() {
149+
await this.ns.drop().catch(() => null);
150+
await this.utilClient
151+
.db(DB_NAME)
152+
.dropDatabase()
153+
.catch(() => null);
154+
}
155+
156+
async create() {
157+
return await this.utilClient.db(DB_NAME).createCollection(COLLECTION_NAME);
158+
}
159+
160+
async load(filePath: string, type: 'json' | 'string' | 'buffer'): Promise<any> {
161+
const content = await fs.readFile(path.join(SPEC_DIRECTORY, filePath));
162+
if (type === 'buffer') return content;
163+
const string = content.toString('utf8');
164+
if (type === 'string') return string;
165+
if (type === 'json') return JSON.parse(string);
166+
throw new Error('unknown type: ' + type);
167+
}
168+
169+
async insertManyOf(document: Record<string, any>, length: number, addId = false) {
170+
await this.ns.insertMany(
171+
Array.from({ length }, (_, _id) => ({ ...(addId ? { _id } : {}), ...document })) as any[]
172+
);
173+
}
174+
175+
async close() {
176+
await this.client.close();
177+
await this.utilClient.close();
178+
}
179+
}
180+
181+
export const driver = new DriverTester();
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/* eslint-disable no-console */
2+
import child_process from 'node:child_process';
3+
import events from 'node:events';
4+
import fs from 'node:fs/promises';
5+
import os from 'node:os';
6+
import path from 'node:path';
7+
import util from 'node:util';
8+
9+
import {
10+
MONGODB_BSON_PATH,
11+
MONGODB_BSON_REVISION,
12+
MONGODB_BSON_VERSION,
13+
MONGODB_CLIENT_OPTIONS,
14+
MONGODB_DRIVER_PATH,
15+
MONGODB_DRIVER_REVISION,
16+
MONGODB_DRIVER_VERSION,
17+
snakeToCamel
18+
} from './driver.mjs';
19+
20+
const __dirname = import.meta.dirname;
21+
22+
/** Find every mjs file in the suites folder */
23+
async function getBenchmarks(): Promise<
24+
Record<string, Record<string, { benchFile: string } & Record<string, any>>>
25+
> {
26+
const tests: Record<
27+
string,
28+
Record<string, { benchFile: string } & Record<string, any>>
29+
> = Object.create(null);
30+
const suites = await fs.readdir(path.join(__dirname, 'suites'));
31+
for (const suite of suites) {
32+
const benchmarks = await fs.readdir(path.join(__dirname, 'suites', suite));
33+
for (const benchmark of benchmarks) {
34+
if (!benchmark.endsWith('.mjs')) continue;
35+
tests[suite] ??= Object.create(null);
36+
tests[suite][benchmark] = { benchFile: path.join('suites', suite, benchmark) };
37+
}
38+
}
39+
return tests;
40+
}
41+
42+
const hw = os.cpus();
43+
const ram = os.totalmem() / 1024 ** 3;
44+
const platform = { name: hw[0].model, cores: hw.length, ram: `${ram}GB` };
45+
46+
const systemInfo = () =>
47+
[
48+
`\n- cpu: ${platform.name}`,
49+
`- cores: ${platform.cores}`,
50+
`- arch: ${os.arch()}`,
51+
`- os: ${process.platform} (${os.release()})`,
52+
`- ram: ${platform.ram}`,
53+
`- node: ${process.version}`,
54+
`- driver: ${MONGODB_DRIVER_VERSION} (${MONGODB_DRIVER_REVISION}): ${MONGODB_DRIVER_PATH}`,
55+
` - options ${util.inspect(MONGODB_CLIENT_OPTIONS)}`,
56+
`- bson: ${MONGODB_BSON_VERSION} (${MONGODB_BSON_REVISION}): (${MONGODB_BSON_PATH})\n`
57+
].join('\n');
58+
59+
console.log(systemInfo());
60+
61+
const tests = await getBenchmarks();
62+
const runnerPath = path.join(__dirname, 'runner.mjs');
63+
64+
const results = [];
65+
66+
for (const [suite, benchmarks] of Object.entries(tests)) {
67+
console.group(snakeToCamel(suite));
68+
69+
for (const [benchmark, { benchFile }] of Object.entries(benchmarks)) {
70+
console.log(snakeToCamel(path.basename(benchmark, '.mjs')));
71+
72+
const runner = child_process.fork(runnerPath, [benchFile], { stdio: 'inherit' });
73+
74+
const [exitCode] = await events.once(runner, 'close');
75+
if (exitCode !== 0) {
76+
throw new Error(`Benchmark exited with failure: ${exitCode}`);
77+
}
78+
79+
const result = JSON.parse(
80+
await fs.readFile(`results_${path.basename(benchmark, '.mjs')}.json`, 'utf8')
81+
);
82+
83+
results.push(result);
84+
}
85+
86+
console.groupEnd();
87+
}
88+
89+
await fs.writeFile('results.json', JSON.stringify(results, undefined, 2), 'utf8');
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/* eslint-disable no-console */
2+
import fs from 'node:fs/promises';
3+
import path from 'node:path';
4+
import process from 'node:process';
5+
6+
import { metrics, snakeToCamel } from './driver.mjs';
7+
8+
const [, , benchmarkFile] = process.argv;
9+
10+
type BenchmarkModule = {
11+
taskSize: number;
12+
before?: () => Promise<void>;
13+
beforeEach?: () => Promise<void>;
14+
run: () => Promise<void>;
15+
afterEach?: () => Promise<void>;
16+
after?: () => Promise<void>;
17+
};
18+
19+
const benchmarkName = snakeToCamel(path.basename(benchmarkFile, '.mjs'));
20+
const benchmark: BenchmarkModule = await import(`./${benchmarkFile}`);
21+
22+
if (typeof benchmark.taskSize !== 'number') throw new Error('missing taskSize');
23+
if (typeof benchmark.run !== 'function') throw new Error('missing run');
24+
25+
/** CRITICAL SECTION: time task took in seconds */
26+
async function timeTask() {
27+
const start = performance.now();
28+
await benchmark.run();
29+
const end = performance.now();
30+
return (end - start) / 1000;
31+
}
32+
33+
/** 1 min in seconds */
34+
const ONE_MIN = 1 * 60;
35+
/** 5 min in seconds */
36+
const FIVE_MIN = 5 * 60;
37+
/** Don't run more than 100 iterations */
38+
const MAX_COUNT = 100;
39+
40+
await benchmark.before?.();
41+
42+
// for 1/10th the max iterations
43+
const warmupIterations = (MAX_COUNT / 10) | 0;
44+
45+
// Warm Up.
46+
for (let i = 0; i < warmupIterations; i++) {
47+
await benchmark.beforeEach?.();
48+
await timeTask();
49+
await benchmark.afterEach?.();
50+
}
51+
52+
// Allocate an obscene amount of space
53+
const data = new Float64Array(10_000_000);
54+
55+
// Test.
56+
let totalDuration = 0;
57+
let count = 0;
58+
do {
59+
await benchmark.beforeEach?.();
60+
61+
data[count] = await timeTask();
62+
63+
await benchmark.afterEach?.();
64+
65+
totalDuration += data[count]; // time moves up by benchmark exec time not wall clock
66+
count += 1;
67+
68+
// must run for at least one minute
69+
if (totalDuration < ONE_MIN) continue;
70+
71+
// 100 runs OR five minutes
72+
if (count > 100 || totalDuration > FIVE_MIN) break;
73+
74+
// count exceeds data space, we never intend to have more than a million data points let alone 10M
75+
if (count === data.length) break;
76+
77+
// else: more than one min, less than 100 iterations, less than 5min
78+
79+
// eslint-disable-next-line no-constant-condition
80+
} while (true);
81+
82+
await benchmark.after?.();
83+
84+
const durations = data.subarray(0, count).toSorted((a, b) => a - b);
85+
86+
function percentileIndex(percentile: number, count: number) {
87+
return Math.max(Math.floor((count * percentile) / 100 - 1), 0);
88+
}
89+
90+
const medianExecution = durations[percentileIndex(50, count)];
91+
92+
console.log(
93+
' ',
94+
benchmarkName,
95+
'finished in',
96+
totalDuration,
97+
'sec and ran',
98+
count,
99+
'iterations.',
100+
'median exec time',
101+
medianExecution,
102+
'sec',
103+
benchmark.taskSize / medianExecution,
104+
'mb/sec'
105+
);
106+
107+
await fs.writeFile(
108+
`results_${path.basename(benchmarkFile, '.mjs')}.json`,
109+
JSON.stringify(metrics(benchmarkName, medianExecution, count), undefined, 2) + '\n',
110+
'utf8'
111+
);

0 commit comments

Comments
 (0)