nsight-python 0.9.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,470 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """
5
+ Visualization utilities for Nsight Python profiling and tensor difference analysis.
6
+
7
+ This module provides:
8
+ - Plotting functions for profiling results with configurable layout and annotation.
9
+ """
10
+ from collections.abc import Callable, Sequence
11
+ from typing import Any
12
+
13
+ import matplotlib
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from nsight import exceptions, utils
19
+
20
+
21
+ def visualize(
22
+ agg_df: str | pd.DataFrame,
23
+ row_panels: Sequence[str] | None,
24
+ col_panels: Sequence[str] | None,
25
+ x_keys: Sequence[str] | None = None,
26
+ print_data: bool = False,
27
+ title: str = "",
28
+ filename: str = "plot.png",
29
+ ylabel: str = "",
30
+ annotate_points: bool = True,
31
+ show_avg: bool = True,
32
+ plot_type: str = "line",
33
+ plot_width: int = 6,
34
+ plot_height: int = 4,
35
+ show_geomean: bool = True,
36
+ show_grid: bool = True,
37
+ variant_fields: Sequence[Any] | None = None,
38
+ variant_annotations: Sequence[Any] | None = None,
39
+ plot_callback: Callable[[matplotlib.figure.Figure], None] | None = None,
40
+ ) -> pd.DataFrame:
41
+ """
42
+ Plots profiling results using line or bar plots in a subplot grid.
43
+
44
+ Args:
45
+ agg_df: Aggregated profiling data or path to CSV file.
46
+ row_panels: List of fields for whose unique values
47
+ to create a new subplot along the vertical axis.
48
+ col_panels: List of fields for whose unique values
49
+ to create a new subplot along the horizontal axis.
50
+ x_keys: List of fields to use for the x-axis. By
51
+ default, we use all parameters of the decorated function except those
52
+ specified in `row_panels` and `col_panels`.
53
+ print_data: Whether to print aggregated profiling data to stdout.
54
+ title: Main plot title.
55
+ filename: Output filename for the saved plot.
56
+ ylabel: Label for the y-axis (typically the metric name).
57
+ annotate_points: Whether to annotate data points with values.
58
+ show_avg: Whether to add an "Avg" column with average metric values.
59
+ plot_type: Type of plot: "line" or "bar".
60
+ show_geomean: Whether to show geometric mean values.
61
+ show_grid: Whether to display grid lines on the plot.
62
+ variant_fields: List of config fields to use as variant fields (lines).
63
+ variant_annotations: List of annotated range names for which to apply variant splitting. The provided strings must each match one of the names defined using nsight.annotate.
64
+
65
+ """
66
+ if isinstance(agg_df, str):
67
+ agg_df = pd.read_csv(agg_df)
68
+ assert isinstance(
69
+ agg_df, pd.DataFrame
70
+ ), f"agg_df must be a pandas DataFrame or a CSV file path, not {type(agg_df)}"
71
+
72
+ row_panels = row_panels or []
73
+ col_panels = col_panels or []
74
+
75
+ # --- Annotation Variants Expansion ---
76
+ if variant_fields and variant_annotations:
77
+ # Remove variant_fields from Configuration for all annotations
78
+ config_exclude = set(variant_fields)
79
+ else:
80
+ config_exclude = set()
81
+
82
+ # Build Configuration field excluding variant_fields
83
+ annotation_idx = agg_df.columns.get_loc("AvgValue")
84
+ func_fields = list(agg_df.columns[1:annotation_idx])
85
+ subplot_fields = row_panels + col_panels # type: ignore[operator]
86
+ non_panel_fields = [
87
+ field
88
+ for field in func_fields
89
+ if field not in subplot_fields and field not in config_exclude
90
+ ]
91
+
92
+ agg_df["Configuration"] = agg_df[non_panel_fields].apply(
93
+ lambda row: ", ".join(
94
+ f"{field_name}={value}" for field_name, value in row.items()
95
+ ),
96
+ axis=1,
97
+ )
98
+
99
+ # Expand variant annotations into separate lines, but keep x-ticks shared
100
+ if variant_fields and variant_annotations:
101
+ df = agg_df.copy()
102
+ new_rows = []
103
+ for annotation in variant_annotations:
104
+ annotation_mask = df["Annotation"] == annotation
105
+ if not annotation_mask.any():
106
+ continue
107
+ annotation_df = df[annotation_mask]
108
+ # For each unique combination of variant_fields fields
109
+ unique_combos = annotation_df[variant_fields].drop_duplicates()
110
+ for _, combo in unique_combos.iterrows():
111
+ combo_mask = (annotation_df[variant_fields] == combo.values).all(axis=1)
112
+ combo_df = annotation_df[combo_mask].copy()
113
+ # Create new annotation label
114
+ variant_label = (
115
+ annotation
116
+ + " "
117
+ + ", ".join(
118
+ f"{k}={v}" for k, v in zip(variant_fields, combo.values)
119
+ )
120
+ )
121
+ combo_df["Annotation"] = variant_label
122
+ new_rows.append(combo_df)
123
+ # Remove the original annotation rows
124
+ df = df[~annotation_mask]
125
+ # Add all new variant rows
126
+ if new_rows:
127
+ df = pd.concat([df] + new_rows, ignore_index=True)
128
+ agg_df = df
129
+
130
+ # --- End Annotation Variants Expansion ---
131
+
132
+ gpu_model = agg_df["GPU"].unique()[0]
133
+ host = agg_df["Host"].unique()[0]
134
+ hw_info_subtitle = f"{gpu_model}, {host}"
135
+ title_with_hardware_info = f"{title}\n{hw_info_subtitle}"
136
+
137
+ # Ensure that all fields in x_keys are present in non_panel_fields
138
+ if x_keys:
139
+ for field in x_keys:
140
+ if field not in non_panel_fields:
141
+ raise exceptions.ProfilerException(
142
+ f"Field '{field}' is not present in the DataFrame. "
143
+ f"Available fields: {non_panel_fields}"
144
+ )
145
+
146
+ unique_rows = (
147
+ agg_df[row_panels].drop_duplicates()
148
+ if row_panels
149
+ else pd.DataFrame({"dummy": [0]})
150
+ )
151
+ unique_cols = (
152
+ agg_df[col_panels].drop_duplicates()
153
+ if col_panels
154
+ else pd.DataFrame({"dummy": [0]})
155
+ )
156
+
157
+ nrows, ncols = max(len(unique_rows), 1), max(len(unique_cols), 1)
158
+ # --- Ensure each subplot has a minimum size ---
159
+ min_width_per_subplot = plot_width # inches
160
+ min_height_per_subplot = plot_height # inches
161
+ fig_width = max(min_width_per_subplot * ncols, 8)
162
+ fig_height = max(min_height_per_subplot * nrows, 6)
163
+ fig, axes = plt.subplots(
164
+ nrows,
165
+ ncols,
166
+ figsize=(fig_width, fig_height),
167
+ constrained_layout=True, # Use constrained layout
168
+ )
169
+
170
+ if nrows == 1:
171
+ axes = np.expand_dims(axes, axis=0)
172
+ if ncols == 1:
173
+ axes = np.expand_dims(axes, axis=1)
174
+
175
+ for row_idx, (_, row_values) in enumerate(unique_rows.iterrows()):
176
+ for col_idx, (_, col_values) in enumerate(unique_cols.iterrows()):
177
+ ax = axes[row_idx, col_idx]
178
+ subplot_df = agg_df
179
+ if row_panels:
180
+ subplot_df = subplot_df[
181
+ (subplot_df[row_panels] == row_values).all(axis=1)
182
+ ]
183
+ if col_panels:
184
+ subplot_df = subplot_df[
185
+ (subplot_df[col_panels] == col_values).all(axis=1)
186
+ ]
187
+
188
+ local_df = subplot_df.copy()
189
+
190
+ # --- x_keys validation and Configuration building ---
191
+ used_fields = (
192
+ set(row_panels or [])
193
+ | set(col_panels or [])
194
+ | set(variant_fields or [])
195
+ )
196
+ if x_keys:
197
+ overlap = used_fields & set(x_keys)
198
+ if overlap:
199
+ raise ValueError(
200
+ f"x_keys cannot contain fields used in row_panels, col_panels, or variant_fields: {overlap}"
201
+ )
202
+ config_fields = x_keys
203
+ else:
204
+ annotation_idx = local_df.columns.get_loc("AvgValue")
205
+ func_fields = list(local_df.columns[1:annotation_idx])
206
+ subplot_fields = row_panels + col_panels # type: ignore[operator]
207
+ config_exclude = set(variant_fields or [])
208
+ config_fields = [
209
+ field
210
+ for field in func_fields
211
+ if field not in subplot_fields and field not in config_exclude
212
+ ]
213
+ local_df["Configuration"] = local_df[config_fields].apply(
214
+ lambda row: ", ".join(
215
+ f"{field_name}={value}" for field_name, value in row.items()
216
+ ),
217
+ axis=1,
218
+ )
219
+ # --- End x_keys validation and Configuration building ---
220
+
221
+ # --- Annotation Variants logic per subplot ---
222
+ if variant_fields and variant_annotations:
223
+ df = local_df.copy()
224
+ new_rows = []
225
+ for annotation in variant_annotations:
226
+ annotation_mask = df["Annotation"] == annotation
227
+ if not annotation_mask.any():
228
+ continue
229
+ annotation_df = df[annotation_mask]
230
+ unique_combos = annotation_df[variant_fields].drop_duplicates()
231
+ for _, combo in unique_combos.iterrows():
232
+ combo_mask = (
233
+ annotation_df[variant_fields] == combo.values
234
+ ).all(axis=1)
235
+ combo_df = annotation_df[combo_mask].copy()
236
+ variant_label = (
237
+ annotation
238
+ + " "
239
+ + ", ".join(
240
+ f"{k}={v}" for k, v in zip(variant_fields, combo.values)
241
+ )
242
+ )
243
+ combo_df["Annotation"] = variant_label
244
+ new_rows.append(combo_df)
245
+ df = df[~annotation_mask]
246
+ if new_rows:
247
+ df = pd.concat([df] + new_rows, ignore_index=True)
248
+ local_df = df
249
+ # --- End Annotation Variants logic per subplot ---
250
+
251
+ annotations = local_df["Annotation"].unique()
252
+ nvidia_colors = [
253
+ "#76B900", # Green
254
+ "#0070C5", # Blue
255
+ "#5C1682", # Purple
256
+ "#890C57", # Red
257
+ "#FAC200", # Yellow
258
+ "#008564", # Dark Green
259
+ "#FF5733", # Orange
260
+ "#C70039", # Crimson
261
+ "#900C3F", # Dark Red
262
+ "#581845", # Dark Purple
263
+ "#1F618D", # Dark Blue
264
+ "#28B463", # Light Green
265
+ "#F39C12", # Bright Yellow
266
+ "#D35400", # Dark Orange
267
+ ]
268
+
269
+ unique_configs = local_df["Configuration"].dropna().unique()
270
+
271
+ x_ticks = np.arange(len(unique_configs))
272
+ n_annotations = len(annotations)
273
+ width = (
274
+ 0.8 / n_annotations if n_annotations > 0 else 0.2
275
+ ) # Adjust width for grouped bars
276
+ avg_values = {}
277
+
278
+ for i, (annotation, color) in enumerate(zip(annotations, nvidia_colors)):
279
+ annotation_data = local_df[local_df["Annotation"] == annotation]
280
+ # Keep annotation_data in original order (no sorting)
281
+
282
+ # Compute valid average (for show_avg only)
283
+ valid_values = annotation_data["AvgValue"].dropna()
284
+ avg_value = valid_values.mean() if not valid_values.empty else np.nan
285
+ avg_values[annotation] = avg_value
286
+
287
+ # Map annotation_data to x positions (may be multiple points per x-tick)
288
+ x_pos_map = {label: idx for idx, label in enumerate(unique_configs)}
289
+ x_positions = annotation_data["Configuration"].map(x_pos_map)
290
+
291
+ # Adjust x positions for grouped bars
292
+ if plot_type == "bar" and n_annotations > 1:
293
+ # Center the group of bars around each x-tick
294
+ x_offset = (i - (n_annotations - 1) / 2) * width
295
+ x_positions = x_positions + x_offset
296
+
297
+ if plot_type == "line":
298
+ ax.plot(
299
+ annotation_data["Configuration"].astype(str),
300
+ annotation_data["AvgValue"],
301
+ marker="o",
302
+ label=annotation,
303
+ color=color,
304
+ )
305
+ elif plot_type == "bar":
306
+ ax.bar(
307
+ x_positions,
308
+ annotation_data["AvgValue"],
309
+ width=width,
310
+ label=annotation,
311
+ color=color,
312
+ )
313
+
314
+ # Annotate each point with its value (formatted to 2 decimal places)
315
+ if annotate_points:
316
+ for x_pos, y in zip(x_positions, annotation_data["AvgValue"]):
317
+ ax.text(
318
+ x_pos,
319
+ y + (0.02 if plot_type == "line" else 0.03),
320
+ f"{y:.2f}",
321
+ fontsize=8,
322
+ color=color,
323
+ ha="center",
324
+ )
325
+
326
+ if show_avg:
327
+ # Add vertical separation line for average values
328
+ sep_index = len(unique_configs)
329
+ ax.axvline(
330
+ x=sep_index - 0.5,
331
+ color="black",
332
+ linestyle="dashed",
333
+ linewidth=1,
334
+ )
335
+
336
+ for i, (annotation, color) in enumerate(
337
+ zip(annotations, nvidia_colors)
338
+ ):
339
+ avg_value = avg_values.get(annotation, np.nan)
340
+ if not np.isnan(avg_value): # Only plot if valid
341
+ avg_x_pos = sep_index + (i - (n_annotations - 1) / 2) * width
342
+ # Plot absolute value as bar on primary axis
343
+ ax.bar(avg_x_pos, avg_value, width=width, color=color, zorder=3)
344
+ # Always annotate the average values after the bar, with higher zorder
345
+ ax.text(
346
+ avg_x_pos,
347
+ avg_value + 0.02,
348
+ f"{avg_value:.2f}",
349
+ fontsize=9,
350
+ color=color,
351
+ ha="center",
352
+ fontweight="bold",
353
+ zorder=4, # Ensure text is above the bar
354
+ )
355
+
356
+ if show_geomean:
357
+ # Add vertical separation line for geomean values
358
+ sep_index = len(unique_configs) + (1 if show_avg else 0)
359
+ ax.axvline(
360
+ x=sep_index - 0.5,
361
+ color="black",
362
+ linestyle="dashed",
363
+ linewidth=1,
364
+ )
365
+
366
+ for i, (annotation, color) in enumerate(
367
+ zip(annotations, nvidia_colors)
368
+ ):
369
+ geomean = agg_df[agg_df["Annotation"] == annotation][
370
+ "Geomean"
371
+ ].iloc[0]
372
+ if not np.isnan(geomean):
373
+ geomean_x_pos = (
374
+ sep_index + (i - (n_annotations - 1) / 2) * width
375
+ )
376
+ ax.bar(
377
+ geomean_x_pos, geomean, width=width, color=color, zorder=3
378
+ )
379
+ ax.text(
380
+ geomean_x_pos,
381
+ geomean + 0.02,
382
+ f"{geomean:.2f}",
383
+ fontsize=9,
384
+ color=color,
385
+ ha="center",
386
+ fontweight="bold",
387
+ zorder=4, # Ensure text is above the bar
388
+ )
389
+
390
+ # Ensure all x-axis labels are strings
391
+ x_labels = list(map(str, unique_configs))
392
+ if show_avg:
393
+ x_labels.append("Avg")
394
+ if show_geomean:
395
+ x_labels.append("Geomean")
396
+
397
+ ax.set_xticks(np.arange(len(x_labels)))
398
+ ax.set_xticklabels(
399
+ x_labels,
400
+ ha="right", # Align to the right to prevent overlap
401
+ rotation=45, # Rotate for better readability
402
+ fontsize=9, # Reduce font size slightly
403
+ )
404
+
405
+ ax.set_ylim(0, max(agg_df["AvgValue"].max(skipna=True) * 1.1, 1))
406
+
407
+ # Add grid
408
+ if show_grid:
409
+ ax.grid(True, linestyle="--", alpha=0.7)
410
+ ax.set_axisbelow(True) # Put grid behind the plot elements
411
+
412
+ ylabel = ylabel or agg_df["Metric"].unique()[0]
413
+ if col_idx == 0:
414
+ ax.set_ylabel(f"{ylabel} (avg: {agg_df['NumRuns'].max()} runs)")
415
+
416
+ # Generate combined subplot title with both row and col fields
417
+ row_label = "\n".join(
418
+ f"{field}={row_values[field]}" for field in row_panels
419
+ )
420
+ col_label = "\n".join(
421
+ f"{field}={col_values[field]}" for field in col_panels
422
+ )
423
+ full_title = (
424
+ f"{row_label}\n{col_label}"
425
+ if row_label and col_label
426
+ else row_label or col_label
427
+ )
428
+ # Count the number of linebreaks to adjust padding
429
+ num_lines = full_title.count("\n") + 1 if full_title else 1
430
+ ax.set_title(full_title, pad=18 + 6 * (num_lines - 1))
431
+
432
+ # Use 'best' legend location for each axis
433
+ for ax in axes.flat:
434
+ ax.legend(
435
+ title="Annotation",
436
+ loc="best", # Let matplotlib pick the best spot
437
+ fontsize=9,
438
+ title_fontsize=10,
439
+ )
440
+
441
+ # Set subplot titles with smaller font and more padding
442
+ n_axes = len(axes.flat)
443
+ for i, ax in enumerate(axes.flat):
444
+ ax.set_title(ax.get_title(), fontsize=11, pad=18)
445
+ # Only show x-axis labels and tick labels on the bottom row
446
+ if nrows > 1:
447
+ # If this axis is not in the last row, hide x labels
448
+ if i < (nrows - 1) * ncols:
449
+ ax.set_xlabel("")
450
+ ax.set_xticklabels([])
451
+ else:
452
+ # If only one row, show all x labels
453
+ pass
454
+
455
+ # Set the main title, move it up a bit
456
+ fig.suptitle(title_with_hardware_info, fontsize=14, fontweight="bold", y=1.08)
457
+
458
+ if plot_callback:
459
+ plot_callback(fig)
460
+
461
+ # Save with tight bounding box to avoid clipping
462
+ fig.savefig(filename, bbox_inches="tight")
463
+
464
+ if print_data:
465
+ agg_df["AvgValue"] = agg_df["AvgValue"].map("{:.4f}".format)
466
+ print("Aggregated Data (Average value and Number of Runs):")
467
+ print(agg_df.to_string(index=False))
468
+
469
+ plt.close()
470
+ return agg_df