gh-135953: Add flamegraph reporter to sampling profiler (#138715)

This commit is contained in:
László Kiss Kollár 2025-09-09 23:06:45 +01:00 committed by GitHub
parent 6bc65c30ff
commit 137519a38c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1446 additions and 6 deletions

1
.gitattributes vendored
View file

@ -103,3 +103,4 @@ Python/stdlib_module_names.h generated
Tools/peg_generator/pegen/grammar_parser.py generated
aclocal.m4 generated
configure generated
*.min.js generated

View file

@ -1169,3 +1169,33 @@ contributors. The pyzstd code is distributed under the 3-Clause BSD License::
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Profiling module
----------------
The :mod:`!profiling` module includes vendored third-party libraries in
:file:`Lib/profiling/sampling/_vendor/` with the following licenses:
**d3-flamegraph**
The d3-flamegraph library is distributed under the Apache License, Version 2.0.
See the OpenSSL section above for the full text of the Apache License Version 2.0.
**d3.js**
The d3.js library contains the following notice::
Copyright 2010-2021 Mike Bostock
Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,46 @@
.d3-flame-graph rect {
stroke: #EEEEEE;
fill-opacity: .8;
}
.d3-flame-graph rect:hover {
stroke: #474747;
stroke-width: 0.5;
cursor: pointer;
}
.d3-flame-graph-label {
pointer-events: none;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 12px;
font-family: Verdana;
margin-left: 4px;
margin-right: 4px;
line-height: 1.5;
padding: 0 0 0;
font-weight: 400;
color: black;
text-align: left;
}
.d3-flame-graph .fade {
opacity: 0.6 !important;
}
.d3-flame-graph .title {
font-size: 20px;
font-family: Verdana;
}
.d3-flame-graph-tip {
background-color: black;
border: none;
border-radius: 3px;
padding: 5px 10px 5px 10px;
min-width: 250px;
text-align: left;
color: white;
z-index: 10;
}

File diff suppressed because one or more lines are too long

2
Lib/profiling/sampling/_vendor/d3/7.8.5/d3.min.js generated vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,405 @@
body {
font-family:
"Source Sans Pro", "Lucida Grande", "Lucida Sans Unicode", "Geneva",
"Verdana", sans-serif;
margin: 0;
padding: 0;
background: #ffffff;
color: #2e3338;
line-height: 1.6;
}
.header {
background: linear-gradient(135deg, #3776ab 0%, #4584bb 100%);
color: white;
padding: 32px 0;
box-shadow: 0 2px 10px rgba(55, 118, 171, 0.2);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 20px;
}
.python-logo {
width: auto;
height: 72px;
margin-bottom: 12px; /* tighter spacing to avoid visual gap */
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.python-logo img {
height: 72px;
width: auto;
display: block; /* avoid baseline alignment issues */
vertical-align: middle;
/* subtle shadow that does not affect layout */
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.1));
}
.header-text h1 {
margin: 0;
font-size: 2.5em;
font-weight: 600;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header-text .subtitle {
margin: 8px 0 0 0;
font-size: 1.1em;
color: rgba(255, 255, 255, 0.9);
font-weight: 300;
}
.header-search {
width: 100%;
max-width: 500px;
}
.header-search #search-input {
width: 100%;
padding: 12px 20px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-radius: 25px;
font-size: 16px;
font-family: inherit;
background: rgba(255, 255, 255, 0.95);
color: #2e3338;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.header-search #search-input:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 1);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.header-search #search-input::placeholder {
color: #6c757d;
}
.stats-section {
background: #ffffff;
padding: 24px 0;
border-bottom: 1px solid #e9ecef;
}
.stats-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.stat-card {
background: #ffffff;
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: flex-start;
gap: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.2s ease;
min-height: 120px;
}
.stat-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.stat-icon {
font-size: 32px;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #3776ab 0%, #4584bb 100%);
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(55, 118, 171, 0.3);
}
.stat-content {
flex: 1;
}
.stat-label {
font-size: 14px;
color: #5a6c7d;
font-weight: 500;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 16px;
font-weight: 700;
color: #2e3338;
line-height: 1.3;
margin-bottom: 4px;
word-break: break-word;
overflow-wrap: break-word;
}
.stat-file {
font-size: 12px;
color: #8b949e;
font-weight: 400;
margin-bottom: 2px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
word-break: break-word;
overflow-wrap: break-word;
}
.stat-detail {
font-size: 12px;
color: #5a6c7d;
font-weight: 400;
line-height: 1.4;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
word-break: break-word;
overflow-wrap: break-word;
}
.controls {
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
padding: 20px 0;
text-align: center;
}
.controls-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
text-align: center;
}
.controls button {
background: #3776ab;
color: white;
border: none;
padding: 12px 24px;
margin: 0 8px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 14px;
font-family: inherit;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(55, 118, 171, 0.2);
}
.controls button:hover {
background: #2d5aa0;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(55, 118, 171, 0.3);
}
.controls button.secondary {
background: #ffd43b;
color: #2e3338;
}
.controls button.secondary:hover {
background: #ffcd02;
}
#chart {
width: 100%;
height: calc(100vh - 160px);
overflow: hidden;
background: #ffffff;
padding: 0 40px;
}
.d3-flame-graph rect {
/* Prefer selector specificity instead of !important */
stroke: rgba(55, 118, 171, 0.3);
stroke-width: 1px;
cursor: pointer;
transition: all 0.1s ease;
}
.d3-flame-graph rect:hover {
stroke: #3776ab;
stroke-width: 2px;
filter: brightness(1.05);
}
.d3-flame-graph text {
/* Ensure labels use our font without !important */
font-family: "Source Sans Pro", sans-serif;
font-size: 12px;
font-weight: 500;
fill: #2e3338;
pointer-events: none;
}
.info-panel {
position: fixed;
bottom: 24px;
left: 84px; /* Leave space for the button */
background: white;
padding: 24px;
border-radius: 8px;
border: 1px solid #e9ecef;
font-size: 14px;
max-width: 280px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
z-index: 1000;
display: none;
}
.info-panel h3 {
margin: 0 0 16px 0;
color: #3776ab;
font-weight: 600;
font-size: 16px;
border-bottom: 2px solid #ffd43b;
padding-bottom: 8px;
}
.info-panel p {
margin: 12px 0;
color: #5a6c7d;
line-height: 1.5;
}
.info-panel strong {
color: #3776ab;
}
#show-info-btn {
position: fixed;
bottom: 32px;
left: 32px;
z-index: 1100;
width: 44px;
height: 44px;
border-radius: 50%;
background: #3776ab;
color: white;
border: none;
font-size: 24px;
box-shadow: 0 2px 8px rgba(55, 118, 171, 0.15);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
#show-info-btn:hover {
background: #2d5aa0;
}
#close-info-btn {
position: absolute;
top: 8px;
right: 12px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #3776ab;
}
@media (max-width: 600px) {
.python-logo { height: 48px; }
.python-logo img { height: 48px; }
#show-info-btn {
left: 8px;
bottom: 8px;
}
.info-panel {
left: 60px; /* Still leave space for button */
bottom: 8px;
max-width: 90vw;
}
}
.legend-panel {
position: fixed;
top: 24px;
left: 24px;
background: white;
padding: 24px;
border-radius: 8px;
border: 1px solid #e9ecef;
font-size: 14px;
max-width: 320px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
display: none;
z-index: 1001;
}
.legend-panel h3 {
margin: 0 0 20px 0;
color: #3776ab;
font-weight: 600;
font-size: 18px;
text-align: center;
border-bottom: 2px solid #ffd43b;
padding-bottom: 8px;
}
.legend-item {
display: flex;
align-items: center;
margin: 12px 0;
padding: 10px;
border-radius: 6px;
background: #f8f9fa;
border: 1px solid #e9ecef;
}
.legend-color {
width: 28px;
height: 18px;
border-radius: 4px;
margin-right: 16px;
border: 1px solid rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
.legend-label {
color: #2e3338;
font-weight: 600;
flex: 1;
}
.legend-description {
color: #5a6c7d;
font-size: 12px;
margin-top: 2px;
font-weight: 400;
}
.chart-container {
background: #ffffff;
margin: 0;
padding: 12px 0;
}

View file

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

View file

@ -0,0 +1,147 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Python Performance Flamegraph</title>
<!-- INLINE_VENDOR_D3_JS -->
<!-- INLINE_VENDOR_FLAMEGRAPH_CSS -->
<!-- INLINE_VENDOR_FLAMEGRAPH_JS -->
<!-- INLINE_VENDOR_FLAMEGRAPH_TOOLTIP_JS -->
<!-- INLINE_CSS -->
</head>
<body>
<div class="header">
<div class="header-content">
<div class="python-logo"><!-- INLINE_LOGO --></div>
<div class="header-text">
<h1>Tachyon Profiler Performance Flamegraph</h1>
<div class="subtitle">
Interactive visualization of function call performance
</div>
</div>
<div class="header-search">
<input type="text" id="search-input" placeholder="🔍 Search functions..." />
</div>
</div>
</div>
<div class="stats-section">
<div class="stats-container">
<div class="stat-card hotspot-card">
<div class="stat-icon">🥇</div>
<div class="stat-content">
<div class="stat-label">#1 Hot Spot</div>
<div class="stat-file" id="hotspot-file-1">--</div>
<div class="stat-value" id="hotspot-func-1">--</div>
<div class="stat-detail" id="hotspot-detail-1">--</div>
</div>
</div>
<div class="stat-card hotspot-card">
<div class="stat-icon">🥈</div>
<div class="stat-content">
<div class="stat-label">#2 Hot Spot</div>
<div class="stat-file" id="hotspot-file-2">--</div>
<div class="stat-value" id="hotspot-func-2">--</div>
<div class="stat-detail" id="hotspot-detail-2">--</div>
</div>
</div>
<div class="stat-card hotspot-card">
<div class="stat-icon">🥉</div>
<div class="stat-content">
<div class="stat-label">#3 Hot Spot</div>
<div class="stat-file" id="hotspot-file-3">--</div>
<div class="stat-value" id="hotspot-func-3">--</div>
<div class="stat-detail" id="hotspot-detail-3">--</div>
</div>
</div>
</div>
</div>
<div class="controls">
<div class="controls-content">
<button onclick="resetZoom()">🏠 Reset Zoom</button>
<button onclick="exportSVG()" class="secondary">📁 Export SVG</button>
<button onclick="toggleLegend()">🔥 Heat Map Legend</button>
</div>
</div>
<button id="show-info-btn" title="Show navigation guide">&#8505;</button>
<div class="info-panel" id="info-panel">
<button id="close-info-btn" title="Close">&times;</button>
<h3>Navigation Guide</h3>
<p><strong>Click:</strong> Zoom into function</p>
<p><strong>Hover:</strong> Show detailed information</p>
<p><strong>Width:</strong> Time spent in function</p>
<p><strong>Height:</strong> Call stack depth</p>
<p><strong>Color:</strong> Performance intensity</p>
</div>
<div class="legend-panel" id="legend-panel">
<h3>🔥 Performance Heat Map</h3>
<div class="legend-item">
<div class="legend-color" style="background-color: #3776ab"></div>
<div>
<div class="legend-label">Hottest Functions (≥60%)</div>
<div class="legend-description">Highest performance impact</div>
</div>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #4584bb"></div>
<div>
<div class="legend-label">Very Hot Functions (35-60%)</div>
<div class="legend-description">High performance impact</div>
</div>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #5592cc"></div>
<div>
<div class="legend-label">Hot Functions (18-35%)</div>
<div class="legend-description">Notable performance cost</div>
</div>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffd43b"></div>
<div>
<div class="legend-label">Warm Functions (12-18%)</div>
<div class="legend-description">Moderate impact</div>
</div>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffdc5c"></div>
<div>
<div class="legend-label">Medium Functions (6-12%)</div>
<div class="legend-description">Some performance impact</div>
</div>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffe47d"></div>
<div>
<div class="legend-label">Cool Functions (3-6%)</div>
<div class="legend-description">Low performance impact</div>
</div>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffec9e"></div>
<div>
<div class="legend-label">Cold Functions (1-3%)</div>
<div class="legend-description">Minimal performance impact</div>
</div>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #fff4bf"></div>
<div>
<div class="legend-label">Coldest Functions (<1%)</div>
<div class="legend-description">Very low performance impact</div>
</div>
</div>
</div>
<div class="chart-container">
<div id="chart"></div>
</div>
<!-- INLINE_JS -->
</body>
</html>

View file

@ -12,17 +12,20 @@
from _colorize import ANSIColors
from .pstats_collector import PstatsCollector
from .stack_collector import CollapsedStackCollector
from .stack_collector import CollapsedStackCollector, FlamegraphCollector
_FREE_THREADED_BUILD = sysconfig.get_config_var("Py_GIL_DISABLED") is not None
_MAX_STARTUP_ATTEMPTS = 5
_STARTUP_RETRY_DELAY_SECONDS = 0.1
_HELP_DESCRIPTION = """Sample a process's stack frames and generate profiling data.
Supports the following target modes:
- -p PID: Profile an existing process by PID
- -m MODULE [ARGS...]: Profile a module as python -m module ...
- filename [ARGS...]: Profile the specified script by running it in a subprocess
Supports the following output formats:
- --pstats: Detailed profiling statistics with sorting options
- --collapsed: Stack traces for generating flamegraphs
- --flamegraph Interactive HTML flamegraph visualization (requires web browser)
Examples:
# Profile process 1234 for 10 seconds with default settings
python -m profiling.sampling -p 1234
@ -39,6 +42,9 @@
# Generate collapsed stacks for flamegraph
python -m profiling.sampling --collapsed -p 1234
# Generate a HTML flamegraph
python -m profiling.sampling --flamegraph -p 1234
# Profile all threads, sort by total time
python -m profiling.sampling -a --sort-tottime -p 1234
@ -185,9 +191,16 @@ def sample(self, collector, duration_sec=10):
if self.realtime_stats and len(self.sample_intervals) > 0:
print() # Add newline after real-time stats
sample_rate = num_samples / running_time
error_rate = (errors / num_samples) * 100 if num_samples > 0 else 0
print(f"Captured {num_samples} samples in {running_time:.2f} seconds")
print(f"Sample rate: {num_samples / running_time:.2f} samples/sec")
print(f"Error rate: {(errors / num_samples) * 100:.2f}%")
print(f"Sample rate: {sample_rate:.2f} samples/sec")
print(f"Error rate: {error_rate:.2f}%")
# Pass stats to flamegraph collector if it's the right type
if hasattr(collector, 'set_stats'):
collector.set_stats(self.sample_interval_usec, running_time, sample_rate, error_rate)
expected_samples = int(duration_sec / sample_interval_sec)
if num_samples < expected_samples:
@ -596,6 +609,9 @@ def sample(
case "collapsed":
collector = CollapsedStackCollector()
filename = filename or f"collapsed.{pid}.txt"
case "flamegraph":
collector = FlamegraphCollector()
filename = filename or f"flamegraph.{pid}.html"
case _:
raise ValueError(f"Invalid output format: {output_format}")
@ -728,12 +744,20 @@ def main():
dest="format",
help="Generate collapsed stack traces for flamegraphs",
)
output_format.add_argument(
"--flamegraph",
action="store_const",
const="flamegraph",
dest="format",
help="Generate HTML flamegraph visualization",
)
output_group.add_argument(
"-o",
"--outfile",
help="Save output to a file (if omitted, prints to stdout for pstats, "
"or saves to collapsed.<pid>.txt for collapsed format)",
"or saves to collapsed.<pid>.txt or flamegraph.<pid>.html for the "
"respective output formats)"
)
# pstats-specific options

View file

@ -1,4 +1,9 @@
import base64
import collections
import functools
import importlib.resources
import json
import linecache
import os
from .collector import Collector
@ -41,3 +46,229 @@ def export(self, filename):
for stack, count in stack_counter.items():
f.write(f"{stack} {count}\n")
print(f"Collapsed stack output written to {filename}")
class FlamegraphCollector(StackTraceCollector):
def __init__(self):
super().__init__()
self.stats = {}
def set_stats(self, sample_interval_usec, duration_sec, sample_rate, error_rate=None):
"""Set profiling statistics to include in flamegraph data."""
self.stats = {
"sample_interval_usec": sample_interval_usec,
"duration_sec": duration_sec,
"sample_rate": sample_rate,
"error_rate": error_rate
}
def export(self, filename):
flamegraph_data = self._convert_to_flamegraph_format()
# Debug output
num_functions = len(flamegraph_data.get("children", []))
total_time = flamegraph_data.get("value", 0)
print(
f"Flamegraph data: {num_functions} root functions, total samples: {total_time}"
)
if num_functions == 0:
print(
"Warning: No functions found in profiling data. Check if sampling captured any data."
)
return
html_content = self._create_flamegraph_html(flamegraph_data)
with open(filename, "w", encoding="utf-8") as f:
f.write(html_content)
print(f"Flamegraph saved to: {filename}")
@functools.lru_cache(maxsize=None)
def _format_function_name(self, func):
filename, lineno, funcname = func
if len(filename) > 50:
parts = filename.split("/")
if len(parts) > 2:
filename = f".../{'/'.join(parts[-2:])}"
return f"{funcname} ({filename}:{lineno})"
def _convert_to_flamegraph_format(self):
"""Convert call trees to d3-flamegraph format with optimized hierarchy building"""
if not self.call_trees:
return {"name": "No Data", "value": 0, "children": []}
unique_functions = set()
for call_tree in self.call_trees:
unique_functions.update(call_tree)
func_to_name = {
func: self._format_function_name(func) for func in unique_functions
}
root = {"name": "root", "children": {}, "samples": 0}
for call_tree in self.call_trees:
current_node = root
current_node["samples"] += 1
for func in call_tree:
func_name = func_to_name[func] # Use pre-computed name
if func_name not in current_node["children"]:
current_node["children"][func_name] = {
"name": func_name,
"func": func,
"children": {},
"samples": 0,
"filename": func[0],
"lineno": func[1],
"funcname": func[2],
}
current_node = current_node["children"][func_name]
current_node["samples"] += 1
def convert_node(node, min_samples=1):
if node["samples"] < min_samples:
return None
source_code = None
if "func" in node:
source_code = self._get_source_lines(node["func"])
result = {
"name": node["name"],
"value": node["samples"],
"children": [],
}
if "filename" in node:
result.update(
{
"filename": node["filename"],
"lineno": node["lineno"],
"funcname": node["funcname"],
}
)
if source_code:
result["source"] = source_code
# Recursively convert children
child_nodes = []
for child_name, child_node in node["children"].items():
child_result = convert_node(child_node, min_samples)
if child_result:
child_nodes.append(child_result)
# Sort children by sample count (descending)
child_nodes.sort(key=lambda x: x["value"], reverse=True)
result["children"] = child_nodes
return result
# Filter out very small functions (less than 0.1% of total samples)
total_samples = len(self.call_trees)
min_samples = max(1, int(total_samples * 0.001))
converted_root = convert_node(root, min_samples)
if not converted_root or not converted_root["children"]:
return {"name": "No significant data", "value": 0, "children": []}
# If we only have one root child, make it the root to avoid redundant level
if len(converted_root["children"]) == 1:
main_child = converted_root["children"][0]
main_child["name"] = f"Program Root: {main_child['name']}"
main_child["stats"] = self.stats
return main_child
converted_root["name"] = "Program Root"
converted_root["stats"] = self.stats
return converted_root
def _get_source_lines(self, func):
filename, lineno, funcname = func
try:
# Get several lines around the function definition
lines = []
start_line = max(1, lineno - 2)
end_line = lineno + 3
for line_num in range(start_line, end_line):
line = linecache.getline(filename, line_num)
if line.strip():
marker = "" if line_num == lineno else " "
lines.append(f"{marker}{line_num}: {line.rstrip()}")
return lines if lines else None
except Exception:
# If we can't get source code, return None
return None
def _create_flamegraph_html(self, data):
data_json = json.dumps(data)
template_dir = importlib.resources.files(__package__)
vendor_dir = template_dir / "_vendor"
assets_dir = template_dir / "_assets"
d3_path = vendor_dir / "d3" / "7.8.5" / "d3.min.js"
d3_flame_graph_dir = vendor_dir / "d3-flame-graph" / "4.1.3"
fg_css_path = d3_flame_graph_dir / "d3-flamegraph.css"
fg_js_path = d3_flame_graph_dir / "d3-flamegraph.min.js"
fg_tooltip_js_path = d3_flame_graph_dir / "d3-flamegraph-tooltip.min.js"
html_template = (template_dir / "flamegraph_template.html").read_text(encoding="utf-8")
css_content = (template_dir / "flamegraph.css").read_text(encoding="utf-8")
js_content = (template_dir / "flamegraph.js").read_text(encoding="utf-8")
# Inline first-party CSS/JS
html_template = html_template.replace(
"<!-- INLINE_CSS -->", f"<style>\n{css_content}\n</style>"
)
html_template = html_template.replace(
"<!-- INLINE_JS -->", f"<script>\n{js_content}\n</script>"
)
png_path = assets_dir / "python-logo-only.png"
b64_logo = base64.b64encode(png_path.read_bytes()).decode("ascii")
# Let CSS control size; keep markup simple
logo_html = f'<img src="data:image/png;base64,{b64_logo}" alt="Python logo"/>'
html_template = html_template.replace("<!-- INLINE_LOGO -->", logo_html)
d3_js = d3_path.read_text(encoding="utf-8")
fg_css = fg_css_path.read_text(encoding="utf-8")
fg_js = fg_js_path.read_text(encoding="utf-8")
fg_tooltip_js = fg_tooltip_js_path.read_text(encoding="utf-8")
html_template = html_template.replace(
"<!-- INLINE_VENDOR_D3_JS -->",
f"<script>\n{d3_js}\n</script>",
)
html_template = html_template.replace(
"<!-- INLINE_VENDOR_FLAMEGRAPH_CSS -->",
f"<style>\n{fg_css}\n</style>",
)
html_template = html_template.replace(
"<!-- INLINE_VENDOR_FLAMEGRAPH_JS -->",
f"<script>\n{fg_js}\n</script>",
)
html_template = html_template.replace(
"<!-- INLINE_VENDOR_FLAMEGRAPH_TOOLTIP_JS -->",
f"<script>\n{fg_tooltip_js}\n</script>",
)
# Replace the placeholder with actual data
html_content = html_template.replace(
"{{FLAMEGRAPH_DATA}}", data_json
)
return html_content

View file

@ -15,6 +15,7 @@
from profiling.sampling.pstats_collector import PstatsCollector
from profiling.sampling.stack_collector import (
CollapsedStackCollector,
FlamegraphCollector,
)
from test.support.os_helper import unlink
@ -436,6 +437,88 @@ def test_collapsed_stack_collector_export(self):
self.assertIn(stack1_expected, lines)
self.assertIn(stack2_expected, lines)
def test_flamegraph_collector_basic(self):
"""Test basic FlamegraphCollector functionality."""
collector = FlamegraphCollector()
# Test empty state (inherits from StackTraceCollector)
self.assertEqual(len(collector.call_trees), 0)
self.assertEqual(len(collector.function_samples), 0)
# Test collecting sample data
test_frames = [
MockInterpreterInfo(
0,
[MockThreadInfo(1, [("file.py", 10, "func1"), ("file.py", 20, "func2")])],
)
]
collector.collect(test_frames)
# Should store call tree (reversed)
self.assertEqual(len(collector.call_trees), 1)
expected_tree = [("file.py", 20, "func2"), ("file.py", 10, "func1")]
self.assertEqual(collector.call_trees[0], expected_tree)
# Should count function samples
self.assertEqual(
collector.function_samples[("file.py", 10, "func1")], 1
)
self.assertEqual(
collector.function_samples[("file.py", 20, "func2")], 1
)
def test_flamegraph_collector_export(self):
"""Test flamegraph HTML export functionality."""
flamegraph_out = tempfile.NamedTemporaryFile(
suffix=".html", delete=False
)
self.addCleanup(close_and_unlink, flamegraph_out)
collector = FlamegraphCollector()
# Create some test data (use Interpreter/Thread objects like runtime)
test_frames1 = [
MockInterpreterInfo(
0,
[MockThreadInfo(1, [("file.py", 10, "func1"), ("file.py", 20, "func2")])],
)
]
test_frames2 = [
MockInterpreterInfo(
0,
[MockThreadInfo(1, [("file.py", 10, "func1"), ("file.py", 20, "func2")])],
)
] # Same stack
test_frames3 = [
MockInterpreterInfo(0, [MockThreadInfo(1, [("other.py", 5, "other_func")])])
]
collector.collect(test_frames1)
collector.collect(test_frames2)
collector.collect(test_frames3)
# Export flamegraph
collector.export(flamegraph_out.name)
# Verify file was created and contains valid data
self.assertTrue(os.path.exists(flamegraph_out.name))
self.assertGreater(os.path.getsize(flamegraph_out.name), 0)
# Check file contains HTML content
with open(flamegraph_out.name, "r", encoding="utf-8") as f:
content = f.read()
# Should be valid HTML
self.assertIn("<!doctype html>", content.lower())
self.assertIn("<html", content)
self.assertIn("Python Performance Flamegraph", content)
self.assertIn("d3-flame-graph", content)
# Should contain the data
self.assertIn('"name":', content)
self.assertIn('"value":', content)
self.assertIn('"children":', content)
def test_pstats_collector_export(self):
collector = PstatsCollector(
sample_interval_usec=1000000
@ -1824,6 +1907,27 @@ def test_esrch_signal_handling(self):
with self.assertRaises(ProcessLookupError):
unwinder.get_stack_trace()
def test_valid_output_formats(self):
"""Test that all valid output formats are accepted."""
valid_formats = ["pstats", "collapsed", "flamegraph"]
tempdir = tempfile.TemporaryDirectory(delete=False)
self.addCleanup(shutil.rmtree, tempdir.name)
with contextlib.chdir(tempdir.name):
for fmt in valid_formats:
try:
# This will likely fail with permissions, but the format should be valid
profiling.sampling.sample.sample(
os.getpid(),
duration_sec=0.1,
output_format=fmt,
filename=f"test_{fmt}.out",
)
except (OSError, RuntimeError, PermissionError):
# Expected errors - we just want to test format validation
pass
class TestSampleProfilerCLI(unittest.TestCase):

View file

@ -2566,6 +2566,9 @@ LIBSUBDIRS= asyncio \
pathlib \
profile \
profiling profiling/sampling profiling/tracing \
profiling/sampling/_assets \
profiling/sampling/_vendor/d3/7.8.5 \
profiling/sampling/_vendor/d3-flame-graph/4.1.3 \
pydoc_data \
re \
site-packages \