cpython/Lib/profiling/sampling/flamegraph.js
László Kiss Kollár 3e06cfcaee
gh-135953: Reduce memory usage of stack collectors (#138875)
The stack collector base class keeps all frames until export() is
called, which causes significant unnecessary memory usage. Instead, we
can process the frames on the fly in the collect call by dispatching the
aggregation logic to the subclass through the process_frames method.

Co-authored-by: Pablo Galindo Salgado <pablogsal@gmail.com>
2025-09-14 23:47:14 +01:00

507 lines
17 KiB
JavaScript

const EMBEDDED_DATA = {{FLAMEGRAPH_DATA}};
// Global string table for resolving string indices
let stringTable = [];
// Function to resolve string indices to actual strings
function resolveString(index) {
if (typeof index === 'number' && index >= 0 && index < stringTable.length) {
return stringTable[index];
}
// Fallback for non-indexed strings or invalid indices
return String(index);
}
// Function to recursively resolve all string indices in flamegraph data
function resolveStringIndices(node) {
if (!node) return node;
// Create a copy to avoid mutating the original
const resolved = { ...node };
// Resolve string fields
if (typeof resolved.name === 'number') {
resolved.name = resolveString(resolved.name);
}
if (typeof resolved.filename === 'number') {
resolved.filename = resolveString(resolved.filename);
}
if (typeof resolved.funcname === 'number') {
resolved.funcname = resolveString(resolved.funcname);
}
// Resolve source lines if present
if (Array.isArray(resolved.source)) {
resolved.source = resolved.source.map(index =>
typeof index === 'number' ? resolveString(index) : index
);
}
// Recursively resolve children
if (Array.isArray(resolved.children)) {
resolved.children = resolved.children.map(child => resolveStringIndices(child));
}
return resolved;
}
// Python color palette - cold to hot
const pythonColors = [
"#fff4bf", // Coldest - light yellow (<1%)
"#ffec9e", // Cold - yellow (1-3%)
"#ffe47d", // Cool - golden yellow (3-6%)
"#ffdc5c", // Medium - golden (6-12%)
"#ffd43b", // Warm - Python gold (12-18%)
"#5592cc", // Hot - light blue (18-35%)
"#4584bb", // Very hot - medium blue (35-60%)
"#3776ab", // Hottest - Python blue (≥60%)
];
function ensureLibraryLoaded() {
if (typeof flamegraph === "undefined") {
console.error("d3-flame-graph library not loaded");
document.getElementById("chart").innerHTML =
'<h2 style="text-align: center; color: #d32f2f;">Error: d3-flame-graph library failed to load</h2>';
throw new Error("d3-flame-graph library failed to load");
}
}
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("position", "absolute")
.style("padding", "20px")
.style("background", "white")
.style("color", "#2e3338")
.style("border-radius", "8px")
.style("font-size", "14px")
.style("border", "1px solid #e9ecef")
.style("box-shadow", "0 8px 30px rgba(0, 0, 0, 0.15)")
.style("z-index", "1000")
.style("pointer-events", "none")
.style("font-weight", "400")
.style("line-height", "1.5")
.style("max-width", "500px")
.style("word-wrap", "break-word")
.style("overflow-wrap", "break-word")
.style("font-family", "'Source Sans Pro', sans-serif")
.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;
// Create source code section if available
let sourceSection = "";
if (source && Array.isArray(source) && source.length > 0) {
const sourceLines = source
.map(
(line) =>
`<div style="font-family: 'SF Mono', 'Monaco', 'Consolas', ` +
`monospace; font-size: 12px; color: ${
line.startsWith("→") ? "#3776ab" : "#5a6c7d"
}; white-space: pre-wrap; word-break: break-all; overflow-wrap: break-word; line-height: 1.4; padding: 2px 0;">${line
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")}</div>`,
)
.join("");
sourceSection = `
<div style="margin-top: 16px; padding-top: 12px;
border-top: 1px solid #e9ecef;">
<div style="color: #3776ab; font-size: 13px;
margin-bottom: 8px; font-weight: 600;">
Source Code:
</div>
<div style="background: #f8f9fa; border: 1px solid #e9ecef;
border-radius: 6px; padding: 12px; max-height: 150px;
overflow-y: auto; overflow-x: hidden;">
${sourceLines}
</div>
</div>`;
} else if (source) {
// Show debug info if source exists but isn't an array
sourceSection = `
<div style="margin-top: 16px; padding-top: 12px;
border-top: 1px solid #e9ecef;">
<div style="color: #d32f2f; font-size: 13px;
margin-bottom: 8px; font-weight: 600;">
[Debug] - Source data type: ${typeof source}
</div>
<div style="background: #f8f9fa; border: 1px solid #e9ecef;
border-radius: 6px; padding: 12px; max-height: 150px;
overflow-y: auto; overflow-x: hidden; font-family: monospace; font-size: 11px; word-break: break-all; overflow-wrap: break-word;">
${JSON.stringify(source, null, 2)}
</div>
</div>`;
}
// Resolve strings for display
const funcname = resolveString(d.data.funcname) || resolveString(d.data.name);
const filename = resolveString(d.data.filename) || "";
const tooltipHTML = `
<div>
<div style="color: #3776ab; font-weight: 600; font-size: 16px;
margin-bottom: 8px; line-height: 1.3; word-break: break-word; overflow-wrap: break-word;">
${funcname}
</div>
<div style="color: #5a6c7d; font-size: 13px; margin-bottom: 12px;
font-family: monospace; background: #f8f9fa;
padding: 4px 8px; border-radius: 4px; word-break: break-all; overflow-wrap: break-word;">
${filename}${d.data.lineno ? ":" + d.data.lineno : ""}
</div>
<div style="display: grid; grid-template-columns: auto 1fr;
gap: 8px 16px; font-size: 14px;">
<span style="color: #5a6c7d; font-weight: 500;">Execution Time:</span>
<strong style="color: #2e3338;">${timeMs} ms</strong>
<span style="color: #5a6c7d; font-weight: 500;">Percentage:</span>
<strong style="color: #3776ab;">${percentage}%</strong>
${calls > 0 ? `
<span style="color: #5a6c7d; font-weight: 500;">Function Calls:</span>
<strong style="color: #2e3338;">${calls.toLocaleString()}</strong>
` : ''}
${childCount > 0 ? `
<span style="color: #5a6c7d; font-weight: 500;">Child Functions:</span>
<strong style="color: #2e3338;">${childCount}</strong>
` : ''}
</div>
${sourceSection}
<div style="margin-top: 16px; padding-top: 12px;
border-top: 1px solid #e9ecef; font-size: 13px;
color: #5a6c7d; text-align: center;">
${childCount > 0 ?
"Click to focus on this function" :
"Leaf function - no children"}
</div>
</div>
`;
// Get mouse position
const event = d3.event || window.event;
const mouseX = event.pageX || event.clientX;
const mouseY = event.pageY || event.clientY;
// Calculate tooltip dimensions (default to 320px width if not rendered yet)
let tooltipWidth = 320;
let tooltipHeight = 200;
if (this._tooltip && this._tooltip.node()) {
const node = this._tooltip
.style("opacity", 0)
.style("display", "block")
.node();
tooltipWidth = node.offsetWidth || 320;
tooltipHeight = node.offsetHeight || 200;
this._tooltip.style("display", null);
}
// Calculate horizontal position: if overflow, show to the left of cursor
const padding = 10;
const rightEdge = mouseX + padding + tooltipWidth;
const viewportWidth = window.innerWidth;
let left;
if (rightEdge > viewportWidth) {
left = mouseX - tooltipWidth - padding;
if (left < 0) left = padding; // prevent off left edge
} else {
left = mouseX + padding;
}
// Calculate vertical position: if overflow, show above cursor
const bottomEdge = mouseY + padding + tooltipHeight;
const viewportHeight = window.innerHeight;
let top;
if (bottomEdge > viewportHeight) {
top = mouseY - tooltipHeight - padding;
if (top < 0) top = padding; // prevent off top edge
} else {
top = mouseY + padding;
}
this._tooltip
.html(tooltipHTML)
.style("left", left + "px")
.style("top", top + "px")
.transition()
.duration(200)
.style("opacity", 1);
};
// Override the hide method
pythonTooltip.hide = function () {
if (this._tooltip) {
this._tooltip.transition().duration(200).style("opacity", 0);
}
};
return pythonTooltip;
}
function createFlamegraph(tooltip, rootValue) {
let chart = flamegraph()
.width(window.innerWidth - 80)
.cellHeight(20)
.transitionDuration(300)
.minFrameSize(1)
.tooltip(tooltip)
.inverted(true)
.setColorMapper(function (d) {
const percentage = d.data.value / rootValue;
let colorIndex;
if (percentage >= 0.6) colorIndex = 7;
else if (percentage >= 0.35) colorIndex = 6;
else if (percentage >= 0.18) colorIndex = 5;
else if (percentage >= 0.12) colorIndex = 4;
else if (percentage >= 0.06) colorIndex = 3;
else if (percentage >= 0.03) colorIndex = 2;
else if (percentage >= 0.01) colorIndex = 1;
else colorIndex = 0; // <1%
return pythonColors[colorIndex];
});
return chart;
}
function renderFlamegraph(chart, data) {
d3.select("#chart").datum(data).call(chart);
window.flamegraphChart = chart; // for controls
window.flamegraphData = data; // for resize/search
populateStats(data);
}
function attachPanelControls() {
const infoBtn = document.getElementById("show-info-btn");
const infoPanel = document.getElementById("info-panel");
const closeBtn = document.getElementById("close-info-btn");
if (infoBtn && infoPanel) {
infoBtn.addEventListener("click", function () {
const isOpen = infoPanel.style.display === "block";
infoPanel.style.display = isOpen ? "none" : "block";
});
}
if (closeBtn && infoPanel) {
closeBtn.addEventListener("click", function () {
infoPanel.style.display = "none";
});
}
}
function updateSearchHighlight(searchTerm, searchInput) {
d3.selectAll("#chart rect")
.style("stroke", null)
.style("stroke-width", null)
.style("opacity", null);
if (searchTerm && searchTerm.length > 0) {
d3.selectAll("#chart rect").style("opacity", 0.3);
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 term = searchTerm.toLowerCase();
const matches =
name.toLowerCase().includes(term) ||
funcname.toLowerCase().includes(term) ||
filename.toLowerCase().includes(term);
if (matches) {
matchCount++;
d3.select(this)
.style("opacity", 1)
.style("stroke", "#ff6b35")
.style("stroke-width", "2px")
.style("stroke-dasharray", "3,3");
}
}
});
if (searchInput) {
if (matchCount > 0) {
searchInput.style.borderColor = "rgba(40, 167, 69, 0.8)";
searchInput.style.boxShadow = "0 6px 20px rgba(40, 167, 69, 0.2)";
} else {
searchInput.style.borderColor = "rgba(220, 53, 69, 0.8)";
searchInput.style.boxShadow = "0 6px 20px rgba(220, 53, 69, 0.2)";
}
}
} else if (searchInput) {
searchInput.style.borderColor = "rgba(255, 255, 255, 0.2)";
searchInput.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
}
}
function initSearchHandlers() {
const searchInput = document.getElementById("search-input");
if (!searchInput) return;
let searchTimeout;
function performSearch() {
const term = searchInput.value.trim();
updateSearchHighlight(term, searchInput);
}
searchInput.addEventListener("input", function () {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(performSearch, 150);
});
window.performSearch = performSearch;
}
function handleResize(chart, data) {
window.addEventListener("resize", function () {
if (chart && data) {
const newWidth = window.innerWidth - 80;
chart.width(newWidth);
d3.select("#chart").datum(data).call(chart);
}
});
}
function initFlamegraph() {
ensureLibraryLoaded();
// Extract string table if present and resolve string indices
let processedData = EMBEDDED_DATA;
if (EMBEDDED_DATA.strings) {
stringTable = EMBEDDED_DATA.strings;
processedData = resolveStringIndices(EMBEDDED_DATA);
}
const tooltip = createPythonTooltip(processedData);
const chart = createFlamegraph(tooltip, processedData.value);
renderFlamegraph(chart, processedData);
attachPanelControls();
initSearchHandlers();
handleResize(chart, processedData);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initFlamegraph);
} else {
initFlamegraph();
}
function populateStats(data) {
const totalSamples = data.value || 0;
// Collect all functions with their metrics, aggregated by function name
const functionMap = new Map();
function collectFunctions(node) {
const filename = resolveString(node.filename);
const funcname = resolveString(node.funcname);
if (filename && funcname) {
// Calculate direct samples (this node's value minus children's values)
let childrenValue = 0;
if (node.children) {
childrenValue = node.children.reduce((sum, child) => sum + child.value, 0);
}
const directSamples = Math.max(0, node.value - childrenValue);
// Use file:line:funcname as key to ensure uniqueness
const funcKey = `${filename}:${node.lineno || '?'}:${funcname}`;
if (functionMap.has(funcKey)) {
const existing = functionMap.get(funcKey);
existing.directSamples += directSamples;
existing.directPercent = (existing.directSamples / totalSamples) * 100;
// Keep the most representative file/line (the one with more samples)
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(data);
// Convert map to array and get top 3 hotspots
const hotSpots = Array.from(functionMap.values())
.filter(f => f.directPercent > 0.5) // At least 0.5% to be significant
.sort((a, b) => b.directPercent - a.directPercent)
.slice(0, 3);
// Populate the 3 cards
for (let i = 0; i < 3; i++) {
const num = i + 1;
if (i < hotSpots.length) {
const hotspot = hotSpots[i];
const basename = hotspot.filename.split('/').pop();
let funcDisplay = hotspot.funcname;
if (funcDisplay.length > 35) {
funcDisplay = funcDisplay.substring(0, 32) + '...';
}
document.getElementById(`hotspot-file-${num}`).textContent = `${basename}:${hotspot.lineno}`;
document.getElementById(`hotspot-func-${num}`).textContent = funcDisplay;
document.getElementById(`hotspot-detail-${num}`).textContent = `${hotspot.directPercent.toFixed(1)}% samples (${hotspot.directSamples.toLocaleString()})`;
} else {
document.getElementById(`hotspot-file-${num}`).textContent = '--';
document.getElementById(`hotspot-func-${num}`).textContent = '--';
document.getElementById(`hotspot-detail-${num}`).textContent = '--';
}
}
}
// Control functions
function resetZoom() {
if (window.flamegraphChart) {
window.flamegraphChart.resetZoom();
}
}
function exportSVG() {
const svgElement = document.querySelector("#chart svg");
if (svgElement) {
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);
}
}
function toggleLegend() {
const legendPanel = document.getElementById("legend-panel");
const isHidden =
legendPanel.style.display === "none" || legendPanel.style.display === "";
legendPanel.style.display = isHidden ? "block" : "none";
}
function clearSearch() {
const searchInput = document.getElementById("search-input");
if (searchInput) {
searchInput.value = "";
if (window.flamegraphChart) {
window.flamegraphChart.clear();
}
}
}