Skip to content

Commit 9ee2b80

Browse files
committed
Add heatmap visualization to Tachyon sampling profiler
Introduce a new --heatmap output format that provides line-by-line execution visualization. The heatmap shows: - Color-coded execution intensity for each line (cold → warm → hot → very hot) - Inline sample counts and percentages per line - Per-file statistics (total samples, hottest line) - Interactive call graph navigation with caller/callee buttons - Module type badges (stdlib, site-packages, project code) Unlike flamegraphs which show call stacks and time distribution, heatmaps excel at identifying hot code paths within files, understanding line-level execution patterns, and navigating through call relationships.
1 parent bcced02 commit 9ee2b80

File tree

8 files changed

+2779
-43
lines changed

8 files changed

+2779
-43
lines changed

Lib/profiling/sampling/flamegraph.css

Lines changed: 1120 additions & 37 deletions
Large diffs are not rendered by default.

Lib/profiling/sampling/heatmap.css

Lines changed: 696 additions & 0 deletions
Large diffs are not rendered by default.

Lib/profiling/sampling/heatmap.js

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Tachyon Profiler - Heatmap JavaScript
2+
// Interactive features for the heatmap visualization
3+
4+
// State management
5+
let currentMenu = null;
6+
7+
// Utility: Create element with class and content
8+
function createElement(tag, className, textContent = '') {
9+
const el = document.createElement(tag);
10+
if (className) el.className = className;
11+
if (textContent) el.textContent = textContent;
12+
return el;
13+
}
14+
15+
// Utility: Calculate smart menu position
16+
function calculateMenuPosition(buttonRect, menuWidth, menuHeight) {
17+
const viewport = { width: window.innerWidth, height: window.innerHeight };
18+
const scroll = {
19+
x: window.pageXOffset || document.documentElement.scrollLeft,
20+
y: window.pageYOffset || document.documentElement.scrollTop
21+
};
22+
23+
const left = buttonRect.right + menuWidth + 10 < viewport.width
24+
? buttonRect.right + scroll.x + 10
25+
: Math.max(scroll.x + 10, buttonRect.left + scroll.x - menuWidth - 10);
26+
27+
const top = buttonRect.bottom + menuHeight + 10 < viewport.height
28+
? buttonRect.bottom + scroll.y + 5
29+
: Math.max(scroll.y + 10, buttonRect.top + scroll.y - menuHeight - 10);
30+
31+
return { left, top };
32+
}
33+
34+
// Close and remove current menu
35+
function closeMenu() {
36+
if (currentMenu) {
37+
currentMenu.remove();
38+
currentMenu = null;
39+
}
40+
}
41+
42+
// Show navigation menu for multiple options
43+
function showNavigationMenu(button, items, title) {
44+
closeMenu();
45+
46+
const menu = createElement('div', 'callee-menu');
47+
menu.appendChild(createElement('div', 'callee-menu-header', title));
48+
49+
items.forEach(linkData => {
50+
const item = createElement('div', 'callee-menu-item');
51+
item.appendChild(createElement('div', 'callee-menu-func', linkData.func));
52+
item.appendChild(createElement('div', 'callee-menu-file', linkData.file));
53+
item.addEventListener('click', () => window.location.href = linkData.link);
54+
menu.appendChild(item);
55+
});
56+
57+
const pos = calculateMenuPosition(button.getBoundingClientRect(), 350, 300);
58+
menu.style.left = `${pos.left}px`;
59+
menu.style.top = `${pos.top}px`;
60+
61+
document.body.appendChild(menu);
62+
currentMenu = menu;
63+
}
64+
65+
// Handle navigation button clicks
66+
function handleNavigationClick(button, e) {
67+
e.stopPropagation();
68+
69+
const navData = button.getAttribute('data-nav');
70+
if (navData) {
71+
window.location.href = JSON.parse(navData).link;
72+
return;
73+
}
74+
75+
const navMulti = button.getAttribute('data-nav-multi');
76+
if (navMulti) {
77+
const items = JSON.parse(navMulti);
78+
const title = button.classList.contains('caller') ? 'Choose a caller:' : 'Choose a callee:';
79+
showNavigationMenu(button, items, title);
80+
}
81+
}
82+
83+
// Initialize navigation buttons
84+
document.querySelectorAll('.nav-btn').forEach(button => {
85+
button.addEventListener('click', e => handleNavigationClick(button, e));
86+
});
87+
88+
// Close menu when clicking outside
89+
document.addEventListener('click', e => {
90+
if (currentMenu && !currentMenu.contains(e.target) && !e.target.classList.contains('nav-btn')) {
91+
closeMenu();
92+
}
93+
});
94+
95+
// Scroll to target line (centered using CSS scroll-margin-top)
96+
function scrollToTargetLine() {
97+
if (!window.location.hash) return;
98+
const target = document.querySelector(window.location.hash);
99+
if (target) {
100+
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
101+
}
102+
}
103+
104+
// Initialize line number permalink handlers
105+
document.querySelectorAll('.line-number').forEach(lineNum => {
106+
lineNum.style.cursor = 'pointer';
107+
lineNum.addEventListener('click', e => {
108+
window.location.hash = `line-${e.target.textContent.trim()}`;
109+
});
110+
});
111+
112+
// Setup scroll-to-line behavior
113+
setTimeout(scrollToTargetLine, 100);
114+
window.addEventListener('hashchange', () => setTimeout(scrollToTargetLine, 50));
115+
116+
// Get sample count from line element
117+
function getSampleCount(line) {
118+
const text = line.querySelector('.line-samples')?.textContent.trim().replace(/,/g, '');
119+
return parseInt(text) || 0;
120+
}
121+
122+
// Classify intensity based on ratio
123+
function getIntensityClass(ratio) {
124+
if (ratio > 0.75) return 'vhot';
125+
if (ratio > 0.5) return 'hot';
126+
if (ratio > 0.25) return 'warm';
127+
return 'cold';
128+
}
129+
130+
// Build scroll minimap showing hotspot locations
131+
function buildScrollMarker() {
132+
const existing = document.getElementById('scroll_marker');
133+
if (existing) existing.remove();
134+
135+
if (document.body.scrollHeight <= window.innerHeight) return;
136+
137+
const lines = document.querySelectorAll('.code-line');
138+
const markerScale = window.innerHeight / document.body.scrollHeight;
139+
const lineHeight = Math.min(Math.max(3, window.innerHeight / lines.length), 10);
140+
const maxSamples = Math.max(...Array.from(lines, getSampleCount));
141+
142+
const scrollMarker = createElement('div', '');
143+
scrollMarker.id = 'scroll_marker';
144+
145+
let prevLine = -99, lastMark, lastTop;
146+
147+
lines.forEach((line, index) => {
148+
const samples = getSampleCount(line);
149+
if (samples === 0) return;
150+
151+
const lineTop = Math.floor(line.offsetTop * markerScale);
152+
const lineNumber = index + 1;
153+
const intensityClass = maxSamples > 0 ? getIntensityClass(samples / maxSamples) : 'cold';
154+
155+
if (lineNumber === prevLine + 1 && lastMark?.classList.contains(intensityClass)) {
156+
lastMark.style.height = `${lineTop + lineHeight - lastTop}px`;
157+
} else {
158+
lastMark = createElement('div', `marker ${intensityClass}`);
159+
lastMark.style.height = `${lineHeight}px`;
160+
lastMark.style.top = `${lineTop}px`;
161+
scrollMarker.appendChild(lastMark);
162+
lastTop = lineTop;
163+
}
164+
165+
prevLine = lineNumber;
166+
});
167+
168+
document.body.appendChild(scrollMarker);
169+
}
170+
171+
// Build scroll marker on load and resize
172+
setTimeout(buildScrollMarker, 200);
173+
window.addEventListener('resize', buildScrollMarker);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title><!-- FILENAME --> - Heatmap</title>
7+
<!-- INLINE_CSS -->
8+
</head>
9+
<body class="code-view">
10+
<header class="code-header">
11+
<div class="code-header-content">
12+
<h1>📄 <!-- FILENAME --></h1>
13+
<a href="index.html" class="back-link">← Back to Index</a>
14+
</div>
15+
</header>
16+
17+
<div class="file-stats">
18+
<div class="stats-grid">
19+
<div class="stat-item">
20+
<div class="stat-value"><!-- TOTAL_SAMPLES --></div>
21+
<div class="stat-label">Total Samples</div>
22+
</div>
23+
<div class="stat-item">
24+
<div class="stat-value"><!-- NUM_LINES --></div>
25+
<div class="stat-label">Lines Hit</div>
26+
</div>
27+
<div class="stat-item">
28+
<div class="stat-value"><!-- PERCENTAGE -->%</div>
29+
<div class="stat-label">% of Total</div>
30+
</div>
31+
<div class="stat-item">
32+
<div class="stat-value"><!-- MAX_SAMPLES --></div>
33+
<div class="stat-label">Max Samples/Line</div>
34+
</div>
35+
</div>
36+
</div>
37+
38+
<div class="legend">
39+
<div class="legend-content">
40+
<span class="legend-title">🔥 Intensity:</span>
41+
<div class="legend-gradient"></div>
42+
<div class="legend-labels">
43+
<span>Cold (0)</span>
44+
<span></span>
45+
<span>Hot (Max)</span>
46+
</div>
47+
</div>
48+
</div>
49+
50+
<div class="code-container">
51+
<!-- CODE_LINES -->
52+
</div>
53+
54+
<!-- INLINE_JS -->
55+
</body>
56+
</html>

Lib/profiling/sampling/sample.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from _colorize import ANSIColors
1313

1414
from .pstats_collector import PstatsCollector
15-
from .stack_collector import CollapsedStackCollector, FlamegraphCollector
15+
from .stack_collector import CollapsedStackCollector, FlamegraphCollector, HeatmapCollector
1616
from .gecko_collector import GeckoCollector
1717

1818
_FREE_THREADED_BUILD = sysconfig.get_config_var("Py_GIL_DISABLED") is not None
@@ -41,6 +41,7 @@ def _parse_mode(mode_string):
4141
- --pstats: Detailed profiling statistics with sorting options
4242
- --collapsed: Stack traces for generating flamegraphs
4343
- --flamegraph Interactive HTML flamegraph visualization (requires web browser)
44+
- --heatmap: Coverage.py-style HTML heatmap showing line-by-line sample intensity
4445
4546
Examples:
4647
# Profile process 1234 for 10 seconds with default settings
@@ -61,6 +62,9 @@ def _parse_mode(mode_string):
6162
# Generate a HTML flamegraph
6263
python -m profiling.sampling --flamegraph -p 1234
6364
65+
# Generate a heatmap report with line-by-line sample intensity
66+
python -m profiling.sampling --heatmap -o results -p 1234
67+
6468
# Profile all threads, sort by total time
6569
python -m profiling.sampling -a --sort-tottime -p 1234
6670
@@ -632,6 +636,9 @@ def sample(
632636
case "flamegraph":
633637
collector = FlamegraphCollector(skip_idle=skip_idle)
634638
filename = filename or f"flamegraph.{pid}.html"
639+
case "heatmap":
640+
collector = HeatmapCollector(skip_idle=skip_idle)
641+
filename = filename or f"heatmap_{pid}"
635642
case "gecko":
636643
collector = GeckoCollector(skip_idle=skip_idle)
637644
filename = filename or f"gecko.{pid}.json"
@@ -676,10 +683,13 @@ def _validate_collapsed_format_args(args, parser):
676683
f"The following options are only valid with --pstats format: {', '.join(invalid_opts)}"
677684
)
678685

679-
# Set default output filename for collapsed format only if we have a PID
686+
# Set default output filename for the format only if we have a PID
680687
# For module/script execution, this will be set later with the subprocess PID
681688
if not args.outfile and args.pid is not None:
682-
args.outfile = f"collapsed.{args.pid}.txt"
689+
if args.format == "collapsed":
690+
args.outfile = f"collapsed.{args.pid}.txt"
691+
elif args.format == "heatmap":
692+
args.outfile = f"heatmap_{args.pid}"
683693

684694

685695
def wait_for_process_and_sample(pid, sort_value, args):
@@ -691,6 +701,10 @@ def wait_for_process_and_sample(pid, sort_value, args):
691701
filename = f"collapsed.{pid}.txt"
692702
elif args.format == "gecko":
693703
filename = f"gecko.{pid}.json"
704+
elif args.format == "flamegraph":
705+
filename = f"flamegraph.{pid}.html"
706+
elif args.format == "heatmap":
707+
filename = f"heatmap_{pid}"
694708

695709
mode = _parse_mode(args.mode)
696710

@@ -794,6 +808,13 @@ def main():
794808
dest="format",
795809
help="Generate HTML flamegraph visualization",
796810
)
811+
output_format.add_argument(
812+
"--heatmap",
813+
action="store_const",
814+
const="heatmap",
815+
dest="format",
816+
help="Generate coverage.py-style HTML heatmap with line-by-line sample intensity",
817+
)
797818
output_format.add_argument(
798819
"--gecko",
799820
action="store_const",
@@ -806,8 +827,8 @@ def main():
806827
"-o",
807828
"--outfile",
808829
help="Save output to a file (if omitted, prints to stdout for pstats, "
809-
"or saves to collapsed.<pid>.txt or flamegraph.<pid>.html for the "
810-
"respective output formats)"
830+
"or saves to collapsed.<pid>.txt, flamegraph.<pid>.html, or heatmap_<pid>/ "
831+
"for the respective output formats)"
811832
)
812833

813834
# pstats-specific options
@@ -879,7 +900,7 @@ def main():
879900
args = parser.parse_args()
880901

881902
# Validate format-specific arguments
882-
if args.format in ("collapsed", "gecko"):
903+
if args.format in ("collapsed", "gecko", "heatmap"):
883904
_validate_collapsed_format_args(args, parser)
884905

885906
sort_value = args.sort if args.sort is not None else 2

0 commit comments

Comments
 (0)