Skip to content

Commit

Permalink
Switch to easier to use benchmark setup stolen from Gadget with profi…
Browse files Browse the repository at this point in the history
…ling built in
  • Loading branch information
airhorns committed May 31, 2024
1 parent 4093e4d commit 757eb52
Show file tree
Hide file tree
Showing 13 changed files with 327 additions and 117 deletions.
16 changes: 14 additions & 2 deletions Benchmarking.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,30 @@
You can run a benchmark file with `pnpm x <the file>`:

```shell
pnpm x bench/create-large-root.ts
pnpm x bench/instantiation.benchmark.ts
```

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

You can also run all the benchmarks with:

```shell
pnpm x bench/all.ts
```

## Profiling

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

### CPU profiling

You can run a benchmark to generate a profile using node.js' built in sampling profiler
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:

```shell
pnpm x bench/instantiation.benchmark.ts --profile
```

You can also run a benchmark to generate a profile using node.js' built in sampling profiler

```shell
node -r ts-node/register/transpile-only --prof bench/create-large-root.ts
Expand Down
83 changes: 36 additions & 47 deletions bench/all.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,42 @@
import { Bench } from "tinybench";
import { withCodSpeed } from "@codspeed/tinybench-plugin";
import findRoot from "find-root";
import fs from "fs";
import { FruitAisle } from "../spec/fixtures/FruitAisle";
import { LargeRoot } from "../spec/fixtures/LargeRoot";
import { TestClassModel } from "../spec/fixtures/TestClassModel";
import { BigTestModelSnapshot, TestModelSnapshot } from "../spec/fixtures/TestModel";
import { registerPropertyAccess } from "./property-access-model-class";

const root = findRoot(__dirname);
const largeRoot = JSON.parse(fs.readFileSync(root + "/spec/fixtures/large-root-snapshot.json", "utf8"));
const fruitAisle = JSON.parse(fs.readFileSync(root + "/spec/fixtures/fruit-aisle-snapshot.json", "utf8"));

void (async () => {
let suite = new Bench();
if (process.env.CI) {
suite = withCodSpeed(suite);
import globby from "globby";
import { hideBin } from "yargs/helpers";
import yargs from "yargs/yargs";
import { benchTable, createSuite } from "./benchmark";

/** Script for running */
const argv = yargs(hideBin(process.argv))
.option("benchmarks", {
alias: ["b", "t"],
type: "string",
describe: "Benchmark file pattern to match",
})
.usage("Usage: run.ts [options]")
.help().argv;

export const runAll = async () => {
let benchmarkFiles = await globby(__dirname + "/**/*.benchmark.ts");
let suite = createSuite();

if (argv.benchmarks) {
benchmarkFiles = benchmarkFiles.filter((file) => file.includes(argv.benchmarks!));
}
console.info("running benchmarks", { benchmarkFiles });

suite
.add("instantiating a small root", function () {
TestClassModel.createReadOnly(TestModelSnapshot);
})
.add("instantiating a large root", function () {
LargeRoot.createReadOnly(largeRoot);
})
.add("instantiating a large union", function () {
FruitAisle.createReadOnly(fruitAisle);
})
.add("instantiating a diverse root", function () {
TestClassModel.createReadOnly(BigTestModelSnapshot);
})
.add("instantiating a small root (mobx-state-tree)", function () {
TestClassModel.create(TestModelSnapshot);
})
.add("instantiating a large root (mobx-state-tree)", function () {
LargeRoot.create(largeRoot);
})
.add("instantiating a large union (mobx-state-tree)", function () {
FruitAisle.create(fruitAisle);
})
.add("instantiating a diverse root (mobx-state-tree)", function () {
TestClassModel.create(BigTestModelSnapshot);
});

suite = registerPropertyAccess(suite);
for (const file of benchmarkFiles) {
let benchmark = await import(file);
if (benchmark.default) {
benchmark = benchmark.default;
}
suite = await benchmark.fn(suite);
}

await suite.warmup();
await suite.run();

console.table(suite.table());
})();
console.table(benchTable(suite));
};

if (require.main === module) {
void runAll();
}

163 changes: 163 additions & 0 deletions bench/benchmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { writeFile } from "fs-extra";
import { compact } from "lodash";
import { Bench, type Options } from "tinybench";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import type { Profiler } from "inspector";
import { Session } from "inspector";

export const newInspectorSession = () => {
const session = new Session();
const post = (method: string, params?: Record<string, unknown>): any =>
new Promise((resolve, reject) => {
session.post(method, params, (err: Error | null, result: any) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
});

session.connect();
return { session, post };
};

export type BenchmarkGenerator = ((suite: Bench) => Bench | Promise<Bench>) & { options?: Options };

/**
* Set up a new benchmark in our library of benchmarks
* If this file is executed directly, it will run the benchmark
* Otherwise, it will export the benchmark for use in other files
*
* @example
* export default benchmarker((suite) => {
* return suite.add("My Benchmark", async () => {
* // something expensive
* });
* });
**/
export const benchmarker = (fn: BenchmarkGenerator, options?: Options) => {
fn.options = options;

const err = new NiceStackError();
const callerFile = (err.stack as unknown as NodeJS.CallSite[])[2].getFileName();

if (require.main?.filename === callerFile) {
void runBenchmark(fn);
} else {
return { fn };
}
};

/** Wrap a plain old async function in the weird deferred management code benchmark.js requires */
export const asyncBench = (fn: () => Promise<void>) => {
return {
defer: true,
fn: async (deferred: any) => {
await fn();
deferred.resolve();
},
};
};

/** Boot up a benchmark suite for registering new cases on */
export const createSuite = (options: Options = { iterations: 100 }) => {
const suite = new Bench(options);

suite.addEventListener("error", (event: any) => {
console.error(event);
});

return suite;
};

/** Run one benchmark function in isolation */
const runBenchmark = async (fn: BenchmarkGenerator) => {
const args = yargs(hideBin(process.argv))
.option("p", {
alias: "profile",
default: false,
describe: "profile each benchmarked case as it runs, writing a CPU profile to disk for each",
type: "boolean",
})
.option("b", {
alias: "blocking",
default: false,
describe: "track event loop blocking time during each iteration, which changes the stats",
type: "boolean",
}).argv;

let suite = createSuite(fn.options);

if (args.profile) {
const key = formatDateForFile();

const { post } = newInspectorSession();
await post("Profiler.enable");
await post("Profiler.setSamplingInterval", { interval: 20 });

suite.addEventListener("add", (event) => {
const oldBeforeAll = event.task.opts.beforeAll;
const oldAfterAll = event.task.opts.beforeAll;

event.task.opts.beforeAll = async function () {
await post("Profiler.start");
await oldBeforeAll?.call(this);
};
event.task.opts.afterAll = async function () {
await oldAfterAll?.call(this);
const { profile } = (await post("Profiler.stop")) as Profiler.StopReturnType;
await writeFile(`./bench-${event.task.name}-${key}.cpuprofile`, JSON.stringify(profile));
};
});
}

suite = await fn(suite);

console.log("running benchmark");

await suite.warmup();
await suite.run();

console.table(benchTable(suite));
};

class NiceStackError extends Error {
constructor() {
super();
const oldStackTrace = Error.prepareStackTrace;
try {
Error.prepareStackTrace = (err, structuredStackTrace) => structuredStackTrace;

Error.captureStackTrace(this);

this.stack; // Invoke the getter for `stack`.
} finally {
Error.prepareStackTrace = oldStackTrace;
}
}
}

const formatDateForFile = () => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(
now.getHours()
).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}-${String(now.getSeconds()).padStart(2, "0")}`;
};

export const benchTable = (bench: Bench) => {
return compact(
bench.tasks.map(({ name: t, result: e }) => {
if (!e) return null;
return {
"Task Name": t,
"ops/sec": e.error ? "NaN" : parseInt(e.hz.toString(), 10).toLocaleString(),
"Average Time (ms)": e.error ? "NaN" : e.mean,
"p99 Time (ms)": e.error ? "NaN" : e.p99,
Margin: e.error ? "NaN" : `\xB1${e.rme.toFixed(2)}%`,
Samples: e.error ? "NaN" : e.samples.length,
};
})
);
};
17 changes: 0 additions & 17 deletions bench/create-large-root.ts

This file was deleted.

6 changes: 0 additions & 6 deletions bench/create-many-model-class.ts

This file was deleted.

12 changes: 4 additions & 8 deletions bench/create-union.ts → bench/create-union.benchmark.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import { Bench } from "tinybench";
import findRoot from "find-root";
import fs from "fs";
import { FruitAisle } from "../spec/fixtures/FruitAisle";
import { benchmarker } from "./benchmark";

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

void (async () => {
const suite = new Bench();

export default benchmarker(async (suite) => {
suite.add("instantiating a large union", function () {
FruitAisle.createReadOnly(fruitBasket);
});

await suite.warmup();
await suite.run();
console.table(suite.table());
})();
return suite
});
12 changes: 4 additions & 8 deletions bench/cross-framework.ts → bench/cross-framework.benchmark.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Bench } from "tinybench";
import { TestClassModel } from "../spec/fixtures/TestClassModel";
import { TestModel } from "../spec/fixtures/TestModel";
import { TestPlainModel } from "./reference/plain-class";
import { ObservablePlainModel } from "./reference/mobx";
import { benchmarker } from "./benchmark";

const TestModelSnapshot: (typeof TestModel)["InputType"] = {
bool: true,
Expand All @@ -21,9 +21,7 @@ const TestModelSnapshot: (typeof TestModel)["InputType"] = {
},
};

void (async () => {
const suite = new Bench();

export default benchmarker(async (suite) => {
suite
.add("mobx-state-tree", function () {
TestModel.create(TestModelSnapshot);
Expand All @@ -41,7 +39,5 @@ void (async () => {
new TestPlainModel(TestModelSnapshot);
});

await suite.warmup();
await suite.run();
console.table(suite.table());
})();
return suite
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { Bench } from "tinybench";
import findRoot from "find-root";
import fs from "fs";
import { LargeRoot } from "../spec/fixtures/LargeRoot";
import { TestClassModel } from "../spec/fixtures/TestClassModel";
import { BigTestModelSnapshot } from "../spec/fixtures/TestModel";
import { NameExample } from "../spec/fixtures/NameExample";
import { benchmarker } from "./benchmark";

const snapshot = JSON.parse(fs.readFileSync(findRoot(__dirname) + "/spec/fixtures/large-root-snapshot.json", "utf8"));

void (async () => {
const suite = new Bench();
export default benchmarker(async (suite) => {
suite
.add("instantiating a large root", function () {
LargeRoot.createReadOnly(snapshot);
Expand All @@ -21,7 +20,5 @@ void (async () => {
TestClassModel.createReadOnly(BigTestModelSnapshot);
});

await suite.warmup();
await suite.run();
console.table(suite.table());
})();
return suite;
});
Loading

0 comments on commit 757eb52

Please sign in to comment.