Skip to content

Commit 3b38388

Browse files
gh-138122: Add inverted flamegraph (#142288)
Co-authored-by: Pablo Galindo Salgado <[email protected]>
1 parent 1356fbe commit 3b38388

File tree

6 files changed

+349
-113
lines changed

6 files changed

+349
-113
lines changed

Lib/profiling/sampling/_flamegraph_assets/flamegraph.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,20 @@ body.resizing-sidebar {
274274
flex: 1;
275275
}
276276

277+
/* View Mode Section */
278+
.view-mode-section {
279+
padding-bottom: 20px;
280+
border-bottom: 1px solid var(--border);
281+
}
282+
283+
.view-mode-section .section-title {
284+
margin-bottom: 12px;
285+
}
286+
287+
.view-mode-section .toggle-switch {
288+
justify-content: center;
289+
}
290+
277291
/* Collapsible sections */
278292
.collapsible .section-header {
279293
display: flex;
@@ -986,3 +1000,26 @@ body.resizing-sidebar {
9861000
grid-template-columns: 1fr;
9871001
}
9881002
}
1003+
1004+
/* --------------------------------------------------------------------------
1005+
Flamegraph Root Node Styling
1006+
-------------------------------------------------------------------------- */
1007+
1008+
/* Style the root node - no border, themed text */
1009+
.d3-flame-graph g:first-of-type rect {
1010+
stroke: none;
1011+
}
1012+
1013+
.d3-flame-graph g:first-of-type .d3-flame-graph-label {
1014+
color: var(--text-muted);
1015+
}
1016+
1017+
/* --------------------------------------------------------------------------
1018+
Flamegraph-Specific Toggle Override
1019+
-------------------------------------------------------------------------- */
1020+
1021+
#toggle-invert .toggle-track.on {
1022+
background: #8e44ad;
1023+
border-color: #8e44ad;
1024+
box-shadow: 0 0 8px rgba(142, 68, 173, 0.3);
1025+
}

Lib/profiling/sampling/_flamegraph_assets/flamegraph.js

Lines changed: 206 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ const EMBEDDED_DATA = {{FLAMEGRAPH_DATA}};
22

33
// Global string table for resolving string indices
44
let stringTable = [];
5-
let originalData = null;
5+
let normalData = null;
6+
let invertedData = null;
67
let currentThreadFilter = 'all';
8+
let isInverted = false;
79

810
// Heat colors are now defined in CSS variables (--heat-1 through --heat-8)
911
// and automatically switch with theme changes - no JS color arrays needed!
@@ -94,9 +96,10 @@ function toggleTheme() {
9496
}
9597

9698
// Re-render flamegraph with new theme colors
97-
if (window.flamegraphData && originalData) {
98-
const tooltip = createPythonTooltip(originalData);
99-
const chart = createFlamegraph(tooltip, originalData.value);
99+
if (window.flamegraphData && normalData) {
100+
const currentData = isInverted ? invertedData : normalData;
101+
const tooltip = createPythonTooltip(currentData);
102+
const chart = createFlamegraph(tooltip, currentData.value);
100103
renderFlamegraph(chart, window.flamegraphData);
101104
}
102105
}
@@ -485,6 +488,9 @@ function createFlamegraph(tooltip, rootValue) {
485488
.tooltip(tooltip)
486489
.inverted(true)
487490
.setColorMapper(function (d) {
491+
// Root node should be transparent
492+
if (d.depth === 0) return 'transparent';
493+
488494
const percentage = d.data.value / rootValue;
489495
const level = getHeatLevel(percentage);
490496
return heatColors[level];
@@ -796,16 +802,35 @@ function populateProfileSummary(data) {
796802
if (rateEl) rateEl.textContent = sampleRate > 0 ? formatNumber(Math.round(sampleRate)) : '--';
797803

798804
// Count unique functions
799-
let functionCount = 0;
800-
function countFunctions(node) {
805+
// Use normal (non-inverted) tree structure, but respect thread filtering
806+
const uniqueFunctions = new Set();
807+
function collectUniqueFunctions(node) {
801808
if (!node) return;
802-
functionCount++;
803-
if (node.children) node.children.forEach(countFunctions);
809+
const filename = resolveString(node.filename) || 'unknown';
810+
const funcname = resolveString(node.funcname) || resolveString(node.name) || 'unknown';
811+
const lineno = node.lineno || 0;
812+
const key = `${filename}|${lineno}|${funcname}`;
813+
uniqueFunctions.add(key);
814+
if (node.children) node.children.forEach(collectUniqueFunctions);
815+
}
816+
// In inverted mode, use normalData (with thread filter if active)
817+
// In normal mode, use the passed data (already has thread filter applied if any)
818+
let functionCountSource;
819+
if (!normalData) {
820+
functionCountSource = data;
821+
} else if (isInverted) {
822+
if (currentThreadFilter !== 'all') {
823+
functionCountSource = filterDataByThread(normalData, parseInt(currentThreadFilter));
824+
} else {
825+
functionCountSource = normalData;
826+
}
827+
} else {
828+
functionCountSource = data;
804829
}
805-
countFunctions(data);
830+
collectUniqueFunctions(functionCountSource);
806831

807832
const functionsEl = document.getElementById('stat-functions');
808-
if (functionsEl) functionsEl.textContent = formatNumber(functionCount);
833+
if (functionsEl) functionsEl.textContent = formatNumber(uniqueFunctions.size);
809834

810835
// Efficiency bar
811836
if (errorRate !== undefined && errorRate !== null) {
@@ -840,14 +865,31 @@ function populateProfileSummary(data) {
840865
// ============================================================================
841866

842867
function populateStats(data) {
843-
const totalSamples = data.value || 0;
844-
845868
// Populate profile summary
846869
populateProfileSummary(data);
847870

848871
// Populate thread statistics if available
849872
populateThreadStats(data);
850873

874+
// For hotspots: use normal (non-inverted) tree structure, but respect thread filtering.
875+
// In inverted view, the tree structure changes but the hottest functions remain the same.
876+
// However, if a thread filter is active, we need to show that thread's hotspots.
877+
let hotspotSource;
878+
if (!normalData) {
879+
hotspotSource = data;
880+
} else if (isInverted) {
881+
// In inverted mode, use normalData (with thread filter if active)
882+
if (currentThreadFilter !== 'all') {
883+
hotspotSource = filterDataByThread(normalData, parseInt(currentThreadFilter));
884+
} else {
885+
hotspotSource = normalData;
886+
}
887+
} else {
888+
// In normal mode, use the passed data (already has thread filter applied if any)
889+
hotspotSource = data;
890+
}
891+
const totalSamples = hotspotSource.value || 0;
892+
851893
const functionMap = new Map();
852894

853895
function collectFunctions(node) {
@@ -905,7 +947,7 @@ function populateStats(data) {
905947
}
906948
}
907949

908-
collectFunctions(data);
950+
collectFunctions(hotspotSource);
909951

910952
const hotSpots = Array.from(functionMap.values())
911953
.filter(f => f.directPercent > 0.5)
@@ -997,19 +1039,20 @@ function initThreadFilter(data) {
9971039

9981040
function filterByThread() {
9991041
const threadFilter = document.getElementById('thread-filter');
1000-
if (!threadFilter || !originalData) return;
1042+
if (!threadFilter || !normalData) return;
10011043

10021044
const selectedThread = threadFilter.value;
10031045
currentThreadFilter = selectedThread;
1046+
const baseData = isInverted ? invertedData : normalData;
10041047

10051048
let filteredData;
10061049
let selectedThreadId = null;
10071050

10081051
if (selectedThread === 'all') {
1009-
filteredData = originalData;
1052+
filteredData = baseData;
10101053
} else {
10111054
selectedThreadId = parseInt(selectedThread, 10);
1012-
filteredData = filterDataByThread(originalData, selectedThreadId);
1055+
filteredData = filterDataByThread(baseData, selectedThreadId);
10131056

10141057
if (filteredData.strings) {
10151058
stringTable = filteredData.strings;
@@ -1021,7 +1064,7 @@ function filterByThread() {
10211064
const chart = createFlamegraph(tooltip, filteredData.value);
10221065
renderFlamegraph(chart, filteredData);
10231066

1024-
populateThreadStats(originalData, selectedThreadId);
1067+
populateThreadStats(baseData, selectedThreadId);
10251068
}
10261069

10271070
function filterDataByThread(data, threadId) {
@@ -1089,6 +1132,137 @@ function exportSVG() {
10891132
URL.revokeObjectURL(url);
10901133
}
10911134

1135+
// ============================================================================
1136+
// Inverted Flamegraph
1137+
// ============================================================================
1138+
1139+
// Example: "file.py|10|foo" or "~|0|<GC>" for special frames
1140+
function getInvertNodeKey(node) {
1141+
return `${node.filename || '~'}|${node.lineno || 0}|${node.funcname || node.name}`;
1142+
}
1143+
1144+
function accumulateInvertedNode(parent, stackFrame, leaf) {
1145+
const key = getInvertNodeKey(stackFrame);
1146+
1147+
if (!parent.children[key]) {
1148+
parent.children[key] = {
1149+
name: stackFrame.name,
1150+
value: 0,
1151+
children: {},
1152+
filename: stackFrame.filename,
1153+
lineno: stackFrame.lineno,
1154+
funcname: stackFrame.funcname,
1155+
source: stackFrame.source,
1156+
threads: new Set()
1157+
};
1158+
}
1159+
1160+
const node = parent.children[key];
1161+
node.value += leaf.value;
1162+
if (leaf.threads) {
1163+
leaf.threads.forEach(t => node.threads.add(t));
1164+
}
1165+
1166+
return node;
1167+
}
1168+
1169+
function processLeaf(invertedRoot, path, leafNode) {
1170+
if (!path || path.length === 0) {
1171+
return;
1172+
}
1173+
1174+
let invertedParent = accumulateInvertedNode(invertedRoot, leafNode, leafNode);
1175+
1176+
// Walk backwards through the call stack
1177+
for (let i = path.length - 2; i >= 0; i--) {
1178+
invertedParent = accumulateInvertedNode(invertedParent, path[i], leafNode);
1179+
}
1180+
}
1181+
1182+
function traverseInvert(path, currentNode, invertedRoot) {
1183+
const children = currentNode.children || [];
1184+
const childThreads = new Set(children.flatMap(c => c.threads || []));
1185+
const selfThreads = (currentNode.threads || []).filter(t => !childThreads.has(t));
1186+
1187+
if (selfThreads.length > 0) {
1188+
processLeaf(invertedRoot, path, { ...currentNode, threads: selfThreads });
1189+
}
1190+
1191+
children.forEach(child => traverseInvert(path.concat([child]), child, invertedRoot));
1192+
}
1193+
1194+
function convertInvertDictToArray(node) {
1195+
if (node.threads instanceof Set) {
1196+
node.threads = Array.from(node.threads).sort((a, b) => a - b);
1197+
}
1198+
1199+
const children = node.children;
1200+
if (children && typeof children === 'object' && !Array.isArray(children)) {
1201+
node.children = Object.values(children);
1202+
node.children.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name));
1203+
node.children.forEach(convertInvertDictToArray);
1204+
}
1205+
return node;
1206+
}
1207+
1208+
function generateInvertedFlamegraph(data) {
1209+
const invertedRoot = {
1210+
name: data.name,
1211+
value: data.value,
1212+
children: {},
1213+
stats: data.stats,
1214+
threads: data.threads
1215+
};
1216+
1217+
const children = data.children || [];
1218+
if (children.length === 0) {
1219+
// Single-frame tree: the root is its own leaf
1220+
processLeaf(invertedRoot, [data], data);
1221+
} else {
1222+
children.forEach(child => traverseInvert([child], child, invertedRoot));
1223+
}
1224+
1225+
convertInvertDictToArray(invertedRoot);
1226+
return invertedRoot;
1227+
}
1228+
1229+
function updateToggleUI(toggleId, isOn) {
1230+
const toggle = document.getElementById(toggleId);
1231+
if (toggle) {
1232+
const track = toggle.querySelector('.toggle-track');
1233+
const labels = toggle.querySelectorAll('.toggle-label');
1234+
if (isOn) {
1235+
track.classList.add('on');
1236+
labels[0].classList.remove('active');
1237+
labels[1].classList.add('active');
1238+
} else {
1239+
track.classList.remove('on');
1240+
labels[0].classList.add('active');
1241+
labels[1].classList.remove('active');
1242+
}
1243+
}
1244+
}
1245+
1246+
function toggleInvert() {
1247+
isInverted = !isInverted;
1248+
updateToggleUI('toggle-invert', isInverted);
1249+
1250+
// Build inverted data on first use
1251+
if (isInverted && !invertedData) {
1252+
invertedData = generateInvertedFlamegraph(normalData);
1253+
}
1254+
1255+
let dataToRender = isInverted ? invertedData : normalData;
1256+
1257+
if (currentThreadFilter !== 'all') {
1258+
dataToRender = filterDataByThread(dataToRender, parseInt(currentThreadFilter));
1259+
}
1260+
1261+
const tooltip = createPythonTooltip(dataToRender);
1262+
const chart = createFlamegraph(tooltip, dataToRender.value);
1263+
renderFlamegraph(chart, dataToRender);
1264+
}
1265+
10921266
// ============================================================================
10931267
// Initialization
10941268
// ============================================================================
@@ -1098,24 +1272,32 @@ function initFlamegraph() {
10981272
restoreUIState();
10991273
setupLogos();
11001274

1101-
let processedData = EMBEDDED_DATA;
11021275
if (EMBEDDED_DATA.strings) {
11031276
stringTable = EMBEDDED_DATA.strings;
1104-
processedData = resolveStringIndices(EMBEDDED_DATA);
1277+
normalData = resolveStringIndices(EMBEDDED_DATA);
1278+
} else {
1279+
normalData = EMBEDDED_DATA;
11051280
}
11061281

11071282
// Initialize opcode mapping from embedded data
11081283
initOpcodeMapping(EMBEDDED_DATA);
11091284

1110-
originalData = processedData;
1111-
initThreadFilter(processedData);
1285+
// Inverted data will be built on first toggle
1286+
invertedData = null;
1287+
1288+
initThreadFilter(normalData);
11121289

1113-
const tooltip = createPythonTooltip(processedData);
1114-
const chart = createFlamegraph(tooltip, processedData.value);
1115-
renderFlamegraph(chart, processedData);
1290+
const tooltip = createPythonTooltip(normalData);
1291+
const chart = createFlamegraph(tooltip, normalData.value);
1292+
renderFlamegraph(chart, normalData);
11161293
initSearchHandlers();
11171294
initSidebarResize();
11181295
handleResize();
1296+
1297+
const toggleInvertBtn = document.getElementById('toggle-invert');
1298+
if (toggleInvertBtn) {
1299+
toggleInvertBtn.addEventListener('click', toggleInvert);
1300+
}
11191301
}
11201302

11211303
if (document.readyState === "loading") {

Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@
7676
<div class="sidebar-logo-img"><!-- INLINE_LOGO --></div>
7777
</div>
7878

79+
<!-- View Mode Section -->
80+
<section class="sidebar-section view-mode-section">
81+
<h3 class="section-title">View Mode</h3>
82+
<div class="toggle-switch" id="toggle-invert">
83+
<span class="toggle-label active">Flamegraph</span>
84+
<div class="toggle-track"></div>
85+
<span class="toggle-label">Inverted Flamegraph</span>
86+
</div>
87+
</section>
88+
7989
<!-- Profile Summary Section -->
8090
<section class="sidebar-section collapsible" id="summary-section">
8191
<button class="section-header" onclick="toggleSection('summary-section')">

0 commit comments

Comments
 (0)