add heat map and compare window

This commit is contained in:
VALLONGOL 2025-06-23 15:07:01 +02:00
parent b0936b3c26
commit 5bc081508a
5 changed files with 385 additions and 119 deletions

4
app_config.json Normal file
View File

@ -0,0 +1,4 @@
{
"last_base_profile": "C:/src/____GitProjects/ProfileAnalyzer/execution_profiles/20250623_142502_test_overlay.prof",
"last_comp_profile": "C:/src/____GitProjects/ProfileAnalyzer/execution_profiles/20250623_143417_test_overlay.prof"
}

View File

@ -6,7 +6,7 @@ import subprocess
import sys import sys
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional, Tuple, Dict from typing import List, Optional, Tuple, Dict, Any
from datetime import datetime from datetime import datetime
from pstats import Stats from pstats import Stats
import tempfile import tempfile
@ -18,29 +18,21 @@ try:
except ImportError: except ImportError:
GRAPHVIZ_AVAILABLE = False GRAPHVIZ_AVAILABLE = False
# --- Utility Function (unchanged) ---
# --- New Utility Function ---
@lru_cache(maxsize=None) @lru_cache(maxsize=None)
def is_graphviz_installed() -> bool: def is_graphviz_installed() -> bool:
"""
Checks if the Graphviz executable is installed and in the system's PATH.
Returns True if available, False otherwise. Caches the result.
"""
if not GRAPHVIZ_AVAILABLE: if not GRAPHVIZ_AVAILABLE:
return False return False
try: try:
# This command is lightweight and fails if 'dot' is not found.
graphviz.Digraph().pipe() graphviz.Digraph().pipe()
return True return True
except graphviz.backend.ExecutableNotFound: except graphviz.backend.ExecutableNotFound:
print("INFO: Graphviz executable not found. Graph generation will be disabled.") print("INFO: Graphviz executable not found. Graph generation will be disabled.")
return False return False
# --- Dataclass (unchanged) --- # --- Dataclass (unchanged) ---
@dataclass @dataclass
class LaunchProfile: class LaunchProfile:
# ... (il codice rimane identico)
name: str name: str
run_as_module: bool = False run_as_module: bool = False
target_path: str = "" target_path: str = ""
@ -50,30 +42,88 @@ class LaunchProfile:
# --- ProfileAnalyzer Class (Updated) --- # --- ProfileAnalyzer Class (Updated) ---
class ProfileAnalyzer: class ProfileAnalyzer:
# ... (tutta la classe ProfileAnalyzer, incluso generate_call_graph, rimane identica alla versione precedente)
""" """
Handles loading a profile data file and extracting statistics Handles loading, analyzing, and comparing profile data.
by directly accessing the pstats data structures.
""" """
def __init__(self): def __init__(self):
self.stats: Optional[Stats] = None self.stats: Optional[Stats] = None
self.profile_path: Optional[str] = None self.profile_path: Optional[str] = None
self._func_info_map: Optional[Dict[str, Tuple]] = None self._func_info_map: Optional[Dict[str, Tuple]] = None
def load_profile(self, filepath: str) -> bool: def load_profile(self, filepath: str) -> Optional[Stats]:
"""
Loads a profile file and returns the Stats object.
If this instance is used for single analysis, it also sets self.stats.
"""
try: try:
self.stats = pstats.Stats(filepath) stats_obj = pstats.Stats(filepath)
self.stats.strip_dirs() stats_obj.strip_dirs()
# For single analysis mode, update instance state
self.stats = stats_obj
self.profile_path = filepath self.profile_path = filepath
self._func_info_map = None self._func_info_map = None # Invalidate cache
return True
return stats_obj
except (FileNotFoundError, TypeError, OSError) as e: except (FileNotFoundError, TypeError, OSError) as e:
print(f"Error loading profile file '{filepath}': {e}") print(f"Error loading profile file '{filepath}': {e}")
self.stats = None self.stats = None
self.profile_path = None self.profile_path = None
self._func_info_map = None self._func_info_map = None
return False return None
def compare_stats(self, stats_base: Stats, stats_comparison: Stats) -> List[Dict]:
"""
Compares two pstats.Stats objects and returns the differences.
Args:
stats_base: The baseline stats object.
stats_comparison: The comparison stats object to check against the baseline.
Returns:
A list of dictionaries, where each dictionary contains stats
for a function from the comparison profile plus the deltas.
"""
base = stats_base.stats
comp = stats_comparison.stats
# Get a set of all function keys from both profiles
all_funcs = set(base.keys()) | set(comp.keys())
comparison_results = []
for func_tuple in all_funcs:
# Get stats, defaulting to zero-tuple if function not in profile
base_stats = base.get(func_tuple, (0, 0, 0, 0, {}))
comp_stats = comp.get(func_tuple, (0, 0, 0, 0, {}))
# Unpack stats for comparison profile
cc, nc, tt, ct, _ = comp_stats
# Calculate deltas
delta_nc = nc - base_stats[1]
delta_tt = tt - base_stats[2]
delta_ct = ct - base_stats[3]
# Calculate per-call stats for the comparison profile
percall_tottime = tt / nc if nc > 0 else 0
percall_cumtime = ct / nc if nc > 0 else 0
comparison_results.append({
'func_str': pstats.func_std_string(func_tuple),
'ncalls': nc,
'tottime': tt,
'percall_tottime': percall_tottime,
'cumtime': ct,
'percall_cumtime': percall_cumtime,
'delta_ncalls': delta_nc,
'delta_tottime': delta_tt,
'delta_cumtime': delta_ct
})
return comparison_results
# --- All other methods remain the same ---
def _get_func_info_map(self) -> Dict[str, Tuple]: def _get_func_info_map(self) -> Dict[str, Tuple]:
if self._func_info_map is None and self.stats: if self._func_info_map is None and self.stats:
self._func_info_map = { self._func_info_map = {
@ -86,32 +136,59 @@ class ProfileAnalyzer:
return self._get_func_info_map().get(func_info_str) return self._get_func_info_map().get(func_info_str)
def get_stats(self, sort_by: str, limit: int = 500) -> List[Tuple]: def get_stats(self, sort_by: str, limit: int = 500) -> List[Tuple]:
"""
Extracts statistics, now including the percentage of total time.
Returns:
A list of tuples in the format:
(ncalls, tottime, percentage_tottime, percall_tottime, cumtime, percall_cumtime, function_details_str)
"""
if not self.stats: if not self.stats:
return [] return []
# Map frontend sort keys to internal pstats keys or custom keys
sort_map = { sort_map = {
"cumulative": 3, "tottime": 2, "ncalls": 1, "filename": 4 "cumulative": "cumtime",
"tottime": "tottime",
"ncalls": "ncalls",
"filename": "func_str"
} }
sort_key_index = sort_map.get(sort_by, 3) sort_key = sort_map.get(sort_by, "cumtime")
all_stats = [] all_stats = []
total_tt = self.stats.total_tt or 1 # Avoid division by zero
for func_tuple, (cc, nc, tt, ct, callers) in self.stats.stats.items(): for func_tuple, (cc, nc, tt, ct, callers) in self.stats.stats.items():
func_str = pstats.func_std_string(func_tuple)
percall_tottime = tt / nc if nc > 0 else 0 percall_tottime = tt / nc if nc > 0 else 0
percall_cumtime = ct / nc if nc > 0 else 0 percall_cumtime = ct / nc if nc > 0 else 0
percentage_tottime = (tt / total_tt) * 100
all_stats.append({ all_stats.append({
'func_str': func_str, 'func_tuple': func_tuple, 'func_str': pstats.func_std_string(func_tuple),
'raw_stats': (cc, nc, tt, ct, percall_tottime, percall_cumtime) 'ncalls': nc,
'tottime': tt,
'cumtime': ct,
'percall_tottime': percall_tottime,
'percall_cumtime': percall_cumtime,
'percentage_tottime': percentage_tottime
}) })
if sort_by == 'filename': # Perform sorting based on the dictionary keys
all_stats.sort(key=lambda x: x['func_str']) all_stats.sort(key=lambda x: x[sort_key], reverse=(sort_key != 'func_str'))
else:
all_stats.sort(key=lambda x: x['raw_stats'][sort_key_index], reverse=True)
# Format final output list
results = [] results = []
for stat_item in all_stats[:limit]: for stat_item in all_stats[:limit]:
s = stat_item['raw_stats'] results.append((
results.append((s[1], s[2], s[4], s[3], s[5], stat_item['func_str'])) stat_item['ncalls'],
stat_item['tottime'],
stat_item['percentage_tottime'],
stat_item['percall_tottime'],
stat_item['cumtime'],
stat_item['percall_cumtime'],
stat_item['func_str']
))
return results return results
def get_callers(self, func_info: str) -> List[Tuple[str, str]]: def get_callers(self, func_info: str) -> List[Tuple[str, str]]:
@ -155,28 +232,21 @@ class ProfileAnalyzer:
if not self.stats or not self.stats.stats or not is_graphviz_installed(): return None if not self.stats or not self.stats.stats or not is_graphviz_installed(): return None
total_time = self.stats.total_tt total_time = self.stats.total_tt
if total_time == 0: return None if total_time == 0: return None
max_tottime = max(s[2] for s in self.stats.stats.values()) if self.stats.stats else 0 max_tottime = max(s[2] for s in self.stats.stats.values()) if self.stats.stats else 0
graph = graphviz.Digraph(comment='Call Graph', graph = graphviz.Digraph(comment='Call Graph', graph_attr={'rankdir': 'LR', 'splines': 'true', 'overlap': 'false'}, node_attr={'shape': 'box', 'style': 'filled'})
graph_attr={'rankdir': 'LR', 'splines': 'true', 'overlap': 'false'},
node_attr={'shape': 'box', 'style': 'filled'})
nodes_in_graph = set() nodes_in_graph = set()
for func_tuple, (cc, nc, tt, ct, callers) in self.stats.stats.items(): for func_tuple, (cc, nc, tt, ct, callers) in self.stats.stats.items():
if tt < total_time * threshold: if tt < total_time * threshold: continue
continue
func_str = pstats.func_std_string(func_tuple) func_str = pstats.func_std_string(func_tuple)
nodes_in_graph.add(func_tuple) nodes_in_graph.add(func_tuple)
node_label = f"{func_str}\n(tottime: {tt:.4f}s)" node_label = f"{func_str}\n(tottime: {tt:.4f}s)"
node_color = self._get_color_for_time(tt, max_tottime) node_color = self._get_color_for_time(tt, max_tottime)
graph.node(name=func_str, label=node_label, fillcolor=node_color) graph.node(name=func_str, label=node_label, fillcolor=node_color)
for caller_tuple, call_stats in callers.items(): for caller_tuple, call_stats in callers.items():
caller_tottime = self.stats.stats.get(caller_tuple, (0,0,0,0))[2] caller_tottime = self.stats.stats.get(caller_tuple, (0,0,0,0))[2]
if caller_tottime >= total_time * threshold: if caller_tottime >= total_time * threshold:
caller_str = pstats.func_std_string(caller_tuple) caller_str = pstats.func_std_string(caller_tuple)
graph.edge(caller_str, func_str, label=f"{call_stats[1]} calls") graph.edge(caller_str, func_str, label=f"{call_stats[1]} calls")
if not nodes_in_graph: if not nodes_in_graph:
print("No significant functions to graph based on the threshold.") print("No significant functions to graph based on the threshold.")
return None return None
@ -190,16 +260,14 @@ class ProfileAnalyzer:
# --- run_and_profile_script function (unchanged) --- # --- run_and_profile_script function (unchanged) ---
def run_and_profile_script(profile: LaunchProfile) -> Optional[str]: def run_and_profile_script(profile: LaunchProfile) -> Optional[str]:
# ... (il codice rimane identico) # ... (code for this function remains identical)
output_dir = os.path.join(os.getcwd(), "execution_profiles") output_dir = os.path.join(os.getcwd(), "execution_profiles")
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
profile_name_sanitized = profile.name.replace(' ', '_').replace('.', '_') profile_name_sanitized = profile.name.replace(' ', '_').replace('.', '_')
profile_output_path = os.path.abspath(os.path.join(output_dir, f"{timestamp}_{profile_name_sanitized}.prof")) profile_output_path = os.path.abspath(os.path.join(output_dir, f"{timestamp}_{profile_name_sanitized}.prof"))
command = [profile.python_interpreter, "-m", "cProfile", "-o", profile_output_path] command = [profile.python_interpreter, "-m", "cProfile", "-o", profile_output_path]
working_directory = None working_directory = None
if profile.run_as_module: if profile.run_as_module:
if not profile.module_name: if not profile.module_name:
print("Error: Module name is required.") print("Error: Module name is required.")
@ -207,7 +275,6 @@ def run_and_profile_script(profile: LaunchProfile) -> Optional[str]:
if not profile.target_path or not os.path.isdir(profile.target_path): if not profile.target_path or not os.path.isdir(profile.target_path):
print(f"Error: Project Root Folder is not a valid directory: {profile.target_path}") print(f"Error: Project Root Folder is not a valid directory: {profile.target_path}")
return None return None
command.extend(["-m", profile.module_name]) command.extend(["-m", profile.module_name])
working_directory = profile.target_path working_directory = profile.target_path
else: else:
@ -216,36 +283,22 @@ def run_and_profile_script(profile: LaunchProfile) -> Optional[str]:
return None return None
command.append(profile.target_path) command.append(profile.target_path)
working_directory = os.path.dirname(profile.target_path) working_directory = os.path.dirname(profile.target_path)
if profile.script_args: if profile.script_args:
command.extend(profile.script_args.split()) command.extend(profile.script_args.split())
try: try:
print(f"Executing profiling command: {' '.join(command)}") print(f"Executing profiling command: {' '.join(command)}")
print(f"Working Directory for subprocess: {working_directory}") print(f"Working Directory for subprocess: {working_directory}")
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=working_directory)
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=working_directory
)
stdout, stderr = process.communicate() stdout, stderr = process.communicate()
if stdout: print(f"--- STDOUT ---\n{stdout}") if stdout: print(f"--- STDOUT ---\n{stdout}")
if stderr: print(f"--- STDERR ---\n{stderr}") if stderr: print(f"--- STDERR ---\n{stderr}")
if process.returncode != 0: if process.returncode != 0:
print(f"Warning: Profiled script exited with non-zero status: {process.returncode}") print(f"Warning: Profiled script exited with non-zero status: {process.returncode}")
if not os.path.exists(profile_output_path) or os.path.getsize(profile_output_path) == 0: if not os.path.exists(profile_output_path) or os.path.getsize(profile_output_path) == 0:
print("Error: Profiling failed and no output file was generated.") print("Error: Profiling failed and no output file was generated.")
return None return None
print(f"Profiling data saved to: {profile_output_path}") print(f"Profiling data saved to: {profile_output_path}")
return profile_output_path return profile_output_path
except Exception as e: except Exception as e:
print(f"Failed to run and profile script: {e}") print(f"Failed to run and profile script: {e}")
return None return None

View File

@ -0,0 +1,183 @@
# profileAnalyzer/gui/compare_window.py
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import json
import os
from pstats import Stats
from typing import Optional, Dict
from ..core.core import ProfileAnalyzer
class CompareWindow(tk.Toplevel):
"""A Toplevel window for comparing two profile data files."""
CONFIG_FILE = "app_config.json"
SIGNIFICANT_CHANGE_THRESHOLD = 0.000001 # 1 microsecond
def __init__(self, master, analyzer: ProfileAnalyzer):
super().__init__(master)
self.analyzer = analyzer
self.base_stats: Optional[Stats] = None
self.comp_stats: Optional[Stats] = None
self._init_window()
self._init_vars()
self._load_config()
self._create_widgets()
self._configure_treeview_tags()
def _init_window(self):
self.title("Compare Profile Data")
self.geometry("1200x700")
self.transient(self.master)
self.grab_set()
def _init_vars(self):
self.base_path_var = tk.StringVar()
self.comp_path_var = tk.StringVar()
def _load_config(self):
try:
if os.path.exists(self.CONFIG_FILE):
with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f:
config = json.load(f)
self.base_path_var.set(config.get("last_base_profile", ""))
self.comp_path_var.set(config.get("last_comp_profile", ""))
except (IOError, json.JSONDecodeError) as e:
print(f"Could not load config file: {e}")
def _save_config(self):
config = {
"last_base_profile": self.base_path_var.get(),
"last_comp_profile": self.comp_path_var.get()
}
try:
with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=4)
except IOError as e:
print(f"Could not save config file: {e}")
def _create_widgets(self):
main_frame = ttk.Frame(self, padding="10")
main_frame.pack(fill=tk.BOTH, expand=True)
main_frame.rowconfigure(1, weight=1)
main_frame.columnconfigure(0, weight=1)
# --- Top frame for file selection ---
selection_frame = ttk.LabelFrame(main_frame, text="Select Profiles", padding="10")
selection_frame.grid(row=0, column=0, sticky="ew", pady=(0, 10))
selection_frame.columnconfigure(1, weight=1)
# Baseline profile
ttk.Label(selection_frame, text="Baseline Profile:").grid(row=0, column=0, sticky="w", padx=5, pady=2)
ttk.Entry(selection_frame, textvariable=self.base_path_var).grid(row=0, column=1, sticky="ew", padx=5)
ttk.Button(selection_frame, text="...", width=3, command=lambda: self._browse_file(self.base_path_var)).grid(row=0, column=2)
# Comparison profile
ttk.Label(selection_frame, text="Comparison Profile:").grid(row=1, column=0, sticky="w", padx=5, pady=2)
ttk.Entry(selection_frame, textvariable=self.comp_path_var).grid(row=1, column=1, sticky="ew", padx=5)
ttk.Button(selection_frame, text="...", width=3, command=lambda: self._browse_file(self.comp_path_var)).grid(row=1, column=2)
ttk.Button(selection_frame, text="Compare", command=self._run_comparison).grid(row=2, column=1, columnspan=2, sticky="e", pady=10)
# --- Results frame ---
results_frame = ttk.LabelFrame(main_frame, text="Comparison Results", padding="10")
results_frame.grid(row=1, column=0, sticky="nsew")
results_frame.rowconfigure(0, weight=1)
results_frame.columnconfigure(0, weight=1)
self._create_results_tree(results_frame)
def _browse_file(self, path_var: tk.StringVar):
current_path = path_var.get()
initial_dir = os.path.dirname(current_path) if current_path and os.path.exists(os.path.dirname(current_path)) else os.getcwd()
filepath = filedialog.askopenfilename(
title="Select a .prof file",
filetypes=[("Profile files", "*.prof"), ("All files", "*.*")],
initialdir=initial_dir
)
if filepath:
path_var.set(filepath)
def _create_results_tree(self, parent):
columns = ("func", "ncalls", "d_ncalls", "tottime", "d_tottime", "cumtime", "d_cumtime")
self.tree = ttk.Treeview(parent, columns=columns, show="headings")
col_map = {
"func": ("Function", 400), "ncalls": ("N-Calls", 80), "d_ncalls": ("Δ N-Calls", 80),
"tottime": ("Total Time (s)", 110), "d_tottime": ("Δ Total Time", 110),
"cumtime": ("Cum. Time (s)", 110), "d_cumtime": ("Δ Cum. Time", 110)
}
for col_id, (text, width) in col_map.items():
# --- INIZIO DELLA CORREZIONE ---
sort_key = ""
if col_id == "func":
sort_key = "func_str" # Usa la chiave corretta per il nome della funzione
else:
sort_key = col_id.replace('d_', 'delta_')
# --- FINE DELLA CORREZIONE ---
self.tree.heading(col_id, text=text, anchor="w", command=lambda c=sort_key: self._sort_tree(c))
self.tree.column(col_id, width=width, stretch=(col_id == "func"), anchor="w" if col_id == "func" else "e")
vsb = ttk.Scrollbar(parent, orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(parent, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self.tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
def _configure_treeview_tags(self):
self.tree.tag_configure('better', background='#c8e6c9') # Light Green
self.tree.tag_configure('worse', background='#ffcdd2') # Light Red
def _run_comparison(self):
base_path = self.base_path_var.get()
comp_path = self.comp_path_var.get()
if not base_path or not comp_path:
messagebox.showerror("Error", "Please select both a baseline and a comparison profile.", parent=self)
return
# Use a separate analyzer instance to avoid state conflicts
loader = ProfileAnalyzer()
base_stats = loader.load_profile(base_path)
comp_stats = loader.load_profile(comp_path)
if not base_stats or not comp_stats:
messagebox.showerror("Error", "One or both profile files could not be loaded. Check console for details.", parent=self)
return
self.master.config(cursor="watch")
self.comparison_data = self.analyzer.compare_stats(base_stats, comp_stats)
self._populate_tree()
self.master.config(cursor="")
self._save_config()
def _populate_tree(self):
self.tree.delete(*self.tree.get_children())
for row in self.comparison_data:
d_tt = row['delta_tottime']
tags = ()
if d_tt > self.SIGNIFICANT_CHANGE_THRESHOLD:
tags = ('worse',)
elif d_tt < -self.SIGNIFICANT_CHANGE_THRESHOLD:
tags = ('better',)
formatted_row = (
row['func_str'],
row['ncalls'], f"{row['delta_ncalls']:+d}",
f"{row['tottime']:.6f}", f"{d_tt:+.6f}",
f"{row['cumtime']:.6f}", f"{row['delta_cumtime']:+.6f}"
)
self.tree.insert("", "end", values=formatted_row, tags=tags)
def _sort_tree(self, col: str):
# Sort internal data
self.comparison_data.sort(key=lambda x: x[col], reverse=True)
# Repopulate tree with sorted data
self._populate_tree()

View File

@ -17,6 +17,7 @@ from profileanalyzer.core.core import (
) )
from profileanalyzer.core.profile_manager import LaunchProfileManager from profileanalyzer.core.profile_manager import LaunchProfileManager
from profileanalyzer.gui.launch_manager_window import LaunchManagerWindow from profileanalyzer.gui.launch_manager_window import LaunchManagerWindow
from profileanalyzer.gui.compare_window import CompareWindow
try: try:
from PIL import Image, ImageTk from PIL import Image, ImageTk
@ -114,7 +115,8 @@ class ProfileAnalyzerGUI(tk.Frame):
top_frame.columnconfigure(3, weight=1) top_frame.columnconfigure(3, weight=1)
ttk.Button(top_frame, text="Load Profile File...", command=self._load_profile).grid(row=0, column=0, padx=(0, 5)) ttk.Button(top_frame, text="Load Profile File...", command=self._load_profile).grid(row=0, column=0, padx=(0, 5))
ttk.Button(top_frame, text="Profile a Script...", command=self._open_launch_manager).grid(row=0, column=1, padx=5) ttk.Button(top_frame, text="Profile a Script...", command=self._open_launch_manager).grid(row=0, column=1, padx=5)
ttk.Button(top_frame, text="Export to CSV...", command=self._export_to_csv).grid(row=0, column=2, padx=5) ttk.Button(top_frame, text="Compare Profiles...", command=self._open_compare_window).grid(row=0, column=2, padx=5)
ttk.Button(top_frame, text="Export to CSV...", command=self._export_to_csv).grid(row=0, column=3, padx=5)
loaded_file_label = ttk.Label(top_frame, text="Current Profile:") loaded_file_label = ttk.Label(top_frame, text="Current Profile:")
loaded_file_label.grid(row=0, column=3, sticky="w", padx=(10, 0)) loaded_file_label.grid(row=0, column=3, sticky="w", padx=(10, 0))
loaded_file_display = ttk.Label(top_frame, textvariable=self.loaded_filepath_var, anchor="w", relief="sunken") loaded_file_display = ttk.Label(top_frame, textvariable=self.loaded_filepath_var, anchor="w", relief="sunken")
@ -283,18 +285,30 @@ class ProfileAnalyzerGUI(tk.Frame):
def _create_table_tab(self, parent_frame): def _create_table_tab(self, parent_frame):
parent_frame.rowconfigure(0, weight=1) parent_frame.rowconfigure(0, weight=1)
parent_frame.columnconfigure(0, weight=1) parent_frame.columnconfigure(0, weight=1)
columns = ("ncalls", "tottime", "percall_tottime", "cumtime", "percall_cumtime", "function")
# Define columns, including the new percentage column
columns = ("ncalls", "tottime", "tottime_percent", "percall_tottime", "cumtime", "percall_cumtime", "function")
self.tree = ttk.Treeview(parent_frame, columns=columns, show="headings") self.tree = ttk.Treeview(parent_frame, columns=columns, show="headings")
self.tree.bind("<<TreeviewSelect>>", self._on_function_select) self.tree.bind("<<TreeviewSelect>>", self._on_function_select)
# Define column properties
col_map = { col_map = {
"ncalls": ("N-Calls", 80), "tottime": ("Total Time (s)", 120), "ncalls": ("N-Calls", 80), "tottime": ("Total Time (s)", 120),
"tottime_percent": ("% Total Time", 100), # New column
"percall_tottime": ("Per Call (s)", 100), "cumtime": ("Cum. Time (s)", 120), "percall_tottime": ("Per Call (s)", 100), "cumtime": ("Cum. Time (s)", 120),
"percall_cumtime": ("Per Call (s)", 100), "function": ("Function", 500) "percall_cumtime": ("Per Call (s)", 100), "function": ("Function", 500)
} }
for col_id, (text, width) in col_map.items(): for col_id, (text, width) in col_map.items():
anchor = "w" if col_id == "function" else "e" anchor = "w" if col_id == "function" else "e"
self.tree.heading(col_id, text=text, anchor="w", command=lambda c=col_id: self._on_header_click(c)) sort_key = col_id if col_id != "tottime_percent" else "tottime" # Sort by tottime value
self.tree.heading(col_id, text=text, anchor="w", command=lambda c=sort_key: self._on_header_click(c))
self.tree.column(col_id, width=width, stretch=(col_id == "function"), anchor=anchor) self.tree.column(col_id, width=width, stretch=(col_id == "function"), anchor=anchor)
# Configure tags for heat-mapping
self.tree.tag_configure('heat_critical', background='#ffcdd2') # Red
self.tree.tag_configure('heat_high', background='#ffecb3') # Yellow
self.tree.tag_configure('heat_medium', background='#e8f5e9') # Green
vsb = ttk.Scrollbar(parent_frame, orient="vertical", command=self.tree.yview) vsb = ttk.Scrollbar(parent_frame, orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(parent_frame, orient="horizontal", command=self.tree.xview) hsb = ttk.Scrollbar(parent_frame, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
@ -354,6 +368,11 @@ class ProfileAnalyzerGUI(tk.Frame):
if manager_window.selected_profile_to_run: if manager_window.selected_profile_to_run:
self._start_profiling_thread(manager_window.selected_profile_to_run) self._start_profiling_thread(manager_window.selected_profile_to_run)
def _open_compare_window(self):
"""Opens the dedicated window for profile comparison."""
compare_win = CompareWindow(self, self.analyzer)
compare_win.focus_set()
def _start_profiling_thread(self, profile: LaunchProfile): def _start_profiling_thread(self, profile: LaunchProfile):
self.master.config(cursor="watch") self.master.config(cursor="watch")
self.loaded_filepath_var.set(f"Profiling '{profile.name}' in progress...") self.loaded_filepath_var.set(f"Profiling '{profile.name}' in progress...")
@ -393,49 +412,88 @@ class ProfileAnalyzerGUI(tk.Frame):
self._apply_filter_and_refresh_views() self._apply_filter_and_refresh_views()
def _apply_filter_and_refresh_views(self, *args): def _apply_filter_and_refresh_views(self, *args):
"""Filters data, refreshes Treeview with heat-mapping, and refreshes Text view."""
filter_term = self.filter_var.get().lower() filter_term = self.filter_var.get().lower()
if filter_term: if filter_term:
# The last element (index 6) is the function string
self.filtered_stats_data = [ self.filtered_stats_data = [
row for row in self.current_stats_data row for row in self.current_stats_data
if filter_term in row[5].lower() if filter_term in row[6].lower()
] ]
else: else:
self.filtered_stats_data = self.current_stats_data self.filtered_stats_data = self.current_stats_data
# Refresh Treeview
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
for row in self.filtered_stats_data: for row in self.filtered_stats_data:
formatted_row = (row[0], f"{row[1]:.6f}", f"{row[2]:.6f}", f"{row[3]:.6f}", f"{row[4]:.6f}", row[5]) # Unpack data including the new percentage
self.tree.insert("", "end", values=formatted_row) ncalls, tottime, percentage, percall_tottime, cumtime, percall_cumtime, func_str = row
# Determine tag for heat-mapping
tags = ()
if percentage > 10.0:
tags = ('heat_critical',)
elif percentage > 2.0:
tags = ('heat_high',)
elif percentage > 0.5:
tags = ('heat_medium',)
# Format row for display
formatted_row = (
ncalls,
f"{tottime:.6f}",
f"{percentage:.2f}%",
f"{percall_tottime:.6f}",
f"{cumtime:.6f}",
f"{percall_cumtime:.6f}",
func_str
)
self.tree.insert("", "end", values=formatted_row, tags=tags)
# Refresh Text view
self._update_text_view() self._update_text_view()
def _update_text_view(self): def _update_text_view(self):
"""Updates the text view with the currently filtered and sorted data."""
self.text_view.config(state=tk.NORMAL) self.text_view.config(state=tk.NORMAL)
self.text_view.delete("1.0", tk.END) self.text_view.delete("1.0", tk.END)
if not self.filtered_stats_data: if not self.filtered_stats_data:
self.text_view.config(state=tk.DISABLED) self.text_view.config(state=tk.DISABLED)
return return
header = f"{'N-Calls':>12s} {'Total Time':>15s} {'Per Call':>12s} {'Cum. Time':>15s} {'Per Call':>12s} {'Function'}\n"
separator = f"{'-'*12} {'-'*15} {'-'*12} {'-'*15} {'-'*12} {'-'*40}\n" header = f"{'N-Calls':>12s} {'Total Time':>15s} {'% Time':>8s} {'Per Call':>12s} {'Cum. Time':>15s} {'Per Call':>12s} {'Function'}\n"
separator = f"{'-'*12} {'-'*15} {'-'*8} {'-'*12} {'-'*15} {'-'*12} {'-'*40}\n"
self.text_view.insert(tk.END, header) self.text_view.insert(tk.END, header)
self.text_view.insert(tk.END, separator) self.text_view.insert(tk.END, separator)
for row in self.filtered_stats_data: for row in self.filtered_stats_data:
line = f"{str(row[0]):>12s} {row[1]:15.6f} {row[2]:12.6f} {row[3]:15.6f} {row[4]:12.6f} {row[5]}\n" # Unpack the full tuple
ncalls, tottime, percentage, percall_tottime, cumtime, percall_cumtime, func_str = row
line = f"{str(ncalls):>12s} {tottime:15.6f} {percentage:7.2f}% {percall_tottime:12.6f} {cumtime:15.6f} {percall_cumtime:12.6f} {func_str}\n"
self.text_view.insert(tk.END, line) self.text_view.insert(tk.END, line)
self.text_view.config(state=tk.DISABLED) self.text_view.config(state=tk.DISABLED)
def _export_to_csv(self): def _export_to_csv(self):
"""Exports the currently visible (filtered) data to a CSV file."""
if not self.filtered_stats_data: if not self.filtered_stats_data:
messagebox.showwarning("No Data", "No data to export.") messagebox.showwarning("No Data", "No data to export.")
return return
filepath = filedialog.asksaveasfilename( filepath = filedialog.asksaveasfilename(
defaultextension=".csv", defaultextension=".csv",
filetypes=[("CSV files", "*.csv"), ("All files", "*,*")], filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
title="Save Profile Stats as CSV" title="Save Profile Stats as CSV"
) )
if not filepath: return if not filepath: return
try: try:
with open(filepath, 'w', newline='', encoding='utf-8') as f: with open(filepath, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f) writer = csv.writer(f)
writer.writerow(["ncalls", "tottime", "percall_tottime", "cumtime", "percall_cumtime", "function_details"]) # Update header for CSV export
writer.writerow([
"ncalls", "tottime", "percentage_tottime",
"percall_tottime", "cumtime", "percall_cumtime", "function_details"
])
writer.writerows(self.filtered_stats_data) writer.writerows(self.filtered_stats_data)
messagebox.showinfo("Export Successful", f"Stats successfully exported to:\n{filepath}") messagebox.showinfo("Export Successful", f"Stats successfully exported to:\n{filepath}")
except IOError as e: except IOError as e:

66
todo.md
View File

@ -1,59 +1,27 @@
Certamente. Ora che abbiamo una base solida, possiamo pensare a funzionalità più avanzate che trasformerebbero questo semplice visualizzatore in un potente strumento di analisi, utile anche per il progetto principale. ### 1. Miglioramenti dell'Analisi e della User Experience (Alto Impatto)
Ecco alcune idee, dalle più semplici alle più complesse, che potremmo implementare: Queste modifiche non aggiungono dati nuovi, ma rendono quelli esistenti molto più facili da interpretare.
### 1. Visualizzazione Grafica dei "Callers" e "Callees"
**Idea**: Quando l'utente seleziona una riga nel `Treeview`, potremmo mostrare in due pannelli separati: * **Percentuali e Heat-Mapping nella Tabella:**
* **Callers**: Quali funzioni hanno chiamato la funzione selezionata e quante volte. * **Perché è utile:** I numeri assoluti (es. `0.015s`) sono meno intuitivi di quelli relativi. Sapere che una funzione occupa il `45%` del tempo totale è molto più eloquente.
* **Callees**: Quali funzioni sono state chiamate dalla funzione selezionata. * **Come potremmo farlo:** Aggiungere una colonna "% Tempo Totale" alla tabella principale. Inoltre, potremmo applicare alla tabella lo stesso principio di "heat-mapping" del grafo: colorare lo sfondo delle righe da verde a rosso in base al loro impatto sul tempo totale. L'occhio verrebbe immediatamente attirato dalle righe più "calde".
**Perché è utile**: Questa è la funzionalità più potente di un profiler. Ti permette di "navigare" nel grafo delle chiamate per capire non solo *quale* funzione è lenta, ma *perché* viene chiamata così spesso o da quale percorso critico. Aiuta a rispondere a domande come: "Questa funzione lenta è chiamata da una sola parte del codice o da molte? Qual è il suo impatto a cascata?". ### 2. Integrazione nel Flusso di Lavoro dello Sviluppatore
**Come implementarlo**: Queste modifiche collegano l'analizzatore agli altri strumenti che usi quotidianamente.
1. Modificheremo `core.py`. La classe `pstats.Stats` ha già i metodi `print_callers()` e `print_callees()`. Possiamo creare due nuovi metodi in `ProfileAnalyzer` (es. `get_callers_for_func()` e `get_callees_for_func()`) che catturano e parsano l'output di questi comandi per una funzione specifica.
2. Modificheremo `gui.py` per aggiungere due nuovi `Treeview` (o `Listbox`) in un'area inferiore della finestra, che verranno popolati quando l'utente clicca su una riga della tabella principale.
### 2. Filtraggio per Nome di Funzione o File * **Integrazione con l'Editor di Codice ("Open in Editor"):**
* **Perché è utile:** Hai trovato la funzione lenta. Ora devi aprire il tuo editor, cercare il file e andare alla riga giusta. Possiamo automatizzarlo.
* **Come potremmo farlo:** Aggiungere un menu contestuale (tasto destro del mouse) nella tabella delle funzioni. Una delle opzioni sarebbe "Open in Editor". L'applicazione conosce già il percorso del file e il numero di riga della funzione. Basterebbe una piccola finestra di impostazioni dove l'utente può inserire il comando per il suo editor preferito (es. per VS Code: `code -g {file}:{line}`). Questo renderebbe il ciclo "analizza -> correggi" incredibilmente veloce.
**Idea**: Aggiungere una casella di testo (un campo di ricerca) che permetta di filtrare la tabella per mostrare solo le funzioni che contengono una certa stringa (es. `_stream_srio_blocks`, `file_reader.py`). * **Migliorare il Launch Profile Manager:**
* **Perché è utile:** Al momento l'interprete Python non è configurabile dalla UI. I progetti reali usano quasi sempre ambienti virtuali (`venv`) specifici.
* **Come potremmo farlo:** Aggiungere un campo di testo e un pulsante "Sfoglia" nel `LaunchManagerWindow` per permettere di specificare il percorso dell'eseguibile Python per *ciascun profilo*. La `dataclass` `LaunchProfile` lo supporta già, dobbiamo solo esporlo nella UI.
**Perché è utile**: Quando i risultati sono centinaia, scorrere la lista è difficile. Un filtro ti permette di concentrarti immediatamente su un modulo o una funzione specifica di cui sospetti, rendendo l'analisi molto più rapida. ### 3. Funzionalità Avanzate di Analisi
**Come implementarlo**: * **Vista Gerarchica (Flame Graph Style):**
1. In `gui.py`, aggiungeremo un `ttk.Entry` per la ricerca. * **Perché è utile:** Una tabella è piatta. Un Flame Graph (o una vista ad albero) mostra la gerarchia delle chiamate e dove si accumula il tempo all'interno dello stack di chiamate.
2. Collegheremo l'evento di modifica del testo (`<KeyRelease>`) a una funzione di aggiornamento. * **Come potremmo farlo:** Potremmo modificare la `Table View` per usare il `ttk.Treeview` in modalità gerarchica. Ogni riga potrebbe essere espandibile per mostrare le sue `callees`, con i loro tempi ricalcolati in proporzione al chiamante. È una modifica complessa, ma offrirebbe una profondità di analisi eccezionale.
3. Il metodo `_update_stats_display` verrà modificato per passare il termine di ricerca al `ProfileAnalyzer`.
4. In `core.py`, il metodo `get_stats` accetterà un nuovo argomento opzionale `filter_term`. Prima di restituire i risultati, filtrerà la lista per includere solo le righe in cui il nome della funzione (`func_info`) contiene il termine di ricerca.
### 3. "Flame Graph" Semplificato (Avanzato)
**Idea**: Un Flame Graph è una visualizzazione gerarchica che mostra lo stack di chiamate e quanto tempo viene speso in ogni funzione. Quelli completi sono complessi, ma possiamo crearne una versione testuale semplificata. Quando l'utente clicca su una funzione, potremmo mostrare un albero di testo che rappresenta il suo stack di chiamate e il tempo cumulativo.
**Perché è utile**: Fornisce una comprensione visiva e immediata di dove il tempo viene "consumato" all'interno di una singola operazione. È lo strumento definitivo per l'analisi delle performance.
**Come implementarlo**:
* Questa è la funzionalità più complessa. Richiede di analizzare in modo ricorsivo le relazioni "caller-callee" fornite da `pstats` per costruire la gerarchia.
* Potremmo usare un `ttk.Treeview` per visualizzare il grafo, dove ogni nodo è una funzione e può essere espanso per vedere le sue sotto-chiamate.
### 4. Confronto tra Due Profili (Avanzato)
**Idea**: Permettere all'utente di caricare due file `.prof` (es. "prima" e "dopo" un'ottimizzazione) e mostrare una vista comparativa che evidenzi le differenze:
* Funzioni che sono diventate più veloci (regressioni positive).
* Funzioni che sono diventate più lente (regressioni negative).
* Funzioni che sono apparse o scomparse.
**Perché è utile**: È essenziale per verificare che una modifica abbia effettivamente migliorato le performance e non abbia introdotto nuovi colli di bottiglia.
**Come implementarlo**:
1. La GUI dovrebbe avere due pulsanti "Load Profile A" e "Load Profile B".
2. Il `core.py` avrebbe bisogno di una logica per "unire" i dati dei due profili, calcolare i delta percentuali per `tottime` e `cumtime`, e restituire una struttura dati comparativa.
3. Il `Treeview` nella GUI mostrerebbe colonne aggiuntive come "Delta Time", "Delta Calls", etc., magari usando colori (verde/rosso) per evidenziare i miglioramenti e i peggioramenti.
---
**La mia raccomandazione su cosa fare ora:**
La **funzionalità #1 (Visualizzazione Callers/Callees)** è il passo successivo più logico e utile. È relativamente semplice da implementare usando le capacità intrinseche di `pstats` e aggiunge un valore enorme all'analisi. Ti permette di "scavare" nei dati invece di guardarli solo in superficie.
Se sei d'accordo, possiamo procedere con l'implementazione di questa funzionalità.