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''' '''
+
+ 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).