Skip to content

Commit 57f1d48

Browse files
committed
gh-142927: Fix heatmap caller navigation for interior lines
The heatmap was only showing caller buttons on function definition lines, not on interior lines within a function. This happened because callers were recorded against the function definition line but looked up by the current line number when building navigation buttons. Added a line_to_function mapping to track which function each sampled line belongs to. When building navigation buttons, callers are now looked up via the function definition line so all lines in a function show who calls that function. Callees remain line-specific since only actual call sites should show what they call. Added tests covering root, middle, and leaf frame behavior in call stacks.
1 parent ea3fd78 commit 57f1d48

File tree

2 files changed

+275
-3
lines changed

2 files changed

+275
-3
lines changed

Lib/profiling/sampling/heatmap_collector.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,10 @@ def __init__(self, *args, **kwargs):
472472
self.callers_graph = collections.defaultdict(set)
473473
self.function_definitions = {}
474474

475+
# Map each sampled line to its function for proper caller lookup
476+
# (filename, lineno) -> funcname
477+
self.line_to_function = {}
478+
475479
# Edge counting for call path analysis
476480
self.edge_samples = collections.Counter()
477481

@@ -596,6 +600,10 @@ def _record_line_sample(self, filename, lineno, funcname, is_leaf=False,
596600
if funcname and (filename, funcname) not in self.function_definitions:
597601
self.function_definitions[(filename, funcname)] = lineno
598602

603+
# Map this line to its function for caller/callee navigation
604+
if funcname:
605+
self.line_to_function[(filename, lineno)] = funcname
606+
599607
def _record_bytecode_sample(self, filename, lineno, opcode,
600608
end_lineno=None, col_offset=None, end_col_offset=None,
601609
weight=1):
@@ -1150,13 +1158,37 @@ def _format_specialization_color(self, spec_pct: int) -> str:
11501158
return f"rgba({r}, {g}, {b}, {alpha})"
11511159

11521160
def _build_navigation_buttons(self, filename: str, line_num: int) -> str:
1153-
"""Build navigation buttons for callers/callees."""
1161+
"""Build navigation buttons for callers/callees.
1162+
1163+
- Callers: All lines in a function show who calls this function
1164+
- Callees: Only actual call site lines show what they call
1165+
"""
11541166
line_key = (filename, line_num)
1155-
caller_list = self._deduplicate_by_function(self.callers_graph.get(line_key, set()))
1167+
1168+
# Find which function this line belongs to
1169+
funcname = self.line_to_function.get(line_key)
1170+
1171+
# Get callers: look up by function definition line, not current line
1172+
# This ensures all lines in a function show who calls this function
1173+
if funcname:
1174+
func_def_line = self.function_definitions.get((filename, funcname), line_num)
1175+
func_def_key = (filename, func_def_line)
1176+
caller_list = self._deduplicate_by_function(self.callers_graph.get(func_def_key, set()))
1177+
else:
1178+
caller_list = self._deduplicate_by_function(self.callers_graph.get(line_key, set()))
1179+
1180+
# Get callees: only show for actual call site lines (not every line in function)
11561181
callee_list = self._deduplicate_by_function(self.call_graph.get(line_key, set()))
11571182

11581183
# Get edge counts for each caller/callee
1159-
callers_with_counts = self._get_edge_counts(line_key, caller_list, is_caller=True)
1184+
# For callers, use the function definition key for edge lookup
1185+
if funcname:
1186+
func_def_line = self.function_definitions.get((filename, funcname), line_num)
1187+
caller_edge_key = (filename, func_def_line)
1188+
else:
1189+
caller_edge_key = line_key
1190+
callers_with_counts = self._get_edge_counts(caller_edge_key, caller_list, is_caller=True)
1191+
# For callees, use the actual line key since that's where the call happens
11601192
callees_with_counts = self._get_edge_counts(line_key, callee_list, is_caller=False)
11611193

11621194
# Build navigation buttons with counts

Lib/test/test_profiling/test_heatmap.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,246 @@ def test_process_frames_with_file_samples_dict(self):
367367
self.assertEqual(collector.file_samples['test.py'][10], 1)
368368

369369

370+
class TestHeatmapCollectorNavigationButtons(unittest.TestCase):
371+
"""Test navigation button behavior for caller/callee relationships.
372+
373+
For every call stack:
374+
- Root frames (entry points): only DOWN arrow (▼) for callees
375+
- Middle frames: both UP (▲) and DOWN (▼) arrows
376+
- Leaf frames: only UP arrow (▲) for callers
377+
378+
All lines within a function should show who calls that function.
379+
"""
380+
381+
def setUp(self):
382+
"""Set up test directory and collector."""
383+
self.test_dir = tempfile.mkdtemp()
384+
self.addCleanup(shutil.rmtree, self.test_dir)
385+
386+
def _create_collector_with_stack(self, frames_list):
387+
"""Helper to create a collector and process frame stacks."""
388+
collector = HeatmapCollector(sample_interval_usec=100)
389+
for frames in frames_list:
390+
collector.process_frames(frames, thread_id=1)
391+
return collector
392+
393+
def _export_and_get_html(self, collector, filename):
394+
"""Export and return HTML content for a specific file."""
395+
output_path = os.path.join(self.test_dir, 'nav_test')
396+
with captured_stdout(), captured_stderr():
397+
collector.export(output_path)
398+
399+
# Find the HTML file for the given source file
400+
for html_file in os.listdir(output_path):
401+
if html_file.startswith('file_') and html_file.endswith('.html'):
402+
html_path = os.path.join(output_path, html_file)
403+
with open(html_path, 'r', encoding='utf-8') as f:
404+
content = f.read()
405+
if filename in content:
406+
return content
407+
return None
408+
409+
def test_leaf_frame_has_only_caller_button(self):
410+
"""Test that leaf frames (top of stack) only have caller buttons."""
411+
# Create a simple call stack: caller.py:10 -> leaf.py:5
412+
# leaf.py:5 is the leaf (where execution happens)
413+
frames = [
414+
('leaf.py', (5, 5, -1, -1), 'leaf_func', None), # Leaf
415+
('caller.py', (10, 10, -1, -1), 'caller_func', None) # Caller
416+
]
417+
collector = self._create_collector_with_stack([frames])
418+
419+
# Check that leaf has callers recorded but no callees
420+
leaf_key = ('leaf.py', 5)
421+
caller_key = ('caller.py', 10)
422+
423+
# Leaf should be in callers_graph (someone calls it)
424+
self.assertIn(leaf_key, collector.callers_graph)
425+
426+
# Leaf should NOT be in call_graph (it doesn't call anyone)
427+
self.assertNotIn(leaf_key, collector.call_graph)
428+
429+
# Caller should be in call_graph (it calls the leaf)
430+
self.assertIn(caller_key, collector.call_graph)
431+
432+
def test_root_frame_has_only_callee_button(self):
433+
"""Test that root frames (bottom of stack) only have callee buttons."""
434+
# Create a simple call stack: root.py:20 -> child.py:10
435+
# root.py:20 is the root (entry point, no one calls it)
436+
frames = [
437+
('child.py', (10, 10, -1, -1), 'child_func', None), # Child (leaf)
438+
('root.py', (20, 20, -1, -1), 'root_func', None) # Root
439+
]
440+
collector = self._create_collector_with_stack([frames])
441+
442+
root_key = ('root.py', 20)
443+
child_key = ('child.py', 10)
444+
445+
# Root should be in call_graph (it calls the child)
446+
self.assertIn(root_key, collector.call_graph)
447+
448+
# Root should NOT be in callers_graph (no one calls it in this profile)
449+
self.assertNotIn(root_key, collector.callers_graph)
450+
451+
# Child's definition line should be in callers_graph
452+
child_def_key = ('child.py', 10)
453+
self.assertIn(child_def_key, collector.callers_graph)
454+
455+
def test_middle_frame_has_both_buttons(self):
456+
"""Test that middle frames have both caller and callee buttons."""
457+
# Create a 3-level stack: root.py:30 -> middle.py:20 -> leaf.py:10
458+
frames = [
459+
('leaf.py', (10, 10, -1, -1), 'leaf_func', None),
460+
('middle.py', (20, 20, -1, -1), 'middle_func', None),
461+
('root.py', (30, 30, -1, -1), 'root_func', None)
462+
]
463+
collector = self._create_collector_with_stack([frames])
464+
465+
middle_key = ('middle.py', 20)
466+
467+
# Middle should be in call_graph (it calls leaf)
468+
self.assertIn(middle_key, collector.call_graph)
469+
470+
# Middle's definition should be in callers_graph (root calls it)
471+
self.assertIn(middle_key, collector.callers_graph)
472+
473+
def test_all_lines_in_function_see_callers(self):
474+
"""Test that all lines in a function show who calls the function."""
475+
# Simulate a function with multiple sampled lines
476+
# Stack 1: caller.py:100 calls func at line 10, executing at line 12
477+
frames1 = [
478+
('module.py', (12, 12, -1, -1), 'my_func', None), # Interior line
479+
('caller.py', (100, 100, -1, -1), 'caller', None)
480+
]
481+
# Stack 2: same caller, function executing at line 15
482+
frames2 = [
483+
('module.py', (15, 15, -1, -1), 'my_func', None), # Different interior line
484+
('caller.py', (100, 100, -1, -1), 'caller', None)
485+
]
486+
# Stack 3: function definition line
487+
frames3 = [
488+
('module.py', (10, 10, -1, -1), 'my_func', None), # Definition line
489+
('caller.py', (100, 100, -1, -1), 'caller', None)
490+
]
491+
492+
collector = self._create_collector_with_stack([frames1, frames2, frames3])
493+
494+
# All lines should map to the same function
495+
self.assertEqual(collector.line_to_function[('module.py', 10)], 'my_func')
496+
self.assertEqual(collector.line_to_function[('module.py', 12)], 'my_func')
497+
self.assertEqual(collector.line_to_function[('module.py', 15)], 'my_func')
498+
499+
# Function definition should have callers recorded
500+
# The first seen line becomes the definition (line 12 in this case)
501+
func_def = collector.function_definitions[('module.py', 'my_func')]
502+
func_def_key = ('module.py', func_def)
503+
self.assertIn(func_def_key, collector.callers_graph)
504+
505+
def test_multiple_callers_recorded(self):
506+
"""Test that multiple callers are recorded for a function."""
507+
# Two different callers call the same function
508+
frames1 = [
509+
('target.py', (10, 10, -1, -1), 'target_func', None),
510+
('caller1.py', (20, 20, -1, -1), 'caller1', None)
511+
]
512+
frames2 = [
513+
('target.py', (10, 10, -1, -1), 'target_func', None),
514+
('caller2.py', (30, 30, -1, -1), 'caller2', None)
515+
]
516+
517+
collector = self._create_collector_with_stack([frames1, frames2])
518+
519+
target_key = ('target.py', 10)
520+
callers = collector.callers_graph[target_key]
521+
522+
# Should have 2 different callers
523+
self.assertEqual(len(callers), 2)
524+
525+
# Extract caller files
526+
caller_files = {c[0] for c in callers}
527+
self.assertIn('caller1.py', caller_files)
528+
self.assertIn('caller2.py', caller_files)
529+
530+
def test_multiple_callees_from_same_line(self):
531+
"""Test that multiple callees from the same call site are recorded."""
532+
# Same line calls different functions in different samples
533+
frames1 = [
534+
('target1.py', (10, 10, -1, -1), 'func1', None),
535+
('caller.py', (20, 20, -1, -1), 'caller', None)
536+
]
537+
frames2 = [
538+
('target2.py', (15, 15, -1, -1), 'func2', None),
539+
('caller.py', (20, 20, -1, -1), 'caller', None)
540+
]
541+
542+
collector = self._create_collector_with_stack([frames1, frames2])
543+
544+
caller_key = ('caller.py', 20)
545+
callees = collector.call_graph[caller_key]
546+
547+
# Should have 2 different callees
548+
self.assertEqual(len(callees), 2)
549+
550+
# Extract callee files
551+
callee_files = {c[0] for c in callees}
552+
self.assertIn('target1.py', callee_files)
553+
self.assertIn('target2.py', callee_files)
554+
555+
def test_edge_samples_counted(self):
556+
"""Test that edge samples are counted correctly."""
557+
frames = [
558+
('callee.py', (10, 10, -1, -1), 'callee', None),
559+
('caller.py', (20, 20, -1, -1), 'caller', None)
560+
]
561+
562+
collector = self._create_collector_with_stack([frames, frames, frames])
563+
564+
# Edge should have 3 samples
565+
caller_key = ('caller.py', 20)
566+
callee_key = ('callee.py', 10)
567+
edge_key = (caller_key, callee_key)
568+
569+
self.assertEqual(collector.edge_samples[edge_key], 3)
570+
571+
def test_deep_call_stack_relationships(self):
572+
"""Test navigation relationships in a deep call stack."""
573+
# 5-level stack: root -> A -> B -> C -> leaf
574+
frames = [
575+
('leaf.py', (5, 5, -1, -1), 'leaf', None),
576+
('c.py', (10, 10, -1, -1), 'func_c', None),
577+
('b.py', (15, 15, -1, -1), 'func_b', None),
578+
('a.py', (20, 20, -1, -1), 'func_a', None),
579+
('root.py', (25, 25, -1, -1), 'root', None),
580+
]
581+
582+
collector = self._create_collector_with_stack([frames])
583+
584+
# Root: only callees (calls A), no callers
585+
root_key = ('root.py', 25)
586+
self.assertIn(root_key, collector.call_graph)
587+
self.assertNotIn(root_key, collector.callers_graph)
588+
589+
# A: both (called by root, calls B)
590+
a_key = ('a.py', 20)
591+
self.assertIn(a_key, collector.call_graph)
592+
self.assertIn(a_key, collector.callers_graph)
593+
594+
# B: both (called by A, calls C)
595+
b_key = ('b.py', 15)
596+
self.assertIn(b_key, collector.call_graph)
597+
self.assertIn(b_key, collector.callers_graph)
598+
599+
# C: both (called by B, calls leaf)
600+
c_key = ('c.py', 10)
601+
self.assertIn(c_key, collector.call_graph)
602+
self.assertIn(c_key, collector.callers_graph)
603+
604+
# Leaf: only callers (called by C), no callees
605+
leaf_key = ('leaf.py', 5)
606+
self.assertNotIn(leaf_key, collector.call_graph)
607+
self.assertIn(leaf_key, collector.callers_graph)
608+
609+
370610
class TestHeatmapCollectorExport(unittest.TestCase):
371611
"""Test HeatmapCollector.export() method."""
372612

0 commit comments

Comments
 (0)