Files
iss_pc/scripts/round_analyze.py
e7b467e992 Advanced launcher screen, bounded wait for compiling shaders, region trace and shader index (#100)
* Test: BC 1D on NVIDIA (TLG_BC_TINY=auto, promoted BC 1D answers dimension queries as 1D)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* Test script: occlusion and BP09 variants

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* Launcher: Advanced screen with the GPU/runtime tuning parameters (switches, numbers, lists)

31 environment-only parameters (synchronisation, threads, readbacks, pipelines, image
workarounds, diagnostics) with bilingual descriptions, Automatic/default handling, search,
restore defaults and a copyable combination. Choices live in tlg.ini (adv_<name>) and reach the
game as ISS_<name> only when changed; the Config: log block shows them.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* Test tools: one-round runner, monitor and analyzer for the parameter study

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* GPU: bounded wait for a compiling pipeline (TLG_ASYNC_WAIT_MS) and a faster driver cache save

A draw whose pipeline is still compiling in the background used to be skipped at once (missing
ground or effects while the shader cache is cold). With TLG_ASYNC_WAIT_MS=N (default 0 = off)
it first waits up to N ms for the compile, and the waits of one GPU frame add up to at most
TLG_ASYNC_WAIT_FRAME_MS (default 8). The log gets an 'Async wait (total)' line every 5 s.
The driver cache is saved every 60 s (TLG_DRIVER_CACHE_SAVE_S, was 180).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* GPU: bounded wait for a compiling pipeline (TLG_ASYNC_WAIT_MS) and a faster driver cache save

A draw whose pipeline is still compiling in the background used to be skipped at once (missing
ground or effects while the shader cache is cold). With TLG_ASYNC_WAIT_MS=N (default 0 = off)
it first waits up to N ms for the compile, and the waits of one GPU frame add up to at most
TLG_ASYNC_WAIT_FRAME_MS (default 8). The log gets an 'Async wait (total)' line every 5 s.
The driver cache is saved every 60 s (TLG_DRIVER_CACHE_SAVE_S, was 180).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* Launcher Advanced: async wait and driver cache save parameters

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* Tools: shader_index.py, an index of the OrbShdr shaders in the player's own dump

Streaming scan of art/cache (.xpps, KCAP + OrbShdr), SQLite index hash -> file/offset/size/stage,
lookup, verify against existing shader caches, stats. Reads the dump only; nothing of the game
is copied or committed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* GPU: region trace (TLG_REGION_TRACE=1), step A of the per-region pre-warm

Writes region_trace.tsv in the GPU user folder: every .xpps package the game opens (O) and
the first request of each graphics/compute pipeline (P), on one monotonic clock, so an offline
pass can tell which pipelines each region needs before anything is built on top of it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* GPU: the bounded wait also covers a shader still being translated

A new shader is the usual reason for a skipped draw (its pipeline is only queued once the shader
is translated), so GetProgram waits too, within the same per-draw and per-frame budget.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* GPU: region trace records whether a pipeline was ready; the async wait tally is a warning

The log keeps warnings and above, so the 'Async wait (total)' line never showed. The region trace's
P lines get a fifth column: r = the pipeline existed at its first request, m = it was missing.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* Docs and polish: Advanced screen, pre-warm notes, region trace parameter, test variant 8

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

* Restore the third_party submodule entries (a worktree's symlinks had been committed)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGrrT8Atu6mshYBUh1H1Mm

---------

Co-authored-by: HayatoG <[email protected]>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-15 21:19:49 -03:00

290 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""round_analyze.py - resume uma rodada de teste de desempenho do port inFAMOUS.
Uso (sempre com python3 -I):
python3 -I round_analyze.py summarize [--log LOG] [--mon DIR] [--window SEG]
[--skip SEG] [--name NOME] [--out DIR] [--sample-secs 5] [--min-fps 0]
python3 -I round_analyze.py compare --control A.json [B.json ...]
--variant C.json [D.json ...] [--csv saida.csv] [--noise-k 2]
summarize: le o log do jogo (linhas 'Frame stats' / 'Frame pacing', uma de cada
a cada ~5 s) e os CSVs do round_monitor.sh (gpu.csv, cpu.csv, mem.csv);
imprime o resumo e grava summary.json + summary.csv em --out (se dado).
--window SEG usa so os ultimos SEG segundos (log: SEG/sample-secs amostras;
CSVs: t >= t_final - SEG).
--skip SEG descarta os primeiros SEG segundos (aquecimento), antes da janela.
compare: tabela controle(s) vs variantes, diferenca em % sobre a media dos
controles e o desvio entre controles (ruido); marca '~' quando a diferenca
fica dentro do ruido.
"""
import argparse, csv, glob, json, math, os, re, statistics as st, sys
NUM = r"([0-9]+(?:\.[0-9]+)?)"
RE_STATS = re.compile(
r"Frame stats: " + NUM + r" FPS, worst frame " + NUM + r" ms.*?; "
+ r"(\d+) shader/pipeline compiles, " + NUM + r" ms.*?(\d+) pending draws")
RE_PACE = re.compile(
r"Frame pacing: median " + NUM + r" ms, stddev " + NUM + r" ms, p99 " + NUM
+ r" ms, (\d+) frames over")
RE_IDLE = re.compile(r"GPU thread .*?idle " + NUM + r"%")
THROTTLE = [(0x1, "gpu_idle"), (0x2, "app_clocks"), (0x4, "sw_power_cap"),
(0x8, "hw_slowdown"), (0x10, "sync_boost"), (0x20, "sw_thermal"),
(0x40, "hw_thermal"), (0x80, "hw_power_brake")]
# razoes que de fato limitam desempenho (gpu_idle/app_clocks/sync_boost nao)
LIMITING = {"sw_power_cap", "hw_slowdown", "sw_thermal", "hw_thermal", "hw_power_brake"}
def mean(v): return sum(v) / len(v) if v else None
def pct(a, b): return None if (a is None or not b) else 100.0 * (a - b) / b
def r(x, n=2): return None if x is None else round(x, n)
def resolve_log(path):
if path is None:
path = "port/out/last_run.log"
here = os.path.dirname(os.path.abspath(__file__))
cand = os.path.join(here, "..", path)
if os.path.exists(cand): path = cand
return os.path.realpath(path)
def parse_log(path, window_s, skip_s, sample_s, min_fps):
stats, pace = [], []
with open(path, errors="replace") as f:
for line in f:
if "Frame stats:" in line:
m = RE_STATS.search(line)
if m:
idle = RE_IDLE.search(line)
stats.append(dict(fps=float(m[1]), worst=float(m[2]), comp=int(m[3]),
comp_ms=float(m[4]), pend=int(m[5]),
gpu_idle=float(idle[1]) if idle else None))
elif "Frame pacing:" in line:
m = RE_PACE.search(line)
if m:
pace.append(dict(median=float(m[1]), std=float(m[2]), p99=float(m[3]), over=int(m[4])))
total = len(stats)
skip_n = int(round(skip_s / sample_s)) if skip_s else 0
stats, pace = stats[skip_n:], pace[skip_n:]
if window_s:
n = max(1, int(round(window_s / sample_s)))
stats, pace = stats[-n:], pace[-n:]
if min_fps:
keep = [i for i, s in enumerate(stats) if s["fps"] >= min_fps]
pace = [pace[i] for i in keep if i < len(pace)]
stats = [stats[i] for i in keep]
out = dict(log=path, log_samples_total=total, samples=len(stats), pacing_samples=len(pace))
if not stats: return out
fps = [s["fps"] for s in stats]
out.update(
fps_mean=r(mean(fps)), fps_min=r(min(fps)), fps_max=r(max(fps)),
fps_stdev=r(st.pstdev(fps)),
worst_frame_ms=r(max(s["worst"] for s in stats)),
worst_frame_mean_ms=r(mean([s["worst"] for s in stats])),
shader_compiles=sum(s["comp"] for s in stats),
shader_compile_ms=r(sum(s["comp_ms"] for s in stats)),
pending_draws_mean=r(mean([s["pend"] for s in stats])),
pending_draws_max=max(s["pend"] for s in stats),
)
idl = [s["gpu_idle"] for s in stats if s["gpu_idle"] is not None]
if idl: out["gpu_thread_idle_pct_mean"] = r(mean(idl))
if pace:
p99 = [p["p99"] for p in pace]
out.update(
pacing_median_ms=r(mean([p["median"] for p in pace]), 3),
pacing_stddev_ms=r(mean([p["std"] for p in pace]), 3),
pacing_p99_mean_ms=r(mean(p99), 3), pacing_p99_max_ms=r(max(p99), 3),
frames_over_1p5x=sum(p["over"] for p in pace),
# 1% low aproximado: FPS equivalente ao p99 do tempo de frame
fps_1pct_low=r(1000.0 / mean(p99)) if mean(p99) else None,
fps_1pct_low_worst_sample=r(1000.0 / max(p99)) if max(p99) else None,
)
return out
def read_csv(path):
try:
with open(path, newline="") as f: return list(csv.DictReader(f))
except OSError:
return []
def fnum(row, key):
try: return float(row[key])
except (KeyError, ValueError, TypeError): return None
def window_rows(rows, window_s, skip_s):
if not rows: return rows
ts = [fnum(x, "t") for x in rows]
ts = [t for t in ts if t is not None]
if not ts: return rows
t0, t1 = ts[0], ts[-1]
lo = t0 + (skip_s or 0)
if window_s: lo = max(lo, t1 - window_s)
return [x for x in rows if (fnum(x, "t") or 0) >= lo]
def col(rows, key):
return [v for v in (fnum(x, key) for x in rows) if v is not None]
def parse_mon(d, window_s, skip_s):
out = {}
gpu = window_rows(read_csv(os.path.join(d, "gpu.csv")), window_s, skip_s)
cpu = window_rows(read_csv(os.path.join(d, "cpu.csv")), window_s, skip_s)
mem = window_rows(read_csv(os.path.join(d, "mem.csv")), window_s, skip_s)
out["mon_dir"] = os.path.abspath(d)
out["mon_samples"] = dict(gpu=len(gpu), cpu=len(cpu), mem=len(mem))
if gpu:
for key, name in [("util_gpu", "gpu_util"), ("util_mem", "gpu_memctl_util"),
("clk_sm_mhz", "clk_sm"), ("clk_mem_mhz", "clk_mem"),
("power_w", "power"), ("temp_c", "temp")]:
v = col(gpu, key)
if v:
out[name + "_mean"] = r(mean(v)); out[name + "_max"] = r(max(v)); out[name + "_min"] = r(min(v))
for key, name in [("mem_used_mib", "vram_used_max_mib"), ("proc_vram_mib", "vram_proc_max_mib")]:
v = col(gpu, key)
if v: out[name] = r(max(v), 0)
masks = []
for x in gpu:
try: masks.append(int(x["throttle_mask"], 0))
except (KeyError, ValueError): pass
if masks:
act = {}
for bit, nm in THROTTLE:
c = sum(1 for m in masks if m & bit)
if c: act[nm] = r(100.0 * c / len(masks), 1)
out["throttle_pct_samples"] = act
out["throttle_limiting"] = sorted(k for k in act if k in LIMITING)
if cpu:
tot = col(cpu, "cpu_total")
if tot: out["cpu_total_mean"] = r(mean(tot)); out["cpu_total_max"] = r(max(tot))
cores = [k for k in cpu[0] if re.fullmatch(r"cpu\d+", k)]
means = {k: mean(col(cpu, k)) for k in cores if col(cpu, k)}
if means:
hot = max(means, key=means.get)
out["cpu_hot_core"] = hot
out["cpu_hot_core_mean"] = r(means[hot])
out["cpu_core_max"] = r(max(max(col(cpu, k)) for k in means))
tc = col(cpu, "cpu_temp_c")
if tc: out["cpu_temp_max"] = r(max(tc))
if mem:
for key, name in [("mem_used_mib", "ram_used_max_mib"), ("proc_rss_mib", "rss_max_mib"),
("swap_used_mib", "swap_used_max_mib")]:
v = col(mem, key)
if v: out[name] = r(max(v), 0)
pc = col(mem, "proc_cpu_pct")
if pc: out["proc_cpu_pct_mean"] = r(mean(pc)); out["proc_cpu_pct_max"] = r(max(pc))
return out
def flat(d):
o = {}
for k, v in d.items():
if isinstance(v, dict):
for k2, v2 in v.items(): o[f"{k}.{k2}"] = v2
elif isinstance(v, list): o[k] = " ".join(map(str, v))
else: o[k] = v
return o
def cmd_summarize(a):
res = dict(name=a.name or os.path.basename(os.path.abspath(a.mon)) if (a.name or a.mon) else "run",
window_s=a.window, skip_s=a.skip)
log = resolve_log(a.log)
if os.path.exists(log):
res.update(parse_log(log, a.window, a.skip, a.sample_secs, a.min_fps))
else:
print(f"aviso: log nao encontrado: {log}", file=sys.stderr)
if a.mon: res.update(parse_mon(a.mon, a.window, a.skip))
for k, v in flat(res).items(): print(f"{k:32s} {v}")
if a.out:
os.makedirs(a.out, exist_ok=True)
with open(os.path.join(a.out, "summary.json"), "w") as f: json.dump(res, f, indent=2)
fl = flat(res)
with open(os.path.join(a.out, "summary.csv"), "w", newline="") as f:
w = csv.writer(f); w.writerow(["metric", "value"])
for k, v in fl.items(): w.writerow([k, v])
print(f"\ngravado: {a.out}/summary.json e summary.csv")
# metrica, rotulo, "maior e melhor"? (None = neutro)
CMP = [("fps_mean", "FPS medio", True), ("fps_min", "FPS minimo", True),
("fps_1pct_low", "1% low (p99)", True), ("worst_frame_ms", "pior frame ms", False),
("shader_compiles", "compilacoes shader", False), ("pending_draws_mean", "draws pend. med", False),
("pending_draws_max", "draws pend. max", False),
("gpu_util_mean", "GPU util med %", None), ("gpu_util_max", "GPU util max %", None),
("cpu_total_mean", "CPU total med %", None), ("cpu_hot_core_mean", "nucleo mais carr. med %", None),
("cpu_core_max", "nucleo max %", None), ("clk_sm_mean", "clk SM med MHz", True),
("power_mean", "potencia med W", None), ("temp_max", "temp max C", False),
("vram_used_max_mib", "VRAM max MiB", False), ("rss_max_mib", "RSS max MiB", False),
("ram_used_max_mib", "RAM usada max MiB", False)]
def load_run(p):
with open(p) as f: d = json.load(f)
d.setdefault("name", os.path.basename(os.path.dirname(os.path.abspath(p))))
return d
def cmd_compare(a):
ctrl = [load_run(p) for p in a.control]
var = [load_run(p) for p in a.variant]
cols = [c["name"] for c in ctrl] + ["ctrl_med", "ruido%"] + [v["name"] for v in var]
rows = []
for key, label, better in CMP:
cv = [c[key] for c in ctrl if c.get(key) is not None]
if not cv and not any(v.get(key) is not None for v in var): continue
cm = mean(cv)
sd = st.stdev(cv) if len(cv) > 1 else None
rng = (max(cv) - min(cv)) if len(cv) > 1 else None
noise = pct(cm + sd, cm) if (sd is not None and cm) else None # desvio em % da media
cells = [c.get(key) for c in ctrl]
row = [label] + [("-" if x is None else f"{x:g}") for x in cells]
row += ["-" if cm is None else f"{cm:.2f}", "-" if noise is None else f"{noise:.1f}"]
for v in var:
x = v.get(key)
if x is None or cm is None: row.append("-"); continue
d = pct(x, cm)
mark = ""
if d is not None and noise is not None and abs(d) <= a.noise_k * noise: mark = " ~"
elif d is not None and cm == 0 and x == 0: d = 0.0
row.append(f"{x:g} ({d:+.1f}%){mark}" if d is not None else f"{x:g}")
rows.append(row)
hdr = ["metrica"] + cols
widths = [max(len(str(x)) for x in [h] + [rw[i] for rw in rows]) for i, h in enumerate(hdr)]
fmt = lambda rw: " ".join(str(x).ljust(widths[i]) for i, x in enumerate(rw))
print(fmt(hdr)); print(fmt(["-" * w for w in widths]))
for rw in rows: print(fmt(rw))
print(f"\n'~' = diferenca dentro de {a.noise_k}x o desvio entre controles (provavel ruido)."
+ ("" if len(ctrl) > 1 else " Com 1 controle so nao ha estimativa de ruido."))
for v in var:
t = v.get("throttle_limiting")
if t: print(f"atencao: {v['name']} teve throttle limitante: {', '.join(t)}")
for c in ctrl:
t = c.get("throttle_limiting")
if t: print(f"atencao: controle {c['name']} teve throttle limitante: {', '.join(t)}")
if a.csv:
with open(a.csv, "w", newline="") as f:
w = csv.writer(f); w.writerow(hdr); w.writerows(rows)
print(f"gravado: {a.csv}")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sp = ap.add_subparsers(dest="cmd", required=True)
s = sp.add_parser("summarize")
s.add_argument("--log"); s.add_argument("--mon"); s.add_argument("--out"); s.add_argument("--name")
s.add_argument("--window", type=float, default=0); s.add_argument("--skip", type=float, default=0)
s.add_argument("--sample-secs", type=float, default=5.0); s.add_argument("--min-fps", type=float, default=0)
c = sp.add_parser("compare")
c.add_argument("--control", nargs="+", required=True); c.add_argument("--variant", nargs="+", required=True)
c.add_argument("--csv"); c.add_argument("--noise-k", type=float, default=2.0)
a = ap.parse_args()
(cmd_summarize if a.cmd == "summarize" else cmd_compare)(a)
if __name__ == "__main__":
main()