Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
109 lines
4.8 KiB
Python
109 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Summarize ERR PERF windows from performance logs (also accepts older console logs).
|
|
|
|
Script wall_ms_per_s measures scoped elapsed time, not CPU utilization.
|
|
Parent/child scopes overlap; engine values are sampled gauges, not scopes.
|
|
"""
|
|
import argparse
|
|
import csv
|
|
import math
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
FIELDS = (
|
|
'run', 'window', 'elapsed_ms', 'final', 'phase_start', 'phase_end',
|
|
'metric', 'samples', 'sum', 'min', 'max', 'zero', 'over50', 'over100',
|
|
)
|
|
INTEGER_FIELDS = ('window', 'elapsed_ms', 'phase_start', 'phase_end',
|
|
'samples', 'zero', 'over50', 'over100')
|
|
SCRIPT_METRICS = {
|
|
'mission_tick_ms', 'maintenance_ms', 'civilians_ms', 'forces_ms',
|
|
'observers_ms', 'hq_tick_ms', 'listeners_ms', 'hq_prep_ms',
|
|
'hq_hunters_ms', 'hq_garrisons_ms', 'hq_convoys_ms', 'hq_bodies_ms',
|
|
'hq_radio_index_ms', 'hq_reports_ms', 'hq_planner_ms', 'hq_support_ms',
|
|
'hq_journal_ms', 'garrison_work_ms', 'performance_flush_ms',
|
|
}
|
|
|
|
|
|
def read_rows(paths):
|
|
seen = {}
|
|
for path in paths:
|
|
with path.open(encoding='utf-8', errors='replace') as stream:
|
|
for number, line in enumerate(stream, 1):
|
|
marker = line.find('ERR PERF run=')
|
|
if marker < 0:
|
|
continue
|
|
values = dict(re.findall(r'(\w+)=([^\s]+)', line[marker:]))
|
|
try:
|
|
row = {key: values[key] for key in FIELDS}
|
|
for key in INTEGER_FIELDS:
|
|
row[key] = int(row[key])
|
|
if row[key] < 0:
|
|
raise ValueError(f'negative {key}')
|
|
for key in ('sum', 'min', 'max'):
|
|
row[key] = float(row[key])
|
|
if not math.isfinite(row[key]) or row[key] < 0:
|
|
raise ValueError(f'invalid {key}')
|
|
if row['elapsed_ms'] == 0 or row['samples'] == 0:
|
|
raise ValueError('empty window/sample count')
|
|
if row['min'] > row['max']:
|
|
raise ValueError('min exceeds max')
|
|
if not 0 <= row['over100'] <= row['over50'] <= row['samples']:
|
|
raise ValueError('invalid threshold counts')
|
|
if row['zero'] > row['samples']:
|
|
raise ValueError('invalid zero count')
|
|
except (KeyError, ValueError) as error:
|
|
raise ValueError(f'{path}:{number}: malformed performance row: {error}') from error
|
|
key = row['run'], row['window'], row['metric']
|
|
if key in seen:
|
|
if seen[key] != row:
|
|
raise ValueError(f'{path}:{number}: conflicting duplicate {key}')
|
|
continue
|
|
seen[key] = row
|
|
return list(seen.values())
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('logs', nargs='+', type=Path)
|
|
parser.add_argument('--csv', action='store_true', help='output individual window rows as CSV')
|
|
args = parser.parse_args()
|
|
try:
|
|
rows = read_rows(args.logs)
|
|
if not rows:
|
|
raise ValueError('no ERR PERF measurement rows found')
|
|
except (OSError, ValueError) as error:
|
|
parser.error(str(error))
|
|
if args.csv:
|
|
writer = csv.DictWriter(sys.stdout, fieldnames=FIELDS)
|
|
writer.writeheader()
|
|
writer.writerows(rows)
|
|
return
|
|
grouped = defaultdict(list)
|
|
for row in rows:
|
|
grouped[row['run'], row['metric']].append(row)
|
|
print('Scope times overlap: do not add mission_tick, hq_tick and HQ children.')
|
|
print('Wall timings have 1 ms resolution; zero does not prove zero cost.')
|
|
print('Engine metrics are ~1 Hz samples of engine-reported values, NOT AI-only time.')
|
|
previous_run = None
|
|
for (run, metric), items in sorted(grouped.items()):
|
|
if run != previous_run:
|
|
print(f'\nRun {run}')
|
|
print(f'{"metric":26s} {"samples":>8s} {"mean":>10s} {"max":>10s} {"zero":>8s} {"wall_ms/s":>10s}')
|
|
previous_run = run
|
|
count = sum(item['samples'] for item in items)
|
|
total = sum(item['sum'] for item in items)
|
|
elapsed = sum(item['elapsed_ms'] for item in items)
|
|
maximum = max(item['max'] for item in items)
|
|
zero = sum(item['zero'] for item in items)
|
|
rate = f'{1000 * total / elapsed:.3f}' if metric in SCRIPT_METRICS else '-'
|
|
print(f'{metric:26s} {count:8d} {total / count:10.3f} {maximum:10.3f} {zero:8d} {rate:>10s}')
|
|
print('\nMeans/rates include only windows containing that metric; use --csv to compare matching windows and player counts.')
|
|
print('No native-AI timing or predicted offload speedup is inferred.')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|