207 lines
7.9 KiB
TypeScript
207 lines
7.9 KiB
TypeScript
import { performance } from 'node:perf_hooks';
|
|
import { serialize } from 'node:v8';
|
|
|
|
import type { InMemoryReservedTurnStore } from '../../src/turn/reservedTurnStore.js';
|
|
import type { InMemoryTurnWorld } from '../../src/turn/inMemoryWorld.js';
|
|
|
|
type MemoryUsageSnapshot = {
|
|
rssBytes: number;
|
|
heapTotalBytes: number;
|
|
heapUsedBytes: number;
|
|
externalBytes: number;
|
|
arrayBuffersBytes: number;
|
|
};
|
|
|
|
export type NpcUnificationMemorySample = {
|
|
label: string;
|
|
year: number;
|
|
month: number;
|
|
activeNationCount: number;
|
|
generalCount: number;
|
|
cityCount: number;
|
|
troopCount: number;
|
|
processBeforeSnapshot: MemoryUsageSnapshot;
|
|
processWithSnapshot: MemoryUsageSnapshot;
|
|
processAfterRelease: MemoryUsageSnapshot;
|
|
worldSnapshotBytes: number;
|
|
reservedTurnSnapshotBytes: number;
|
|
totalParticipantSnapshotBytes: number;
|
|
participantSnapshotCloneMs: number;
|
|
};
|
|
|
|
type TickObservation = {
|
|
heapUsedBytes: number;
|
|
rssBytes: number;
|
|
};
|
|
|
|
const readMemoryUsage = (): MemoryUsageSnapshot => {
|
|
const usage = process.memoryUsage();
|
|
return {
|
|
rssBytes: usage.rss,
|
|
heapTotalBytes: usage.heapTotal,
|
|
heapUsedBytes: usage.heapUsed,
|
|
externalBytes: usage.external,
|
|
arrayBuffersBytes: usage.arrayBuffers,
|
|
};
|
|
};
|
|
|
|
const percentile = (values: number[], ratio: number): number => {
|
|
if (values.length === 0) {
|
|
return 0;
|
|
}
|
|
const sorted = [...values].sort((left, right) => left - right);
|
|
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1));
|
|
return sorted[index] ?? 0;
|
|
};
|
|
|
|
const average = (values: number[]): number =>
|
|
values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
|
|
export class NpcUnificationMemoryProfiler {
|
|
private readonly samples: NpcUnificationMemorySample[] = [];
|
|
private readonly tickObservations: TickObservation[] = [];
|
|
|
|
constructor(
|
|
private readonly world: InMemoryTurnWorld,
|
|
private readonly reservedTurns: InMemoryReservedTurnStore
|
|
) {}
|
|
|
|
observeTick(): void {
|
|
const usage = readMemoryUsage();
|
|
this.tickObservations.push({
|
|
heapUsedBytes: usage.heapUsedBytes,
|
|
rssBytes: usage.rssBytes,
|
|
});
|
|
}
|
|
|
|
sample(label: string): NpcUnificationMemorySample {
|
|
globalThis.gc?.();
|
|
const processBeforeSnapshot = readMemoryUsage();
|
|
const snapshotMetrics = (() => {
|
|
const startedAt = performance.now();
|
|
const worldSnapshot = this.world.captureState();
|
|
const reservedTurnSnapshot = this.reservedTurns.captureState();
|
|
return {
|
|
participantSnapshotCloneMs: performance.now() - startedAt,
|
|
processWithSnapshot: readMemoryUsage(),
|
|
worldSnapshotBytes: serialize(worldSnapshot).byteLength,
|
|
reservedTurnSnapshotBytes: serialize(reservedTurnSnapshot).byteLength,
|
|
};
|
|
})();
|
|
globalThis.gc?.();
|
|
const processAfterRelease = readMemoryUsage();
|
|
|
|
const state = this.world.getState();
|
|
const cities = this.world.listCities();
|
|
const nations = this.world.listNations();
|
|
const sample: NpcUnificationMemorySample = {
|
|
label,
|
|
year: state.currentYear,
|
|
month: state.currentMonth,
|
|
activeNationCount: nations.filter(
|
|
(nation) => nation.level > 0 && cities.some((city) => city.nationId === nation.id)
|
|
).length,
|
|
generalCount: this.world.listGenerals().length,
|
|
cityCount: cities.length,
|
|
troopCount: this.world.listTroops().length,
|
|
processBeforeSnapshot,
|
|
processWithSnapshot: snapshotMetrics.processWithSnapshot,
|
|
processAfterRelease,
|
|
worldSnapshotBytes: snapshotMetrics.worldSnapshotBytes,
|
|
reservedTurnSnapshotBytes: snapshotMetrics.reservedTurnSnapshotBytes,
|
|
totalParticipantSnapshotBytes:
|
|
snapshotMetrics.worldSnapshotBytes + snapshotMetrics.reservedTurnSnapshotBytes,
|
|
participantSnapshotCloneMs: snapshotMetrics.participantSnapshotCloneMs,
|
|
};
|
|
this.samples.push(sample);
|
|
return sample;
|
|
}
|
|
|
|
buildReport(input: {
|
|
startedAtMs: number;
|
|
initialGeneralCount: number;
|
|
foundedNationCount: number;
|
|
declarationCount: number;
|
|
sortieCount: number;
|
|
unifiedAt: { year: number; month: number };
|
|
startYear: number;
|
|
startMonth: number;
|
|
}) {
|
|
const cloneTimes = this.samples.map((sample) => sample.participantSnapshotCloneMs);
|
|
const snapshotSizes = this.samples.map((sample) => sample.totalParticipantSnapshotBytes);
|
|
const retainedSnapshotHeapDeltas = this.samples.map(
|
|
(sample) =>
|
|
sample.processWithSnapshot.heapUsedBytes - sample.processBeforeSnapshot.heapUsedBytes
|
|
);
|
|
const releasedSnapshotHeapDeltas = this.samples.map(
|
|
(sample) =>
|
|
sample.processAfterRelease.heapUsedBytes - sample.processBeforeSnapshot.heapUsedBytes
|
|
);
|
|
const observedHeap = [
|
|
...this.tickObservations.map((entry) => entry.heapUsedBytes),
|
|
...this.samples.flatMap((sample) => [
|
|
sample.processBeforeSnapshot.heapUsedBytes,
|
|
sample.processWithSnapshot.heapUsedBytes,
|
|
sample.processAfterRelease.heapUsedBytes,
|
|
]),
|
|
];
|
|
const observedRss = [
|
|
...this.tickObservations.map((entry) => entry.rssBytes),
|
|
...this.samples.flatMap((sample) => [
|
|
sample.processBeforeSnapshot.rssBytes,
|
|
sample.processWithSnapshot.rssBytes,
|
|
sample.processAfterRelease.rssBytes,
|
|
]),
|
|
];
|
|
const startIndex = input.startYear * 12 + input.startMonth - 1;
|
|
const unifiedIndex = input.unifiedAt.year * 12 + input.unifiedAt.month - 1;
|
|
|
|
return {
|
|
schemaVersion: 1,
|
|
runtime: {
|
|
node: process.version,
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
explicitGc: typeof globalThis.gc === 'function',
|
|
},
|
|
scenario: {
|
|
name: 'npcNationUprisingUnification-large-test-map',
|
|
initialGeneralCount: input.initialGeneralCount,
|
|
cityCount: this.samples[0]?.cityCount ?? 0,
|
|
startYear: input.startYear,
|
|
startMonth: input.startMonth,
|
|
},
|
|
result: {
|
|
unifiedAt: input.unifiedAt,
|
|
simulatedMonths: unifiedIndex - startIndex,
|
|
foundedNationCount: input.foundedNationCount,
|
|
declarationCount: input.declarationCount,
|
|
sortieCount: input.sortieCount,
|
|
wallDurationMs: performance.now() - input.startedAtMs,
|
|
},
|
|
memory: {
|
|
processIncludes: ['node', 'vitest-worker', 'scenario-harness', 'engine-state'],
|
|
maxObservedHeapUsedBytes: Math.max(0, ...observedHeap),
|
|
maxObservedRssBytes: Math.max(0, ...observedRss),
|
|
processResourceMaxRssBytes: process.resourceUsage().maxRSS * 1024,
|
|
participantSnapshotBytes: {
|
|
initial: snapshotSizes[0] ?? 0,
|
|
final: snapshotSizes.at(-1) ?? 0,
|
|
peak: Math.max(0, ...snapshotSizes),
|
|
},
|
|
participantSnapshotHeapDeltaBytes: {
|
|
peakWhileRetained: Math.max(0, ...retainedSnapshotHeapDeltas),
|
|
peakAfterRelease: Math.max(0, ...releasedSnapshotHeapDeltas),
|
|
},
|
|
participantSnapshotCloneMs: {
|
|
count: cloneTimes.length,
|
|
average: average(cloneTimes),
|
|
p95: percentile(cloneTimes, 0.95),
|
|
max: Math.max(0, ...cloneTimes),
|
|
},
|
|
},
|
|
samples: this.samples,
|
|
};
|
|
}
|
|
}
|