https://github.com/python/cpython/commit/3b3838823a3e1baeb9e7568d2271971fc3b9e836 commit: 3b3838823a3e1baeb9e7568d2271971fc3b9e836 branch: main author: ivonastojanovic <[email protected]> committer: pablogsal <[email protected]> date: 2025-12-12T01:36:28Z summary:
gh-138122: Add inverted flamegraph (#142288) Co-authored-by: Pablo Galindo Salgado <[email protected]> files: A Misc/NEWS.d/next/Library/2025-12-09-22-11-59.gh-issue-138122.CsoBEo.rst M Lib/profiling/sampling/_flamegraph_assets/flamegraph.css M Lib/profiling/sampling/_flamegraph_assets/flamegraph.js M Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html M Lib/profiling/sampling/_heatmap_assets/heatmap.css M Lib/profiling/sampling/_shared_assets/base.css diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css index c3b1d955f7f526..2940f263f7ff29 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css @@ -274,6 +274,20 @@ body.resizing-sidebar { flex: 1; } +/* View Mode Section */ +.view-mode-section { + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +.view-mode-section .section-title { + margin-bottom: 12px; +} + +.view-mode-section .toggle-switch { + justify-content: center; +} + /* Collapsible sections */ .collapsible .section-header { display: flex; @@ -986,3 +1000,26 @@ body.resizing-sidebar { grid-template-columns: 1fr; } } + +/* -------------------------------------------------------------------------- + Flamegraph Root Node Styling + -------------------------------------------------------------------------- */ + +/* Style the root node - no border, themed text */ +.d3-flame-graph g:first-of-type rect { + stroke: none; +} + +.d3-flame-graph g:first-of-type .d3-flame-graph-label { + color: var(--text-muted); +} + +/* -------------------------------------------------------------------------- + Flamegraph-Specific Toggle Override + -------------------------------------------------------------------------- */ + +#toggle-invert .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 dc7bfed602f32a..fb81094521815e 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -2,8 +2,10 @@ const EMBEDDED_DATA = {{FLAMEGRAPH_DATA}}; // Global string table for resolving string indices let stringTable = []; -let originalData = null; +let normalData = null; +let invertedData = null; let currentThreadFilter = 'all'; +let isInverted = false; // Heat colors are now defined in CSS variables (--heat-1 through --heat-8) // and automatically switch with theme changes - no JS color arrays needed! @@ -94,9 +96,10 @@ function toggleTheme() { } // Re-render flamegraph with new theme colors - if (window.flamegraphData && originalData) { - const tooltip = createPythonTooltip(originalData); - const chart = createFlamegraph(tooltip, originalData.value); + if (window.flamegraphData && normalData) { + const currentData = isInverted ? invertedData : normalData; + const tooltip = createPythonTooltip(currentData); + const chart = createFlamegraph(tooltip, currentData.value); renderFlamegraph(chart, window.flamegraphData); } } @@ -485,6 +488,9 @@ function createFlamegraph(tooltip, rootValue) { .tooltip(tooltip) .inverted(true) .setColorMapper(function (d) { + // Root node should be transparent + if (d.depth === 0) return 'transparent'; + const percentage = d.data.value / rootValue; const level = getHeatLevel(percentage); return heatColors[level]; @@ -796,16 +802,35 @@ function populateProfileSummary(data) { if (rateEl) rateEl.textContent = sampleRate > 0 ? formatNumber(Math.round(sampleRate)) : '--'; // Count unique functions - let functionCount = 0; - function countFunctions(node) { + // Use normal (non-inverted) tree structure, but respect thread filtering + const uniqueFunctions = new Set(); + function collectUniqueFunctions(node) { if (!node) return; - functionCount++; - if (node.children) node.children.forEach(countFunctions); + const filename = resolveString(node.filename) || 'unknown'; + const funcname = resolveString(node.funcname) || resolveString(node.name) || 'unknown'; + const lineno = node.lineno || 0; + const key = `${filename}|${lineno}|${funcname}`; + uniqueFunctions.add(key); + if (node.children) node.children.forEach(collectUniqueFunctions); + } + // In inverted mode, use normalData (with thread filter if active) + // In normal mode, use the passed data (already has thread filter applied if any) + let functionCountSource; + if (!normalData) { + functionCountSource = data; + } else if (isInverted) { + if (currentThreadFilter !== 'all') { + functionCountSource = filterDataByThread(normalData, parseInt(currentThreadFilter)); + } else { + functionCountSource = normalData; + } + } else { + functionCountSource = data; } - countFunctions(data); + collectUniqueFunctions(functionCountSource); const functionsEl = document.getElementById('stat-functions'); - if (functionsEl) functionsEl.textContent = formatNumber(functionCount); + if (functionsEl) functionsEl.textContent = formatNumber(uniqueFunctions.size); // Efficiency bar if (errorRate !== undefined && errorRate !== null) { @@ -840,14 +865,31 @@ function populateProfileSummary(data) { // ============================================================================ function populateStats(data) { - const totalSamples = data.value || 0; - // Populate profile summary populateProfileSummary(data); // Populate thread statistics if available populateThreadStats(data); + // For hotspots: use normal (non-inverted) tree structure, but respect thread filtering. + // In inverted view, the tree structure changes but the hottest functions remain the same. + // However, if a thread filter is active, we need to show that thread's hotspots. + let hotspotSource; + if (!normalData) { + hotspotSource = data; + } else if (isInverted) { + // In inverted mode, use normalData (with thread filter if active) + if (currentThreadFilter !== 'all') { + hotspotSource = filterDataByThread(normalData, parseInt(currentThreadFilter)); + } else { + hotspotSource = normalData; + } + } else { + // In normal mode, use the passed data (already has thread filter applied if any) + hotspotSource = data; + } + const totalSamples = hotspotSource.value || 0; + const functionMap = new Map(); function collectFunctions(node) { @@ -905,7 +947,7 @@ function populateStats(data) { } } - collectFunctions(data); + collectFunctions(hotspotSource); const hotSpots = Array.from(functionMap.values()) .filter(f => f.directPercent > 0.5) @@ -997,19 +1039,20 @@ function initThreadFilter(data) { function filterByThread() { const threadFilter = document.getElementById('thread-filter'); - if (!threadFilter || !originalData) return; + if (!threadFilter || !normalData) return; const selectedThread = threadFilter.value; currentThreadFilter = selectedThread; + const baseData = isInverted ? invertedData : normalData; let filteredData; let selectedThreadId = null; if (selectedThread === 'all') { - filteredData = originalData; + filteredData = baseData; } else { selectedThreadId = parseInt(selectedThread, 10); - filteredData = filterDataByThread(originalData, selectedThreadId); + filteredData = filterDataByThread(baseData, selectedThreadId); if (filteredData.strings) { stringTable = filteredData.strings; @@ -1021,7 +1064,7 @@ function filterByThread() { const chart = createFlamegraph(tooltip, filteredData.value); renderFlamegraph(chart, filteredData); - populateThreadStats(originalData, selectedThreadId); + populateThreadStats(baseData, selectedThreadId); } function filterDataByThread(data, threadId) { @@ -1089,6 +1132,137 @@ function exportSVG() { URL.revokeObjectURL(url); } +// ============================================================================ +// Inverted Flamegraph +// ============================================================================ + +// Example: "file.py|10|foo" or "~|0|<GC>" for special frames +function getInvertNodeKey(node) { + return `${node.filename || '~'}|${node.lineno || 0}|${node.funcname || node.name}`; +} + +function accumulateInvertedNode(parent, stackFrame, leaf) { + const key = getInvertNodeKey(stackFrame); + + if (!parent.children[key]) { + parent.children[key] = { + name: stackFrame.name, + value: 0, + children: {}, + filename: stackFrame.filename, + lineno: stackFrame.lineno, + funcname: stackFrame.funcname, + source: stackFrame.source, + threads: new Set() + }; + } + + const node = parent.children[key]; + node.value += leaf.value; + if (leaf.threads) { + leaf.threads.forEach(t => node.threads.add(t)); + } + + return node; +} + +function processLeaf(invertedRoot, path, leafNode) { + if (!path || path.length === 0) { + return; + } + + let invertedParent = accumulateInvertedNode(invertedRoot, leafNode, leafNode); + + // Walk backwards through the call stack + for (let i = path.length - 2; i >= 0; i--) { + invertedParent = accumulateInvertedNode(invertedParent, path[i], leafNode); + } +} + +function traverseInvert(path, currentNode, invertedRoot) { + const children = currentNode.children || []; + const childThreads = new Set(children.flatMap(c => c.threads || [])); + const selfThreads = (currentNode.threads || []).filter(t => !childThreads.has(t)); + + if (selfThreads.length > 0) { + processLeaf(invertedRoot, path, { ...currentNode, threads: selfThreads }); + } + + children.forEach(child => traverseInvert(path.concat([child]), child, invertedRoot)); +} + +function convertInvertDictToArray(node) { + if (node.threads instanceof Set) { + node.threads = Array.from(node.threads).sort((a, b) => a - b); + } + + const children = node.children; + if (children && typeof children === 'object' && !Array.isArray(children)) { + node.children = Object.values(children); + node.children.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name)); + node.children.forEach(convertInvertDictToArray); + } + return node; +} + +function generateInvertedFlamegraph(data) { + const invertedRoot = { + name: data.name, + value: data.value, + children: {}, + stats: data.stats, + threads: data.threads + }; + + const children = data.children || []; + if (children.length === 0) { + // Single-frame tree: the root is its own leaf + processLeaf(invertedRoot, [data], data); + } else { + children.forEach(child => traverseInvert([child], child, invertedRoot)); + } + + convertInvertDictToArray(invertedRoot); + return invertedRoot; +} + +function updateToggleUI(toggleId, isOn) { + const toggle = document.getElementById(toggleId); + if (toggle) { + const track = toggle.querySelector('.toggle-track'); + const labels = toggle.querySelectorAll('.toggle-label'); + if (isOn) { + track.classList.add('on'); + labels[0].classList.remove('active'); + labels[1].classList.add('active'); + } else { + track.classList.remove('on'); + labels[0].classList.add('active'); + labels[1].classList.remove('active'); + } + } +} + +function toggleInvert() { + isInverted = !isInverted; + updateToggleUI('toggle-invert', isInverted); + + // Build inverted data on first use + if (isInverted && !invertedData) { + invertedData = generateInvertedFlamegraph(normalData); + } + + let dataToRender = isInverted ? invertedData : normalData; + + if (currentThreadFilter !== 'all') { + dataToRender = filterDataByThread(dataToRender, parseInt(currentThreadFilter)); + } + + const tooltip = createPythonTooltip(dataToRender); + const chart = createFlamegraph(tooltip, dataToRender.value); + renderFlamegraph(chart, dataToRender); +} + // ============================================================================ // Initialization // ============================================================================ @@ -1098,24 +1272,32 @@ function initFlamegraph() { restoreUIState(); setupLogos(); - let processedData = EMBEDDED_DATA; if (EMBEDDED_DATA.strings) { stringTable = EMBEDDED_DATA.strings; - processedData = resolveStringIndices(EMBEDDED_DATA); + normalData = resolveStringIndices(EMBEDDED_DATA); + } else { + normalData = EMBEDDED_DATA; } // Initialize opcode mapping from embedded data initOpcodeMapping(EMBEDDED_DATA); - originalData = processedData; - initThreadFilter(processedData); + // Inverted data will be built on first toggle + invertedData = null; + + initThreadFilter(normalData); - const tooltip = createPythonTooltip(processedData); - const chart = createFlamegraph(tooltip, processedData.value); - renderFlamegraph(chart, processedData); + const tooltip = createPythonTooltip(normalData); + const chart = createFlamegraph(tooltip, normalData.value); + renderFlamegraph(chart, normalData); initSearchHandlers(); initSidebarResize(); handleResize(); + + const toggleInvertBtn = document.getElementById('toggle-invert'); + if (toggleInvertBtn) { + toggleInvertBtn.addEventListener('click', toggleInvert); + } } if (document.readyState === "loading") { diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html b/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html index 05277fb225c86f..211296a708643f 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html @@ -76,6 +76,16 @@ <div class="sidebar-logo-img"><!-- INLINE_LOGO --></div> </div> + <!-- View Mode Section --> + <section class="sidebar-section view-mode-section"> + <h3 class="section-title">View Mode</h3> + <div class="toggle-switch" id="toggle-invert"> + <span class="toggle-label active">Flamegraph</span> + <div class="toggle-track"></div> + <span class="toggle-label">Inverted Flamegraph</span> + </div> + </section> + <!-- Profile Summary Section --> <section class="sidebar-section collapsible" id="summary-section"> <button class="section-header" onclick="toggleSection('summary-section')"> diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap.css b/Lib/profiling/sampling/_heatmap_assets/heatmap.css index dfb6fd69b40ee6..4fba9d866acb46 100644 --- a/Lib/profiling/sampling/_heatmap_assets/heatmap.css +++ b/Lib/profiling/sampling/_heatmap_assets/heatmap.css @@ -666,95 +666,7 @@ margin-left: auto; } -/* Toggle Switch Styles */ -.toggle-switch { - display: inline-flex; - align-items: center; - gap: 8px; - cursor: pointer; - user-select: none; - font-family: var(--font-sans); - transition: opacity var(--transition-fast); - flex-shrink: 0; -} - -.toggle-switch:hover { - opacity: 0.85; -} - -.toggle-switch:focus-visible { - border-radius: 4px; -} - -.toggle-switch .toggle-label { - font-size: 11px; - font-weight: 500; - color: var(--text-muted); - transition: color var(--transition-fast); - white-space: nowrap; - display: inline-flex; - flex-direction: column; -} - -.toggle-switch .toggle-label.active { - color: var(--text-primary); - font-weight: 600; -} - -/* Reserve space for bold text to prevent layout shift on toggle */ -.toggle-switch .toggle-label::after { - content: attr(data-text); - font-weight: 600; - height: 0; - visibility: hidden; -} - -.toggle-switch.disabled { - opacity: 0.4; - pointer-events: none; - cursor: not-allowed; -} - -.toggle-track { - position: relative; - width: 36px; - height: 20px; - background: var(--bg-tertiary); - border: 2px solid var(--border); - border-radius: 12px; - transition: all var(--transition-fast); - box-shadow: inset var(--shadow-sm); -} - -.toggle-track:hover { - border-color: var(--text-muted); -} - -.toggle-track.on { - background: var(--accent); - border-color: var(--accent); - box-shadow: 0 0 8px var(--accent-glow); -} - -.toggle-track::after { - content: ''; - position: absolute; - top: 1px; - left: 1px; - width: 14px; - height: 14px; - background: white; - border-radius: 50%; - box-shadow: var(--shadow-sm); - transition: all var(--transition-fast); -} - -.toggle-track.on::after { - transform: translateX(16px); - box-shadow: var(--shadow-md); -} - -/* Specific toggle overrides */ +/* Heatmap-Specific Toggle Overrides */ #toggle-color-mode .toggle-track.on { background: #8e44ad; border-color: #8e44ad; diff --git a/Lib/profiling/sampling/_shared_assets/base.css b/Lib/profiling/sampling/_shared_assets/base.css index c88cf58eef9260..d51636a3bf7d61 100644 --- a/Lib/profiling/sampling/_shared_assets/base.css +++ b/Lib/profiling/sampling/_shared_assets/base.css @@ -408,3 +408,90 @@ a.toolbar-btn:focus-visible { display: none; } } + +/* -------------------------------------------------------------------------- + Toggle Switch + -------------------------------------------------------------------------- */ + +.toggle-switch { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + font-family: var(--font-sans); + transition: opacity var(--transition-fast); + flex-shrink: 0; +} + +.toggle-switch:hover { + opacity: 0.85; +} + +.toggle-switch .toggle-label { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + transition: color var(--transition-fast); + white-space: nowrap; + display: inline-flex; + flex-direction: column; +} + +.toggle-switch .toggle-label.active { + color: var(--text-primary); + font-weight: 600; +} + +/* Reserve space for bold text to prevent layout shift on toggle */ +.toggle-switch .toggle-label::after { + content: attr(data-text); + font-weight: 600; + height: 0; + visibility: hidden; +} + +.toggle-switch.disabled { + opacity: 0.4; + pointer-events: none; + cursor: not-allowed; +} + +.toggle-track { + position: relative; + width: 36px; + height: 20px; + background: var(--bg-tertiary); + border: 2px solid var(--border); + border-radius: 12px; + transition: all var(--transition-fast); + box-shadow: inset var(--shadow-sm); +} + +.toggle-track:hover { + border-color: var(--text-muted); +} + +.toggle-track.on { + background: var(--accent); + border-color: var(--accent); + box-shadow: 0 0 8px var(--accent-glow); +} + +.toggle-track::after { + content: ''; + position: absolute; + top: 1px; + left: 1px; + width: 14px; + height: 14px; + background: white; + border-radius: 50%; + box-shadow: var(--shadow-sm); + transition: all var(--transition-fast); +} + +.toggle-track.on::after { + transform: translateX(16px); + box-shadow: var(--shadow-md); +} diff --git a/Misc/NEWS.d/next/Library/2025-12-09-22-11-59.gh-issue-138122.CsoBEo.rst b/Misc/NEWS.d/next/Library/2025-12-09-22-11-59.gh-issue-138122.CsoBEo.rst new file mode 100644 index 00000000000000..5aaa2cba99aa4d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-09-22-11-59.gh-issue-138122.CsoBEo.rst @@ -0,0 +1,8 @@ +The ``profiling.sampling`` flamegraph profiler now supports inverted +flamegraph view that aggregates all leaf nodes. In a standard flamegraph, if +a hot function is called from multiple locations, it appears multiple times +as separate leaf nodes. In the inverted flamegraph, all occurrences of the +same leaf function are merged into a single aggregated node at the root, +showing the total hotness of that function in one place. The children of each +aggregated node represent its callers, making it easier to identify which +functions consume the most CPU time and where they are called from. _______________________________________________ Python-checkins mailing list -- [email protected] To unsubscribe send an email to [email protected] https://mail.python.org/mailman3//lists/python-checkins.python.org Member address: [email protected]
