mirror of
https://github.com/python/cpython.git
synced 2025-12-31 04:23:37 +00:00
gh-135953: Add flamegraph reporter to sampling profiler (#138715)
This commit is contained in:
parent
6bc65c30ff
commit
137519a38c
14 changed files with 1446 additions and 6 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
BIN
Lib/profiling/sampling/_assets/python-logo-only.png
Normal file
BIN
Lib/profiling/sampling/_assets/python-logo-only.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
1
Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js
generated
vendored
Normal file
1
Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
}
|
||||
1
Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.min.js
generated
vendored
Normal file
1
Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.min.js
generated
vendored
Normal file
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
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
405
Lib/profiling/sampling/flamegraph.css
Normal file
405
Lib/profiling/sampling/flamegraph.css
Normal 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;
|
||||
}
|
||||
445
Lib/profiling/sampling/flamegraph.js
Normal file
445
Lib/profiling/sampling/flamegraph.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")}</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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
147
Lib/profiling/sampling/flamegraph_template.html
Normal file
147
Lib/profiling/sampling/flamegraph_template.html
Normal 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">ℹ</button>
|
||||
|
||||
<div class="info-panel" id="info-panel">
|
||||
<button id="close-info-btn" title="Close">×</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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue