diff --git a/bin/mangohud-setup.sh b/bin/mangohud-setup.sh index ab0d04f7..1f7a5d5d 100755 --- a/bin/mangohud-setup.sh +++ b/bin/mangohud-setup.sh @@ -38,6 +38,7 @@ mangohud_uninstall() { rm -frv "/usr/share/doc/mangohud" rm -fv "/usr/share/man/man1/mangohud.1" rm -fv "/usr/bin/mangohud" + rm -fv "/usr/bin/mangoplot" rm -fv "/usr/bin/mangohud.x86" } @@ -70,6 +71,7 @@ mangohud_install() { /usr/bin/install -Dvm644 ./usr/share/man/man1/mangohud.1 /usr/share/man/man1/mangohud.1 /usr/bin/install -Dvm644 ./usr/share/doc/mangohud/MangoHud.conf.example /usr/share/doc/mangohud/MangoHud.conf.example /usr/bin/install -vm755 ./usr/bin/mangohud /usr/bin/mangohud + /usr/bin/install -vm755 ./usr/bin/mangoplot /usr/bin/mangoplot ln -sv $DEFAULTLIB /usr/lib/mangohud/lib diff --git a/bin/mangoplot.py b/bin/mangoplot.py new file mode 100755 index 00000000..83b15d0f --- /dev/null +++ b/bin/mangoplot.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python + +r""" + Script to plot all the MangoHud benchmarks contained in a given folder. +""" +from pathlib import Path +import argparse +import csv + +from typing import List, Union + +import numpy as np + +import matplotlib.pyplot as plt +from matplotlib.widgets import Cursor + +from matplotlib.ticker import EngFormatter + + +def identity(val): + r""" + returns the value as-is + """ + return val + + +def get_integer(val: str) -> int: + r""" + interprets the str 'val' as an integer and returns it + """ + if is_integer(val): + return int(val) + else: + raise ValueError("Casting a non integer value: ", val) + + +def is_integer(s: str) -> bool: + r""" + tests if 's' is an integer and returns a bool + """ + try: + int(s) + return True + except ValueError: + return False + + +def get_float(val): + r""" + interprets the str 'val' as a float and returns it + """ + if is_float(val): + return float(val) + else: + return float("nan") + + +def is_float(s: str) -> bool: + r""" + tests if 's' is an float and returns a bool + """ + try: + float(s) + return True + except ValueError: + return False + + +class Database: + r""" + A class that contains all the csv files within + the folder that it is instanced with + """ + def __init__(self, + data_folder_path=None, + csv_separator=" ", + filename_var_separator="|"): + + self.datafiles = [] + self.result_names_col = None + self.result_values_col = None + self.sim_settings_names_col = None + self.sim_settings_values_col = None + + if data_folder_path: + self.load_from_folder( + data_folder_path, + csv_separator, + filename_var_separator) + + def load_from_folder(self, + data_folder_path, + csv_separator=" ", + filename_var_separator="|"): + r""" + Load all CSV files form the given folder + """ + filepaths = list(Path(data_folder_path).rglob("*.csv")) + + self.datafiles = [] + N = len(filepaths) + + print(f"Loading {N} benchmark files") + for filepath in filepaths: + try: + datafile = BenchmarkFile( + str(filepath), + csv_separator=csv_separator, + filename_var_separator=filename_var_separator) + self.datafiles.append(datafile) + except Exception: + pass + + self.datafiles.sort() + + +class BenchmarkFile: + r""" + A class that represents a single CSV file, can load CSV files + with arbitrary separators. It can return separately any column + of the file and any mathematical combinations of its columns. + """ + + def __init__(self, + filepath="", + filename_var_separator="|", + csv_separator=" "): + self.csv_separator = csv_separator + self.filepath = Path(filepath) + self.filename = self.filepath.name + self.filename_var_separator = filename_var_separator + self.variables = dict() + + self.skip_lines = None + + self.columns = [] + self.column_name_to_index = dict() + + self._is_data_loaded = False + + if not self.filepath.is_file(): + raise Exception("CSV file does not exist") + + self._read_column_names() + + def __lt__(self, other): + stem = self.filename[:-4] # remove the trailing ".csv" + other_stem = other.filename[:-4] + if stem.startswith(other_stem): + return False + elif stem.startswith(other_stem): + return True + else: + return stem < other_stem + + def set_variable(self, name, value): + r""" + Saves a variable within the datafile instance + Note: it will not be saved to disk, it's just a helper method to + attach variables to a given data file. + """ + self.variables[name] = value + + def get_variable(self, name): + r""" + Retrieves a saved variable in the instance + """ + return self.variables[name] + + def _read_column_names(self): + r""" + Read the first few lines of the benchmark file + to look for the row taht contains the benchmark's + column names i.e. "fps", "frametime", "cpu_load"... etc + and save the columns names and their index + + Note: we decide that we found the right row by looking if it + contains "fps" + not the best approach, but it works TM + """ + + with open(self.filepath) as open_file: + reader = csv.reader(open_file, delimiter=self.csv_separator) + + found_fps_column = False + for row_number, row_content in enumerate(reader): + if row_number > 4: + # if we're past the 4th row, break the loop + break + + if "fps" in row_content: + self.skip_lines = row_number + 1 + found_fps_column = True + + for col, col_name in enumerate(row_content): + if col_name in self.column_name_to_index: + raise Exception("Two columns have the same name") + self.column_name_to_index[col_name] = col + + if not found_fps_column: + raise Exception("Not a benchmark file") + + def _load_data(self): + r""" + Load the benchmark data into memory. + """ + + def extend_columns(new_column_num): + current_row_num = 0 + if self.columns: + current_row_num = len(self.columns[0]) + assert (all([len(column) == current_row_num for column in self.columns])) + + current_column_num = len(self.columns) + if new_column_num >= current_column_num: + self.columns += [["" for j in range(current_row_num)] for i in range(new_column_num - current_column_num)] + + # no need to load data if it's already loaded + if self._is_data_loaded: + return + + with open(self.filepath) as open_file: + reader = csv.reader(open_file, delimiter=self.csv_separator) + self._is_data_loaded = True + + for row_number, row_content in enumerate(reader): + if row_number <= self.skip_lines: + continue + + extend_columns(len(row_content)) + for col, val in enumerate(row_content): + self.columns[col].append(val) + + # Delete any eventual empty column + if all([val == "" for val in self.columns[-1]]): + del self.columns[-1] + + def get_column_names(self) -> List[str]: + r""" + Returns the list of columns names of the csv file. + """ + + return list(self.column_name_to_index.keys()) + + def get(self, col: str, data_type: str = "float") \ + -> Union[List[float], List[str], List[int], List[complex]]: + r""" + Returns the column `col`. + + Parameters + ---------- + + col : str + The desired column name to retrieve, or its index + + data_type : str + "string", "integer" or "float", the type to cast + the data to before returning it. + + Returns + ------- + + A list of `data_type` containing the column `col` + + """ + + if not self._is_data_loaded: + self._load_data() + + if len(self.columns) == 0: + raise ValueError("Datafile empty, can't return any data") + + data_caster_dict = { + "string": identity, + "float": get_float, + "integer": get_integer + } + + if data_type not in data_caster_dict: + raise ValueError("the given `data_type' doesn't match any " + "known types. Which are `string', `integer', " + "`float' or `complex'") + + if is_integer(col): + # the column's index is given + return [data_caster_dict[data_type](val) for val in self.columns[col]] + + if col in self.column_name_to_index: + # a column name has been given + return [data_caster_dict[data_type](val) + for val in self.columns[self.column_name_to_index[col]]] + + raise Exception("Column {} does not exist".format(col)) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description='Plot all the MangoHud benchmarks contained in a given folder.') + + parser.add_argument('folder', metavar='folder', nargs=1, + help='path the a MangoHud benchmark folder') + + args = parser.parse_args() + + bench_folder_path = Path(args.folder[0]) + + if not bench_folder_path.is_dir(): + print(f"The path '{bench_folder_path.absolute()}' " + "does not point to an existing folder") + exit(1) + + fps_subdivs = 1.0 # one division every fps_subdivs FPS + + y_labels = [] # bench files + x_labels = [] # FPS subidivions + + database = Database(bench_folder_path, csv_separator=',') + distributions = [] + + if len(database.datafiles) == 0: + print(f"The folder \n {bench_folder_path.absolute()} \n" + "contains no CSV file " + "(make sure they have the .csv extension)") + exit(1) + + for datafile in database.datafiles: + bar_distribution = [] + + # sort array to get percentiles + fps_array = np.sort(datafile.get("fps")) + + # save percentiles + if len(fps_array) < 10000: + print(f"'{datafile.filename}' simulation " + "isn't long enough for precise statistics") + datafile.set_variable("selected", False) + continue + + # Save label only if this file has long enough simulation + y_labels.append(datafile.filename[:-4]) + datafile.set_variable("selected", True) + + # Save percentiles + datafile.set_variable("0.1%", fps_array[int(float(len(fps_array))*0.001)]) + datafile.set_variable("1%", fps_array[int(float(len(fps_array))*0.01)]) + datafile.set_variable("50%", fps_array[int(float(len(fps_array))*0.5)]) + + datafile.set_variable("average fps", np.average(fps_array)) + + for frame_num, fps in enumerate(fps_array): + if fps > 1000: + print("FPS value above 1000, omitting outlier.") + continue + index = int(fps/fps_subdivs) + for i in range(len(bar_distribution), index+1): + bar_distribution.append(0) + bar_distribution[index] += 1 + distributions.append(bar_distribution) + + if not distributions: + print("Nothing to plot, exiting.") + exit(1) + + num_benchs = len(distributions) + max_size = 0 + for distrib in distributions: + max_size = max(max_size, len(distrib)) + for distrib in distributions: + for i in range(len(distrib), max_size): + distrib.append(0) + + for i in range(max_size): + x_labels.append(str(fps_subdivs * i)) + + fig, ax = plt.subplots() + im = ax.imshow(distributions, + aspect="auto", + extent=[0, max_size*fps_subdivs, 0, num_benchs]) + + for i in range(len(y_labels)+1): + ax.axhline(float(i), color='white', lw=2) + + i = 0 + for datafile in database.datafiles: + if datafile.get_variable("selected"): + kwargs = dict(ymin=(num_benchs-i-1+0.15)/num_benchs, + ymax=(num_benchs-i-0.15)/num_benchs, + lw=3) + + ax.axvline(datafile.get_variable("0.1%"), + color='#f45d7bff', + label=("0.1%" if i == 0 else None), **kwargs) + + ax.axvline(datafile.get_variable("1%"), + color='#c879c1ff', + label=("1%" if i == 0 else None), **kwargs) + + ax.axvline(datafile.get_variable("50%"), + color='#7b4182ff', + label=("50%" if i == 0 else None), **kwargs) + + ax.axvline(datafile.get_variable("average fps"), + color='#336f74ff', + label=("Average" if i == 0 else None), **kwargs) + i += 1 + + ax.set_yticks(np.arange(len(y_labels)-0.5, 0, -1), labels=y_labels) + ax.grid(False) + + ax.ticklabel_format(axis='x', style='plain') + + formatter0 = EngFormatter(unit='FPS') + ax.xaxis.set_major_formatter(formatter0) + + plt.tight_layout() + plt.legend() + + cursor = Cursor(ax, + horizOn=False, + color='#6c49abff', + linewidth=4, + useblit=True) + + plt.show() diff --git a/bin/meson.build b/bin/meson.build new file mode 100644 index 00000000..0f9c1161 --- /dev/null +++ b/bin/meson.build @@ -0,0 +1,7 @@ +# runtime dependencies for `mangoplot`: matplotlib and a GUI backed like PyQt5 +install_data( + 'mangoplot.py', + install_dir: get_option('bindir'), + rename: 'mangoplot', + install_mode: 'rwxr-xr-x' +) diff --git a/build.sh b/build.sh index 19dcc054..b674e539 100755 --- a/build.sh +++ b/build.sh @@ -195,6 +195,7 @@ uninstall() { rm -fv "/usr/share/vulkan/implicit_layer.d/MangoHud.x86.json" rm -fv "/usr/share/vulkan/implicit_layer.d/MangoHud.x86_64.json" rm -fv "/usr/bin/mangohud" + rm -fv "/usr/bin/mangoplot" rm -fv "/usr/bin/mangohud.x86" } @@ -235,6 +236,7 @@ install() { /usr/bin/install -Dvm644 ./build/release/usr/share/man/man1/mangohud.1 /usr/share/man/man1/mangohud.1 /usr/bin/install -Dvm644 ./build/release/usr/share/doc/mangohud/MangoHud.conf.example /usr/share/doc/mangohud/MangoHud.conf.example /usr/bin/install -vm755 ./build/release/usr/bin/mangohud /usr/bin/mangohud + /usr/bin/install -vm755 ./build/release/usr/bin/mangoplot /usr/bin/mangoplot ln -sv $DEFAULTLIB /usr/lib/mangohud/lib diff --git a/meson.build b/meson.build index dd936c3c..199cd112 100644 --- a/meson.build +++ b/meson.build @@ -8,6 +8,7 @@ project('MangoHud', cc = meson.get_compiler('c') cpp = meson.get_compiler('cpp') + prog_python = import('python').find_installation('python3', modules: ['mako']) null_dep = dependency('', required : false) @@ -272,3 +273,5 @@ if get_option('tests').enabled() endif +# install helper sripts +subdir('bin')