Files
core2026/app/game-engine/test/helpers/npcLifecycleMemoryProfiler.ts
T

211 lines
7.8 KiB
TypeScript

import { performance } from 'node:perf_hooks';
import { serialize } from 'node:v8';
import type { InMemoryTurnWorld } from '../../src/turn/inMemoryWorld.js';
import type {
InMemoryReservedTurnStore,
ReservedTurnQueueCounts,
} from '../../src/turn/reservedTurnStore.js';
export type NpcLifecycleMemoryScenario =
| 'steady-state'
| 'growth'
| 'death-drain'
| 'balanced-churn'
| 'rollback-churn';
export interface ProcessMemorySnapshot {
rssBytes: number;
heapTotalBytes: number;
heapUsedBytes: number;
externalBytes: number;
arrayBuffersBytes: number;
}
export interface NpcLifecycleMemorySample {
cycle: number;
phase: 'initial' | 'in-transaction' | 'post-flush';
elapsedMs: number;
liveGeneralCount: number;
queueCounts: ReservedTurnQueueCounts;
process: ProcessMemorySnapshot;
pending?: {
createdGenerals: number;
deletedGenerals: number;
lifecycleEvents: number;
reservedGeneralQueues: number;
};
snapshot?: {
worldBytes: number;
reservedTurnBytes: number;
totalBytes: number;
cloneAndSerializeMs: number;
heapUsedAfterReleaseBytes: number;
};
}
export const readProcessMemory = (): ProcessMemorySnapshot => {
const usage = process.memoryUsage();
return {
rssBytes: usage.rss,
heapTotalBytes: usage.heapTotal,
heapUsedBytes: usage.heapUsed,
externalBytes: usage.external,
arrayBuffersBytes: usage.arrayBuffers,
};
};
export const linearRegressionSlope = (points: ReadonlyArray<{ x: number; y: number }>): number => {
if (points.length < 2) {
return 0;
}
const meanX = points.reduce((sum, point) => sum + point.x, 0) / points.length;
const meanY = points.reduce((sum, point) => sum + point.y, 0) / points.length;
let numerator = 0;
let denominator = 0;
for (const point of points) {
const xDelta = point.x - meanX;
numerator += xDelta * (point.y - meanY);
denominator += xDelta * xDelta;
}
return denominator === 0 ? 0 : numerator / denominator;
};
export const captureLifecycleMemorySample = (input: {
world: InMemoryTurnWorld;
reservedTurns: InMemoryReservedTurnStore;
startedAtMs: number;
cycle: number;
phase: NpcLifecycleMemorySample['phase'];
includePending: boolean;
includeSnapshot: boolean;
}): NpcLifecycleMemorySample => {
globalThis.gc?.();
const processSnapshot = readProcessMemory();
const pending = input.includePending
? (() => {
const worldChanges = input.world.peekDirtyState();
const reservedChanges = input.reservedTurns.peekDirtyState();
return {
createdGenerals: worldChanges.createdGenerals.length,
deletedGenerals: worldChanges.deletedGenerals.length,
lifecycleEvents: worldChanges.lifecycleEvents.length,
reservedGeneralQueues: reservedChanges.generalIds.length,
};
})()
: undefined;
const sample: NpcLifecycleMemorySample = {
cycle: input.cycle,
phase: input.phase,
elapsedMs: performance.now() - input.startedAtMs,
liveGeneralCount: input.world.getEntityCounts().generals,
queueCounts: input.reservedTurns.getQueueCounts(),
process: processSnapshot,
...(pending ? { pending } : {}),
};
if (input.includeSnapshot) {
const snapshotMetrics = (() => {
const snapshotStartedAt = performance.now();
const worldSnapshot = input.world.captureState();
const reservedSnapshot = input.reservedTurns.captureTransactionState();
const worldBytes = serialize(worldSnapshot).byteLength;
const reservedTurnBytes = serialize(reservedSnapshot).byteLength;
return {
worldBytes,
reservedTurnBytes,
cloneAndSerializeMs: performance.now() - snapshotStartedAt,
};
})();
globalThis.gc?.();
sample.snapshot = {
...snapshotMetrics,
totalBytes: snapshotMetrics.worldBytes + snapshotMetrics.reservedTurnBytes,
heapUsedAfterReleaseBytes: readProcessMemory().heapUsedBytes,
};
}
return sample;
};
const maxValue = (values: readonly number[]): number => Math.max(0, ...values);
export const buildNpcLifecycleMemoryReport = (input: {
scenario: NpcLifecycleMemoryScenario;
pruneDeletedQueues: boolean;
initialGeneralCount: number;
cycles: number;
batchSize: number;
sampleEvery: number;
createdTotal: number;
deletedTotal: number;
rolledBackCycles: number;
startedAtMs: number;
samples: NpcLifecycleMemorySample[];
}) => {
const retained = input.samples.filter(
(sample) => sample.phase === 'initial' || sample.phase === 'post-flush'
);
const warmSampleIndex = Math.floor(retained.length / 3);
const trendSamples = retained.slice(warmSampleIndex);
const first = retained[0];
const final = retained.at(-1);
const heapSlope = linearRegressionSlope(
trendSamples.map((sample) => ({ x: sample.cycle, y: sample.process.heapUsedBytes }))
);
const snapshotSlope = linearRegressionSlope(
trendSamples.flatMap((sample) =>
sample.snapshot ? [{ x: sample.cycle, y: sample.snapshot.totalBytes }] : []
)
);
const queueSlope = linearRegressionSlope(
trendSamples.map((sample) => ({ x: sample.cycle, y: sample.queueCounts.generalQueues }))
);
const lifecycleOperations = input.createdTotal + input.deletedTotal;
return {
schemaVersion: 1,
runtime: {
node: process.version,
platform: process.platform,
arch: process.arch,
explicitGc: typeof globalThis.gc === 'function',
},
scenario: {
name: input.scenario,
pruneDeletedQueues: input.pruneDeletedQueues,
initialGeneralCount: input.initialGeneralCount,
cycles: input.cycles,
batchSize: input.batchSize,
sampleEvery: input.sampleEvery,
},
result: {
createdTotal: input.createdTotal,
deletedTotal: input.deletedTotal,
rolledBackCycles: input.rolledBackCycles,
finalGeneralCount: final?.liveGeneralCount ?? 0,
finalGeneralQueueCount: final?.queueCounts.generalQueues ?? 0,
deadQueueRetentionCount:
(final?.queueCounts.generalQueues ?? 0) - (final?.liveGeneralCount ?? 0),
wallDurationMs: performance.now() - input.startedAtMs,
},
memory: {
retainedHeapStartBytes: first?.process.heapUsedBytes ?? 0,
retainedHeapFinalBytes: final?.process.heapUsedBytes ?? 0,
retainedHeapDeltaBytes:
(final?.process.heapUsedBytes ?? 0) - (first?.process.heapUsedBytes ?? 0),
retainedHeapSlopeBytesPerCycle: heapSlope,
retainedHeapSlopeBytesPerLifecycleOperation:
lifecycleOperations === 0 ? 0 : (heapSlope * input.cycles) / lifecycleOperations,
retainedSnapshotStartBytes: first?.snapshot?.totalBytes ?? 0,
retainedSnapshotFinalBytes: final?.snapshot?.totalBytes ?? 0,
retainedSnapshotDeltaBytes:
(final?.snapshot?.totalBytes ?? 0) - (first?.snapshot?.totalBytes ?? 0),
retainedSnapshotSlopeBytesPerCycle: snapshotSlope,
generalQueueSlopePerCycle: queueSlope,
maxObservedHeapUsedBytes: maxValue(input.samples.map((sample) => sample.process.heapUsedBytes)),
maxObservedRssBytes: maxValue(input.samples.map((sample) => sample.process.rssBytes)),
processResourceMaxRssBytes: process.resourceUsage().maxRSS * 1024,
},
samples: input.samples,
};
};