@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 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 {};