hologit
Version:
Hologit automates the projection of layered composite file trees based on flat, declarative plans
184 lines (157 loc) • 7.49 kB
JavaScript
// Benchmark harness for @hologit/holo-tree.
//
// Canonical workload (the reference shape from the gitsheets spike: an
// 18k-record flat tree — see issue #464):
//
// 1. bulk-load N records into one flat directory, write, commit, advance ref
// 2. single-record upsert→commit against the big tree
// 3. 500-record batch upsert→commit
// 4. read paths: full getBlobMap, and repeated deep readBlob
//
// Usage:
// npm run bench # requires a RELEASE build (npm run build)
// node bench/holo-tree-bench.mjs --records 18000 --json
// node bench/holo-tree-bench.mjs --allow-debug # only to demo the penalty
//
// Debug builds are refused by default: a debug binding measures SLOWER than
// the JS + git-subprocess path it replaces (~2.4x on this workload), so any
// number it produces is disinformation. See README § Performance.
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { parseArgs } from 'node:util';
const require = createRequire(import.meta.url);
let binding;
try {
binding = require('../index.js');
} catch (err) {
console.error(`holo-tree-napi addon not built — run \`npm run build\` first.\n cause: ${err.message}`);
process.exit(1);
}
const { Repo } = binding;
const { values: opts } = parseArgs({
options: {
records: { type: 'string', default: process.env.HOLO_TREE_BENCH_RECORDS ?? '18000' },
iterations: { type: 'string', default: '20' },
json: { type: 'boolean', default: false },
'allow-debug': { type: 'boolean', default: false },
},
});
const N = Number(opts.records);
const ITERS = Number(opts.iterations);
// ── build-profile guard ─────────────────────────────────────────────────────
const profile = typeof binding.buildProfile === 'function' ? binding.buildProfile() : 'unknown';
if (profile !== 'release' && !opts['allow-debug']) {
console.error(
`refusing to benchmark a ${profile} build — its numbers invert the real story ` +
'(debug is slower than the JS path this binding replaces).\n' +
'Build with `npm run build` first, or pass --allow-debug to demo the penalty.',
);
process.exit(1);
}
// ── scratch repo ────────────────────────────────────────────────────────────
function git(cwd, ...args) {
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
}
const dir = mkdtempSync(join(tmpdir(), 'holo-tree-bench-'));
git(dir, 'init', '-q', '--bare');
process.on('exit', () => rmSync(dir, { recursive: true, force: true }));
const repo = Repo.open(dir);
const REF = 'refs/heads/bench';
const SIG = { name: 'Bench', email: 'bench@example.com' };
const record = (i, rev = 0) =>
`id = ${i}\nrev = ${rev}\nname = "Record ${i}"\nemail = "record-${i}@example.com"\nactive = ${i % 2 === 0}\n`;
const recordPath = (i) => `people/${String(i).padStart(6, '0')}.toml`;
// ── timing helpers ──────────────────────────────────────────────────────────
const ms = (t0, t1) => Number(t1 - t0) / 1e6;
function bench(name, iters, fn) {
fn(-1); // warmup, not recorded
const samples = [];
for (let i = 0; i < iters; i++) {
const t0 = process.hrtime.bigint();
fn(i);
samples.push(ms(t0, process.hrtime.bigint()));
}
samples.sort((a, b) => a - b);
const median = samples[Math.floor(samples.length / 2)];
const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
return { name, iters, min: samples[0], median, mean, max: samples[samples.length - 1] };
}
// ── 1. bulk load: N records → write → commit → ref advance ─────────────────
let rev = 0;
const t0 = process.hrtime.bigint();
{
const tree = repo.createTree();
for (let i = 0; i < N; i++) tree.writeChild(recordPath(i), record(i));
const treeHash = tree.write();
const commit = repo.commitTree(treeHash, [], `seed ${N} records`, SIG, SIG);
repo.updateRef(REF, commit);
}
const bulkLoadMs = ms(t0, process.hrtime.bigint());
// ── 2–3. upsert→commit scenarios (each iteration advances the ref) ─────────
function upsert(count, iter) {
const parent = repo.resolveRef(REF);
const tree = repo.createTreeFromRef(REF);
for (let k = 0; k < count; k++) {
const i = ((iter + 1) * 7919 + k * 104729) % N; // deterministic spread
tree.writeChild(recordPath(i), record(i, ++rev));
}
const treeHash = tree.write();
const commit = repo.commitTree(treeHash, [parent], `upsert ${count} (iter ${iter})`, SIG, SIG);
repo.updateRef(REF, commit, parent); // compare-and-swap
}
const results = [];
results.push(bench('single upsert→commit', ITERS, (i) => upsert(1, i)));
results.push(bench('500-record batch upsert→commit', Math.max(3, Math.floor(ITERS / 4)), (i) => upsert(500, i)));
// ── 4. read paths ───────────────────────────────────────────────────────────
results.push(
bench('getBlobMap (full tree, fresh Tree)', ITERS, () => {
const n = repo.createTreeFromRef(REF).getBlobMap().length;
if (n !== N) throw new Error(`blob map size ${n} != ${N}`);
}),
);
const READS = 200;
results.push(
bench(`readBlob x${READS} (fresh Tree)`, ITERS, (i) => {
const tree = repo.createTreeFromRef(REF);
for (let k = 0; k < READS; k++) {
const idx = (k * 92821 + (i + 1) * 31) % N;
if (tree.readBlob(recordPath(idx)) === null) throw new Error(`missing record ${idx}`);
}
}),
);
const warmTree = repo.createTreeFromRef(REF);
results.push(
bench(`readBlob x${READS} (warm Tree, repeated)`, ITERS, () => {
for (let k = 0; k < READS; k++) {
const idx = (k * 92821) % N;
if (warmTree.readBlob(recordPath(idx)) === null) throw new Error(`missing record ${idx}`);
}
}),
);
// ── report ──────────────────────────────────────────────────────────────────
const meta = {
profile,
records: N,
node: process.version,
platform: `${process.platform}-${process.arch}`,
};
if (opts.json) {
console.log(JSON.stringify({ meta, bulkLoadMs, results }, null, 2));
} else {
console.log(`holo-tree bench — ${meta.platform}, node ${meta.node}, build: ${profile}, records: ${N}`);
if (profile !== 'release') console.log('!! NON-RELEASE BUILD — numbers below are not representative !!');
console.log(`\nbulk load (${N} writeChild + write + commit + ref): ${bulkLoadMs.toFixed(1)}ms\n`);
const pad = Math.max(...results.map((r) => r.name.length));
console.log(`${'scenario'.padEnd(pad)} iters median min mean max`);
for (const r of results) {
console.log(
`${r.name.padEnd(pad)} ${String(r.iters).padStart(5)} ` +
`${r.median.toFixed(2).padStart(6)}ms ${r.min.toFixed(2).padStart(6)}ms ` +
`${r.mean.toFixed(2).padStart(6)}ms ${r.max.toFixed(2).padStart(6)}ms`,
);
}
}