fs-safe/scripts/benchmark.mjs
2026-05-05 19:50:19 +01:00

303 lines
8.7 KiB
JavaScript

#!/usr/bin/env node
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { performance } from "node:perf_hooks";
const DEFAULT_ITERATIONS = 1000;
const DEFAULT_SAMPLES = 1;
const DEFAULT_WARMUP = 25;
const BYTES_PER_PAYLOAD = 128;
function parsePositiveInteger(value, fallback) {
if (value === undefined) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function parseArgs(argv) {
const args = {
iterations: parsePositiveInteger(process.env.FS_SAFE_BENCHMARK_ITERATIONS, DEFAULT_ITERATIONS),
samples: parsePositiveInteger(process.env.FS_SAFE_BENCHMARK_SAMPLES, DEFAULT_SAMPLES),
warmup: parsePositiveInteger(process.env.FS_SAFE_BENCHMARK_WARMUP, DEFAULT_WARMUP),
json: process.env.FS_SAFE_BENCHMARK_JSON,
markdown: process.env.FS_SAFE_BENCHMARK_MARKDOWN,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--") {
continue;
} else if (arg === "--iterations") {
args.iterations = parsePositiveInteger(argv[++i], args.iterations);
} else if (arg === "--samples") {
args.samples = parsePositiveInteger(argv[++i], args.samples);
} else if (arg === "--warmup") {
args.warmup = parsePositiveInteger(argv[++i], args.warmup);
} else if (arg === "--json") {
args.json = argv[++i];
} else if (arg === "--markdown") {
args.markdown = argv[++i];
} else {
throw new Error(`Unknown benchmark argument: ${arg}`);
}
}
return args;
}
function mean(values) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}
function formatMs(value) {
return value.toFixed(2);
}
function formatRatio(value) {
return value.toFixed(2);
}
async function ensureDistIsBuilt() {
const required = ["dist/root.js", "dist/regular-file.js", "dist/atomic.js", "dist/json.js"];
const missing = required.filter((filePath) => !fsSync.existsSync(filePath));
if (missing.length > 0) {
throw new Error(`Benchmark needs built dist files. Run pnpm build first. Missing: ${missing.join(", ")}`);
}
}
async function timeCase(params) {
for (let i = 0; i < params.warmup; i += 1) {
await params.run(i);
}
const sampleMs = [];
for (let sample = 0; sample < params.samples; sample += 1) {
await params.beforeSample?.(sample);
const startedAt = performance.now();
for (let i = 0; i < params.iterations; i += 1) {
await params.run(i);
}
sampleMs.push(performance.now() - startedAt);
}
return {
group: params.group,
name: params.name,
baseline: params.baseline,
iterations: params.iterations,
samples: params.samples,
sampleMs,
bestMs: Math.min(...sampleMs),
medianMs: median(sampleMs),
meanMs: mean(sampleMs),
};
}
function renderMarkdown(metadata, results) {
const baselineByGroup = new Map();
for (const result of results) {
if (result.baseline) {
baselineByGroup.set(result.group, result.bestMs);
}
}
const lines = [
"# fs-safe benchmark",
"",
`Report-only microbenchmark. Each row times ${metadata.iterations} sequential iterations; lower is better.`,
"",
`Node ${metadata.node} on ${metadata.platform}/${metadata.arch}. Samples per case: ${metadata.samples}.`,
"",
"| Group | Case | Best ms | Median ms | Mean ms | vs raw best | Samples |",
"|---|---:|---:|---:|---:|---:|---|",
];
for (const result of results) {
const baseline = baselineByGroup.get(result.group) ?? result.bestMs;
const ratio = baseline > 0 ? result.bestMs / baseline : 1;
lines.push(
`| ${result.group} | ${result.name} | ${formatMs(result.bestMs)} | ${formatMs(result.medianMs)} | ${formatMs(result.meanMs)} | ${formatRatio(ratio)}x | ${result.sampleMs.map(formatMs).join(", ")} |`,
);
}
return `${lines.join("\n")}\n`;
}
async function writeFileEnsuringDir(filePath, content) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content);
}
async function main() {
await ensureDistIsBuilt();
const [
{ replaceFileAtomic },
{ readRegularFile },
{ root },
{ tryReadJson, writeJson },
] = await Promise.all([
import("../dist/atomic.js"),
import("../dist/regular-file.js"),
import("../dist/root.js"),
import("../dist/json.js"),
]);
const args = parseArgs(process.argv.slice(2));
const iterations = args.iterations;
const samples = args.samples;
const warmup = Math.min(args.warmup, iterations);
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "fs-safe-benchmark-"));
const payload = Buffer.from("x".repeat(BYTES_PER_PAYLOAD));
const jsonPayload = { ok: true, count: 42, label: "fs-safe benchmark" };
const jsonText = `${JSON.stringify(jsonPayload, null, 2)}\n`;
try {
const safe = await root(workspace, { mkdir: true, hardlinks: "allow" });
const readPath = path.join(workspace, "read.txt");
const readRelPath = "read.txt";
const jsonPath = path.join(workspace, "state.json");
await fs.writeFile(readPath, payload);
await fs.writeFile(jsonPath, jsonText);
const cases = [
{
group: "read file",
name: "raw fs.readFile",
baseline: true,
run: async () => {
await fs.readFile(readPath);
},
},
{
group: "read file",
name: "readRegularFile",
run: async () => {
await readRegularFile({ filePath: readPath });
},
},
{
group: "read file",
name: "root.readBytes",
run: async () => {
await safe.readBytes(readRelPath);
},
},
{
group: "write file",
name: "raw fs.writeFile",
baseline: true,
run: async (i) => {
await fs.writeFile(path.join(workspace, "raw-write.txt"), `${i}:${payload.toString("utf8")}`);
},
},
{
group: "write file",
name: "replaceFileAtomic",
run: async (i) => {
await replaceFileAtomic({
filePath: path.join(workspace, "atomic-write.txt"),
content: `${i}:${payload.toString("utf8")}`,
});
},
},
{
group: "write file",
name: "root.write",
run: async (i) => {
await safe.write("root-write.txt", `${i}:${payload.toString("utf8")}`);
},
},
{
group: "read json",
name: "raw readFile + JSON.parse",
baseline: true,
run: async () => {
JSON.parse(await fs.readFile(jsonPath, "utf8"));
},
},
{
group: "read json",
name: "tryReadJson",
run: async () => {
await tryReadJson(jsonPath);
},
},
{
group: "write json",
name: "raw writeFile + stringify",
baseline: true,
run: async (i) => {
await fs.writeFile(
path.join(workspace, "raw-json.json"),
`${JSON.stringify({ ...jsonPayload, count: i }, null, 2)}\n`,
);
},
},
{
group: "write json",
name: "writeJson",
run: async (i) => {
await writeJson(path.join(workspace, "safe-json.json"), { ...jsonPayload, count: i }, {
trailingNewline: true,
});
},
},
];
const results = [];
for (const benchCase of cases) {
console.error(`benchmark: ${benchCase.group} / ${benchCase.name}`);
const result = await timeCase({
...benchCase,
iterations,
samples,
warmup,
});
console.error(`benchmark: ${benchCase.name} best=${formatMs(result.bestMs)}ms`);
results.push(result);
}
const metadata = {
iterations,
samples,
warmup,
payloadBytes: payload.byteLength,
node: process.version,
platform: process.platform,
arch: process.arch,
date: new Date().toISOString(),
};
const output = {
metadata,
results,
};
const markdown = renderMarkdown(metadata, results);
process.stdout.write(markdown);
if (args.json) {
await writeFileEnsuringDir(args.json, `${JSON.stringify(output, null, 2)}\n`);
}
if (args.markdown) {
await writeFileEnsuringDir(args.markdown, markdown);
}
if (process.env.GITHUB_STEP_SUMMARY) {
await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, `\n${markdown}`);
}
} finally {
await fs.rm(workspace, { recursive: true, force: true }).catch(() => undefined);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});