test profile 300 NPC unification memory
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"start": "pnpm run build && node dist/index.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const vitestPath = path.join(packageRoot, 'node_modules', 'vitest', 'vitest.mjs');
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--expose-gc',
|
||||
vitestPath,
|
||||
'run',
|
||||
'--config',
|
||||
'vitest.config.ts',
|
||||
'--pool=threads',
|
||||
'--maxWorkers=1',
|
||||
'test/npcNationUprisingUnification.test.ts',
|
||||
],
|
||||
{
|
||||
cwd: packageRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NPC_UNIFICATION_MEMORY_PROFILE: '1',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
}
|
||||
);
|
||||
|
||||
child.once('error', (error) => {
|
||||
console.error('[npc-unification-memory] failed to start profiler', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
console.error(`[npc-unification-memory] profiler terminated by ${signal}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.exitCode = code ?? 1;
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
|
||||
import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
|
||||
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||
import { NpcUnificationMemoryProfiler } from './helpers/npcUnificationMemoryProfiler.js';
|
||||
|
||||
const mockDate = new Date('0181-08-01T00:00:00Z');
|
||||
|
||||
@@ -148,6 +152,8 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => {
|
||||
|
||||
describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
it('건국, 선포, 출병, 점령과 장기 국가 감소가 안정적으로 진행되어야 한다', async () => {
|
||||
const memoryProfileEnabled = process.env.NPC_UNIFICATION_MEMORY_PROFILE === '1';
|
||||
const profileStartedAtMs = performance.now();
|
||||
const cities = buildLargeTestCities().map(maxCityStats);
|
||||
for (const city of cities) {
|
||||
city.nationId = 0;
|
||||
@@ -253,6 +259,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
|
||||
const worldRef = { current: null as InMemoryTurnWorld | null };
|
||||
|
||||
let unificationLogObserved = false;
|
||||
const unificationHandler: TurnCalendarHandler = {
|
||||
onMonthChanged: () => {
|
||||
const world = worldRef.current;
|
||||
@@ -279,6 +286,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
}
|
||||
world.updateWorldMeta({ isUnited: 2 });
|
||||
world.pushLog(buildUnificationLog(winner.name));
|
||||
unificationLogObserved = true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -287,7 +295,14 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
let sortieCount = 0;
|
||||
let lastResolvedAction = 'none';
|
||||
|
||||
const { runUntil, getCollectedLogs, getCollectedLogsCount, getCollectedLogsRange } =
|
||||
const {
|
||||
runUntil,
|
||||
reservedTurnStore,
|
||||
getCollectedLogs,
|
||||
getCollectedLogsCount,
|
||||
getCollectedLogsRange,
|
||||
getAndClearCollectedLogs,
|
||||
} =
|
||||
await createTurnTestHarness({
|
||||
snapshot,
|
||||
state,
|
||||
@@ -326,6 +341,30 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
}
|
||||
},
|
||||
});
|
||||
const memoryProfiler =
|
||||
memoryProfileEnabled && worldRef.current
|
||||
? new NpcUnificationMemoryProfiler(worldRef.current, reservedTurnStore)
|
||||
: null;
|
||||
memoryProfiler?.sample('initialized');
|
||||
let lastProfileYearMonth = -1;
|
||||
const observeProfileMonth = (current: TurnWorldState) => {
|
||||
if (!memoryProfiler) {
|
||||
return;
|
||||
}
|
||||
const yearMonth = current.currentYear * 100 + current.currentMonth;
|
||||
if (yearMonth === lastProfileYearMonth) {
|
||||
return;
|
||||
}
|
||||
lastProfileYearMonth = yearMonth;
|
||||
memoryProfiler.observeTick();
|
||||
const logs = getAndClearCollectedLogs();
|
||||
if (logs.some((log) => log.text.includes('전토를 통일하였습니다.'))) {
|
||||
unificationLogObserved = true;
|
||||
}
|
||||
if (current.currentMonth === 1) {
|
||||
memoryProfiler.sample(`year-${current.currentYear}`);
|
||||
}
|
||||
};
|
||||
|
||||
let monthlyLogCursor = 0;
|
||||
const maxMonthlyLogEntries = 20;
|
||||
@@ -355,7 +394,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
|
||||
try {
|
||||
await runUntil(
|
||||
(current) => current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1)
|
||||
(current) => current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1),
|
||||
undefined,
|
||||
observeProfileMonth
|
||||
);
|
||||
|
||||
const world = worldRef.current;
|
||||
@@ -366,10 +407,14 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
|
||||
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
|
||||
const foundedNationCount = foundedNations.length;
|
||||
memoryProfiler?.sample('nations-founded');
|
||||
|
||||
await runUntil(
|
||||
(current) => current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6)
|
||||
(current) => current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6),
|
||||
undefined,
|
||||
observeProfileMonth
|
||||
);
|
||||
memoryProfiler?.sample('all-cities-occupied');
|
||||
|
||||
const neutralCities = world.listCities().filter((city) => city.nationId <= 0);
|
||||
expect(neutralCities.length).toBe(0);
|
||||
@@ -381,7 +426,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
|
||||
if (declarationCount === 0) {
|
||||
await runUntil(
|
||||
(current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1)
|
||||
(current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1),
|
||||
undefined,
|
||||
observeProfileMonth
|
||||
);
|
||||
if (declarationCount === 0) {
|
||||
const generals = world.listGenerals().filter((general) => general.nationId > 0);
|
||||
@@ -488,7 +535,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
await runUntil(
|
||||
(current) =>
|
||||
current.currentYear > target.year ||
|
||||
(current.currentYear === target.year && current.currentMonth >= target.month)
|
||||
(current.currentYear === target.year && current.currentMonth >= target.month),
|
||||
undefined,
|
||||
observeProfileMonth
|
||||
);
|
||||
//_dumpMonthlyLogs(`${target.year}-${String(target.month).padStart(2, '0')}`);
|
||||
|
||||
@@ -521,9 +570,12 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
await runUntil(
|
||||
(current) =>
|
||||
current.currentYear > nextMonth.year ||
|
||||
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month)
|
||||
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month),
|
||||
undefined,
|
||||
observeProfileMonth
|
||||
);
|
||||
unifiedAt = nextMonth;
|
||||
memoryProfiler?.sample('unified');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -537,7 +589,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
|
||||
const meta = world.getState().meta as Record<string, unknown>;
|
||||
const logs = getCollectedLogs();
|
||||
const hasUnificationLog = logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
|
||||
const hasUnificationLog =
|
||||
unificationLogObserved || logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
|
||||
if (unifiedAt) {
|
||||
expect(meta.isUnited).toBe(2);
|
||||
expect(hasUnificationLog).toBe(true);
|
||||
@@ -546,6 +599,39 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
expect(meta.isUnited ?? 0).toBe(0);
|
||||
}
|
||||
expect(sortieCount).toBeGreaterThan(0);
|
||||
|
||||
if (memoryProfiler) {
|
||||
expect(typeof globalThis.gc).toBe('function');
|
||||
expect(unifiedAt).not.toBeNull();
|
||||
if (!unifiedAt) {
|
||||
throw new Error('memory profile requires actual unification');
|
||||
}
|
||||
const report = memoryProfiler.buildReport({
|
||||
startedAtMs: profileStartedAtMs,
|
||||
initialGeneralCount: generals.length,
|
||||
foundedNationCount,
|
||||
declarationCount,
|
||||
sortieCount,
|
||||
unifiedAt,
|
||||
startYear: state.currentYear,
|
||||
startMonth: state.currentMonth,
|
||||
});
|
||||
const reportPath = resolve(
|
||||
process.env.NPC_UNIFICATION_MEMORY_REPORT_PATH ??
|
||||
'test-results/npc-unification-memory.json'
|
||||
);
|
||||
mkdirSync(dirname(reportPath), { recursive: true });
|
||||
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
console.log(
|
||||
`[NPC_UNIFICATION_MEMORY_REPORT]${JSON.stringify({
|
||||
reportPath,
|
||||
runtime: report.runtime,
|
||||
scenario: report.scenario,
|
||||
result: report.result,
|
||||
memory: report.memory,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const world = worldRef.current;
|
||||
if (world) {
|
||||
|
||||
Reference in New Issue
Block a user