diff --git a/Lib/profiling/sampling/__init__.py b/Lib/profiling/sampling/__init__.py index b493c6aa7eb06d..ff020dfc5ef02b 100644 --- a/Lib/profiling/sampling/__init__.py +++ b/Lib/profiling/sampling/__init__.py @@ -8,6 +8,7 @@ from .pstats_collector import PstatsCollector from .stack_collector import CollapsedStackCollector from .gecko_collector import GeckoCollector +from .heatmap_collector import HeatmapCollector from .string_table import StringTable -__all__ = ("Collector", "PstatsCollector", "CollapsedStackCollector", "GeckoCollector", "StringTable") +__all__ = ("Collector", "PstatsCollector", "CollapsedStackCollector", "GeckoCollector", "HeatmapCollector", "StringTable") diff --git a/Lib/profiling/sampling/heatmap_collector.py b/Lib/profiling/sampling/heatmap_collector.py new file mode 100644 index 00000000000000..87be3f444cf634 --- /dev/null +++ b/Lib/profiling/sampling/heatmap_collector.py @@ -0,0 +1,360 @@ +"""Heatmap collector for line-by-line sample intensity visualization.""" + +from collections import defaultdict +from pathlib import Path +from .collector import Collector + +class HeatmapCollector(Collector): + """Collect line-level sample counts for heatmap visualization.""" + + def __init__(self, skip_idle=False): + self.skip_idle = skip_idle + # file_path -> line_number -> sample_count + self.line_samples = defaultdict(lambda: defaultdict(int)) + # file_path -> set of function names seen + self.file_functions = defaultdict(set) + + def collect(self, stack_frames): + """Collect line-level sample counts from stack frames.""" + for frames, thread_id in self._iter_all_frames(stack_frames, skip_idle=self.skip_idle): + for frame in frames: + filename = frame.file_name + lineno = frame.line_number + function = frame.function_name + + # Skip internal/system files + if filename.startswith('<') or filename.startswith('frozen '): + continue + + self.line_samples[filename][lineno] += 1 + self.file_functions[filename].add(function) + + def export(self, filename): + """Export heatmap data as HTML with color-coded source code.""" + if not self.line_samples: + print("No samples were collected for heatmap.") + return + + html = self._generate_html() + + with open(filename, 'w', encoding='utf-8') as f: + f.write(html) + + print(f"Heatmap saved to {filename}") + + def _generate_html(self): + """Generate HTML heatmap visualization.""" + # Calculate global max for color scaling + global_max = max( + max(line_counts.values()) + for line_counts in self.line_samples.values() + ) + + # Sort files by total samples + files_by_samples = sorted( + self.line_samples.items(), + key=lambda x: sum(x[1].values()), + reverse=True + ) + + # Generate HTML + html_parts = [self._html_header()] + + # Add file sections + for file_idx, (filepath, line_counts) in enumerate(files_by_samples): + total_samples = sum(line_counts.values()) + html_parts.append(self._render_file_section( + filepath, line_counts, total_samples, global_max, file_idx + )) + + html_parts.append(self._html_footer()) + + return '\n'.join(html_parts) + + def _html_header(self): + """Generate HTML header with styles.""" + return ''' + + + + + Profile Heatmap + + + + +

🔥 Profile Heatmap

+
+

Color Legend

+

Lines are colored based on sample intensity (number of times observed during profiling).

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cold (few samples) + Hot (many samples) +
+

Tip: Click on file headers to expand/collapse sections.

+
+''' + + def _render_file_section(self, filepath, line_counts, total_samples, global_max, file_idx): + """Render a single file section with heatmap.""" + try: + with open(filepath, 'r', encoding='utf-8') as f: + source_lines = f.readlines() + except (FileNotFoundError, IOError, UnicodeDecodeError): + source_lines = [""] + + # Calculate percentages and heat levels + max_samples = max(line_counts.values()) + + # Build table rows + rows = [] + for lineno, line in enumerate(source_lines, start=1): + samples = line_counts.get(lineno, 0) + + # Calculate heat level (0-15) + if samples == 0: + heat_class = "heat-0 no-samples" + else: + # Log scale for better visualization + import math + normalized = math.log(samples + 1) / math.log(global_max + 1) + heat_level = min(15, int(normalized * 15) + 1) + heat_class = f"heat-{heat_level}" + + # Escape HTML in source code + line_html = line.rstrip('\n').replace('&', '&').replace('<', '<').replace('>', '>') + if not line_html: + line_html = ' ' # Preserve empty lines + + sample_display = str(samples) if samples > 0 else '' + + rows.append(f''' + {lineno} + {sample_display} + {line_html} + ''') + + # Get relative path for display + try: + display_path = str(Path(filepath).resolve()) + except: + display_path = filepath + + percentage = (total_samples / sum(sum(lc.values()) for lc in self.line_samples.values())) * 100 + + return f'''
+
+ + â–¼ + {display_path} + + {total_samples} samples ({percentage:.1f}%) +
+
+ +{''.join(rows)} +
+
+
''' + + def _html_footer(self): + """Generate HTML footer.""" + return ''' +''' + diff --git a/Lib/profiling/sampling/sample.py b/Lib/profiling/sampling/sample.py index 7a0f739a5428c6..ad05abff73e0cb 100644 --- a/Lib/profiling/sampling/sample.py +++ b/Lib/profiling/sampling/sample.py @@ -14,6 +14,7 @@ from .pstats_collector import PstatsCollector from .stack_collector import CollapsedStackCollector, FlamegraphCollector from .gecko_collector import GeckoCollector +from .heatmap_collector import HeatmapCollector _FREE_THREADED_BUILD = sysconfig.get_config_var("Py_GIL_DISABLED") is not None @@ -41,6 +42,7 @@ def _parse_mode(mode_string): - --pstats: Detailed profiling statistics with sorting options - --collapsed: Stack traces for generating flamegraphs - --flamegraph Interactive HTML flamegraph visualization (requires web browser) + - --heatmap: Line-by-line heatmap showing sample intensity (hot spots) Examples: # Profile process 1234 for 10 seconds with default settings @@ -635,6 +637,9 @@ def sample( case "gecko": collector = GeckoCollector(skip_idle=skip_idle) filename = filename or f"gecko.{pid}.json" + case "heatmap": + collector = HeatmapCollector(skip_idle=skip_idle) + filename = filename or f"heatmap.{pid}.html" case _: raise ValueError(f"Invalid output format: {output_format}") @@ -676,10 +681,13 @@ def _validate_collapsed_format_args(args, parser): f"The following options are only valid with --pstats format: {', '.join(invalid_opts)}" ) - # Set default output filename for collapsed format only if we have a PID + # Set default output filename for non-pstats formats only if we have a PID # For module/script execution, this will be set later with the subprocess PID if not args.outfile and args.pid is not None: - args.outfile = f"collapsed.{args.pid}.txt" + if args.format == "collapsed": + args.outfile = f"collapsed.{args.pid}.txt" + elif args.format == "heatmap": + args.outfile = f"heatmap.{args.pid}.html" def wait_for_process_and_sample(pid, sort_value, args): @@ -691,6 +699,8 @@ def wait_for_process_and_sample(pid, sort_value, args): filename = f"collapsed.{pid}.txt" elif args.format == "gecko": filename = f"gecko.{pid}.json" + elif args.format == "heatmap": + filename = f"heatmap.{pid}.html" mode = _parse_mode(args.mode) @@ -801,6 +811,13 @@ def main(): dest="format", help="Generate Gecko format for Firefox Profiler", ) + output_format.add_argument( + "--heatmap", + action="store_const", + const="heatmap", + dest="format", + help="Generate HTML heatmap with line-by-line sample intensity", + ) output_group.add_argument( "-o", @@ -879,7 +896,7 @@ def main(): args = parser.parse_args() # Validate format-specific arguments - if args.format in ("collapsed", "gecko"): + if args.format in ("collapsed", "gecko", "heatmap"): _validate_collapsed_format_args(args, parser) sort_value = args.sort if args.sort is not None else 2 diff --git a/Misc/NEWS.d/next/Library/2025-10-27-16-53-04.gh-issue-140677.H3rV3s.rst b/Misc/NEWS.d/next/Library/2025-10-27-16-53-04.gh-issue-140677.H3rV3s.rst new file mode 100644 index 00000000000000..bd3e29b606d718 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-10-27-16-53-04.gh-issue-140677.H3rV3s.rst @@ -0,0 +1 @@ +Add :option:`--heatmap` output format to :mod:`profiling.sampling` that generates an HTML visualization showing line-by-line sample intensity with color coding from blue (cold) to red (hot).