From d113c88258f4bcb9a706e62c07b6a020a21577b4 Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Mon, 16 Mar 2026 18:06:25 +0000 Subject: [PATCH 1/4] Extract module name utilities to module_utils.py Move module name extraction logic from heatmap_collector to shared module_utils to enable flamegraph to display module names instead of full file paths. --- Lib/profiling/sampling/heatmap_collector.py | 121 +------------------ Lib/profiling/sampling/module_utils.py | 122 ++++++++++++++++++++ 2 files changed, 123 insertions(+), 120 deletions(-) create mode 100644 Lib/profiling/sampling/module_utils.py diff --git a/Lib/profiling/sampling/heatmap_collector.py b/Lib/profiling/sampling/heatmap_collector.py index ea1beec70d39f8..5c36d78f5535e7 100644 --- a/Lib/profiling/sampling/heatmap_collector.py +++ b/Lib/profiling/sampling/heatmap_collector.py @@ -20,6 +20,7 @@ from .collector import normalize_location, extract_lineno from .opcode_utils import get_opcode_info, format_opcode from .stack_collector import StackTraceCollector +from .module_utils import extract_module_name, get_python_path_info # ============================================================================ @@ -49,126 +50,6 @@ class TreeNode: children: Dict[str, 'TreeNode'] = field(default_factory=dict) -# ============================================================================ -# Module Path Analysis -# ============================================================================ - -def get_python_path_info(): - """Get information about Python installation paths for module extraction. - - Returns: - dict: Dictionary containing stdlib path, site-packages paths, and sys.path entries. - """ - info = { - 'stdlib': None, - 'site_packages': [], - 'sys_path': [] - } - - # Get standard library path from os module location - try: - if hasattr(os, '__file__') and os.__file__: - info['stdlib'] = Path(os.__file__).parent - except (AttributeError, OSError): - pass # Silently continue if we can't determine stdlib path - - # Get site-packages directories - site_packages = [] - try: - site_packages.extend(Path(p) for p in site.getsitepackages()) - except (AttributeError, OSError): - pass # Continue without site packages if unavailable - - # Get user site-packages - try: - user_site = site.getusersitepackages() - if user_site and Path(user_site).exists(): - site_packages.append(Path(user_site)) - except (AttributeError, OSError): - pass # Continue without user site packages - - info['site_packages'] = site_packages - info['sys_path'] = [Path(p) for p in sys.path if p] - - return info - - -def extract_module_name(filename, path_info): - """Extract Python module name and type from file path. - - Args: - filename: Path to the Python file - path_info: Dictionary from get_python_path_info() - - Returns: - tuple: (module_name, module_type) where module_type is one of: - 'stdlib', 'site-packages', 'project', or 'other' - """ - if not filename: - return ('unknown', 'other') - - try: - file_path = Path(filename) - except (ValueError, OSError): - return (str(filename), 'other') - - # Check if it's in stdlib - if path_info['stdlib'] and _is_subpath(file_path, path_info['stdlib']): - try: - rel_path = file_path.relative_to(path_info['stdlib']) - return (_path_to_module(rel_path), 'stdlib') - except ValueError: - pass - - # Check site-packages - for site_pkg in path_info['site_packages']: - if _is_subpath(file_path, site_pkg): - try: - rel_path = file_path.relative_to(site_pkg) - return (_path_to_module(rel_path), 'site-packages') - except ValueError: - continue - - # Check other sys.path entries (project files) - if not str(file_path).startswith(('<', '[')): # Skip special files - for path_entry in path_info['sys_path']: - if _is_subpath(file_path, path_entry): - try: - rel_path = file_path.relative_to(path_entry) - return (_path_to_module(rel_path), 'project') - except ValueError: - continue - - # Fallback: just use the filename - return (_path_to_module(file_path), 'other') - - -def _is_subpath(file_path, parent_path): - try: - file_path.relative_to(parent_path) - return True - except (ValueError, OSError): - return False - - -def _path_to_module(path): - if isinstance(path, str): - path = Path(path) - - # Remove .py extension - if path.suffix == '.py': - path = path.with_suffix('') - - # Convert path separators to dots - parts = path.parts - - # Handle __init__ files - they represent the package itself - if parts and parts[-1] == '__init__': - parts = parts[:-1] - - return '.'.join(parts) if parts else path.stem - - # ============================================================================ # Helper Classes # ============================================================================ diff --git a/Lib/profiling/sampling/module_utils.py b/Lib/profiling/sampling/module_utils.py new file mode 100644 index 00000000000000..6d838d411902af --- /dev/null +++ b/Lib/profiling/sampling/module_utils.py @@ -0,0 +1,122 @@ +"""Utilities for extracting module names from file paths.""" + +import os +import site +import sys +from pathlib import Path + + +def get_python_path_info(): + """Get information about Python's search paths. + + Returns: + dict: Dictionary containing stdlib path, site-packages paths, and sys.path entries. + """ + info = { + 'stdlib': None, + 'site_packages': [], + 'sys_path': [] + } + + # Get standard library path from os module location + try: + if hasattr(os, '__file__') and os.__file__: + info['stdlib'] = Path(os.__file__).parent + except (AttributeError, OSError): + pass # Silently continue if we can't determine stdlib path + + # Get site-packages directories + site_packages = [] + try: + site_packages.extend(Path(p) for p in site.getsitepackages()) + except (AttributeError, OSError): + pass # Continue without site packages if unavailable + + # Get user site-packages + try: + user_site = site.getusersitepackages() + if user_site and Path(user_site).exists(): + site_packages.append(Path(user_site)) + except (AttributeError, OSError): + pass # Continue without user site packages + + info['site_packages'] = site_packages + info['sys_path'] = [Path(p) for p in sys.path if p] + + return info + + +def extract_module_name(filename, path_info): + """Extract Python module name and type from file path. + + Args: + filename: Path to the Python file + path_info: Dictionary from get_python_path_info() + + Returns: + tuple: (module_name, module_type) where module_type is one of: + 'stdlib', 'site-packages', 'project', or 'other' + """ + if not filename: + return ('unknown', 'other') + + try: + file_path = Path(filename) + except (ValueError, OSError): + return (str(filename), 'other') + + # Check if it's in stdlib + if path_info['stdlib'] and _is_subpath(file_path, path_info['stdlib']): + try: + rel_path = file_path.relative_to(path_info['stdlib']) + return (_path_to_module(rel_path), 'stdlib') + except ValueError: + pass + + # Check site-packages + for site_pkg in path_info['site_packages']: + if _is_subpath(file_path, site_pkg): + try: + rel_path = file_path.relative_to(site_pkg) + return (_path_to_module(rel_path), 'site-packages') + except ValueError: + continue + + # Check other sys.path entries (project files) + if not str(file_path).startswith(('<', '[')): # Skip special files + for path_entry in path_info['sys_path']: + if _is_subpath(file_path, path_entry): + try: + rel_path = file_path.relative_to(path_entry) + return (_path_to_module(rel_path), 'project') + except ValueError: + continue + + # Fallback: just use the filename + return (_path_to_module(file_path), 'other') + + +def _is_subpath(file_path, parent_path): + try: + file_path.relative_to(parent_path) + return True + except (ValueError, OSError): + return False + + +def _path_to_module(path): + if isinstance(path, str): + path = Path(path) + + # Remove .py extension + if path.suffix == '.py': + path = path.with_suffix('') + + # Convert path separators to dots + parts = path.parts + + # Handle __init__ files - they represent the package itself + if parts and parts[-1] == '__init__': + parts = parts[:-1] + + return '.'.join(parts) if parts else path.stem From 4df8d3d1b943a75fe9ef5557617c15c0be6d4906 Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Mon, 16 Mar 2026 19:32:05 +0000 Subject: [PATCH 2/4] Show module names instead of file paths in flamegraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display module names instead of full file paths (/home/user/project/pkg/mod.py → pkg.mod) in flamegraph for readability. --- .../sampling/_flamegraph_assets/flamegraph.js | 39 +++++++++++++------ Lib/profiling/sampling/stack_collector.py | 29 +++++++++----- .../test_sampling_profiler/test_collectors.py | 4 +- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index 166c03d03fbe5b..8196ba72ea1918 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -64,6 +64,9 @@ function resolveStringIndices(node, table) { if (typeof resolved.funcname === 'number') { resolved.funcname = resolveString(resolved.funcname, table); } + if (typeof resolved.module_name === 'number') { + resolved.module_name = resolveString(resolved.module_name); + } if (Array.isArray(resolved.source)) { resolved.source = resolved.source.map(index => @@ -78,6 +81,11 @@ function resolveStringIndices(node, table) { return resolved; } +// Escape HTML special characters +function escapeHtml(str) { + return str.replace(/&/g, "&").replace(//g, ">"); +} + function selectFlamegraphData() { const baseData = isShowingElided ? elidedFlamegraphData : normalData; @@ -228,6 +236,7 @@ function setupLogos() { function updateStatusBar(nodeData, rootValue) { const funcname = resolveString(nodeData.funcname) || resolveString(nodeData.name) || "--"; const filename = resolveString(nodeData.filename) || ""; + const moduleName = resolveString(nodeData.module_name) || ""; const lineno = nodeData.lineno; const timeMs = (nodeData.value / 1000).toFixed(2); const percent = rootValue > 0 ? ((nodeData.value / rootValue) * 100).toFixed(1) : "0.0"; @@ -249,8 +258,7 @@ function updateStatusBar(nodeData, rootValue) { const fileEl = document.getElementById('status-file'); if (fileEl && filename && filename !== "~") { - const basename = filename.split('/').pop(); - fileEl.textContent = lineno ? `${basename}:${lineno}` : basename; + fileEl.textContent = lineno ? `${moduleName}:${lineno}` : moduleName; } const funcEl = document.getElementById('status-func'); @@ -299,6 +307,7 @@ function createPythonTooltip(data) { const funcname = resolveString(d.data.funcname) || resolveString(d.data.name); const filename = resolveString(d.data.filename) || ""; + const moduleName = escapeHtml(resolveString(d.data.module_name) || ""); const isSpecialFrame = filename === "~"; // Build source section @@ -307,7 +316,7 @@ function createPythonTooltip(data) { const sourceLines = source .map((line) => { const isCurrent = line.startsWith("→"); - const escaped = line.replace(/&/g, "&").replace(//g, ">"); + const escaped = escapeHtml(line); return `
${escaped}
`; }) .join(""); @@ -367,7 +376,7 @@ function createPythonTooltip(data) { } const fileLocationHTML = isSpecialFrame ? "" : ` -
${filename}${d.data.lineno ? ":" + d.data.lineno : ""}
`; +
${moduleName}${d.data.lineno ? ":" + d.data.lineno : ""}
`; // Differential stats section let diffSection = ""; @@ -621,24 +630,24 @@ function updateSearchHighlight(searchTerm, searchInput) { const name = resolveString(d.data.name) || ""; const funcname = resolveString(d.data.funcname) || ""; const filename = resolveString(d.data.filename) || ""; + const moduleName = resolveString(d.data.module_name) || ""; const lineno = d.data.lineno; const term = searchTerm.toLowerCase(); - // Check if search term looks like file:line pattern + // Check if search term looks like module:line pattern const fileLineMatch = term.match(/^(.+):(\d+)$/); let matches = false; if (fileLineMatch) { - // Exact file:line matching const searchFile = fileLineMatch[1]; const searchLine = parseInt(fileLineMatch[2], 10); - const basename = filename.split('/').pop().toLowerCase(); - matches = basename.includes(searchFile) && lineno === searchLine; + matches = moduleName.toLowerCase().includes(searchFile) && lineno === searchLine; } else { // Regular substring search matches = name.toLowerCase().includes(term) || funcname.toLowerCase().includes(term) || + moduleName.toLowerCase().includes(term) || filename.toLowerCase().includes(term); } @@ -1040,6 +1049,7 @@ function populateStats(data) { let filename = resolveString(node.filename); let funcname = resolveString(node.funcname); + let moduleName = resolveString(node.module_name); if (!filename || !funcname) { const nameStr = resolveString(node.name); @@ -1054,6 +1064,7 @@ function populateStats(data) { filename = filename || 'unknown'; funcname = funcname || 'unknown'; + moduleName = moduleName || 'unknown'; if (filename !== 'unknown' && funcname !== 'unknown' && node.value > 0) { let childrenValue = 0; @@ -1070,12 +1081,14 @@ function populateStats(data) { existing.directPercent = (existing.directSamples / totalSamples) * 100; if (directSamples > existing.maxSingleSamples) { existing.filename = filename; + existing.module_name = moduleName; existing.lineno = node.lineno || '?'; existing.maxSingleSamples = directSamples; } } else { functionMap.set(funcKey, { filename: filename, + module_name: moduleName, lineno: node.lineno || '?', funcname: funcname, directSamples, @@ -1110,6 +1123,7 @@ function populateStats(data) { const h = hotSpots[i]; const filename = h.filename || 'unknown'; const lineno = h.lineno ?? '?'; + const moduleName = h.module_name || 'unknown'; const isSpecialFrame = filename === '~' && (lineno === 0 || lineno === '?'); let funcDisplay = h.funcname || 'unknown'; @@ -1120,8 +1134,7 @@ function populateStats(data) { if (isSpecialFrame) { fileEl.textContent = '--'; } else { - const basename = filename !== 'unknown' ? filename.split('/').pop() : 'unknown'; - fileEl.textContent = `${basename}:${lineno}`; + fileEl.textContent = `${moduleName}:${lineno}`; } } if (percentEl) percentEl.textContent = `${h.directPercent.toFixed(1)}%`; @@ -1137,8 +1150,9 @@ function populateStats(data) { if (card) { if (i < hotSpots.length && hotSpots[i]) { const h = hotSpots[i]; - const basename = h.filename !== 'unknown' ? h.filename.split('/').pop() : ''; - const searchTerm = basename && h.lineno !== '?' ? `${basename}:${h.lineno}` : h.funcname; + const moduleName = h.module_name || 'unknown'; + const hasValidLocation = moduleName !== 'unknown' && h.lineno !== '?'; + const searchTerm = hasValidLocation ? `${moduleName}:${h.lineno}` : h.funcname; card.dataset.searchterm = searchTerm; card.onclick = () => searchForHotspot(searchTerm); card.style.cursor = 'pointer'; @@ -1273,6 +1287,7 @@ function accumulateInvertedNode(parent, stackFrame, leaf, isDifferential) { value: 0, children: {}, filename: stackFrame.filename, + module_name: stackFrame.module_name, lineno: stackFrame.lineno, funcname: stackFrame.funcname, source: stackFrame.source, diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 31102d3eb0ffa6..e8ba5d24353db2 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -12,6 +12,7 @@ from .collector import Collector, extract_lineno from .opcode_utils import get_opcode_mapping from .string_table import StringTable +from .module_utils import extract_module_name, get_python_path_info class StackTraceCollector(Collector): @@ -72,6 +73,7 @@ def __init__(self, *args, **kwargs): self._sample_count = 0 # Track actual number of samples (not thread traces) self._func_intern = {} self._string_table = StringTable() + self._module_cache = {} self._all_threads = set() # Thread status statistics (similar to LiveStatsCollector) @@ -168,19 +170,21 @@ def export(self, filename): @staticmethod @functools.lru_cache(maxsize=None) - def _format_function_name(func): + def _format_function_name(func, module_name): filename, lineno, funcname = func # Special frames like and should not show file:line if filename == "~" and lineno == 0: return funcname - if len(filename) > 50: - parts = filename.split("/") - if len(parts) > 2: - filename = f".../{'/'.join(parts[-2:])}" + return f"{funcname} ({module_name}:{lineno})" - return f"{funcname} ({filename}:{lineno})" + def _get_module_name(self, filename, path_info): + module_name = self._module_cache.get(filename) + if module_name is None: + module_name, _ = extract_module_name(filename, path_info) + self._module_cache[filename] = module_name + return module_name def _convert_to_flamegraph_format(self): if self._total_samples == 0: @@ -192,7 +196,7 @@ def _convert_to_flamegraph_format(self): "strings": self._string_table.get_strings() } - def convert_children(children, min_samples): + def convert_children(children, min_samples, path_info): out = [] for func, node in children.items(): samples = node["samples"] @@ -202,13 +206,17 @@ def convert_children(children, min_samples): # Intern all string components for maximum efficiency filename_idx = self._string_table.intern(func[0]) funcname_idx = self._string_table.intern(func[2]) - name_idx = self._string_table.intern(self._format_function_name(func)) + module_name = self._get_module_name(func[0], path_info) + + module_name_idx = self._string_table.intern(module_name) + name_idx = self._string_table.intern(self._format_function_name(func, module_name)) child_entry = { "name": name_idx, "value": samples, "children": [], "filename": filename_idx, + "module_name": module_name_idx, "lineno": func[1], "funcname": funcname_idx, "threads": sorted(list(node.get("threads", set()))), @@ -227,7 +235,7 @@ def convert_children(children, min_samples): # Recurse child_entry["children"] = convert_children( - node["children"], min_samples + node["children"], min_samples, path_info ) out.append(child_entry) @@ -238,8 +246,9 @@ def convert_children(children, min_samples): # Filter out very small functions (less than 0.1% of total samples) total_samples = self._total_samples min_samples = max(1, int(total_samples * 0.001)) + path_info = get_python_path_info() - root_children = convert_children(self._root["children"], min_samples) + root_children = convert_children(self._root["children"], min_samples, path_info) if not root_children: return { "name": self._string_table.intern("No significant data"), diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py index 86fb9d4c05b3bc..8c04ecf0ce6559 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -435,11 +435,11 @@ def test_flamegraph_collector_basic(self): strings = data.get("strings", []) name = resolve_name(data, strings) self.assertTrue(name.startswith("Program Root: ")) - self.assertIn("func2 (file.py:20)", name) # formatted name + self.assertIn("func2 (file:20)", name) children = data.get("children", []) self.assertEqual(len(children), 1) child = children[0] - self.assertIn("func1 (file.py:10)", resolve_name(child, strings)) + self.assertIn("func1 (file:10)", resolve_name(child, strings)) self.assertEqual(child["value"], 1) def test_flamegraph_collector_export(self): From a5cda5bcf7f7d0e95e5e54a47331d279ba492a0d Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Fri, 20 Mar 2026 19:22:52 +0000 Subject: [PATCH 3/4] Add 'Full Paths/Module Names' toggle for flamegraph display Users can now switch between module names and file paths using the toggle in the View Mode sidebar. Module names are concise, while file paths help locate the exact source file, both are useful depending on the debugging context. --- .../_flamegraph_assets/flamegraph.css | 9 ++- .../sampling/_flamegraph_assets/flamegraph.js | 58 +++++++++++++++---- .../flamegraph_template.html | 6 ++ Lib/profiling/sampling/stack_collector.py | 22 ++++++- .../test_sampling_profiler/test_collectors.py | 4 +- 5 files changed, 84 insertions(+), 15 deletions(-) diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css index c4da169d15de88..c93ee1e9dd470e 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css @@ -315,6 +315,12 @@ body.resizing-sidebar { } /* View Mode Section */ +.view-mode-section { + display: flex; + flex-direction: column; + gap: 8px; +} + .view-mode-section .section-content { display: flex; flex-direction: column; @@ -1067,7 +1073,8 @@ body.resizing-sidebar { -------------------------------------------------------------------------- */ #toggle-invert .toggle-track.on, -#toggle-elided .toggle-track.on { +#toggle-elided .toggle-track.on, +#toggle-path-display .toggle-track.on { background: #8e44ad; border-color: #8e44ad; box-shadow: 0 0 8px rgba(142, 68, 173, 0.3); diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index 8196ba72ea1918..9995b16c4335f8 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -6,6 +6,7 @@ let normalData = null; let invertedData = null; let currentThreadFilter = 'all'; let isInverted = false; +let useModuleNames = true; // Heat colors are now defined in CSS variables (--heat-1 through --heat-8) // and automatically switch with theme changes - no JS color arrays needed! @@ -67,6 +68,9 @@ function resolveStringIndices(node, table) { if (typeof resolved.module_name === 'number') { resolved.module_name = resolveString(resolved.module_name); } + if (typeof resolved.name_module === 'number') { + resolved.name_module = resolveString(resolved.name_module); + } if (Array.isArray(resolved.source)) { resolved.source = resolved.source.map(index => @@ -86,6 +90,14 @@ function escapeHtml(str) { return str.replace(/&/g, "&").replace(//g, ">"); } +// Get display path based on user preference (module name or basename) +function getDisplayName(moduleName, filename) { + if (useModuleNames) { + return moduleName || filename; + } + return filename ? filename.split('/').pop() : filename; +} + function selectFlamegraphData() { const baseData = isShowingElided ? elidedFlamegraphData : normalData; @@ -258,7 +270,8 @@ function updateStatusBar(nodeData, rootValue) { const fileEl = document.getElementById('status-file'); if (fileEl && filename && filename !== "~") { - fileEl.textContent = lineno ? `${moduleName}:${lineno}` : moduleName; + const displayName = getDisplayName(moduleName, filename); + fileEl.textContent = lineno ? `${displayName}:${lineno}` : displayName; } const funcEl = document.getElementById('status-func'); @@ -307,7 +320,8 @@ function createPythonTooltip(data) { const funcname = resolveString(d.data.funcname) || resolveString(d.data.name); const filename = resolveString(d.data.filename) || ""; - const moduleName = escapeHtml(resolveString(d.data.module_name) || ""); + const moduleName = resolveString(d.data.module_name) || ""; + const displayName = escapeHtml(useModuleNames ? (moduleName || filename) : filename); const isSpecialFrame = filename === "~"; // Build source section @@ -376,7 +390,7 @@ function createPythonTooltip(data) { } const fileLocationHTML = isSpecialFrame ? "" : ` -
${moduleName}${d.data.lineno ? ":" + d.data.lineno : ""}
`; +
${displayName}${d.data.lineno ? ":" + d.data.lineno : ""}
`; // Differential stats section let diffSection = ""; @@ -588,6 +602,7 @@ function createFlamegraph(tooltip, rootValue, data) { .minFrameSize(1) .tooltip(tooltip) .inverted(true) + .getName(d => resolveString(useModuleNames ? d.data.name_module : d.data.name) || resolveString(d.data.name) || '') .setColorMapper(function (d) { if (d.depth === 0) return 'transparent'; @@ -631,24 +646,24 @@ function updateSearchHighlight(searchTerm, searchInput) { const funcname = resolveString(d.data.funcname) || ""; const filename = resolveString(d.data.filename) || ""; const moduleName = resolveString(d.data.module_name) || ""; + const displayName = getDisplayName(moduleName, filename); const lineno = d.data.lineno; const term = searchTerm.toLowerCase(); - // Check if search term looks like module:line pattern + // Check if search term looks like path:line pattern const fileLineMatch = term.match(/^(.+):(\d+)$/); let matches = false; if (fileLineMatch) { const searchFile = fileLineMatch[1]; const searchLine = parseInt(fileLineMatch[2], 10); - matches = moduleName.toLowerCase().includes(searchFile) && lineno === searchLine; + matches = displayName.toLowerCase().includes(searchFile) && lineno === searchLine; } else { // Regular substring search matches = name.toLowerCase().includes(term) || funcname.toLowerCase().includes(term) || - moduleName.toLowerCase().includes(term) || - filename.toLowerCase().includes(term); + displayName.toLowerCase().includes(term); } if (matches) { @@ -1134,7 +1149,8 @@ function populateStats(data) { if (isSpecialFrame) { fileEl.textContent = '--'; } else { - fileEl.textContent = `${moduleName}:${lineno}`; + const displayName = getDisplayName(moduleName, filename); + fileEl.textContent = `${displayName}:${lineno}`; } } if (percentEl) percentEl.textContent = `${h.directPercent.toFixed(1)}%`; @@ -1151,8 +1167,10 @@ function populateStats(data) { if (i < hotSpots.length && hotSpots[i]) { const h = hotSpots[i]; const moduleName = h.module_name || 'unknown'; - const hasValidLocation = moduleName !== 'unknown' && h.lineno !== '?'; - const searchTerm = hasValidLocation ? `${moduleName}:${h.lineno}` : h.funcname; + const filename = h.filename || 'unknown'; + const displayName = getDisplayName(moduleName, filename); + const hasValidLocation = displayName !== 'unknown' && h.lineno !== '?'; + const searchTerm = hasValidLocation ? `${displayName}:${h.lineno}` : h.funcname; card.dataset.searchterm = searchTerm; card.onclick = () => searchForHotspot(searchTerm); card.style.cursor = 'pointer'; @@ -1284,6 +1302,7 @@ function accumulateInvertedNode(parent, stackFrame, leaf, isDifferential) { if (!parent.children[key]) { const newNode = { name: stackFrame.name, + name_module: stackFrame.name_module, value: 0, children: {}, filename: stackFrame.filename, @@ -1381,6 +1400,7 @@ function generateInvertedFlamegraph(data) { const invertedRoot = { name: data.name, + name_module: data.name_module, value: data.value, children: {}, stats: data.stats, @@ -1405,6 +1425,19 @@ function toggleInvert() { updateFlamegraphView(); } +function togglePathDisplay() { + useModuleNames = !useModuleNames; + updateToggleUI('toggle-path-display', useModuleNames); + const dataToRender = isInverted ? invertedData : normalData; + const filteredData = currentThreadFilter !== 'all' + ? filterDataByThread(dataToRender, parseInt(currentThreadFilter)) + : dataToRender; + + const tooltip = createPythonTooltip(filteredData); + const chart = createFlamegraph(tooltip, filteredData.value); + renderFlamegraph(chart, filteredData); +} + // ============================================================================ // Initialization // ============================================================================ @@ -1452,6 +1485,11 @@ function initFlamegraph() { if (toggleInvertBtn) { toggleInvertBtn.addEventListener('click', toggleInvert); } + + const togglePathDisplayBtn = document.getElementById('toggle-path-display'); + if (togglePathDisplayBtn) { + togglePathDisplayBtn.addEventListener('click', togglePathDisplay); + } } // Keyboard shortcut: Enter/Space activates toggle switches diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html b/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html index 9a77178aeff7ec..03f09447d0db65 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html @@ -117,6 +117,12 @@

View Mode

Elided +
+ Full Paths +
+ Module Names +
+
Flamegraph
diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index e8ba5d24353db2..73fe34333a86e9 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -170,7 +170,23 @@ def export(self, filename): @staticmethod @functools.lru_cache(maxsize=None) - def _format_function_name(func, module_name): + def _format_function_name(func): + filename, lineno, funcname = func + + # Special frames like and should not show file:line + if filename == "~" and lineno == 0: + return funcname + + if len(filename) > 50: + parts = filename.split("/") + if len(parts) > 2: + filename = f".../{'/'.join(parts[-2:])}" + + return f"{funcname} ({filename}:{lineno})" + + @staticmethod + @functools.lru_cache(maxsize=None) + def _format_module_name(func, module_name): filename, lineno, funcname = func # Special frames like and should not show file:line @@ -209,10 +225,12 @@ def convert_children(children, min_samples, path_info): module_name = self._get_module_name(func[0], path_info) module_name_idx = self._string_table.intern(module_name) - name_idx = self._string_table.intern(self._format_function_name(func, module_name)) + name_idx = self._string_table.intern(self._format_function_name(func)) + name_module_idx = self._string_table.intern(self._format_module_name(func, module_name)) child_entry = { "name": name_idx, + "name_module": name_module_idx, "value": samples, "children": [], "filename": filename_idx, diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py index 8c04ecf0ce6559..d087e157f2dfea 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -435,11 +435,11 @@ def test_flamegraph_collector_basic(self): strings = data.get("strings", []) name = resolve_name(data, strings) self.assertTrue(name.startswith("Program Root: ")) - self.assertIn("func2 (file:20)", name) + self.assertIn("func2 (file.py:20)", name) children = data.get("children", []) self.assertEqual(len(children), 1) child = children[0] - self.assertIn("func1 (file:10)", resolve_name(child, strings)) + self.assertIn("func1 (file.py:10)", resolve_name(child, strings)) self.assertEqual(child["value"], 1) def test_flamegraph_collector_export(self): From 518c923c21684f3af1fbb4ab75fd1ccfa561ee72 Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Tue, 31 Mar 2026 15:19:18 +0100 Subject: [PATCH 4/4] Rename module_name/name_module to module/label in flamegraph data --- .../sampling/_flamegraph_assets/flamegraph.js | 43 ++++++++----------- Lib/profiling/sampling/stack_collector.py | 8 ++-- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index 9995b16c4335f8..c994e2644503f0 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -65,11 +65,11 @@ function resolveStringIndices(node, table) { if (typeof resolved.funcname === 'number') { resolved.funcname = resolveString(resolved.funcname, table); } - if (typeof resolved.module_name === 'number') { - resolved.module_name = resolveString(resolved.module_name); + if (typeof resolved.module === 'number') { + resolved.module = resolveString(resolved.module, table); } - if (typeof resolved.name_module === 'number') { - resolved.name_module = resolveString(resolved.name_module); + if (typeof resolved.label === 'number') { + resolved.label = resolveString(resolved.label, table); } if (Array.isArray(resolved.source)) { @@ -90,7 +90,7 @@ function escapeHtml(str) { return str.replace(/&/g, "&").replace(//g, ">"); } -// Get display path based on user preference (module name or basename) +// Get display path based on user preference (module or full path) function getDisplayName(moduleName, filename) { if (useModuleNames) { return moduleName || filename; @@ -248,7 +248,7 @@ function setupLogos() { function updateStatusBar(nodeData, rootValue) { const funcname = resolveString(nodeData.funcname) || resolveString(nodeData.name) || "--"; const filename = resolveString(nodeData.filename) || ""; - const moduleName = resolveString(nodeData.module_name) || ""; + const moduleName = resolveString(nodeData.module) || ""; const lineno = nodeData.lineno; const timeMs = (nodeData.value / 1000).toFixed(2); const percent = rootValue > 0 ? ((nodeData.value / rootValue) * 100).toFixed(1) : "0.0"; @@ -320,7 +320,7 @@ function createPythonTooltip(data) { const funcname = resolveString(d.data.funcname) || resolveString(d.data.name); const filename = resolveString(d.data.filename) || ""; - const moduleName = resolveString(d.data.module_name) || ""; + const moduleName = resolveString(d.data.module) || ""; const displayName = escapeHtml(useModuleNames ? (moduleName || filename) : filename); const isSpecialFrame = filename === "~"; @@ -602,7 +602,7 @@ function createFlamegraph(tooltip, rootValue, data) { .minFrameSize(1) .tooltip(tooltip) .inverted(true) - .getName(d => resolveString(useModuleNames ? d.data.name_module : d.data.name) || resolveString(d.data.name) || '') + .getName(d => resolveString(useModuleNames ? d.data.label : d.data.name) || resolveString(d.data.name) || '') .setColorMapper(function (d) { if (d.depth === 0) return 'transparent'; @@ -645,7 +645,7 @@ function updateSearchHighlight(searchTerm, searchInput) { const name = resolveString(d.data.name) || ""; const funcname = resolveString(d.data.funcname) || ""; const filename = resolveString(d.data.filename) || ""; - const moduleName = resolveString(d.data.module_name) || ""; + const moduleName = resolveString(d.data.module) || ""; const displayName = getDisplayName(moduleName, filename); const lineno = d.data.lineno; const term = searchTerm.toLowerCase(); @@ -1064,7 +1064,7 @@ function populateStats(data) { let filename = resolveString(node.filename); let funcname = resolveString(node.funcname); - let moduleName = resolveString(node.module_name); + let moduleName = resolveString(node.module); if (!filename || !funcname) { const nameStr = resolveString(node.name); @@ -1096,14 +1096,14 @@ function populateStats(data) { existing.directPercent = (existing.directSamples / totalSamples) * 100; if (directSamples > existing.maxSingleSamples) { existing.filename = filename; - existing.module_name = moduleName; + existing.module = moduleName; existing.lineno = node.lineno || '?'; existing.maxSingleSamples = directSamples; } } else { functionMap.set(funcKey, { filename: filename, - module_name: moduleName, + module: moduleName, lineno: node.lineno || '?', funcname: funcname, directSamples, @@ -1138,7 +1138,7 @@ function populateStats(data) { const h = hotSpots[i]; const filename = h.filename || 'unknown'; const lineno = h.lineno ?? '?'; - const moduleName = h.module_name || 'unknown'; + const moduleName = h.module || 'unknown'; const isSpecialFrame = filename === '~' && (lineno === 0 || lineno === '?'); let funcDisplay = h.funcname || 'unknown'; @@ -1166,7 +1166,7 @@ function populateStats(data) { if (card) { if (i < hotSpots.length && hotSpots[i]) { const h = hotSpots[i]; - const moduleName = h.module_name || 'unknown'; + const moduleName = h.module || 'unknown'; const filename = h.filename || 'unknown'; const displayName = getDisplayName(moduleName, filename); const hasValidLocation = displayName !== 'unknown' && h.lineno !== '?'; @@ -1302,11 +1302,11 @@ function accumulateInvertedNode(parent, stackFrame, leaf, isDifferential) { if (!parent.children[key]) { const newNode = { name: stackFrame.name, - name_module: stackFrame.name_module, + label: stackFrame.label, value: 0, children: {}, filename: stackFrame.filename, - module_name: stackFrame.module_name, + module: stackFrame.module, lineno: stackFrame.lineno, funcname: stackFrame.funcname, source: stackFrame.source, @@ -1400,7 +1400,7 @@ function generateInvertedFlamegraph(data) { const invertedRoot = { name: data.name, - name_module: data.name_module, + label: data.label, value: data.value, children: {}, stats: data.stats, @@ -1428,14 +1428,7 @@ function toggleInvert() { function togglePathDisplay() { useModuleNames = !useModuleNames; updateToggleUI('toggle-path-display', useModuleNames); - const dataToRender = isInverted ? invertedData : normalData; - const filteredData = currentThreadFilter !== 'all' - ? filterDataByThread(dataToRender, parseInt(currentThreadFilter)) - : dataToRender; - - const tooltip = createPythonTooltip(filteredData); - const chart = createFlamegraph(tooltip, filteredData.value); - renderFlamegraph(chart, filteredData); + updateFlamegraphView(); } // ============================================================================ diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 73fe34333a86e9..9dffead1777fc1 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -224,17 +224,17 @@ def convert_children(children, min_samples, path_info): funcname_idx = self._string_table.intern(func[2]) module_name = self._get_module_name(func[0], path_info) - module_name_idx = self._string_table.intern(module_name) + module_idx = self._string_table.intern(module_name) name_idx = self._string_table.intern(self._format_function_name(func)) - name_module_idx = self._string_table.intern(self._format_module_name(func, module_name)) + label_idx = self._string_table.intern(self._format_module_name(func, module_name)) child_entry = { "name": name_idx, - "name_module": name_module_idx, + "label": label_idx, "value": samples, "children": [], "filename": filename_idx, - "module_name": module_name_idx, + "module": module_idx, "lineno": func[1], "funcname": funcname_idx, "threads": sorted(list(node.get("threads", set()))),