Skip to content
Draft
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
27 changes: 27 additions & 0 deletions Lib/profiling/sampling/_flamegraph_assets/flamegraph.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -899,3 +913,16 @@ 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);
}
224 changes: 200 additions & 24 deletions Lib/profiling/sampling/_flamegraph_assets/flamegraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -68,9 +70,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);
}
}
Expand Down Expand Up @@ -380,6 +383,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];
Expand Down Expand Up @@ -687,16 +693,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) {
Expand Down Expand Up @@ -731,14 +756,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) {
Expand Down Expand Up @@ -796,7 +838,7 @@ function populateStats(data) {
}
}

collectFunctions(data);
collectFunctions(hotspotSource);

const hotSpots = Array.from(functionMap.values())
.filter(f => f.directPercent > 0.5)
Expand Down Expand Up @@ -888,19 +930,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;
Expand All @@ -912,7 +955,7 @@ function filterByThread() {
const chart = createFlamegraph(tooltip, filteredData.value);
renderFlamegraph(chart, filteredData);

populateThreadStats(originalData, selectedThreadId);
populateThreadStats(baseData, selectedThreadId);
}

function filterDataByThread(data, threadId) {
Expand Down Expand Up @@ -980,6 +1023,131 @@ 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 traverseInvert(path, currentNode, invertedRoot) {
if (!currentNode.children || currentNode.children.length === 0) {
// We've reached a leaf node
if (!path || path.length === 0) {
return;
}

let invertedParent = accumulateInvertedNode(invertedRoot, currentNode, currentNode);

// Walk backwards through the call stack
for (let i = path.length - 2; i >= 0; i--) {
invertedParent = accumulateInvertedNode(invertedParent, path[i], currentNode);
}
} else {
// Not a leaf, continue traversing down the tree
for (const child of currentNode.children) {
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
};

data.children?.forEach(child => {
traverseInvert([child], child, invertedRoot);
});

// Convert children dictionaries to arrays for rendering
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
// ============================================================================
Expand All @@ -988,21 +1156,29 @@ function initFlamegraph() {
ensureLibraryLoaded();
restoreUIState();

let processedData = EMBEDDED_DATA;
if (EMBEDDED_DATA.strings) {
stringTable = EMBEDDED_DATA.strings;
processedData = resolveStringIndices(EMBEDDED_DATA);
normalData = resolveStringIndices(EMBEDDED_DATA);
} else {
normalData = EMBEDDED_DATA;
}

originalData = processedData;
initThreadFilter(processedData);
// Inverted data will be built on first toggle
invertedData = null;

const tooltip = createPythonTooltip(processedData);
const chart = createFlamegraph(tooltip, processedData.value);
renderFlamegraph(chart, processedData);
initThreadFilter(normalData);

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") {
Expand Down
10 changes: 10 additions & 0 deletions Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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')">
Expand Down
Loading
Loading