diff --git a/projects/rocprofiler-compute/CHANGELOG.md b/projects/rocprofiler-compute/CHANGELOG.md index 0a2f352dee..9f33653aa6 100644 --- a/projects/rocprofiler-compute/CHANGELOG.md +++ b/projects/rocprofiler-compute/CHANGELOG.md @@ -25,6 +25,8 @@ Full documentation for ROCm Compute Profiler is available at [https://rocm.docs. * CLI analysis mode baseline comparison will now only compare common metrics across workloads and will not show Metric ID * Remove metrics from analysis configuration files which are explicitly marked as empty or None +* Change the basic view of TUI from aggregated analysis data to individual kernel analysis data + ### Resolved issues * Fixed not detecting memory clock issue when using amd-smi @@ -42,6 +44,7 @@ Full documentation for ROCm Compute Profiler is available at [https://rocm.docs. * Usage of rocm-smi * Hardware IP block based filtering has been removed in favor of analysis report block based filtering +* Remove aggregated analysis view from TUI mode ## ROCm Compute Profiler 3.2.1 for ROCm 7.0.0 diff --git a/projects/rocprofiler-compute/docs/data/analyze/tui_home.png b/projects/rocprofiler-compute/docs/data/analyze/tui_home.png new file mode 100644 index 0000000000..24fde654f0 Binary files /dev/null and b/projects/rocprofiler-compute/docs/data/analyze/tui_home.png differ diff --git a/projects/rocprofiler-compute/docs/data/analyze/tui_kernel_selection.png b/projects/rocprofiler-compute/docs/data/analyze/tui_kernel_selection.png new file mode 100644 index 0000000000..0ea6204b97 Binary files /dev/null and b/projects/rocprofiler-compute/docs/data/analyze/tui_kernel_selection.png differ diff --git a/projects/rocprofiler-compute/src/config.py b/projects/rocprofiler-compute/src/config.py index 5cb8b279cf..0fe7891c2f 100644 --- a/projects/rocprofiler-compute/src/config.py +++ b/projects/rocprofiler-compute/src/config.py @@ -22,6 +22,7 @@ # SOFTWARE. ##############################################################################el +import re from pathlib import Path # NB: Creating a new module to share global vars across modules @@ -30,6 +31,7 @@ PROJECT_NAME = "rocprofiler-compute" HIDDEN_COLUMNS = ["coll_level"] HIDDEN_COLUMNS_CLI = ["Description", "coll_level"] +HIDDEN_COLUMNS_TUI = ["Description", "coll_level"] HIDDEN_SECTIONS = [400, 1900, 2000] TIME_UNITS = {"s": 10**9, "ms": 10**6, "us": 10**3, "ns": 1} diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/analysis_tui.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/analysis_tui.py index ae13c6edbe..cac303795f 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/analysis_tui.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/analysis_tui.py @@ -23,11 +23,13 @@ ##############################################################################el import copy -import sys from pathlib import Path from rocprof_compute_analyze.analysis_base import OmniAnalyze_Base -from rocprof_compute_tui.utils.tui_utils import process_panels_to_dataframes +from rocprof_compute_tui.utils.tui_utils import ( + get_top_kernels_and_dispatch_ids, + process_panels_to_dataframes, +) from utils import file_io, parser, schema from utils.kernel_name_shortener import kernel_name_shortener from utils.logger import console_error, demarcate @@ -38,23 +40,21 @@ class tui_analysis(OmniAnalyze_Base): super().__init__(args, supported_archs) self.path = str(path) self.arch = None + self.raw_dfs = {} + self.kernel_dfs = {} # ----------------------- # Required child methods # ----------------------- @demarcate def pre_processing(self): - """Perform any pre-processing steps prior to analysis.""" - # Read profiling config self._profiling_config = file_io.load_profiling_config(self.path) - # initalize runs self._runs = self.initalize_runs() if self.get_args().random_port: console_error("--gui flag is required to enable --random-port") - # create 'mega dataframe' self._runs[self.path].raw_pmc = file_io.create_df_pmc( self.path, self.get_args().nodes, @@ -80,22 +80,33 @@ class tui_analysis(OmniAnalyze_Base): kernel_verbose=self.get_args().kernel_verbose, ) - # demangle and overwrite original 'Kernel_Name' kernel_name_shortener( self._runs[self.path].raw_pmc, self.get_args().kernel_verbose ) - # create the loaded table - parser.load_table_data( - workload=self._runs[self.path], - dir=self.path, - is_gui=False, - args=self.get_args(), - config=self._profiling_config, + # 1. load top kernel + parser.load_kernel_top( + workload=self._runs[self.path], dir=self.path, args=self.get_args() ) + # 2. load table data for each kernel + self.raw_dfs.clear() + for idx in self._runs[self.path].raw_pmc.index: + kernel_df = self._runs[self.path].raw_pmc.loc[[idx]] + kernel_name = kernel_df.pmc_perf["Kernel_Name"].loc[idx] + this_dfs = copy.deepcopy(self._runs[self.path].dfs) + parser.eval_metric( + this_dfs, + self._runs[self.path].dfs_type, + self._runs[self.path].sys_info.iloc[0], + kernel_df, + self.get_args().debug, + self._profiling_config, + ) + + self.raw_dfs[kernel_name] = this_dfs + def initalize_runs(self, normalization_filter=None): - # load required configs sysinfo_path = Path(self.path) sys_info = file_io.load_sys_info(sysinfo_path.joinpath("sysinfo.csv")) self.arch = sys_info.iloc[0]["gpu_arch"] @@ -111,10 +122,6 @@ class tui_analysis(OmniAnalyze_Base): self.load_options(normalization_filter) w = schema.Workload() - # FIXME: - # For regular single node case, load sysinfo.csv directly - # For multi-node, either the default "all", or specified some, - # pick up the one in the 1st sub_dir. We could fix it properly later. w.sys_info = file_io.load_sys_info(sysinfo_path.joinpath("sysinfo.csv")) mspec = self.get_socs()[self.arch]._mspec if args.specs_correction: @@ -127,43 +134,14 @@ class tui_analysis(OmniAnalyze_Base): return self._runs @demarcate - def run_analysis(self): - """Run TUI analysis.""" - super().run_analysis() - - roof_plot = None - # 1. check if not baseline && compatible soc: - if self.arch in [ - # >= MI200 - "gfx90a", - "gfx940", - "gfx941", - "gfx942", - "gfx950", - ]: - # add roofline plot to cli output - self.get_socs()[self.arch].analysis_setup( - roofline_parameters={ - "workload_dir": self.path, - "device_id": 0, - "sort_type": "kernels", - "mem_level": "ALL", - "include_kernel_names": False, - "is_standalone": False, - "roofline_data_type": "FP32", - } + def run_kernel_analysis(self): + self.kernel_dfs.clear() + for kernel_name, df in self.raw_dfs.items(): + self.kernel_dfs[kernel_name] = process_panels_to_dataframes( + self.get_args(), df, self._arch_configs[self.arch], roof_plot=None ) - roof_obj = self.get_socs()[self.arch].roofline_obj + return self.kernel_dfs - if roof_obj: - # NOTE: using default data type - roof_plot = roof_obj.cli_generate_plot(roof_obj.get_dtype()[0]) - - results = process_panels_to_dataframes( - self.get_args(), - self._runs, - self._arch_configs[self.arch], - self._profiling_config, - roof_plot=roof_plot, - ) - return results + @demarcate + def run_top_kernel(self): + return get_top_kernels_and_dispatch_ids(self._runs) diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/config.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/config.py index fc51effe2a..c7e1198e31 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/config.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/config.py @@ -30,7 +30,6 @@ Central configuration for the application. # Application settings APP_TITLE = "ROCm Compute Profiler TUI" -VERSION = "3.2.0" # Widget configurations DEFAULT_COLLAPSIBLE_STATE = True # True = collapsed by default diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/tui_app.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/tui_app.py index 4f07be9605..4f23660bac 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/tui_app.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/tui_app.py @@ -39,15 +39,18 @@ from textual.binding import Binding from textual.widgets import Button, Footer, Header from textual_fspicker import SelectDirectory -from rocprof_compute_tui.config import APP_TITLE, VERSION +import config +from rocprof_compute_tui.config import APP_TITLE from rocprof_compute_tui.views.main_view import MainView from rocprof_compute_tui.widgets.menu_bar.menu_bar import DropdownMenu from utils.specs import MachineSpecs, generate_machine_specs +from utils.utils import get_version class RocprofTUIApp(App): """Main application for the performance analysis tool.""" + VERSION = get_version(config.rocprof_compute_home)["version"] TITLE = f"{APP_TITLE} v{VERSION}" SUB_TITLE = "Workload Analysis Tool" @@ -55,7 +58,8 @@ class RocprofTUIApp(App): BINDINGS = [ Binding(key="q", action="quit", description="Quit"), Binding(key="r", action="refresh", description="Refresh"), - Binding(key="a", action="analyze", description="Analyze"), + # TODO + # Binding(key="a", action="analyze", description="Analyze"), ] def __init__( diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/analyze_config.yaml b/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/analyze_config.yaml deleted file mode 100644 index daea744094..0000000000 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/analyze_config.yaml +++ /dev/null @@ -1,51 +0,0 @@ -sections: - - title: "πŸ“Š Summaries" - collapsed: true - class: "summary-section" - subsections: - - title: "Top Kernels" - data_path: ["0. Top Stats", "0.1 Top Kernels"] - collapsed: true - header_label: "Top Kernels by Duration (ns):" - header_class: "section-header" - - title: "Dispatch List" - data_path: ["0. Top Stats", "0.2 Dispatch List"] - collapsed: true - - title: "System Info" - data_path: ["1. System Info", "1.1"] - collapsed: true - - - title: "⚑ High Level Analysis" - collapsed: true - class: "sysinfo-section" - subsections: - - title: "System Speed-of-Light" - data_path: ["2. System Speed-of-Light", "2.1 Speed-of-Light"] - collapsed: true - - title: "Roofline" - collapsed: true - tui_style: "roofline" - widget_id: "roofline-plot" - - title: "Memory Chart" - data_path: ["3. Memory Chart", "3.1 Memory Chart"] - collapsed: true - tui_style: "mem_chart" - - - title: "πŸ” Detailed Block Analysis" - collapsed: true - class: "kernels-section" - dynamic_sections: true - skip_sections: - - "0. Top Stats" - - "1. System Info" - - "2. System Speed-of-Light" - - "3. Memory Chart" - - "4. Roofline" - - - title: "🚧 Source Level Analysis" - collapsed: true - class: "source-section" - subsections: - - title: "PC Sampling" - data_path: ["21. PC Sampling", "21.1 PC Sampling"] - collapsed: true diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/kernel_view_config.yaml b/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/kernel_view_config.yaml new file mode 100644 index 0000000000..a12e2f846b --- /dev/null +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/kernel_view_config.yaml @@ -0,0 +1,35 @@ +# TODO: add System Info +# - title: "System Info" +# data_path: ["1. System Info", "1.1"] +# collapsed: true +sections: + - title: "High Level Analysis" + collapsed: true + class: "sysinfo-section" + subsections: + - title: "System Speed-of-Light" + data_path: ["2. System Speed-of-Light", "2.1 System Speed-of-Light"] + collapsed: true + - title: "Memory Chart" + data_path: ["3. Memory Chart", "3.1 Memory Chart"] + collapsed: true + tui_style: "mem_chart" + + - title: "Detailed Block Analysis" + collapsed: true + class: "kernels-section" + dynamic_sections: true + skip_sections: + - "0. Top Stats" + - "1. System Info" + - "2. System Speed-of-Light" + - "3. Memory Chart" + - "4. Roofline" + + - title: "Source Level Analysis" + collapsed: true + class: "source-section" + subsections: + - title: "PC Sampling" + data_path: ["21. PC Sampling", "21.1 PC Sampling"] + collapsed: true diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/tui_utils.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/tui_utils.py index de56c56607..5f5f0ce0ed 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/tui_utils.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/utils/tui_utils.py @@ -1,87 +1,27 @@ -import copy import logging -import os -import re from collections import defaultdict from datetime import datetime from enum import Enum -from pathlib import Path +import numpy as np import pandas as pd -from config import HIDDEN_COLUMNS, HIDDEN_SECTIONS - -supported_field = [ - "Value", - "Minimum", - "Maximum", - "Average", - "Median", - "Min", - "Max", - "Avg", - "Pct of Peak", - "Peak", - "Count", - "Mean", - "Pct", - "Std Dev", - "Q1", - "Q3", - "Expression", - # Special keywords for L2 channel - "Channel", - "L2 Cache Hit Rate", - "Requests", - "L2 Read", - "L2 Write", - "L2 Atomic", - "L2-Fabric Requests", - "L2-Fabric Read", - "L2-Fabric Write and Atomic", - "L2-Fabric Atomic", - "L2 Read Req", - "L2 Write Req", - "L2 Atomic Req", - "L2-Fabric Read Req", - "L2-Fabric Write and Atomic Req", - "L2-Fabric Atomic Req", - "L2-Fabric Read Latency", - "L2-Fabric Write Latency", - "L2-Fabric Atomic Latency", - "L2-Fabric Read Stall (PCIe)", - "L2-Fabric Read Stall (Infinity Fabricβ„’)", - "L2-Fabric Read Stall (HBM)", - "L2-Fabric Write Stall (PCIe)", - "L2-Fabric Write Stall (Infinity Fabricβ„’)", - "L2-Fabric Write Stall (HBM)", - "L2-Fabric Write Starve", -] +import config class LogLevel(str, Enum): - """Log levels for consistent logging.""" - INFO = "info" WARNING = "warning" ERROR = "error" - SUCCESS = "success" # Maintained for UI compatibility + SUCCESS = "success" class Logger: - """Centralized logging handler for the application.""" - def __init__(self, output_area=None): - """ - Initialize the logger. - """ self.output_area = output_area self._setup_logger() def _setup_logger(self): - """ - Setup the Python logger with proper formatting. - """ self.logger = logging.getLogger("app") self.logger.setLevel(logging.INFO) @@ -94,15 +34,9 @@ class Logger: self.logger.addHandler(handler) def set_output_area(self, output_area): - """ - Set or update the output area for displaying logs. - """ self.output_area = output_area def log(self, message, level=LogLevel.INFO, update_ui=True): - """ - Log a message with the specified level. - """ level_map = { LogLevel.INFO: logging.INFO, LogLevel.SUCCESS: logging.INFO, @@ -145,151 +79,28 @@ class Logger: self.log(message, LogLevel.ERROR, update_ui) -def split_table_line(line): - """ - Splits a table row line into a list of cell strings (trimmed). For example: +def get_top_kernels_and_dispatch_ids(runs): + if not runs: + return None - β”‚ β”‚ Kernel_Name β”‚ Count β”‚ ... - """ + base_run = next(iter(runs.values())) + if not hasattr(base_run, "dfs"): + return None - cells = line.split("β”‚") - if cells and cells[0] == "": - cells = cells[1:] - if cells and cells[-1] == "": - cells = cells[:-1] - return [cell.strip() for cell in cells] + top_kernel_df = base_run.dfs.get(1) + dispatch_id_df = base_run.dfs.get(2) + + if top_kernel_df is None or dispatch_id_df is None: + return None + + merged_df = pd.merge( + top_kernel_df, dispatch_id_df, on="Kernel_Name", how="outer" + ).sort_values("Pct", ascending=False) + + return merged_df.to_dict("records") -def parse_ascii_table(table_lines): - """ - Given a list of lines belonging to one ASCII table (including border rows), - return a tuple (header, data_rows) where header is a list of column names and - data_rows is a list of rows (each a list of cell strings). - - Skips border/separator lines and also checks for continuation - rows (which have an empty first cell). Continuation rows get merged into the previous row. - """ - - header = None - data_rows = [] - - for line in table_lines: - if re.match(r"^[β•’β•žβ•˜β”œβ””β”€]+", line): - continue - if "β”‚" not in line: - continue - - cells = split_table_line(line) - - if header is None: - header = cells - continue - - if cells and cells[0] == "": - if data_rows: # There should be at least one row already. - for i, cell in enumerate(cells): - if cell: - data_rows[-1][i] += " " + cell - else: - continue - else: - data_rows.append(cells) - return header, data_rows - - -def parse_file(filename): - """ - Returns nested structure: - { - "0. Top Stats": { - "0.1 Top Kernels": {header: [...], data: [...]}, - "0.2 Dispatch List": {header: [...], data: [...]} - }, - "1. System Info": { - "1.1 System Information": {header: [...], data: [...]} - }, - ... - } - """ - with open(filename, "r", encoding="utf-8") as f: - lines = f.readlines() - - sections = {} - current_section = None - current_subsection = None - table_lines = [] - in_table = False - - for line in lines: - line = line.rstrip("\n") - - # Skip separator lines - if line.startswith( - "--------------------------------------------------------------------------------" - ): - continue - - # Check for section header (e.g., "0. Top Stats") - section_match = re.match(r"^\s*(\d+\. .+)$", line) - if section_match: - current_section = section_match.group(1).strip() - sections[current_section] = {} - continue - - # Check for subsection header (e.g., "0.1 Top Kernels") - # FIXME: 1. System Info is an exception, no subsection - subsection_match = re.match(r"^\s*(\d+\.\d+ .+)$", line) - if subsection_match: - current_subsection = subsection_match.group(1).strip() - if current_section is None: - current_section = "Uncategorized" - sections[current_section] = {} - continue - - # Table parsing logic - if line.startswith("β•’"): - in_table = True - table_lines = [line] - continue - - if in_table: - table_lines.append(line) - if line.startswith("β•˜"): - if current_section and current_subsection: - header, data = parse_ascii_table(table_lines) - sections[current_section][current_subsection] = { - "header": header, - "data": data, - } - in_table = False - table_lines = [] - - return sections - - -def get_table_dfs(): - filename = str(Path(os.getcwd()).joinpath("analyze_output.csv")) - sections_info = parse_file(filename) - - # Convert to DataFrames while maintaining nested structure - section_dfs = {} - for section_name, subsections in sections_info.items(): - section_dfs[section_name] = {} - for subsection_name, table_data in subsections.items(): - if table_data and table_data["data"]: - try: - df = pd.DataFrame(table_data["data"], columns=table_data["header"]) - section_dfs[section_name][subsection_name] = df - except Exception as e: - print(f"Error creating DataFrame for {subsection_name}: {e}") - continue - - return section_dfs - - -def process_panels_to_dataframes( - args, runs, archConfigs, profiling_config, roof_plot=None -): +def process_panels_to_dataframes(args, kernel_df, archConfigs, roof_plot=None): """ Process panel data into pandas DataFrames. Returns a nested dictionary structure with DataFrames and tui_style information. @@ -305,318 +116,87 @@ def process_panels_to_dataframes( } """ - comparable_columns = build_comparable_columns(args.time_unit) - filter_panel_ids = profiling_config.get("filter_blocks", []) - if isinstance(filter_panel_ids, dict): - # For backward compatibility - filter_panel_ids = [ - name for name, type in filter_panel_ids.items() if type == "metric_id" - ] - filter_panel_ids = [ - int(convert_metric_id_to_panel_info(metric_id)[0]) - for metric_id in filter_panel_ids - ] + # TODO: add individual kernel roofline logic + # TODO: implement args logic: + # args.filter_metrics + # args.cols + # args.max_stat_num + # args.df_file_dir - # Initialize the result structure result_structure = defaultdict(dict) + decimal_precision = getattr(args, "decimal", 2) if args else 2 + for panel_id, panel in archConfigs.panel_configs.items(): - # Skip panels that don't support baseline comparison - if panel_id in HIDDEN_SECTIONS: + if panel_id in config.HIDDEN_SECTIONS: continue - # Get section name (e.g., "0. Top Stats") section_name = f"{panel_id // 100}. {panel['title']}" for data_source in panel["data source"]: for type, table_config in data_source.items(): - # Check for filtering conditions - if ( - not args.filter_metrics - and filter_panel_ids - and table_config["id"] not in filter_panel_ids - and panel_id not in filter_panel_ids - and panel_id > 100 - ): - table_id_str = ( - str(table_config["id"] // 100) - + "." - + str(table_config["id"] % 100) - ) + table_id = table_config["id"] + + if table_id not in kernel_df: continue - # Process the data - base_run, base_data = next(iter(runs.items())) - base_df = base_data.dfs[table_config["id"]] + base_df = kernel_df[table_id] + + if base_df is None or base_df.empty: + continue df = pd.DataFrame(index=base_df.index) - # Process columns - for header in list(base_df.keys()): - if should_process_column(header, args, type): - if header in HIDDEN_COLUMNS: - pass - elif header not in comparable_columns: - df = process_non_comparable_column( - df, header, base_df, type, table_config, runs - ) - else: - df = process_comparable_column( - df, - header, - base_df, - table_config, - runs, - base_run, - type, - args, - HIDDEN_COLUMNS, - ) + for header in list(base_df.columns): + if header in config.HIDDEN_COLUMNS_TUI: + continue + else: + df[header] = base_df[header] - if not df.empty: - # Check for empty columns - is_empty_columns_exist = check_empty_columns(df) + df = apply_rounding_logic(df, decimal_precision) - if not is_empty_columns_exist: - # Get subsection name - table_id_str = ( - str(table_config["id"] // 100) - + "." - + str(table_config["id"] % 100) - ) - subsection_name = table_id_str - if "title" in table_config and table_config["title"]: - subsection_name += " " + table_config["title"] + subsection_name = ( + str(table_config["id"] // 100) + "." + str(table_config["id"] % 100) + ) + if "title" in table_config and table_config["title"]: + subsection_name += " " + table_config["title"] - # Handle special cases for top stats - if type == "raw_csv_table" and ( - table_config["source"] == "pmc_kernel_top.csv" - or table_config["source"] == "pmc_dispatch_info.csv" - ): - df = df.head(args.max_stat_num) + result_structure[section_name][subsection_name] = { + "df": df, + "tui_style": None, + } - # Check for transpose requirement - transpose = ( - type != "raw_csv_table" - and "columnwise" in table_config - and table_config.get("columnwise") == True - ) + if type == "metric_table" and "tui_style" in table_config: + result_structure[section_name][subsection_name]["tui_style"] = ( + table_config["tui_style"] + ) - if transpose: - df = df.T - - # Store the DataFrame with tui_style as separate keys - result_structure[section_name][subsection_name] = { - "df": df, - "tui_style": None, - } - - # Set tui_style if available - if type == "metric_table" and "tui_style" in table_config: - result_structure[section_name][subsection_name][ - "tui_style" - ] = table_config["tui_style"] - - # Save to CSV if requested - if args.df_file_dir: - save_dataframe_to_csv(df, table_id_str, table_config, args) - result_structure["4. Roofline"] = roof_plot return dict(result_structure) -def should_process_column(header, args, type): - """Check if a column should be processed based on arguments.""" - return ( - (not args.cols) - or ( - args.cols and header in args.cols - ) # Assuming args.cols is now a list of column names - or (type == "raw_csv_table") - ) +def apply_rounding_logic(df, decimal_precision): + df_copy = df.copy() + for column in df_copy.columns: + if column in ["Metric", "Tips", "coll_level", "Unit", "Kernel_Name", "Info"]: + continue -def process_non_comparable_column(df, header, base_df, type, table_config, runs): - """Process columns that are not comparable across runs.""" - if ( - type == "raw_csv_table" - and ( - table_config["source"] == "pmc_kernel_top.csv" - or table_config["source"] == "pmc_dispatch_info.csv" - ) - and header == "Kernel_Name" - ): - # Adjust kernel name width based on source - if table_config["source"] == "pmc_kernel_top.csv": - adjusted_name = base_df["Kernel_Name"].apply( - lambda x: string_multiple_lines(x, 40, 3) - ) + if df_copy[column].dtype in ["float64", "float32", "int64", "int32"]: + df_copy[column] = df_copy[column].round(decimal_precision) else: - adjusted_name = base_df["Kernel_Name"].apply( - lambda x: string_multiple_lines(x, 80, 4) - ) - df = pd.concat([df, adjusted_name], axis=1) - elif type == "raw_csv_table" and header == "Info": - for run, data in runs.items(): - cur_df = data.dfs[table_config["id"]] - df = pd.concat([df, cur_df[header]], axis=1) - else: - df = pd.concat([df, base_df[header]], axis=1) + try: + numeric_series = pd.to_numeric(df_copy[column], errors="coerce") + if not numeric_series.isna().all(): + rounded_series = numeric_series.round(decimal_precision) - return df + if df_copy[column].dtype == "object": + df_copy[column] = df_copy[column].combine( + rounded_series, + lambda orig, rounded: rounded if pd.notna(rounded) else orig, + ) + else: + df_copy[column] = rounded_series + except (ValueError, TypeError): + continue - -def process_comparable_column( - df, header, base_df, table_config, runs, base_run, type, args, hidden_columns -): - """Process columns that can be compared across runs.""" - for run, data in runs.items(): - cur_df = data.dfs[table_config["id"]] - if (type == "raw_csv_table") or ( - type == "metric_table" and (header not in hidden_columns) - ): - if run != base_run: - # Calculate percentage over the baseline - base_values = [float(x) if x != "" else float(0) for x in base_df[header]] - cur_values = [float(x) if x != "" else float(0) for x in cur_df[header]] - - base_df[header] = base_values - cur_df[header] = cur_values - - t_df = pd.concat( - [base_df[header], cur_df[header]], - axis=1, - ) - absolute_diff = (t_df.iloc[:, 1] - t_df.iloc[:, 0]).round(args.decimal) - t_df = absolute_diff / t_df.iloc[:, 0].replace(0, 1) - - t_df_pretty = t_df.astype(float).mul(100).round(args.decimal) - - # Show value + percentage - t_df = ( - cur_df[header].astype(float).round(args.decimal).map(str).astype(str) - + " (" - + t_df_pretty.map(str) - + "%)" - ) - df = pd.concat([df, t_df], axis=1) - - # Check for threshold violations - if ( - header in ["Value", "Count", "Avg"] - and t_df_pretty.abs().gt(args.report_diff).any() - ): - df["Abs Diff"] = absolute_diff - if args.report_diff: - violation_idx = t_df_pretty.index[ - t_df_pretty.abs() > args.report_diff - ] - else: - cur_df_copy = copy.deepcopy(cur_df) - cur_df_copy[header] = [ - (round(float(x), args.decimal) if x != "" else x) - for x in base_df[header] - ] - df = pd.concat([df, cur_df_copy[header]], axis=1) - - return df - - -def check_empty_columns(df): - """Check if any column in the DataFrame is empty.""" - return any( - [ - df.columns[col_idx] - for col_idx in range(len(df.columns)) - if df.replace("", None).iloc[:, col_idx].isnull().all() - ] - ) - - -def save_dataframe_to_csv(df, table_id_str, table_config, args): - """Save DataFrame to CSV file if directory is specified.""" - p = Path(args.df_file_dir) - if not p.exists(): - p.mkdir() - if p.is_dir(): - filename = table_id_str - if "title" in table_config and table_config["title"]: - filename += "_" + table_config["title"] - df.to_csv( - p.joinpath(filename.replace(" ", "_") + ".csv"), - index=False, - ) - - -def string_multiple_lines(source, width, max_rows): - """ - Adjust string with multiple lines by inserting '\n' - """ - idx = 0 - lines = [] - while idx < len(source) and len(lines) < max_rows: - lines.append(source[idx : idx + width]) - idx += width - - if idx < len(source): - last = lines[-1] - lines[-1] = last[0:-3] + "..." - return "\n".join(lines) - - -def convert_metric_id_to_panel_info(metric_id): - """ - Convert metric id into panel information. - Output is a tuples of the form (file_id, panel_id, metric_id). - - For example: - - Input: "2" - Output: ("0200", None, None) - - Input: "11" - Output: ("1100", None, None) - - Input: "11.1" - Output: ("1100", 1101, None) - - Input: "11.1.1" - Output: ("1100", 1101, 1) - - Raises exception for invalid metric id. - """ - tokens = metric_id.split(".") - if 0 < len(tokens) < 4: - # File id - file_id = str(int(tokens[0])) - # 4 -> 04 - if len(file_id) < 2: - file_id = f"0{file_id}" - # Multiply integer by 100 - file_id = f"{file_id}00" - # Panel id - if len(tokens) > 1: - panel_id = int(tokens[0]) * 100 - panel_id += int(tokens[1]) - else: - panel_id = None - # Metric id - if len(tokens) > 2: - metric_id = int(tokens[2]) - else: - metric_id = None - return (file_id, panel_id, metric_id) - else: - raise Exception(f"Invalid metric id: {metric_id}") - - -def build_comparable_columns(time_unit): - """ - Build comparable columns/headers for display - """ - comparable_columns = supported_field - top_stat_base = ["Count", "Sum", "Mean", "Median", "Standard Deviation"] - - for h in top_stat_base: - comparable_columns.append(h + "(" + time_unit + ")") - - return comparable_columns + return df_copy diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/views/kernel_view.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/views/kernel_view.py new file mode 100644 index 0000000000..61e3957b30 --- /dev/null +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/views/kernel_view.py @@ -0,0 +1,203 @@ +""" +Panel Widget Modules +------------------- +Contains the panel widgets used in the main layout. +""" + +from typing import Optional + +from textual import on +from textual.containers import Container, VerticalScroll +from textual.widgets import Label, RadioButton, RadioSet + +from config import rocprof_compute_home +from rocprof_compute_tui.widgets.collapsibles import build_all_sections + + +class KernelView(Container): + """Center panel with analysis results split into two scrollable sections.""" + + DEFAULT_CSS = """ + KernelView { + layout: vertical; + } + + #top-container { + height: 1fr; + border: none; + margin-top: 1; + } + + #bottom-container { + height: 4fr; + border: none; + margin-top: 2; + } + + .kernel-table-header { + background: $primary; + color: $text; + text-style: bold; + padding: 0 1; + offset: 5 0; + margin-top: 1; + } + + .kernel-row { + padding: 0 1; + border-bottom: solid $border; + } + + RadioSet { + border: solid $border; + } + """ + + def __init__(self, config_path: Optional[str] = None): + super().__init__(id="kernel-view") + self.status_label = None + self.dfs = {} + self.top_kernel = [] + + if rocprof_compute_home: + config_path = ( + rocprof_compute_home + / "rocprof_compute_tui" + / "utils" + / "kernel_view_config.yaml" + ) + self.config_path = config_path + + self.keys = None + self.current_selection = None + + def compose(self): + """ + Compose the split panel layout with two scrollable containers. + """ + with VerticalScroll(id="top-container"): + yield Label( + "Open a workload directory to run analysis and view individual kernel analysis results.", + classes="placeholder", + ) + + with VerticalScroll(id="bottom-container"): + # empty on init + pass + + def update_results(self, per_kernel_dfs, top_kernels) -> None: + self.dfs = per_kernel_dfs + self.top_kernel = top_kernels + + top_container = self.query_one("#top-container", VerticalScroll) + top_container.remove_children() + + if self.top_kernel: + try: + header = self.build_header() + top_container.mount(header) + selector = self.build_selector() + top_container.mount(selector) + except Exception as e: + top_container.mount( + Label(f"Error displaying kernel list: {str(e)}", classes="error") + ) + else: + top_container.mount(Label("No kernels available", classes="placeholder")) + + self.current_selection = self.top_kernel[0]["Kernel_Name"] + self._update_bottom_content() + + def update_view(self, message: str, log_level: str) -> None: + """ + Update the view with a status message. + """ + if self.status_label is None: + self.status_label = Label(f"{message}", classes=log_level) + self.mount(self.status_label) + else: + self.status_label.update(f"{message}") + self.status_label.set_classes(log_level) + + def reload_config(self, config_path: str = None) -> None: + if config_path: + self.config_path = config_path + + if self.dfs and self.top_kernel: + self.update_results() + + def build_header(self): + all_keys = set() + + for kernel in self.top_kernel: + all_keys.update(kernel.keys()) + + self.keys = sorted(all_keys) + + if "Kernel_Name" in self.keys: + self.keys.remove("Kernel_Name") + self.keys.insert(0, "Kernel_Name") + + header_text = " | ".join(f"{key:25}" for key in self.keys) + header_label = Label(header_text, classes="kernel-table-header") + + return header_label + + def build_selector(self): + radio_buttons = [] + + for i, kernel in enumerate(self.top_kernel): + row_data = [] + for key in self.keys: + value = str(kernel.get(key, "N/A")) + if len(value) > 18: + value = value[:15] + "..." + row_data.append(f"{value:25}") + + row_text = " | ".join(row_data) + radio_button = RadioButton(row_text, id=f"kernel-{i}") + radio_button.kernel_data = kernel + radio_buttons.append(radio_button) + + selector = RadioSet(*radio_buttons) + + return selector + + @on(RadioSet.Changed) + def on_radio_changed(self, event: RadioSet.Changed) -> None: + if event.pressed: + kernel_data = getattr(event.pressed, "kernel_data", None) + if kernel_data and "Kernel_Name" in kernel_data: + selected_kernel = kernel_data["Kernel_Name"] + self.current_selection = selected_kernel + self._update_bottom_content() + + def _update_bottom_content(self): + bottom_container = self.query_one("#bottom-container", VerticalScroll) + bottom_container.remove_children() + + bottom_container.mount( + Label(f"Toggle kernel selection to view detailed analysis.") + ) + + if self.current_selection and self.current_selection in self.dfs: + bottom_container.mount( + Label(f"Current kernel selection: {self.current_selection}") + ) + filtered_dfs = self.dfs[self.current_selection] + + try: + sections = build_all_sections(filtered_dfs, self.config_path) + for section in sections: + bottom_container.mount(section) + except Exception as e: + bottom_container.mount( + Label(f"Error displaying results: {str(e)}", classes="error") + ) + else: + bottom_container.mount( + Label( + f"No data available for kernel: {self.current_selection}", + classes="error", + ) + ) diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/views/main_view.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/views/main_view.py index ba8ce495a7..aa33167e46 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/views/main_view.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/views/main_view.py @@ -50,10 +50,10 @@ class MainView(Horizontal): """Main view layout for the application.""" selected_path = reactive(None) - dfs = reactive({}) + per_kernel_dfs = reactive({}) + top_kernels = reactive([]) def __init__(self): - """Initialize the main view.""" super().__init__(id="main-container") self.start_path = ( # NOTE: is cwd the best choice? @@ -70,7 +70,6 @@ class MainView(Horizontal): pass def compose(self) -> ComposeResult: - """Compose the main view layout.""" self.logger.info("Composing main view layout", update_ui=False) yield MenuBar() @@ -80,7 +79,6 @@ class MainView(Horizontal): # Center Panel - Analysis results display center_panel = CenterPanel() yield center_panel - self.center = center_panel # Bottom Panel - Output, terminal, and metric description @@ -91,7 +89,6 @@ class MainView(Horizontal): self.metric_description = tabs.description_area self.output = tabs.output_area - # Now set the output area for the logger self.logger.set_output_area(self.output) self.logger.info("Main view layout composed") @@ -107,8 +104,9 @@ class MainView(Horizontal): try: row_data = table.get_row_at(row_idx) - content = f"Selected Row {row_idx}:\n" - content += "\n".join(f"{val}" for val in row_data) + content = f"Selected Metric ID: {row_data[0]}\n" + content += f"Selected Metric: {row_data[1]}\n" + # content += f"Metric Description:\n\t{row_data[-1]}" self.metric_description.text = content self.logger.info(f"Row {row_idx} data displayed in metric_description") @@ -122,7 +120,8 @@ class MainView(Horizontal): @work(thread=True) def run_analysis(self) -> None: - self.dfs = {} + self.per_kernel_dfs = {} + self.top_kernels = [] if not self.selected_path: error_msg = "No directory selected for analysis" @@ -173,7 +172,6 @@ class MainView(Horizontal): self.logger.info( f"Step 3: sys_info_df shape = {sys_info_df.shape if hasattr(sys_info_df, 'shape') else 'No shape attribute'}" ) - self.logger.info(f"Step 3: sys_info_df = {sys_info_df}") except Exception as e: self.logger.error(f"Step 3 failed - Error loading sys_info: {str(e)}") @@ -196,7 +194,6 @@ class MainView(Horizontal): raise TypeError(f"Unexpected type for sys_info: {type(sys_info_df)}") self.logger.info(f"Step 4: sys_info converted = {sys_info}") - self.logger.info(f"Step 4: sys_info type = {type(sys_info)}") except Exception as e: self.logger.error(f"Step 4 failed - Error converting sys_info: {str(e)}") @@ -231,18 +228,19 @@ class MainView(Horizontal): # Step 8: Run analysis try: self.logger.info("Step 8: Running analysis") - self.dfs = analyzer.run_analysis() - if not self.dfs: - warning_msg = "Step 8: Analysis completed but no data was returned" + self.per_kernel_dfs = analyzer.run_kernel_analysis() + self.top_kernels = analyzer.run_top_kernel() + + # TODO: add per kernel Roofline support when available + + if not self.per_kernel_dfs or not self.top_kernels: + warning_msg = "Step 8: Per Kernel Analysis completed but not all data was returned" self._update_view(warning_msg, LogLevel.WARNING) self.logger.warning(warning_msg) else: self.app.call_from_thread(self.refresh_results) - self.logger.info("Step 8: Analysis completed successfully") - if self.dfs.get("4. Roofline"): - self.logger.info("Step 8: Roofline data available") - else: - self.logger.info("Step 8: Roofline data not available") + self.logger.info("Step 8: Kernel Analysis completed successfully") + # self.logger.info(f"{self.per_kernel_dfs}") except Exception as e: self.logger.error(f"Step 8 failed - Error running analysis: {str(e)}") raise @@ -257,17 +255,15 @@ class MainView(Horizontal): def _update_view(self, message: str, log_level: LogLevel) -> None: try: - # Use call_from_thread to safely update UI from background thread self.app.call_from_thread(self._safe_update_view, message, log_level) except Exception as e: - # Capture errors that might occur when scheduling the UI update self.logger.error(f"View update scheduling error: {str(e)}") def _safe_update_view(self, message: str, log_level: LogLevel) -> None: try: - analyze_view = self.query_one("#analyze-view") - if analyze_view: - analyze_view.update_view(message, log_level) + kernel_view = self.query_one("#kernel-view") + if kernel_view: + kernel_view.update_view(message, log_level) else: self.logger.warning("Analysis view not found when updating log") except Exception as e: @@ -275,24 +271,29 @@ class MainView(Horizontal): def refresh_results(self) -> None: try: - self.logger.info("Refreshing analysis results") - analyze_view = self.query_one("#analyze-view") - if not analyze_view: - self.logger.error("Analysis view not found") + self.logger.info("Refreshing kernel results") + kernel_view = self.query_one("#kernel-view") + if not kernel_view: + self.logger.error("Kernel view not found") return - if not hasattr(self, "dfs") or self.dfs is None: - self.logger.error("No analysis data available to display") + if ( + not hasattr(self, "per_kernel_dfs") + or self.per_kernel_dfs is None + or not hasattr(self, "top_kernels") + or self.top_kernels is None + ): + self.logger.error("No kernel analysis data available to display") return - analyze_view.update_results(self.dfs) + kernel_view.update_results(self.per_kernel_dfs, self.top_kernels) self.logger.success(f"Results displayed successfully.") except Exception as e: self.logger.error(f"Error refreshing results: {str(e)}") def refresh_view(self) -> None: self.logger.info("Refreshing view...") - if self.dfs: + if self.top_kernels: self.refresh_results() else: self.logger.warning("No data available for refresh") diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/center_panel/analyze_view.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/center_panel/analyze_view.py deleted file mode 100644 index c314c73c60..0000000000 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/center_panel/analyze_view.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -Panel Widget Modules -------------------- -Contains the panel widgets used in the main layout. -""" - -from importlib import resources -from typing import Any, Dict, Optional - -from textual.containers import ScrollableContainer -from textual.widgets import Label - -from rocprof_compute_tui.widgets.collapsibles import build_all_sections - - -class AnalyzeView(ScrollableContainer): - """Center panel with analysis results.""" - - def __init__(self, config_path: Optional[str] = None): - super().__init__(id="analyze-view") - self.dfs = {} - - if config_path is None: - config_path = ( - resources.files("rocprof_compute_tui.utils") / "analyze_config.yaml" - ) - - self.config_path = str(config_path) - - def compose(self): - """ - Compose the initial center panel state. - """ - yield Label( - "Open a workload directory to run analysis and view results", - classes="placeholder", - ) - - def update_results(self, dfs: Dict[str, Any]) -> None: - """ - Update the center panel with analysis results. - """ - self.dfs = dfs - self.remove_children() - - try: - sections = build_all_sections(self.dfs, self.config_path) - - # Mount all sections - for section in sections: - self.mount(section) - - except Exception as e: - self.mount(Label(f"Error displaying results: {str(e)}", classes="error")) - - def update_view(self, message: str, log_level: str) -> None: - """ - Update the view with a status message. - """ - self.remove_children() - try: - self.mount(Label(f"{message}", classes=log_level)) - except Exception as e: - self.mount(Label(f"Error displaying results: {str(e)}", classes="error")) - - def reload_config(self, config_path: str = None) -> None: - """ - Reload the configuration and update the view. - """ - if config_path: - self.config_path = config_path - - if self.dfs: - self.update_results(self.dfs) diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/center_panel/center_area.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/center_panel/center_area.py index b7d588f0ed..59d075ebe3 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/center_panel/center_area.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/center_panel/center_area.py @@ -29,9 +29,9 @@ Contains the panel widgets used in the main layout. """ from textual.containers import Vertical -from textual.widgets import Label, TabPane +from textual.widgets import TabPane -from rocprof_compute_tui.widgets.center_panel.analyze_view import AnalyzeView +from rocprof_compute_tui.views.kernel_view import KernelView from rocprof_compute_tui.widgets.tabbed_content import TabsTabbedContent @@ -48,15 +48,12 @@ class CenterPanel(Vertical): super().__init__() self.default_tab = "center-analyze" - self.analyze_view = AnalyzeView() + self.kernel_view = KernelView() def compose(self): - with TabsTabbedContent(initial="tab-analyze"): - with TabPane("Basic View", id="tab-analyze"): - yield self.analyze_view - # TODO: - # with TabPane("placeholder (🚧)", id="tab-1"): - # yield Label("🚧 Under Construction") + with TabsTabbedContent(initial="tab-kernel"): + with TabPane("Basic View", id="tab-kernel"): + yield self.kernel_view def on_mount(self) -> None: self.add_class("section") diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/charts.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/charts.py index 3cb710f2e2..b80352abbf 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/charts.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/charts.py @@ -68,7 +68,10 @@ def simple_bar(df, title=None): w *= 100 plt.simple_bar(list(metric_dict.keys()), list(metric_dict.values()), width=w) # plt.show() - return "\n" + plt.build() + "\n" + plot_content = plt.build() + if not plot_content or plot_content.strip() == "": + return None + return "\n" + plot_content + "\n" def simple_multiple_bar(df, title=None): @@ -100,10 +103,13 @@ def simple_multiple_bar(df, title=None): h *= 300 plt.plot_size(height=h) - plt.multiple_bar(labels, data, color=["blue", "blue+", 68, 63]) + plt.multiple_bar(labels, data) # plt.show() - return "\n" + plt.build() + "\n" + plot_content = plt.build() + if not plot_content or plot_content.strip() == "": + return None + return "\n" + plot_content + "\n" def simple_box(df, orientation="v", title=None): @@ -173,7 +179,10 @@ def simple_box(df, orientation="v", title=None): plt.theme("pro") # plt.show() - return "\n" + plt.build() + "\n" + plot_content = plt.build() + if not plot_content or plot_content.strip() == "": + return None + return "\n" + plot_content + "\n" def px_simple_bar(df, title: str = None, id=None, style: dict = None, orientation="h"): @@ -284,18 +293,8 @@ class RooflinePlot(Static): super().__init__("", classes="roofline", **kwargs) self.df = df - # Disable markup rendering - self._render_markup = False - try: - plot_str = "" - try: - result = self.df["4. Roofline"] - if result: - plot_str = str(result) - except: - plot_str = "No roofline data generated" - + plot_str = str(self.df.get("4. Roofline", "No roofline data generated")) self.update(plot_str) except Exception as e: error_message = f"Roofline plot error: {str(e)}\n{traceback.format_exc()}" @@ -319,41 +318,37 @@ class MemoryChart(Static): """ def __init__(self, df: pd.DataFrame, **kwargs): - """Initialize the memory chart.""" super().__init__("", classes="mem-chart", **kwargs) self.df = df - # Generate the chart content on initialization try: - # Prepare data - metric_dict = ( - self.df[["Metric", "Value"]].set_index("Metric").to_dict()["Value"] - ) + if self.df is None or self.df.empty: + self.update("No chart data generated") + return + + if not {"Metric", "Value"}.issubset(self.df.columns): + self.update("Error: Missing required columns") + return + + metric_dict = dict(zip(self.df["Metric"], self.df["Value"])) - # Capture stdout original_stdout = sys.stdout - string_buffer = StringIO() - sys.stdout = string_buffer - try: - # Generate the chart - result = plot_mem_chart("", "per_kernel", metric_dict) - stdout_output = string_buffer.getvalue() - - if stdout_output: - plot_str = stdout_output - elif result: - plot_str = str(result) - else: - plot_str = "No chart data generated" + with StringIO() as string_buffer: + sys.stdout = string_buffer + result = plot_mem_chart("", "per_kernel", metric_dict) + stdout_output = string_buffer.getvalue() finally: sys.stdout = original_stdout + plot_str = next( + (x for x in [stdout_output, str(result) if result else None] if x), + "No chart data generated", + ) self.update(plot_str) except Exception as e: - error_message = f"Memory chart error: {str(e)}\n{traceback.format_exc()}" - self.update(f"Error: {str(error_message)}") + self.update(f"Memory chart error: {str(e)}") class SimpleBar(Static): @@ -372,7 +367,6 @@ class SimpleBar(Static): """ def __init__(self, df: pd.DataFrame, **kwargs): - """Initialize the simple bar.""" super().__init__("", classes="simple-bar", **kwargs) self.df = df @@ -381,13 +375,8 @@ class SimpleBar(Static): if result: plot_str = str(result) - # Escape markup characters escaped_content = plot_str.replace("[", r"\[").replace("]", r"\]") self.update(escaped_content) - - # Alternative - wrap in [pre] tags for preformatted text - # self.update(f"[pre]{plot_str}[/pre]") - else: self.update("No simple bar data generated") @@ -398,7 +387,6 @@ class SimpleBar(Static): class SimpleBox(Static): - """Simple Box visualization widget.""" DEFAULT_CSS = """ SimpleBox { @@ -413,7 +401,6 @@ class SimpleBox(Static): """ def __init__(self, df: pd.DataFrame, **kwargs): - """Initialize the simple box.""" super().__init__("", classes="simple-box", **kwargs) self.df = df @@ -422,7 +409,6 @@ class SimpleBox(Static): if result: plot_str = str(result) - # Escape markup characters escaped_content = plot_str.replace("[", r"\[").replace("]", r"\]") self.update(escaped_content) else: @@ -450,7 +436,6 @@ class SimpleMultiBar(Static): """ def __init__(self, df: pd.DataFrame, **kwargs): - """Initialize the simple multiple bar.""" super().__init__("", classes="simple-multi-bar", **kwargs) self.df = df @@ -459,7 +444,6 @@ class SimpleMultiBar(Static): if result: plot_str = str(result) - # Escape markup characters escaped_content = plot_str.replace("[", r"\[").replace("]", r"\]") self.update(escaped_content) else: diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/collapsibles.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/collapsibles.py index 1970354391..3ffb316273 100644 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/collapsibles.py +++ b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/collapsibles.py @@ -26,7 +26,6 @@ from typing import Any, Dict, List, Optional import pandas as pd import yaml -from textual.containers import VerticalScroll from textual.widgets import Collapsible, DataTable, Label from rocprof_compute_tui.widgets.charts import ( @@ -41,12 +40,9 @@ from rocprof_compute_tui.widgets.charts import ( def create_table(df: pd.DataFrame) -> DataTable: table = DataTable(zebra_stripes=True) - # Clean the DataFrame - remove NaN and empty cells df = df.reset_index() - df = df.dropna(how="any") df = df[~df.apply(lambda row: row.astype(str).str.strip().eq("").any(), axis=1)] - # Add columns and rows str_columns = [str(col) for col in df.columns] table.add_columns(*str_columns) table.add_rows([tuple(str(x) for x in row) for row in df.itertuples(index=False)]) @@ -59,7 +55,9 @@ def load_config(config_path) -> Dict[str, Any]: with open(config_path, "r") as file: return yaml.safe_load(file) except FileNotFoundError: - raise FileNotFoundError(f"Configuration file {config_path} not found") + raise FileNotFoundError( + f"Configuration file {config_path} not found, \nplease populate the analysis_config.yaml file." + ) except yaml.YAMLError as e: raise ValueError(f"Error parsing YAML configuration: {e}") @@ -167,7 +165,7 @@ def build_subsection( return collapsible -def build_dynamic_kernel_sections( +def build_kernel_sections( dfs: Dict[str, Any], skip_sections: List[str] ) -> List[Collapsible]: children = [] @@ -198,9 +196,10 @@ def build_dynamic_kernel_sections( return None try: - df = data["df"] + if data["df"] is None or data["df"].empty: + return None tui_style = data.get("tui_style") - widget = create_widget_from_data(df, tui_style) + widget = create_widget_from_data(data["df"], tui_style) if widget is None: add_warning(f"Widget creation returned None for '{subsection_name}'") @@ -277,7 +276,7 @@ def build_section_from_config( # Handle dynamic sections (like kernel sections) elif section_config.get("dynamic_sections", False): skip_sections = section_config.get("skip_sections", []) - children = build_dynamic_kernel_sections(dfs, skip_sections) + children = build_kernel_sections(dfs, skip_sections) # Handle regular sections with subsections elif "subsections" in section_config: @@ -290,7 +289,6 @@ def build_section_from_config( except Exception as e: error_msg = f"{subsection_config.get('title', 'Unknown')} error: {str(e)}" children.append(Label(error_msg, classes="warning")) - else: children = [Label("No configuration provided for this section")] diff --git a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/directory_tree.py b/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/directory_tree.py deleted file mode 100644 index e7dc44bc6a..0000000000 --- a/projects/rocprofiler-compute/src/rocprof_compute_tui/widgets/directory_tree.py +++ /dev/null @@ -1,39 +0,0 @@ -##############################################################################bl -# MIT License -# -# Copyright (c) 2025 Advanced Micro Devices, Inc. All Rights Reserved. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -##############################################################################el - -""" -Specialized Widget Modules -------------------------- -Contains custom widget implementations for the application. -""" - -from textual.widgets import DirectoryTree - - -class FolderOnlyDirectory(DirectoryTree): - """Directory tree that only shows folders.""" - - def filter_paths(self, paths): - """Filter to only show directories.""" - return [path for path in paths if path.is_dir()] diff --git a/projects/rocprofiler-compute/src/utils/mem_chart.py b/projects/rocprofiler-compute/src/utils/mem_chart.py index 6d3211d0b5..8679210759 100644 --- a/projects/rocprofiler-compute/src/utils/mem_chart.py +++ b/projects/rocprofiler-compute/src/utils/mem_chart.py @@ -106,8 +106,8 @@ def format_text( ) key_str = ( "{key:{key_format}}".format(key=key, key_format=key_format) - if key is not None - else None + if key and isinstance(key, (int, float)) + else str(key) if key else None ) unit_string = post_description_with_space if not "N/A" in value_str else "" @@ -1013,8 +1013,8 @@ class MemChart: block_instr_buff.y_max = self.y_max - 5.0 block_instr_buff.y_min = block_instr_buff.y_max - 24.0 - block_instr_buff.wave_occupancy = metric_dict["Wavefront Occupancy"] - block_instr_buff.wave_life = metric_dict["Wave Life"] + block_instr_buff.wave_occupancy = metric_dict.get("Wavefront Occupancy", "n/a") + block_instr_buff.wave_life = metric_dict.get("Wave Life", "n/a") block_instr_buff.draw(canvas) @@ -1037,14 +1037,14 @@ class MemChart: block_instr_disp.y_max = block_instr_buff.y_max block_instr_disp.y_min = block_instr_buff.y_min - block_instr_disp.instrs["SALU"] = metric_dict["SALU"] - block_instr_disp.instrs["SMEM"] = metric_dict["SMEM"] - block_instr_disp.instrs["VALU"] = metric_dict["VALU"] - block_instr_disp.instrs["MFMA"] = metric_dict["MFMA"] - block_instr_disp.instrs["VMEM"] = metric_dict["VMEM"] - block_instr_disp.instrs["LDS"] = metric_dict["LDS"] - block_instr_disp.instrs["GWS"] = metric_dict["GWS"] - block_instr_disp.instrs["BRANCH"] = metric_dict["BR"] + block_instr_disp.instrs["SALU"] = metric_dict.get("SALU", "n/a") + block_instr_disp.instrs["SMEM"] = metric_dict.get("SMEM", "n/a") + block_instr_disp.instrs["VALU"] = metric_dict.get("VALU", "n/a") + block_instr_disp.instrs["MFMA"] = metric_dict.get("MFMA", "n/a") + block_instr_disp.instrs["VMEM"] = metric_dict.get("VMEM", "n/a") + block_instr_disp.instrs["LDS"] = metric_dict.get("LDS", "n/a") + block_instr_disp.instrs["GWS"] = metric_dict.get("GWS", "n/a") + block_instr_disp.instrs["BRANCH"] = metric_dict.get("BR", "n/a") block_instr_disp.draw(canvas) @@ -1056,14 +1056,14 @@ class MemChart: block_exec.y_min = block_instr_disp.y_min - 6 block_exec.y_max = block_instr_disp.y_max - block_exec.active_cus = metric_dict["Active CUs"] - block_exec.num_cus = metric_dict["Num CUs"] - block_exec.vgprs = metric_dict["VGPR"] - block_exec.sgprs = metric_dict["SGPR"] - block_exec.lds_alloc = metric_dict["LDS Allocation"] - block_exec.scratch_alloc = metric_dict["Scratch Allocation"] - block_exec.wavefronts = metric_dict["Wavefronts"] - block_exec.workgroups = metric_dict["Workgroups"] + block_exec.active_cus = metric_dict.get("Active CUs", "n/a") + block_exec.num_cus = metric_dict.get("Num CUs", "n/a") + block_exec.vgprs = metric_dict.get("VGPR", "n/a") + block_exec.sgprs = metric_dict.get("SGPR", "n/a") + block_exec.lds_alloc = metric_dict.get("LDS Allocation", "n/a") + block_exec.scratch_alloc = metric_dict.get("Scratch Allocation", "n/a") + block_exec.wavefronts = metric_dict.get("Wavefronts", "n/a") + block_exec.workgroups = metric_dict.get("Workgroups", "n/a") block_exec.draw(canvas) @@ -1075,11 +1075,11 @@ class MemChart: wires_E_GLV.y_min = block_instr_disp.y_min wires_E_GLV.y_max = block_instr_disp.y_max - wires_E_GLV.lds_req = metric_dict["LDS Req"] - wires_E_GLV.vl1_rd = metric_dict["VL1 Rd"] - wires_E_GLV.vl1_wr = metric_dict["VL1 Wr"] - wires_E_GLV.vl1_atomic = metric_dict["VL1 Atomic"] - wires_E_GLV.sl1_rd = metric_dict["sL1D Rd"] + wires_E_GLV.lds_req = metric_dict.get("LDS Req", "n/a") + wires_E_GLV.vl1_rd = metric_dict.get("VL1 Rd", "n/a") + wires_E_GLV.vl1_wr = metric_dict.get("VL1 Wr", "n/a") + wires_E_GLV.vl1_atomic = metric_dict.get("VL1 Atomic", "n/a") + wires_E_GLV.sl1_rd = metric_dict.get("VL1D Rd", "n/a") wires_E_GLV.draw(canvas) @@ -1093,7 +1093,7 @@ class MemChart: y_max=block_instr_buff.y_min, ) - wire_InstrBuff_IL1Cache.il1_fetch = metric_dict["IL1 Fetch"] + wire_InstrBuff_IL1Cache.il1_fetch = metric_dict.get("IL1 Fetch", "n/a") wire_InstrBuff_IL1Cache.draw(canvas) @@ -1118,8 +1118,8 @@ class MemChart: block_lds.y_max = wires_E_GLV.y_max block_lds.y_min = block_lds.y_max - 5 - block_lds.util = metric_dict["LDS Util"] - block_lds.latency = metric_dict["LDS Latency"] + block_lds.util = metric_dict.get("LDS Util", "n/a") + block_lds.latency = metric_dict.get("LDS Latency", "n/a") block_lds.draw(canvas) @@ -1131,10 +1131,10 @@ class MemChart: block_vector_L1.y_max = block_lds.y_min - 3 block_vector_L1.y_min = block_vector_L1.y_max - 9 - block_vector_L1.hit = metric_dict["VL1 Hit"] - block_vector_L1.latency = metric_dict["VL1 Lat"] - block_vector_L1.coales = metric_dict["VL1 Coalesce"] - block_vector_L1.stall = metric_dict["VL1 Stall"] + block_vector_L1.hit = metric_dict.get("VL1 Hit", "n/a") + block_vector_L1.latency = metric_dict.get("VL1 Lat", "n/a") + block_vector_L1.coales = metric_dict.get("VL1 Coalesce", "n/a") + block_vector_L1.stall = metric_dict.get("VL1 Stall", "n/a") block_vector_L1.draw(canvas) @@ -1146,8 +1146,8 @@ class MemChart: block_const_L1.y_max = block_vector_L1.y_min - 3 block_const_L1.y_min = block_const_L1.y_max - 5 - block_const_L1.hit = metric_dict["sL1D Hit"] - block_const_L1.latency = metric_dict["sL1D Lat"] + block_const_L1.hit = metric_dict.get("sL1D Hit", "n/a") + block_const_L1.latency = metric_dict.get("sL1D Lat", "n/a") block_const_L1.draw(canvas) @@ -1159,8 +1159,8 @@ class MemChart: block_instr_L1.y_max = block_const_L1.y_min - 3 block_instr_L1.y_min = block_instr_L1.y_max - 5 - block_instr_L1.hit = metric_dict["IL1 Hit"] - block_instr_L1.latency = metric_dict["IL1 Lat"] + block_instr_L1.hit = metric_dict.get("IL1 Hit", "n/a") + block_instr_L1.latency = metric_dict.get("IL1 Lat", "n/a") block_instr_L1.draw(canvas) @@ -1171,13 +1171,13 @@ class MemChart: wires_L1_L2.x_max = wires_L1_L2.x_min + 14 wires_L1_L2.y_min = block_instr_L1.y_min wires_L1_L2.y_max = block_vector_L1.y_max - wires_L1_L2.vl1_l2_rd = metric_dict["VL1_L2 Rd"] - wires_L1_L2.vl1_l2_wr = metric_dict["VL1_L2 Wr"] - wires_L1_L2.vl1_l2_atomic = metric_dict["VL1_L2 Atomic"] - wires_L1_L2.sl1_l2_rd = metric_dict["sL1D_L2 Rd"] - wires_L1_L2.sl1_l2_wr = metric_dict["sL1D_L2 Wr"] - wires_L1_L2.sl1_l2_atomic = metric_dict["sL1D_L2 Atomic"] - wires_L1_L2.il1_l2_req = metric_dict["IL1_L2 Rd"] + wires_L1_L2.vl1_l2_rd = metric_dict.get("VL1_L2 Rd", "n/a") + wires_L1_L2.vl1_l2_wr = metric_dict.get("VL1_L2 Wr", "n/a") + wires_L1_L2.vl1_l2_atomic = metric_dict.get("VL1_L2 Atomic", "n/a") + wires_L1_L2.sl1_l2_rd = metric_dict.get("VL1D_L2 Rd", "n/a") + wires_L1_L2.sl1_l2_wr = metric_dict.get("VL1D_L2 Wr", "n/a") + wires_L1_L2.sl1_l2_atomic = metric_dict.get("VL1D_L2 Atomic", "n/a") + wires_L1_L2.il1_l2_req = metric_dict.get("IL1_L2 Rd", "n/a") wires_L1_L2.draw(canvas) @@ -1190,12 +1190,12 @@ class MemChart: block_L2.y_min = block_instr_L1.y_min block_L2.y_max = block_lds.y_max - block_L2.hit = metric_dict["L2 Hit"] - block_L2.rd = metric_dict["L2 Rd"] - block_L2.wr = metric_dict["L2 Wr"] - block_L2.atomic = metric_dict["L2 Atomic"] - block_L2.rd_lat = metric_dict["L2 Rd Lat"] - block_L2.wr_lat = metric_dict["L2 Wr Lat"] + block_L2.hit = metric_dict.get("L2 Hit", "n/a") + block_L2.rd = metric_dict.get("L2 Rd", "n/a") + block_L2.wr = metric_dict.get("L2 Wr", "n/a") + block_L2.atomic = metric_dict.get("L2 Atomic", "n/a") + block_L2.rd_lat = metric_dict.get("L2 Rd Lat", "n/a") + block_L2.wr_lat = metric_dict.get("L2 Wr Lat", "n/a") block_L2.draw(canvas) @@ -1209,9 +1209,9 @@ class MemChart: y_max=block_L2.y_max - 10, ) - wires_L2_Fabric.rd = metric_dict["Fabric_L2 Rd"] - wires_L2_Fabric.wr = metric_dict["Fabric_L2 Wr"] - wires_L2_Fabric.atomic = metric_dict["Fabric_L2 Atomic"] + wires_L2_Fabric.rd = metric_dict.get("Fabric_L2 Rd", "n/a") + wires_L2_Fabric.wr = metric_dict.get("Fabric_L2 Wr", "n/a") + wires_L2_Fabric.atomic = metric_dict.get("Fabric_L2 Atomic", "n/a") wires_L2_Fabric.draw(canvas) @@ -1236,9 +1236,9 @@ class MemChart: y_min=block_xgmi_pcie.y_min - 5 - 11, ) - block_fabric.lat["Rd"] = metric_dict["Fabric Rd Lat"] - block_fabric.lat["Wr"] = metric_dict["Fabric Wr Lat"] - block_fabric.lat["Atomic"] = metric_dict["Fabric Atomic Lat"] + block_fabric.lat["Rd"] = metric_dict.get("Fabric Rd Lat", "n/a") + block_fabric.lat["Wr"] = metric_dict.get("Fabric Wr Lat", "n/a") + block_fabric.lat["Atomic"] = metric_dict.get("Fabric Atomic Lat", "n/a") block_fabric.draw(canvas) @@ -1264,8 +1264,8 @@ class MemChart: y_max=block_fabric.y_max - 4, ) - wires_Fabric_HBM.rd = metric_dict["HBM Rd"] - wires_Fabric_HBM.wr = metric_dict["HBM Wr"] + wires_Fabric_HBM.rd = metric_dict.get("HBM Rd", "n/a") + wires_Fabric_HBM.wr = metric_dict.get("HBM Wr", "n/a") wires_Fabric_HBM.draw(canvas) diff --git a/projects/rocprofiler-compute/src/utils/parser.py b/projects/rocprofiler-compute/src/utils/parser.py index b8bfaa8519..09e8c73deb 100644 --- a/projects/rocprofiler-compute/src/utils/parser.py +++ b/projects/rocprofiler-compute/src/utils/parser.py @@ -137,14 +137,28 @@ def to_max(*args): def to_avg(a): if str(type(a)) == "": return np.nan - elif np.isnan(a).all(): - return np.nan - elif a.empty: - return np.nan elif isinstance(a, pd.core.series.Series): - return a.mean() + if a.empty: + return np.nan + elif np.isnan(a).all(): + return np.nan + else: + return a.mean() + elif isinstance(a, (np.ndarray, list)): + arr = np.array(a) + if arr.size == 0: + return np.nan + elif np.isnan(arr).all(): + return np.nan + else: + return np.nanmean(arr) + elif isinstance(a, (int, float, np.number)): + if np.isnan(a): + return np.nan + else: + return float(a) else: - raise Exception("to_avg: unsupported type.") + raise Exception(f"to_avg: unsupported type: {type(a)}") def to_median(a): @@ -313,6 +327,7 @@ def build_eval_string(equation, coll_level, config): s = re.sub(r"\'\]\[(\d+)\]", r"[\g<1>]']", s) # use .get() to catch any potential KeyErrors s = re.sub(r"raw_pmc_df\['(.*?)']", r'raw_pmc_df.get("\1")', s) + # print("--- intermediate string: ", s) # apply coll_level if config.get("format_rocprof_output") == "rocpd": # Replace SQ_ACCUM_PREV_HIRES with coll_level_ACCUM then ignore coll_level df @@ -1448,7 +1463,7 @@ def load_kernel_top(workload, dir, args): def load_table_data(workload, dir, is_gui, args, config, skipKernelTop=False): """ - Load data for all "raw_csv_table" - - Load dat for "pc_sampling_table" + - Load data for "pc_sampling_table" - Calculate mertric value for all "metric_table" """ if not skipKernelTop: diff --git a/projects/rocprofiler-compute/tests/test_analyze_commands.py b/projects/rocprofiler-compute/tests/test_analyze_commands.py index 0dbfd53d52..12e6a8fece 100644 --- a/projects/rocprofiler-compute/tests/test_analyze_commands.py +++ b/projects/rocprofiler-compute/tests/test_analyze_commands.py @@ -24,7 +24,7 @@ import os import shutil -from unittest.mock import Mock, patch +from unittest.mock import Mock import pandas as pd import pytest diff --git a/projects/rocprofiler-compute/tests/test_db_connector.py b/projects/rocprofiler-compute/tests/test_db_connector.py index ad48a87a77..5bd657bf2f 100644 --- a/projects/rocprofiler-compute/tests/test_db_connector.py +++ b/projects/rocprofiler-compute/tests/test_db_connector.py @@ -24,11 +24,7 @@ import logging -import shutil -import sys -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, Mock, call, patch +from unittest.mock import MagicMock, Mock, patch import pandas as pd import pytest