Skip to content

Commit 7ffe4cb

Browse files
committed
Add opcode panel to live profiler TUI
New widget displays instruction-level stats for selected function when --opcodes is enabled. Navigation via j/k keys with scroll support. Adds per-thread opcode tracking. Updates pstats collector for new frame format.
1 parent af27d23 commit 7ffe4cb

4 files changed

Lines changed: 324 additions & 25 deletions

File tree

Lib/profiling/sampling/live_collector/collector.py

Lines changed: 170 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import time
1212
import _colorize
1313

14-
from ..collector import Collector
14+
from ..collector import Collector, extract_lineno
1515
from ..constants import (
1616
THREAD_STATUS_HAS_GIL,
1717
THREAD_STATUS_ON_CPU,
@@ -41,7 +41,7 @@
4141
COLOR_PAIR_SORTED_HEADER,
4242
)
4343
from .display import CursesDisplay
44-
from .widgets import HeaderWidget, TableWidget, FooterWidget, HelpWidget
44+
from .widgets import HeaderWidget, TableWidget, FooterWidget, HelpWidget, OpcodePanel
4545
from .trend_tracker import TrendTracker
4646

4747

@@ -67,6 +67,11 @@ class ThreadData:
6767
sample_count: int = 0
6868
gc_frame_samples: int = 0
6969

70+
# Opcode statistics: {location: {opcode: count}}
71+
opcode_stats: dict = field(default_factory=lambda: collections.defaultdict(
72+
lambda: collections.defaultdict(int)
73+
))
74+
7075
def increment_status_flag(self, status_flags):
7176
"""Update status counts based on status bit flags."""
7277
if status_flags & THREAD_STATUS_HAS_GIL:
@@ -103,6 +108,7 @@ def __init__(
103108
pid=None,
104109
display=None,
105110
mode=None,
111+
opcodes=False,
106112
):
107113
"""
108114
Initialize the live stats collector.
@@ -115,6 +121,7 @@ def __init__(
115121
pid: Process ID being profiled
116122
display: DisplayInterface implementation (None means curses will be used)
117123
mode: Profiling mode ('cpu', 'gil', etc.) - affects what stats are shown
124+
opcodes: Whether to show opcode panel (requires --opcodes flag)
118125
"""
119126
self.result = collections.defaultdict(
120127
lambda: dict(total_rec_calls=0, direct_calls=0, cumulative_calls=0)
@@ -152,6 +159,12 @@ def __init__(
152159
}
153160
self.gc_frame_samples = 0 # Track samples with GC frames
154161

162+
# Opcode statistics: {location: {opcode: count}}
163+
self.opcode_stats = collections.defaultdict(lambda: collections.defaultdict(int))
164+
self.show_opcodes = opcodes # Show opcode panel when --opcodes flag is passed
165+
self.selected_row = 0 # Currently selected row in table for opcode view
166+
self.scroll_offset = 0 # Scroll offset for table when in opcode mode
167+
155168
# Interactive controls state
156169
self.paused = False # Pause UI updates (profiling continues)
157170
self.show_help = False # Show help screen
@@ -178,6 +191,7 @@ def __init__(
178191
self.table_widget = None
179192
self.footer_widget = None
180193
self.help_widget = None
194+
self.opcode_panel = None
181195

182196
# Color mode
183197
self._can_colorize = _colorize.can_colorize()
@@ -282,18 +296,29 @@ def process_frames(self, frames, thread_id=None):
282296
thread_data = self._get_or_create_thread_data(thread_id) if thread_id is not None else None
283297

284298
# Process each frame in the stack to track cumulative calls
299+
# frame.location is (lineno, end_lineno, col_offset, end_col_offset), int, or None
285300
for frame in frames:
286-
location = (frame.filename, frame.lineno, frame.funcname)
301+
lineno = extract_lineno(frame.location)
302+
location = (frame.filename, lineno, frame.funcname)
287303
self.result[location]["cumulative_calls"] += 1
288304
if thread_data:
289305
thread_data.result[location]["cumulative_calls"] += 1
290306

291307
# The top frame gets counted as an inline call (directly executing)
292-
top_location = (frames[0].filename, frames[0].lineno, frames[0].funcname)
308+
top_frame = frames[0]
309+
top_lineno = extract_lineno(top_frame.location)
310+
top_location = (top_frame.filename, top_lineno, top_frame.funcname)
293311
self.result[top_location]["direct_calls"] += 1
294312
if thread_data:
295313
thread_data.result[top_location]["direct_calls"] += 1
296314

315+
# Track opcode for top frame (the actively executing instruction)
316+
opcode = getattr(top_frame, 'opcode', None)
317+
if opcode is not None:
318+
self.opcode_stats[top_location][opcode] += 1
319+
if thread_data:
320+
thread_data.opcode_stats[top_location][opcode] += 1
321+
297322
def collect_failed_sample(self):
298323
self.failed_samples += 1
299324
self.total_samples += 1
@@ -431,6 +456,7 @@ def _initialize_widgets(self, colors):
431456
self.table_widget = TableWidget(self.display, colors, self)
432457
self.footer_widget = FooterWidget(self.display, colors, self)
433458
self.help_widget = HelpWidget(self.display, colors)
459+
self.opcode_panel = OpcodePanel(self.display, colors, self)
434460

435461
def _render_display_sections(
436462
self, height, width, elapsed, stats_list, colors
@@ -451,6 +477,12 @@ def _render_display_sections(
451477
line, width, height=height, stats_list=stats_list
452478
)
453479

480+
# Render opcode panel if enabled
481+
if self.show_opcodes:
482+
line = self.opcode_panel.render(
483+
line, width, height=height, stats_list=stats_list
484+
)
485+
454486
except curses.error:
455487
pass
456488

@@ -918,19 +950,148 @@ def _handle_input(self):
918950
if self._trend_tracker is not None:
919951
self._trend_tracker.toggle()
920952

921-
elif ch == curses.KEY_LEFT or ch == curses.KEY_UP:
922-
# Navigate to previous thread in PER_THREAD mode, or switch from ALL to PER_THREAD
953+
elif ch == ord("j") or ch == ord("J"):
954+
# Move selection down in opcode mode (with scrolling)
955+
if self.show_opcodes:
956+
# Use the actual displayed stats_list count, not raw result_source
957+
# This matches what _prepare_display_data() produces
958+
stats_list = self.build_stats_list()
959+
if self.display:
960+
height, _ = self.display.get_dimensions()
961+
# Same calculation as _prepare_display_data
962+
extra_header = FINISHED_BANNER_EXTRA_LINES if self.finished else 0
963+
max_stats = max(0, height - HEADER_LINES - extra_header - FOOTER_LINES - SAFETY_MARGIN)
964+
stats_list = stats_list[:max_stats]
965+
visible_rows = max(1, height - 8 - 2 - 12)
966+
else:
967+
visible_rows = self.limit
968+
total_rows = len(stats_list)
969+
if total_rows == 0:
970+
return
971+
# Max scroll is when last item is at bottom
972+
max_scroll = max(0, total_rows - visible_rows)
973+
# Current absolute position
974+
abs_pos = self.scroll_offset + self.selected_row
975+
# Only move if not at the last item
976+
if abs_pos < total_rows - 1:
977+
# Try to move selection within visible area first
978+
if self.selected_row < visible_rows - 1:
979+
self.selected_row += 1
980+
elif self.scroll_offset < max_scroll:
981+
# Scroll down
982+
self.scroll_offset += 1
983+
# Clamp to valid range
984+
self.scroll_offset = min(self.scroll_offset, max_scroll)
985+
max_selected = min(visible_rows - 1, total_rows - self.scroll_offset - 1)
986+
self.selected_row = min(self.selected_row, max(0, max_selected))
987+
988+
elif ch == ord("k") or ch == ord("K"):
989+
# Move selection up in opcode mode (with scrolling)
990+
if self.show_opcodes:
991+
if self.selected_row > 0:
992+
self.selected_row -= 1
993+
elif self.scroll_offset > 0:
994+
self.scroll_offset -= 1
995+
# Clamp to valid range based on actual stats_list
996+
stats_list = self.build_stats_list()
997+
if self.display:
998+
height, _ = self.display.get_dimensions()
999+
extra_header = FINISHED_BANNER_EXTRA_LINES if self.finished else 0
1000+
max_stats = max(0, height - HEADER_LINES - extra_header - FOOTER_LINES - SAFETY_MARGIN)
1001+
stats_list = stats_list[:max_stats]
1002+
visible_rows = max(1, height - 8 - 2 - 12)
1003+
else:
1004+
visible_rows = self.limit
1005+
total_rows = len(stats_list)
1006+
if total_rows > 0:
1007+
max_scroll = max(0, total_rows - visible_rows)
1008+
self.scroll_offset = min(self.scroll_offset, max_scroll)
1009+
max_selected = min(visible_rows - 1, total_rows - self.scroll_offset - 1)
1010+
self.selected_row = min(self.selected_row, max(0, max_selected))
1011+
1012+
elif ch == curses.KEY_UP:
1013+
# Move selection up (same as 'k') when in opcode mode
1014+
if self.show_opcodes:
1015+
if self.selected_row > 0:
1016+
self.selected_row -= 1
1017+
elif self.scroll_offset > 0:
1018+
self.scroll_offset -= 1
1019+
# Clamp to valid range based on actual stats_list
1020+
stats_list = self.build_stats_list()
1021+
if self.display:
1022+
height, _ = self.display.get_dimensions()
1023+
extra_header = FINISHED_BANNER_EXTRA_LINES if self.finished else 0
1024+
max_stats = max(0, height - HEADER_LINES - extra_header - FOOTER_LINES - SAFETY_MARGIN)
1025+
stats_list = stats_list[:max_stats]
1026+
visible_rows = max(1, height - 8 - 2 - 12)
1027+
else:
1028+
visible_rows = self.limit
1029+
total_rows = len(stats_list)
1030+
if total_rows > 0:
1031+
max_scroll = max(0, total_rows - visible_rows)
1032+
self.scroll_offset = min(self.scroll_offset, max_scroll)
1033+
max_selected = min(visible_rows - 1, total_rows - self.scroll_offset - 1)
1034+
self.selected_row = min(self.selected_row, max(0, max_selected))
1035+
else:
1036+
# Navigate to previous thread (same as KEY_LEFT)
1037+
if len(self.thread_ids) > 0:
1038+
if self.view_mode == "ALL":
1039+
self.view_mode = "PER_THREAD"
1040+
self.current_thread_index = len(self.thread_ids) - 1
1041+
else:
1042+
self.current_thread_index = (
1043+
self.current_thread_index - 1
1044+
) % len(self.thread_ids)
1045+
1046+
elif ch == curses.KEY_DOWN:
1047+
# Move selection down (same as 'j') when in opcode mode
1048+
if self.show_opcodes:
1049+
stats_list = self.build_stats_list()
1050+
if self.display:
1051+
height, _ = self.display.get_dimensions()
1052+
extra_header = FINISHED_BANNER_EXTRA_LINES if self.finished else 0
1053+
max_stats = max(0, height - HEADER_LINES - extra_header - FOOTER_LINES - SAFETY_MARGIN)
1054+
stats_list = stats_list[:max_stats]
1055+
visible_rows = max(1, height - 8 - 2 - 12)
1056+
else:
1057+
visible_rows = self.limit
1058+
total_rows = len(stats_list)
1059+
if total_rows == 0:
1060+
return
1061+
max_scroll = max(0, total_rows - visible_rows)
1062+
abs_pos = self.scroll_offset + self.selected_row
1063+
if abs_pos < total_rows - 1:
1064+
if self.selected_row < visible_rows - 1:
1065+
self.selected_row += 1
1066+
elif self.scroll_offset < max_scroll:
1067+
self.scroll_offset += 1
1068+
self.scroll_offset = min(self.scroll_offset, max_scroll)
1069+
max_selected = min(visible_rows - 1, total_rows - self.scroll_offset - 1)
1070+
self.selected_row = min(self.selected_row, max(0, max_selected))
1071+
else:
1072+
# Navigate to next thread (same as KEY_RIGHT)
1073+
if len(self.thread_ids) > 0:
1074+
if self.view_mode == "ALL":
1075+
self.view_mode = "PER_THREAD"
1076+
self.current_thread_index = 0
1077+
else:
1078+
self.current_thread_index = (
1079+
self.current_thread_index + 1
1080+
) % len(self.thread_ids)
1081+
1082+
elif ch == curses.KEY_LEFT:
1083+
# Navigate to previous thread
9231084
if len(self.thread_ids) > 0:
9241085
if self.view_mode == "ALL":
9251086
self.view_mode = "PER_THREAD"
926-
self.current_thread_index = 0
1087+
self.current_thread_index = len(self.thread_ids) - 1
9271088
else:
9281089
self.current_thread_index = (
9291090
self.current_thread_index - 1
9301091
) % len(self.thread_ids)
9311092

932-
elif ch == curses.KEY_RIGHT or ch == curses.KEY_DOWN:
933-
# Navigate to next thread in PER_THREAD mode, or switch from ALL to PER_THREAD
1093+
elif ch == curses.KEY_RIGHT:
1094+
# Navigate to next thread
9341095
if len(self.thread_ids) > 0:
9351096
if self.view_mode == "ALL":
9361097
self.view_mode = "PER_THREAD"

Lib/profiling/sampling/live_collector/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
# Finished banner display
4646
FINISHED_BANNER_EXTRA_LINES = 3 # Blank line + banner + blank line
4747

48+
# Opcode panel display
49+
OPCODE_PANEL_HEIGHT = 12 # Height reserved for opcode statistics panel
50+
4851
# Color pair IDs
4952
COLOR_PAIR_HEADER_BG = 4
5053
COLOR_PAIR_CYAN = 5

0 commit comments

Comments
 (0)