| 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) { |
| if (index === null || index === undefined) { |
| return null; |
| } |
| if (typeof index === 'number' && index >= 0 && index < stringTable.length) { |
| return stringTable[index]; |
| } |
| return String(index); |
| } |
| |
| function resolveStringIndices(node) { |
| if (!node) return node; |
| |
| const resolved = { ...node }; |
| |
| 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); |
| } |
| |
| if (Array.isArray(resolved.source)) { |
| resolved.source = resolved.source.map(index => |
| typeof index === 'number' ? resolveString(index) : index |
| ); |
| } |
| |
| if (Array.isArray(resolved.children)) { |
| resolved.children = resolved.children.map(child => resolveStringIndices(child)); |
| } |
| |
| return resolved; |
| } |
| |
| // ============================================================================ |
| // Theme & UI Controls |
| // ============================================================================ |
| |
| function toggleTheme() { |
| const html = document.documentElement; |
| const current = html.getAttribute('data-theme') || 'light'; |
| const next = current === 'light' ? 'dark' : 'light'; |
| html.setAttribute('data-theme', next); |
| localStorage.setItem('flamegraph-theme', next); |
| |
| // Update theme button icon |
| const btn = document.getElementById('theme-btn'); |
| if (btn) { |
| btn.querySelector('.icon-moon').style.display = next === 'dark' ? 'none' : ''; |
| btn.querySelector('.icon-sun').style.display = next === 'dark' ? '' : 'none'; |
| } |
| |
| // Re-render flamegraph with new theme colors |
| if (window.flamegraphData && normalData) { |
| const currentData = isInverted ? invertedData : normalData; |
| const tooltip = createPythonTooltip(currentData); |
| const chart = createFlamegraph(tooltip, currentData.value); |
| renderFlamegraph(chart, window.flamegraphData); |
| } |
| } |
| |
| 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)); |
| } |
| } |
| |
| function restoreUIState() { |
| // Restore theme |
| const savedTheme = localStorage.getItem('flamegraph-theme'); |
| if (savedTheme) { |
| document.documentElement.setAttribute('data-theme', savedTheme); |
| const btn = document.getElementById('theme-btn'); |
| if (btn) { |
| btn.querySelector('.icon-moon').style.display = savedTheme === 'dark' ? 'none' : ''; |
| btn.querySelector('.icon-sun').style.display = savedTheme === 'dark' ? '' : 'none'; |
| } |
| } |
| |
| // 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>`; |
| |
| 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> |
| ${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 createFlamegraph(tooltip, rootValue) { |
| const chartArea = document.querySelector('.chart-area'); |
| const width = chartArea ? chartArea.clientWidth - 32 : window.innerWidth - 320; |
| const heatColors = getHeatColors(); |
| |
| let chart = flamegraph() |
| .width(width) |
| .cellHeight(20) |
| .transitionDuration(300) |
| .minFrameSize(1) |
| .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]; |
| }); |
| |
| 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 gilHeldPctElem = document.getElementById('gil-held-pct'); |
| if (gilHeldPctElem) gilHeldPctElem.textContent = `${(threadStats.has_gil_pct || 0).toFixed(1)}%`; |
| |
| const gilReleasedPctElem = document.getElementById('gil-released-pct'); |
| // 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)); |
| if (gilReleasedPctElem) gilReleasedPctElem.textContent = `${gilReleasedPct.toFixed(1)}%`; |
| |
| const gilWaitingPctElem = document.getElementById('gil-waiting-pct'); |
| if (gilWaitingPctElem) gilWaitingPctElem.textContent = `${(threadStats.gil_requested_pct || 0).toFixed(1)}%`; |
| } |
| |
| const gcPctElem = document.getElementById('gc-pct'); |
| if (gcPctElem) gcPctElem.textContent = `${(threadStats.gc_pct || 0).toFixed(1)}%`; |
| |
| // Exception stats |
| const excPctElem = document.getElementById('exc-pct'); |
| if (excPctElem) excPctElem.textContent = `${(threadStats.has_exception_pct || 0).toFixed(1)}%`; |
| } |
| |
| // ============================================================================ |
| // 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 + '%'; |
| } |
| } |
| |
| // ============================================================================ |
| // Hotspot Stats |
| // ============================================================================ |
| |
| function populateStats(data) { |
| // 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) { |
| 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; |
| const baseData = isInverted ? invertedData : normalData; |
| |
| let filteredData; |
| let selectedThreadId = null; |
| |
| if (selectedThread === 'all') { |
| filteredData = baseData; |
| } else { |
| selectedThreadId = parseInt(selectedThread, 10); |
| filteredData = filterDataByThread(baseData, selectedThreadId); |
| |
| if (filteredData.strings) { |
| stringTable = filteredData.strings; |
| filteredData = resolveStringIndices(filteredData); |
| } |
| } |
| |
| const tooltip = createPythonTooltip(filteredData); |
| const chart = createFlamegraph(tooltip, filteredData.value); |
| renderFlamegraph(chart, filteredData); |
| |
| populateThreadStats(baseData, selectedThreadId); |
| } |
| |
| 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) { |
| const key = getInvertNodeKey(stackFrame); |
| |
| if (!parent.children[key]) { |
| parent.children[key] = { |
| name: stackFrame.name, |
| value: 0, |
| children: {}, |
| filename: stackFrame.filename, |
| lineno: stackFrame.lineno, |
| funcname: stackFrame.funcname, |
| source: stackFrame.source, |
| threads: new Set() |
| }; |
| } |
| |
| const node = parent.children[key]; |
| node.value += leaf.value; |
| if (leaf.threads) { |
| leaf.threads.forEach(t => node.threads.add(t)); |
| } |
| |
| return node; |
| } |
| |
| function processLeaf(invertedRoot, path, leafNode) { |
| if (!path || path.length === 0) { |
| return; |
| } |
| |
| let invertedParent = accumulateInvertedNode(invertedRoot, leafNode, leafNode); |
| |
| // Walk backwards through the call stack |
| for (let i = path.length - 2; i >= 0; i--) { |
| invertedParent = accumulateInvertedNode(invertedParent, path[i], leafNode); |
| } |
| } |
| |
| function traverseInvert(path, currentNode, invertedRoot) { |
| const children = currentNode.children || []; |
| const childThreads = new Set(children.flatMap(c => c.threads || [])); |
| const selfThreads = (currentNode.threads || []).filter(t => !childThreads.has(t)); |
| |
| if (selfThreads.length > 0) { |
| processLeaf(invertedRoot, path, { ...currentNode, threads: selfThreads }); |
| } |
| |
| children.forEach(child => traverseInvert(path.concat([child]), child, invertedRoot)); |
| } |
| |
| function convertInvertDictToArray(node) { |
| if (node.threads instanceof Set) { |
| node.threads = Array.from(node.threads).sort((a, b) => a - b); |
| } |
| |
| const children = node.children; |
| if (children && typeof children === 'object' && !Array.isArray(children)) { |
| node.children = Object.values(children); |
| node.children.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name)); |
| node.children.forEach(convertInvertDictToArray); |
| } |
| return node; |
| } |
| |
| function generateInvertedFlamegraph(data) { |
| const invertedRoot = { |
| name: data.name, |
| value: data.value, |
| children: {}, |
| stats: data.stats, |
| threads: data.threads |
| }; |
| |
| const children = data.children || []; |
| if (children.length === 0) { |
| // Single-frame tree: the root is its own leaf |
| processLeaf(invertedRoot, [data], data); |
| } else { |
| children.forEach(child => traverseInvert([child], child, invertedRoot)); |
| } |
| |
| convertInvertDictToArray(invertedRoot); |
| return invertedRoot; |
| } |
| |
| function updateToggleUI(toggleId, isOn) { |
| const toggle = document.getElementById(toggleId); |
| if (toggle) { |
| const track = toggle.querySelector('.toggle-track'); |
| const labels = toggle.querySelectorAll('.toggle-label'); |
| if (isOn) { |
| track.classList.add('on'); |
| labels[0].classList.remove('active'); |
| labels[1].classList.add('active'); |
| } else { |
| track.classList.remove('on'); |
| labels[0].classList.add('active'); |
| labels[1].classList.remove('active'); |
| } |
| } |
| } |
| |
| function toggleInvert() { |
| isInverted = !isInverted; |
| updateToggleUI('toggle-invert', isInverted); |
| |
| // Build inverted data on first use |
| if (isInverted && !invertedData) { |
| invertedData = generateInvertedFlamegraph(normalData); |
| } |
| |
| let dataToRender = isInverted ? invertedData : normalData; |
| |
| if (currentThreadFilter !== 'all') { |
| dataToRender = filterDataByThread(dataToRender, parseInt(currentThreadFilter)); |
| } |
| |
| const tooltip = createPythonTooltip(dataToRender); |
| const chart = createFlamegraph(tooltip, dataToRender.value); |
| renderFlamegraph(chart, dataToRender); |
| } |
| |
| // ============================================================================ |
| // Initialization |
| // ============================================================================ |
| |
| function initFlamegraph() { |
| ensureLibraryLoaded(); |
| restoreUIState(); |
| setupLogos(); |
| |
| if (EMBEDDED_DATA.strings) { |
| stringTable = EMBEDDED_DATA.strings; |
| normalData = resolveStringIndices(EMBEDDED_DATA); |
| } 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); |
| |
| 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") { |
| document.addEventListener("DOMContentLoaded", initFlamegraph); |
| } else { |
| initFlamegraph(); |
| } |