diff --git a/params_LinearMHDDriftkineticCC.py b/params_LinearMHDDriftkineticCC.py new file mode 100644 index 000000000..2b95f552d --- /dev/null +++ b/params_LinearMHDDriftkineticCC.py @@ -0,0 +1,116 @@ +from struphy import main +from struphy.fields_background import equils +from struphy.geometry import domains +from struphy.initial import perturbations +from struphy.io.options import BaseUnits, DerhamOptions, EnvironmentOptions, FieldsBackground, Time +from struphy.kinetic_background import maxwellians + +# import model, set verbosity +from struphy.models.hybrid import LinearMHDDriftkineticCC +from struphy.pic.utilities import ( + BinningPlot, + BoundaryParameters, + KernelDensityPlot, + LoadingParameters, + WeightsParameters, +) +from struphy.topology import grids + +# environment options +env = EnvironmentOptions(profiling_activated=True, profiling_trace=True) + +# units +base_units = BaseUnits() + +# time stepping +time_opts = Time() + +# geometry +domain = domains.Cuboid() + +# fluid equilibrium (can be used as part of initial conditions) +equil = equils.HomogenSlab() + +# grid +grid = grids.TensorProductGrid(Nel=(16, 4, 1)) + +# derham options +derham_opts = DerhamOptions() + +# light-weight model instance +model = LinearMHDDriftkineticCC() + +# species parameters +model.mhd.set_phys_params() +model.energetic_ions.set_phys_params() + +loading_params = LoadingParameters(ppc=1000) +weights_params = WeightsParameters() +boundary_params = BoundaryParameters() +model.energetic_ions.set_markers( + loading_params=loading_params, + weights_params=weights_params, + boundary_params=boundary_params, +) +model.energetic_ions.set_sorting_boxes() +model.energetic_ions.set_save_data() + +# propagator options +model.propagators.push_bxe.options = model.propagators.push_bxe.Options( + b_tilde=model.em_fields.b_field, +) +model.propagators.push_parallel.options = model.propagators.push_parallel.Options( + b_tilde=model.em_fields.b_field, +) +model.propagators.shearalfen_cc5d.options = model.propagators.shearalfen_cc5d.Options( + energetic_ions=model.energetic_ions.var, +) +model.propagators.magnetosonic.options = model.propagators.magnetosonic.Options( + b_field=model.em_fields.b_field, +) +model.propagators.cc5d_density.options = model.propagators.cc5d_density.Options( + energetic_ions=model.energetic_ions.var, + b_tilde=model.em_fields.b_field, +) +model.propagators.cc5d_gradb.options = model.propagators.cc5d_gradb.Options( + b_tilde=model.em_fields.b_field, +) +model.propagators.cc5d_curlb.options = model.propagators.cc5d_curlb.Options( + b_tilde=model.em_fields.b_field, +) + +# background, perturbations and initial conditions +model.mhd.velocity.add_background(FieldsBackground()) +model.mhd.velocity.add_perturbation(perturbations.TorusModesCos(given_in_basis="v", comp=0)) +model.mhd.velocity.add_perturbation(perturbations.TorusModesCos(given_in_basis="v", comp=1)) +model.mhd.velocity.add_perturbation(perturbations.TorusModesCos(given_in_basis="v", comp=2)) +maxwellian_1 = maxwellians.GyroMaxwellian2D(n=(1.0, None), equil=equil) +maxwellian_2 = maxwellians.GyroMaxwellian2D(n=(0.1, None), equil=equil) +background = maxwellian_1 + maxwellian_2 +model.energetic_ions.var.add_background(background) + +# if .add_initial_condition is not called, the background is the kinetic initial condition +perturbation = perturbations.TorusModesCos() +maxwellian_1pt = maxwellians.GyroMaxwellian2D(n=(1.0, perturbation), equil=equil) +init = maxwellian_1pt + maxwellian_2 +model.energetic_ions.var.add_initial_condition(init) + +# optional: exclude variables from saving +# model.energetic_ions.var.save_data = False + +if __name__ == "__main__": + # start run + verbose = True + + main.run( + model, + params_path=__file__, + env=env, + base_units=base_units, + time_opts=time_opts, + domain=domain, + equil=equil, + grid=grid, + derham_opts=derham_opts, + verbose=verbose, + ) diff --git a/pyproject.toml b/pyproject.toml index 76c1e0b0a..3e6d596fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ 'pytest-mpi', 'pytest-testmon', 'line_profiler', + 'scope-profiler==0.1.6', ] [project.license] diff --git a/src/struphy/io/options.py b/src/struphy/io/options.py index 9be8b3249..e99d31762 100644 --- a/src/struphy/io/options.py +++ b/src/struphy/io/options.py @@ -336,6 +336,12 @@ class EnvironmentOptions: num_clones: int, optional Number of domain clones (default=1) + + profiling_activated: bool, optional + Activate profiling with scope-profiler (default=False) + + profiling_trace: bool, optional + Save time-trace of each profiling region (default=False) """ out_folders: str = os.getcwd() @@ -345,6 +351,8 @@ class EnvironmentOptions: save_step: int = 1 sort_step: int = 0 num_clones: int = 1 + profiling_activated: bool = False + profiling_trace: bool = False def __post_init__(self): self.path_out: str = os.path.join(self.out_folders, self.sim_folder) diff --git a/src/struphy/main.py b/src/struphy/main.py index bae521086..92a60edf6 100644 --- a/src/struphy/main.py +++ b/src/struphy/main.py @@ -14,6 +14,7 @@ from psydac.ddm.mpi import MockMPI from psydac.ddm.mpi import mpi as MPI from pyevtk.hl import gridToVTK +from scope_profiler import ProfileManager from struphy.fields_background.base import FluidEquilibrium, FluidEquilibriumWithB from struphy.fields_background.equils import HomogenSlab @@ -36,7 +37,6 @@ post_process_markers, post_process_n_sph, ) -from struphy.profiling.profiling import ProfileManager from struphy.topology import grids from struphy.topology.grids import TensorProductGrid from struphy.utils.clone_config import CloneConfig @@ -69,6 +69,17 @@ def run( Absolute path to .py parameter file. """ + ProfileManager.setup( + profiling_activated=env.profiling_activated, + time_trace=env.profiling_trace, + use_likwid=False, + file_path=os.path.join( + env.out_folders, + env.sim_folder, + "profiling_data.h5", + ), + ) + if isinstance(MPI, MockMPI): comm = None rank = 0 @@ -407,6 +418,8 @@ def run( if clone_config is not None: clone_config.free() + ProfileManager.finalize() + def pproc( path: str, @@ -831,154 +844,3 @@ def load_data(path: str) -> SimData: print(f" {kkk}") return simdata - - -if __name__ == "__main__": - import argparse - import os - - import struphy - import struphy.utils.utils as utils - from struphy.profiling.profiling import ( - ProfileManager, - ProfilingConfig, - pylikwid_markerclose, - pylikwid_markerinit, - ) - - # Read struphy state file - state = utils.read_state() - o_path = state["o_path"] - - parser = argparse.ArgumentParser(description="Run an Struphy model.") - - # model - parser.add_argument( - "model", - type=str, - nargs="?", - default=None, - metavar="MODEL", - help="the name of the model to run (default: None)", - ) - - # input (absolute path) - parser.add_argument( - "-i", - "--input", - type=str, - metavar="FILE", - help="absolute path of parameter file", - ) - - # output (absolute path) - parser.add_argument( - "-o", - "--output", - type=str, - metavar="DIR", - help="absolute path of output folder (default=/sim_1)", - default=os.path.join(o_path, "sim_1"), - ) - - # restart - parser.add_argument( - "-r", - "--restart", - help="restart the simulation in the output folder specified under -o", - action="store_true", - ) - - # max_runtime - parser.add_argument( - "--max-runtime", - type=int, - metavar="N", - help="maximum wall-clock time of program in minutes (default=300)", - default=300, - ) - - # save step - parser.add_argument( - "-s", - "--save-step", - type=int, - metavar="N", - help="how often to skip data saving (default=1, which means data is saved every time step)", - default=1, - ) - - # sort step - parser.add_argument( - "--sort-step", - type=int, - metavar="N", - help="sort markers in memory every N time steps (default=0, which means markers are sorted only at the start of simulation)", - default=0, - ) - - parser.add_argument( - "--nclones", - type=int, - metavar="N", - help="number of domain clones (default=1)", - default=1, - ) - - # verbosity (screen output) - parser.add_argument( - "-v", - "--verbose", - help="supress screen output during time integration", - action="store_true", - ) - - parser.add_argument( - "--likwid", - help="run with Likwid", - action="store_true", - ) - - parser.add_argument( - "--time-trace", - help="Measure time traces for each call of the regions measured with ProfileManager", - action="store_true", - ) - - parser.add_argument( - "--sample-duration", - help="Duration of samples when measuring time traces with ProfileManager", - default=1.0, - ) - - parser.add_argument( - "--sample-interval", - help="Time between samples when measuring time traces with ProfileManager", - default=1.0, - ) - - args = parser.parse_args() - config = ProfilingConfig() - config.likwid = args.likwid - config.sample_duration = float(args.sample_duration) - config.sample_interval = float(args.sample_interval) - config.time_trace = args.time_trace - config.simulation_label = "" - pylikwid_markerinit() - with ProfileManager.profile_region("main"): - # solve the model - run( - args.model, - args.input, - args.output, - restart=args.restart, - runtime=args.runtime, - save_step=args.save_step, - verbose=args.verbose, - sort_step=args.sort_step, - num_clones=args.nclones, - ) - pylikwid_markerclose() - if config.time_trace: - ProfileManager.print_summary() - ProfileManager.save_to_pickle(os.path.join(args.output, "profiling_time_trace.pkl")) diff --git a/src/struphy/models/base.py b/src/struphy/models/base.py index e75d281e0..0f7d90401 100644 --- a/src/struphy/models/base.py +++ b/src/struphy/models/base.py @@ -12,6 +12,7 @@ from psydac.ddm.mpi import MockMPI from psydac.ddm.mpi import mpi as MPI from psydac.linalg.stencil import StencilVector +from scope_profiler import ProfileManager import struphy from struphy.feec.basis_projection_ops import BasisProjectionOperators @@ -39,7 +40,6 @@ from struphy.models.variables import FEECVariable, PICVariable, SPHVariable from struphy.pic import particles from struphy.pic.base import Particles -from struphy.profiling.profiling import ProfileManager from struphy.propagators.base import Propagator from struphy.topology.grids import TensorProductGrid from struphy.utils.clone_config import CloneConfig @@ -692,7 +692,7 @@ def integrate(self, dt, split_algo="LieTrotter"): for propagator in self.prop_list: prop_name = propagator.__class__.__name__ - with ProfileManager.profile_region(prop_name): + with ProfileManager.profile_region("prop: " + prop_name): propagator(dt) # second order in time @@ -701,17 +701,17 @@ def integrate(self, dt, split_algo="LieTrotter"): for propagator in self.prop_list[:-1]: prop_name = type(propagator).__name__ - with ProfileManager.profile_region(prop_name): + with ProfileManager.profile_region("prop: " + prop_name): propagator(dt / 2) propagator = self.prop_list[-1] prop_name = type(propagator).__name__ - with ProfileManager.profile_region(prop_name): + with ProfileManager.profile_region("prop: " + prop_name): propagator(dt) for propagator in self.prop_list[:-1][::-1]: prop_name = type(propagator).__name__ - with ProfileManager.profile_region(prop_name): + with ProfileManager.profile_region("prop: " + prop_name): propagator(dt / 2) else: diff --git a/src/struphy/pic/accumulation/particles_to_grid.py b/src/struphy/pic/accumulation/particles_to_grid.py index 06d67a6df..31143a029 100644 --- a/src/struphy/pic/accumulation/particles_to_grid.py +++ b/src/struphy/pic/accumulation/particles_to_grid.py @@ -4,6 +4,7 @@ from psydac.ddm.mpi import mpi as MPI from psydac.linalg.block import BlockVector from psydac.linalg.stencil import StencilMatrix, StencilVector +from scope_profiler import ProfileManager import struphy.pic.accumulation.accum_kernels as accums import struphy.pic.accumulation.accum_kernels_gc as accums_gc @@ -12,7 +13,6 @@ from struphy.kernel_arguments.pusher_args_kernels import DerhamArguments, DomainArguments from struphy.pic.accumulation.filter import AccumFilter, FilterParameters from struphy.pic.base import Particles -from struphy.profiling.profiling import ProfileManager from struphy.utils.pyccel import Pyccelkernel diff --git a/src/struphy/pic/pushing/pusher.py b/src/struphy/pic/pushing/pusher.py index 14c756b31..235fe6cba 100644 --- a/src/struphy/pic/pushing/pusher.py +++ b/src/struphy/pic/pushing/pusher.py @@ -3,10 +3,10 @@ import cunumpy as xp from line_profiler import profile from psydac.ddm.mpi import mpi as MPI +from scope_profiler import ProfileManager from struphy.kernel_arguments.pusher_args_kernels import DerhamArguments, DomainArguments from struphy.pic.base import Particles -from struphy.profiling.profiling import ProfileManager from struphy.utils.pyccel import Pyccelkernel diff --git a/src/struphy/post_processing/likwid/plot_time_traces.py b/src/struphy/post_processing/likwid/plot_time_traces.py index 7451833cb..573f511bb 100644 --- a/src/struphy/post_processing/likwid/plot_time_traces.py +++ b/src/struphy/post_processing/likwid/plot_time_traces.py @@ -6,6 +6,7 @@ import matplotlib.pyplot as plt import plotly.graph_objects as go import plotly.io as pio +from scope_profiler.h5reader import ProfilingH5Reader # pio.kaleido.scope.mathjax = None import struphy.post_processing.likwid.maxplotlylib as mply @@ -17,22 +18,6 @@ def glob_to_regex(pat: str) -> str: return "^" + esc.replace(r"\*", ".*").replace(r"\?", ".") + "$" -# def plot_region(region_name, groups_include=["*"], groups_skip=[]): -# # skips first -# for pat in groups_skip: -# rx = glob_to_regex(pat) -# if re.fullmatch(rx, region_name): -# return False - -# # includes next -# for pat in groups_include: -# rx = glob_to_regex(pat) -# if re.fullmatch(rx, region_name): -# return True - -# return False - - def plot_region(region_name, groups_include=["*"], groups_skip=[]): from fnmatch import fnmatch @@ -46,7 +31,7 @@ def plot_region(region_name, groups_include=["*"], groups_skip=[]): def plot_time_vs_duration( - paths, + reader, output_path, groups_include=["*"], groups_skip=[], @@ -54,97 +39,119 @@ def plot_time_vs_duration( ): """ Plot start times versus durations for all profiling regions from all MPI ranks, - with each region using the same color across ranks. + using the new _region_dict[region][rank] = RegionData structure. Parameters ---------- - path: str - Path to the file where profiling data is saved. + reader : ProfilingH5Reader + The profiling reader object with _region_dict. """ - if isinstance(paths, str): - paths = [paths] + import os + + import matplotlib.pyplot as plt + import numpy as np plt.figure(figsize=(10, 6)) - for path in paths: - print(f"{path =}") - with open(path, "rb") as file: - profiling_data = pickle.load(file) - - # Create a color map for each unique region - unique_regions = set( - region_name for rank_data in profiling_data["rank_data"].values() for region_name in rank_data - ) - color_map = {region_name: plt.cm.tab10(i % 10) for i, region_name in enumerate(unique_regions)} - - # Iterate through each rank's data - for rank_name, rank_data in profiling_data["rank_data"].items(): - for region_name, info in rank_data.items(): - if plot_region(region_name=region_name, groups_include=groups_include, groups_skip=groups_skip): - start_times = info["start_times"] - durations = info["durations"] - - # Use the color from the color_map for each region - color = color_map[region_name] - label = f"{region_name}" if rank_name == "rank_0" else None - if len(paths) > 1: - label += f" ({path.split('/')[-2]})" - plt.plot(start_times, durations, "x-", color=color, label=label) - xmax = max(start_times) - x = 0 - if show_spans: - while x < xmax: - xa = x + info["config"]["sample_duration"] - xb = xa + (info["config"]["sample_interval"] - info["config"]["sample_duration"]) - plt.axvspan(xa, xb, alpha=0.5, color="red", zorder=1) - x += info["config"]["sample_interval"] + + # Collect all unique region names for color mapping + unique_regions = list(reader._region_dict.keys()) + color_map = {region_name: plt.cm.tab10(i % 10) for i, region_name in enumerate(unique_regions)} + + # Iterate over regions and ranks + for region_name, region in reader._region_dict.items(): + if not plot_region(region_name=region_name, groups_include=groups_include, groups_skip=groups_skip): + continue + + color = color_map[region_name] + + for rank_idx, rank_data in region.items(): + start_times = rank_data.start_times + durations = rank_data.end_times - rank_data.start_times + + # Only label the first rank for legend + label = region_name if rank_idx == 0 else None + + plt.plot(start_times, durations, "x-", color=color, label=label) + + # Optionally show sampling spans + if show_spans and hasattr(rank_data, "config"): + x = 0 + xmax = np.max(start_times) if len(start_times) > 0 else 0 + while x < xmax: + sample_duration = rank_data.config["sample_duration"] + sample_interval = rank_data.config["sample_interval"] + plt.axvspan( + x + sample_duration, + x + sample_interval, + alpha=0.5, + color="red", + zorder=1, + ) + x += sample_interval plt.title("Time vs. Duration for Profiling Regions") plt.xlabel("Start Time (s)") plt.ylabel("Duration (s)") - plt.legend() plt.grid(visible=True, linestyle="--", alpha=0.5) + plt.legend() plt.tight_layout() - # plt.show() + + os.makedirs(output_path, exist_ok=True) figure_path = os.path.join(output_path, "time_vs_duration.pdf") plt.savefig(figure_path) - print(f"Saved time trace to:{figure_path}") + print(f"Saved time trace to: {figure_path}") def plot_avg_duration_bar_chart( - path, + reader, output_path, groups_include=["*"], groups_skip=[], ): - with open(path, "rb") as file: - profiling_data = pickle.load(file) + """ + Plot average duration per profiling region across all ranks. + Uses the new data structure: + reader._region_dict[region][rank] = RegionData(start_times, end_times) + """ + + import os + + import matplotlib.pyplot as plt + import numpy as np region_durations = {} # Gather all durations per region across all ranks - for rank_data in profiling_data["rank_data"].values(): - for region_name, info in rank_data.items(): - if any(skip in region_name for skip in groups_skip): - continue - if groups_include != ["*"] and not any(inc in region_name for inc in groups_include): - continue - durations = info["durations"] + for region_name, region in reader._region_dict.items(): + if any(skip in region_name for skip in groups_skip): + continue + if groups_include != ["*"] and not any(inc in region_name for inc in groups_include): + continue + + for rank_data in region.values(): + durations = rank_data.end_times - rank_data.start_times region_durations.setdefault(region_name, []).extend(durations) + if len(region_durations) == 0: + print("No regions matched the filter.") + return + # Compute statistics per region regions = sorted(region_durations.keys()) - avg_durations = [xp.mean(region_durations[r]) for r in regions] - min_durations = [xp.min(region_durations[r]) for r in regions] - max_durations = [xp.max(region_durations[r]) for r in regions] + avg_durations = [np.mean(region_durations[r]) for r in regions] + min_durations = [np.min(region_durations[r]) for r in regions] + max_durations = [np.max(region_durations[r]) for r in regions] + + # Error bars (min-max) yerr = [ [avg - min_ for avg, min_ in zip(avg_durations, min_durations)], [max_ - avg for avg, max_ in zip(avg_durations, max_durations)], ] - # Plot bar chart with error bars (min-max spans) + # ---- Plot ---- plt.figure(figsize=(12, 6)) - x = xp.arange(len(regions)) + x = np.arange(len(regions)) plt.bar(x, avg_durations, yerr=yerr, capsize=5, color="skyblue", edgecolor="k") plt.yscale("log") plt.xticks(x, regions, rotation=45, ha="right") @@ -153,213 +160,157 @@ def plot_avg_duration_bar_chart( plt.grid(True, linestyle="--", alpha=0.5) plt.tight_layout() - # Save the figure + # ---- Save ---- + os.makedirs(output_path, exist_ok=True) figure_path = os.path.join(output_path, "avg_duration_per_region.pdf") plt.savefig(figure_path) print(f"Saved average duration bar chart to: {figure_path}") def plot_gantt_chart_plotly( - path: str, + reader: ProfilingH5Reader, output_path: str, groups_include: list = ["*"], groups_skip: list = [], show: bool = False, ): - # print(f'Parsing {path}...') - with open(path, "rb") as file: - profiling_data = pickle.load(file) + """ + Plot an interactive Plotly Gantt chart using the new: + _region_dict[region][rank] = RegionData(start_times, end_times) + structure. - region_start_times = {} - for rank_data in profiling_data["rank_data"].values(): - for region_name, info in rank_data.items(): - first_start_time = xp.min(info["start_times"]) - if region_name not in region_start_times or first_start_time < region_start_times[region_name]: - region_start_times[region_name] = first_start_time + Each rank gets its own horizontal lane, only one y-axis label per region, + and regions are sorted by the earliest start time. + """ + + import os + + import numpy as np + import plotly.graph_objects as go + # ---- Compute earliest start time per region ---- + region_start_times = {} + for region_name, region in reader._region_dict.items(): + earliest = np.inf + for rank_data in region.values(): + if len(rank_data.start_times) > 0: + earliest = min(earliest, min(rank_data.start_times)) + region_start_times[region_name] = earliest + + # ---- Sort regions by earliest start time ---- region_names = sorted(region_start_times, key=region_start_times.get) - rank_names = list(profiling_data["rank_data"].keys()) - num_ranks = len(rank_names) + if len(region_names) == 0: + print("No regions found.") + return + + # ---- Determine number of ranks ---- + first_region = reader._region_dict[region_names[0]] + n_ranks = len(first_region.keys()) + rank_names = list(range(n_ranks)) + + # ---- Collect bars for Plotly ---- bars = [] + y_positions = [] - for region_idx, region_name in enumerate(region_names): + for i, region_name in enumerate(region_names): if not plot_region(region_name, groups_include, groups_skip): continue - for rank_idx, (rank_name, rank_data) in enumerate(profiling_data["rank_data"].items()): - if region_name in rank_data: - info = rank_data[region_name] - start_times = info["start_times"] - end_times = info["end_times"] - durations = end_times - start_times - - for i in range(len(start_times)): - bars.append( - dict( - Task=region_name, - Rank=rank_name, - Start=start_times[i], - Finish=end_times[i], - Duration=durations[i], - ), + region = reader._region_dict[region_name] + + for r in rank_names: + y = i * n_ranks + r + y_positions.append(y) + + if r not in region: + continue + + region_data = region[r] + starts = region_data.start_times + ends = region_data.end_times + durations = ends - starts + + for s, e, d in zip(starts, ends, durations): + bars.append( + dict( + y=y, + region=region_name, + rank=r, + start=float(s), + duration=float(d), ) + ) if len(bars) == 0: print("No regions matched the filter.") return - # Create a color map per rank - rank_color_map = {rank: f"hsl({360 * i / max(1, num_ranks)}, 70%, 50%)" for i, rank in enumerate(rank_names)} - - # Create plotly figure + # ---- Create Plotly figure ---- fig = go.Figure() + for bar in bars: + if "kernel" in bar["region"]: + color = "blue" + elif "prop" in bar["region"]: + color = "red" + else: + color = "black" + fig.add_trace( go.Bar( - x=[bar["Duration"]], - y=[bar["Task"]], - base=[bar["Start"]], + x=[bar["duration"]], + y=[bar["y"]], + base=[bar["start"]], orientation="h", - name=bar["Rank"], - marker_color=rank_color_map[bar["Rank"]], - hovertemplate=f"Rank: {bar['Rank']}
Start: {bar['Start']:.3f}s
Duration: {bar['Duration']:.3f}s", - ), + marker_color=color, + hovertemplate=( + f"Region: {bar['region']}
" + f"Rank: {bar['rank']}
" + f"Start: {bar['start']:.6f}s
" + f"Duration: {bar['duration']:.6f}s" + ), + showlegend=False, + ) ) + # ---- Label only first rank of each region ---- + yticks = [] + yticklabels = [] + for i, region_name in enumerate(region_names): + y_first_rank = i * n_ranks + yticks.append(y_first_rank) + yticklabels.append(region_name) + + # ---- Layout ---- fig.update_layout( - barmode="stack", - # title="Gantt Chart of Profiling Regions", - xaxis_title="Elapsed Time (s)", - yaxis_title="Profiling Regions", - height=600 + 20 * len(region_names), - # legend_title="MPI Ranks", - margin=dict(t=0, b=0, l=0, r=0), + barmode="overlay", + xaxis_title="Time (s)", + yaxis=dict( + tickmode="array", + tickvals=yticks, + ticktext=yticklabels, + ), + height=300 + len(y_positions) * 12, + margin=dict(t=10, b=10, l=10, r=10), showlegend=False, ) + # ---- Formatting helpers ---- mply.format_axes(fig) mply.format_font(fig) mply.format_grid(fig) mply.format_size(fig, width=1600, height=800) - # Save the plot as HTML - figure_path = os.path.join(output_path, "gantt_chart_plotly.html") - figure_path_pdf = os.path.join(output_path, "gantt_chart_plotly.pdf") + # ---- Save ---- + os.makedirs(output_path, exist_ok=True) + out_html = os.path.join(output_path, "gantt_chart_plotly.html") + fig.write_html(out_html) if show: fig.show() - fig.write_html(figure_path) - - # fig.write_image(figure_path_pdf) - print(f"Saved interactive gantt chart to: {figure_path}") - - -def plot_gantt_chart( - paths, - output_path, - groups_include=["*"], - groups_skip=[], - backend="matplotlib", -): - """ - Plot Gantt chart of profiling regions from all MPI ranks using a grouped bar plot, - where bars are grouped by region and stacked for different ranks, with each rank having a specific color. - - Parameters - ---------- - path: str - Path to the file where profiling data is saved. - """ - assert backend in ["matplotlib", "plotly"], "backend must be either matplotlib or plotly" - - if isinstance(paths, str): - paths = [paths] - - for path in paths: - # Load the profiling data from the specified path - with open(path, "rb") as file: - profiling_data = pickle.load(file) - - plt.figure(figsize=(12, 8)) - # color_map = matplotlib.cm.get_cmap('tab10') # Use 'tab10' colormap - color_map = plt.get_cmap("tab10") - - # Collect unique region names and their earliest start times - region_start_times = {} - for rank_data in profiling_data["rank_data"].values(): - for region_name, info in rank_data.items(): - first_start_time = xp.min(info["start_times"]) - if region_name not in region_start_times or first_start_time < region_start_times[region_name]: - region_start_times[region_name] = first_start_time - - # Sort region names by their earliest start time - region_names = sorted(region_start_times, key=region_start_times.get) - - # Generate a color map for each rank - rank_names = list(profiling_data["rank_data"].keys()) - num_ranks = len(rank_names) - - # Parameters for spacing - bar_height = 0.1 # Height of each bar for a rank - rank_spacing = 0.1 # Vertical spacing between bars for different ranks - region_spacing = 0.5 # Vertical spacing between groups for different regions - - y_positions = [] # To store y positions for labels - region_labels = [] # To store labels for the y-axis - current_y = 0 # Initial y position - - # Iterate through each region in the sorted order - for region_idx, region_name in enumerate(region_names): - if not plot_region(region_name=region_name, groups_include=groups_include, groups_skip=groups_skip): - continue - start_y = current_y # Starting y position for the first rank in this region - - # Plot bars for each rank for the current region - for rank_idx, (rank_name, rank_data) in enumerate(profiling_data["rank_data"].items()): - if region_name in rank_data: - info = rank_data[region_name] - start_times = info["start_times"] - end_times = info["end_times"] - durations = end_times - start_times - - # Calculate the y position for this bar - y_position = current_y + rank_idx * rank_spacing - # Plot each call as a bar with a specific color for the rank - plt.barh( - y=y_position, - width=durations, - left=start_times, - color=color_map(rank_idx / num_ranks), - alpha=0.6, - height=bar_height, - label=f"{rank_name}" if region_idx == 0 else None, - ) - - # Calculate the middle y position for this region's label - middle_y = start_y + (rank_idx * rank_spacing) / 2 - y_positions.append(middle_y) - region_labels.append(region_name) - - # Move to the next y position for the next region, adding region spacing - current_y += (rank_idx + 1) * rank_spacing + region_spacing - # Customize the plot - plt.xlim(left=-1) - plt.yticks(y_positions, region_labels) # Label the y-axis with region names - plt.xlabel("Elapsed time (s)") - plt.ylabel("Profiling Regions") - # plt.title("Gantt chart of profiling regions") - if num_ranks < 10: - plt.legend(title="MPI Ranks", loc="upper left") # Add legend for MPI ranks - plt.grid(visible=True, linestyle="--", alpha=0.5, axis="x") # Grid only on x-axis - plt.tight_layout() - # plt.show() - - # Save the plot as a PDF file - figure_path = os.path.join(output_path, "gantt_chart.pdf") - plt.savefig(figure_path) - print(f"Saved gantt chart to:{figure_path}") + print(f"Saved interactive gantt chart to: {out_html}") if __name__ == "__main__": @@ -405,8 +356,25 @@ def plot_gantt_chart( # path = os.path.abspath(args.path) # Convert to absolute path # simulations = parser.simulations - paths = [os.path.join(o_path, simulation, "profiling_time_trace.pkl") for simulation in args.simulations] + for simulation in args.simulations: + reader = ProfilingH5Reader(os.path.join(simulation, "profiling_data.h5")) - # Plot the time trace - plot_time_vs_duration(paths=paths, output_path=o_path, groups_include=args.groups, groups_skip=args.groups_skip) - plot_gantt_chart(paths=paths, output_path=o_path, groups_include=args.groups, groups_skip=args.groups_skip) + # Plot the time traces + plot_gantt_chart_plotly( + reader=reader, + output_path=o_path, + groups_include=args.groups, + groups_skip=args.groups_skip, + ) + plot_avg_duration_bar_chart( + reader=reader, + output_path=o_path, + groups_include=args.groups, + groups_skip=args.groups_skip, + ) + plot_time_vs_duration( + reader, + output_path=o_path, + groups_include=args.groups, + groups_skip=args.groups_skip, + ) diff --git a/src/struphy/profiling/__init__.py b/src/struphy/profiling/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/struphy/profiling/profiling.py b/src/struphy/profiling/profiling.py deleted file mode 100644 index e96749614..000000000 --- a/src/struphy/profiling/profiling.py +++ /dev/null @@ -1,347 +0,0 @@ -""" -profiling.py - -This module provides a centralized profiling configuration and management system -using LIKWID markers. It includes: -- A singleton class for managing profiling configuration. -- A context manager for profiling specific code regions. -- Initialization and cleanup functions for LIKWID markers. -- Convenience functions for setting and getting the profiling configuration. - -LIKWID is imported only when profiling is enabled to avoid unnecessary overhead. -""" - -import os -import pickle - -# Import the profiling configuration class and context manager -from functools import lru_cache - -import cunumpy as xp -from psydac.ddm.mpi import mpi as MPI - - -@lru_cache(maxsize=None) # Cache the import result to avoid repeated imports -def _import_pylikwid(): - import pylikwid - - return pylikwid - - -class ProfilingConfig: - """Singleton class for managing global profiling configuration.""" - - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance.likwid = False # Default value (profiling disabled) - cls._instance.simulation_label = "" - cls._instance.sample_duration = None - cls._instance.sample_interval = None - cls._instance.time_trace = False - return cls._instance - - @property - def likwid(self): - return self._likwid - - @likwid.setter - def likwid(self, value): - self._likwid = value - - @property - def simulation_label(self): - return self._simulation_label - - @simulation_label.setter - def simulation_label(self, value): - self._simulation_label = value - - @property - def sample_duration(self): - return self._sample_duration - - @sample_duration.setter - def sample_duration(self, value): - self._sample_duration = value - - @property - def sample_interval(self): - return self._sample_interval - - @sample_interval.setter - def sample_interval(self, value): - self._sample_interval = value - - @property - def time_trace(self): - return self._time_trace - - @time_trace.setter - def time_trace(self, value): - if value: - assert self.sample_interval is not None, "sample_interval must be set first!" - assert self.sample_duration is not None, "sample_duration must be set first!" - self._time_trace = value - - -class ProfileManager: - """ - Singleton class to manage and track all ProfileRegion instances. - """ - - _regions = {} - - @classmethod - def profile_region(cls, region_name): - """ - Get an existing ProfileRegion by name, or create a new one if it doesn't exist. - - Parameters - ---------- - region_name: str - The name of the profiling region. - - Returns - ------- - ProfileRegion: The ProfileRegion instance. - """ - if region_name in cls._regions: - return cls._regions[region_name] - else: - # Check if time profiling is enabled - _config = ProfilingConfig() - # Create and register a new ProfileRegion - new_region = ProfileRegion(region_name, time_trace=_config.time_trace) - cls._regions[region_name] = new_region - return new_region - - @classmethod - def get_region(cls, region_name): - """ - Get a registered ProfileRegion by name. - - Parameters - ---------- - region_name: str - The name of the profiling region. - - Returns - ------- - ProfileRegion or None: The registered ProfileRegion instance or None if not found. - """ - return cls._regions.get(region_name) - - @classmethod - def get_all_regions(cls): - """ - Get all registered ProfileRegion instances. - - Returns - ------- - dict: Dictionary of all registered ProfileRegion instances. - """ - return cls._regions - - @classmethod - def save_to_pickle(cls, file_path): - """ - Save profiling data to a single file using pickle and NumPy arrays in parallel. - - Parameters - ---------- - file_path: str - Path to the file where data will be saved. - """ - - _config = ProfilingConfig() - if not _config.time_trace: - print("time_trace is not set to True --> Time traces are not measured --> Skip saving...") - return - - comm = MPI.COMM_WORLD - rank = comm.Get_rank() - # size = comm.Get_size() - - # Prepare the data to be gathered - local_data = {} - for name, region in cls._regions.items(): - local_data[name] = { - "ncalls": region.ncalls, - "durations": xp.array(region.durations, dtype=xp.float64), - "start_times": xp.array(region.start_times, dtype=xp.float64), - "end_times": xp.array(region.end_times, dtype=xp.float64), - "config": { - "likwid": region.config.likwid, - "simulation_label": region.config.simulation_label, - "sample_duration": region.config.sample_duration, - "sample_interval": region.config.sample_interval, - }, - } - - # Gather all data at the root process (rank 0) - all_data = comm.gather(local_data, root=0) - - # Save the likwid configuration data - likwid_data = {} - if ProfilingConfig().likwid: - pylikwid = _import_pylikwid() - - # Gather LIKWID-specific information - pylikwid.inittopology() - likwid_data["cpu_info"] = pylikwid.getcpuinfo() - likwid_data["cpu_topology"] = pylikwid.getcputopology() - pylikwid.finalizetopology() - - likwid_data["numa_info"] = pylikwid.initnuma() - pylikwid.finalizenuma() - - likwid_data["affinity_info"] = pylikwid.initaffinity() - pylikwid.finalizeaffinity() - - pylikwid.initconfiguration() - likwid_data["configuration"] = pylikwid.getconfiguration() - pylikwid.destroyconfiguration() - - likwid_data["groups"] = pylikwid.getgroups() - - if rank == 0: - # Combine the data from all processes - combined_data = { - "config": None, - "rank_data": {f"rank_{i}": data for i, data in enumerate(all_data)}, - } - - # Add the likwid data - if likwid_data: - combined_data["config"] = likwid_data - - # Convert the file path to an absolute path - absolute_path = os.path.abspath(file_path) - - # Save the combined data using pickle - with open(absolute_path, "wb") as file: - pickle.dump(combined_data, file) - - print(f"Data saved to {absolute_path}") - - @classmethod - def print_summary(cls): - """ - Print a summary of the profiling data for all regions. - """ - - _config = ProfilingConfig() - if not _config.time_trace: - print("time_trace is not set to True --> Time traces are not measured --> Skip printing summary...") - return - - print("Profiling Summary:") - print("=" * 40) - for name, region in cls._regions.items(): - if region.ncalls > 0: - total_duration = sum(region.durations) - average_duration = total_duration / region.ncalls - min_duration = min(region.durations) - max_duration = max(region.durations) - std_duration = xp.std(region.durations) - else: - total_duration = average_duration = min_duration = max_duration = std_duration = 0 - - print(f"Region: {name}") - print(f" Number of Calls: {region.ncalls}") - print(f" Total Duration: {total_duration:.6f} seconds") - print(f" Average Duration: {average_duration:.6f} seconds") - print(f" Min Duration: {min_duration:.6f} seconds") - print(f" Max Duration: {max_duration:.6f} seconds") - print(f" Std Deviation: {std_duration:.6f} seconds") - print("-" * 40) - - -class ProfileRegion: - """Context manager for profiling specific code regions using LIKWID markers.""" - - def __init__(self, region_name, time_trace=False): - if hasattr(self, "_initialized") and self._initialized: - return - self._config = ProfilingConfig() - self._region_name = self.config.simulation_label + region_name - self._time_trace = time_trace - self._ncalls = 0 - self._start_times = xp.empty(1, dtype=float) - self._end_times = xp.empty(1, dtype=float) - self._durations = xp.empty(1, dtype=float) - self._started = False - - def __enter__(self): - if self._ncalls == len(self._start_times): - self._start_times = xp.append(self._start_times, xp.zeros_like(self._start_times)) - self._end_times = xp.append(self._end_times, xp.zeros_like(self._end_times)) - self._durations = xp.append(self._durations, xp.zeros_like(self._durations)) - - if self.config.likwid: - self._pylikwid().markerstartregion(self.region_name) - - if self._time_trace: - self._start_time = MPI.Wtime() - if self._start_time % self.config.sample_interval < self.config.sample_duration or self._ncalls == 0: - self._start_times[self._ncalls] = self._start_time - self._started = True - - self._ncalls += 1 - - return self - - def __exit__(self, exc_type, exc_value, traceback): - if self.config.likwid: - self._pylikwid().markerstopregion(self.region_name) - if self._time_trace and self.started: - end_time = MPI.Wtime() - self._end_times[self._ncalls - 1] = end_time - self._durations[self._ncalls - 1] = end_time - self._start_time - self._started = False - - def _pylikwid(self): - return _import_pylikwid() - - @property - def config(self): - return self._config - - @property - def durations(self): - return self._durations - - @property - def end_times(self): - return self._end_times - - @property - def ncalls(self): - return self._ncalls - - @property - def region_name(self): - return self._region_name - - @property - def start_times(self): - return self._start_times - - @property - def started(self): - return self._started - - -def pylikwid_markerinit(): - """Initialize LIKWID profiling markers.""" - if ProfilingConfig().likwid: - _import_pylikwid().markerinit() - - -def pylikwid_markerclose(): - """Close LIKWID profiling markers.""" - if ProfilingConfig().likwid: - _import_pylikwid().markerclose()