251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|