Skip to content

Commit 41c5d95

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 41c5d95

File tree

2 files changed

+141
-3
lines changed

2 files changed

+141
-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: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,112 @@ def test_process_frames_with_file_samples_dict(self):
367367
self.assertEqual(collector.file_samples['test.py'][10], 1)
368368

369369

370+
def frame(filename, line, func):
371+
"""Create a frame tuple: (filename, location, funcname, opcode)."""
372+
return (filename, (line, line, -1, -1), func, None)
373+
374+
375+
class TestHeatmapCollectorNavigationButtons(unittest.TestCase):
376+
"""Test navigation button behavior for caller/callee relationships.
377+
378+
For every call stack:
379+
- Root frames (entry points): only DOWN arrow (callees)
380+
- Middle frames: both UP and DOWN arrows
381+
- Leaf frames: only UP arrow (callers)
382+
"""
383+
384+
def collect(self, *stacks):
385+
"""Create collector and process frame stacks."""
386+
collector = HeatmapCollector(sample_interval_usec=100)
387+
for stack in stacks:
388+
collector.process_frames(stack, thread_id=1)
389+
return collector
390+
391+
def key(self, filename, line):
392+
"""Create a line key tuple."""
393+
return (filename, line)
394+
395+
def assert_has_callers(self, collector, filename, line):
396+
self.assertIn(self.key(filename, line), collector.callers_graph)
397+
398+
def assert_no_callers(self, collector, filename, line):
399+
self.assertNotIn(self.key(filename, line), collector.callers_graph)
400+
401+
def assert_has_callees(self, collector, filename, line):
402+
self.assertIn(self.key(filename, line), collector.call_graph)
403+
404+
def assert_no_callees(self, collector, filename, line):
405+
self.assertNotIn(self.key(filename, line), collector.call_graph)
406+
407+
def test_deep_call_stack_relationships(self):
408+
"""Test root/middle/leaf navigation in a 5-level call stack."""
409+
# Stack: root -> A -> B -> C -> leaf
410+
stack = [
411+
frame('leaf.py', 5, 'leaf'),
412+
frame('c.py', 10, 'func_c'),
413+
frame('b.py', 15, 'func_b'),
414+
frame('a.py', 20, 'func_a'),
415+
frame('root.py', 25, 'root'),
416+
]
417+
c = self.collect(stack)
418+
419+
# Root: only callees (no one calls it)
420+
self.assert_has_callees(c, 'root.py', 25)
421+
self.assert_no_callers(c, 'root.py', 25)
422+
423+
# Middle frames: both callers and callees
424+
for f, l in [('a.py', 20), ('b.py', 15), ('c.py', 10)]:
425+
self.assert_has_callees(c, f, l)
426+
self.assert_has_callers(c, f, l)
427+
428+
# Leaf: only callers (doesn't call anyone)
429+
self.assert_no_callees(c, 'leaf.py', 5)
430+
self.assert_has_callers(c, 'leaf.py', 5)
431+
432+
def test_all_lines_in_function_see_callers(self):
433+
"""Test that interior lines map to their function for caller lookup."""
434+
# Same function sampled at different lines (12, 15, 10)
435+
c = self.collect(
436+
[frame('mod.py', 12, 'my_func'), frame('caller.py', 100, 'caller')],
437+
[frame('mod.py', 15, 'my_func'), frame('caller.py', 100, 'caller')],
438+
[frame('mod.py', 10, 'my_func'), frame('caller.py', 100, 'caller')],
439+
)
440+
441+
# All lines should map to same function
442+
for line in [10, 12, 15]:
443+
self.assertEqual(c.line_to_function[('mod.py', line)], 'my_func')
444+
445+
# Function definition line should have callers
446+
func_def = c.function_definitions[('mod.py', 'my_func')]
447+
self.assert_has_callers(c, 'mod.py', func_def)
448+
449+
def test_multiple_callers_and_callees(self):
450+
"""Test multiple callers/callees are recorded correctly."""
451+
# Two callers -> target, and caller -> two callees
452+
c = self.collect(
453+
[frame('target.py', 10, 'target'), frame('caller1.py', 20, 'c1')],
454+
[frame('target.py', 10, 'target'), frame('caller2.py', 30, 'c2')],
455+
[frame('callee1.py', 5, 'f1'), frame('dispatcher.py', 40, 'dispatch')],
456+
[frame('callee2.py', 6, 'f2'), frame('dispatcher.py', 40, 'dispatch')],
457+
)
458+
459+
# Target has 2 callers
460+
callers = c.callers_graph[('target.py', 10)]
461+
self.assertEqual({x[0] for x in callers}, {'caller1.py', 'caller2.py'})
462+
463+
# Dispatcher has 2 callees
464+
callees = c.call_graph[('dispatcher.py', 40)]
465+
self.assertEqual({x[0] for x in callees}, {'callee1.py', 'callee2.py'})
466+
467+
def test_edge_samples_counted(self):
468+
"""Test that repeated calls accumulate edge counts."""
469+
stack = [frame('callee.py', 10, 'callee'), frame('caller.py', 20, 'caller')]
470+
c = self.collect(stack, stack, stack)
471+
472+
edge_key = (('caller.py', 20), ('callee.py', 10))
473+
self.assertEqual(c.edge_samples[edge_key], 3)
474+
475+
370476
class TestHeatmapCollectorExport(unittest.TestCase):
371477
"""Test HeatmapCollector.export() method."""
372478

0 commit comments

Comments
 (0)