perf(game-engine): 대규모 NPC 천통 시간을 계측
시나리오 2601의 880명·94도시를 DB와 Redis 없이 자동 실행하고 커맨드, 수뇌 여부, 연월별 시간을 JSON으로 기록한다.
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import os from 'node:os';
|
||||
|
||||
import type { createReservedTurnHandler } from '../../src/turn/reservedTurnHandler.js';
|
||||
|
||||
export type ProfiledAction = Parameters<
|
||||
NonNullable<Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled']>
|
||||
>[0];
|
||||
|
||||
type DurationSeries = {
|
||||
durationsNs: number[];
|
||||
totalNs: number;
|
||||
};
|
||||
|
||||
type MonthBucket = {
|
||||
year: number;
|
||||
month: number;
|
||||
generalTurns: number;
|
||||
chiefGeneralTurns: number;
|
||||
ordinaryGeneralTurns: number;
|
||||
totalGeneralTurnNs: number;
|
||||
aiDecisionCount: number;
|
||||
aiDecisionNs: number;
|
||||
commandCount: number;
|
||||
commandExecutionNs: number;
|
||||
activeNationCount: number;
|
||||
generalCount: number;
|
||||
};
|
||||
|
||||
const createSeries = (): DurationSeries => ({ durationsNs: [], totalNs: 0 });
|
||||
|
||||
const percentile = (values: readonly 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 maximum = (values: readonly number[]): number => {
|
||||
let result = 0;
|
||||
for (const value of values) result = Math.max(result, value);
|
||||
return result;
|
||||
};
|
||||
|
||||
const summarizeSeries = (series: DurationSeries) => ({
|
||||
count: series.durationsNs.length,
|
||||
totalMs: series.totalNs / 1_000_000,
|
||||
averageMs: series.durationsNs.length > 0 ? series.totalNs / series.durationsNs.length / 1_000_000 : 0,
|
||||
p50Ms: percentile(series.durationsNs, 0.5) / 1_000_000,
|
||||
p95Ms: percentile(series.durationsNs, 0.95) / 1_000_000,
|
||||
p99Ms: percentile(series.durationsNs, 0.99) / 1_000_000,
|
||||
maxMs: maximum(series.durationsNs) / 1_000_000,
|
||||
});
|
||||
|
||||
const monthKey = (year: number, month: number): string => `${year}-${String(month).padStart(2, '0')}`;
|
||||
|
||||
export class NpcUnificationTimingProfiler {
|
||||
private readonly commandSeries = new Map<string, DurationSeries>();
|
||||
private readonly commandAiSeries = new Map<string, DurationSeries>();
|
||||
private readonly decisionSeries = new Map<'chief' | 'ordinary', DurationSeries>([
|
||||
['chief', createSeries()],
|
||||
['ordinary', createSeries()],
|
||||
]);
|
||||
private readonly turnSeries = new Map<'chief' | 'ordinary', DurationSeries>([
|
||||
['chief', createSeries()],
|
||||
['ordinary', createSeries()],
|
||||
]);
|
||||
private readonly months = new Map<string, MonthBucket>();
|
||||
private readonly monthWallMs = new Map<string, number>();
|
||||
private maxHeapUsedBytes = 0;
|
||||
private maxRssBytes = 0;
|
||||
|
||||
private getMonth(year: number, month: number): MonthBucket {
|
||||
const key = monthKey(year, month);
|
||||
const existing = this.months.get(key);
|
||||
if (existing) return existing;
|
||||
const created: MonthBucket = {
|
||||
year,
|
||||
month,
|
||||
generalTurns: 0,
|
||||
chiefGeneralTurns: 0,
|
||||
ordinaryGeneralTurns: 0,
|
||||
totalGeneralTurnNs: 0,
|
||||
aiDecisionCount: 0,
|
||||
aiDecisionNs: 0,
|
||||
commandCount: 0,
|
||||
commandExecutionNs: 0,
|
||||
activeNationCount: 0,
|
||||
generalCount: 0,
|
||||
};
|
||||
this.months.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
observeAction(payload: ProfiledAction): void {
|
||||
const commandKey = `${payload.kind}:${payload.actionKey}`;
|
||||
const actionDurationNs = Number(payload.actionDurationNs);
|
||||
const command = this.commandSeries.get(commandKey) ?? createSeries();
|
||||
command.durationsNs.push(actionDurationNs);
|
||||
command.totalNs += actionDurationNs;
|
||||
this.commandSeries.set(commandKey, command);
|
||||
|
||||
const month = this.getMonth(payload.year, payload.month);
|
||||
month.commandCount += 1;
|
||||
month.commandExecutionNs += actionDurationNs;
|
||||
|
||||
if (!payload.usedAi) return;
|
||||
const decisionDurationNs = Number(payload.aiDecisionDurationNs);
|
||||
const commandAi = this.commandAiSeries.get(commandKey) ?? createSeries();
|
||||
commandAi.durationsNs.push(decisionDurationNs);
|
||||
commandAi.totalNs += decisionDurationNs;
|
||||
this.commandAiSeries.set(commandKey, commandAi);
|
||||
|
||||
const officerGroup = payload.officerLevel >= 5 ? 'chief' : 'ordinary';
|
||||
const decision = this.decisionSeries.get(officerGroup)!;
|
||||
decision.durationsNs.push(decisionDurationNs);
|
||||
decision.totalNs += decisionDurationNs;
|
||||
month.aiDecisionCount += 1;
|
||||
month.aiDecisionNs += decisionDurationNs;
|
||||
}
|
||||
|
||||
observeGeneralTurn(input: { year: number; month: number; officerLevel: number; durationNs: bigint }): void {
|
||||
const durationNs = Number(input.durationNs);
|
||||
const officerGroup = input.officerLevel >= 5 ? 'chief' : 'ordinary';
|
||||
const series = this.turnSeries.get(officerGroup)!;
|
||||
series.durationsNs.push(durationNs);
|
||||
series.totalNs += durationNs;
|
||||
|
||||
const month = this.getMonth(input.year, input.month);
|
||||
month.generalTurns += 1;
|
||||
month.totalGeneralTurnNs += durationNs;
|
||||
if (officerGroup === 'chief') month.chiefGeneralTurns += 1;
|
||||
else month.ordinaryGeneralTurns += 1;
|
||||
}
|
||||
|
||||
observeMonth(input: {
|
||||
year: number;
|
||||
month: number;
|
||||
wallDurationMs: number;
|
||||
activeNationCount: number;
|
||||
generalCount: number;
|
||||
}): void {
|
||||
this.monthWallMs.set(monthKey(input.year, input.month), input.wallDurationMs);
|
||||
const month = this.getMonth(input.year, input.month);
|
||||
month.activeNationCount = input.activeNationCount;
|
||||
month.generalCount = input.generalCount;
|
||||
const usage = process.memoryUsage();
|
||||
this.maxHeapUsedBytes = Math.max(this.maxHeapUsedBytes, usage.heapUsed);
|
||||
this.maxRssBytes = Math.max(this.maxRssBytes, usage.rss);
|
||||
}
|
||||
|
||||
buildReport(input: {
|
||||
startedAtNs: bigint;
|
||||
scenarioId: number;
|
||||
scenarioTitle: string;
|
||||
hiddenSeed: string;
|
||||
initialGeneralCount: number;
|
||||
initialCityCount: number;
|
||||
startYear: number;
|
||||
startMonth: number;
|
||||
finalYear: number;
|
||||
finalMonth: number;
|
||||
finalGeneralCount: number;
|
||||
foundedNationCount: number;
|
||||
finalNationCount: number;
|
||||
unificationReached: boolean;
|
||||
convergenceAssist: string;
|
||||
discardedDrafts: { logs: number; messages: number; neutralAuctions: number };
|
||||
}) {
|
||||
const commandKeys = Array.from(this.commandSeries.keys()).sort();
|
||||
const commands = commandKeys.map((key) => ({
|
||||
key,
|
||||
execution: summarizeSeries(this.commandSeries.get(key)!),
|
||||
aiDecision: summarizeSeries(this.commandAiSeries.get(key) ?? createSeries()),
|
||||
}));
|
||||
const months = Array.from(this.months.entries())
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, bucket]) => ({
|
||||
key,
|
||||
year: bucket.year,
|
||||
month: bucket.month,
|
||||
generalTurns: bucket.generalTurns,
|
||||
chiefGeneralTurns: bucket.chiefGeneralTurns,
|
||||
ordinaryGeneralTurns: bucket.ordinaryGeneralTurns,
|
||||
activeNationCount: bucket.activeNationCount,
|
||||
generalCount: bucket.generalCount,
|
||||
wallDurationMs: this.monthWallMs.get(key) ?? 0,
|
||||
totalGeneralTurnMs: bucket.totalGeneralTurnNs / 1_000_000,
|
||||
averageGeneralTurnMs:
|
||||
bucket.generalTurns > 0 ? bucket.totalGeneralTurnNs / bucket.generalTurns / 1_000_000 : 0,
|
||||
aiDecisionCount: bucket.aiDecisionCount,
|
||||
totalAiDecisionMs: bucket.aiDecisionNs / 1_000_000,
|
||||
averageAiDecisionMs:
|
||||
bucket.aiDecisionCount > 0 ? bucket.aiDecisionNs / bucket.aiDecisionCount / 1_000_000 : 0,
|
||||
commandCount: bucket.commandCount,
|
||||
totalCommandExecutionMs: bucket.commandExecutionNs / 1_000_000,
|
||||
averageCommandExecutionMs:
|
||||
bucket.commandCount > 0 ? bucket.commandExecutionNs / bucket.commandCount / 1_000_000 : 0,
|
||||
}));
|
||||
const startIndex = input.startYear * 12 + input.startMonth - 1;
|
||||
const finalIndex = input.finalYear * 12 + input.finalMonth - 1;
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtime: {
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
cpuModel: os.cpus()[0]?.model ?? 'unknown',
|
||||
logicalCpuCount: os.cpus().length,
|
||||
totalMemoryBytes: os.totalmem(),
|
||||
},
|
||||
scenario: {
|
||||
id: input.scenarioId,
|
||||
title: input.scenarioTitle,
|
||||
hiddenSeed: input.hiddenSeed,
|
||||
initialGeneralCount: input.initialGeneralCount,
|
||||
initialCityCount: input.initialCityCount,
|
||||
startYear: input.startYear,
|
||||
startMonth: input.startMonth,
|
||||
convergenceAssist: input.convergenceAssist,
|
||||
},
|
||||
result: {
|
||||
unificationReached: input.unificationReached,
|
||||
finalYear: input.finalYear,
|
||||
finalMonth: input.finalMonth,
|
||||
simulatedMonths: finalIndex - startIndex,
|
||||
finalGeneralCount: input.finalGeneralCount,
|
||||
foundedNationCount: input.foundedNationCount,
|
||||
finalNationCount: input.finalNationCount,
|
||||
wallDurationMs: Number(process.hrtime.bigint() - input.startedAtNs) / 1_000_000,
|
||||
discardedDrafts: input.discardedDrafts,
|
||||
},
|
||||
npcDecisionByOfficerGroup: {
|
||||
chief: summarizeSeries(this.decisionSeries.get('chief')!),
|
||||
ordinary: summarizeSeries(this.decisionSeries.get('ordinary')!),
|
||||
},
|
||||
generalTurnByOfficerGroup: {
|
||||
chief: summarizeSeries(this.turnSeries.get('chief')!),
|
||||
ordinary: summarizeSeries(this.turnSeries.get('ordinary')!),
|
||||
},
|
||||
memory: {
|
||||
maxObservedHeapUsedBytes: this.maxHeapUsedBytes,
|
||||
maxObservedRssBytes: this.maxRssBytes,
|
||||
processResourceMaxRssBytes: process.resourceUsage().maxRSS * 1024,
|
||||
},
|
||||
commands,
|
||||
months,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -67,10 +67,12 @@ export type TurnTestHarnessOptions = {
|
||||
};
|
||||
turnProcessorOptions?: {
|
||||
tickMinutes: number;
|
||||
beforeExecuteGeneral?: InMemoryTurnProcessorOptions['beforeExecuteGeneral'];
|
||||
afterExecuteGeneral?: InMemoryTurnProcessorOptions['afterExecuteGeneral'];
|
||||
};
|
||||
worldRef?: { current: InMemoryTurnWorld | null };
|
||||
onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved'];
|
||||
onActionProfiled?: Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled'];
|
||||
commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory'];
|
||||
wrapGeneralTurnHandler?: (handler: GeneralTurnHandler) => GeneralTurnHandler;
|
||||
extraCalendarHandlers?: TurnCalendarHandler[];
|
||||
@@ -110,6 +112,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
|
||||
unitSet: options.snapshot.unitSet,
|
||||
getWorld: () => worldRef.current,
|
||||
onActionResolved: options.onActionResolved,
|
||||
onActionProfiled: options.onActionProfiled,
|
||||
commandRngFactory: options.commandRngFactory,
|
||||
});
|
||||
|
||||
@@ -150,6 +153,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
|
||||
|
||||
const processor = new InMemoryTurnProcessor(world, {
|
||||
tickMinutes: options.turnProcessorOptions?.tickMinutes ?? 10,
|
||||
beforeExecuteGeneral: options.turnProcessorOptions?.beforeExecuteGeneral,
|
||||
afterExecuteGeneral: options.turnProcessorOptions?.afterExecuteGeneral,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user