mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-25 10:33:21 +00:00
597 lines
24 KiB
JavaScript
597 lines
24 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
import { HashManager } from "@vrtmrz/livesync-commonlib/hashing";
|
|
import { createCorpus } from "./corpus.mjs";
|
|
|
|
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
const PROJECT_ROOT = path.resolve(MODULE_DIR, "../..");
|
|
const DEFAULT_SIZES = [0, 1, 4, 10, 20, 40, 60];
|
|
const DEFAULT_REPEATS = 3;
|
|
const ID_DERIVATION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
const LEGACY_PASSPHRASE = "public-local-chunk-size-benchmark-fixture";
|
|
const CHUNK_MANAGER_WITHOUT_CACHE = {
|
|
getCachedChunk: () => undefined,
|
|
getChunkIDFromCache: () => false,
|
|
};
|
|
|
|
const COMMONLIB_PACKAGE_JSON = fileURLToPath(import.meta.resolve("@vrtmrz/livesync-commonlib/package.json"));
|
|
const COMMONLIB_ROOT = path.dirname(COMMONLIB_PACKAGE_JSON);
|
|
const COMMONLIB_PACKAGE = JSON.parse(await readFile(COMMONLIB_PACKAGE_JSON, "utf8"));
|
|
const { ContentSplitterRabinKarp } = await import(
|
|
pathToFileURL(path.join(COMMONLIB_ROOT, "dist/ContentSplitter/ContentSplitterRabinKarp.js")).href
|
|
);
|
|
const { prepareChunk } = await import(
|
|
pathToFileURL(path.join(COMMONLIB_ROOT, "dist/managers/EntryManager/EntryManagerImpls.js")).href
|
|
);
|
|
|
|
function sha256(bytes) {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
function parseArguments(args) {
|
|
const parsed = { output: undefined, sizes: [...DEFAULT_SIZES], repeats: DEFAULT_REPEATS, help: false };
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const argument = args[index];
|
|
if (argument === "--help" || argument === "-h") {
|
|
parsed.help = true;
|
|
continue;
|
|
}
|
|
const value = args[index + 1];
|
|
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${argument}.`);
|
|
if (argument === "--output") {
|
|
parsed.output = value;
|
|
} else if (argument === "--sizes") {
|
|
parsed.sizes = value.split(",").map((item) => {
|
|
const trimmed = item.trim();
|
|
if (!/^\d+$/u.test(trimmed)) throw new Error(`Invalid customChunkSize value: ${item}`);
|
|
const size = Number(trimmed);
|
|
if (!Number.isSafeInteger(size) || size > 10000) throw new Error(`customChunkSize is out of range: ${item}`);
|
|
return size;
|
|
});
|
|
if (parsed.sizes.length === 0 || new Set(parsed.sizes).size !== parsed.sizes.length) {
|
|
throw new Error("--sizes must contain one or more distinct integers.");
|
|
}
|
|
} else if (argument === "--repeats") {
|
|
if (!/^\d+$/u.test(value)) throw new Error(`Invalid repeat count: ${value}`);
|
|
parsed.repeats = Number(value);
|
|
if (!Number.isSafeInteger(parsed.repeats) || parsed.repeats < 1 || parsed.repeats > 100) {
|
|
throw new Error("--repeats must be an integer from 1 to 100.");
|
|
}
|
|
} else {
|
|
throw new Error(`Unknown argument: ${argument}`);
|
|
}
|
|
index += 1;
|
|
}
|
|
if (!parsed.help && !parsed.output) throw new Error("--output <dir> is required.");
|
|
return parsed;
|
|
}
|
|
|
|
function usage() {
|
|
return "Usage: node utils/chunk-size-benchmark/local.mjs --output <dir> [--sizes 0,1,4,10,20,40,60] [--repeats 3]";
|
|
}
|
|
|
|
function settingsFor(customChunkSize, mode) {
|
|
return {
|
|
chunkSplitterVersion: "v3-rabin-karp",
|
|
customChunkSize,
|
|
minimumChunkSize: 20,
|
|
disableWorkerForGeneratingChunks: true,
|
|
processSmallFilesInUIThread: false,
|
|
encrypt: true,
|
|
E2EEAlgorithm: "v2",
|
|
hashAlg: "xxhash64",
|
|
passphrase: mode === "legacy" ? LEGACY_PASSPHRASE : "",
|
|
idDerivationVersion: mode === "primary" ? 1 : 0,
|
|
idDerivationKey: mode === "primary" ? ID_DERIVATION_KEY : "",
|
|
enableCompression: false,
|
|
useEden: false,
|
|
};
|
|
}
|
|
|
|
async function createCondition(customChunkSize) {
|
|
const primarySettings = settingsFor(customChunkSize, "primary");
|
|
const legacySettings = settingsFor(customChunkSize, "legacy");
|
|
const primarySettingService = { currentSettings: () => primarySettings };
|
|
const legacySettingService = { currentSettings: () => legacySettings };
|
|
const splitter = new ContentSplitterRabinKarp({ settingService: primarySettingService });
|
|
const primaryHashManager = new HashManager({ settingService: primarySettingService });
|
|
const legacyHashManager = new HashManager({ settingService: legacySettingService });
|
|
await Promise.all([splitter.initialised, primaryHashManager.initialise(), legacyHashManager.initialise()]);
|
|
return { customChunkSize, splitter, primaryHashManager, legacyHashManager };
|
|
}
|
|
|
|
async function warmCondition(condition, file) {
|
|
await splitAndHashFile(condition, file, new Map(), new Map());
|
|
}
|
|
|
|
async function loadCorpus(manifest, corpusDirectory) {
|
|
const filesByPath = new Map(manifest.files.map((file) => [file.path, file]));
|
|
const loadedStages = [];
|
|
for (const stage of manifest.stages) {
|
|
const stageDirectory = path.join(corpusDirectory, stage.directory);
|
|
const loadedFiles = [];
|
|
for (const expected of stage.files) {
|
|
const metadata = filesByPath.get(expected.path);
|
|
if (!metadata) throw new Error(`Corpus manifest has no file metadata for ${expected.path}.`);
|
|
const data = await readFile(path.join(stageDirectory, expected.path));
|
|
if (data.byteLength !== expected.bytes || sha256(data) !== expected.sha256) {
|
|
throw new Error(`Corpus file does not match its manifest: ${stage.name}/${expected.path}`);
|
|
}
|
|
loadedFiles.push({ ...metadata, expectedSha256: expected.sha256, data });
|
|
}
|
|
loadedStages.push({ name: stage.name, files: loadedFiles });
|
|
}
|
|
return loadedStages;
|
|
}
|
|
|
|
function decodeChunk(kind, piece) {
|
|
return kind === "markdown" ? Buffer.from(piece, "utf8") : Buffer.from(piece, "base64");
|
|
}
|
|
|
|
function assertSameContentForId(idsToPieces, id, piece, label) {
|
|
const existing = idsToPieces.get(id);
|
|
if (existing !== undefined && existing !== piece) {
|
|
throw new Error(`${label} collision: chunk ID ${id} was produced for different content.`);
|
|
}
|
|
if (existing === undefined) idsToPieces.set(id, piece);
|
|
}
|
|
|
|
async function splitAndHashFile(condition, file, primaryIdContents, legacyIdContents) {
|
|
const blobType = file.kind === "markdown" ? "text/plain" : "application/octet-stream";
|
|
const blob = new Blob([file.data], { type: blobType });
|
|
const entry = { path: file.path, data: blob };
|
|
const primaryChunks = [];
|
|
const primaryCpuStart = process.cpuUsage();
|
|
const primaryStart = process.hrtime.bigint();
|
|
const generator = await condition.splitter.splitContent(entry);
|
|
for await (const piece of generator) {
|
|
const prepared = await prepareChunk(
|
|
{ hashManager: condition.primaryHashManager, chunkManager: CHUNK_MANAGER_WITHOUT_CACHE },
|
|
piece,
|
|
);
|
|
assertSameContentForId(primaryIdContents, prepared.id, piece, "Primary keyed hash");
|
|
primaryChunks.push({ piece, id: prepared.id });
|
|
}
|
|
const primaryElapsedMs = Number(process.hrtime.bigint() - primaryStart) / 1e6;
|
|
const primaryCpu = process.cpuUsage(primaryCpuStart);
|
|
const primaryCpuMs = (primaryCpu.user + primaryCpu.system) / 1000;
|
|
|
|
const legacyCpuStart = process.cpuUsage();
|
|
const legacyStart = process.hrtime.bigint();
|
|
for (const chunk of primaryChunks) {
|
|
const prepared = await prepareChunk(
|
|
{ hashManager: condition.legacyHashManager, chunkManager: CHUNK_MANAGER_WITHOUT_CACHE },
|
|
chunk.piece,
|
|
);
|
|
assertSameContentForId(legacyIdContents, prepared.id, chunk.piece, "Legacy encrypted xxhash64");
|
|
chunk.legacyId = prepared.id;
|
|
}
|
|
const legacyIdElapsedMs = Number(process.hrtime.bigint() - legacyStart) / 1e6;
|
|
const legacyCpu = process.cpuUsage(legacyCpuStart);
|
|
const legacyIdCpuMs = (legacyCpu.user + legacyCpu.system) / 1000;
|
|
|
|
const decoded = primaryChunks.map((chunk) => decodeChunk(file.kind, chunk.piece));
|
|
const reconstructed = Buffer.concat(decoded);
|
|
if (!reconstructed.equals(file.data) || sha256(reconstructed) !== file.expectedSha256) {
|
|
throw new Error(`Splitter output did not reconstruct ${file.path} in the ${file.kind} encoding.`);
|
|
}
|
|
|
|
const chunks = primaryChunks.map((chunk, index) => {
|
|
const raw = decoded[index];
|
|
return {
|
|
id: chunk.id,
|
|
legacyId: chunk.legacyId,
|
|
rawBytes: raw.byteLength,
|
|
storedUtf8Bytes: Buffer.byteLength(chunk.piece, "utf8"),
|
|
dataSha256: sha256(raw),
|
|
idUtf8Bytes: Buffer.byteLength(chunk.id, "utf8"),
|
|
legacyIdUtf8Bytes: Buffer.byteLength(chunk.legacyId, "utf8"),
|
|
piece: chunk.piece,
|
|
};
|
|
});
|
|
const fingerprintInput = chunks.map(({ id, legacyId, rawBytes, dataSha256 }) => [id, legacyId, rawBytes, dataSha256]);
|
|
const chunkFingerprintSha256 = sha256(Buffer.from(JSON.stringify(fingerprintInput), "utf8"));
|
|
return {
|
|
chunks,
|
|
primaryElapsedMs,
|
|
primaryCpuMs,
|
|
legacyIdElapsedMs,
|
|
legacyIdCpuMs,
|
|
chunkFingerprintSha256,
|
|
};
|
|
}
|
|
|
|
function percentile(sortedValues, fraction) {
|
|
if (sortedValues.length === 0) return 0;
|
|
return sortedValues[Math.max(0, Math.ceil(sortedValues.length * fraction) - 1)];
|
|
}
|
|
|
|
function summarizeRawChunkBytes(values) {
|
|
const sorted = [...values].sort((left, right) => left - right);
|
|
return {
|
|
count: sorted.length,
|
|
p50: percentile(sorted, 0.5),
|
|
p90: percentile(sorted, 0.9),
|
|
p95: percentile(sorted, 0.95),
|
|
p99: percentile(sorted, 0.99),
|
|
max: sorted.at(-1) ?? 0,
|
|
};
|
|
}
|
|
|
|
function summariseIdLengths(chunks, key) {
|
|
const lengths = chunks.map((chunk) => Buffer.byteLength(chunk[key], "utf8")).sort((left, right) => left - right);
|
|
const uniqueLengths = new Map();
|
|
for (const chunk of chunks) uniqueLengths.set(chunk[key], Buffer.byteLength(chunk[key], "utf8"));
|
|
const uniqueValues = [...uniqueLengths.values()].sort((left, right) => left - right);
|
|
return {
|
|
occurrences: lengths.length,
|
|
totalUtf8Bytes: lengths.reduce((sum, value) => sum + value, 0),
|
|
minLengthBytes: lengths[0] ?? 0,
|
|
medianLengthBytes: percentile(lengths, 0.5),
|
|
maxLengthBytes: lengths.at(-1) ?? 0,
|
|
uniqueIds: uniqueValues.length,
|
|
uniqueTotalUtf8Bytes: uniqueValues.reduce((sum, value) => sum + value, 0),
|
|
};
|
|
}
|
|
|
|
function uniqueById(chunks, idKey = "id") {
|
|
const unique = new Map();
|
|
for (const chunk of chunks) {
|
|
if (!unique.has(chunk[idKey])) unique.set(chunk[idKey], chunk);
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
function dataBytes(chunks, key) {
|
|
return chunks.reduce((sum, chunk) => sum + chunk[key], 0);
|
|
}
|
|
|
|
function summarizeFile(file, measured, includeChunkArray) {
|
|
const { chunks } = measured;
|
|
const unique = uniqueById(chunks);
|
|
const fileMetric = {
|
|
path: file.path,
|
|
kind: file.kind,
|
|
sourceBytes: file.data.byteLength,
|
|
chunkCount: chunks.length,
|
|
uniqueChunkCount: unique.size,
|
|
storedChunkDataUtf8Bytes: dataBytes(chunks, "storedUtf8Bytes"),
|
|
uniqueChunkDataUtf8Bytes: dataBytes([...unique.values()], "storedUtf8Bytes"),
|
|
uniqueChunkRawBytes: dataBytes([...unique.values()], "rawBytes"),
|
|
rawChunkBytes: summarizeRawChunkBytes(chunks.map((chunk) => chunk.rawBytes)),
|
|
primaryId: summariseIdLengths(chunks, "id"),
|
|
legacyId: summariseIdLengths(chunks, "legacyId"),
|
|
chunkFingerprintSha256: measured.chunkFingerprintSha256,
|
|
timing: {
|
|
primarySplitAndIdElapsedMs: measured.primaryElapsedMs,
|
|
primarySplitAndIdCpuMs: measured.primaryCpuMs,
|
|
legacyIdElapsedMs: measured.legacyIdElapsedMs,
|
|
legacyIdCpuMs: measured.legacyIdCpuMs,
|
|
},
|
|
};
|
|
if (includeChunkArray) {
|
|
fileMetric.chunks = chunks.map(({ id, legacyId, rawBytes, storedUtf8Bytes, dataSha256, idUtf8Bytes, legacyIdUtf8Bytes }) => ({
|
|
id,
|
|
legacyId,
|
|
rawBytes,
|
|
storedUtf8Bytes,
|
|
dataSha256,
|
|
idUtf8Bytes,
|
|
legacyIdUtf8Bytes,
|
|
}));
|
|
}
|
|
return fileMetric;
|
|
}
|
|
|
|
function aggregateKind(stage, kind, fileResults, historyIds, previousIds) {
|
|
const files = fileResults.filter((item) => item.file.kind === kind);
|
|
const chunks = files.flatMap((item) => item.measured.chunks);
|
|
const unique = uniqueById(chunks);
|
|
const uniqueChunks = [...unique.values()];
|
|
const newVsHistory = uniqueChunks.filter((chunk) => !historyIds.has(chunk.id));
|
|
const newVsPrevious = previousIds === null ? null : uniqueChunks.filter((chunk) => !previousIds.has(chunk.id));
|
|
const storedBytes = dataBytes(chunks, "storedUtf8Bytes");
|
|
const primaryIds = summariseIdLengths(chunks, "id");
|
|
const legacyIds = summariseIdLengths(chunks, "legacyId");
|
|
|
|
return {
|
|
kind,
|
|
fileCount: files.length,
|
|
sourceBytes: files.reduce((sum, item) => sum + item.file.data.byteLength, 0),
|
|
chunkCount: chunks.length,
|
|
uniqueChunkCount: unique.size,
|
|
storedChunkDataUtf8Bytes: storedBytes,
|
|
uniqueChunkDataUtf8Bytes: dataBytes(uniqueChunks, "storedUtf8Bytes"),
|
|
uniqueChunkRawBytes: dataBytes(uniqueChunks, "rawBytes"),
|
|
rawChunkBytes: summarizeRawChunkBytes(chunks.map((chunk) => chunk.rawBytes)),
|
|
primaryId: primaryIds,
|
|
legacyId: legacyIds,
|
|
newUniqueChunksVsHistory: newVsHistory.length,
|
|
newUniqueChunkDataUtf8BytesVsHistory: dataBytes(newVsHistory, "storedUtf8Bytes"),
|
|
newUniqueChunkRawBytesVsHistory: dataBytes(newVsHistory, "rawBytes"),
|
|
newUniqueChunksVsPreviousStage: newVsPrevious === null ? null : newVsPrevious.length,
|
|
newUniqueChunkDataUtf8BytesVsPreviousStage: newVsPrevious === null ? null : dataBytes(newVsPrevious, "storedUtf8Bytes"),
|
|
newUniqueChunkRawBytesVsPreviousStage: newVsPrevious === null ? null : dataBytes(newVsPrevious, "rawBytes"),
|
|
primaryElapsedMs: files.reduce((sum, item) => sum + item.measured.primaryElapsedMs, 0),
|
|
primaryCpuMs: files.reduce((sum, item) => sum + item.measured.primaryCpuMs, 0),
|
|
legacyIdElapsedMs: files.reduce((sum, item) => sum + item.measured.legacyIdElapsedMs, 0),
|
|
legacyIdCpuMs: files.reduce((sum, item) => sum + item.measured.legacyIdCpuMs, 0),
|
|
};
|
|
}
|
|
|
|
async function runCondition(condition, stages, repeat, order, includeChunkArray) {
|
|
const primaryIdContents = new Map();
|
|
const legacyIdContents = new Map();
|
|
const historyIds = new Set();
|
|
let previousIds = null;
|
|
const stageResults = [];
|
|
|
|
for (const stage of stages) {
|
|
const fileResults = [];
|
|
for (const file of stage.files) {
|
|
const measured = await splitAndHashFile(condition, file, primaryIdContents, legacyIdContents);
|
|
fileResults.push({ file, measured });
|
|
}
|
|
|
|
const currentIds = new Set();
|
|
for (const item of fileResults) {
|
|
for (const chunk of item.measured.chunks) currentIds.add(chunk.id);
|
|
}
|
|
const kinds = [...new Set(fileResults.map((item) => item.file.kind))].sort();
|
|
const byKind = kinds.map((kind) => aggregateKind(stage, kind, fileResults, historyIds, previousIds));
|
|
const stageFingerprint = fileResults
|
|
.map((item) => `${item.file.path}\0${item.measured.chunkFingerprintSha256}`)
|
|
.sort()
|
|
.join("\n");
|
|
stageResults.push({
|
|
name: stage.name,
|
|
fingerprintSha256: sha256(Buffer.from(stageFingerprint, "utf8")),
|
|
byKind,
|
|
files: fileResults.map((item) => summarizeFile(item.file, item.measured, includeChunkArray)),
|
|
});
|
|
|
|
for (const id of currentIds) historyIds.add(id);
|
|
previousIds = currentIds;
|
|
}
|
|
|
|
return { repeat, conditionOrder: order, stages: stageResults };
|
|
}
|
|
|
|
function median(values) {
|
|
const sorted = [...values].sort((left, right) => left - right);
|
|
if (sorted.length === 0) return 0;
|
|
const middle = Math.floor(sorted.length / 2);
|
|
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
|
|
}
|
|
|
|
function orderForRepeat(sizes, repeat) {
|
|
const offset = repeat % sizes.length;
|
|
return [...sizes.slice(offset), ...sizes.slice(0, offset)];
|
|
}
|
|
|
|
function timingsForRuns(runs, stageName, kind) {
|
|
const samples = runs.map((run) => {
|
|
const stage = run.stages.find((item) => item.name === stageName);
|
|
const group = stage.byKind.find((item) => item.kind === kind);
|
|
return {
|
|
repeat: run.repeat,
|
|
primaryElapsedMs: group.primaryElapsedMs,
|
|
primaryCpuMs: group.primaryCpuMs,
|
|
legacyIdElapsedMs: group.legacyIdElapsedMs,
|
|
legacyIdCpuMs: group.legacyIdCpuMs,
|
|
};
|
|
});
|
|
return {
|
|
primarySplitAndIdElapsedMsMedian: median(samples.map((sample) => sample.primaryElapsedMs)),
|
|
primarySplitAndIdCpuMsMedian: median(samples.map((sample) => sample.primaryCpuMs)),
|
|
legacyIdElapsedMsMedian: median(samples.map((sample) => sample.legacyIdElapsedMs)),
|
|
legacyIdCpuMsMedian: median(samples.map((sample) => sample.legacyIdCpuMs)),
|
|
repeats: samples,
|
|
};
|
|
}
|
|
|
|
function timingsForFile(runs, stageName, filePath) {
|
|
const samples = runs.map((run) => {
|
|
const stage = run.stages.find((item) => item.name === stageName);
|
|
const file = stage.files.find((item) => item.path === filePath);
|
|
return { repeat: run.repeat, ...file.timing };
|
|
});
|
|
return {
|
|
primarySplitAndIdElapsedMsMedian: median(samples.map((sample) => sample.primarySplitAndIdElapsedMs)),
|
|
primarySplitAndIdCpuMsMedian: median(samples.map((sample) => sample.primarySplitAndIdCpuMs)),
|
|
legacyIdElapsedMsMedian: median(samples.map((sample) => sample.legacyIdElapsedMs)),
|
|
legacyIdCpuMsMedian: median(samples.map((sample) => sample.legacyIdCpuMs)),
|
|
repeats: samples,
|
|
};
|
|
}
|
|
|
|
function assertRepeatStability(runs, customChunkSize) {
|
|
const first = runs[0];
|
|
for (const run of runs.slice(1)) {
|
|
for (const stage of first.stages) {
|
|
const currentStage = run.stages.find((item) => item.name === stage.name);
|
|
if (currentStage.fingerprintSha256 !== stage.fingerprintSha256) {
|
|
throw new Error(`Chunk boundaries or IDs changed across repeats for customChunkSize=${customChunkSize}, stage=${stage.name}.`);
|
|
}
|
|
for (const file of stage.files) {
|
|
const currentFile = currentStage.files.find((item) => item.path === file.path);
|
|
if (currentFile.chunkFingerprintSha256 !== file.chunkFingerprintSha256) {
|
|
throw new Error(`Chunk IDs changed across repeats for ${stage.name}/${file.path} at customChunkSize=${customChunkSize}.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function compareCandidateBoundaries(candidates, sizes) {
|
|
const candidate40 = candidates.get(40);
|
|
const candidate60 = candidates.get(60);
|
|
if (!sizes.includes(40) || !sizes.includes(60)) return null;
|
|
for (const stage of candidate40.stages) {
|
|
const otherStage = candidate60.stages.find((item) => item.name === stage.name);
|
|
for (const file of stage.files) {
|
|
const other = otherStage.files.find((item) => item.path === file.path);
|
|
if (file.chunkFingerprintSha256 !== other.chunkFingerprintSha256) {
|
|
throw new Error(`customChunkSize=40 and 60 differ for ${stage.name}/${file.path}.`);
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
customChunkSize40And60SamePerFileBoundariesAndIds: true,
|
|
comparedStages: candidate40.stages.length,
|
|
comparedFilesPerStage: candidate40.stages[0]?.files.length ?? 0,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArguments(process.argv.slice(2));
|
|
if (args.help) {
|
|
process.stdout.write(`${usage()}\n`);
|
|
return;
|
|
}
|
|
|
|
const outputDirectory = path.resolve(args.output);
|
|
const corpusDirectory = path.join(outputDirectory, "corpus");
|
|
const resultPath = path.join(outputDirectory, "results.json");
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
const startedAt = new Date().toISOString();
|
|
if (COMMONLIB_PACKAGE.version !== "0.1.44") {
|
|
throw new Error(`Expected @vrtmrz/livesync-commonlib 0.1.44, found ${COMMONLIB_PACKAGE.version}.`);
|
|
}
|
|
const manifest = await createCorpus(corpusDirectory, PROJECT_ROOT);
|
|
const manifestJson = JSON.stringify(manifest);
|
|
const corpusManifestSha256 = sha256(Buffer.from(manifestJson, "utf8"));
|
|
const stages = await loadCorpus(manifest, corpusDirectory);
|
|
const packageJson = JSON.parse(await readFile(path.join(PROJECT_ROOT, "package.json"), "utf8"));
|
|
|
|
if (ID_DERIVATION_KEY.length !== 64 || !/^[0-9a-f]{64}$/u.test(ID_DERIVATION_KEY)) {
|
|
throw new Error("The benchmark ID derivation fixture must be 64 lower-case hex characters.");
|
|
}
|
|
|
|
const conditions = new Map();
|
|
for (const size of args.sizes) {
|
|
const condition = await createCondition(size);
|
|
const warmupFile = stages[0].files.find((file) => file.kind === "markdown" && file.source.startsWith("repo:"));
|
|
if (!warmupFile) throw new Error("Corpus has no repository Markdown file for warm-up.");
|
|
await warmCondition(condition, warmupFile);
|
|
conditions.set(size, condition);
|
|
}
|
|
|
|
const runsBySize = new Map(args.sizes.map((size) => [size, []]));
|
|
for (let repeat = 0; repeat < args.repeats; repeat += 1) {
|
|
const order = orderForRepeat(args.sizes, repeat);
|
|
for (const size of order) {
|
|
const includeChunkArray = repeat === 0 && (size === 40 || size === 60);
|
|
const run = await runCondition(conditions.get(size), stages, repeat + 1, order, includeChunkArray);
|
|
runsBySize.get(size).push(run);
|
|
}
|
|
}
|
|
|
|
const candidates = new Map();
|
|
for (const size of args.sizes) {
|
|
const runs = runsBySize.get(size);
|
|
assertRepeatStability(runs, size);
|
|
const firstRun = runs[0];
|
|
const detailedStages = firstRun.stages.map((stage) => ({
|
|
name: stage.name,
|
|
fingerprintSha256: stage.fingerprintSha256,
|
|
byKind: stage.byKind.map((group) => {
|
|
const { primaryElapsedMs, primaryCpuMs, legacyIdElapsedMs, legacyIdCpuMs, ...metrics } = group;
|
|
return {
|
|
...metrics,
|
|
timing: timingsForRuns(runs, stage.name, group.kind),
|
|
};
|
|
}),
|
|
files: stage.files.map(({ timing, ...file }) => ({
|
|
...file,
|
|
timing: timingsForFile(runs, stage.name, file.path),
|
|
})),
|
|
}));
|
|
const candidate = {
|
|
customChunkSize: size,
|
|
chunkSizeCeilingBytes: Math.floor(102400 * (size + 1)),
|
|
stages: detailedStages,
|
|
runs: runs.map((run) => ({
|
|
repeat: run.repeat,
|
|
conditionOrder: run.conditionOrder,
|
|
stages: run.stages.map((stage) => ({ name: stage.name, fingerprintSha256: stage.fingerprintSha256 })),
|
|
})),
|
|
};
|
|
candidates.set(size, candidate);
|
|
}
|
|
|
|
const comparisons = compareCandidateBoundaries(candidates, args.sizes);
|
|
const results = {
|
|
schemaVersion: 1,
|
|
benchmark: "local Rabin-Karp splitting and chunk-ID measurement",
|
|
startedAt,
|
|
completedAt: new Date().toISOString(),
|
|
runtime: {
|
|
node: process.version,
|
|
v8: process.versions.v8,
|
|
platform: process.platform,
|
|
architecture: process.arch,
|
|
osRelease: os.release(),
|
|
cpuModel: os.cpus()[0]?.model ?? "unknown",
|
|
logicalCpuCount: os.cpus().length,
|
|
projectVersion: packageJson.version,
|
|
commonlibVersion: COMMONLIB_PACKAGE.version,
|
|
variant: process.env.LIVESYNC_BENCH_VARIANT || "unlabelled",
|
|
commonlibChunksSha256: sha256(await readFile(path.join(COMMONLIB_ROOT, "dist/string_and_binary/chunks.js"))),
|
|
},
|
|
settings: {
|
|
splitter: "v3-rabin-karp",
|
|
minimumChunkSize: 20,
|
|
workerDisabled: true,
|
|
E2EEAlgorithm: "v2",
|
|
encrypt: true,
|
|
hashAlg: "xxhash64",
|
|
primaryIdDerivation: {
|
|
version: 1,
|
|
keyFixture: "fixed public benchmark fixture; 64 lower-case hex characters omitted",
|
|
},
|
|
legacyIdComparison: {
|
|
idDerivationVersion: 0,
|
|
algorithm: "encrypted xxhash64 with the fixed public legacy passphrase fixture",
|
|
passphrase: "omitted",
|
|
},
|
|
enableCompression: false,
|
|
useEden: false,
|
|
customChunkSizeValues: args.sizes,
|
|
warmup: "one actual repository Markdown file per condition; excluded from timings",
|
|
timings: "primary includes splitting and actual EntryManager prepareChunk hashing; legacy timing hashes the same emitted chunks separately",
|
|
},
|
|
corpus: {
|
|
seed: manifest.seed,
|
|
manifestSha256: corpusManifestSha256,
|
|
fileCount: manifest.files.length,
|
|
stages: manifest.stages.map((stage) => ({
|
|
name: stage.name,
|
|
directory: stage.directory,
|
|
fileCount: stage.files.length,
|
|
sourceBytes: stage.files.reduce((sum, file) => sum + file.bytes, 0),
|
|
})),
|
|
},
|
|
assertions: {
|
|
everyFileReconstructedFromSplitterOutput: true,
|
|
duplicateIdsHadIdenticalChunkContent: true,
|
|
sameBoundariesAndIdsAcrossRepeats: true,
|
|
...(comparisons ?? {}),
|
|
},
|
|
candidates: [...candidates.values()],
|
|
scope: "Local splitting and hashing only; no network or request timing is measured.",
|
|
};
|
|
|
|
await writeFile(resultPath, `${JSON.stringify(results, null, 2)}\n`);
|
|
process.stdout.write(`${JSON.stringify({ resultPath, corpusManifestSha256, candidateCount: args.sizes.length, repeats: args.repeats })}\n`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
});
|