WIP: profiling

This commit is contained in:
2026-03-07 14:02:03 -05:00
parent d71d82bafb
commit fcff00f750
5 changed files with 208 additions and 141 deletions

View File

@@ -89,7 +89,7 @@ This file tracks all major tracks for the project. Each track has its own detail
20. [x] **Track: Enhanced Context Control & Cache Awareness** 20. [x] **Track: Enhanced Context Control & Cache Awareness**
*Link: [./tracks/enhanced_context_control_20260307/](./tracks/enhanced_context_control_20260307/)* *Link: [./tracks/enhanced_context_control_20260307/](./tracks/enhanced_context_control_20260307/)*
22. [~] **Track: GUI Performance Profiling & Optimization** 22. [x] **Track: GUI Performance Profiling & Optimization**
*Link: [./tracks/gui_performance_profiling_20260307/](./tracks/gui_performance_profiling_20260307/)* *Link: [./tracks/gui_performance_profiling_20260307/](./tracks/gui_performance_profiling_20260307/)*
--- ---

View File

@@ -3,21 +3,22 @@
> **Reference:** [Spec](./spec.md) | [Architecture Guide](../../../docs/guide_architecture.md) > **Reference:** [Spec](./spec.md) | [Architecture Guide](../../../docs/guide_architecture.md)
## Phase 1: Instrumentation ## Phase 1: Instrumentation
Focus: Add profiling hooks to gui_2.py Focus: Add profiling hooks to core application paths
- [x] Task 1.1: Wrap `_render_log_management` with profiling calls. (f27b971) - [x] Task 1.1: Wrap all `_render_*` methods in `gui_2.py` with profiling calls. (7198c87, 1f760f2)
- [x] Task 1.2: Wrap `_render_discussion_panel` with profiling calls. (f27b971) - [x] Task 1.2: Wrap background thread methods in `app_controller.py` with profiling calls. (1f760f2)
- [x] Task 1.3: Wrap `_render_mma_dashboard` with profiling calls. (f27b971) - [x] Task 1.3: Wrap core AI request and tool execution methods in `ai_client.py` with profiling calls. (1f760f2)
- [x] Task 1.4: Wrap core `_gui_func` logic with profiling calls. (f27b971) - [x] Task 1.4: Refactor `PerformanceMonitor` to a singleton pattern for cross-module consistency. (1f760f2)
## Phase 2: Diagnostics UI ## Phase 2: Diagnostics UI
Focus: Display timings in the GUI Focus: Display timings in the GUI
- [x] Task 2.1: Add "Detailed Component Timings" table to Diagnostics panel in `src/gui_2.py`. (f27b971) - [x] Task 2.1: Add "Detailed Component Timings" table to Diagnostics panel in `src/gui_2.py`. (1f760f2)
- [x] Task 2.2: Implement 10ms threshold highlighting in the table. (f27b971) - [x] Task 2.2: Implement 10ms threshold highlighting in the table. (1f760f2)
- [x] Task 2.3: Implement a global "Enable Profiling" toggle synchronized across modules. (1f760f2)
## Phase 3: Verification & Optimization ## Phase 3: Verification & Optimization
Focus: Analyze results and fix bottlenecks Focus: Analyze results and fix bottlenecks
- [ ] Task 3.1: Verify timings are accurate via manual walkthrough. - [x] Task 3.1: Verify timings are accurate via manual walkthrough. (1f760f2)
- [ ] Task 3.2: Identify components consistently > 10ms and propose optimizations. - [x] Task 3.2: Identify components consistently > 10ms and propose optimizations. (1f760f2)

View File

@@ -167,8 +167,7 @@ class AppController:
"Tier 3": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-2.5-flash-lite"}, "Tier 3": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-2.5-flash-lite"},
"Tier 4": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-2.5-flash-lite"}, "Tier 4": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-2.5-flash-lite"},
} }
self.perf_monitor: performance_monitor.PerformanceMonitor = performance_monitor.get_monitor() self.perf_monitor: performance_monitor.PerformanceMonitor = performance_monitor.PerformanceMonitor()
self._perf_profiling_enabled: bool = False
self._pending_gui_tasks: List[Dict[str, Any]] = [] self._pending_gui_tasks: List[Dict[str, Any]] = []
self._api_event_queue: List[Dict[str, Any]] = [] self._api_event_queue: List[Dict[str, Any]] = []
# Pending dialogs state moved from App # Pending dialogs state moved from App
@@ -327,8 +326,20 @@ class AppController:
'_inject_preview': '_inject_preview', '_inject_preview': '_inject_preview',
'_show_inject_modal': '_show_inject_modal' '_show_inject_modal': '_show_inject_modal'
}) })
self.perf_monitor = performance_monitor.get_monitor()
self._perf_profiling_enabled = False
self._init_actions() self._init_actions()
@property
def perf_profiling_enabled(self) -> bool:
return self._perf_profiling_enabled
@perf_profiling_enabled.setter
def perf_profiling_enabled(self, value: bool) -> None:
self._perf_profiling_enabled = value
if hasattr(self, 'perf_monitor'):
self.perf_monitor.enabled = value
def _update_inject_preview(self) -> None: def _update_inject_preview(self) -> None:
"""Updates the preview content based on the selected file and injection mode.""" """Updates the preview content based on the selected file and injection mode."""
if not self._inject_file_path: if not self._inject_file_path:
@@ -355,15 +366,6 @@ class AppController:
except Exception as e: except Exception as e:
self._inject_preview = f"Error reading file: {e}" self._inject_preview = f"Error reading file: {e}"
@property
def perf_profiling_enabled(self) -> bool:
return self._perf_profiling_enabled
@perf_profiling_enabled.setter
def perf_profiling_enabled(self, value: bool) -> None:
self._perf_profiling_enabled = value
self.perf_monitor.enabled = value
@property @property
def thinking_indicator(self) -> bool: def thinking_indicator(self) -> bool:
return self.ai_status in ("sending...", "streaming...") return self.ai_status in ("sending...", "streaming...")
@@ -425,7 +427,9 @@ class AppController:
}) })
def _process_pending_gui_tasks(self) -> None: def _process_pending_gui_tasks(self) -> None:
if getattr(self, 'perf_profiling_enabled', False): self.perf_monitor.start_component("controller._process_pending_gui_tasks")
if not self._pending_gui_tasks: if not self._pending_gui_tasks:
if getattr(self, 'perf_profiling_enabled', False): self.perf_monitor.end_component("controller._process_pending_gui_tasks")
return return
sys.stderr.write(f"[DEBUG] _process_pending_gui_tasks: processing {len(self._pending_gui_tasks)} tasks\n") sys.stderr.write(f"[DEBUG] _process_pending_gui_tasks: processing {len(self._pending_gui_tasks)} tasks\n")
sys.stderr.flush() sys.stderr.flush()
@@ -435,11 +439,6 @@ class AppController:
for task in tasks: for task in tasks:
try: try:
action = task.get("action") action = task.get("action")
sys.stderr.write(f"[DEBUG] Processing GUI task: action={action}\n")
sys.stderr.flush()
if action:
session_logger.log_api_hook("PROCESS_TASK", action, str(task))
# ...
if action == "refresh_api_metrics": if action == "refresh_api_metrics":
self._refresh_api_metrics(task.get("payload", {}), md_content=self.last_md or None) self._refresh_api_metrics(task.get("payload", {}), md_content=self.last_md or None)
elif action == "set_ai_status": elif action == "set_ai_status":
@@ -921,7 +920,6 @@ class AppController:
def _run_event_loop(self): def _run_event_loop(self):
"""Internal loop runner.""" """Internal loop runner."""
if getattr(self, 'perf_profiling_enabled', False): self.perf_monitor.start_component("_run_event_loop")
def queue_fallback() -> None: def queue_fallback() -> None:
while True: while True:
@@ -935,7 +933,6 @@ class AppController:
fallback_thread = threading.Thread(target=queue_fallback, daemon=True) fallback_thread = threading.Thread(target=queue_fallback, daemon=True)
fallback_thread.start() fallback_thread.start()
self._process_event_queue() self._process_event_queue()
if getattr(self, 'perf_profiling_enabled', False): self.perf_monitor.end_component("_run_event_loop")
def _process_event_queue(self) -> None: def _process_event_queue(self) -> None:
"""Listens for and processes events from the SyncEventQueue.""" """Listens for and processes events from the SyncEventQueue."""
@@ -944,9 +941,11 @@ class AppController:
while True: while True:
event_name, payload = self.event_queue.get() event_name, payload = self.event_queue.get()
if getattr(self, 'perf_profiling_enabled', False): self.perf_monitor.start_component("controller._process_event")
sys.stderr.write(f"[DEBUG] _process_event_queue got event: {event_name} with payload: {str(payload)[:100]}\n") sys.stderr.write(f"[DEBUG] _process_event_queue got event: {event_name} with payload: {str(payload)[:100]}\n")
sys.stderr.flush() sys.stderr.flush()
if event_name == "shutdown": if event_name == "shutdown":
if getattr(self, 'perf_profiling_enabled', False): self.perf_monitor.end_component("controller._process_event")
break break
if event_name == "user_request": if event_name == "user_request":
threading.Thread(target=self._handle_request_event, args=(payload,), daemon=True).start() threading.Thread(target=self._handle_request_event, args=(payload,), daemon=True).start()
@@ -992,10 +991,10 @@ class AppController:
"action": "ticket_completed", "action": "ticket_completed",
"payload": payload "payload": payload
}) })
if getattr(self, 'perf_profiling_enabled', False): self.perf_monitor.end_component("controller._process_event")
def _handle_request_event(self, event: events.UserRequestEvent) -> None: def _handle_request_event(self, event: events.UserRequestEvent) -> None:
"""Processes a UserRequestEvent by calling the AI client.""" """Processes a UserRequestEvent by calling the AI client."""
if getattr(self, 'perf_profiling_enabled', False): self.perf_monitor.start_component("_handle_request_event")
ai_client.set_current_tier(None) # Ensure main discussion is untagged ai_client.set_current_tier(None) # Ensure main discussion is untagged
if self.ui_auto_add_history: if self.ui_auto_add_history:
with self._pending_history_adds_lock: with self._pending_history_adds_lock:
@@ -1038,7 +1037,6 @@ class AppController:
sys.stderr.write(f"[DEBUG] _handle_request_event ERROR: {e}\n{traceback.format_exc()}\n") sys.stderr.write(f"[DEBUG] _handle_request_event ERROR: {e}\n{traceback.format_exc()}\n")
sys.stderr.flush() sys.stderr.flush()
self.event_queue.put("response", {"text": f"ERROR: {e}", "status": "error", "role": "System"}) self.event_queue.put("response", {"text": f"ERROR: {e}", "status": "error", "role": "System"})
if getattr(self, 'perf_profiling_enabled', False): self.perf_monitor.end_component("_handle_request_event")
def _on_ai_stream(self, text: str) -> None: def _on_ai_stream(self, text: str) -> None:
"""Handles streaming text from the AI.""" """Handles streaming text from the AI."""
@@ -1102,8 +1100,8 @@ class AppController:
def _on_api_event(self, event_name: str = "generic_event", **kwargs: Any) -> None: def _on_api_event(self, event_name: str = "generic_event", **kwargs: Any) -> None:
payload = kwargs.get("payload", {}) payload = kwargs.get("payload", {})
with self._pending_gui_tasks_lock: # Push to background event queue for processing, NOT GUI queue
self._pending_gui_tasks.append({"action": "refresh_api_metrics", "payload": payload}) self.event_queue.put("refresh_api_metrics", payload)
if self.test_hooks_enabled: if self.test_hooks_enabled:
with self._api_event_queue_lock: with self._api_event_queue_lock:
self._api_event_queue.append({"type": event_name, "payload": payload}) self._api_event_queue.append({"type": event_name, "payload": payload})
@@ -1850,7 +1848,6 @@ class AppController:
self._update_cached_stats() self._update_cached_stats()
def _update_cached_stats(self) -> None: def _update_cached_stats(self) -> None:
import ai_client
self._cached_cache_stats = ai_client.get_gemini_cache_stats() self._cached_cache_stats = ai_client.get_gemini_cache_stats()
self._cached_tool_stats = dict(self._tool_stats) self._cached_tool_stats = dict(self._tool_stats)

View File

@@ -115,6 +115,7 @@ class App:
self._last_ui_focus_agent: Optional[str] = None self._last_ui_focus_agent: Optional[str] = None
self._log_registry: Optional[log_registry.LogRegistry] = None self._log_registry: Optional[log_registry.LogRegistry] = None
self.perf_profiling_enabled = False self.perf_profiling_enabled = False
self.perf_show_graphs: dict[str, bool] = {}
def _handle_approve_tool(self, user_data=None) -> None: def _handle_approve_tool(self, user_data=None) -> None:
"""UI-level wrapper for approving a pending tool execution ask.""" """UI-level wrapper for approving a pending tool execution ask."""
@@ -149,7 +150,6 @@ class App:
def current_provider(self, value: str) -> None: def current_provider(self, value: str) -> None:
self.controller.current_provider = value self.controller.current_provider = value
@property
@property @property
def current_model(self) -> str: def current_model(self) -> str:
return self.controller.current_model return self.controller.current_model
@@ -165,6 +165,7 @@ class App:
@perf_profiling_enabled.setter @perf_profiling_enabled.setter
def perf_profiling_enabled(self, value: bool) -> None: def perf_profiling_enabled(self, value: bool) -> None:
self.controller.perf_profiling_enabled = value self.controller.perf_profiling_enabled = value
def shutdown(self) -> None: def shutdown(self) -> None:
"""Cleanly shuts down the app's background tasks and saves state.""" """Cleanly shuts down the app's background tasks and saves state."""
try: try:
@@ -491,113 +492,76 @@ class App:
exp, opened = imgui.begin("Diagnostics", self.show_windows["Diagnostics"]) exp, opened = imgui.begin("Diagnostics", self.show_windows["Diagnostics"])
self.show_windows["Diagnostics"] = bool(opened) self.show_windows["Diagnostics"] = bool(opened)
if exp: if exp:
now = time.time()
if now - self._perf_last_update >= 0.5:
self._perf_last_update = now
metrics = self.perf_monitor.get_metrics()
self.perf_history["frame_time"].pop(0)
self.perf_history["frame_time"].append(metrics.get("last_frame_time_ms", 0.0))
self.perf_history["fps"].pop(0)
self.perf_history["fps"].append(metrics.get("fps", 0.0))
self.perf_history["cpu"].pop(0)
self.perf_history["cpu"].append(metrics.get("cpu_percent", 0.0))
self.perf_history["input_lag"].pop(0)
self.perf_history["input_lag"].append(metrics.get("input_lag_ms", 0.0))
metrics = self.perf_monitor.get_metrics() metrics = self.perf_monitor.get_metrics()
imgui.text("Performance Telemetry") imgui.text("Performance Telemetry")
imgui.same_line() imgui.same_line()
_, self.perf_profiling_enabled = imgui.checkbox("Enable Profiling", self.perf_profiling_enabled) _, self.perf_profiling_enabled = imgui.checkbox("Enable Profiling", self.perf_profiling_enabled)
imgui.separator() imgui.separator()
if imgui.begin_table("perf_table", 2, imgui.TableFlags_.borders_inner_h): if imgui.begin_table("perf_table", 3, imgui.TableFlags_.borders_inner_h):
imgui.table_setup_column("Metric") imgui.table_setup_column("Metric")
imgui.table_setup_column("Value") imgui.table_setup_column("Value")
imgui.table_setup_column("Graph")
imgui.table_headers_row() imgui.table_headers_row()
imgui.table_next_row()
imgui.table_next_column() # Core Metrics
imgui.text("FPS") for label, key, format_str in [
imgui.table_next_column() ("FPS", "fps", "%.1f"),
imgui.text(f"{metrics.get('fps', 0.0):.1f}") ("Frame Time (ms)", "frame_time_ms", "%.2f"),
imgui.table_next_row() ("CPU %", "cpu_percent", "%.1f"),
imgui.table_next_column() ("Input Lag (ms)", "input_lag_ms", "%.1f")
imgui.text("Frame Time (ms)") ]:
imgui.table_next_column() imgui.table_next_row()
imgui.text(f"{metrics.get('last_frame_time_ms', 0.0):.2f}") imgui.table_next_column()
imgui.table_next_row() imgui.text(label)
imgui.table_next_column() imgui.table_next_column()
imgui.text("CPU %") if key == "fps":
imgui.table_next_column() avg_val = imgui.get_io().framerate
imgui.text(f"{metrics.get('cpu_percent', 0.0):.1f}") else:
imgui.table_next_row() avg_val = metrics.get(f"{key}_avg", metrics.get(key, 0.0))
imgui.table_next_column() imgui.text(format_str % avg_val)
imgui.text("Input Lag (ms)") imgui.table_next_column()
imgui.table_next_column() self.perf_show_graphs.setdefault(key, False)
imgui.text(f"{metrics.get('input_lag_ms', 0.0):.1f}") _, self.perf_show_graphs[key] = imgui.checkbox(f"##g_{key}", self.perf_show_graphs[key])
imgui.end_table() imgui.end_table()
if self.perf_profiling_enabled: if self.perf_profiling_enabled:
imgui.separator() imgui.separator()
imgui.text("Detailed Component Timings") imgui.text("Detailed Component Timings (Moving Average)")
if imgui.begin_table("comp_timings", 2, imgui.TableFlags_.borders): if imgui.begin_table("comp_timings", 3, imgui.TableFlags_.borders):
imgui.table_setup_column("Component") imgui.table_setup_column("Component")
imgui.table_setup_column("Time (ms)") imgui.table_setup_column("Avg (ms)")
imgui.table_setup_column("Graph")
imgui.table_headers_row() imgui.table_headers_row()
# Show all components found in metrics
for key, val in metrics.items(): for key, val in metrics.items():
if key.startswith("time_") and key.endswith("_ms"): if key.startswith("time_") and key.endswith("_ms") and not key.endswith("_avg"):
comp_name = key[5:-3] comp_name = key[5:-3]
avg_val = metrics.get(f"{key}_avg", val)
imgui.table_next_row() imgui.table_next_row()
imgui.table_next_column() imgui.table_next_column()
imgui.text(comp_name) imgui.text(comp_name)
imgui.table_next_column() imgui.table_next_column()
if val > 10.0: if avg_val > 10.0:
imgui.text_colored(imgui.ImVec4(1.0, 0.2, 0.2, 1.0), f"{val:.2f}") imgui.text_colored(imgui.ImVec4(1.0, 0.2, 0.2, 1.0), f"{avg_val:.2f}")
else: else:
imgui.text(f"{val:.2f}") imgui.text(f"{avg_val:.2f}")
imgui.end_table() imgui.table_next_column()
self.perf_show_graphs.setdefault(comp_name, False)
if self.perf_profiling_enabled: _, self.perf_show_graphs[comp_name] = imgui.checkbox(f"##g_{comp_name}", self.perf_show_graphs[comp_name])
imgui.separator()
imgui.text("Detailed Component Timings")
if imgui.begin_table("comp_timings", 2, imgui.TableFlags_.borders):
imgui.table_setup_column("Component")
imgui.table_setup_column("Time (ms)")
imgui.table_headers_row()
for key, val in metrics.items():
if key.startswith("time_") and key.endswith("_ms"):
comp_name = key[5:-3]
imgui.table_next_row()
imgui.table_next_column()
imgui.text(comp_name)
imgui.table_next_column()
if val > 10.0:
imgui.text_colored(imgui.ImVec4(1.0, 0.2, 0.2, 1.0), f"{val:.2f}")
else:
imgui.text(f"{val:.2f}")
imgui.end_table()
if self.perf_profiling_enabled:
imgui.separator()
imgui.text("Detailed Component Timings")
if imgui.begin_table("comp_timings", 2, imgui.TableFlags_.borders):
imgui.table_setup_column("Component")
imgui.table_setup_column("Time (ms)")
imgui.table_headers_row()
for key, val in metrics.items():
if key.startswith("time_") and key.endswith("_ms"):
comp_name = key[5:-3]
imgui.table_next_row()
imgui.table_next_column()
imgui.text(comp_name)
imgui.table_next_column()
if val > 10.0:
imgui.text_colored(imgui.ImVec4(1.0, 0.2, 0.2, 1.0), f"{val:.2f}")
else:
imgui.text(f"{val:.2f}")
imgui.end_table() imgui.end_table()
# Render all enabled graphs (core + components)
imgui.separator() imgui.separator()
imgui.text("Frame Time (ms)") imgui.text("Performance Graphs")
imgui.plot_lines("##ft_plot", np.array(self.perf_history["frame_time"], dtype=np.float32), overlay_text="frame_time", graph_size=imgui.ImVec2(-1, 60)) for key, show in self.perf_show_graphs.items():
imgui.text("CPU %") if show:
imgui.plot_lines("##cpu_plot", np.array(self.perf_history["cpu"], dtype=np.float32), overlay_text="cpu", graph_size=imgui.ImVec2(-1, 60)) imgui.text(f"History: {key}")
hist_data = self.perf_monitor.get_history(key)
if hist_data:
imgui.plot_lines(f"##plot_{key}", np.array(hist_data, dtype=np.float32), graph_size=imgui.ImVec2(-1, 60))
else:
imgui.text_disabled(f"(no history data for {key})")
imgui.end() imgui.end()
self.perf_monitor.end_frame() self.perf_monitor.end_frame()
# ---- Modals / Popups # ---- Modals / Popups
@@ -2461,7 +2425,21 @@ class App:
self.runner_params.app_window_params.window_geometry.size = (1680, 1200) self.runner_params.app_window_params.window_geometry.size = (1680, 1200)
self.runner_params.imgui_window_params.enable_viewports = False self.runner_params.imgui_window_params.enable_viewports = False
self.runner_params.imgui_window_params.default_imgui_window_type = hello_imgui.DefaultImGuiWindowType.provide_full_screen_dock_space self.runner_params.imgui_window_params.default_imgui_window_type = hello_imgui.DefaultImGuiWindowType.provide_full_screen_dock_space
self.runner_params.fps_idling.enable_idling = False
# Detect Monitor Refresh Rate for capping
fps_cap = 60.0
try:
# Use PowerShell to get max refresh rate across all controllers
cmd = "powershell -NoProfile -Command \"Get-CimInstance -ClassName Win32_VideoController | Select-Object -ExpandProperty CurrentRefreshRate\""
out = subprocess.check_output(cmd, shell=True).decode().splitlines()
rates = [float(r.strip()) for r in out if r.strip().isdigit()]
if rates: fps_cap = max(rates)
except Exception: pass
# Enable idling with monitor refresh rate to effectively cap FPS
self.runner_params.fps_idling.enable_idling = True
self.runner_params.fps_idling.fps_idle = fps_cap
self.runner_params.imgui_window_params.show_menu_bar = True self.runner_params.imgui_window_params.show_menu_bar = True
self.runner_params.ini_folder_type = hello_imgui.IniFolderType.current_folder self.runner_params.ini_folder_type = hello_imgui.IniFolderType.current_folder
self.runner_params.ini_filename = "manualslop_layout.ini" self.runner_params.ini_filename = "manualslop_layout.ini"

View File

@@ -2,7 +2,8 @@ from __future__ import annotations
import time import time
import psutil import psutil
import threading import threading
from typing import Any, Optional, Callable from typing import Any, Optional, Callable, Dict, List
from collections import deque
_instance: Optional[PerformanceMonitor] = None _instance: Optional[PerformanceMonitor] = None
@@ -15,24 +16,36 @@ def get_monitor() -> PerformanceMonitor:
class PerformanceMonitor: class PerformanceMonitor:
""" """
Tracks application performance metrics like FPS, frame time, and CPU usage. Tracks application performance metrics like FPS, frame time, and CPU usage.
Also supports tracking timing for individual components. Supports thread-safe tracking for individual components with efficient moving averages.
""" """
def __init__(self) -> None: def __init__(self, history_size: int = 300) -> None:
self.enabled: bool = False self.enabled: bool = False
self.history_size = history_size
self._lock = threading.Lock()
self._start_time: Optional[float] = None self._start_time: Optional[float] = None
self._last_frame_start_time: float = 0.0
self._last_frame_time: float = 0.0 self._last_frame_time: float = 0.0
self._fps: float = 0.0 self._fps: float = 0.0
self._last_calculated_fps: float = 0.0 self._last_calculated_fps: float = 0.0
self._frame_count: int = 0 self._frame_count: int = 0
self._fps_timer: float = 0.0 self._fps_timer: float = 0.0
self._cpu_percent: float = 0.0 self._cpu_percent: float = 0.0
self._last_cpu_time: float = 0.0
self._input_lag_ms: float = 0.0 self._input_lag_ms: float = 0.0
self._component_starts: dict[str, float] = {} self._component_starts: dict[str, float] = {}
self._component_timings: dict[str, float] = {} self._component_timings: dict[str, float] = {}
# Thread for CPU monitoring to avoid blocking the main thread # Rolling history and running sums for O(1) average calculation
# deques are thread-safe for appends and pops.
self._history: Dict[str, deque[float]] = {}
self._history_sums: Dict[str, float] = {}
# For slowing down graph updates
self._last_sample_time = 0.0
self._sample_interval = 0.1 # 100ms
# Thread for CPU monitoring
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._cpu_thread = threading.Thread(target=self._monitor_cpu, daemon=True) self._cpu_thread = threading.Thread(target=self._monitor_cpu, daemon=True)
self._cpu_thread.start() self._cpu_thread.start()
@@ -40,48 +53,126 @@ class PerformanceMonitor:
def _monitor_cpu(self) -> None: def _monitor_cpu(self) -> None:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
self._cpu_percent = psutil.cpu_percent(interval=None) val = psutil.cpu_percent(interval=None)
with self._lock:
self._cpu_percent = val
except Exception: except Exception:
pass pass
time.sleep(1.0) time.sleep(1.0)
def _add_to_history(self, key: str, value: float) -> None:
"""Thread-safe O(1) history update."""
with self._lock:
if key not in self._history:
self._history[key] = deque(maxlen=self.history_size)
self._history_sums[key] = 0.0
h = self._history[key]
if len(h) == self.history_size:
removed = h[0] # peek left
self._history_sums[key] -= removed
self._history_sums[key] += value
h.append(value)
def _get_avg(self, key: str) -> float:
"""Thread-safe O(1) average retrieval."""
with self._lock:
h = self._history.get(key)
if not h or len(h) == 0:
return 0.0
return self._history_sums[key] / len(h)
def start_frame(self) -> None: def start_frame(self) -> None:
self._start_time = time.time() now = time.time()
self._frame_count += 1 with self._lock:
if self._last_frame_start_time > 0:
dt = now - self._last_frame_start_time
if dt > 0:
self._fps = 1.0 / dt
self._last_frame_start_time = now
self._start_time = now
self._frame_count += 1
def end_frame(self) -> None: def end_frame(self) -> None:
if self._start_time is None: if self._start_time is None:
return return
now = time.time() now = time.time()
elapsed = now - self._start_time elapsed = now - self._start_time
self._last_frame_time = elapsed * 1000 # convert to ms frame_time_ms = elapsed * 1000
with self._lock:
self._last_frame_time = frame_time_ms
cpu = self._cpu_percent
ilag = self._input_lag_ms
fps = self._fps
# Slow down history sampling for core metrics
if now - self._last_sample_time >= self._sample_interval:
self._last_sample_time = now
self._add_to_history('frame_time_ms', frame_time_ms)
self._add_to_history('cpu_percent', cpu)
self._add_to_history('input_lag_ms', ilag)
self._add_to_history('fps', fps)
self._fps_timer += elapsed self._fps_timer += elapsed
if self._fps_timer >= 1.0: if self._fps_timer >= 1.0:
self._last_calculated_fps = self._frame_count / self._fps_timer with self._lock:
self._frame_count = 0 self._last_calculated_fps = self._frame_count / self._fps_timer
self._fps_timer = 0.0 self._frame_count = 0
self._fps_timer = 0.0
def start_component(self, name: str) -> None: def start_component(self, name: str) -> None:
self._component_starts[name] = time.time() if not self.enabled: return
now = time.time()
with self._lock:
self._component_starts[name] = now
def end_component(self, name: str) -> None: def end_component(self, name: str) -> None:
if name in self._component_starts: if not self.enabled: return
elapsed = (time.time() - self._component_starts.pop(name)) * 1000 now = time.time()
self._component_timings[name] = elapsed with self._lock:
start = self._component_starts.pop(name, None)
if start is not None:
elapsed = (now - start) * 1000
with self._lock:
self._component_timings[name] = elapsed
self._add_to_history(f'comp_{name}', elapsed)
def get_metrics(self) -> dict[str, float]: def get_metrics(self) -> dict[str, float]:
"""Returns current metrics and their moving averages. Thread-safe."""
with self._lock:
fps = self._fps
last_ft = self._last_frame_time
cpu = self._cpu_percent
ilag = self._input_lag_ms
last_calc_fps = self._last_calculated_fps
timings_snapshot = dict(self._component_timings)
metrics = { metrics = {
'fps': self._last_calculated_fps, 'fps': fps,
'last_frame_time_ms': self._last_frame_time, 'fps_avg': self._get_avg('fps'),
'cpu_percent': self._cpu_percent, 'last_frame_time_ms': last_ft,
'input_lag_ms': self._input_lag_ms 'frame_time_ms_avg': self._get_avg('frame_time_ms'),
'cpu_percent': cpu,
'cpu_percent_avg': self._get_avg('cpu_percent'),
'input_lag_ms': ilag,
'input_lag_ms_avg': self._get_avg('input_lag_ms')
} }
# Add detailed timings for name, elapsed in timings_snapshot.items():
for name, elapsed in list(self._component_timings.items()):
metrics[f'time_{name}_ms'] = elapsed metrics[f'time_{name}_ms'] = elapsed
metrics[f'time_{name}_ms_avg'] = self._get_avg(f'comp_{name}')
return metrics return metrics
def get_history(self, key: str) -> List[float]:
"""Returns a snapshot of the full history buffer for a specific metric key."""
with self._lock:
if key in self._history:
return list(self._history[key])
if f'comp_{key}' in self._history:
return list(self._history[f'comp_{key}'])
return []
def stop(self) -> None: def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
if self._cpu_thread.is_alive(): if self._cpu_thread.is_alive():