chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
1 parent
719370c8f7
commit
6081e3e325
1743 files changed
+205459
-131704
No files matched your search
@@ -0,0 +1,210 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type Mode = "tracker";
|
||||
type Scenario =
|
||||
| "import"
|
||||
| "committed-read"
|
||||
| "draft-read"
|
||||
| "sparse"
|
||||
| "queue"
|
||||
| "sort"
|
||||
| "dense"
|
||||
| "unshift"
|
||||
| "watch-authority-control"
|
||||
| "watch-live-borrowed"
|
||||
| "watch-acquire-shared"
|
||||
| "watch-acquire-detached"
|
||||
| "watch-acquire-staggered"
|
||||
| "watch-lag"
|
||||
| "watch-catch-up"
|
||||
| "watch-catch-up-coalesced"
|
||||
| "watch-live-immutable";
|
||||
type Result = {
|
||||
mode: Mode;
|
||||
scenario: Scenario;
|
||||
size: number;
|
||||
trial: number;
|
||||
queueOperations: number;
|
||||
watchProfile: string | null;
|
||||
watchCount: number;
|
||||
watchUpdates: number;
|
||||
queuedBatchReferences: number;
|
||||
importMs: number;
|
||||
mutateMs: number;
|
||||
prepareMs: number;
|
||||
serializeMs: number;
|
||||
adoptMs: number;
|
||||
acquireMs: number;
|
||||
enqueueMs: number;
|
||||
deliveryMs: number;
|
||||
scenarioWorkMs: number;
|
||||
opBytes: number;
|
||||
operationCount: number;
|
||||
checksum: number;
|
||||
readyMiB: number;
|
||||
sampledAcquireMiB: number;
|
||||
sampledTransientMiB: number;
|
||||
retainedMiB: number;
|
||||
retainedOverReadyMiB: number;
|
||||
postAcquireRetainedMiB: number;
|
||||
postLagRetainedMiB: number;
|
||||
postDeliveryRetainedMiB: number;
|
||||
releasedRetainedMiB: number;
|
||||
maxRssMiB: number;
|
||||
};
|
||||
|
||||
type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string };
|
||||
|
||||
const ordinaryScenarios: readonly Scenario[] = [
|
||||
"import",
|
||||
"committed-read",
|
||||
"draft-read",
|
||||
"sparse",
|
||||
"queue",
|
||||
"sort",
|
||||
"dense",
|
||||
"unshift",
|
||||
];
|
||||
const watchScenarios: readonly Scenario[] = [
|
||||
"watch-authority-control",
|
||||
"watch-live-borrowed",
|
||||
"watch-acquire-shared",
|
||||
"watch-acquire-detached",
|
||||
"watch-acquire-staggered",
|
||||
"watch-lag",
|
||||
"watch-catch-up",
|
||||
"watch-catch-up-coalesced",
|
||||
"watch-live-immutable",
|
||||
];
|
||||
const modes: readonly Mode[] = ["tracker"];
|
||||
const quick = process.argv.includes("--quick");
|
||||
const watchOnly = process.argv.includes("--watch-only");
|
||||
const ordinaryOnly = process.argv.includes("--ordinary-only");
|
||||
if (watchOnly && ordinaryOnly) throw new Error("Choose at most one scenario filter");
|
||||
const scenarios = watchOnly
|
||||
? watchScenarios
|
||||
: ordinaryOnly
|
||||
? ordinaryScenarios
|
||||
: [...ordinaryScenarios, ...watchScenarios];
|
||||
const size = quick ? 10_000 : 100_000;
|
||||
const trials = quick ? 1 : 3;
|
||||
const watchCount = quick ? 4 : 8;
|
||||
const watchUpdates = quick ? 20 : 100;
|
||||
const root = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const worker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url));
|
||||
const results: Result[] = [];
|
||||
const failures: Failure[] = [];
|
||||
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
for (const scenario of scenarios) {
|
||||
for (const mode of modes) {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--expose-gc",
|
||||
"--max-old-space-size=4096",
|
||||
worker,
|
||||
mode,
|
||||
scenario,
|
||||
String(size),
|
||||
String(trial),
|
||||
String(watchCount),
|
||||
String(watchUpdates),
|
||||
],
|
||||
{ cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
if (child.status !== 0) {
|
||||
const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure;
|
||||
failures.push(failure);
|
||||
console.log(JSON.stringify(failure));
|
||||
} else {
|
||||
const result = JSON.parse(child.stdout) as Result;
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const average = (values: readonly number[]): number | null =>
|
||||
values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length;
|
||||
const median = (values: readonly number[]): number | null => {
|
||||
if (values.length === 0) return null;
|
||||
const ordered = [...values].sort((left, right) => left - right);
|
||||
return ordered[Math.floor(ordered.length / 2)]!;
|
||||
};
|
||||
const range = (values: readonly number[]): readonly [number, number] | null =>
|
||||
values.length === 0 ? null : [Math.min(...values), Math.max(...values)];
|
||||
const metricNames = [
|
||||
"importMs",
|
||||
"mutateMs",
|
||||
"prepareMs",
|
||||
"serializeMs",
|
||||
"adoptMs",
|
||||
"acquireMs",
|
||||
"enqueueMs",
|
||||
"deliveryMs",
|
||||
"scenarioWorkMs",
|
||||
"opBytes",
|
||||
"operationCount",
|
||||
"readyMiB",
|
||||
"sampledAcquireMiB",
|
||||
"sampledTransientMiB",
|
||||
"retainedMiB",
|
||||
"retainedOverReadyMiB",
|
||||
"postAcquireRetainedMiB",
|
||||
"postLagRetainedMiB",
|
||||
"postDeliveryRetainedMiB",
|
||||
"releasedRetainedMiB",
|
||||
"maxRssMiB",
|
||||
] as const satisfies readonly (keyof Result)[];
|
||||
|
||||
const summary = scenarios.flatMap((scenario) =>
|
||||
modes.map((mode) => {
|
||||
const selected = results.filter((result) => result.mode === mode && result.scenario === scenario);
|
||||
const averages: Record<string, number | null> = {};
|
||||
const medians: Record<string, number | null> = {};
|
||||
const ranges: Record<string, readonly [number, number] | null> = {};
|
||||
for (const metric of metricNames) {
|
||||
const values = selected.map((result) => result[metric]);
|
||||
averages[metric] = average(values);
|
||||
medians[metric] = median(values);
|
||||
ranges[metric] = range(values);
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
scenario,
|
||||
watchProfile: selected[0]?.watchProfile ?? null,
|
||||
watchCount: selected[0]?.watchCount ?? 0,
|
||||
watchUpdates: selected[0]?.watchUpdates ?? 0,
|
||||
averages,
|
||||
medians,
|
||||
ranges,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const output = join(tmpdir(), "chord-delta-benchmark.json");
|
||||
writeFileSync(
|
||||
output,
|
||||
JSON.stringify(
|
||||
{ node: process.version, size, trials, watchCount, watchUpdates, results, failures, summary },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
output,
|
||||
node: process.version,
|
||||
size,
|
||||
trials,
|
||||
watchCount,
|
||||
watchUpdates,
|
||||
failures: failures.length,
|
||||
summary,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,436 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import type { Draft } from "../../src/delta/draft.ts";
|
||||
import { applyImmutable, type Op, track } from "../../src/delta/index.ts";
|
||||
|
||||
type Mode = "tracker";
|
||||
type Scenario =
|
||||
| "import"
|
||||
| "committed-read"
|
||||
| "draft-read"
|
||||
| "sparse"
|
||||
| "queue"
|
||||
| "sort"
|
||||
| "dense"
|
||||
| "unshift"
|
||||
| "watch-authority-control"
|
||||
| "watch-live-borrowed"
|
||||
| "watch-acquire-shared"
|
||||
| "watch-acquire-detached"
|
||||
| "watch-acquire-staggered"
|
||||
| "watch-lag"
|
||||
| "watch-catch-up"
|
||||
| "watch-catch-up-coalesced"
|
||||
| "watch-live-immutable";
|
||||
type Row = { id: number; value: number; payload: string };
|
||||
type Document = { rows: Row[] };
|
||||
type PreparedLike = { readonly value: Readonly<Document>; readonly ops: readonly Op[] };
|
||||
type ChangeLike = { readonly state: Draft<Document>; prepare(): PreparedLike };
|
||||
type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void };
|
||||
type Frame = { readonly sequence: number; readonly ops: readonly Op[] };
|
||||
type WatchReplica = {
|
||||
value: Document;
|
||||
readonly baseSequence: number;
|
||||
sequence: number;
|
||||
readonly queue: Frame[];
|
||||
};
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const MODES = new Set<Mode>(["tracker"]);
|
||||
const SCENARIOS = new Set<Scenario>([
|
||||
"import",
|
||||
"committed-read",
|
||||
"draft-read",
|
||||
"sparse",
|
||||
"queue",
|
||||
"sort",
|
||||
"dense",
|
||||
"unshift",
|
||||
"watch-authority-control",
|
||||
"watch-live-borrowed",
|
||||
"watch-acquire-shared",
|
||||
"watch-acquire-detached",
|
||||
"watch-acquire-staggered",
|
||||
"watch-lag",
|
||||
"watch-catch-up",
|
||||
"watch-catch-up-coalesced",
|
||||
"watch-live-immutable",
|
||||
]);
|
||||
const WATCH_SCENARIOS = new Set<Scenario>([
|
||||
"watch-authority-control",
|
||||
"watch-live-borrowed",
|
||||
"watch-acquire-shared",
|
||||
"watch-acquire-detached",
|
||||
"watch-acquire-staggered",
|
||||
"watch-lag",
|
||||
"watch-catch-up",
|
||||
"watch-catch-up-coalesced",
|
||||
"watch-live-immutable",
|
||||
]);
|
||||
const mode = process.argv[2] as Mode;
|
||||
const scenario = process.argv[3] as Scenario;
|
||||
if (!MODES.has(mode)) throw new Error(`Unknown benchmark mode ${mode}`);
|
||||
if (!SCENARIOS.has(scenario)) throw new Error(`Unknown benchmark scenario ${scenario}`);
|
||||
const size = Number(process.argv[4] ?? 100_000);
|
||||
const trial = Number(process.argv[5] ?? 0);
|
||||
const requestedWatchCount = Number(process.argv[6] ?? 8);
|
||||
const requestedWatchUpdates = Number(process.argv[7] ?? 100);
|
||||
if (!Number.isSafeInteger(requestedWatchCount) || requestedWatchCount < 1) throw new RangeError("Invalid watch count");
|
||||
if (!Number.isSafeInteger(requestedWatchUpdates) || requestedWatchUpdates < 0)
|
||||
throw new RangeError("Invalid watch update count");
|
||||
|
||||
function fixture(count: number): Document {
|
||||
return {
|
||||
rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneTrusted<T>(value: T): T {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map((child) => cloneTrusted(child)) as T;
|
||||
const result = Object.create(Object.getPrototypeOf(value)) as Record<string, unknown>;
|
||||
for (const key of Object.keys(value)) {
|
||||
Object.defineProperty(result, key, {
|
||||
value: cloneTrusted((value as Record<string, unknown>)[key]),
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function sum(document: Document | Draft<Document>): number {
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value;
|
||||
return checksum;
|
||||
}
|
||||
|
||||
async function gc(): Promise<void> {
|
||||
assert.ok(global.gc, "benchmark worker requires --expose-gc");
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
}
|
||||
}
|
||||
|
||||
await gc();
|
||||
const baselineHeap = process.memoryUsage().heapUsed;
|
||||
let input: Document | undefined = fixture(size);
|
||||
if (scenario === "sort") input.rows.reverse();
|
||||
const importStart = performance.now();
|
||||
const tracker = track(input) as TrackerLike;
|
||||
const importMs = performance.now() - importStart;
|
||||
input = undefined;
|
||||
await gc();
|
||||
const readyHeap = process.memoryUsage().heapUsed;
|
||||
let peakHeap = readyHeap;
|
||||
let mutateMs = 0;
|
||||
let prepareMs = 0;
|
||||
let serializeMs = 0;
|
||||
let adoptMs = 0;
|
||||
let acquireMs = 0;
|
||||
let enqueueMs = 0;
|
||||
let deliveryMs = 0;
|
||||
let opBytes = 0;
|
||||
let operationCount = 0;
|
||||
let checksum = 0;
|
||||
let authoritySequence = 0;
|
||||
let postAcquireRetainedMiB = 0;
|
||||
let postLagRetainedMiB = 0;
|
||||
let postDeliveryRetainedMiB = 0;
|
||||
let sampledAcquireMiB = 0;
|
||||
let watchers: WatchReplica[] = [];
|
||||
let borrowedListeners: Array<(value: Document, sequence: number) => number> = [];
|
||||
const queueOperations = Math.min(size, 1_000);
|
||||
const watchCount = WATCH_SCENARIOS.has(scenario) ? requestedWatchCount : 0;
|
||||
const watchUpdateTarget = WATCH_SCENARIOS.has(scenario) ? requestedWatchUpdates : 0;
|
||||
|
||||
const samplePeak = (): void => {
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
};
|
||||
|
||||
const retainedFromBaseline = async (): Promise<number> => {
|
||||
await gc();
|
||||
return (process.memoryUsage().heapUsed - baselineHeap) / MiB;
|
||||
};
|
||||
|
||||
const createWatch = (value: Document): WatchReplica => ({
|
||||
value,
|
||||
baseSequence: authoritySequence,
|
||||
sequence: authoritySequence,
|
||||
queue: [],
|
||||
});
|
||||
|
||||
const acquireSharedWatches = (): void => {
|
||||
const beforeHeap = process.memoryUsage().heapUsed;
|
||||
const start = performance.now();
|
||||
const base = tracker.value;
|
||||
watchers = Array.from({ length: watchCount }, () => createWatch(base));
|
||||
acquireMs += performance.now() - start;
|
||||
sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB);
|
||||
for (const watcher of watchers) assert.equal(watcher.value, tracker.value);
|
||||
samplePeak();
|
||||
};
|
||||
|
||||
const acquireDetachedWatches = (): void => {
|
||||
const beforeHeap = process.memoryUsage().heapUsed;
|
||||
const start = performance.now();
|
||||
watchers = Array.from({ length: watchCount }, () => createWatch(cloneTrusted(tracker.value)));
|
||||
acquireMs += performance.now() - start;
|
||||
sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB);
|
||||
for (let left = 0; left < watchers.length; left++) {
|
||||
assert.notEqual(watchers[left]!.value, tracker.value);
|
||||
for (let right = left + 1; right < watchers.length; right++) {
|
||||
assert.notEqual(watchers[left]!.value, watchers[right]!.value);
|
||||
}
|
||||
}
|
||||
samplePeak();
|
||||
};
|
||||
|
||||
const acquireOneWatch = (): void => {
|
||||
const beforeHeap = process.memoryUsage().heapUsed;
|
||||
const start = performance.now();
|
||||
watchers.push(createWatch(tracker.value));
|
||||
acquireMs += performance.now() - start;
|
||||
sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB);
|
||||
samplePeak();
|
||||
};
|
||||
|
||||
const commitWatchUpdate = (update: number): Frame => {
|
||||
let change: ChangeLike | undefined = tracker.beginChange();
|
||||
const row = ((update + 1) * 997) % size;
|
||||
const mutateStart = performance.now();
|
||||
change.state.rows[row]!.value = -update - 1;
|
||||
mutateMs += performance.now() - mutateStart;
|
||||
samplePeak();
|
||||
const prepareStart = performance.now();
|
||||
let prepared: PreparedLike | undefined = change.prepare();
|
||||
prepareMs += performance.now() - prepareStart;
|
||||
samplePeak();
|
||||
const operations = prepared.ops;
|
||||
operationCount += operations.length;
|
||||
const adoptStart = performance.now();
|
||||
tracker.adopt(prepared);
|
||||
adoptMs += performance.now() - adoptStart;
|
||||
samplePeak();
|
||||
change = undefined;
|
||||
prepared = undefined;
|
||||
authoritySequence += 1;
|
||||
return { sequence: authoritySequence, ops: operations };
|
||||
};
|
||||
|
||||
const enqueueFrame = (frame: Frame): void => {
|
||||
const start = performance.now();
|
||||
for (const watcher of watchers) {
|
||||
assert.equal(frame.sequence, watcher.sequence + watcher.queue.length + 1);
|
||||
watcher.queue.push(frame);
|
||||
}
|
||||
enqueueMs += performance.now() - start;
|
||||
};
|
||||
|
||||
const applyFrame = (watcher: WatchReplica, frame: Frame): void => {
|
||||
assert.equal(frame.sequence, watcher.sequence + 1);
|
||||
watcher.value = applyImmutable(watcher.value, frame.ops);
|
||||
watcher.sequence = frame.sequence;
|
||||
};
|
||||
|
||||
const drainWatchQueues = (measure: boolean): void => {
|
||||
const start = performance.now();
|
||||
for (const watcher of watchers) {
|
||||
for (const frame of watcher.queue) applyFrame(watcher, frame);
|
||||
watcher.queue.length = 0;
|
||||
samplePeak();
|
||||
}
|
||||
if (measure) deliveryMs += performance.now() - start;
|
||||
};
|
||||
|
||||
const drainWatchQueuesCoalesced = (): void => {
|
||||
const start = performance.now();
|
||||
for (const watcher of watchers) {
|
||||
const operations = watcher.queue.flatMap((frame) => frame.ops);
|
||||
watcher.value = applyImmutable(watcher.value, operations);
|
||||
watcher.sequence = watcher.queue.at(-1)?.sequence ?? watcher.sequence;
|
||||
watcher.queue.length = 0;
|
||||
samplePeak();
|
||||
}
|
||||
deliveryMs += performance.now() - start;
|
||||
};
|
||||
|
||||
const assertWatchConvergence = (): void => {
|
||||
for (const watcher of watchers) {
|
||||
assert.equal(watcher.sequence, authoritySequence);
|
||||
assert.deepEqual(watcher.value, tracker.value);
|
||||
}
|
||||
};
|
||||
|
||||
if (scenario === "committed-read") {
|
||||
const start = performance.now();
|
||||
checksum = sum(tracker.value);
|
||||
mutateMs = performance.now() - start;
|
||||
samplePeak();
|
||||
} else if (scenario === "watch-authority-control") {
|
||||
for (let update = 0; update < watchUpdateTarget; update++) commitWatchUpdate(update);
|
||||
postDeliveryRetainedMiB = await retainedFromBaseline();
|
||||
checksum = sum(tracker.value);
|
||||
} else if (scenario === "watch-live-borrowed") {
|
||||
const start = performance.now();
|
||||
borrowedListeners = Array.from({ length: watchCount }, () => (value, sequence) => value.rows[0]!.value + sequence);
|
||||
for (const listener of borrowedListeners) checksum += listener(tracker.value, authoritySequence);
|
||||
acquireMs = performance.now() - start;
|
||||
postAcquireRetainedMiB = await retainedFromBaseline();
|
||||
for (let update = 0; update < watchUpdateTarget; update++) {
|
||||
commitWatchUpdate(update);
|
||||
const deliveryStart = performance.now();
|
||||
for (const listener of borrowedListeners) checksum += listener(tracker.value, authoritySequence);
|
||||
deliveryMs += performance.now() - deliveryStart;
|
||||
samplePeak();
|
||||
}
|
||||
postDeliveryRetainedMiB = await retainedFromBaseline();
|
||||
} else if (scenario === "watch-acquire-shared" || scenario === "watch-acquire-detached") {
|
||||
if (scenario === "watch-acquire-shared") acquireSharedWatches();
|
||||
else acquireDetachedWatches();
|
||||
postAcquireRetainedMiB = await retainedFromBaseline();
|
||||
checksum = sum(watchers[0]!.value) + watchers.length;
|
||||
} else if (scenario === "watch-acquire-staggered") {
|
||||
for (let watcher = 0; watcher < watchCount; watcher++) {
|
||||
acquireOneWatch();
|
||||
if (watcher + 1 === watchCount) break;
|
||||
const frame = commitWatchUpdate(watcher);
|
||||
enqueueFrame(frame);
|
||||
}
|
||||
for (let watcher = 0; watcher < watchers.length; watcher++) {
|
||||
assert.equal(watchers[watcher]!.baseSequence, watcher);
|
||||
assert.equal(watchers[watcher]!.queue.length, watchCount - watcher - 1);
|
||||
}
|
||||
postAcquireRetainedMiB = await retainedFromBaseline();
|
||||
drainWatchQueues(false);
|
||||
assertWatchConvergence();
|
||||
postDeliveryRetainedMiB = await retainedFromBaseline();
|
||||
checksum = sum(watchers[0]!.value) + sum(watchers.at(-1)!.value);
|
||||
} else if (
|
||||
scenario === "watch-lag" ||
|
||||
scenario === "watch-catch-up" ||
|
||||
scenario === "watch-catch-up-coalesced" ||
|
||||
scenario === "watch-live-immutable"
|
||||
) {
|
||||
acquireSharedWatches();
|
||||
postAcquireRetainedMiB = await retainedFromBaseline();
|
||||
for (let update = 0; update < watchUpdateTarget; update++) {
|
||||
const frame = commitWatchUpdate(update);
|
||||
if (scenario === "watch-live-immutable") {
|
||||
const start = performance.now();
|
||||
for (const watcher of watchers) applyFrame(watcher, frame);
|
||||
deliveryMs += performance.now() - start;
|
||||
samplePeak();
|
||||
} else enqueueFrame(frame);
|
||||
}
|
||||
postLagRetainedMiB = await retainedFromBaseline();
|
||||
if (scenario === "watch-catch-up-coalesced") drainWatchQueuesCoalesced();
|
||||
else if (scenario !== "watch-live-immutable") drainWatchQueues(scenario === "watch-catch-up");
|
||||
assertWatchConvergence();
|
||||
postDeliveryRetainedMiB = await retainedFromBaseline();
|
||||
checksum = sum(watchers[0]!.value) + sum(tracker.value);
|
||||
} else if (scenario !== "import") {
|
||||
let change: ChangeLike | undefined = tracker.beginChange();
|
||||
const start = performance.now();
|
||||
switch (scenario) {
|
||||
case "draft-read":
|
||||
checksum = sum(change.state);
|
||||
break;
|
||||
case "sparse":
|
||||
for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index;
|
||||
checksum = change.state.rows.at(-1)!.value;
|
||||
break;
|
||||
case "queue":
|
||||
for (let index = 0; index < queueOperations; index++) {
|
||||
change.state.rows.shift();
|
||||
change.state.rows.push({ id: size + index, value: index, payload: "queue" });
|
||||
}
|
||||
checksum = change.state.rows.length;
|
||||
break;
|
||||
case "sort":
|
||||
change.state.rows.sort((left, right) => left.id - right.id);
|
||||
checksum = change.state.rows[0]!.id;
|
||||
break;
|
||||
case "dense":
|
||||
for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index;
|
||||
checksum = change.state.rows.at(-1)!.value;
|
||||
break;
|
||||
case "unshift": {
|
||||
const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" }));
|
||||
Reflect.apply(change.state.rows.unshift, change.state.rows, inserted);
|
||||
checksum = change.state.rows.length;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unhandled benchmark scenario ${scenario}`);
|
||||
}
|
||||
mutateMs = performance.now() - start;
|
||||
samplePeak();
|
||||
const prepareStart = performance.now();
|
||||
let prepared: PreparedLike | undefined = change.prepare();
|
||||
prepareMs = performance.now() - prepareStart;
|
||||
samplePeak();
|
||||
const serializeStart = performance.now();
|
||||
const serialized = JSON.stringify(prepared.ops);
|
||||
serializeMs = performance.now() - serializeStart;
|
||||
opBytes = Buffer.byteLength(serialized);
|
||||
operationCount = prepared.ops.length;
|
||||
samplePeak();
|
||||
const adoptStart = performance.now();
|
||||
tracker.adopt(prepared);
|
||||
adoptMs = performance.now() - adoptStart;
|
||||
change = undefined;
|
||||
prepared = undefined;
|
||||
}
|
||||
|
||||
const scenarioWorkMs = mutateMs + prepareMs + serializeMs + adoptMs + acquireMs + enqueueMs + deliveryMs;
|
||||
samplePeak();
|
||||
await gc();
|
||||
const retainedHeap = process.memoryUsage().heapUsed;
|
||||
const retainedMiB = (retainedHeap - baselineHeap) / MiB;
|
||||
const retainedOverReadyMiB = (retainedHeap - readyHeap) / MiB;
|
||||
const watcherRoots = watchers.length;
|
||||
const queuedBatchReferences = watchers.reduce((total, watcher) => total + watcher.queue.length, 0);
|
||||
const listenerCount = borrowedListeners.length;
|
||||
watchers = [];
|
||||
borrowedListeners = [];
|
||||
await gc();
|
||||
const releasedRetainedMiB = (process.memoryUsage().heapUsed - baselineHeap) / MiB;
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mode,
|
||||
scenario,
|
||||
size,
|
||||
trial,
|
||||
queueOperations,
|
||||
watchProfile: WATCH_SCENARIOS.has(scenario) ? "local-sparse-array-leaf" : null,
|
||||
watchCount: watcherRoots + listenerCount,
|
||||
watchUpdates: authoritySequence,
|
||||
queuedBatchReferences,
|
||||
importMs,
|
||||
mutateMs,
|
||||
prepareMs,
|
||||
serializeMs,
|
||||
adoptMs,
|
||||
acquireMs,
|
||||
enqueueMs,
|
||||
deliveryMs,
|
||||
scenarioWorkMs,
|
||||
opBytes,
|
||||
operationCount,
|
||||
checksum,
|
||||
readyMiB: (readyHeap - baselineHeap) / MiB,
|
||||
sampledAcquireMiB,
|
||||
sampledTransientMiB: (peakHeap - readyHeap) / MiB,
|
||||
retainedMiB,
|
||||
retainedOverReadyMiB,
|
||||
postAcquireRetainedMiB,
|
||||
postLagRetainedMiB,
|
||||
postDeliveryRetainedMiB,
|
||||
releasedRetainedMiB,
|
||||
maxRssMiB: process.resourceUsage().maxRSS / 1024,
|
||||
}),
|
||||
);
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Provisional Pico5 benchmark schemas. Results are architecture experiments,
|
||||
// not compatibility commitments for public or persisted ConversationView data.
|
||||
const PROVISIONAL_SCHEMA = "pico5.conversation-view.provisional.v0";
|
||||
|
||||
type Mode = "tracker";
|
||||
type Scenario =
|
||||
| "projection-control"
|
||||
| "normal"
|
||||
| "fanout"
|
||||
| "delayed-start-lag"
|
||||
| "churn-reconnect"
|
||||
| "overflow-reset"
|
||||
| "branch-head-replacement";
|
||||
type Distribution = {
|
||||
count: number;
|
||||
totalMs: number;
|
||||
medianMs: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
};
|
||||
type PhaseName =
|
||||
| "mutation"
|
||||
| "prepare"
|
||||
| "adopt"
|
||||
| "synthesize"
|
||||
| "projectApply"
|
||||
| "project"
|
||||
| "enqueue"
|
||||
| "sessionLine"
|
||||
| "delivery"
|
||||
| "opSerialize"
|
||||
| "resetSerialize"
|
||||
| "conversationAcquire"
|
||||
| "documentAcquire"
|
||||
| "conversationSerialize"
|
||||
| "documentSerialize"
|
||||
| "resetClone";
|
||||
type Result = {
|
||||
mode: Mode;
|
||||
scenario: Scenario;
|
||||
trial: number;
|
||||
provisionalSchema: string;
|
||||
headlineEntries: number;
|
||||
commitTarget: number;
|
||||
initialAggregateBytes: number;
|
||||
conversationWatchCount: number;
|
||||
documentWatchCount: number;
|
||||
queueCapacity: number | null;
|
||||
queueCapacityUnit: "pending-operations";
|
||||
deliveryModel: "async-serialized-ops-only-no-consumer-apply";
|
||||
conversationSequence: number;
|
||||
documentSequences: Record<string, number>;
|
||||
publishedAggregateBatches: number;
|
||||
publishedDocumentBatches: number;
|
||||
conversationRegistrations: number;
|
||||
documentRegistrations: number;
|
||||
conversationBaseBytes: number;
|
||||
documentBaseBytes: number;
|
||||
noOpCommitCount: number;
|
||||
unmountedCommitCount: number;
|
||||
resetCount: number;
|
||||
resetBytes: number;
|
||||
deliveryFrameReferences: number;
|
||||
deliveryOperationReferences: number;
|
||||
aggregateOperationCount: number;
|
||||
aggregateOperationBytes: number;
|
||||
documentOperationCount: number;
|
||||
documentOperationBytes: number;
|
||||
peakQueueReferences: number;
|
||||
peakQueuedOperationReferences: number;
|
||||
peakPendingOperationsPerWatch: number;
|
||||
peakUniqueQueuedBatches: number;
|
||||
finalQueueReferences: number;
|
||||
finalQueuedOperationReferences: number;
|
||||
finalUniqueQueuedBatches: number;
|
||||
phaseDistributions: Record<PhaseName, Distribution>;
|
||||
authorityReadyMiB: number;
|
||||
postAcquireRetainedMiB: number;
|
||||
postWorkRetainedMiB: number;
|
||||
postVerificationRetainedMiB: number;
|
||||
releaseHeapMiB: number;
|
||||
releaseTotalMiB: number;
|
||||
peakHeapMiB: number;
|
||||
peakTotalHeapMiB: number;
|
||||
maxRssMiB: number;
|
||||
checksum: number;
|
||||
};
|
||||
type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string };
|
||||
type NumericResultKey =
|
||||
| "initialAggregateBytes"
|
||||
| "conversationSequence"
|
||||
| "publishedAggregateBatches"
|
||||
| "publishedDocumentBatches"
|
||||
| "conversationRegistrations"
|
||||
| "documentRegistrations"
|
||||
| "conversationBaseBytes"
|
||||
| "documentBaseBytes"
|
||||
| "noOpCommitCount"
|
||||
| "unmountedCommitCount"
|
||||
| "resetCount"
|
||||
| "resetBytes"
|
||||
| "deliveryFrameReferences"
|
||||
| "deliveryOperationReferences"
|
||||
| "aggregateOperationCount"
|
||||
| "aggregateOperationBytes"
|
||||
| "documentOperationCount"
|
||||
| "documentOperationBytes"
|
||||
| "peakQueueReferences"
|
||||
| "peakQueuedOperationReferences"
|
||||
| "peakPendingOperationsPerWatch"
|
||||
| "peakUniqueQueuedBatches"
|
||||
| "finalQueueReferences"
|
||||
| "finalQueuedOperationReferences"
|
||||
| "finalUniqueQueuedBatches"
|
||||
| "authorityReadyMiB"
|
||||
| "postAcquireRetainedMiB"
|
||||
| "postWorkRetainedMiB"
|
||||
| "postVerificationRetainedMiB"
|
||||
| "releaseHeapMiB"
|
||||
| "releaseTotalMiB"
|
||||
| "peakHeapMiB"
|
||||
| "peakTotalHeapMiB"
|
||||
| "maxRssMiB";
|
||||
|
||||
type SummaryValue = { median: number | null; range: readonly [number, number] | null };
|
||||
|
||||
const modes: readonly Mode[] = ["tracker"];
|
||||
const scenarios: readonly Scenario[] = [
|
||||
"projection-control",
|
||||
"normal",
|
||||
"fanout",
|
||||
"delayed-start-lag",
|
||||
"churn-reconnect",
|
||||
"overflow-reset",
|
||||
"branch-head-replacement",
|
||||
];
|
||||
const phaseNames: readonly PhaseName[] = [
|
||||
"mutation",
|
||||
"prepare",
|
||||
"adopt",
|
||||
"synthesize",
|
||||
"projectApply",
|
||||
"project",
|
||||
"enqueue",
|
||||
"sessionLine",
|
||||
"delivery",
|
||||
"opSerialize",
|
||||
"resetSerialize",
|
||||
"conversationAcquire",
|
||||
"documentAcquire",
|
||||
"conversationSerialize",
|
||||
"documentSerialize",
|
||||
"resetClone",
|
||||
];
|
||||
const numericMetrics: readonly NumericResultKey[] = [
|
||||
"initialAggregateBytes",
|
||||
"conversationSequence",
|
||||
"publishedAggregateBatches",
|
||||
"publishedDocumentBatches",
|
||||
"conversationRegistrations",
|
||||
"documentRegistrations",
|
||||
"conversationBaseBytes",
|
||||
"documentBaseBytes",
|
||||
"noOpCommitCount",
|
||||
"unmountedCommitCount",
|
||||
"resetCount",
|
||||
"resetBytes",
|
||||
"deliveryFrameReferences",
|
||||
"deliveryOperationReferences",
|
||||
"aggregateOperationCount",
|
||||
"aggregateOperationBytes",
|
||||
"documentOperationCount",
|
||||
"documentOperationBytes",
|
||||
"peakQueueReferences",
|
||||
"peakQueuedOperationReferences",
|
||||
"peakPendingOperationsPerWatch",
|
||||
"peakUniqueQueuedBatches",
|
||||
"finalQueueReferences",
|
||||
"finalQueuedOperationReferences",
|
||||
"finalUniqueQueuedBatches",
|
||||
"authorityReadyMiB",
|
||||
"postAcquireRetainedMiB",
|
||||
"postWorkRetainedMiB",
|
||||
"postVerificationRetainedMiB",
|
||||
"releaseHeapMiB",
|
||||
"releaseTotalMiB",
|
||||
"peakHeapMiB",
|
||||
"peakTotalHeapMiB",
|
||||
"maxRssMiB",
|
||||
];
|
||||
|
||||
function optionNumber(name: string, fallback: number): number {
|
||||
const prefix = `--${name}=`;
|
||||
const raw = process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length);
|
||||
if (raw === undefined) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`Invalid --${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
const ordered = [...values].sort((left, right) => left - right);
|
||||
return ordered[Math.floor(ordered.length / 2)]!;
|
||||
}
|
||||
|
||||
function range(values: readonly number[]): readonly [number, number] | null {
|
||||
return values.length === 0 ? null : [Math.min(...values), Math.max(...values)];
|
||||
}
|
||||
|
||||
function summarize(values: readonly number[]): SummaryValue {
|
||||
return { median: median(values), range: range(values) };
|
||||
}
|
||||
|
||||
const quick = process.argv.includes("--quick");
|
||||
const selectedScenario = process.argv
|
||||
.find((argument) => argument.startsWith("--scenario="))
|
||||
?.slice("--scenario=".length);
|
||||
if (selectedScenario !== undefined && !(scenarios as readonly string[]).includes(selectedScenario)) {
|
||||
throw new Error(`Unknown scenario ${selectedScenario}`);
|
||||
}
|
||||
const selectedScenarios = selectedScenario === undefined ? scenarios : [selectedScenario as Scenario];
|
||||
const headlineEntries = optionNumber("entries", quick ? 128 : 512);
|
||||
const commits = optionNumber("commits", quick ? 120 : 1_000);
|
||||
const trials = optionNumber("trials", quick ? 1 : 3);
|
||||
const queueOperationCapacity = quick ? 64 : 256;
|
||||
const root = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const worker = fileURLToPath(new URL("./conversation-view-benchmark.worker.ts", import.meta.url));
|
||||
const results: Result[] = [];
|
||||
const failures: Failure[] = [];
|
||||
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
for (const scenario of selectedScenarios) {
|
||||
const offset = trial % modes.length;
|
||||
const trialModes = [...modes.slice(offset), ...modes.slice(0, offset)];
|
||||
for (const mode of trialModes) {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--expose-gc",
|
||||
"--max-old-space-size=4096",
|
||||
worker,
|
||||
mode,
|
||||
scenario,
|
||||
String(headlineEntries),
|
||||
String(commits),
|
||||
String(trial),
|
||||
String(queueOperationCapacity),
|
||||
],
|
||||
{ cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 20 * 1024 * 1024 },
|
||||
);
|
||||
if (child.status !== 0) {
|
||||
const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure;
|
||||
failures.push(failure);
|
||||
console.log(JSON.stringify(failure));
|
||||
continue;
|
||||
}
|
||||
const result = JSON.parse(child.stdout) as Result;
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary = selectedScenarios.flatMap((scenario) =>
|
||||
modes.map((mode) => {
|
||||
const selected = results.filter((result) => result.scenario === scenario && result.mode === mode);
|
||||
const metrics: Record<string, SummaryValue> = {};
|
||||
for (const metric of numericMetrics) metrics[metric] = summarize(selected.map((result) => result[metric]));
|
||||
const phases: Record<
|
||||
string,
|
||||
{ medianMs: SummaryValue; p95Ms: SummaryValue; p99Ms: SummaryValue; totalMs: SummaryValue }
|
||||
> = {};
|
||||
for (const phase of phaseNames) {
|
||||
phases[phase] = {
|
||||
medianMs: summarize(selected.map((result) => result.phaseDistributions[phase].medianMs)),
|
||||
p95Ms: summarize(selected.map((result) => result.phaseDistributions[phase].p95Ms)),
|
||||
p99Ms: summarize(selected.map((result) => result.phaseDistributions[phase].p99Ms)),
|
||||
totalMs: summarize(selected.map((result) => result.phaseDistributions[phase].totalMs)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
scenario,
|
||||
provisionalSchema: PROVISIONAL_SCHEMA,
|
||||
conversationWatchCount: selected[0]?.conversationWatchCount ?? 0,
|
||||
documentWatchCount: selected[0]?.documentWatchCount ?? 0,
|
||||
queueCapacity: selected[0]?.queueCapacity ?? null,
|
||||
queueCapacityUnit: "pending-operations",
|
||||
deliveryModel: "async-serialized-ops-only-no-consumer-apply",
|
||||
metrics,
|
||||
phases,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const output = join(tmpdir(), "chord-conversation-view-benchmark.json");
|
||||
writeFileSync(
|
||||
output,
|
||||
JSON.stringify(
|
||||
{
|
||||
node: process.version,
|
||||
provisionalSchema: PROVISIONAL_SCHEMA,
|
||||
profile: "local-pico5-provisional-conversation-view",
|
||||
headlineEntries,
|
||||
commits,
|
||||
trials,
|
||||
queueOperationCapacity,
|
||||
results,
|
||||
failures,
|
||||
summary,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
output,
|
||||
node: process.version,
|
||||
provisionalSchema: PROVISIONAL_SCHEMA,
|
||||
headlineEntries,
|
||||
commits,
|
||||
trials,
|
||||
queueOperationCapacity,
|
||||
failures: failures.length,
|
||||
summary,
|
||||
}),
|
||||
);
|
||||
+1140
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,142 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type Mode = "tracker";
|
||||
type Scenario = "idle-control" | "sparse-burst" | "staggered-revisions" | "lagging-watches";
|
||||
type Result = {
|
||||
mode: Mode;
|
||||
scenario: Scenario;
|
||||
trial: number;
|
||||
size: number;
|
||||
updates: number;
|
||||
watchCount: number;
|
||||
workMs: number;
|
||||
acquisitionMs: number;
|
||||
gcCount: number;
|
||||
gcMs: number;
|
||||
readyHeapMiB: number;
|
||||
readyRssMiB: number;
|
||||
peakOverReadyHeapMiB: number;
|
||||
peakOverProcessBaselineHeapMiB: number;
|
||||
peakOverReadyRssMiB: number;
|
||||
retainedOverReadyHeapMiB: number;
|
||||
retainedOverProcessBaselineHeapMiB: number;
|
||||
retainedOverReadyRssMiB: number;
|
||||
snapshotRetainedHeapMiB: number;
|
||||
afterSnapshotReleaseOverReadyHeapMiB: number;
|
||||
afterSnapshotReleaseOverProcessBaselineHeapMiB: number;
|
||||
releasedOverProcessBaselineHeapMiB: number;
|
||||
releasedOverProcessBaselineRssMiB: number;
|
||||
maxRssMiB: number;
|
||||
checksum: number;
|
||||
};
|
||||
type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string };
|
||||
|
||||
const modes: readonly Mode[] = ["tracker"];
|
||||
const scenarios: readonly Scenario[] = ["idle-control", "sparse-burst", "staggered-revisions", "lagging-watches"];
|
||||
const metrics = [
|
||||
"workMs",
|
||||
"acquisitionMs",
|
||||
"gcCount",
|
||||
"gcMs",
|
||||
"readyHeapMiB",
|
||||
"readyRssMiB",
|
||||
"peakOverReadyHeapMiB",
|
||||
"peakOverProcessBaselineHeapMiB",
|
||||
"peakOverReadyRssMiB",
|
||||
"retainedOverReadyHeapMiB",
|
||||
"retainedOverProcessBaselineHeapMiB",
|
||||
"retainedOverReadyRssMiB",
|
||||
"snapshotRetainedHeapMiB",
|
||||
"afterSnapshotReleaseOverReadyHeapMiB",
|
||||
"afterSnapshotReleaseOverProcessBaselineHeapMiB",
|
||||
"releasedOverProcessBaselineHeapMiB",
|
||||
"releasedOverProcessBaselineRssMiB",
|
||||
"maxRssMiB",
|
||||
] as const satisfies readonly (keyof Result)[];
|
||||
|
||||
function median(values: readonly number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
const ordered = [...values].sort((left, right) => left - right);
|
||||
return ordered[Math.floor(ordered.length / 2)]!;
|
||||
}
|
||||
|
||||
function range(values: readonly number[]): readonly [number, number] | null {
|
||||
return values.length === 0 ? null : [Math.min(...values), Math.max(...values)];
|
||||
}
|
||||
|
||||
const quick = process.argv.includes("--quick");
|
||||
const size = quick ? 10_000 : 100_000;
|
||||
const updates = quick ? 20 : 100;
|
||||
const watchCount = quick ? 4 : 8;
|
||||
const trials = quick ? 1 : 3;
|
||||
const root = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const worker = fileURLToPath(new URL("./memory-benchmark.worker.ts", import.meta.url));
|
||||
const results: Result[] = [];
|
||||
const failures: Failure[] = [];
|
||||
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
for (const scenario of scenarios) {
|
||||
const offset = trial % modes.length;
|
||||
const trialModes = [...modes.slice(offset), ...modes.slice(0, offset)];
|
||||
for (const mode of trialModes) {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--expose-gc",
|
||||
"--no-flush-bytecode",
|
||||
"--max-old-space-size=4096",
|
||||
worker,
|
||||
mode,
|
||||
scenario,
|
||||
String(size),
|
||||
String(updates),
|
||||
String(watchCount),
|
||||
String(trial),
|
||||
],
|
||||
{ cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
if (child.status !== 0) {
|
||||
const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure;
|
||||
failures.push(failure);
|
||||
console.log(JSON.stringify(failure));
|
||||
continue;
|
||||
}
|
||||
const result = JSON.parse(child.stdout) as Result;
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary = scenarios.flatMap((scenario) =>
|
||||
modes.map((mode) => {
|
||||
const selected = results.filter((result) => result.scenario === scenario && result.mode === mode);
|
||||
const values: Record<string, { median: number | null; range: readonly [number, number] | null }> = {};
|
||||
for (const metric of metrics) {
|
||||
const samples = selected.map((result) => result[metric]);
|
||||
values[metric] = { median: median(samples), range: range(samples) };
|
||||
}
|
||||
return { scenario, mode, metrics: values };
|
||||
}),
|
||||
);
|
||||
const output = join(tmpdir(), "chord-delta-memory-benchmark.json");
|
||||
writeFileSync(
|
||||
output,
|
||||
JSON.stringify({ node: process.version, size, updates, watchCount, trials, results, failures, summary }, null, 2),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
output,
|
||||
node: process.version,
|
||||
size,
|
||||
updates,
|
||||
watchCount,
|
||||
trials,
|
||||
failures: failures.length,
|
||||
summary,
|
||||
}),
|
||||
);
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { PerformanceObserver, performance } from "node:perf_hooks";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import type { Draft } from "../../src/delta/draft.ts";
|
||||
import { type Op, track } from "../../src/delta/index.ts";
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
|
||||
type Mode = "tracker";
|
||||
type Scenario = "idle-control" | "sparse-burst" | "staggered-revisions" | "lagging-watches";
|
||||
type Row = { id: number; value: number; payload: string };
|
||||
type Document = { rows: Row[] };
|
||||
type PreparedLike = { readonly value: Document; readonly ops: readonly Op[] };
|
||||
type ChangeLike = { readonly state: Draft<Document>; prepare(): PreparedLike };
|
||||
type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void };
|
||||
|
||||
const MODES = new Set<Mode>(["tracker"]);
|
||||
const SCENARIOS = new Set<Scenario>(["idle-control", "sparse-burst", "staggered-revisions", "lagging-watches"]);
|
||||
const mode = process.argv[2] as Mode;
|
||||
const scenario = process.argv[3] as Scenario;
|
||||
const size = Number(process.argv[4] ?? 100_000);
|
||||
const updates = Number(process.argv[5] ?? 100);
|
||||
const watchCount = Number(process.argv[6] ?? 8);
|
||||
const trial = Number(process.argv[7] ?? 0);
|
||||
if (!MODES.has(mode)) throw new Error(`Unknown mode ${mode}`);
|
||||
if (!SCENARIOS.has(scenario)) throw new Error(`Unknown scenario ${scenario}`);
|
||||
if (!Number.isSafeInteger(size) || size < 1) throw new RangeError("Invalid size");
|
||||
if (!Number.isSafeInteger(updates) || updates < 1) throw new RangeError("Invalid update count");
|
||||
if (!Number.isSafeInteger(watchCount) || watchCount < 1) throw new RangeError("Invalid watch count");
|
||||
|
||||
function fixture(count: number): Document {
|
||||
return { rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })) };
|
||||
}
|
||||
|
||||
async function collect(): Promise<void> {
|
||||
assert.ok(global.gc, "memory benchmark requires --expose-gc");
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
}
|
||||
}
|
||||
|
||||
function createTracker(value: Document): TrackerLike {
|
||||
return track(value) as TrackerLike;
|
||||
}
|
||||
|
||||
function commitUpdate(tracker: TrackerLike, update: number): void {
|
||||
const change = tracker.beginChange();
|
||||
const row = ((update + 1) * 997) % size;
|
||||
change.state.rows[row]!.value = -update - 1;
|
||||
const prepared = change.prepare();
|
||||
tracker.adopt(prepared);
|
||||
}
|
||||
|
||||
function acquireSnapshot(tracker: TrackerLike): Document {
|
||||
return tracker.value;
|
||||
}
|
||||
|
||||
let warmup: TrackerLike | undefined = createTracker(fixture(16));
|
||||
const warmupChange = warmup.beginChange();
|
||||
warmupChange.state.rows[0]!.value = -1;
|
||||
const warmupPrepared = warmupChange.prepare();
|
||||
warmup.adopt(warmupPrepared);
|
||||
warmup = undefined;
|
||||
await collect();
|
||||
const processBaseline = process.memoryUsage();
|
||||
let input: Document | undefined = fixture(size);
|
||||
let tracker: TrackerLike | undefined = createTracker(input);
|
||||
input = undefined;
|
||||
await collect();
|
||||
const ready = process.memoryUsage();
|
||||
let peakHeap = ready.heapUsed;
|
||||
let peakRss = ready.rss;
|
||||
let acquisitionMs = 0;
|
||||
let snapshots: Document[] = [];
|
||||
let gcCount = 0;
|
||||
let gcMs = 0;
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
gcCount += 1;
|
||||
gcMs += entry.duration;
|
||||
}
|
||||
});
|
||||
observer.observe({ entryTypes: ["gc"] });
|
||||
|
||||
const samplePeak = (): void => {
|
||||
const memory = process.memoryUsage();
|
||||
peakHeap = Math.max(peakHeap, memory.heapUsed);
|
||||
peakRss = Math.max(peakRss, memory.rss);
|
||||
};
|
||||
|
||||
const acquire = (): void => {
|
||||
const start = performance.now();
|
||||
snapshots.push(acquireSnapshot(tracker!));
|
||||
acquisitionMs += performance.now() - start;
|
||||
samplePeak();
|
||||
};
|
||||
|
||||
const workStart = performance.now();
|
||||
if (scenario === "idle-control") {
|
||||
for (let update = 0; update < updates; update++) samplePeak();
|
||||
} else if (scenario === "sparse-burst") {
|
||||
for (let update = 0; update < updates; update++) {
|
||||
commitUpdate(tracker, update);
|
||||
samplePeak();
|
||||
}
|
||||
} else if (scenario === "staggered-revisions") {
|
||||
for (let watcher = 0; watcher < watchCount; watcher++) {
|
||||
acquire();
|
||||
if (watcher + 1 < watchCount) commitUpdate(tracker, watcher);
|
||||
samplePeak();
|
||||
}
|
||||
} else {
|
||||
for (let watcher = 0; watcher < watchCount; watcher++) acquire();
|
||||
for (let update = 0; update < updates; update++) {
|
||||
commitUpdate(tracker, update);
|
||||
samplePeak();
|
||||
}
|
||||
}
|
||||
const workMs = performance.now() - workStart;
|
||||
for (let attempt = 0; attempt < 3; attempt++) await setImmediate();
|
||||
observer.disconnect();
|
||||
samplePeak();
|
||||
await collect();
|
||||
const retained = process.memoryUsage();
|
||||
const checksum = tracker.value.rows[0]!.value + tracker.value.rows.at(-1)!.value + snapshots.length;
|
||||
|
||||
snapshots = [];
|
||||
await collect();
|
||||
const afterSnapshotRelease = process.memoryUsage();
|
||||
tracker = undefined;
|
||||
await collect();
|
||||
const released = process.memoryUsage();
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mode,
|
||||
scenario,
|
||||
trial,
|
||||
size,
|
||||
updates: scenario === "staggered-revisions" ? watchCount - 1 : scenario === "idle-control" ? 0 : updates,
|
||||
watchCount: scenario === "staggered-revisions" || scenario === "lagging-watches" ? watchCount : 0,
|
||||
workMs,
|
||||
acquisitionMs,
|
||||
gcCount,
|
||||
gcMs,
|
||||
readyHeapMiB: (ready.heapUsed - processBaseline.heapUsed) / MiB,
|
||||
readyRssMiB: (ready.rss - processBaseline.rss) / MiB,
|
||||
peakOverReadyHeapMiB: (peakHeap - ready.heapUsed) / MiB,
|
||||
peakOverProcessBaselineHeapMiB: (peakHeap - processBaseline.heapUsed) / MiB,
|
||||
peakOverReadyRssMiB: (peakRss - ready.rss) / MiB,
|
||||
retainedOverReadyHeapMiB: (retained.heapUsed - ready.heapUsed) / MiB,
|
||||
retainedOverProcessBaselineHeapMiB: (retained.heapUsed - processBaseline.heapUsed) / MiB,
|
||||
retainedOverReadyRssMiB: (retained.rss - ready.rss) / MiB,
|
||||
snapshotRetainedHeapMiB: (retained.heapUsed - afterSnapshotRelease.heapUsed) / MiB,
|
||||
afterSnapshotReleaseOverReadyHeapMiB: (afterSnapshotRelease.heapUsed - ready.heapUsed) / MiB,
|
||||
afterSnapshotReleaseOverProcessBaselineHeapMiB: (afterSnapshotRelease.heapUsed - processBaseline.heapUsed) / MiB,
|
||||
releasedOverProcessBaselineHeapMiB: (released.heapUsed - processBaseline.heapUsed) / MiB,
|
||||
releasedOverProcessBaselineRssMiB: (released.rss - processBaseline.rss) / MiB,
|
||||
maxRssMiB: process.resourceUsage().maxRSS / 1024,
|
||||
checksum,
|
||||
}),
|
||||
);
|
||||
Reference in new issue
Block a user