perf: NPC 생명주기 메모리 프로파일과 큐 정리를 추가한다
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { buildScenarioBootstrap, type TurnSchedule } from '@sammo-ts/logic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadMapDefinitionByName } from '../src/scenario/mapLoader.js';
|
||||
import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js';
|
||||
import { loadUnitSetDefinitionByName } from '../src/scenario/unitSetLoader.js';
|
||||
import { EngineStateManager } from '../src/turn/engineStateManager.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import {
|
||||
buildNpcLifecycleMemoryReport,
|
||||
captureLifecycleMemorySample,
|
||||
type NpcLifecycleMemorySample,
|
||||
type NpcLifecycleMemoryScenario,
|
||||
} from './helpers/npcLifecycleMemoryProfiler.js';
|
||||
|
||||
const profileEnabled = process.env.NPC_LIFECYCLE_MEMORY_PROFILE === '1';
|
||||
const profileDescribe = describe.runIf(profileEnabled);
|
||||
const SCENARIO_ID = 2601;
|
||||
const HIDDEN_SEED = 'scenario-2601-npc-lifecycle-memory-v1';
|
||||
const ROLLBACK_SENTINEL = new Error('npc-lifecycle-memory-rollback');
|
||||
const VALID_SCENARIOS = new Set<NpcLifecycleMemoryScenario>([
|
||||
'steady-state',
|
||||
'growth',
|
||||
'death-drain',
|
||||
'balanced-churn',
|
||||
'rollback-churn',
|
||||
]);
|
||||
|
||||
const readPositiveInteger = (name: string, fallback: number): number => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer: ${raw}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const createGameDate = (year: number, month: number): Date => {
|
||||
const date = new Date(0);
|
||||
date.setUTCFullYear(year, month - 1, 1);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
return date;
|
||||
};
|
||||
|
||||
const buildTurnGeneral = (
|
||||
domainGeneral: ReturnType<typeof buildScenarioBootstrap>['snapshot']['generals'][number],
|
||||
seedGeneral: ReturnType<typeof buildScenarioBootstrap>['seed']['generals'][number],
|
||||
startTime: Date,
|
||||
startYear: number,
|
||||
startMonth: number
|
||||
): TurnGeneral => {
|
||||
const deathMonthRaw = seedGeneral.meta.deathMonth;
|
||||
const deathMonth =
|
||||
typeof deathMonthRaw === 'number' && Number.isInteger(deathMonthRaw) ? deathMonthRaw : startMonth;
|
||||
const killturn = Math.max(0, (seedGeneral.deathYear - startYear) * 12 + deathMonth - startMonth);
|
||||
return {
|
||||
...domainGeneral,
|
||||
userId: null,
|
||||
bornYear: seedGeneral.birthYear,
|
||||
deadYear: seedGeneral.deathYear,
|
||||
affinity: seedGeneral.affinity,
|
||||
picture: seedGeneral.picture === null ? null : String(seedGeneral.picture),
|
||||
startAge: 20,
|
||||
turnTime: new Date(startTime),
|
||||
recentWarTime: null,
|
||||
lastTurn: { command: '휴식' },
|
||||
penalty: {},
|
||||
inheritancePoints: {},
|
||||
meta: {
|
||||
...domainGeneral.meta,
|
||||
...seedGeneral.meta,
|
||||
killturn,
|
||||
npcType: seedGeneral.npcType,
|
||||
crewTypeId: seedGeneral.crewTypeId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const cloneNpcGeneral = (source: TurnGeneral, id: number): TurnGeneral => {
|
||||
const cloned = structuredClone(source);
|
||||
return {
|
||||
...cloned,
|
||||
id,
|
||||
name: `${source.name}#M${id}`,
|
||||
userId: null,
|
||||
npcState: Math.max(2, source.npcState),
|
||||
nationId: 0,
|
||||
cityId: 0,
|
||||
troopId: 0,
|
||||
officerLevel: 0,
|
||||
meta: {
|
||||
...cloned.meta,
|
||||
lifecycleMemoryFixture: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createProfileWorld = async (initialGeneralCount: number) => {
|
||||
const scenario = await loadScenarioDefinitionById(SCENARIO_ID);
|
||||
const map = await loadMapDefinitionByName(scenario.config.environment.mapName);
|
||||
const unitSet = await loadUnitSetDefinitionByName(scenario.config.environment.unitSet);
|
||||
const startYear = scenario.startYear ?? 180;
|
||||
const startMonth = 1;
|
||||
const startTime = createGameDate(startYear, startMonth);
|
||||
const bootstrap = buildScenarioBootstrap({
|
||||
scenario,
|
||||
map,
|
||||
unitSet,
|
||||
options: {
|
||||
hiddenSeed: HIDDEN_SEED,
|
||||
initialYear: startYear,
|
||||
initialMonth: startMonth,
|
||||
turnTermMinutes: 10,
|
||||
includeNeutralNationInSeed: true,
|
||||
},
|
||||
});
|
||||
if (bootstrap.warnings.length > 0) {
|
||||
throw new Error(`scenario bootstrap warnings: ${bootstrap.warnings.join(', ')}`);
|
||||
}
|
||||
const domainGeneralById = new Map(bootstrap.snapshot.generals.map((general) => [general.id, general]));
|
||||
const scenarioGenerals = bootstrap.seed.generals.map((seedGeneral) => {
|
||||
const domainGeneral = domainGeneralById.get(seedGeneral.id);
|
||||
if (!domainGeneral) {
|
||||
throw new Error(`missing scenario general: ${seedGeneral.id}`);
|
||||
}
|
||||
return buildTurnGeneral(domainGeneral, seedGeneral, startTime, startYear, startMonth);
|
||||
});
|
||||
const generals = Array.from({ length: initialGeneralCount }, (_, index) =>
|
||||
cloneNpcGeneral(scenarioGenerals[index % scenarioGenerals.length]!, index + 1)
|
||||
);
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: bootstrap.snapshot.scenarioConfig,
|
||||
scenarioMeta: bootstrap.snapshot.scenarioMeta,
|
||||
worldConfig: {
|
||||
fiction: scenario.fiction,
|
||||
npcMode: 2,
|
||||
turnTermMinutes: 10,
|
||||
tournamentTrig: false,
|
||||
},
|
||||
map,
|
||||
unitSet,
|
||||
generals,
|
||||
cities: bootstrap.snapshot.cities,
|
||||
nations: bootstrap.snapshot.nations,
|
||||
troops: bootstrap.snapshot.troops,
|
||||
diplomacy: bootstrap.snapshot.diplomacy.map((entry) => ({
|
||||
fromNationId: entry.fromNationId,
|
||||
toNationId: entry.toNationId,
|
||||
state: entry.state,
|
||||
term: entry.durationMonths,
|
||||
dead: 0,
|
||||
meta: {},
|
||||
})),
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
};
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: startYear,
|
||||
currentMonth: startMonth,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: startTime,
|
||||
clockBaseTime: startTime,
|
||||
clockTick: 0,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: startTime,
|
||||
lastTurnTick: 0,
|
||||
meta: {
|
||||
scenarioId: SCENARIO_ID,
|
||||
hiddenSeed: HIDDEN_SEED,
|
||||
killturn: 480,
|
||||
lastGeneralId: initialGeneralCount,
|
||||
serverId: 'npc-lifecycle-memory-profile',
|
||||
},
|
||||
};
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
const reservedTurns = new InMemoryReservedTurnStore({} as never, {
|
||||
maxGeneralTurns: 30,
|
||||
maxNationTurns: 12,
|
||||
leaseOwner: 'npc-lifecycle-memory-profile',
|
||||
});
|
||||
for (const general of generals) {
|
||||
reservedTurns.getGeneralTurns(general.id);
|
||||
}
|
||||
return { world, reservedTurns, templateGenerals: scenarioGenerals };
|
||||
};
|
||||
|
||||
profileDescribe('NPC 생성·사망 장기 구동 메모리 프로파일', () => {
|
||||
it('격리 시나리오의 GC 안정 heap, rollback snapshot과 예약 큐 보유량을 기록한다', async () => {
|
||||
expect(typeof globalThis.gc).toBe('function');
|
||||
const rawScenario = process.env.NPC_LIFECYCLE_MEMORY_SCENARIO ?? 'balanced-churn';
|
||||
if (!VALID_SCENARIOS.has(rawScenario as NpcLifecycleMemoryScenario)) {
|
||||
throw new Error(`unknown NPC lifecycle memory scenario: ${rawScenario}`);
|
||||
}
|
||||
const scenario = rawScenario as NpcLifecycleMemoryScenario;
|
||||
const cycles = readPositiveInteger('NPC_LIFECYCLE_MEMORY_CYCLES', 80);
|
||||
const batchSize = readPositiveInteger('NPC_LIFECYCLE_MEMORY_BATCH_SIZE', 100);
|
||||
const sampleEvery = readPositiveInteger('NPC_LIFECYCLE_MEMORY_SAMPLE_EVERY', 5);
|
||||
const baseGeneralCount = readPositiveInteger('NPC_LIFECYCLE_MEMORY_BASE_GENERALS', 1_200);
|
||||
const pruneDeletedQueues = process.env.NPC_LIFECYCLE_MEMORY_PRUNE_DELETED === '1';
|
||||
const initialGeneralCount =
|
||||
scenario === 'death-drain' ? baseGeneralCount + cycles * batchSize : baseGeneralCount;
|
||||
const { world, reservedTurns, templateGenerals } = await createProfileWorld(initialGeneralCount);
|
||||
const stateManager = new EngineStateManager();
|
||||
stateManager.register('world', {
|
||||
capture: () => world.captureState(),
|
||||
restore: (snapshot) => world.restoreState(snapshot),
|
||||
});
|
||||
stateManager.register('reservedTurns', {
|
||||
capture: () => reservedTurns.captureTransactionState(),
|
||||
restore: (snapshot) => reservedTurns.restoreState(snapshot),
|
||||
});
|
||||
|
||||
const startedAtMs = performance.now();
|
||||
const samples: NpcLifecycleMemorySample[] = [
|
||||
captureLifecycleMemorySample({
|
||||
world,
|
||||
reservedTurns,
|
||||
startedAtMs,
|
||||
cycle: 0,
|
||||
phase: 'initial',
|
||||
includePending: false,
|
||||
includeSnapshot: true,
|
||||
}),
|
||||
];
|
||||
const activeGeneralIds = world
|
||||
.listGenerals()
|
||||
.map((general) => general.id)
|
||||
.sort((left, right) => left - right);
|
||||
let nextGeneralId = Math.max(...activeGeneralIds) + 1;
|
||||
let createdTotal = 0;
|
||||
let deletedTotal = 0;
|
||||
let rolledBackCycles = 0;
|
||||
|
||||
const addGenerals = (count: number): void => {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const generalId = nextGeneralId++;
|
||||
const source = templateGenerals[(generalId - 1) % templateGenerals.length]!;
|
||||
if (!world.addGeneral(cloneNpcGeneral(source, generalId))) {
|
||||
throw new Error(`failed to add profile general ${generalId}`);
|
||||
}
|
||||
reservedTurns.ensureGeneralTurns(generalId);
|
||||
activeGeneralIds.push(generalId);
|
||||
createdTotal += 1;
|
||||
}
|
||||
};
|
||||
const deleteGenerals = (count: number): void => {
|
||||
const targetIds = activeGeneralIds.splice(0, count);
|
||||
for (const generalId of targetIds) {
|
||||
if (!world.deleteGeneralWithLifecycle(generalId, 180, 1)) {
|
||||
throw new Error(`failed to delete profile general ${generalId}`);
|
||||
}
|
||||
deletedTotal += 1;
|
||||
}
|
||||
};
|
||||
|
||||
for (let cycle = 1; cycle <= cycles; cycle += 1) {
|
||||
const sampledCycle = cycle % sampleEvery === 0 || cycle === cycles;
|
||||
try {
|
||||
await stateManager.transaction(() => {
|
||||
if (scenario === 'steady-state') {
|
||||
for (let index = 0; index < batchSize; index += 1) {
|
||||
const generalId = activeGeneralIds[((cycle - 1) * batchSize + index) % activeGeneralIds.length]!;
|
||||
const current = world.getGeneralById(generalId);
|
||||
if (!current) {
|
||||
throw new Error(`missing steady-state general ${generalId}`);
|
||||
}
|
||||
world.updateGeneral(generalId, { experience: current.experience + 1 });
|
||||
reservedTurns.shiftGeneralTurns(generalId, -1);
|
||||
}
|
||||
} else if (scenario === 'growth') {
|
||||
addGenerals(batchSize);
|
||||
} else if (scenario === 'death-drain') {
|
||||
deleteGenerals(batchSize);
|
||||
} else if (scenario === 'balanced-churn') {
|
||||
deleteGenerals(batchSize);
|
||||
addGenerals(batchSize);
|
||||
} else {
|
||||
addGenerals(batchSize);
|
||||
deleteGenerals(batchSize);
|
||||
}
|
||||
|
||||
if (sampledCycle) {
|
||||
samples.push(
|
||||
captureLifecycleMemorySample({
|
||||
world,
|
||||
reservedTurns,
|
||||
startedAtMs,
|
||||
cycle,
|
||||
phase: 'in-transaction',
|
||||
includePending: true,
|
||||
includeSnapshot: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (scenario === 'rollback-churn') {
|
||||
throw ROLLBACK_SENTINEL;
|
||||
}
|
||||
|
||||
const worldChanges = world.peekDirtyState();
|
||||
const reservedChanges = reservedTurns.peekDirtyState();
|
||||
world.acknowledgeDirtyState(worldChanges);
|
||||
reservedTurns.acknowledgeDirtyState(reservedChanges);
|
||||
if (pruneDeletedQueues) {
|
||||
reservedTurns.pruneDeletedEntityQueues(
|
||||
worldChanges.deletedGenerals,
|
||||
worldChanges.deletedNations
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (scenario !== 'rollback-churn' || error !== ROLLBACK_SENTINEL) {
|
||||
throw error;
|
||||
}
|
||||
rolledBackCycles += 1;
|
||||
activeGeneralIds.splice(0, activeGeneralIds.length, ...world.listGenerals().map((general) => general.id));
|
||||
}
|
||||
|
||||
if (sampledCycle) {
|
||||
samples.push(
|
||||
captureLifecycleMemorySample({
|
||||
world,
|
||||
reservedTurns,
|
||||
startedAtMs,
|
||||
cycle,
|
||||
phase: 'post-flush',
|
||||
includePending: true,
|
||||
includeSnapshot: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const report = buildNpcLifecycleMemoryReport({
|
||||
scenario,
|
||||
pruneDeletedQueues,
|
||||
initialGeneralCount,
|
||||
cycles,
|
||||
batchSize,
|
||||
sampleEvery,
|
||||
createdTotal,
|
||||
deletedTotal,
|
||||
rolledBackCycles,
|
||||
startedAtMs,
|
||||
samples,
|
||||
});
|
||||
const reportPath = resolve(
|
||||
process.env.NPC_LIFECYCLE_MEMORY_CHILD_REPORT_PATH ??
|
||||
`test-results/npc-lifecycle-memory-${scenario}.json`
|
||||
);
|
||||
mkdirSync(dirname(reportPath), { recursive: true });
|
||||
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
console.log(
|
||||
`[NPC_LIFECYCLE_MEMORY_REPORT]${JSON.stringify({
|
||||
reportPath,
|
||||
scenario: report.scenario,
|
||||
result: report.result,
|
||||
memory: report.memory,
|
||||
})}`
|
||||
);
|
||||
|
||||
const expectedFinalGeneralCount =
|
||||
scenario === 'growth'
|
||||
? initialGeneralCount + cycles * batchSize
|
||||
: scenario === 'death-drain'
|
||||
? baseGeneralCount
|
||||
: initialGeneralCount;
|
||||
expect(report.result.finalGeneralCount).toBe(expectedFinalGeneralCount);
|
||||
expect(report.result.rolledBackCycles).toBe(scenario === 'rollback-churn' ? cycles : 0);
|
||||
if (pruneDeletedQueues || !['death-drain', 'balanced-churn'].includes(scenario)) {
|
||||
expect(report.result.deadQueueRetentionCount).toBe(0);
|
||||
} else {
|
||||
expect(report.result.deadQueueRetentionCount).toBe(cycles * batchSize);
|
||||
}
|
||||
expect(world.peekDirtyState().lifecycleEvents).toHaveLength(0);
|
||||
expect(reservedTurns.peekDirtyState().generalIds).toHaveLength(0);
|
||||
}, 600_000);
|
||||
});
|
||||
Reference in New Issue
Block a user