@soroush.tech/bench 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +10 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/bin.mjs +147 -0
- package/dist/harness.mjs +1730 -0
- package/dist/index.cjs +17 -0
- package/dist/index.d.cts +37 -0
- package/dist/index.d.mts +37 -0
- package/dist/index.mjs +17 -0
- package/package.json +76 -0
package/Dockerfile
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Sandbox image for @soroush.tech/bench.
|
|
2
|
+
# Generic and content-stable: the harness and bench files are mounted at run
|
|
3
|
+
# time, so this image is built once and cached across every benchmark.
|
|
4
|
+
FROM node:24-slim
|
|
5
|
+
|
|
6
|
+
# tsx runs the TypeScript harness and bench files. mitata is bundled into the
|
|
7
|
+
# harness at build time, so it is not installed here.
|
|
8
|
+
RUN npm install -g tsx@^4
|
|
9
|
+
|
|
10
|
+
WORKDIR /repo
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Masoud Soroush <https://soroush.tech>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# @soroush.tech/bench
|
|
2
|
+
|
|
3
|
+
Run A/B function benchmarks inside a **CPU/RAM-pinned Docker sandbox** so the
|
|
4
|
+
numbers stay stable run-to-run and are not skewed by whatever else the machine
|
|
5
|
+
is doing. Built on [mitata](https://github.com/evanwashere/mitata).
|
|
6
|
+
|
|
7
|
+
Compare:
|
|
8
|
+
|
|
9
|
+
- two (or more) implementations of the same function, or
|
|
10
|
+
- two-or-more **versions of one npm package** (e.g. `lodash@4` vs `lodash@5`).
|
|
11
|
+
|
|
12
|
+
> **What pinning does and does not buy you.** Pinning to a single core
|
|
13
|
+
> (`--cpuset-cpus`), a hard CPU quota (`--cpus`), and fixed memory with swap off
|
|
14
|
+
> (`--memory` + `--memory-swap`) removes the noise from background load, turbo
|
|
15
|
+
> boost variance, and paging — so repeated runs **on the same host** agree. It
|
|
16
|
+
> does **not** make results identical across different physical CPUs (a
|
|
17
|
+
> container shares the host kernel and silicon). For numbers that are identical
|
|
18
|
+
> on any machine, count operations instead of measuring time.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
Docker must be installed and running on the host. The sandbox image is built
|
|
23
|
+
once and cached; subsequent runs only re-run the container.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
Write a `*.bench.ts` file that default-exports a `defineBench(...)` config:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// clone.bench.ts
|
|
31
|
+
import defineBench from '@soroush.tech/bench'
|
|
32
|
+
|
|
33
|
+
const data = { a: 1, nested: { b: [1, 2, 3] } }
|
|
34
|
+
|
|
35
|
+
export default defineBench({
|
|
36
|
+
name: 'deep clone',
|
|
37
|
+
cases: {
|
|
38
|
+
structuredClone: () => structuredClone(data),
|
|
39
|
+
jsonRoundtrip: () => JSON.parse(JSON.stringify(data)),
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run it in the pinned sandbox:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
bench ./clone.bench.ts
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Comparing versions of one package
|
|
51
|
+
|
|
52
|
+
Declare each version under an alias in `packages`; the harness installs them
|
|
53
|
+
side by side and injects the resolved modules into every case via
|
|
54
|
+
`ctx.modules`.
|
|
55
|
+
|
|
56
|
+
### Comparing a local workspace package against npm
|
|
57
|
+
|
|
58
|
+
Because the sandbox mounts the whole repo, a local workspace package is reached
|
|
59
|
+
with a normal top-level import, while the npm release is installed under an
|
|
60
|
+
alias. This benchmarks the local `@soroush.tech/styled-system` rewrite against
|
|
61
|
+
the original `styled-system` it replaces:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// styled-system-color.bench.ts
|
|
65
|
+
import { color } from '@soroush.tech/styled-system/color' // local source (mounted repo)
|
|
66
|
+
import defineBench from '@soroush.tech/bench'
|
|
67
|
+
|
|
68
|
+
const props = { theme: { colors: { brand: '#0af', muted: '#666' } }, color: 'brand', bg: 'muted' }
|
|
69
|
+
type ColorFn = (props: typeof props) => unknown
|
|
70
|
+
|
|
71
|
+
export default defineBench({
|
|
72
|
+
name: 'styled-system color()',
|
|
73
|
+
packages: { upstream: 'styled-system@5.1.5' }, // npm release
|
|
74
|
+
cases: {
|
|
75
|
+
local: () => color(props),
|
|
76
|
+
upstream: ({ modules }) => (modules.upstream as { color: ColorFn }).color(props),
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Run it from the repo root so the mount includes both the workspace package and
|
|
82
|
+
its `node_modules` link:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
bench ./examples/bench/styled-system-color.bench.ts
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
> The same shape compares **two-or-more versions of one npm package** — give
|
|
89
|
+
> several aliases the same name at different versions (e.g.
|
|
90
|
+
> `{ v4: 'lodash@4.17.21', v5: 'lodash@5.0.0' }`) and read each off `ctx.modules`.
|
|
91
|
+
|
|
92
|
+
### Options
|
|
93
|
+
|
|
94
|
+
| Flag | Default | Meaning |
|
|
95
|
+
| ---------- | --------------------- | ---------------------------------------- |
|
|
96
|
+
| `--cpus` | `1` | CPU quota (`docker run --cpus`). |
|
|
97
|
+
| `--cpuset` | `0` | Logical CPU(s) to pin to (`--cpuset-cpus`, e.g. `0,2`). |
|
|
98
|
+
| `--memory` | `512m` | Memory cap; swap is pinned to the same. |
|
|
99
|
+
| `--tag` | `soroush-bench:latest`| Sandbox image tag. |
|
|
100
|
+
| `--mount` | — | Extra `host:container[:ro]` volume, passed through to `docker -v`. Repeatable. |
|
|
101
|
+
|
|
102
|
+
### pnpm workspaces
|
|
103
|
+
|
|
104
|
+
pnpm links workspace packages with **absolute** symlinks. Inside the sandbox
|
|
105
|
+
those only resolve if the repo is also mounted at its host-visible path. With
|
|
106
|
+
Docker Desktop that path is `/mnt/host/<drive>/…`, so to benchmark a local
|
|
107
|
+
workspace package add:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
bench ./examples/bench/styled-system-color.bench.ts \
|
|
111
|
+
--cpuset 0,2 --cpus 2 --memory 1g \
|
|
112
|
+
--mount "$PWD:/mnt/host/c/Users/you/your-repo:ro"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
(npm/yarn flat `node_modules` need no extra mount — the package is copied, not
|
|
116
|
+
symlinked out of the repo.)
|
|
117
|
+
|
|
118
|
+
### Pinning on hybrid CPUs (P/E cores) & Docker Desktop
|
|
119
|
+
|
|
120
|
+
On Intel 12th–14th gen (P-cores + E-cores), prefer a pin that avoids SMT
|
|
121
|
+
siblings: `--cpuset 0` (a single thread — most reproducible, and enough since a
|
|
122
|
+
mitata run is single-threaded) or `--cpuset 0,2` (two **distinct** physical
|
|
123
|
+
cores), not `0-1` (which is the two hyperthreads of one core → SMT contention).
|
|
124
|
+
Confirm a pin with `cat /sys/devices/system/cpu/cpu<N>/topology/thread_siblings_list`.
|
|
125
|
+
|
|
126
|
+
Under **Docker Desktop (WSL2)** the hybrid topology is virtualized — `lscpu`
|
|
127
|
+
reports a flat layout with no P/E distinction — so `--cpuset` selects VM vCPUs
|
|
128
|
+
and the Windows scheduler/Thread Director decides their physical placement.
|
|
129
|
+
Physical P-core residency is therefore **not** guaranteed here; for that, use
|
|
130
|
+
native Linux Docker, or pin at the `.wslconfig`/Windows-affinity layer.
|
|
131
|
+
|
|
132
|
+
For flat run-to-run numbers the biggest lever is locking the clock: cap power in
|
|
133
|
+
BIOS (PL1/PL2) or disable Turbo (BIOS, or Windows power plan max state 99%), so a
|
|
134
|
+
thermal/turbo spike mid-run can't skew a sample.
|
|
135
|
+
|
|
136
|
+
## API
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import defineBench, { type BenchConfig, type BenchContext } from '@soroush.tech/bench'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`defineBench(config)` validates and freezes the config. It requires a non-empty
|
|
143
|
+
`name` and at least two `cases`; each case is `(ctx: BenchContext) => unknown`.
|
package/dist/bin.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
//#region src/docker.ts
|
|
6
|
+
/** Argv for `docker build` of the sandbox image. */
|
|
7
|
+
function buildImageArgs(opts) {
|
|
8
|
+
return [
|
|
9
|
+
"build",
|
|
10
|
+
"-t",
|
|
11
|
+
opts.imageTag,
|
|
12
|
+
opts.contextDir
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Argv for the pinned `docker run`. The pinning flags — single-core
|
|
17
|
+
* `--cpuset-cpus`, a hard `--cpus` quota, and `--memory` with `--memory-swap`
|
|
18
|
+
* set equal (swap off) — are what make timings stable run-to-run regardless of
|
|
19
|
+
* host load.
|
|
20
|
+
*/
|
|
21
|
+
function buildRunArgs(opts) {
|
|
22
|
+
return [
|
|
23
|
+
"run",
|
|
24
|
+
"--rm",
|
|
25
|
+
"--cpuset-cpus",
|
|
26
|
+
opts.cpuset,
|
|
27
|
+
"--cpus",
|
|
28
|
+
String(opts.cpus),
|
|
29
|
+
"--memory",
|
|
30
|
+
opts.memory,
|
|
31
|
+
"--memory-swap",
|
|
32
|
+
opts.memory,
|
|
33
|
+
"--tmpfs",
|
|
34
|
+
"/bench",
|
|
35
|
+
"-v",
|
|
36
|
+
`${opts.repoDir}:/repo:ro`,
|
|
37
|
+
"-v",
|
|
38
|
+
`${opts.appDir}:/app:ro`,
|
|
39
|
+
...opts.extraMounts.flatMap((mount) => ["-v", mount]),
|
|
40
|
+
"-w",
|
|
41
|
+
"/repo",
|
|
42
|
+
opts.imageTag,
|
|
43
|
+
"tsx",
|
|
44
|
+
"/app/dist/harness.mjs",
|
|
45
|
+
`/repo/${opts.benchRelPath}`
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
/** Builds the image then runs the pinned benchmark; throws on a non-zero exit. */
|
|
49
|
+
async function runSandbox(opts, exec) {
|
|
50
|
+
const buildCode = await exec("docker", buildImageArgs(opts));
|
|
51
|
+
if (buildCode !== 0) throw new Error(`docker build failed with exit code ${buildCode}`);
|
|
52
|
+
const runCode = await exec("docker", buildRunArgs(opts));
|
|
53
|
+
if (runCode !== 0) throw new Error(`benchmark run failed with exit code ${runCode}`);
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/cli.ts
|
|
57
|
+
const DEFAULTS = {
|
|
58
|
+
cpus: 1,
|
|
59
|
+
cpuset: "0",
|
|
60
|
+
memory: "512m",
|
|
61
|
+
imageTag: "soroush-bench:latest"
|
|
62
|
+
};
|
|
63
|
+
function requireValue(value, flag) {
|
|
64
|
+
if (value === void 0) throw new Error(`bench: ${flag} requires a value`);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
function parseCpus(value) {
|
|
68
|
+
const cpus = Number(requireValue(value, "--cpus"));
|
|
69
|
+
if (!Number.isFinite(cpus) || cpus <= 0) throw new Error("bench: --cpus must be a positive number");
|
|
70
|
+
return cpus;
|
|
71
|
+
}
|
|
72
|
+
/** Parses `bench <file> [--cpus N] [--cpuset C] [--memory M] [--tag T]`. */
|
|
73
|
+
function parseCliArgs(argv) {
|
|
74
|
+
const opts = { ...DEFAULTS };
|
|
75
|
+
const mounts = [];
|
|
76
|
+
let benchFile;
|
|
77
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
78
|
+
const arg = argv[i];
|
|
79
|
+
switch (arg) {
|
|
80
|
+
case "--cpu":
|
|
81
|
+
case "--cpus":
|
|
82
|
+
opts.cpus = parseCpus(argv[i += 1]);
|
|
83
|
+
break;
|
|
84
|
+
case "--cpuset":
|
|
85
|
+
opts.cpuset = requireValue(argv[i += 1], "--cpuset");
|
|
86
|
+
break;
|
|
87
|
+
case "--memory":
|
|
88
|
+
opts.memory = requireValue(argv[i += 1], "--memory");
|
|
89
|
+
break;
|
|
90
|
+
case "--tag":
|
|
91
|
+
opts.imageTag = requireValue(argv[i += 1], "--tag");
|
|
92
|
+
break;
|
|
93
|
+
case "--mount":
|
|
94
|
+
mounts.push(requireValue(argv[i += 1], "--mount"));
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
if (arg.startsWith("--")) throw new Error(`bench: unknown option ${arg}`);
|
|
98
|
+
if (benchFile !== void 0) throw new Error("bench: only one bench file may be given");
|
|
99
|
+
benchFile = arg;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (benchFile === void 0) throw new Error("bench: a bench file path is required");
|
|
103
|
+
return {
|
|
104
|
+
benchFile,
|
|
105
|
+
...opts,
|
|
106
|
+
mounts
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Maps parsed options + host paths into the concrete sandbox run options. */
|
|
110
|
+
function resolveSandboxOptions(cli, cwd, packageRoot) {
|
|
111
|
+
const rel = relative(cwd, resolve(cwd, cli.benchFile));
|
|
112
|
+
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("bench: the bench file must live inside the current directory");
|
|
113
|
+
return {
|
|
114
|
+
imageTag: cli.imageTag,
|
|
115
|
+
contextDir: packageRoot,
|
|
116
|
+
repoDir: cwd,
|
|
117
|
+
appDir: packageRoot,
|
|
118
|
+
benchRelPath: rel.split(sep).join("/"),
|
|
119
|
+
cpuset: cli.cpuset,
|
|
120
|
+
cpus: cli.cpus,
|
|
121
|
+
memory: cli.memory,
|
|
122
|
+
extraMounts: cli.mounts
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Parses argv, resolves the run, and executes it in the pinned sandbox. */
|
|
126
|
+
async function main(argv, deps) {
|
|
127
|
+
await runSandbox(resolveSandboxOptions(parseCliArgs(argv), deps.cwd, deps.packageRoot), deps.exec);
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/bin.ts
|
|
131
|
+
/** Real process runner: inherits stdio so Docker/mitata output streams through. */
|
|
132
|
+
const exec = (command, args) => new Promise((resolvePromise, reject) => {
|
|
133
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
134
|
+
child.on("error", reject);
|
|
135
|
+
child.on("close", (code) => resolvePromise(code ?? 0));
|
|
136
|
+
});
|
|
137
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
138
|
+
main(process.argv.slice(2), {
|
|
139
|
+
cwd: process.cwd(),
|
|
140
|
+
packageRoot,
|
|
141
|
+
exec
|
|
142
|
+
}).catch((error) => {
|
|
143
|
+
console.error(error instanceof Error ? error.message : error);
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
});
|
|
146
|
+
//#endregion
|
|
147
|
+
export {};
|