Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Lib/profiling/sampling/_format_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import locale


def fmt(value: int | float, decimals: int = 1) -> str:
return locale.format_string(f'%.{decimals}f', value, grouping=True)
4 changes: 3 additions & 1 deletion Lib/profiling/sampling/_heatmap_assets/heatmap.js
Original file line number Diff line number Diff line change
Expand Up @@ -598,10 +598,12 @@ function populateBytecodePanel(panel, button) {
else if (specPct >= 33) specClass = 'medium';

// Build specialization summary
const instruction_word = instructions.length === 1 ? 'instruction' : 'instructions';
const sample_word = totalSamples === 1 ? 'sample' : 'samples';
let html = `<div class="bytecode-spec-summary ${specClass}">
<span class="spec-pct">${specPct}%</span>
<span class="spec-label">specialized</span>
<span class="spec-detail">(${specializedCount}/${instructions.length} instructions, ${specializedSamples.toLocaleString()}/${totalSamples.toLocaleString()} samples)</span>
<span class="spec-detail">(${specializedCount}/${instructions.length} ${instruction_word}, ${specializedSamples.toLocaleString()}/${totalSamples.toLocaleString()} ${sample_word})</span>
</div>`;

html += '<div class="bytecode-header">' +
Expand Down
11 changes: 11 additions & 0 deletions Lib/profiling/sampling/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import argparse
import importlib.util
import locale
import os
import selectors
import socket
Expand Down Expand Up @@ -634,6 +635,16 @@ def _validate_args(args, parser):

def main():
"""Main entry point for the CLI."""
# Set locale for number formatting, restore on exit
old_locale = locale.setlocale(locale.LC_ALL, None)
locale.setlocale(locale.LC_ALL, "")
try:
_main()
finally:
locale.setlocale(locale.LC_ALL, old_locale)


def _main():
# Create the main parser
parser = argparse.ArgumentParser(
description=_HELP_DESCRIPTION,
Expand Down
46 changes: 25 additions & 21 deletions Lib/profiling/sampling/heatmap_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import html
import importlib.resources
import json
import locale
import math
import os
import platform
Expand All @@ -15,6 +16,7 @@
from typing import Dict, List, Tuple

from ._css_utils import get_combined_css
from ._format_utils import fmt
from .collector import normalize_location, extract_lineno
from .stack_collector import StackTraceCollector

Expand Down Expand Up @@ -343,7 +345,7 @@ def render_hierarchical_html(self, trees: Dict[str, TreeNode]) -> str:
<div class="type-header" onclick="toggleTypeSection(this)">
<span class="type-icon">{icon}</span>
<span class="type-title">{type_names[module_type]}</span>
<span class="type-stats">({tree.count} {file_word}, {tree.samples:,} {sample_word})</span>
<span class="type-stats">({tree.count} {file_word}, {tree.samples:n} {sample_word})</span>
</div>
<div class="type-content"{content_style}>
'''
Expand Down Expand Up @@ -390,7 +392,7 @@ def _render_folder(self, node: TreeNode, name: str, level: int = 1) -> str:
parts.append(f'{indent} <span class="folder-icon">▶</span>')
parts.append(f'{indent} <span class="folder-name">📁 {html.escape(name)}</span>')
parts.append(f'{indent} <span class="folder-stats">'
f'({node.count} {file_word}, {node.samples:,} {sample_word})</span>')
f'({node.count} {file_word}, {node.samples:n} {sample_word})</span>')
parts.append(f'{indent} </div>')
parts.append(f'{indent} <div class="folder-content" style="display: none;">')

Expand Down Expand Up @@ -431,10 +433,11 @@ def _render_file_item(self, stat: FileStats, indent: str = '') -> str:
bar_width = min(stat.percentage, 100)

html_file = self.file_index[stat.filename]
s = "" if stat.total_samples == 1 else "s"

return (f'{indent}<div class="file-item">\n'
f'{indent} <a href="{html_file}" class="file-link" title="{full_path}">📄 {module_name}</a>\n'
f'{indent} <span class="file-samples">{stat.total_samples:,} samples</span>\n'
f'{indent} <span class="file-samples">{stat.total_samples:n} sample{s}</span>\n'
f'{indent} <div class="heatmap-bar-container"><div class="heatmap-bar" style="width: {bar_width}px; height: {self.heatmap_bar_height}px;" data-intensity="{intensity:.3f}"></div></div>\n'
f'{indent}</div>\n')

Expand Down Expand Up @@ -761,7 +764,8 @@ def _print_export_summary(self, output_dir, file_stats: List[FileStats]):
"""Print summary of exported heatmap."""
print(f"Heatmap output written to {output_dir}/")
print(f" - Index: {output_dir / 'index.html'}")
print(f" - {len(file_stats)} source file(s) analyzed")
s = "" if len(file_stats) == 1 else "s"
print(f" - {len(file_stats)} source file{s} analyzed")

def _calculate_file_stats(self) -> List[FileStats]:
"""Calculate statistics for each file.
Expand Down Expand Up @@ -824,7 +828,7 @@ def _generate_index_html(self, index_path: Path, file_stats: List[FileStats]):
# Format error rate and missed samples with bar classes
error_rate = self.stats.get('error_rate')
if error_rate is not None:
error_rate_str = f"{error_rate:.1f}%"
error_rate_str = f"{fmt(error_rate)}%"
error_rate_width = min(error_rate, 100)
# Determine bar color class based on rate
if error_rate < 5:
Expand All @@ -840,7 +844,7 @@ def _generate_index_html(self, index_path: Path, file_stats: List[FileStats]):

missed_samples = self.stats.get('missed_samples')
if missed_samples is not None:
missed_samples_str = f"{missed_samples:.1f}%"
missed_samples_str = f"{fmt(missed_samples)}%"
missed_samples_width = min(missed_samples, 100)
if missed_samples < 5:
missed_samples_class = "good"
Expand All @@ -859,10 +863,10 @@ def _generate_index_html(self, index_path: Path, file_stats: List[FileStats]):
"<!-- INLINE_JS -->": f"<script>\n{self._template_loader.index_js}\n</script>",
"<!-- PYTHON_LOGO -->": self._template_loader.logo_html,
"<!-- PYTHON_VERSION -->": f"{sys.version_info.major}.{sys.version_info.minor}",
"<!-- NUM_FILES -->": str(len(file_stats)),
"<!-- TOTAL_SAMPLES -->": f"{self._total_samples:,}",
"<!-- DURATION -->": f"{self.stats.get('duration_sec', 0):.1f}s",
"<!-- SAMPLE_RATE -->": f"{self.stats.get('sample_rate', 0):.1f}",
"<!-- NUM_FILES -->": f"{len(file_stats):n}",
"<!-- TOTAL_SAMPLES -->": f"{self._total_samples:n}",
"<!-- DURATION -->": fmt(self.stats.get('duration_sec', 0)),
"<!-- SAMPLE_RATE -->": fmt(self.stats.get('sample_rate', 0)),
"<!-- ERROR_RATE -->": error_rate_str,
"<!-- ERROR_RATE_WIDTH -->": str(error_rate_width),
"<!-- ERROR_RATE_CLASS -->": error_rate_class,
Expand Down Expand Up @@ -906,12 +910,12 @@ def _generate_file_html(self, output_path: Path, filename: str,
# Populate template
replacements = {
"<!-- FILENAME -->": html.escape(filename),
"<!-- TOTAL_SAMPLES -->": f"{file_stat.total_samples:,}",
"<!-- TOTAL_SELF_SAMPLES -->": f"{file_stat.total_self_samples:,}",
"<!-- NUM_LINES -->": str(file_stat.num_lines),
"<!-- PERCENTAGE -->": f"{file_stat.percentage:.2f}",
"<!-- MAX_SAMPLES -->": str(file_stat.max_samples),
"<!-- MAX_SELF_SAMPLES -->": str(file_stat.max_self_samples),
"<!-- TOTAL_SAMPLES -->": f"{file_stat.total_samples:n}",
"<!-- TOTAL_SELF_SAMPLES -->": f"{file_stat.total_self_samples:n}",
"<!-- NUM_LINES -->": f"{file_stat.num_lines:n}",
"<!-- PERCENTAGE -->": fmt(file_stat.percentage, 2),
"<!-- MAX_SAMPLES -->": f"{file_stat.max_samples:n}",
"<!-- MAX_SELF_SAMPLES -->": f"{file_stat.max_self_samples:n}",
"<!-- CODE_LINES -->": ''.join(code_lines_html),
"<!-- INLINE_CSS -->": f"<style>\n{self._template_loader.file_css}\n</style>",
"<!-- INLINE_JS -->": f"<script>\n{self._template_loader.file_js}\n</script>",
Expand Down Expand Up @@ -948,9 +952,9 @@ def _build_line_html(self, line_num: int, line_content: str,
else:
self_intensity = 0

self_display = f"{self_samples:,}" if self_samples > 0 else ""
cumulative_display = f"{cumulative_samples:,}"
tooltip = f"Self: {self_samples:,}, Total: {cumulative_samples:,}"
self_display = f"{self_samples:n}" if self_samples > 0 else ""
cumulative_display = f"{cumulative_samples:n}"
tooltip = f"Self: {self_samples:n}, Total: {cumulative_samples:n}"
else:
cumulative_intensity = 0
self_intensity = 0
Expand Down Expand Up @@ -1195,7 +1199,7 @@ def _create_navigation_button(self, items_with_counts: List[Tuple[str, int, str,
file, line, func, count = valid_items[0]
target_html = self.file_index[file]
nav_data = json.dumps({'link': f"{target_html}#line-{line}", 'func': func})
title = f"Go to {btn_class}: {html.escape(func)} ({count:,} samples)"
title = f"Go to {btn_class}: {html.escape(func)} ({count:n} samples)"
return f'<button class="nav-btn {btn_class}" data-nav=\'{html.escape(nav_data)}\' title="{title}">{arrow}</button>'

# Multiple items - create menu
Expand All @@ -1210,5 +1214,5 @@ def _create_navigation_button(self, items_with_counts: List[Tuple[str, int, str,
for file, line, func, count in valid_items
]
items_json = html.escape(json.dumps(items_data))
title = f"{len(items_data)} {btn_class}s ({total_samples:,} samples)"
title = f"{len(items_data)} {btn_class}s ({total_samples:n} samples)"
return f'<button class="nav-btn {btn_class}" data-nav-multi=\'{items_json}\' title="{title}">{arrow}</button>'
44 changes: 20 additions & 24 deletions Lib/profiling/sampling/sample.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
import _remote_debugging
import os
import pstats
import statistics
import sys
import sysconfig
import time
from collections import deque
from _colorize import ANSIColors

from .pstats_collector import PstatsCollector
from .stack_collector import CollapsedStackCollector, FlamegraphCollector
from .heatmap_collector import HeatmapCollector
from .gecko_collector import GeckoCollector
from .constants import (
PROFILING_MODE_WALL,
PROFILING_MODE_CPU,
PROFILING_MODE_GIL,
PROFILING_MODE_ALL,
PROFILING_MODE_EXCEPTION,
)
from ._format_utils import fmt
try:
from .live_collector import LiveStatsCollector
except ImportError:
Expand Down Expand Up @@ -135,9 +131,9 @@ def sample(self, collector, duration_sec=10, *, async_aware=False):
# Don't print stats for live mode (curses is handling display)
is_live_mode = LiveStatsCollector is not None and isinstance(collector, LiveStatsCollector)
if not is_live_mode:
print(f"Captured {num_samples} samples in {running_time:.2f} seconds")
print(f"Sample rate: {sample_rate:.2f} samples/sec")
print(f"Error rate: {error_rate:.2f}%")
print(f"Captured {num_samples:n} samples in {fmt(running_time, 2)} seconds")
print(f"Sample rate: {fmt(sample_rate, 2)} samples/sec")
print(f"Error rate: {fmt(error_rate, 2)}")

# Print unwinder stats if stats collection is enabled
if self.collect_stats:
Expand All @@ -151,7 +147,7 @@ def sample(self, collector, duration_sec=10, *, async_aware=False):
print(
f"Warning: missed {expected_samples - num_samples} samples "
f"from the expected total of {expected_samples} "
f"({(expected_samples - num_samples) / expected_samples * 100:.2f}%)"
f"({fmt((expected_samples - num_samples) / expected_samples * 100, 2)}%)"
)

def _print_realtime_stats(self):
Expand Down Expand Up @@ -185,16 +181,16 @@ def _print_realtime_stats(self):
total = hits + partial + misses
if total > 0:
hit_pct = (hits + partial) / total * 100
cache_stats_str = f" {ANSIColors.MAGENTA}Cache: {hit_pct:.1f}% ({hits}+{partial}/{misses}){ANSIColors.RESET}"
cache_stats_str = f" {ANSIColors.MAGENTA}Cache: {fmt(hit_pct)}% ({hits}+{partial}/{misses}){ANSIColors.RESET}"
except RuntimeError:
pass

# Clear line and print stats
print(
f"\r\033[K{ANSIColors.BOLD_BLUE}Stats:{ANSIColors.RESET} "
f"{ANSIColors.YELLOW}{mean_hz:.1f}Hz ({mean_us_per_sample:.1f}µs){ANSIColors.RESET} "
f"{ANSIColors.GREEN}Min: {min_hz:.1f}Hz{ANSIColors.RESET} "
f"{ANSIColors.RED}Max: {max_hz:.1f}Hz{ANSIColors.RESET} "
f"{ANSIColors.YELLOW}{fmt(mean_hz)}Hz ({fmt(mean_us_per_sample)}µs){ANSIColors.RESET} "
f"{ANSIColors.GREEN}Min: {fmt(min_hz)}Hz{ANSIColors.RESET} "
f"{ANSIColors.RED}Max: {fmt(max_hz)}Hz{ANSIColors.RESET} "
f"{ANSIColors.CYAN}N={self.total_samples}{ANSIColors.RESET}"
f"{cache_stats_str}",
end="",
Expand Down Expand Up @@ -224,10 +220,10 @@ def _print_unwinder_stats(self):
misses_pct = (frame_cache_misses / total_lookups * 100) if total_lookups > 0 else 0

print(f" {ANSIColors.CYAN}Frame Cache:{ANSIColors.RESET}")
print(f" Total samples: {total_samples:,}")
print(f" Full hits: {frame_cache_hits:,} ({ANSIColors.GREEN}{hits_pct:.1f}%{ANSIColors.RESET})")
print(f" Partial hits: {frame_cache_partial_hits:,} ({ANSIColors.YELLOW}{partial_pct:.1f}%{ANSIColors.RESET})")
print(f" Misses: {frame_cache_misses:,} ({ANSIColors.RED}{misses_pct:.1f}%{ANSIColors.RESET})")
print(f" Total samples: {total_samples:n}")
print(f" Full hits: {frame_cache_hits:n} ({ANSIColors.GREEN}{fmt(hits_pct)}%{ANSIColors.RESET})")
print(f" Partial hits: {frame_cache_partial_hits:n} ({ANSIColors.YELLOW}{fmt(partial_pct)}%{ANSIColors.RESET})")
print(f" Misses: {frame_cache_misses:n} ({ANSIColors.RED}{fmt(misses_pct)}%{ANSIColors.RESET})")

# Frame read stats
frames_from_cache = stats.get('frames_read_from_cache', 0)
Expand All @@ -237,8 +233,8 @@ def _print_unwinder_stats(self):
memory_frame_pct = (frames_from_memory / total_frames * 100) if total_frames > 0 else 0

print(f" {ANSIColors.CYAN}Frame Reads:{ANSIColors.RESET}")
print(f" From cache: {frames_from_cache:,} ({ANSIColors.GREEN}{cache_frame_pct:.1f}%{ANSIColors.RESET})")
print(f" From memory: {frames_from_memory:,} ({ANSIColors.RED}{memory_frame_pct:.1f}%{ANSIColors.RESET})")
print(f" From cache: {frames_from_cache:n} ({ANSIColors.GREEN}{fmt(cache_frame_pct)}%{ANSIColors.RESET})")
print(f" From memory: {frames_from_memory:n} ({ANSIColors.RED}{fmt(memory_frame_pct)}%{ANSIColors.RESET})")

# Code object cache stats
code_hits = stats.get('code_object_cache_hits', 0)
Expand All @@ -248,20 +244,20 @@ def _print_unwinder_stats(self):
code_misses_pct = (code_misses / total_code * 100) if total_code > 0 else 0

print(f" {ANSIColors.CYAN}Code Object Cache:{ANSIColors.RESET}")
print(f" Hits: {code_hits:,} ({ANSIColors.GREEN}{code_hits_pct:.1f}%{ANSIColors.RESET})")
print(f" Misses: {code_misses:,} ({ANSIColors.RED}{code_misses_pct:.1f}%{ANSIColors.RESET})")
print(f" Hits: {code_hits:n} ({ANSIColors.GREEN}{fmt(code_hits_pct)}%{ANSIColors.RESET})")
print(f" Misses: {code_misses:n} ({ANSIColors.RED}{fmt(code_misses_pct)}%{ANSIColors.RESET})")

# Memory operations
memory_reads = stats.get('memory_reads', 0)
memory_bytes = stats.get('memory_bytes_read', 0)
if memory_bytes >= 1024 * 1024:
memory_str = f"{memory_bytes / (1024 * 1024):.1f} MB"
memory_str = f"{fmt(memory_bytes / (1024 * 1024))} MB"
elif memory_bytes >= 1024:
memory_str = f"{memory_bytes / 1024:.1f} KB"
memory_str = f"{fmt(memory_bytes / 1024)} KB"
else:
memory_str = f"{memory_bytes} B"
print(f" {ANSIColors.CYAN}Memory:{ANSIColors.RESET}")
print(f" Read operations: {memory_reads:,} ({memory_str})")
print(f" Read operations: {memory_reads:n} ({memory_str})")

# Stale invalidations
stale_invalidations = stats.get('stale_cache_invalidations', 0)
Expand Down
Loading