mirror of
https://github.com/RustPython/RustPython.git
synced 2026-10-29 11:14:38 +08:00
* Add deterministic profiler and canonical Python 3.15 tools
Use the merged operation critical sections with interpreter-bound profiler
entry checks and short payload borrows. Trace owned code references for GC.
Import the canonical tracing and offline profiling output closure via the
repository updater, retaining existing test markers.
Assisted-by: dot:GPT-6.1
* Clarify standalone profiling license cross-reference
Preserve license terms and copyright notices; point to the reproduced
Apache text below instead of CPython's original OpenSSL section above.
Assisted-by: dot:GPT-6.1
* Defer unsupported sampling package from profiler extraction
Keep the deterministic tracing profiler and its canonical tools together.
Defer the entirely new sampling production, tests, assets and their license
as one closure until native record and binary-collector support is ready.
Retain canonical pstats for deterministic empty-profile handling; its
upstream sampled-file sorting issue is not patched here.
Assisted-by: dot:GPT-6.1
* Stage profiling root as a namespace until sampling is available
Omit the whole newly added canonical package initializer rather than
advertise an absent sampling module or hand-edit canonical __all__.
Explicit profiling.tracing and cProfile imports remain available.
The namespace root intentionally lacks canonical metadata/automatic exports.
This addresses test___all__ failures in the prior Linux, macOS and Windows
CI runs. No test assertions, skips or canonical file contents are changed.
Assisted-by: dot:GPT-6.1
* Match canonical static builtin profiler labels and metadata
Normalize the raw builtin receiver instead of its call-argument accessor.
Snapshot static method module metadata at construction, matching CPython,
so explicit module mutation and deletion remain observable. Avoid a
profiler-time fallback that would obscure deleted metadata.
Correct float.__getformat__ to CPython's classmethod calling convention,
including subclass binding, rather than special-casing its profiler label.
Assisted-by: dot:GPT-6.1
* Support collision-safe frozen package containers
Generate empty frozen packages only for source namespace directories with
real frozen descendants. Preserve genuine modules and packages across
frozen-library registrations while retaining real-real replacement order.
Record the container marker in FrozenModule and its library envelope;
legacy module/package encodings remain unchanged. This is closed-tree
frozen packaging, not full PEP 420 namespace merging.
Add structural codec, mock generator, and registration-priority checks.
* Revert frozen package containers before restoring canonical profiling root
The container implementation changed filesystem namespace precedence.
Restore the existing importer and frozen envelope unchanged; the following
offline sampling closure restores the canonical regular package root.
This reverses d5a8049234.
Assisted-by: dot:GPT-6.1
* Restore canonical profiling package with offline sampling codec
Provide native records, status constants and genuine uncompressed binary
reader/writer APIs for canonical offline tools. Keep the unsupported live
process capability absent and preserve existing canonical test gates.
Restore the complete selected sampling production/test/assets closure via
update_lib, including its asyncio frame-schema dependency. The prior frozen
container extension is reverted separately; canonical packages work with
both filesystem and frozen imports without changing namespace semantics.
Assisted-by: dot:GPT-6.1
* Keep platform-dependent C long normalization explicit
Allow only the c_long-to-i32 conversion lint: Windows uses the same type,
while LP64 requires the existing truncation to match CPython normalization.
No runtime behavior or test assertions change.
Assisted-by: dot:GPT-6.1
* Include profiling notices in library and desktop packages
Bundle a byte-identical copy of the root profiling notice in rustpython-pylib
and include the root notice in configured desktop resources. Avoid a symlink
so Windows checkouts preserve the actual notice text. Terms are unchanged.
Assisted-by: dot:GPT-6.1
* Keep profiling asset license notice in pylib crate
* Serve frozen profiling assets through native resource readers
* Preserve canonical profiling asset line endings on checkout
---------
Co-authored-by: youknowdot <dot@youknowone.org>
1529 lines
51 KiB
JavaScript
Vendored
1529 lines
51 KiB
JavaScript
Vendored
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;
|
|
let useModuleNames = true;
|
|
let zoomedNodeValue = null;
|
|
|
|
// 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 (typeof resolved.module === 'number') {
|
|
resolved.module = resolveString(resolved.module, table);
|
|
}
|
|
if (typeof resolved.label === 'number') {
|
|
resolved.label = resolveString(resolved.label, 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;
|
|
}
|
|
|
|
// Escape HTML special characters
|
|
function escapeHtml(str) {
|
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
// Get display path based on user preference (module or full path)
|
|
function getDisplayName(moduleName, filename) {
|
|
if (useModuleNames) {
|
|
return moduleName || filename;
|
|
}
|
|
return filename;
|
|
}
|
|
|
|
function samplesToMilliseconds(samples, data) {
|
|
return (samples * data.stats.sample_interval_usec / 1000).toFixed(2);
|
|
}
|
|
|
|
function selectFlamegraphData(selectedThreadId = null) {
|
|
let baseData = isShowingElided ? elidedFlamegraphData : normalData;
|
|
|
|
if (selectedThreadId !== null) {
|
|
baseData = filterDataByThread(baseData, selectedThreadId);
|
|
}
|
|
|
|
if (!isInverted) {
|
|
return baseData;
|
|
}
|
|
|
|
// Thread-filtered trees have different values, so invert them after filtering
|
|
// instead of using the cached all-thread tree.
|
|
if (selectedThreadId !== null) {
|
|
return generateInvertedFlamegraph(baseData);
|
|
}
|
|
|
|
if (isShowingElided) {
|
|
if (!invertedElidedData) {
|
|
invertedElidedData = generateInvertedFlamegraph(baseData);
|
|
}
|
|
return invertedElidedData;
|
|
}
|
|
|
|
if (!invertedData) {
|
|
invertedData = generateInvertedFlamegraph(baseData);
|
|
}
|
|
return invertedData;
|
|
}
|
|
|
|
function updateFlamegraphView() {
|
|
const selectedThreadId = currentThreadFilter !== 'all' ? parseInt(currentThreadFilter, 10) : null;
|
|
const selectedData = selectFlamegraphData(selectedThreadId);
|
|
const tooltip = createPythonTooltip(selectedData);
|
|
const chart = createFlamegraph(tooltip, selectedData.value, selectedData);
|
|
renderFlamegraph(chart, selectedData);
|
|
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, data) {
|
|
const funcname = resolveString(nodeData.funcname) || resolveString(nodeData.name) || "--";
|
|
const filename = resolveString(nodeData.filename) || "";
|
|
const moduleName = resolveString(nodeData.module) || "";
|
|
const lineno = nodeData.lineno;
|
|
const timeMs = samplesToMilliseconds(nodeData.value, data);
|
|
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 displayName = getDisplayName(moduleName, filename);
|
|
fileEl.textContent = lineno ? `${displayName}:${lineno}` : displayName;
|
|
}
|
|
|
|
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 = samplesToMilliseconds(d.data.value, data);
|
|
const selfSamples = d.data.self || 0;
|
|
const selfMs = samplesToMilliseconds(selfSamples, data);
|
|
const percentage = ((d.data.value / data.value) * 100).toFixed(2);
|
|
const relativePercentage = Math.min(100, ((d.data.value / (zoomedNodeValue ?? 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 moduleName = resolveString(d.data.module) || "";
|
|
const displayName = escapeHtml(useModuleNames ? (moduleName || filename) : 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 = escapeHtml(line);
|
|
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">${displayName}${d.data.lineno ? ":" + d.data.lineno : ""}</div>`;
|
|
|
|
// Differential stats section
|
|
let diffSection = "";
|
|
if (d.data.diff !== undefined && d.data.baseline !== undefined) {
|
|
const baselineSelf = samplesToMilliseconds(d.data.baseline, data);
|
|
const currentSelf = samplesToMilliseconds(d.data.self_time || 0, data);
|
|
const diffMs = samplesToMilliseconds(d.data.diff, data);
|
|
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">Total Time:</span>
|
|
<span class="tooltip-stat-value">${timeMs} ms</span>
|
|
|
|
${selfSamples > 0 ? `
|
|
<span class="tooltip-stat-label">Self Time:</span>
|
|
<span class="tooltip-stat-value">${selfMs} ms</span>
|
|
` : ''}
|
|
|
|
<span class="tooltip-stat-label">Percentage:</span>
|
|
<span class="tooltip-stat-value accent">${percentage}%</span>
|
|
|
|
${relativePercentage != percentage && relativePercentage != "100.00" ? `
|
|
<span class="tooltip-stat-label">Relative Percentage:</span>
|
|
<span class="tooltip-stat-value accent">${relativePercentage}%</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, data);
|
|
};
|
|
|
|
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)
|
|
.getName(d => resolveString(useModuleNames ? d.data.label : d.data.name) || resolveString(d.data.name) || '')
|
|
.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];
|
|
})
|
|
.onClick(function (d) {
|
|
zoomedNodeValue = d.data.value;
|
|
});
|
|
|
|
return chart;
|
|
}
|
|
|
|
function renderFlamegraph(chart, data) {
|
|
d3.select("#chart").datum(data).call(chart);
|
|
window.flamegraphChart = chart;
|
|
window.flamegraphData = data;
|
|
zoomedNodeValue = null;
|
|
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 moduleName = resolveString(d.data.module) || "";
|
|
const displayName = getDisplayName(moduleName, filename);
|
|
const lineno = d.data.lineno;
|
|
const term = searchTerm.toLowerCase();
|
|
|
|
// Check if search term looks like path:line pattern
|
|
const fileLineMatch = term.match(/^(.+):(\d+)$/);
|
|
let matches = false;
|
|
|
|
if (fileLineMatch) {
|
|
const searchFile = fileLineMatch[1];
|
|
const searchLine = parseInt(fileLineMatch[2], 10);
|
|
matches = displayName.toLowerCase().includes(searchFile) && lineno === searchLine;
|
|
} else {
|
|
// Regular substring search
|
|
matches =
|
|
name.toLowerCase().includes(term) ||
|
|
funcname.toLowerCase().includes(term) ||
|
|
displayName.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 = currentThreadFilter !== 'all'
|
|
? (data.value ?? 0)
|
|
: (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;
|
|
const missedSamples = stats.missed_samples;
|
|
|
|
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);
|
|
let moduleName = resolveString(node.module);
|
|
|
|
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';
|
|
moduleName = moduleName || 'unknown';
|
|
|
|
if (filename !== 'unknown' && funcname !== 'unknown' && node.value > 0) {
|
|
const directSamples = node.self || 0;
|
|
|
|
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.module = moduleName;
|
|
existing.lineno = node.lineno || '?';
|
|
existing.maxSingleSamples = directSamples;
|
|
}
|
|
} else {
|
|
functionMap.set(funcKey, {
|
|
filename: filename,
|
|
module: moduleName,
|
|
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 moduleName = h.module || 'unknown';
|
|
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 displayName = getDisplayName(moduleName, filename);
|
|
fileEl.textContent = `${displayName}:${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 moduleName = h.module || 'unknown';
|
|
const filename = h.filename || 'unknown';
|
|
const displayName = getDisplayName(moduleName, filename);
|
|
const hasValidLocation = displayName !== 'unknown' && h.lineno !== '?';
|
|
const searchTerm = hasValidLocation ? `${displayName}:${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 || data.stats?.is_differential) 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) {
|
|
const threadValues = node.thread_values?.[threadId];
|
|
if (!threadValues) {
|
|
return null;
|
|
}
|
|
|
|
const {
|
|
thread_values: _threadValues,
|
|
thread_opcodes: threadOpcodes,
|
|
...sharedNode
|
|
} = node;
|
|
const filteredNode = {
|
|
...sharedNode,
|
|
value: threadValues[0],
|
|
self: threadValues[1],
|
|
opcodes: threadOpcodes?.[threadId] ?? {},
|
|
children: []
|
|
};
|
|
|
|
if (node.children && Array.isArray(node.children)) {
|
|
filteredNode.children = node.children
|
|
.map(child => filterNode(child))
|
|
.filter(child => child !== null);
|
|
}
|
|
|
|
return filteredNode;
|
|
}
|
|
|
|
return filterNode(data);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Control Functions
|
|
// ============================================================================
|
|
|
|
function resetZoom() {
|
|
if (window.flamegraphChart) {
|
|
zoomedNodeValue = null;
|
|
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,
|
|
label: stackFrame.label,
|
|
value: 0,
|
|
self: 0,
|
|
children: {},
|
|
filename: stackFrame.filename,
|
|
module: stackFrame.module,
|
|
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;
|
|
node.self += stackFrame.self || 0;
|
|
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 selfValue = currentNode.self || 0;
|
|
|
|
if (selfValue > 0) {
|
|
processLeaf(invertedRoot, path, { ...currentNode, value: selfValue }, isDifferential);
|
|
}
|
|
|
|
const children = currentNode.children || [];
|
|
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,
|
|
label: data.label,
|
|
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();
|
|
}
|
|
|
|
function togglePathDisplay() {
|
|
useModuleNames = !useModuleNames;
|
|
updateToggleUI('toggle-path-display', useModuleNames);
|
|
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);
|
|
}
|
|
|
|
const togglePathDisplayBtn = document.getElementById('toggle-path-display');
|
|
if (togglePathDisplayBtn) {
|
|
togglePathDisplayBtn.addEventListener('click', togglePathDisplay);
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|