mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
222 lines
9.2 KiB
Python
222 lines
9.2 KiB
Python
"""Always-on sampling profile of the car renderer, in a size-capped file.
|
|
|
|
A daemon thread looks at the render thread's Python stack every INTERVAL
|
|
seconds (sys._current_frames: no tracing, so the renderer runs at full speed)
|
|
and counts, per function, how often it was running (self) or on the stack
|
|
(inclusive), plus the hottest source lines. Samples taken while the renderer
|
|
is idle (pacing, or the car showing its own screen) are only counted as idle.
|
|
|
|
Every WINDOW seconds the busiest entries of that window are appended to the
|
|
report file together with the renderer's latest ``render_stats`` (see RenderSummary). When the
|
|
file would grow past ``max_bytes`` it becomes ``<name>.1.txt`` (replacing the
|
|
previous one) and a new file starts, so at most twice ``max_bytes`` is kept.
|
|
The file outlives renderer restarts, unlike car_ui.log.
|
|
|
|
The sampler can only look while the render thread lets go of the GIL, which it
|
|
does in every raylib/GL call, so pure-Python work is credited to the next such
|
|
call in the innermost-frame sections. The "on the stack" section is accurate
|
|
per function and is the one to read for which part of the layout costs time.
|
|
Sampling runs at 25 samples a second.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from collections import Counter, deque
|
|
from pathlib import Path
|
|
|
|
INTERVAL = 0.04 # 25 samples a second
|
|
WINDOW = 60.0
|
|
TOP = 25
|
|
TOP_LINES = 15
|
|
SUMMARY_INTERVAL = 5.0
|
|
|
|
|
|
class RenderPipelineSummary:
|
|
"""Bounded measurements of published frames; no tracing or per-frame log I/O.
|
|
|
|
Camera EOF is an exposure timestamp, not CameraState arrival. Keep it separate
|
|
from renderer capture-to-publish latency so unlike clocks/stages aren't compared.
|
|
ID discontinuities count observations, not proof of a renderer-caused drop.
|
|
"""
|
|
|
|
def __init__(self, now_ns: int, interval_ns: int = 5_000_000_000):
|
|
self.started_ns, self.interval_ns = now_ns, interval_ns
|
|
self.samples = deque(maxlen=240)
|
|
self.previous = None
|
|
self.repeats = self.skipped = self.resets = self.frames = 0
|
|
|
|
def published(self, captured_ns, submitted_ns, published_ns, camera=None):
|
|
if not captured_ns <= submitted_ns <= published_ns:
|
|
return None
|
|
camera_age = None
|
|
if camera is None:
|
|
self.previous = None
|
|
else:
|
|
stream, frame_id, eof_ns = camera
|
|
if 0 < eof_ns <= published_ns:
|
|
camera_age = published_ns - eof_ns
|
|
if self.previous is not None and self.previous[0] == stream:
|
|
delta = frame_id - self.previous[1]
|
|
self.repeats += delta == 0
|
|
self.skipped += max(0, delta - 1)
|
|
self.resets += delta < 0
|
|
self.previous = (stream, frame_id)
|
|
self.frames += 1
|
|
self.samples.append((submitted_ns - captured_ns, published_ns - submitted_ns,
|
|
published_ns - captured_ns, camera_age))
|
|
if published_ns - self.started_ns < self.interval_ns:
|
|
return None
|
|
report = {'published_frames': self.frames, 'camera_id_repeats': self.repeats,
|
|
'camera_id_gaps': self.skipped, 'camera_id_resets': self.resets}
|
|
for index, name in enumerate(('capture_to_submit', 'submit_to_publish', 'capture_to_publish', 'camera_eof_to_publish')):
|
|
values = sorted(sample[index] for sample in self.samples if sample[index] is not None)
|
|
report[name + '_p95_ms'] = round(values[max(0, (95 * len(values) + 99) // 100 - 1)] / 1e6, 2) if values else None
|
|
self.started_ns = published_ns
|
|
self.samples.clear()
|
|
self.repeats = self.skipped = self.resets = self.frames = 0
|
|
return report
|
|
|
|
|
|
class RenderSummary:
|
|
"""Frame rate and mean frame time of the renderer while it renders, for the profile header."""
|
|
|
|
def __init__(self, now: float, interval: float = SUMMARY_INTERVAL):
|
|
self.interval = interval
|
|
self.reset(now)
|
|
|
|
def reset(self, now: float) -> None:
|
|
self.started, self.frames, self.frame_seconds = now, 0, 0.0
|
|
|
|
def frame_done(self, began: float, done: float) -> dict | None:
|
|
"""Count one frame; every ``interval`` returns the window's render_stats and starts a new one."""
|
|
self.frames += 1
|
|
self.frame_seconds += done - began
|
|
if done - self.started < self.interval:
|
|
return None
|
|
report = {"event": "render_stats", "fps": round(self.frames / (done - self.started), 1),
|
|
"frame_ms": round(1000 * self.frame_seconds / self.frames, 1)}
|
|
self.reset(done)
|
|
return report
|
|
|
|
|
|
class RenderSampler:
|
|
def __init__(self, path: Path, *, max_bytes: int = 256 * 1024, interval: float = INTERVAL, window: float = WINDOW,
|
|
clock=time.monotonic):
|
|
self.path, self.max_bytes = path, max_bytes
|
|
self.interval, self.window, self.clock = interval, window, clock
|
|
self.thread_id = threading.get_ident() # created on the render thread, which it samples
|
|
self.rendering = False # set by the render loop around each frame
|
|
self.onroad = False
|
|
self.summary: dict | None = None # latest render_stats
|
|
self._names: dict = {}
|
|
self._lock = threading.Lock()
|
|
self._stop = threading.Event()
|
|
self._worker = threading.Thread(target=self._run, name="car_ui_sampler", daemon=True)
|
|
self._reset()
|
|
|
|
def _reset(self) -> None:
|
|
self.started = self.clock()
|
|
self.samples = self.idle = self.onroad_samples = 0
|
|
self.self_counts: Counter = Counter()
|
|
self.total_counts: Counter = Counter()
|
|
self.line_counts: Counter = Counter()
|
|
self.param_samples: Counter = Counter()
|
|
|
|
def start(self) -> None:
|
|
self._worker.start()
|
|
|
|
def close(self) -> None:
|
|
self._stop.set()
|
|
if self._worker.is_alive():
|
|
self._worker.join(timeout=1.0)
|
|
self.flush()
|
|
|
|
def _name(self, code) -> str:
|
|
name = self._names.get(code)
|
|
if name is None:
|
|
name = self._names[code] = f"{Path(code.co_filename).name}:{code.co_firstlineno} {code.co_name}"
|
|
return name
|
|
|
|
def sample(self, frame) -> None:
|
|
"""Count one sample of the render thread's stack (``frame`` is its innermost frame)."""
|
|
with self._lock:
|
|
self.samples += 1
|
|
if not self.rendering:
|
|
self.idle += 1
|
|
return
|
|
self.onroad_samples += self.onroad
|
|
code = frame.f_code
|
|
self.line_counts[f"{Path(code.co_filename).name}:{frame.f_lineno} {code.co_name}"] += 1
|
|
self.self_counts[self._name(code)] += 1
|
|
seen = set()
|
|
param_seen = False
|
|
while frame is not None:
|
|
code = frame.f_code
|
|
# Attribute sampled waits to keys without wrapping every Params call.
|
|
# Only key names are captured; values (including credentials) never are.
|
|
if not param_seen and code.co_name in ("get", "get_bool") and code.co_filename.endswith("/common/params.py"):
|
|
key = frame.f_locals.get("key")
|
|
if isinstance(key, str):
|
|
self.param_samples[f"{code.co_name}({key})"] += 1
|
|
param_seen = True
|
|
name = self._name(frame.f_code)
|
|
if name not in seen:
|
|
seen.add(name)
|
|
self.total_counts[name] += 1
|
|
frame = frame.f_back
|
|
|
|
def _run(self) -> None:
|
|
while not self._stop.wait(self.interval):
|
|
frame = sys._current_frames().get(self.thread_id)
|
|
if frame is not None:
|
|
self.sample(frame)
|
|
del frame
|
|
if self.clock() - self.started >= self.window:
|
|
self.flush()
|
|
|
|
def report(self) -> str:
|
|
with self._lock:
|
|
busy = self.samples - self.idle
|
|
if busy <= 0:
|
|
return ""
|
|
state = "onroad" if self.onroad_samples * 2 >= busy else "offroad"
|
|
lines = [f"==== {time.strftime('%Y-%m-%d %H:%M:%S')} {self.clock() - self.started:.0f} s, {state} " +
|
|
f"({100 * self.onroad_samples // busy}% onroad), rendering {100 * busy // self.samples}% of {self.samples} samples ===="]
|
|
if self.summary:
|
|
lines.append("render_stats " + " ".join(f"{key}={value}" for key, value in self.summary.items() if key != "event"))
|
|
for title, counts, top in (("on the stack (time inside each function)", self.total_counts, TOP),
|
|
("innermost function (pure Python shows up at the next raylib/GL call)", self.self_counts, TOP),
|
|
("innermost line", self.line_counts, TOP_LINES)):
|
|
lines.append(f"-- {title}")
|
|
# Frames on every sample (the render loop and interpreter startup) say nothing.
|
|
shown = [(name, count) for name, count in counts.most_common() if count < busy or counts is not self.total_counts]
|
|
lines += [f"{100 * count / busy:6.1f}% {name}" for name, count in shown[:top]]
|
|
if self.param_samples:
|
|
lines.append("-- Params keys sampled on the render thread (% of busy samples, not read counts)")
|
|
lines += [f"{100 * count / busy:6.1f}% {name}" for name, count in self.param_samples.most_common(TOP)]
|
|
return "\n".join(lines) + "\n\n"
|
|
|
|
def flush(self) -> None:
|
|
text = self.report()
|
|
with self._lock:
|
|
self._reset()
|
|
if text:
|
|
self._write(text)
|
|
|
|
def _write(self, text: str) -> None:
|
|
try:
|
|
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
try:
|
|
if self.path.stat().st_size + len(text) > self.max_bytes:
|
|
os.replace(self.path, self.path.with_name(f"{self.path.stem}.1{self.path.suffix}"))
|
|
except FileNotFoundError:
|
|
pass
|
|
with open(self.path, "a") as handle:
|
|
handle.write(text)
|
|
except OSError:
|
|
pass
|