]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-138122: Add inverted flamegraph (#142288)
authorivonastojanovic <80911834+ivonastojanovic@users.noreply.github.com>
Fri, 12 Dec 2025 01:36:28 +0000 (01:36 +0000)
committerGitHub <noreply@github.com>
Fri, 12 Dec 2025 01:36:28 +0000 (01:36 +0000)
Co-authored-by: Pablo Galindo Salgado <pablogsal@gmail.com>
Lib/profiling/sampling/_flamegraph_assets/flamegraph.css
Lib/profiling/sampling/_flamegraph_assets/flamegraph.js
Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html
Lib/profiling/sampling/_heatmap_assets/heatmap.css
Lib/profiling/sampling/_shared_assets/base.css
Misc/NEWS.d/next/Library/2025-12-09-22-11-59.gh-issue-138122.CsoBEo.rst [new file with mode: 0644]

index c3b1d955f7f52641fd1d033da14bc8fed3d8329a..2940f263f7ff2919f0081c2a96811e0efbb3c170 100644 (file)
@@ -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);
+}
index dc7bfed602f32a0becb5dd2e990243ff3900b111..fb81094521815e0f055c296ed71ddc46adc93cd1 100644 (file)
@@ -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") {
index 05277fb225c86fd43dc4373b0ccd5854128c1b4e..211296a708643fea25de3a8ec8caf9284cf358a2 100644 (file)
               <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')">
index dfb6fd69b40ee651c5f67d506e3dddd7eb38bcb9..4fba9d866acb4670995846774d7fd35bfd4b1acb 100644 (file)
   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;
index c88cf58eef9260c5cf183403a4567d7479e644f4..d51636a3bf7d6190ca3a84b04f469aa6741a5b70 100644 (file)
@@ -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 (file)
index 0000000..5aaa2cb
--- /dev/null
@@ -0,0 +1,8 @@
+The ``profiling.sampling`` flamegraph profiler now supports inverted\r
+flamegraph view that aggregates all leaf nodes. In a standard flamegraph, if\r
+a hot function is called from multiple locations, it appears multiple times\r
+as separate leaf nodes. In the inverted flamegraph, all occurrences of the\r
+same leaf function are merged into a single aggregated node at the root,\r
+showing the total hotness of that function in one place. The children of each\r
+aggregated node represent its callers, making it easier to identify which\r
+functions consume the most CPU time and where they are called from.