mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
perf: complete persist-load P2 hydration pass
This commit is contained in:
397
tests/perf/load-preapply-bench.mjs
Normal file
397
tests/perf/load-preapply-bench.mjs
Normal file
@@ -0,0 +1,397 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
BmeDatabase,
|
||||
buildBmeDbName,
|
||||
buildGraphFromSnapshot,
|
||||
buildSnapshotFromGraph,
|
||||
ensureDexieLoaded,
|
||||
} from "../../sync/bme-db.js";
|
||||
import {
|
||||
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
|
||||
OpfsGraphStore,
|
||||
} from "../../sync/bme-opfs-store.js";
|
||||
import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs";
|
||||
|
||||
const RUNS = 4;
|
||||
const outputJson = process.argv.includes("--json");
|
||||
const projectRootHint = String(process.env.ST_BME_NODE_MODULES_ROOT || "").trim();
|
||||
const requireFromProjectRoot = projectRootHint
|
||||
? createRequire(path.join(projectRootHint, "package.json"))
|
||||
: null;
|
||||
const SIZE_PRESETS = [
|
||||
{ label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600 },
|
||||
{ label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800 },
|
||||
{ label: "XL", seed: 43, nodeCount: 7200, edgeCount: 21600 },
|
||||
];
|
||||
|
||||
async function importWithProjectRootFallback(specifier) {
|
||||
try {
|
||||
return await import(specifier);
|
||||
} catch (error) {
|
||||
if (!requireFromProjectRoot) {
|
||||
throw error;
|
||||
}
|
||||
const resolved = requireFromProjectRoot.resolve(specifier);
|
||||
return await import(pathToFileURL(resolved).href);
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(values = []) {
|
||||
if (!values.length) {
|
||||
return { avg: 0, p95: 0, min: 0, max: 0 };
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const sum = sorted.reduce((acc, value) => acc + value, 0);
|
||||
const p95Index = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95));
|
||||
return {
|
||||
avg: sum / sorted.length,
|
||||
p95: sorted[p95Index],
|
||||
min: sorted[0],
|
||||
max: sorted[sorted.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
function formatSummary(label, values = []) {
|
||||
const summary = summarize(values);
|
||||
return `${label} avg=${summary.avg.toFixed(2)}ms p95=${summary.p95.toFixed(2)}ms min=${summary.min.toFixed(2)}ms max=${summary.max.toFixed(2)}ms`;
|
||||
}
|
||||
|
||||
function createRandom(seed = 1) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 0xffffffff;
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeGraph(seed = 1, nodeCount = 100, edgeCount = 200, chatId = "bench-chat") {
|
||||
const rand = createRandom(seed);
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
for (let index = 0; index < nodeCount; index += 1) {
|
||||
nodes.push({
|
||||
id: `node-${index}`,
|
||||
type: "event",
|
||||
updatedAt: 1000 + index,
|
||||
archived: false,
|
||||
sourceFloor: index,
|
||||
fields: {
|
||||
title: `Node ${index}`,
|
||||
text: `node-${index}-${Math.floor(rand() * 100000)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < edgeCount; index += 1) {
|
||||
const fromIndex = Math.floor(rand() * nodeCount);
|
||||
let toIndex = Math.floor(rand() * nodeCount);
|
||||
if (toIndex === fromIndex) {
|
||||
toIndex = (toIndex + 1) % nodeCount;
|
||||
}
|
||||
edges.push({
|
||||
id: `edge-${index}`,
|
||||
fromId: `node-${fromIndex}`,
|
||||
toId: `node-${toIndex}`,
|
||||
relation: "related",
|
||||
strength: rand(),
|
||||
updatedAt: 2000 + index,
|
||||
});
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
nodes,
|
||||
edges,
|
||||
historyState: {
|
||||
chatId,
|
||||
lastProcessedAssistantFloor: Math.max(0, Math.floor(nodeCount / 12)),
|
||||
extractionCount: Math.max(1, Math.floor(nodeCount / 40)),
|
||||
processedMessageHashes: {},
|
||||
processedMessageHashVersion: 1,
|
||||
processedMessageHashesNeedRefresh: false,
|
||||
recentRecallOwnerKeys: [],
|
||||
activeRecallOwnerKey: "",
|
||||
activeRegion: "",
|
||||
activeRegionSource: "",
|
||||
activeStorySegmentId: "",
|
||||
activeStoryTimeLabel: "",
|
||||
activeStoryTimeSource: "",
|
||||
lastBatchStatus: null,
|
||||
lastMutationSource: "bench",
|
||||
lastExtractedRegion: "",
|
||||
lastExtractedStorySegmentId: "",
|
||||
activeCharacterPovOwner: "",
|
||||
activeUserPovOwner: "",
|
||||
},
|
||||
vectorIndexState: {
|
||||
chatId,
|
||||
collectionId: "",
|
||||
hashToNodeId: {},
|
||||
nodeToHash: {},
|
||||
replayRequiredNodeIds: [],
|
||||
dirty: false,
|
||||
dirtyReason: "",
|
||||
pendingRepairFromFloor: null,
|
||||
lastIntegrityIssue: null,
|
||||
lastStats: {
|
||||
nodesIndexed: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
},
|
||||
knowledgeState: {
|
||||
owners: {},
|
||||
activeOwnerKey: "",
|
||||
},
|
||||
regionState: {
|
||||
activeRegion: "",
|
||||
knownRegions: {},
|
||||
manualActiveRegion: "",
|
||||
},
|
||||
timelineState: {
|
||||
activeSegmentId: "",
|
||||
manualActiveSegmentId: "",
|
||||
segments: [],
|
||||
},
|
||||
summaryState: {
|
||||
updatedAt: 0,
|
||||
entries: [],
|
||||
},
|
||||
batchJournal: [],
|
||||
maintenanceJournal: [],
|
||||
lastRecallResult: null,
|
||||
lastProcessedSeq: Math.max(0, Math.floor(nodeCount / 12)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBenchSnapshot({ label, seed, nodeCount, edgeCount }) {
|
||||
const chatId = `load-bench-${label.toLowerCase()}-${seed}`;
|
||||
const graph = buildRuntimeGraph(seed, nodeCount, edgeCount, chatId);
|
||||
return {
|
||||
chatId,
|
||||
snapshot: buildSnapshotFromGraph(graph, {
|
||||
chatId,
|
||||
revision: 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function setupIndexedDbTestEnv() {
|
||||
try {
|
||||
await importWithProjectRootFallback("fake-indexeddb/auto");
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
if (!globalThis.Dexie) {
|
||||
try {
|
||||
const imported = await importWithProjectRootFallback("dexie");
|
||||
globalThis.Dexie = imported?.default || imported?.Dexie || imported;
|
||||
} catch {
|
||||
await import("../../lib/dexie.min.js");
|
||||
}
|
||||
}
|
||||
|
||||
await ensureDexieLoaded();
|
||||
}
|
||||
|
||||
async function cleanupDatabase(chatId = "") {
|
||||
if (!chatId || typeof globalThis.Dexie?.delete !== "function") return;
|
||||
try {
|
||||
await globalThis.Dexie.delete(buildBmeDbName(chatId));
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareIndexedDb(chatId, snapshot) {
|
||||
await cleanupDatabase(chatId);
|
||||
const db = new BmeDatabase(chatId, { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
await db.importSnapshot(snapshot, {
|
||||
mode: "replace",
|
||||
preserveRevision: true,
|
||||
markSyncDirty: false,
|
||||
});
|
||||
return db;
|
||||
}
|
||||
|
||||
async function prepareOpfsStore(chatId, snapshot) {
|
||||
const rootDirectory = createMemoryOpfsRoot();
|
||||
const store = new OpfsGraphStore(chatId, {
|
||||
rootDirectoryFactory: async () => rootDirectory,
|
||||
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
|
||||
});
|
||||
await store.open();
|
||||
await store.importSnapshot(snapshot, {
|
||||
mode: "replace",
|
||||
preserveRevision: true,
|
||||
markSyncDirty: false,
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
async function readProbeOrFallback(store) {
|
||||
let inspectionSnapshot = null;
|
||||
let exportProbeMs = 0;
|
||||
let exportSnapshotMs = 0;
|
||||
let exportSource = "";
|
||||
|
||||
if (typeof store.exportSnapshotProbe === "function") {
|
||||
const probeStartedAt = performance.now();
|
||||
inspectionSnapshot = await store.exportSnapshotProbe({ includeTombstones: false });
|
||||
exportProbeMs = performance.now() - probeStartedAt;
|
||||
exportSource = "probe";
|
||||
}
|
||||
|
||||
if (!inspectionSnapshot) {
|
||||
const exportStartedAt = performance.now();
|
||||
inspectionSnapshot = await store.exportSnapshot({ includeTombstones: false });
|
||||
exportSnapshotMs = performance.now() - exportStartedAt;
|
||||
exportSource = "full-export";
|
||||
}
|
||||
|
||||
return {
|
||||
inspectionSnapshot,
|
||||
exportProbeMs,
|
||||
exportSnapshotMs,
|
||||
exportSource,
|
||||
};
|
||||
}
|
||||
|
||||
async function measureSuccessPreApply(store, chatId) {
|
||||
const startedAt = performance.now();
|
||||
const probeResult = await readProbeOrFallback(store);
|
||||
let snapshot = probeResult.inspectionSnapshot;
|
||||
let exportSnapshotMs = probeResult.exportSnapshotMs;
|
||||
let exportSource = probeResult.exportSource;
|
||||
|
||||
if (snapshot?.__stBmeProbeOnly === true) {
|
||||
const exportStartedAt = performance.now();
|
||||
snapshot = await store.exportSnapshot({ includeTombstones: false });
|
||||
exportSnapshotMs += performance.now() - exportStartedAt;
|
||||
exportSource =
|
||||
probeResult.exportSource === "probe" ? "probe+full-export" : "full-export";
|
||||
}
|
||||
|
||||
const preApplyMs = performance.now() - startedAt;
|
||||
const hydrateStartedAt = performance.now();
|
||||
buildGraphFromSnapshot(snapshot, { chatId });
|
||||
const hydrateMs = performance.now() - hydrateStartedAt;
|
||||
|
||||
return {
|
||||
preApplyMs,
|
||||
exportProbeMs: probeResult.exportProbeMs,
|
||||
exportSnapshotMs,
|
||||
hydrateMs,
|
||||
exportSource,
|
||||
};
|
||||
}
|
||||
|
||||
async function measureProbeRejectPreApply(store) {
|
||||
const startedAt = performance.now();
|
||||
const probeResult = await readProbeOrFallback(store);
|
||||
return {
|
||||
preApplyMs: performance.now() - startedAt,
|
||||
exportProbeMs: probeResult.exportProbeMs,
|
||||
exportSnapshotMs: probeResult.exportSnapshotMs,
|
||||
exportSource: probeResult.exportSource,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPreset(preset) {
|
||||
const indexedDbSuccessSamples = [];
|
||||
const indexedDbProbeRejectSamples = [];
|
||||
const indexedDbProbeSamples = [];
|
||||
const indexedDbExportSamples = [];
|
||||
const indexedDbHydrateSamples = [];
|
||||
const opfsSuccessSamples = [];
|
||||
const opfsProbeRejectSamples = [];
|
||||
const opfsProbeSamples = [];
|
||||
const opfsExportSamples = [];
|
||||
const opfsHydrateSamples = [];
|
||||
|
||||
for (let run = 0; run < RUNS; run += 1) {
|
||||
const { chatId, snapshot } = buildBenchSnapshot({
|
||||
...preset,
|
||||
seed: preset.seed + run * 17,
|
||||
});
|
||||
|
||||
const indexedDbChatId = `${chatId}-indexeddb`;
|
||||
const db = await prepareIndexedDb(indexedDbChatId, snapshot);
|
||||
const indexedDbSuccess = await measureSuccessPreApply(db, indexedDbChatId);
|
||||
const indexedDbProbeReject = await measureProbeRejectPreApply(db);
|
||||
indexedDbSuccessSamples.push(indexedDbSuccess.preApplyMs);
|
||||
indexedDbProbeRejectSamples.push(indexedDbProbeReject.preApplyMs);
|
||||
indexedDbProbeSamples.push(indexedDbSuccess.exportProbeMs);
|
||||
indexedDbExportSamples.push(indexedDbSuccess.exportSnapshotMs);
|
||||
indexedDbHydrateSamples.push(indexedDbSuccess.hydrateMs);
|
||||
await db.close();
|
||||
await cleanupDatabase(indexedDbChatId);
|
||||
|
||||
const opfsChatId = `${chatId}-opfs`;
|
||||
const opfsStore = await prepareOpfsStore(opfsChatId, snapshot);
|
||||
const opfsSuccess = await measureSuccessPreApply(opfsStore, opfsChatId);
|
||||
const opfsProbeReject = await measureProbeRejectPreApply(opfsStore);
|
||||
opfsSuccessSamples.push(opfsSuccess.preApplyMs);
|
||||
opfsProbeRejectSamples.push(opfsProbeReject.preApplyMs);
|
||||
opfsProbeSamples.push(opfsSuccess.exportProbeMs);
|
||||
opfsExportSamples.push(opfsSuccess.exportSnapshotMs);
|
||||
opfsHydrateSamples.push(opfsSuccess.hydrateMs);
|
||||
await opfsStore.close();
|
||||
}
|
||||
|
||||
const result = {
|
||||
indexedDbPreApplySuccessMs: summarize(indexedDbSuccessSamples),
|
||||
indexedDbProbeRejectMs: summarize(indexedDbProbeRejectSamples),
|
||||
indexedDbExportProbeMs: summarize(indexedDbProbeSamples),
|
||||
indexedDbExportSnapshotMs: summarize(indexedDbExportSamples),
|
||||
indexedDbHydrateMs: summarize(indexedDbHydrateSamples),
|
||||
opfsPreApplySuccessMs: summarize(opfsSuccessSamples),
|
||||
opfsProbeRejectMs: summarize(opfsProbeRejectSamples),
|
||||
opfsExportProbeMs: summarize(opfsProbeSamples),
|
||||
opfsExportSnapshotMs: summarize(opfsExportSamples),
|
||||
opfsHydrateMs: summarize(opfsHydrateSamples),
|
||||
};
|
||||
|
||||
if (!outputJson) {
|
||||
console.log(`\n[ST-BME][load-preapply-bench] ${preset.label}`);
|
||||
console.log(
|
||||
formatSummary("indexeddb-preapply-success", indexedDbSuccessSamples),
|
||||
`probeRejectP95=${result.indexedDbProbeRejectMs.p95.toFixed(2)}ms`,
|
||||
`probeP95=${result.indexedDbExportProbeMs.p95.toFixed(2)}ms`,
|
||||
`exportP95=${result.indexedDbExportSnapshotMs.p95.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
formatSummary("opfs-preapply-success", opfsSuccessSamples),
|
||||
`probeRejectP95=${result.opfsProbeRejectMs.p95.toFixed(2)}ms`,
|
||||
`probeP95=${result.opfsExportProbeMs.p95.toFixed(2)}ms`,
|
||||
`exportP95=${result.opfsExportSnapshotMs.p95.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
formatSummary("indexeddb-hydrate", indexedDbHydrateSamples),
|
||||
formatSummary("opfs-hydrate", opfsHydrateSamples),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await setupIndexedDbTestEnv();
|
||||
const results = {};
|
||||
for (const preset of SIZE_PRESETS) {
|
||||
results[preset.label] = await runPreset(preset);
|
||||
}
|
||||
if (outputJson) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
runs: RUNS,
|
||||
presets: results,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs";
|
||||
|
||||
const RUNS = 4;
|
||||
const outputJson = process.argv.includes("--json");
|
||||
const SIZE_PRESETS = [
|
||||
{ label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600, churn: 0.08 },
|
||||
{ label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800, churn: 0.1 },
|
||||
@@ -260,6 +261,11 @@ async function runPreset(preset) {
|
||||
const opfsCommitSamples = [];
|
||||
const snapshotNodesSamples = [];
|
||||
const hydrateRuntimeMetaSamples = [];
|
||||
const hydrateNodesSamples = [];
|
||||
const hydrateEdgesSamples = [];
|
||||
const hydrateStateSamples = [];
|
||||
const hydrateNormalizeSamples = [];
|
||||
const hydrateIntegritySamples = [];
|
||||
const walFileWriteSamples = [];
|
||||
const manifestFileWriteSamples = [];
|
||||
|
||||
@@ -295,31 +301,64 @@ async function runPreset(preset) {
|
||||
opfsCommitSamples.push(opfsCommitResult.elapsedMs);
|
||||
snapshotNodesSamples.push(Number(afterSnapshotResult.diagnostics?.nodesMs || 0));
|
||||
hydrateRuntimeMetaSamples.push(Number(hydrateResult.diagnostics?.runtimeMetaMs || 0));
|
||||
hydrateNodesSamples.push(Number(hydrateResult.diagnostics?.nodesMs || 0));
|
||||
hydrateEdgesSamples.push(Number(hydrateResult.diagnostics?.edgesMs || 0));
|
||||
hydrateStateSamples.push(Number(hydrateResult.diagnostics?.stateMs || 0));
|
||||
hydrateNormalizeSamples.push(Number(hydrateResult.diagnostics?.normalizeMs || 0));
|
||||
hydrateIntegritySamples.push(Number(hydrateResult.diagnostics?.integrityMs || 0));
|
||||
walFileWriteSamples.push(Number(opfsCommitResult.diagnostics?.walFileWriteMs || 0));
|
||||
manifestFileWriteSamples.push(
|
||||
Number(opfsCommitResult.diagnostics?.manifestFileWriteMs || 0),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n[ST-BME][persist-load-bench] ${preset.label}`);
|
||||
console.log(
|
||||
formatSummary("snapshot-build", snapshotBuildSamples),
|
||||
`nodesPhaseP95=${summarize(snapshotNodesSamples).p95.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
formatSummary("hydrate", hydrateSamples),
|
||||
`runtimeMetaP95=${summarize(hydrateRuntimeMetaSamples).p95.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
formatSummary("opfs-commit", opfsCommitSamples),
|
||||
`walFileP95=${summarize(walFileWriteSamples).p95.toFixed(2)}ms`,
|
||||
`manifestFileP95=${summarize(manifestFileWriteSamples).p95.toFixed(2)}ms`,
|
||||
);
|
||||
const result = {
|
||||
snapshotBuildMs: summarize(snapshotBuildSamples),
|
||||
snapshotNodesMs: summarize(snapshotNodesSamples),
|
||||
hydrateMs: summarize(hydrateSamples),
|
||||
hydrateNodesMs: summarize(hydrateNodesSamples),
|
||||
hydrateEdgesMs: summarize(hydrateEdgesSamples),
|
||||
hydrateStateMs: summarize(hydrateStateSamples),
|
||||
hydrateNormalizeMs: summarize(hydrateNormalizeSamples),
|
||||
hydrateIntegrityMs: summarize(hydrateIntegritySamples),
|
||||
hydrateRuntimeMetaMs: summarize(hydrateRuntimeMetaSamples),
|
||||
opfsCommitMs: summarize(opfsCommitSamples),
|
||||
opfsWalFileWriteMs: summarize(walFileWriteSamples),
|
||||
opfsManifestFileWriteMs: summarize(manifestFileWriteSamples),
|
||||
};
|
||||
if (!outputJson) {
|
||||
console.log(`\n[ST-BME][persist-load-bench] ${preset.label}`);
|
||||
console.log(
|
||||
formatSummary("snapshot-build", snapshotBuildSamples),
|
||||
`nodesPhaseP95=${result.snapshotNodesMs.p95.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
formatSummary("hydrate", hydrateSamples),
|
||||
`nodesP95=${result.hydrateNodesMs.p95.toFixed(2)}ms`,
|
||||
`edgesP95=${result.hydrateEdgesMs.p95.toFixed(2)}ms`,
|
||||
`normalizeP95=${result.hydrateNormalizeMs.p95.toFixed(2)}ms`,
|
||||
`integrityP95=${result.hydrateIntegrityMs.p95.toFixed(2)}ms`,
|
||||
`runtimeMetaP95=${result.hydrateRuntimeMetaMs.p95.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
formatSummary("opfs-commit", opfsCommitSamples),
|
||||
`walFileP95=${result.opfsWalFileWriteMs.p95.toFixed(2)}ms`,
|
||||
`manifestFileP95=${result.opfsManifestFileWriteMs.p95.toFixed(2)}ms`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results = {};
|
||||
for (const preset of SIZE_PRESETS) {
|
||||
await runPreset(preset);
|
||||
results[preset.label] = await runPreset(preset);
|
||||
}
|
||||
if (outputJson) {
|
||||
console.log(JSON.stringify({
|
||||
runs: RUNS,
|
||||
presets: results,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user