mirror of
https://github.com/python/cpython.git
synced 2026-04-15 08:11:10 +00:00
Differential flame graphs compare two profiling runs and highlight where performance has changed. This makes it easier to detect regressions introduced by code changes and to verify that optimizations have the intended effect. The visualization renders the current profile with frame widths representing current time consumption. Color is then applied to show the difference relative to the baseline profile: red gradients indicate regressions, while blue gradients indicate improvements. Some call paths may disappear entirely between profiles. These are referred to as elided stacks and occur when optimizations remove code paths or when certain branches stop executing. When elided stacks are present, an "Elided" toggle is displayed, allowing the user to switch between the main differential view and a view showing only the removed paths. Co-authored-by: Pablo Galindo Salgado <Pablogsal@gmail.com>
1457 lines
48 KiB
JavaScript
1457 lines
48 KiB
JavaScript
const EMBEDDED_DATA = {{FLAMEGRAPH_DATA}};
|
|
|
|
// Global string table for resolving string indices
|
|
let stringTable = [];
|
|
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!
|
|
|
|
// Opcode mappings - loaded from embedded data (generated by Python)
|
|
let OPCODE_NAMES = {};
|
|
let DEOPT_MAP = {};
|
|
|
|
// Initialize opcode mappings from embedded data
|
|
function initOpcodeMapping(data) {
|
|
if (data && data.opcode_mapping) {
|
|
OPCODE_NAMES = data.opcode_mapping.names || {};
|
|
DEOPT_MAP = data.opcode_mapping.deopt || {};
|
|
}
|
|
}
|
|
|
|
// Get opcode info from opcode number
|
|
function getOpcodeInfo(opcode) {
|
|
const opname = OPCODE_NAMES[opcode] || `<${opcode}>`;
|
|
const baseOpcode = DEOPT_MAP[opcode];
|
|
const isSpecialized = baseOpcode !== undefined;
|
|
const baseOpname = isSpecialized ? (OPCODE_NAMES[baseOpcode] || `<${baseOpcode}>`) : opname;
|
|
|
|
return {
|
|
opname: opname,
|
|
baseOpname: baseOpname,
|
|
isSpecialized: isSpecialized
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// String Resolution
|
|
// ============================================================================
|
|
|
|
function resolveString(index, table = stringTable) {
|
|
if (index === null || index === undefined) {
|
|
return null;
|
|
}
|
|
if (typeof index === 'number' && index >= 0 && index < table.length) {
|
|
return table[index];
|
|
}
|
|
return String(index);
|
|
}
|
|
|
|
function resolveStringIndices(node, table) {
|
|
if (!node) return node;
|
|
|
|
const resolved = { ...node };
|
|
|
|
if (typeof resolved.name === 'number') {
|
|
resolved.name = resolveString(resolved.name, table);
|
|
}
|
|
if (typeof resolved.filename === 'number') {
|
|
resolved.filename = resolveString(resolved.filename, table);
|
|
}
|
|
if (typeof resolved.funcname === 'number') {
|
|
resolved.funcname = resolveString(resolved.funcname, table);
|
|
}
|
|
|
|
if (Array.isArray(resolved.source)) {
|
|
resolved.source = resolved.source.map(index =>
|
|
typeof index === 'number' ? resolveString(index, table) : index
|
|
);
|
|
}
|
|
|
|
if (Array.isArray(resolved.children)) {
|
|
resolved.children = resolved.children.map(child => resolveStringIndices(child, table));
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
function selectFlamegraphData() {
|
|
const baseData = isShowingElided ? elidedFlamegraphData : normalData;
|
|
|
|
if (!isInverted) {
|
|
return baseData;
|
|
}
|
|
|
|
if (isShowingElided) {
|
|
if (!invertedElidedData) {
|
|
invertedElidedData = generateInvertedFlamegraph(baseData);
|
|
}
|
|
return invertedElidedData;
|
|
}
|
|
|
|
if (!invertedData) {
|
|
invertedData = generateInvertedFlamegraph(baseData);
|
|
}
|
|
return invertedData;
|
|
}
|
|
|
|
function updateFlamegraphView() {
|
|
const selectedData = selectFlamegraphData();
|
|
const selectedThreadId = currentThreadFilter !== 'all' ? parseInt(currentThreadFilter, 10) : null;
|
|
const filteredData = selectedThreadId !== null ? filterDataByThread(selectedData, selectedThreadId) : selectedData;
|
|
const tooltip = createPythonTooltip(filteredData);
|
|
const chart = createFlamegraph(tooltip, filteredData.value, filteredData);
|
|
renderFlamegraph(chart, filteredData);
|
|
populateThreadStats(selectedData, selectedThreadId);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Theme & UI Controls
|
|
// ============================================================================
|
|
|
|
function toggleTheme() {
|
|
toggleAndSaveTheme();
|
|
|
|
// Re-render flamegraph with new theme colors
|
|
if (window.flamegraphData && normalData) {
|
|
updateFlamegraphView();
|
|
}
|
|
}
|
|
|
|
function toggleSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
if (sidebar) {
|
|
const isCollapsing = !sidebar.classList.contains('collapsed');
|
|
|
|
if (isCollapsing) {
|
|
// Save current width before collapsing
|
|
const currentWidth = sidebar.offsetWidth;
|
|
sidebar.dataset.expandedWidth = currentWidth;
|
|
localStorage.setItem('flamegraph-sidebar-width', currentWidth);
|
|
} else {
|
|
// Restore width when expanding
|
|
const savedWidth = sidebar.dataset.expandedWidth || localStorage.getItem('flamegraph-sidebar-width');
|
|
if (savedWidth) {
|
|
sidebar.style.width = savedWidth + 'px';
|
|
}
|
|
}
|
|
|
|
sidebar.classList.toggle('collapsed');
|
|
localStorage.setItem('flamegraph-sidebar', sidebar.classList.contains('collapsed') ? 'collapsed' : 'expanded');
|
|
|
|
// Resize chart after sidebar animation
|
|
setTimeout(() => {
|
|
resizeChart();
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
function resizeChart() {
|
|
if (window.flamegraphChart && window.flamegraphData) {
|
|
const chartArea = document.querySelector('.chart-area');
|
|
if (chartArea) {
|
|
window.flamegraphChart.width(chartArea.clientWidth - 32);
|
|
d3.select("#chart").datum(window.flamegraphData).call(window.flamegraphChart);
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleSection(sectionId) {
|
|
const section = document.getElementById(sectionId);
|
|
if (section) {
|
|
section.classList.toggle('collapsed');
|
|
// Save state
|
|
const collapsedSections = JSON.parse(localStorage.getItem('flamegraph-collapsed-sections') || '{}');
|
|
collapsedSections[sectionId] = section.classList.contains('collapsed');
|
|
localStorage.setItem('flamegraph-collapsed-sections', JSON.stringify(collapsedSections));
|
|
}
|
|
}
|
|
|
|
// Restore theme from localStorage, or use browser preference
|
|
function restoreUIState() {
|
|
applyTheme(getPreferredTheme());
|
|
|
|
// Restore sidebar state
|
|
const savedSidebar = localStorage.getItem('flamegraph-sidebar');
|
|
if (savedSidebar === 'collapsed') {
|
|
const sidebar = document.getElementById('sidebar');
|
|
if (sidebar) sidebar.classList.add('collapsed');
|
|
}
|
|
|
|
// Restore sidebar width
|
|
const savedWidth = localStorage.getItem('flamegraph-sidebar-width');
|
|
if (savedWidth) {
|
|
const sidebar = document.getElementById('sidebar');
|
|
if (sidebar) {
|
|
sidebar.style.width = savedWidth + 'px';
|
|
}
|
|
}
|
|
|
|
// Restore collapsed sections
|
|
const collapsedSections = JSON.parse(localStorage.getItem('flamegraph-collapsed-sections') || '{}');
|
|
for (const [sectionId, isCollapsed] of Object.entries(collapsedSections)) {
|
|
if (isCollapsed) {
|
|
const section = document.getElementById(sectionId);
|
|
if (section) section.classList.add('collapsed');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Logo/Favicon Setup
|
|
// ============================================================================
|
|
|
|
function setupLogos() {
|
|
const logo = document.querySelector('.sidebar-logo-img img');
|
|
if (!logo) return;
|
|
|
|
const navbarLogoContainer = document.getElementById('navbar-logo');
|
|
if (navbarLogoContainer) {
|
|
const navbarLogo = logo.cloneNode(true);
|
|
navbarLogoContainer.appendChild(navbarLogo);
|
|
}
|
|
|
|
const favicon = document.createElement('link');
|
|
favicon.rel = 'icon';
|
|
favicon.type = 'image/png';
|
|
favicon.href = logo.src;
|
|
document.head.appendChild(favicon);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Status Bar
|
|
// ============================================================================
|
|
|
|
function updateStatusBar(nodeData, rootValue) {
|
|
const funcname = resolveString(nodeData.funcname) || resolveString(nodeData.name) || "--";
|
|
const filename = resolveString(nodeData.filename) || "";
|
|
const lineno = nodeData.lineno;
|
|
const timeMs = (nodeData.value / 1000).toFixed(2);
|
|
const percent = rootValue > 0 ? ((nodeData.value / rootValue) * 100).toFixed(1) : "0.0";
|
|
|
|
const brandEl = document.getElementById('status-brand');
|
|
const taglineEl = document.getElementById('status-tagline');
|
|
if (brandEl) brandEl.style.display = 'none';
|
|
if (taglineEl) taglineEl.style.display = 'none';
|
|
|
|
const locationEl = document.getElementById('status-location');
|
|
const funcItem = document.getElementById('status-func-item');
|
|
const timeItem = document.getElementById('status-time-item');
|
|
const percentItem = document.getElementById('status-percent-item');
|
|
|
|
if (locationEl) locationEl.style.display = filename && filename !== "~" ? 'flex' : 'none';
|
|
if (funcItem) funcItem.style.display = 'flex';
|
|
if (timeItem) timeItem.style.display = 'flex';
|
|
if (percentItem) percentItem.style.display = 'flex';
|
|
|
|
const fileEl = document.getElementById('status-file');
|
|
if (fileEl && filename && filename !== "~") {
|
|
const basename = filename.split('/').pop();
|
|
fileEl.textContent = lineno ? `${basename}:${lineno}` : basename;
|
|
}
|
|
|
|
const funcEl = document.getElementById('status-func');
|
|
if (funcEl) funcEl.textContent = funcname.length > 40 ? funcname.substring(0, 37) + '...' : funcname;
|
|
|
|
const timeEl = document.getElementById('status-time');
|
|
if (timeEl) timeEl.textContent = `${timeMs} ms`;
|
|
|
|
const percentEl = document.getElementById('status-percent');
|
|
if (percentEl) percentEl.textContent = `${percent}%`;
|
|
}
|
|
|
|
function clearStatusBar() {
|
|
const ids = ['status-location', 'status-func-item', 'status-time-item', 'status-percent-item'];
|
|
ids.forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.style.display = 'none';
|
|
});
|
|
|
|
const brandEl = document.getElementById('status-brand');
|
|
const taglineEl = document.getElementById('status-tagline');
|
|
if (brandEl) brandEl.style.display = 'flex';
|
|
if (taglineEl) taglineEl.style.display = 'flex';
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tooltip
|
|
// ============================================================================
|
|
|
|
function createPythonTooltip(data) {
|
|
const pythonTooltip = flamegraph.tooltip.defaultFlamegraphTooltip();
|
|
|
|
pythonTooltip.show = function (d, element) {
|
|
if (!this._tooltip) {
|
|
this._tooltip = d3.select("body")
|
|
.append("div")
|
|
.attr("class", "python-tooltip")
|
|
.style("opacity", 0);
|
|
}
|
|
|
|
const timeMs = (d.data.value / 1000).toFixed(2);
|
|
const percentage = ((d.data.value / data.value) * 100).toFixed(2);
|
|
const calls = d.data.calls || 0;
|
|
const childCount = d.children ? d.children.length : 0;
|
|
const source = d.data.source;
|
|
|
|
const funcname = resolveString(d.data.funcname) || resolveString(d.data.name);
|
|
const filename = resolveString(d.data.filename) || "";
|
|
const isSpecialFrame = filename === "~";
|
|
|
|
// Build source section
|
|
let sourceSection = "";
|
|
if (source && Array.isArray(source) && source.length > 0) {
|
|
const sourceLines = source
|
|
.map((line) => {
|
|
const isCurrent = line.startsWith("→");
|
|
const escaped = line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
return `<div class="tooltip-source-line${isCurrent ? ' current' : ''}">${escaped}</div>`;
|
|
})
|
|
.join("");
|
|
|
|
sourceSection = `
|
|
<div class="tooltip-source">
|
|
<div class="tooltip-source-title">Source Code:</div>
|
|
<div class="tooltip-source-code">${sourceLines}</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Create bytecode/opcode section if available
|
|
let opcodeSection = "";
|
|
const opcodes = d.data.opcodes;
|
|
if (opcodes && typeof opcodes === 'object' && Object.keys(opcodes).length > 0) {
|
|
// Sort opcodes by sample count (descending)
|
|
const sortedOpcodes = Object.entries(opcodes)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 8); // Limit to top 8
|
|
|
|
const totalOpcodeSamples = sortedOpcodes.reduce((sum, [, count]) => sum + count, 0);
|
|
const maxCount = sortedOpcodes[0][1] || 1;
|
|
|
|
const opcodeLines = sortedOpcodes.map(([opcode, count]) => {
|
|
const opcodeInfo = getOpcodeInfo(parseInt(opcode, 10));
|
|
const pct = ((count / totalOpcodeSamples) * 100).toFixed(1);
|
|
const barWidth = (count / maxCount) * 100;
|
|
const specializedBadge = opcodeInfo.isSpecialized
|
|
? '<span class="tooltip-opcode-badge">SPECIALIZED</span>'
|
|
: '';
|
|
const baseOpHint = opcodeInfo.isSpecialized
|
|
? `<span class="tooltip-opcode-base-hint">(${opcodeInfo.baseOpname})</span>`
|
|
: '';
|
|
const nameClass = opcodeInfo.isSpecialized
|
|
? 'tooltip-opcode-name specialized'
|
|
: 'tooltip-opcode-name';
|
|
|
|
return `
|
|
<div class="tooltip-opcode-row">
|
|
<div class="${nameClass}">
|
|
${opcodeInfo.opname}${baseOpHint}${specializedBadge}
|
|
</div>
|
|
<div class="tooltip-opcode-count">${count.toLocaleString()} (${pct}%)</div>
|
|
<div class="tooltip-opcode-bar">
|
|
<div class="tooltip-opcode-bar-fill" style="width: ${barWidth}%;"></div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
opcodeSection = `
|
|
<div class="tooltip-opcodes">
|
|
<div class="tooltip-opcodes-title">Bytecode Instructions:</div>
|
|
<div class="tooltip-opcodes-list">
|
|
${opcodeLines}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
const fileLocationHTML = isSpecialFrame ? "" : `
|
|
<div class="tooltip-location">${filename}${d.data.lineno ? ":" + d.data.lineno : ""}</div>`;
|
|
|
|
// Differential stats section
|
|
let diffSection = "";
|
|
if (d.data.diff !== undefined && d.data.baseline !== undefined) {
|
|
const baselineSelf = (d.data.baseline / 1000).toFixed(2);
|
|
const currentSelf = ((d.data.self_time || 0) / 1000).toFixed(2);
|
|
const diffMs = (d.data.diff / 1000).toFixed(2);
|
|
const diffPct = d.data.diff_pct;
|
|
const sign = d.data.diff >= 0 ? "+" : "";
|
|
const diffClass = d.data.diff > 0 ? "regression" : (d.data.diff < 0 ? "improvement" : "neutral");
|
|
|
|
diffSection = `
|
|
<div class="tooltip-diff">
|
|
<div class="tooltip-diff-title">Self-Time Comparison:</div>
|
|
<div class="tooltip-diff-row">
|
|
<span class="tooltip-stat-label">Baseline Self:</span>
|
|
<span class="tooltip-stat-value">${baselineSelf} ms</span>
|
|
</div>
|
|
<div class="tooltip-diff-row">
|
|
<span class="tooltip-stat-label">Current Self:</span>
|
|
<span class="tooltip-stat-value">${currentSelf} ms</span>
|
|
</div>
|
|
<div class="tooltip-diff-row ${diffClass}">
|
|
<span class="tooltip-stat-label">Difference:</span>
|
|
<span class="tooltip-stat-value">${sign}${diffMs} ms (${sign}${diffPct.toFixed(1)}%)</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
const tooltipHTML = `
|
|
<div class="tooltip-header">
|
|
<div class="tooltip-title">${funcname}</div>
|
|
${fileLocationHTML}
|
|
</div>
|
|
<div class="tooltip-stats">
|
|
<span class="tooltip-stat-label">Execution Time:</span>
|
|
<span class="tooltip-stat-value">${timeMs} ms</span>
|
|
|
|
<span class="tooltip-stat-label">Percentage:</span>
|
|
<span class="tooltip-stat-value accent">${percentage}%</span>
|
|
|
|
${calls > 0 ? `
|
|
<span class="tooltip-stat-label">Function Calls:</span>
|
|
<span class="tooltip-stat-value">${calls.toLocaleString()}</span>
|
|
` : ''}
|
|
|
|
${childCount > 0 ? `
|
|
<span class="tooltip-stat-label">Child Functions:</span>
|
|
<span class="tooltip-stat-value">${childCount}</span>
|
|
` : ''}
|
|
</div>
|
|
${diffSection}
|
|
${sourceSection}
|
|
${opcodeSection}
|
|
<div class="tooltip-hint">
|
|
${childCount > 0 ? "Click to zoom into this function" : "Leaf function - no children"}
|
|
</div>
|
|
`;
|
|
|
|
// Position tooltip
|
|
const event = d3.event || window.event;
|
|
const mouseX = event.pageX || event.clientX;
|
|
const mouseY = event.pageY || event.clientY;
|
|
const padding = 12;
|
|
|
|
this._tooltip.html(tooltipHTML);
|
|
|
|
// Measure tooltip
|
|
const node = this._tooltip.style("display", "block").style("opacity", 0).node();
|
|
const tooltipWidth = node.offsetWidth || 320;
|
|
const tooltipHeight = node.offsetHeight || 200;
|
|
|
|
// Calculate position
|
|
let left = mouseX + padding;
|
|
let top = mouseY + padding;
|
|
|
|
if (left + tooltipWidth > window.innerWidth) {
|
|
left = mouseX - tooltipWidth - padding;
|
|
if (left < 0) left = padding;
|
|
}
|
|
|
|
if (top + tooltipHeight > window.innerHeight) {
|
|
top = mouseY - tooltipHeight - padding;
|
|
if (top < 0) top = padding;
|
|
}
|
|
|
|
this._tooltip
|
|
.style("left", left + "px")
|
|
.style("top", top + "px")
|
|
.transition()
|
|
.duration(150)
|
|
.style("opacity", 1);
|
|
|
|
// Update status bar
|
|
updateStatusBar(d.data, data.value);
|
|
};
|
|
|
|
pythonTooltip.hide = function () {
|
|
if (this._tooltip) {
|
|
this._tooltip.transition().duration(150).style("opacity", 0);
|
|
}
|
|
clearStatusBar();
|
|
};
|
|
|
|
return pythonTooltip;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Flamegraph Creation
|
|
// ============================================================================
|
|
|
|
function ensureLibraryLoaded() {
|
|
if (typeof flamegraph === "undefined") {
|
|
console.error("d3-flame-graph library not loaded");
|
|
document.getElementById("chart").innerHTML =
|
|
'<div style="padding: 40px; text-align: center; color: var(--text-muted);">Error: d3-flame-graph library failed to load</div>';
|
|
throw new Error("d3-flame-graph library failed to load");
|
|
}
|
|
}
|
|
|
|
const HEAT_THRESHOLDS = [
|
|
[0.6, 8],
|
|
[0.35, 7],
|
|
[0.18, 6],
|
|
[0.12, 5],
|
|
[0.06, 4],
|
|
[0.03, 3],
|
|
[0.01, 2],
|
|
];
|
|
|
|
function getHeatLevel(percentage) {
|
|
for (const [threshold, level] of HEAT_THRESHOLDS) {
|
|
if (percentage >= threshold) return level;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
function getHeatColors() {
|
|
const style = getComputedStyle(document.documentElement);
|
|
const colors = {};
|
|
for (let i = 1; i <= 8; i++) {
|
|
colors[i] = style.getPropertyValue(`--heat-${i}`).trim();
|
|
}
|
|
return colors;
|
|
}
|
|
|
|
function getDiffColors() {
|
|
const style = getComputedStyle(document.documentElement);
|
|
return {
|
|
elided: style.getPropertyValue('--diff-elided').trim(),
|
|
new: style.getPropertyValue('--diff-new').trim(),
|
|
neutral: style.getPropertyValue('--diff-neutral').trim(),
|
|
regressionDeep: style.getPropertyValue('--diff-regression-deep').trim(),
|
|
regressionMedium: style.getPropertyValue('--diff-regression-medium').trim(),
|
|
regressionLight: style.getPropertyValue('--diff-regression-light').trim(),
|
|
regressionVerylight: style.getPropertyValue('--diff-regression-verylight').trim(),
|
|
improvementDeep: style.getPropertyValue('--diff-improvement-deep').trim(),
|
|
improvementMedium: style.getPropertyValue('--diff-improvement-medium').trim(),
|
|
improvementLight: style.getPropertyValue('--diff-improvement-light').trim(),
|
|
improvementVerylight: style.getPropertyValue('--diff-improvement-verylight').trim(),
|
|
};
|
|
}
|
|
|
|
function getDiffColorForNode(node, diffColors) {
|
|
if (isShowingElided) {
|
|
return diffColors.elided;
|
|
}
|
|
|
|
const diff_pct = node.data.diff_pct || 0;
|
|
const diff_samples = node.data.diff || 0;
|
|
const self_time = node.data.self_time || 0;
|
|
|
|
if (diff_pct === 100 && self_time > 0 && Math.abs(diff_samples - self_time) < 0.1) {
|
|
return diffColors.new;
|
|
}
|
|
|
|
// Neutral zone: small percentage change
|
|
if (Math.abs(diff_pct) < 15) {
|
|
return diffColors.neutral;
|
|
}
|
|
|
|
// Regression (red scale)
|
|
if (diff_pct > 0) {
|
|
if (diff_pct >= 100) return diffColors.regressionDeep;
|
|
if (diff_pct > 50) return diffColors.regressionMedium;
|
|
if (diff_pct > 30) return diffColors.regressionLight;
|
|
return diffColors.regressionVerylight;
|
|
}
|
|
|
|
// Improvement (blue scale)
|
|
if (diff_pct <= -100) return diffColors.improvementDeep;
|
|
if (diff_pct < -50) return diffColors.improvementMedium;
|
|
if (diff_pct < -30) return diffColors.improvementLight;
|
|
return diffColors.improvementVerylight;
|
|
}
|
|
|
|
function createFlamegraph(tooltip, rootValue, data) {
|
|
const chartArea = document.querySelector('.chart-area');
|
|
const width = chartArea ? chartArea.clientWidth - 32 : window.innerWidth - 320;
|
|
const heatColors = getHeatColors();
|
|
|
|
const isDifferential = data && data.stats && data.stats.is_differential;
|
|
const diffColors = isDifferential ? getDiffColors() : null;
|
|
|
|
let chart = flamegraph()
|
|
.width(width)
|
|
.cellHeight(20)
|
|
.transitionDuration(300)
|
|
.minFrameSize(1)
|
|
.tooltip(tooltip)
|
|
.inverted(true)
|
|
.setColorMapper(function (d) {
|
|
if (d.depth === 0) return 'transparent';
|
|
|
|
if (isDifferential) {
|
|
return getDiffColorForNode(d, diffColors);
|
|
}
|
|
|
|
const percentage = d.data.value / rootValue;
|
|
const level = getHeatLevel(percentage);
|
|
return heatColors[level];
|
|
});
|
|
|
|
return chart;
|
|
}
|
|
|
|
function renderFlamegraph(chart, data) {
|
|
d3.select("#chart").datum(data).call(chart);
|
|
window.flamegraphChart = chart;
|
|
window.flamegraphData = data;
|
|
populateStats(data);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Search
|
|
// ============================================================================
|
|
|
|
function updateSearchHighlight(searchTerm, searchInput) {
|
|
d3.selectAll("#chart rect")
|
|
.classed("search-match", false)
|
|
.classed("search-dim", false);
|
|
|
|
// Clear active state from all hotspots
|
|
document.querySelectorAll('.hotspot').forEach(h => h.classList.remove('active'));
|
|
|
|
if (searchTerm && searchTerm.length > 0) {
|
|
let matchCount = 0;
|
|
|
|
d3.selectAll("#chart rect").each(function (d) {
|
|
if (d && d.data) {
|
|
const name = resolveString(d.data.name) || "";
|
|
const funcname = resolveString(d.data.funcname) || "";
|
|
const filename = resolveString(d.data.filename) || "";
|
|
const lineno = d.data.lineno;
|
|
const term = searchTerm.toLowerCase();
|
|
|
|
// Check if search term looks like file: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;
|
|
} else {
|
|
// Regular substring search
|
|
matches =
|
|
name.toLowerCase().includes(term) ||
|
|
funcname.toLowerCase().includes(term) ||
|
|
filename.toLowerCase().includes(term);
|
|
}
|
|
|
|
if (matches) {
|
|
matchCount++;
|
|
d3.select(this).classed("search-match", true);
|
|
} else {
|
|
d3.select(this).classed("search-dim", true);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (searchInput) {
|
|
searchInput.classList.remove("has-matches", "no-matches");
|
|
searchInput.classList.add(matchCount > 0 ? "has-matches" : "no-matches");
|
|
}
|
|
|
|
// Mark matching hotspot as active
|
|
document.querySelectorAll('.hotspot').forEach(h => {
|
|
if (h.dataset.searchterm && h.dataset.searchterm.toLowerCase() === searchTerm.toLowerCase()) {
|
|
h.classList.add('active');
|
|
}
|
|
});
|
|
} else if (searchInput) {
|
|
searchInput.classList.remove("has-matches", "no-matches");
|
|
}
|
|
}
|
|
|
|
function searchForHotspot(funcname) {
|
|
const searchInput = document.getElementById('search-input');
|
|
const searchWrapper = document.querySelector('.search-wrapper');
|
|
if (searchInput) {
|
|
// Toggle: if already searching for this term, clear it
|
|
if (searchInput.value.trim() === funcname) {
|
|
clearSearch();
|
|
} else {
|
|
searchInput.value = funcname;
|
|
if (searchWrapper) {
|
|
searchWrapper.classList.add('has-value');
|
|
}
|
|
performSearch();
|
|
}
|
|
}
|
|
}
|
|
|
|
function initSearchHandlers() {
|
|
const searchInput = document.getElementById("search-input");
|
|
const searchWrapper = document.querySelector(".search-wrapper");
|
|
if (!searchInput) return;
|
|
|
|
let searchTimeout;
|
|
function performSearch() {
|
|
const term = searchInput.value.trim();
|
|
updateSearchHighlight(term, searchInput);
|
|
// Toggle has-value class for clear button visibility
|
|
if (searchWrapper) {
|
|
searchWrapper.classList.toggle("has-value", term.length > 0);
|
|
}
|
|
}
|
|
|
|
searchInput.addEventListener("input", function () {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(performSearch, 150);
|
|
});
|
|
|
|
window.performSearch = performSearch;
|
|
}
|
|
|
|
function clearSearch() {
|
|
const searchInput = document.getElementById("search-input");
|
|
const searchWrapper = document.querySelector(".search-wrapper");
|
|
if (searchInput) {
|
|
searchInput.value = "";
|
|
searchInput.classList.remove("has-matches", "no-matches");
|
|
if (searchWrapper) {
|
|
searchWrapper.classList.remove("has-value");
|
|
}
|
|
// Clear highlights
|
|
d3.selectAll("#chart rect")
|
|
.classed("search-match", false)
|
|
.classed("search-dim", false);
|
|
// Clear active hotspot
|
|
document.querySelectorAll('.hotspot').forEach(h => h.classList.remove('active'));
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Resize Handler
|
|
// ============================================================================
|
|
|
|
function handleResize() {
|
|
let resizeTimeout;
|
|
window.addEventListener("resize", function () {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(resizeChart, 100);
|
|
});
|
|
}
|
|
|
|
function initSidebarResize() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const resizeHandle = document.getElementById('sidebar-resize-handle');
|
|
if (!sidebar || !resizeHandle) return;
|
|
|
|
let isResizing = false;
|
|
let startX = 0;
|
|
let startWidth = 0;
|
|
const minWidth = 200;
|
|
const maxWidth = 600;
|
|
|
|
resizeHandle.addEventListener('mousedown', function(e) {
|
|
isResizing = true;
|
|
startX = e.clientX;
|
|
startWidth = sidebar.offsetWidth;
|
|
resizeHandle.classList.add('resizing');
|
|
document.body.classList.add('resizing-sidebar');
|
|
e.preventDefault();
|
|
});
|
|
|
|
document.addEventListener('mousemove', function(e) {
|
|
if (!isResizing) return;
|
|
|
|
const deltaX = e.clientX - startX;
|
|
const newWidth = Math.min(Math.max(startWidth + deltaX, minWidth), maxWidth);
|
|
sidebar.style.width = newWidth + 'px';
|
|
e.preventDefault();
|
|
});
|
|
|
|
document.addEventListener('mouseup', function() {
|
|
if (isResizing) {
|
|
isResizing = false;
|
|
resizeHandle.classList.remove('resizing');
|
|
document.body.classList.remove('resizing-sidebar');
|
|
|
|
// Save the new width
|
|
const width = sidebar.offsetWidth;
|
|
localStorage.setItem('flamegraph-sidebar-width', width);
|
|
|
|
// Resize chart after sidebar resize
|
|
setTimeout(() => {
|
|
resizeChart();
|
|
}, 10);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Thread Stats
|
|
// ============================================================================
|
|
|
|
// Mode constants (must match constants.py)
|
|
const PROFILING_MODE_WALL = 0;
|
|
const PROFILING_MODE_CPU = 1;
|
|
const PROFILING_MODE_GIL = 2;
|
|
const PROFILING_MODE_ALL = 3;
|
|
|
|
function populateThreadStats(data, selectedThreadId = null) {
|
|
const stats = data?.stats;
|
|
if (!stats || !stats.thread_stats) {
|
|
return;
|
|
}
|
|
|
|
const mode = stats.mode !== undefined ? stats.mode : PROFILING_MODE_WALL;
|
|
let threadStats;
|
|
|
|
if (selectedThreadId !== null && stats.per_thread_stats && stats.per_thread_stats[selectedThreadId]) {
|
|
threadStats = stats.per_thread_stats[selectedThreadId];
|
|
} else {
|
|
threadStats = stats.thread_stats;
|
|
}
|
|
|
|
if (!threadStats || typeof threadStats.total !== 'number' || threadStats.total <= 0) {
|
|
return;
|
|
}
|
|
|
|
const section = document.getElementById('thread-stats-bar');
|
|
if (!section) {
|
|
return;
|
|
}
|
|
|
|
section.style.display = 'block';
|
|
|
|
const gilHeldStat = document.getElementById('gil-held-stat');
|
|
const gilReleasedStat = document.getElementById('gil-released-stat');
|
|
const gilWaitingStat = document.getElementById('gil-waiting-stat');
|
|
|
|
if (mode === PROFILING_MODE_GIL) {
|
|
// In GIL mode, hide GIL-related stats
|
|
if (gilHeldStat) gilHeldStat.style.display = 'none';
|
|
if (gilReleasedStat) gilReleasedStat.style.display = 'none';
|
|
if (gilWaitingStat) gilWaitingStat.style.display = 'none';
|
|
} else {
|
|
// Show all stats
|
|
if (gilHeldStat) gilHeldStat.style.display = 'block';
|
|
if (gilReleasedStat) gilReleasedStat.style.display = 'block';
|
|
if (gilWaitingStat) gilWaitingStat.style.display = 'block';
|
|
|
|
const gilHeldPct = threadStats.has_gil_pct || 0;
|
|
const gilHeldPctElem = document.getElementById('gil-held-pct');
|
|
if (gilHeldPctElem) gilHeldPctElem.textContent = `${gilHeldPct.toFixed(1)}%`;
|
|
const gilHeldFill = document.getElementById('gil-held-fill');
|
|
if (gilHeldFill) gilHeldFill.style.width = `${gilHeldPct}%`;
|
|
|
|
// GIL Released = not holding GIL and not waiting for it
|
|
const gilReleasedPct = Math.max(0, 100 - (threadStats.has_gil_pct || 0) - (threadStats.gil_requested_pct || 0));
|
|
const gilReleasedPctElem = document.getElementById('gil-released-pct');
|
|
if (gilReleasedPctElem) gilReleasedPctElem.textContent = `${gilReleasedPct.toFixed(1)}%`;
|
|
const gilReleasedFill = document.getElementById('gil-released-fill');
|
|
if (gilReleasedFill) gilReleasedFill.style.width = `${gilReleasedPct}%`;
|
|
|
|
const gilWaitingPct = threadStats.gil_requested_pct || 0;
|
|
const gilWaitingPctElem = document.getElementById('gil-waiting-pct');
|
|
if (gilWaitingPctElem) gilWaitingPctElem.textContent = `${gilWaitingPct.toFixed(1)}%`;
|
|
const gilWaitingFill = document.getElementById('gil-waiting-fill');
|
|
if (gilWaitingFill) gilWaitingFill.style.width = `${gilWaitingPct}%`;
|
|
}
|
|
|
|
const gcPct = threadStats.gc_pct || 0;
|
|
const gcPctElem = document.getElementById('gc-pct');
|
|
if (gcPctElem) gcPctElem.textContent = `${gcPct.toFixed(1)}%`;
|
|
const gcFill = document.getElementById('gc-fill');
|
|
if (gcFill) gcFill.style.width = `${gcPct}%`;
|
|
|
|
// Exception stats
|
|
const excPct = threadStats.has_exception_pct || 0;
|
|
const excPctElem = document.getElementById('exc-pct');
|
|
if (excPctElem) excPctElem.textContent = `${excPct.toFixed(1)}%`;
|
|
const excFill = document.getElementById('exc-fill');
|
|
if (excFill) excFill.style.width = `${excPct}%`;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Profile Summary Stats
|
|
// ============================================================================
|
|
|
|
function formatNumber(num) {
|
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
|
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
|
return num.toLocaleString();
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
if (seconds >= 3600) {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
return `${h}h ${m}m`;
|
|
}
|
|
if (seconds >= 60) {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${m}m ${s}s`;
|
|
}
|
|
return seconds.toFixed(2) + 's';
|
|
}
|
|
|
|
function populateProfileSummary(data) {
|
|
const stats = data.stats || {};
|
|
const totalSamples = stats.total_samples || data.value || 0;
|
|
const duration = stats.duration_sec || 0;
|
|
const sampleRate = stats.sample_rate || (duration > 0 ? totalSamples / duration : 0);
|
|
const errorRate = stats.error_rate || 0;
|
|
const missedSamples= stats.missed_samples || 0;
|
|
|
|
const samplesEl = document.getElementById('stat-total-samples');
|
|
if (samplesEl) samplesEl.textContent = formatNumber(totalSamples);
|
|
|
|
const durationEl = document.getElementById('stat-duration');
|
|
if (durationEl) durationEl.textContent = duration > 0 ? formatDuration(duration) : '--';
|
|
|
|
const rateEl = document.getElementById('stat-sample-rate');
|
|
if (rateEl) rateEl.textContent = sampleRate > 0 ? formatNumber(Math.round(sampleRate)) : '--';
|
|
|
|
// Count unique functions
|
|
// Use normal (non-inverted) tree structure, but respect thread filtering
|
|
const uniqueFunctions = new Set();
|
|
function collectUniqueFunctions(node) {
|
|
if (!node) return;
|
|
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;
|
|
}
|
|
collectUniqueFunctions(functionCountSource);
|
|
|
|
const functionsEl = document.getElementById('stat-functions');
|
|
if (functionsEl) functionsEl.textContent = formatNumber(uniqueFunctions.size);
|
|
|
|
// Efficiency bar
|
|
if (errorRate !== undefined && errorRate !== null) {
|
|
const efficiency = Math.max(0, Math.min(100, (100 - errorRate)));
|
|
|
|
const efficiencySection = document.getElementById('efficiency-section');
|
|
if (efficiencySection) efficiencySection.style.display = 'block';
|
|
|
|
const efficiencyValue = document.getElementById('stat-efficiency');
|
|
if (efficiencyValue) efficiencyValue.textContent = efficiency.toFixed(1) + '%';
|
|
|
|
const efficiencyFill = document.getElementById('efficiency-fill');
|
|
if (efficiencyFill) efficiencyFill.style.width = efficiency + '%';
|
|
}
|
|
// MissedSamples bar
|
|
if (missedSamples !== undefined && missedSamples !== null) {
|
|
const sampleEfficiency = Math.max(0, missedSamples);
|
|
|
|
const efficiencySection = document.getElementById('efficiency-section');
|
|
if (efficiencySection) efficiencySection.style.display = 'block';
|
|
|
|
const sampleEfficiencyValue = document.getElementById('stat-missed-samples');
|
|
if (sampleEfficiencyValue) sampleEfficiencyValue.textContent = sampleEfficiency.toFixed(1) + '%';
|
|
|
|
const sampleEfficiencyFill = document.getElementById('missed-samples-fill');
|
|
if (sampleEfficiencyFill) sampleEfficiencyFill.style.width = sampleEfficiency + '%';
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Elided Stacks (Differential)
|
|
// ============================================================================
|
|
|
|
let elidedFlamegraphData = null;
|
|
let invertedElidedData = null;
|
|
let isShowingElided = false;
|
|
|
|
function setupElidedToggle(data) {
|
|
const stats = data.stats || {};
|
|
const elidedCount = stats.elided_count || 0;
|
|
const elidedFlamegraph = stats.elided_flamegraph;
|
|
|
|
if (!elidedCount || !elidedFlamegraph) {
|
|
return;
|
|
}
|
|
|
|
elidedFlamegraphData = resolveStringIndices(elidedFlamegraph, elidedFlamegraph.strings);
|
|
|
|
const toggleElided = document.getElementById('toggle-elided');
|
|
if (toggleElided) {
|
|
toggleElided.style.display = 'flex';
|
|
|
|
toggleElided.onclick = function() {
|
|
isShowingElided = !isShowingElided;
|
|
updateToggleUI('toggle-elided', isShowingElided);
|
|
updateFlamegraphView();
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Hotspot Stats
|
|
// ============================================================================
|
|
|
|
function populateStats(data) {
|
|
// Populate profile summary
|
|
populateProfileSummary(data);
|
|
|
|
// Populate thread statistics if available
|
|
populateThreadStats(data);
|
|
|
|
// Setup elided stacks toggle if this is a differential flamegraph
|
|
setupElidedToggle(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) {
|
|
if (!node) return;
|
|
|
|
let filename = resolveString(node.filename);
|
|
let funcname = resolveString(node.funcname);
|
|
|
|
if (!filename || !funcname) {
|
|
const nameStr = resolveString(node.name);
|
|
if (nameStr?.includes('(')) {
|
|
const match = nameStr.match(/^(.+?)\s*\((.+?):(\d+)\)$/);
|
|
if (match) {
|
|
funcname = funcname || match[1];
|
|
filename = filename || match[2];
|
|
}
|
|
}
|
|
}
|
|
|
|
filename = filename || 'unknown';
|
|
funcname = funcname || 'unknown';
|
|
|
|
if (filename !== 'unknown' && funcname !== 'unknown' && node.value > 0) {
|
|
let childrenValue = 0;
|
|
if (node.children) {
|
|
childrenValue = node.children.reduce((sum, child) => sum + child.value, 0);
|
|
}
|
|
const directSamples = Math.max(0, node.value - childrenValue);
|
|
|
|
const funcKey = `${filename}:${node.lineno || '?'}:${funcname}`;
|
|
|
|
if (functionMap.has(funcKey)) {
|
|
const existing = functionMap.get(funcKey);
|
|
existing.directSamples += directSamples;
|
|
existing.directPercent = (existing.directSamples / totalSamples) * 100;
|
|
if (directSamples > existing.maxSingleSamples) {
|
|
existing.filename = filename;
|
|
existing.lineno = node.lineno || '?';
|
|
existing.maxSingleSamples = directSamples;
|
|
}
|
|
} else {
|
|
functionMap.set(funcKey, {
|
|
filename: filename,
|
|
lineno: node.lineno || '?',
|
|
funcname: funcname,
|
|
directSamples,
|
|
directPercent: (directSamples / totalSamples) * 100,
|
|
maxSingleSamples: directSamples
|
|
});
|
|
}
|
|
}
|
|
|
|
if (node.children) {
|
|
node.children.forEach(child => collectFunctions(child));
|
|
}
|
|
}
|
|
|
|
collectFunctions(hotspotSource);
|
|
|
|
const hotSpots = Array.from(functionMap.values())
|
|
.filter(f => f.directPercent > 0.5)
|
|
.sort((a, b) => b.directPercent - a.directPercent)
|
|
.slice(0, 3);
|
|
|
|
// Populate and animate hotspot cards
|
|
for (let i = 0; i < 3; i++) {
|
|
const num = i + 1;
|
|
const card = document.getElementById(`hotspot-${num}`);
|
|
const funcEl = document.getElementById(`hotspot-func-${num}`);
|
|
const fileEl = document.getElementById(`hotspot-file-${num}`);
|
|
const percentEl = document.getElementById(`hotspot-percent-${num}`);
|
|
const samplesEl = document.getElementById(`hotspot-samples-${num}`);
|
|
|
|
if (i < hotSpots.length && hotSpots[i]) {
|
|
const h = hotSpots[i];
|
|
const filename = h.filename || 'unknown';
|
|
const lineno = h.lineno ?? '?';
|
|
const isSpecialFrame = filename === '~' && (lineno === 0 || lineno === '?');
|
|
|
|
let funcDisplay = h.funcname || 'unknown';
|
|
if (funcDisplay.length > 28) funcDisplay = funcDisplay.substring(0, 25) + '...';
|
|
|
|
if (funcEl) funcEl.textContent = funcDisplay;
|
|
if (fileEl) {
|
|
if (isSpecialFrame) {
|
|
fileEl.textContent = '--';
|
|
} else {
|
|
const basename = filename !== 'unknown' ? filename.split('/').pop() : 'unknown';
|
|
fileEl.textContent = `${basename}:${lineno}`;
|
|
}
|
|
}
|
|
if (percentEl) percentEl.textContent = `${h.directPercent.toFixed(1)}%`;
|
|
if (samplesEl) samplesEl.textContent = ` (${h.directSamples.toLocaleString()})`;
|
|
} else {
|
|
if (funcEl) funcEl.textContent = '--';
|
|
if (fileEl) fileEl.textContent = '--';
|
|
if (percentEl) percentEl.textContent = '--';
|
|
if (samplesEl) samplesEl.textContent = '';
|
|
}
|
|
|
|
// Add click handler and animate entrance
|
|
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;
|
|
card.dataset.searchterm = searchTerm;
|
|
card.onclick = () => searchForHotspot(searchTerm);
|
|
card.style.cursor = 'pointer';
|
|
} else {
|
|
card.onclick = null;
|
|
delete card.dataset.searchterm;
|
|
card.style.cursor = 'default';
|
|
}
|
|
|
|
setTimeout(() => {
|
|
card.classList.add('visible');
|
|
}, 100 + i * 80);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Thread Filter
|
|
// ============================================================================
|
|
|
|
function initThreadFilter(data) {
|
|
const threadFilter = document.getElementById('thread-filter');
|
|
const threadSection = document.getElementById('thread-section');
|
|
|
|
if (!threadFilter || !data.threads) return;
|
|
|
|
threadFilter.innerHTML = '<option value="all">All Threads</option>';
|
|
|
|
const threads = data.threads || [];
|
|
threads.forEach(threadId => {
|
|
const option = document.createElement('option');
|
|
option.value = threadId;
|
|
option.textContent = `Thread ${threadId}`;
|
|
threadFilter.appendChild(option);
|
|
});
|
|
|
|
if (threads.length > 1 && threadSection) {
|
|
threadSection.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function filterByThread() {
|
|
const threadFilter = document.getElementById('thread-filter');
|
|
if (!threadFilter || !normalData) return;
|
|
|
|
const selectedThread = threadFilter.value;
|
|
currentThreadFilter = selectedThread;
|
|
|
|
updateFlamegraphView();
|
|
}
|
|
|
|
function filterDataByThread(data, threadId) {
|
|
function filterNode(node) {
|
|
if (!node.threads || !node.threads.includes(threadId)) {
|
|
return null;
|
|
}
|
|
|
|
const filteredNode = { ...node, children: [] };
|
|
|
|
if (node.children && Array.isArray(node.children)) {
|
|
filteredNode.children = node.children
|
|
.map(child => filterNode(child))
|
|
.filter(child => child !== null);
|
|
}
|
|
|
|
return filteredNode;
|
|
}
|
|
|
|
function recalculateValue(node) {
|
|
if (!node.children || node.children.length === 0) {
|
|
return node.value || 0;
|
|
}
|
|
const childrenValue = node.children.reduce((sum, child) => sum + recalculateValue(child), 0);
|
|
node.value = Math.max(node.value || 0, childrenValue);
|
|
return node.value;
|
|
}
|
|
|
|
const filteredRoot = { ...data, children: [] };
|
|
|
|
if (data.children && Array.isArray(data.children)) {
|
|
filteredRoot.children = data.children
|
|
.map(child => filterNode(child))
|
|
.filter(child => child !== null);
|
|
}
|
|
|
|
recalculateValue(filteredRoot);
|
|
return filteredRoot;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Control Functions
|
|
// ============================================================================
|
|
|
|
function resetZoom() {
|
|
if (window.flamegraphChart) {
|
|
window.flamegraphChart.resetZoom();
|
|
}
|
|
}
|
|
|
|
function exportSVG() {
|
|
const svgElement = document.querySelector("#chart svg");
|
|
if (!svgElement) {
|
|
console.warn("Cannot export: No flamegraph SVG found");
|
|
return;
|
|
}
|
|
const serializer = new XMLSerializer();
|
|
const svgString = serializer.serializeToString(svgElement);
|
|
const blob = new Blob([svgString], { type: "image/svg+xml" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "python-performance-flamegraph.svg";
|
|
a.click();
|
|
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, isDifferential) {
|
|
const key = getInvertNodeKey(stackFrame);
|
|
|
|
if (!parent.children[key]) {
|
|
const newNode = {
|
|
name: stackFrame.name,
|
|
value: 0,
|
|
children: {},
|
|
filename: stackFrame.filename,
|
|
lineno: stackFrame.lineno,
|
|
funcname: stackFrame.funcname,
|
|
source: stackFrame.source,
|
|
opcodes: null,
|
|
threads: new Set()
|
|
};
|
|
|
|
if (isDifferential) {
|
|
newNode.baseline = 0;
|
|
newNode.baseline_total = 0;
|
|
newNode.self_time = 0;
|
|
newNode.diff = 0;
|
|
newNode.diff_pct = 0;
|
|
}
|
|
|
|
parent.children[key] = newNode;
|
|
}
|
|
|
|
const node = parent.children[key];
|
|
node.value += leaf.value;
|
|
if (leaf.threads) {
|
|
leaf.threads.forEach(t => node.threads.add(t));
|
|
}
|
|
if (stackFrame.opcodes) {
|
|
if (!node.opcodes) {
|
|
node.opcodes = { ...stackFrame.opcodes };
|
|
} else {
|
|
for (const [op, count] of Object.entries(stackFrame.opcodes)) {
|
|
node.opcodes[op] = (node.opcodes[op] || 0) + count;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isDifferential) {
|
|
node.baseline += stackFrame.baseline || 0;
|
|
node.baseline_total += stackFrame.baseline_total || 0;
|
|
node.self_time += stackFrame.self_time || 0;
|
|
node.diff += stackFrame.diff || 0;
|
|
|
|
if (node.baseline > 0) {
|
|
node.diff_pct = (node.diff / node.baseline) * 100.0;
|
|
} else if (node.self_time > 0) {
|
|
node.diff_pct = 100.0;
|
|
}
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
function processLeaf(invertedRoot, path, leafNode, isDifferential) {
|
|
if (!path || path.length === 0) {
|
|
return;
|
|
}
|
|
|
|
let invertedParent = accumulateInvertedNode(invertedRoot, leafNode, leafNode, isDifferential);
|
|
|
|
// Walk backwards through the call stack
|
|
for (let i = path.length - 2; i >= 0; i--) {
|
|
invertedParent = accumulateInvertedNode(invertedParent, path[i], leafNode, isDifferential);
|
|
}
|
|
}
|
|
|
|
function traverseInvert(path, currentNode, invertedRoot, isDifferential) {
|
|
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 }, isDifferential);
|
|
}
|
|
|
|
children.forEach(child => traverseInvert(path.concat([child]), child, invertedRoot, isDifferential));
|
|
}
|
|
|
|
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 isDifferential = data && data.stats && data.stats.is_differential;
|
|
|
|
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, isDifferential);
|
|
} else {
|
|
children.forEach(child => traverseInvert([child], child, invertedRoot, isDifferential));
|
|
}
|
|
|
|
convertInvertDictToArray(invertedRoot);
|
|
return invertedRoot;
|
|
}
|
|
|
|
function toggleInvert() {
|
|
isInverted = !isInverted;
|
|
updateToggleUI('toggle-invert', isInverted);
|
|
updateFlamegraphView();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Initialization
|
|
// ============================================================================
|
|
|
|
function initFlamegraph() {
|
|
ensureLibraryLoaded();
|
|
restoreUIState();
|
|
setupLogos();
|
|
|
|
if (EMBEDDED_DATA.strings) {
|
|
stringTable = EMBEDDED_DATA.strings;
|
|
normalData = resolveStringIndices(EMBEDDED_DATA, EMBEDDED_DATA.strings);
|
|
} else {
|
|
normalData = EMBEDDED_DATA;
|
|
}
|
|
|
|
// Initialize opcode mapping from embedded data
|
|
initOpcodeMapping(EMBEDDED_DATA);
|
|
|
|
// Inverted data will be built on first toggle
|
|
invertedData = null;
|
|
|
|
initThreadFilter(normalData);
|
|
|
|
// Toggle legend based on differential mode
|
|
const isDifferential = normalData && normalData.stats && normalData.stats.is_differential;
|
|
const heatmapLegend = document.getElementById('heatmap-legend-section');
|
|
const diffLegend = document.getElementById('diff-legend-section');
|
|
if (isDifferential) {
|
|
if (heatmapLegend) heatmapLegend.style.display = 'none';
|
|
if (diffLegend) diffLegend.style.display = 'block';
|
|
} else {
|
|
if (heatmapLegend) heatmapLegend.style.display = 'block';
|
|
if (diffLegend) diffLegend.style.display = 'none';
|
|
}
|
|
|
|
const tooltip = createPythonTooltip(normalData);
|
|
const chart = createFlamegraph(tooltip, normalData.value, normalData);
|
|
renderFlamegraph(chart, normalData);
|
|
initSearchHandlers();
|
|
initSidebarResize();
|
|
handleResize();
|
|
|
|
const toggleInvertBtn = document.getElementById('toggle-invert');
|
|
if (toggleInvertBtn) {
|
|
toggleInvertBtn.addEventListener('click', toggleInvert);
|
|
}
|
|
}
|
|
|
|
// Keyboard shortcut: Enter/Space activates toggle switches
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
|
return;
|
|
}
|
|
if ((e.key === 'Enter' || e.key === ' ') && e.target.classList.contains('toggle-switch')) {
|
|
e.preventDefault();
|
|
e.target.click();
|
|
}
|
|
});
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", initFlamegraph);
|
|
} else {
|
|
initFlamegraph();
|
|
}
|