Skip to content

Commit 88406c8

Browse files
authored
Merge pull request #97 from gadget-inc/nicer-benchmarking
nicer benchmarking
2 parents af10b7c + 8e535a7 commit 88406c8

14 files changed

+363
-107
lines changed

.vscode/launch.json

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
// Use IntelliSense to learn about possible attributes.
3+
// Hover to view descriptions of existing attributes.
4+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5+
"version": "0.2.0",
6+
"configurations": [
7+
{
8+
"type": "node",
9+
"name": "vscode-jest-tests.v2",
10+
"request": "launch",
11+
"args": ["--runInBand", "--watchAll=false", "--testNamePattern", "${jest.testNamePattern}", "--runTestsByPath", "${jest.testFile}"],
12+
"cwd": "${workspaceFolder}",
13+
"runtimeExecutable": "bash",
14+
"console": "integratedTerminal",
15+
"internalConsoleOptions": "neverOpen",
16+
"disableOptimisticBPs": true,
17+
"program": "${workspaceFolder}/node_modules/.bin/jest",
18+
"windows": {
19+
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
20+
}
21+
},
22+
{
23+
"type": "node",
24+
"name": "run-benchmark",
25+
"request": "launch",
26+
"args": ["--transpile-only", "bench/create-large-root.ts"],
27+
"cwd": "${workspaceFolder}",
28+
"console": "integratedTerminal",
29+
"runtimeExecutable": "bash",
30+
"internalConsoleOptions": "neverOpen",
31+
"disableOptimisticBPs": true,
32+
"program": "${workspaceFolder}/node_modules/.bin/ts-node"
33+
}
34+
]
35+
}

Benchmarking.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,30 @@
77
You can run a benchmark file with `pnpm x <the file>`:
88

99
```shell
10-
pnpm x bench/create-large-root.ts
10+
pnpm x bench/instantiation.benchmark.ts
1111
```
1212

1313
This will run the file and output a speed measurement for comparison between git branches.
1414

15+
You can also run all the benchmarks with:
16+
17+
```shell
18+
pnpm x bench/all.ts
19+
```
20+
1521
## Profiling
1622

1723
It's nice to use the benchmarks for profiling to identify optimization candidates.
1824

1925
### CPU profiling
2026

21-
You can run a benchmark to generate a profile using node.js' built in sampling profiler
27+
The benchmark framework supports a `--profile` option for writing a profile of the benchmark loop, excluding setup and teardown code. Run a benchmark with profile, then open the created `.cpuprofile` file:
28+
29+
```shell
30+
pnpm x bench/instantiation.benchmark.ts --profile
31+
```
32+
33+
You can also run a benchmark to generate a profile using node.js' built in sampling profiler
2234

2335
```shell
2436
node -r ts-node/register/transpile-only --prof bench/create-large-root.ts

bench/all.ts

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,46 @@
1-
import { Bench } from "tinybench";
1+
import globby from "globby";
2+
import { hideBin } from "yargs/helpers";
3+
import yargs from "yargs/yargs";
4+
import { benchTable, createSuite } from "./benchmark";
25
import { withCodSpeed } from "@codspeed/tinybench-plugin";
3-
import findRoot from "find-root";
4-
import fs from "fs";
5-
import { FruitAisle } from "../spec/fixtures/FruitAisle";
6-
import { LargeRoot } from "../spec/fixtures/LargeRoot";
7-
import { TestClassModel } from "../spec/fixtures/TestClassModel";
8-
import { BigTestModelSnapshot, TestModelSnapshot } from "../spec/fixtures/TestModel";
9-
import { registerPropertyAccess } from "./property-access-model-class";
10-
11-
const root = findRoot(__dirname);
12-
const largeRoot = JSON.parse(fs.readFileSync(root + "/spec/fixtures/large-root-snapshot.json", "utf8"));
13-
const fruitAisle = JSON.parse(fs.readFileSync(root + "/spec/fixtures/fruit-aisle-snapshot.json", "utf8"));
14-
15-
void (async () => {
16-
let suite = new Bench();
6+
7+
export const runAll = async () => {
8+
const argv = await yargs(hideBin(process.argv))
9+
.option("benchmarks", {
10+
alias: ["b", "t"],
11+
type: "string",
12+
describe: "Benchmark file pattern to match",
13+
})
14+
.usage("Usage: run.ts [options]")
15+
.help().argv;
16+
17+
let benchmarkFiles = await globby(__dirname + "/**/*.benchmark.ts");
18+
let suite = createSuite();
19+
1720
if (process.env.CI) {
1821
suite = withCodSpeed(suite);
1922
}
2023

21-
suite
22-
.add("instantiating a small root", function () {
23-
TestClassModel.createReadOnly(TestModelSnapshot);
24-
})
25-
.add("instantiating a large root", function () {
26-
LargeRoot.createReadOnly(largeRoot);
27-
})
28-
.add("instantiating a large union", function () {
29-
FruitAisle.createReadOnly(fruitAisle);
30-
})
31-
.add("instantiating a diverse root", function () {
32-
TestClassModel.createReadOnly(BigTestModelSnapshot);
33-
})
34-
.add("instantiating a small root (mobx-state-tree)", function () {
35-
TestClassModel.create(TestModelSnapshot);
36-
})
37-
.add("instantiating a large root (mobx-state-tree)", function () {
38-
LargeRoot.create(largeRoot);
39-
})
40-
.add("instantiating a large union (mobx-state-tree)", function () {
41-
FruitAisle.create(fruitAisle);
42-
})
43-
.add("instantiating a diverse root (mobx-state-tree)", function () {
44-
TestClassModel.create(BigTestModelSnapshot);
45-
});
24+
if (argv.benchmarks) {
25+
benchmarkFiles = benchmarkFiles.filter((file) => file.includes(argv.benchmarks!));
26+
}
27+
console.info("running benchmarks", { benchmarkFiles });
4628

47-
suite = registerPropertyAccess(suite);
29+
for (const file of benchmarkFiles) {
30+
let benchmark = await import(file);
31+
if (benchmark.default) {
32+
benchmark = benchmark.default;
33+
}
34+
suite = await benchmark.fn(suite);
35+
}
4836

4937
await suite.warmup();
5038
await suite.run();
5139

52-
console.table(suite.table());
53-
})();
40+
console.table(benchTable(suite));
41+
};
42+
43+
if (require.main === module) {
44+
void runAll();
45+
}
46+

bench/benchmark.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { writeFile } from "fs-extra";
2+
import { compact } from "lodash";
3+
import { Bench, type Options } from "tinybench";
4+
import yargs from "yargs";
5+
import { hideBin } from "yargs/helpers";
6+
import type { Profiler } from "inspector";
7+
import { Session } from "inspector";
8+
9+
export const newInspectorSession = () => {
10+
const session = new Session();
11+
const post = (method: string, params?: Record<string, unknown>): any =>
12+
new Promise((resolve, reject) => {
13+
session.post(method, params, (err: Error | null, result: any) => {
14+
if (err) {
15+
reject(err);
16+
} else {
17+
resolve(result);
18+
}
19+
});
20+
});
21+
22+
session.connect();
23+
return { session, post };
24+
};
25+
26+
export type BenchmarkGenerator = ((suite: Bench) => Bench | Promise<Bench>) & { options?: Options };
27+
28+
/**
29+
* Set up a new benchmark in our library of benchmarks
30+
* If this file is executed directly, it will run the benchmark
31+
* Otherwise, it will export the benchmark for use in other files
32+
*
33+
* @example
34+
* export default benchmarker((suite) => {
35+
* return suite.add("My Benchmark", async () => {
36+
* // something expensive
37+
* });
38+
* });
39+
**/
40+
export const benchmarker = (fn: BenchmarkGenerator, options?: Options) => {
41+
fn.options = options;
42+
43+
const err = new NiceStackError();
44+
const callerFile = (err.stack as unknown as NodeJS.CallSite[])[2].getFileName();
45+
46+
if (require.main?.filename === callerFile) {
47+
void runBenchmark(fn);
48+
} else {
49+
return { fn };
50+
}
51+
};
52+
53+
/** Wrap a plain old async function in the weird deferred management code benchmark.js requires */
54+
export const asyncBench = (fn: () => Promise<void>) => {
55+
return {
56+
defer: true,
57+
fn: async (deferred: any) => {
58+
await fn();
59+
deferred.resolve();
60+
},
61+
};
62+
};
63+
64+
/** Boot up a benchmark suite for registering new cases on */
65+
export const createSuite = (options: Options = { iterations: 100 }) => {
66+
const suite = new Bench(options);
67+
68+
suite.addEventListener("error", (event: any) => {
69+
console.error(event);
70+
});
71+
72+
return suite;
73+
};
74+
75+
/** Run one benchmark function in isolation */
76+
const runBenchmark = async (fn: BenchmarkGenerator) => {
77+
const args = await yargs(hideBin(process.argv))
78+
.option("p", {
79+
alias: "profile",
80+
default: false,
81+
describe: "profile each benchmarked case as it runs, writing a CPU profile to disk for each",
82+
type: "boolean",
83+
})
84+
.option("b", {
85+
alias: "blocking",
86+
default: false,
87+
describe: "track event loop blocking time during each iteration, which changes the stats",
88+
type: "boolean",
89+
}).argv;
90+
91+
let suite = createSuite(fn.options);
92+
93+
if (args.profile) {
94+
const key = formatDateForFile();
95+
96+
const { post } = newInspectorSession();
97+
await post("Profiler.enable");
98+
await post("Profiler.setSamplingInterval", { interval: 20 });
99+
100+
suite.addEventListener("add", (event) => {
101+
const oldBeforeAll = event.task.opts.beforeAll;
102+
const oldAfterAll = event.task.opts.beforeAll;
103+
104+
event.task.opts.beforeAll = async function () {
105+
await post("Profiler.start");
106+
await oldBeforeAll?.call(this);
107+
};
108+
event.task.opts.afterAll = async function () {
109+
await oldAfterAll?.call(this);
110+
const { profile } = (await post("Profiler.stop")) as Profiler.StopReturnType;
111+
await writeFile(`./bench-${event.task.name}-${key}.cpuprofile`, JSON.stringify(profile));
112+
};
113+
});
114+
}
115+
116+
suite = await fn(suite);
117+
118+
console.log("running benchmark");
119+
120+
await suite.warmup();
121+
await suite.run();
122+
123+
console.table(benchTable(suite));
124+
};
125+
126+
class NiceStackError extends Error {
127+
constructor() {
128+
super();
129+
const oldStackTrace = Error.prepareStackTrace;
130+
try {
131+
Error.prepareStackTrace = (err, structuredStackTrace) => structuredStackTrace;
132+
133+
Error.captureStackTrace(this);
134+
135+
this.stack; // Invoke the getter for `stack`.
136+
} finally {
137+
Error.prepareStackTrace = oldStackTrace;
138+
}
139+
}
140+
}
141+
142+
const formatDateForFile = () => {
143+
const now = new Date();
144+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(
145+
now.getHours()
146+
).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}-${String(now.getSeconds()).padStart(2, "0")}`;
147+
};
148+
149+
export const benchTable = (bench: Bench) => {
150+
return compact(
151+
bench.tasks.map(({ name: t, result: e }) => {
152+
if (!e) return null;
153+
return {
154+
"Task Name": t,
155+
"ops/sec": e.error ? "NaN" : parseInt(e.hz.toString(), 10).toLocaleString(),
156+
"Average Time (ms)": e.error ? "NaN" : e.mean,
157+
"p99 Time (ms)": e.error ? "NaN" : e.p99,
158+
Margin: e.error ? "NaN" : `\xB1${e.rme.toFixed(2)}%`,
159+
Samples: e.error ? "NaN" : e.samples.length,
160+
};
161+
})
162+
);
163+
};

bench/create-large-root.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.
Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
1-
import { Bench } from "tinybench";
21
import findRoot from "find-root";
32
import fs from "fs";
43
import { FruitAisle } from "../spec/fixtures/FruitAisle";
4+
import { benchmarker } from "./benchmark";
55

66
const root = findRoot(__dirname);
77
const fruitBasket = JSON.parse(fs.readFileSync(root + "/spec/fixtures/fruit-aisle-snapshot.json", "utf8"));
88

9-
void (async () => {
10-
const suite = new Bench();
11-
9+
export default benchmarker(async (suite) => {
1210
suite.add("instantiating a large union", function () {
1311
FruitAisle.createReadOnly(fruitBasket);
1412
});
1513

16-
await suite.warmup();
17-
await suite.run();
18-
console.table(suite.table());
19-
})();
14+
return suite
15+
});

bench/cross-framework.ts renamed to bench/cross-framework.benchmark.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { Bench } from "tinybench";
21
import { TestClassModel } from "../spec/fixtures/TestClassModel";
32
import { TestModel } from "../spec/fixtures/TestModel";
43
import { TestPlainModel } from "./reference/plain-class";
54
import { ObservablePlainModel } from "./reference/mobx";
5+
import { benchmarker } from "./benchmark";
66

77
const TestModelSnapshot: (typeof TestModel)["InputType"] = {
88
bool: true,
@@ -21,9 +21,7 @@ const TestModelSnapshot: (typeof TestModel)["InputType"] = {
2121
},
2222
};
2323

24-
void (async () => {
25-
const suite = new Bench();
26-
24+
export default benchmarker(async (suite) => {
2725
suite
2826
.add("mobx-state-tree", function () {
2927
TestModel.create(TestModelSnapshot);
@@ -41,7 +39,5 @@ void (async () => {
4139
new TestPlainModel(TestModelSnapshot);
4240
});
4341

44-
await suite.warmup();
45-
await suite.run();
46-
console.table(suite.table());
47-
})();
42+
return suite
43+
});

0 commit comments

Comments
 (0)