diff options
Diffstat (limited to 'tools/perf/python/treport.py')
| -rwxr-xr-x | tools/perf/python/treport.py | 458 |
1 files changed, 350 insertions, 108 deletions
diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py index 0921b428b1aa..e02121475370 100755 --- a/tools/perf/python/treport.py +++ b/tools/perf/python/treport.py @@ -2,14 +2,17 @@ # SPDX-License-Identifier: MIT """treport.py - perf report like tool written using textual.""" from abc import ABC, abstractmethod -from typing import Dict, Optional +from typing import Callable, Dict, List, Optional, Set import argparse import os import sys +import threading +import time import perf +from rich.markup import escape from rich.segment import Segment from rich.style import Style -from textual import events +from textual import events, work from textual.app import App, ComposeResult from textual.binding import Binding from textual.color import Color @@ -18,9 +21,6 @@ from textual.strip import Strip from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree from textual.widgets.tree import TreeNode -# Global session. -session :Optional[perf.session] = None - def make_fixed_length_string(s: str, length: int, pad_char=' '): """Make the string s a fixed length. @@ -61,7 +61,7 @@ class ProfileNode: children (Dict[str, ProfileNode]): A dictionary of child nodes, keyed by their names. """ - def __init__(self, name: str, parent: "ProfileNode"): + def __init__(self, name: str, parent: Optional["ProfileNode"]): """Initializes a ProfileNode.""" self.name = name self.value: int = 0 @@ -82,20 +82,13 @@ class ProfileNode: return 1 return max(child.depth() for child in self.children.values()) + 1 - def process_event(self, sample) -> None: + def process_event(self, sample, comm: str) -> None: """Processes a single profiling event to update the call stack tree. Args: sample: a single profiling sample. + comm: the command name of the sampled thread. """ - pid = sample.sample_pid - try: - assert session - thread = session.find_thread(pid, sample.sample_tid) - comm = (thread.comm() if thread else None) or f"unknown ({pid})" - except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError): - comm = f"unknown ({pid})" - period = sample.sample_period self.value += period @@ -120,47 +113,36 @@ class ProfileNode: node = node.find_or_create_node(name) node.value += period - def add_to_tree(self, node: TreeNode, root_value: int) -> None: - """Recursively adds this node and its children to a textual TreeNode. + def sorted_children(self) -> List["ProfileNode"]: + """The children, largest value first. - Args: - node (TreeNode): The textual `TreeNode` object to which this - ProfileNode should be added. - root_value (int): Value at the root of the tree. + The root's children are events whose values aren't comparable, each is + 100% of itself, so they are kept in the order they were first seen + rather than reordering as they load. """ - if root_value == 0: - root_value = self.value + if self.parent is self: + return list(self.children.values()) + return sorted(self.children.values(), key=lambda pnode: pnode.value, reverse=True) - # Calculate the percentage for the node, highlighting the - # percentage with reversed colors. - if root_value != 0: - percent = self.value / root_value * 100 - label = f"{self.name} [r]{percent:.3g}%[/]" - else: - label = self.name - - # Add a standalone leaf. - if not self.children: - node.add_leaf(label) - return + def tree_label(self, root_value: int) -> str: + """Label for the node in a Tree, with a percentage of root_value. - # Recursively add children. - new_node = node.add(label) - for pnode in sorted(self.children.values(), - key=lambda pnode: pnode.value, reverse=True): - pnode.add_to_tree(new_node, root_value) + The percentage is highlighted with reversed colors. + """ + if root_value == 0: + return escape(self.name) + return f"{escape(self.name)} [r]{self.value / root_value * 100:.3g}%[/]" def largest_child(self) -> "ProfileNode": """Finds the child with the highest value (sample count).""" if self.children: - return max(self.children.values(), key=lambda node: node.value) + return self.sorted_children()[0] return self def child_after(self, sought: "ProfileNode") -> "ProfileNode": """Finds the next sibling after the given node, sorted by value.""" found = False - for child in sorted(self.children.values(), key=lambda node: node.value, - reverse=True): + for child in self.sorted_children(): if child == sought: found = True elif found: @@ -170,8 +152,7 @@ class ProfileNode: def child_before(self, sought: "ProfileNode") -> "ProfileNode": """Finds the previous sibling before the given node, sorted by value.""" last = None - for child in sorted(self.children.values(), key=lambda node: node.value, - reverse=True): + for child in self.sorted_children(): if child == sought: return last if last else sought last = child @@ -226,8 +207,7 @@ class ProfileNode: # left_over is used to check for a gap after the children due # to samples being in the parent. left_over = parent_width - for child in sorted(self.children.values(), key=lambda node: node.value, - reverse=True): + for child in self.sorted_children(): if parent_selected: if self.value: desired_width = int((parent_width * child.value) / self.value) @@ -412,16 +392,22 @@ class FlameGraph(ScrollView): } """ - def __init__(self, root: ProfileNode, *pos_args, **kwargs): - """Initialize the FlameGraph widget.""" + def __init__(self, root: ProfileNode, lock: threading.Lock, *pos_args, **kwargs): + """Initialize the FlameGraph widget. + + The lock must be held when reading the profile as it may be being built + in another thread. + """ super().__init__(*pos_args, **kwargs) self.root = root + self.profile_lock = lock self.cursor = root self.selected = root def action_move_down(self) -> None: """Handle key press down.""" - self.cursor = self.cursor.largest_child() + with self.profile_lock: + self.cursor = self.cursor.largest_child() self.refresh() def action_move_up(self) -> None: @@ -432,12 +418,14 @@ class FlameGraph(ScrollView): def action_move_right(self) -> None: """Handle key press right.""" - self.cursor = self.cursor.parent.child_after(self.cursor) + with self.profile_lock: + self.cursor = self.cursor.parent.child_after(self.cursor) self.refresh() def action_move_left(self) -> None: """Handle key press left.""" - self.cursor = self.cursor.parent.child_before(self.cursor) + with self.profile_lock: + self.cursor = self.cursor.parent.child_before(self.cursor) self.refresh() def action_zoom_in(self) -> None: @@ -454,24 +442,148 @@ class FlameGraph(ScrollView): """Render a single line (row) of the flame graph.""" _, scroll_y = self.scroll_offset y += scroll_y - return self.root.make_flame_strip(y, self.size.width, self.cursor, - self.selected, self.app.theme_variables) + with self.profile_lock: + return self.root.make_flame_strip(y, self.size.width, self.cursor, + self.selected, self.app.theme_variables) + + def profile_changed(self) -> None: + """Resize and redraw after the profile changed.""" + with self.profile_lock: + self.styles.height = self.root.depth() + self.refresh() def on_mount(self) -> None: """Set the height of the widget when it is displayed.""" - self.styles.height = self.root.depth() + self.profile_changed() def on_click(self, click: events.Click) -> None: """Handles a mouse click and update the cursor position.""" _, scroll_y = self.scroll_offset y = scroll_y + click.y - clicked_node = self.root.find_node(click.x, y, self.size.width, - self.selected) + with self.profile_lock: + clicked_node = self.root.find_node(click.x, y, self.size.width, + self.selected) if clicked_node: self.cursor = clicked_node self.refresh() +class ProfileTree(Tree): + """A tree view of the profile that can be updated while it is built. + + Tree nodes are created lazily when expanded, the data of each tree node is + its ProfileNode. The lock must be held when reading the profile. + """ + + def __init__(self, root: ProfileNode, lock: threading.Lock, *pos_args, **kwargs): + super().__init__("Profile", *pos_args, **kwargs) + self.profile = root + self.profile_lock = lock + # Events whose initial expansion has been done. + self.seen_events: Set[str] = set() + + def pnode_of(self, tnode: TreeNode) -> ProfileNode: + """The ProfileNode shown by tnode, the tree's root shows the profile's root.""" + return tnode.data if tnode.data is not None else self.profile + + def event_value(self, pnode: ProfileNode) -> int: + """Value of the event containing pnode, percentages are relative to it.""" + while pnode.parent is not self.profile and pnode.parent is not pnode: + pnode = pnode.parent + return pnode.value + + @staticmethod + def expanded_descendants(tnode: TreeNode, expanded: Set[ProfileNode]) -> None: + """Add the profile nodes of the expanded descendants of tnode to expanded.""" + for tchild in tnode.children: + if tchild.is_expanded and tchild.data is not None: + expanded.add(tchild.data) + if tchild.children: + ProfileTree.expanded_descendants(tchild, expanded) + + def sync_node(self, tnode: TreeNode, expanded: Optional[Set[ProfileNode]] = None) -> None: + """Update the children of tnode, and expanded descendants, from the profile. + + expanded holds the profile nodes of descendants that were expanded + before an ancestor's children were recreated. The lock must be held. + """ + children = self.pnode_of(tnode).sorted_children() + if expanded is not None or [tchild.data for tchild in tnode.children] != children: + # Recreate the children in the new order keeping the expanded + # ones, and their expanded descendants. + if expanded is None: + expanded = set() + self.expanded_descendants(tnode, expanded) + tnode.remove_children() + ancestors: Set[ProfileNode] = set() + for pnode in expanded: + parent = pnode.parent + while parent is not None and parent not in ancestors: + ancestors.add(parent) + parent = parent.parent + for child in children: + label = child.tree_label(self.event_value(child)) + new = tnode.add(label, child, allow_expand=bool(child.children)) + if child in expanded: + new.expand() + if child in expanded or child in ancestors: + self.sync_node(new, expanded) + return + for tchild in tnode.children: + child = self.pnode_of(tchild) + tchild.set_label(child.tree_label(self.event_value(child))) + tchild.allow_expand = bool(child.children) + if tchild.is_expanded or tchild.children: + self.sync_node(tchild) + + def expand_largest(self, tnode: TreeNode) -> None: + """Expand the chain of largest children below tnode. + + The lock must be held. + """ + while self.pnode_of(tnode).children: + self.sync_node(tnode) + tnode = tnode.children[0] + tnode.expand() + + def find(self, tnode: TreeNode, pnode: ProfileNode) -> Optional[TreeNode]: + """Find the visible tree node for pnode.""" + for tchild in tnode.children: + if tchild.data is pnode: + return tchild + if tchild.is_expanded: + found = self.find(tchild, pnode) + if found: + return found + return None + + def sync(self) -> None: + """Update the tree from the profile keeping the expanded nodes and cursor.""" + cursor = self.cursor_node.data if self.cursor_node else None + with self.profile_lock: + self.root.expand() + self.sync_node(self.root) + for tnode in self.root.children: + name = self.pnode_of(tnode).name + if name in self.seen_events: + continue + self.seen_events.add(name) + self.expand_largest(tnode) + # If there is only one event, expand it also. + if len(self.root.children) == 1: + tnode.expand() + if cursor is not None: + found = self.find(self.root, cursor) + if found: + self.move_cursor(found) + + def on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: + """Create the children of a node when it is expanded.""" + if event.node is not self.root: + with self.profile_lock: + self.sync_node(event.node) + + class ReportApp(App): """A Textual application to display profiling data.""" @@ -481,84 +593,214 @@ class ReportApp(App): tooltip="Quit the app"), ] - def __init__(self, root: ProfileNode): - """Initialize the application.""" - super().__init__() - self.root = root + def __init__(self, input_file: str, root: Optional[ProfileNode] = None): + """Initialize the application. - def make_report_tree(self) -> Tree: - """Make a Tree widget from the profile data.""" - tree: Tree[None] = Tree("Profile") - # Add events to tree skipping the root. - for pnode in sorted(self.root.children.values(), - key=lambda node: node.value, reverse=True): - pnode.add_to_tree(tree.root, root_value=0) - - # Expand the root tree (shows all events) and the largest of the children - # for each event. - def expand_first_child(tnode: TreeNode) -> None: - """Recursively expand the first child node""" - if not tnode.children: - return - first = tnode.children[0] - first.expand() - expand_first_child(first) - tree.root.expand() - for tnode in tree.root.children: - expand_first_child(tnode) - - # If there is only one event, expand it also. - if len(tree.root.children) == 1: - tree.root.children[0].expand() - - return tree + If root isn't given the profile is loaded from input_file in the + background after the application starts, the views show the profile + as it is built. + """ + super().__init__() + self.input_file = input_file + self.root = root if root else ProfileNode("root", parent=None) + self.loaded = root is not None + # Held when reading or changing the profile, which is built in a + # background thread. + self.profile_lock = threading.Lock() + # The profile being built in a background thread. + self.loading: Optional[ProfileBuilder] = None def compose(self) -> ComposeResult: """Composes the user interface of the application.""" yield Header() with TabbedContent(initial="report"): with TabPane("Report", id="report"): - yield self.make_report_tree() + yield ProfileTree(self.root, self.profile_lock) with TabPane("Flame Graph", id="flame"): - yield FlameGraph(self.root) + yield FlameGraph(self.root, self.profile_lock) yield Footer() + def on_mount(self) -> None: + """Start loading the profile unless it was given.""" + self.sub_title = self.input_file + if self.loaded: + self.update_views() + else: + self.sub_title = f"Loading {self.input_file}" + self.loading = ProfileBuilder(self.root, self.profile_lock) + self.load_profile() + + def update_views(self) -> None: + """Show the latest state of the profile.""" + self.query_one(ProfileTree).sync() + self.query_one(FlameGraph).profile_changed() + + @work(thread=True, exclusive=True) + def load_profile(self) -> None: + """Build the profile in a thread so the UI stays responsive.""" + profile = self.loading + assert profile is not None + + def progress() -> None: + try: + self.call_from_thread(self.update_progress, profile) + except RuntimeError: + # The app is no longer running. + profile.cancelled = True + + profile.progress = progress + try: + profile.read(self.input_file) + except LoadCancelled: + return + except (OSError, ValueError, RuntimeError) as e: + self.call_from_thread(self.exit, None, 1, + f"Error processing {self.input_file}: {e}") + return + finally: + profile.progress = None + self.loading = None + if not profile.cancelled: + self.call_from_thread(self.profile_loaded, profile) + + def update_progress(self, profile: "ProfileBuilder") -> None: + """Show how much of the file has been processed and the profile so far.""" + self.sub_title = (f"Loading {self.input_file}: {profile.nr_samples:,} samples" + f"{profile.duration_str()}") + self.update_views() + + def profile_loaded(self, profile: "ProfileBuilder") -> None: + """Called on the UI thread when loading completes.""" + self.loaded = True + self.sub_title = (f"{self.input_file}: {profile.nr_samples:,} samples" + f"{profile.duration_str()}") + self.update_views() + + def cancel_loading(self) -> None: + """Stop a background load, noticed at the next progress interval.""" + loading = self.loading + if loading is not None: + loading.cancelled = True + + async def action_quit(self) -> None: + """Quit, stopping any background load.""" + self.cancel_loading() + await super().action_quit() + + def on_unmount(self) -> None: + """Stop any background load when the app exits.""" + self.cancel_loading() + + +class LoadCancelled(Exception): + """Raised from the sample callback to stop processing events early.""" + class ProfileBuilder: """Constructs a profile tree from a stream of events.""" - def __init__(self): - self.root = ProfileNode("root", parent=None) + # Number of samples between checks for cancellation and progress. + PROGRESS_INTERVAL = 1000 + # Minimum and maximum time between calls to the progress callback. + PROGRESS_SECONDS = 1.0 + PROGRESS_MAX_SECONDS = 10.0 + # Updating the views costs more as the profile grows, so the time between + # progress calls grows as this fraction of the time spent loading. This + # bounds the fraction of the load time spent updating the views. + PROGRESS_FRACTION = 0.25 + + def __init__(self, root: Optional[ProfileNode] = None, + lock: Optional[threading.Lock] = None) -> None: + """Build into root, holding lock when changing it.""" + self.root = root if root else ProfileNode("root", parent=None) + self.profile_lock = lock if lock else threading.Lock() + self.session: Optional[perf.session] = None + self.nr_samples = 0 + self.first_time = 0 + self.last_time = 0 + # Set, possibly from another thread, to stop processing events. + self.cancelled = False + # Called periodically, see PROGRESS_FRACTION, while processing events. + self.progress: Optional[Callable[[], None]] = None + self.start_progress = time.monotonic() + self.last_progress = self.start_progress + + def duration_str(self) -> str: + """Description of the time covered by the processed samples.""" + if not self.last_time: + return "" + return f", {(self.last_time - self.first_time) / 1e9:.3f}s of trace" + + def comm(self, sample) -> str: + """The command name of the thread of the sample.""" + pid = sample.sample_pid + try: + assert self.session + thread = self.session.find_thread(pid, sample.sample_tid) + return (thread.comm() if thread else None) or f"unknown ({pid})" + except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError, + AssertionError): + return f"unknown ({pid})" def process_event(self, sample) -> None: """Called by session.process_events to update the profile tree.""" + self.nr_samples += 1 + if self.nr_samples % self.PROGRESS_INTERVAL == 0: + if self.cancelled: + raise LoadCancelled() + now = time.monotonic() + interval = min(max(self.PROGRESS_SECONDS, + (now - self.start_progress) * self.PROGRESS_FRACTION), + self.PROGRESS_MAX_SECONDS) + if self.progress and now - self.last_progress >= interval: + # Must not hold the lock as the callback may read the profile. + self.progress() + # Time from when the callback, that may block, returns. + self.last_progress = time.monotonic() + sample_time = sample.sample_time + if sample_time: + if not self.first_time or sample_time < self.first_time: + self.first_time = sample_time + self.last_time = max(self.last_time, sample_time) ev_name = str(sample.evsel)[6:-1] - ev_root = self.root.find_or_create_node(ev_name) - ev_root.process_event(sample) + comm = self.comm(sample) + with self.profile_lock: + ev_root = self.root.find_or_create_node(ev_name) + ev_root.process_event(sample, comm) + + def read(self, input_file: str) -> None: + """Process the events in input_file, raising on errors.""" + if self.cancelled: + raise LoadCancelled() + self.start_progress = self.last_progress = time.monotonic() + try: + self.session = perf.session(perf.data(input_file), sample=self.process_event) + self.session.process_events() + finally: + # Break the reference cycle between the session and the callback. + self.session = None -if __name__ == "__main__": + +def main() -> None: + """Parse arguments and run the app.""" parser = argparse.ArgumentParser( description="TUI report and flame graph using perf python module.") parser.add_argument("-i", "--input", help="input perf.data file") args = parser.parse_args() input_file = args.input or "perf.data" + if input_file == "-": + # The interactive UI reads the keyboard from stdin. + print("Error: reading perf.data from stdin isn't supported.", file=sys.stderr) + sys.exit(1) if not os.path.exists(input_file): print(f"Error: {input_file} not found. (try 'perf record' first)", file=sys.stderr) sys.exit(1) - profile = ProfileBuilder() - try: - session = perf.session(perf.data(input_file), sample=profile.process_event) - except (OSError, ValueError, RuntimeError) as e: - print(f"Error opening session: {e}", file=sys.stderr) - sys.exit(1) + # The app starts immediately and builds the profile in the background. + app = ReportApp(input_file) + app.run() + sys.exit(app.return_code or 0) - # profile.process_event is called for each perf event to build the profile. - try: - session.process_events() - finally: - session = None - # Visualize data. - app = ReportApp(profile.root) - app.run() +if __name__ == "__main__": + main() |
