* [PATCH v1 1/2] perf script: New treport script @ 2025-07-25 8:24 Ian Rogers 2025-07-25 8:24 ` [PATCH v1 2/2] perf script: treport add flamegraph support Ian Rogers 2025-07-26 6:39 ` [PATCH v1 1/2] perf script: New treport script Namhyung Kim 0 siblings, 2 replies; 24+ messages in thread From: Ian Rogers @ 2025-07-25 8:24 UTC (permalink / raw) To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo, Namhyung Kim, Mark Rutland, Alexander Shishkin, Jiri Olsa, Ian Rogers, Adrian Hunter, Kan Liang, Alice Rogers, linux-kernel, linux-perf-users From: Alice Rogers <alice.mei.rogers@gmail.com> A textual app that displays the results of processing samples similar to perf report. The app displays a tree of first processed and then functions which drop down to show more detail on the functions they call. The functions with the largest number of samples are sorted first, after each function the percentage of time spent within it is highlighted. Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com> Co-developed-by: Ian Rogers <irogers@google.com> Signed-off-by: Ian Rogers <irogers@google.com> --- tools/perf/scripts/python/treport.py | 177 +++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 tools/perf/scripts/python/treport.py diff --git a/tools/perf/scripts/python/treport.py b/tools/perf/scripts/python/treport.py new file mode 100644 index 000000000000..fd1ca79efdad --- /dev/null +++ b/tools/perf/scripts/python/treport.py @@ -0,0 +1,177 @@ +# treport.py - perf report like tool written using textual +# SPDX-License-Identifier: MIT +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree +from textual.widgets.tree import TreeNode +from typing import Dict + +class ProfileNode: + """Represents a single node in a call stack tree. + + Generally a ProfileNode corresponds to a symbol in a call stack. + The root is special, its children are events and the events + children are process names. After the process name come the + samples. + + Attributes: + name (str): The name of the function, process or event. + value (int): The sample count for this node including counts from its + children. + parent (ProfileNode): The parent of this node, this node belongs to its + children. + children (Dict[str, ProfileNode]): A dictionary of child nodes, keyed by + their names. + """ + def __init__(self, name: str, parent: "ProfileNode"): + """Initializes a ProfileNode.""" + self.name = name + self.value: int = 0 + self.parent = parent if parent else self + self.children: Dict[str, ProfileNode] = {} + + def find_or_create_node(self, name: str) -> "ProfileNode": + """Finds a child node by name or creates it if it doesn't exist.""" + if name in self.children: + return self.children[name] + child = ProfileNode(name, self) + self.children[name] = child + return child + + def depth(self) -> int: + """The maximum depth of the call stack tree from this node down.""" + if not self.children: + return 1 + return max([child.depth() for child in self.children.values()]) + 1 + + def process_event(self, event: Dict) -> None: + """Processes a single profiling event to update the call stack tree. + + Args: + event (Dict): A dictionary representing a single profiling sample, + expected to contain keys like 'comm', 'pid', 'period', + and 'callchain'. + """ + pid = 0 + if "sample" in event and "pid" in event["sample"]: + pid = event["sample"]["pid"] + + if pid == 0: + comm = event.get("comm", "kernel") + else: + comm = f"{event.get('comm', 'unknown')} ({pid})" + + period = int(event["period"]) if 'period' in event else 1 + self.value += period + + node = self.find_or_create_node(comm) + node.value += period + + if "callchain" in event: + for entry in reversed(event["callchain"]): + sym = entry.get("sym") + name = None + if sym: + name = sym.get("name") + if not name: + name = entry.get("dso", "unknown") + if "ip" in entry: + name += f" 0x{entry['ip']:x}" + node = node.find_or_create_node(name) + node.value += period + else: + name = event.get("symbol") + if not name: + name = event.get("dso", "unknown") + if "ip" in event: + name += f" 0x{event['ip']:x}" + 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. + + Args: + node (TreeNode): The textual `TreeNode` object to which this + ProfileNode should be added. + root_value (int): Value at the root of the tree. + """ + if root_value == 0: + root_value = self.value + + # 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 + + # 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) + + +class ReportApp(App): + """A Textual application to display profiling data.""" + + # The ^q binding is implied but having it here adds it in the Footer. + BINDINGS = [ + Binding(key="^q", action="quit", description="Quit", + tooltip="Quit the app"), + ] + + def __init__(self, root: ProfileNode): + """Initialize the application.""" + super().__init__() + self.root = root + + 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 first 2 levels of the tree. + tree.root.expand() + for tnode in tree.root.children: + tnode.expand() + return tree + + 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 Footer() + + +class ProfileBuilder: + """Constructs a profile tree from a stream of events.""" + def __init__(self): + self.root = ProfileNode("root", parent=None) + + def process_event(self, event) -> None: + """Called by `perf script` to update the profile tree.""" + ev_name = event.get("ev_name", "default") + ev_root = self.root.find_or_create_node(ev_name) + ev_root.process_event(event) + +if __name__ == "__main__": + # process_event is called for each perf event to build the profile. + profile = ProfileBuilder() + process_event = profile.process_event + # trace_end will run the application, this can't be done + # concurrently as perf expects to be the main thread as does + # Textual. + app = ReportApp(profile.root) + trace_end = app.run -- 2.50.1.552.g942d659e1b-goog ^ permalink raw reply related [flat|nested] 24+ messages in thread
* [PATCH v1 2/2] perf script: treport add flamegraph support 2025-07-25 8:24 [PATCH v1 1/2] perf script: New treport script Ian Rogers @ 2025-07-25 8:24 ` Ian Rogers 2025-07-25 8:38 ` Ian Rogers 2025-07-26 6:43 ` [PATCH v1 2/2] perf script: " Namhyung Kim 2025-07-26 6:39 ` [PATCH v1 1/2] perf script: New treport script Namhyung Kim 1 sibling, 2 replies; 24+ messages in thread From: Ian Rogers @ 2025-07-25 8:24 UTC (permalink / raw) To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo, Namhyung Kim, Mark Rutland, Alexander Shishkin, Jiri Olsa, Ian Rogers, Adrian Hunter, Kan Liang, Alice Rogers, linux-kernel, linux-perf-users From: Alice Rogers <alice.mei.rogers@gmail.com> Implement a flamegraph widget that recursively walks down a tree splitting line segments based on their value (summed up periods across call chains). A visitor pattern is used so that the same logic can both draw the line segments and locate which segment had a mouse click. Add a tab for the flame graph widget. Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com> Co-developed-by: Ian Rogers <irogers@google.com> Signed-off-by: Ian Rogers <irogers@google.com> --- tools/perf/scripts/python/treport.py | 342 ++++++++++++++++++++++++++- 1 file changed, 341 insertions(+), 1 deletion(-) diff --git a/tools/perf/scripts/python/treport.py b/tools/perf/scripts/python/treport.py index fd1ca79efdad..fd43a3dbe1c2 100644 --- a/tools/perf/scripts/python/treport.py +++ b/tools/perf/scripts/python/treport.py @@ -1,10 +1,40 @@ # treport.py - perf report like tool written using textual # SPDX-License-Identifier: MIT +from abc import ABC, abstractmethod +from rich.segment import Segment +from rich.style import Style +from textual import events from textual.app import App, ComposeResult from textual.binding import Binding +from textual.color import Color +from textual.strip import Strip from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree from textual.widgets.tree import TreeNode -from typing import Dict +from textual.scroll_view import ScrollView +from typing import Dict, Optional + +def make_fixed_length_string(s: str, length: int, pad_char=' '): + """Make the string s a fixed length. + + Increases or decreases the length of s to be length. If the length is + increased then pad_char is inserted on the right. + """ + return s[:length] if len(s) > length else s.ljust(length, pad_char) + + +class FlameVisitor(ABC): + """Parent for visitor used by ProfileNode.flame_walk""" + @abstractmethod + def visit(self, node: Optional["ProfileNode"], width: int) -> None: + """Visit a profile node width the specified flame graph width. + + Args: + node: The `ProfileNode` for the current segment. This may be `None` + to represent a gap or an unknown portion of the stack. + width: The calculated width of the flame graph rectangle for this + node, which is proportional to its sample count. + """ + class ProfileNode: """Represents a single node in a call stack tree. @@ -118,6 +148,314 @@ class ProfileNode: key=lambda pnode: pnode.value, reverse=True): pnode.add_to_tree(new_node, root_value) + 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 + + 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): + if child == sought: + found = True + elif found: + return child + return sought + + 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): + if child == sought: + return last if last else sought + last = child + return sought + + def has_child(self, sought: "ProfileNode") -> bool: + """Checks if the sought node is a descendant of this node.""" + for child in self.children.values(): + if child == sought or child.has_child(sought): + return True + return False + + def has_parent(self, parent: "ProfileNode") -> bool: + """Checks if the parent node is an ancestor of this node.""" + p = self.parent + while True: + if p == parent: + return True + new_p = p.parent + if new_p == p: + break + p = new_p + return False + + def flame_walk(self, wanted_strip: int, cur_strip: int, parent_width: int, + selected: "ProfileNode", visitor: FlameVisitor) -> None: + """Recursively walks the tree to visit a single flame graph row. + + This method calculates the proportional width for each child + based on its value (sample count) relative to its parent. It + then invokes a `visitor` to process each segment of the flame + graph row. + + Args: + wanted_strip (int): The target depth (Y-axis) of the flame graph row + to generate. + cur_strip (int): The current depth of the traversal. + parent_width (int): The width of the parent of this node. + selected (ProfileNode): The currently selected node in the UI, used + to adjust rendering to highlight the + selected path. + visitor (FlameVisitor): A visitor object whose `visit` method is + called for each segment of the flame graph + row. + """ + if parent_width == 0: + return + + parent_selected = selected == self or self.has_parent(selected) + child_selected = not parent_selected and self.has_child(selected) + if not parent_selected and not child_selected: + # Branches of the tree with no node selected aren't drawn. + return + + # 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): + if parent_selected: + if self.value: + desired_width = int((parent_width * child.value) / self.value) + else: + desired_width = parent_width // len(self.children) + if desired_width == 0: + # Nothing can be drawn for this node or later smaller children. + break + elif child == selected or child.has_child(selected): + desired_width = parent_width + else: + # A sibling or its child are selected, but not this branch. + continue + + # Either visit the wanted_strip or recurse to the next level. + if wanted_strip == cur_strip: + visitor.visit(child, desired_width) + else: + child.flame_walk(wanted_strip, cur_strip + 1, desired_width, + selected, visitor) + left_over -= desired_width + if left_over == 0: + # No space left to draw in. + break + + # Always visit the left_over regardless of the wanted_strip as there + # may be additional gap added to a line by a parent. + if left_over: + visitor.visit(None, left_over) + + def make_flame_strip(self, wanted_strip: int, parent_width: int, + cursor: "ProfileNode", selected: "ProfileNode") -> Strip: + """Creates a renderable 'Strip' for a single row of a flame graph. + + This method orchestrates the `flame_walk` traversal with a specialized + visitor to generate a list of segments. The segments are used by a`Strip` + object for rendering in the terminal. + + Args: + wanted_strip (int): The target depth (Y-axis) of the flame graph row. + parent_width (int): The total width (in characters) of the display + area. + cursor (ProfileNode): The node currently under the cursor, for + highlighting. + selected (ProfileNode): The node that is actively selected. + + Returns: + Strip: A renderable strip of segments for the specified row. + """ + black = Color.parse("#000000") + # Non-cursor values range from red up to white. + normal_styles = [ + Style(color=black.rich_color, bgcolor=Color(255, x, x).rich_color + ) for x in range(0, 220, 25) + ] + # Cursor is red text with a black background. + cursor_style = Style(color=Color.parse("#ff0000").rich_color, + bgcolor=black.rich_color) + + class StripVisitor(FlameVisitor): + """Visitor creating textual flame graph segments. + + Attributes: + segments (list): The textual segments that will be placed in a + `Strip`. + gap_width (int): The width of any outstanding gap between the + last and next node. + ctr (int): Used to adjust the flame graph segment's color. + """ + def __init__(self): + self.segments = [] + self.gap_width = 0 + self.ctr = wanted_strip + + def visit(self, node: Optional[ProfileNode], width: int) -> None: + if node: + if self.gap_width > 0: + self.segments.append(Segment( + make_fixed_length_string(" ", self.gap_width))) + self.gap_width = 0 + style = cursor_style + if node != cursor: + style = normal_styles[self.ctr % len(normal_styles)] + self.segments.append(Segment( + make_fixed_length_string(node.name, width), style)) + else: + self.gap_width += width + self.ctr += 1 + + visitor = StripVisitor() + self.flame_walk(wanted_strip, 0, parent_width, selected, visitor) + return Strip(visitor.segments) if visitor.segments else Strip.blank(parent_width) + + def find_node(self, sought_x: int, sought_y: int, parent_width: int, + selected: "ProfileNode") -> "ProfileNode": + """Finds the ProfileNode corresponding to specific X, Y coordinates. + + This translates a mouse click on a flame graph back to the + `ProfileNode` that it represents. + + Args: + sought_x (int): The X coordinate (character column). + sought_y (int): The Y coordinate (row or depth). + parent_width (int): The total width of the display area. + selected (ProfileNode): The currently selected node, which affects + layout. + + Returns: + Optional[ProfileNode]: The node found at the coordinates, or None. + + """ + class FindVisitor(FlameVisitor): + """Visitor locating a `ProfileNode`. + + Attributes: + x (int): offset within line. + found (Optional[ProfileNode]): located node + gap_width (int): The width of any outstanding gap between the + last and next node. + ctr (int): Used to adjust the flame graph segment's color. + """ + def __init__(self): + self.x = 0 + self.found = None + + def visit(self, node: Optional[ProfileNode], width: int) -> None: + if self.x <= sought_x and sought_x < self.x + width: + self.found = node + self.x += width + + visitor = FindVisitor() + self.flame_walk(sought_y, 0, parent_width, selected, visitor) + return visitor.found + + +class FlameGraph(ScrollView): + """A scrollable widget to display a flame graph from a profile. + + Attributes: + root (ProfileNode): Root of the profile tree. + cursor (ProfileNode): Currently highlighted cursor node. + selected (ProfileNode): The currently selected node for zooming. + """ + + # Define key bindings for navigating the flame graph. + # Allows movement with vim-style keys (h,j,k,l) and arrow keys. + BINDINGS = [ + Binding("j,down", "move_down", "Down", key_display="↓", + tooltip="Move cursor down to largest child"), + Binding("k,up", "move_up", "Up", key_display="↑", + tooltip="Move cursor up to parent"), + Binding("l,right", "move_right", "Right", key_display="→", + tooltip="Move cursor to the right sibling"), + Binding("h,left", "move_left", "Left", key_display="←", + tooltip="Move cursor to the left sibling"), + Binding("enter", "zoom_in", "Zoom In", + tooltip="Expand the cursor's node to be screen width"), + Binding("escape", "zoom_out", "Zoom Out", + tooltip="Zoom out to initial view."), + ] + + # Default CSS for the widget to ensure it fills its container's width. + DEFAULT_CSS = """ + FlameGraph { + width: 100%; + } + """ + + def __init__(self, root: ProfileNode, *args, **kwargs): + """Initialize the FlameGraph widget.""" + super().__init__(*args, **kwargs) + self.root = root + self.cursor = root + self.selected = root + + def action_move_down(self) -> None: + """Handle key press down.""" + self.cursor = self.cursor.largest_child() + self.refresh() + + def action_move_up(self) -> None: + """Handle key press up.""" + if self.cursor.parent != self.cursor.parent.parent: + self.cursor = self.cursor.parent + self.refresh() + + def action_move_right(self) -> None: + """Handle key press right.""" + 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) + self.refresh() + + def action_zoom_in(self) -> None: + """Handle key press zoom in.""" + self.selected = self.cursor + self.refresh() + + def action_zoom_out(self) -> None: + """Handle key press zoom out.""" + self.selected = self.root + self.refresh() + + def render_line(self, y: int) -> Strip: + """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) + + def on_mount(self) -> None: + """Set the height of the widget when it is displayed.""" + self.styles.height = self.root.depth() + + 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) + if clicked_node: + self.cursor = clicked_node + self.refresh() + class ReportApp(App): """A Textual application to display profiling data.""" @@ -152,6 +490,8 @@ class ReportApp(App): with TabbedContent(initial="report"): with TabPane("Report", id="report"): yield self.make_report_tree() + with TabPane("Flame Graph", id="flame"): + yield FlameGraph(self.root) yield Footer() -- 2.50.1.552.g942d659e1b-goog ^ permalink raw reply related [flat|nested] 24+ messages in thread
* Re: [PATCH v1 2/2] perf script: treport add flamegraph support 2025-07-25 8:24 ` [PATCH v1 2/2] perf script: treport add flamegraph support Ian Rogers @ 2025-07-25 8:38 ` Ian Rogers 2026-08-08 6:57 ` [PATCH v2 0/2] perf python TUI report and flamegraph Ian Rogers 2025-07-26 6:43 ` [PATCH v1 2/2] perf script: " Namhyung Kim 1 sibling, 1 reply; 24+ messages in thread From: Ian Rogers @ 2025-07-25 8:38 UTC (permalink / raw) To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo, Namhyung Kim, Mark Rutland, Alexander Shishkin, Jiri Olsa, Ian Rogers, Adrian Hunter, Kan Liang, Alice Rogers, linux-kernel, linux-perf-users Cc: xt lai On Fri, Jul 25, 2025 at 1:26 AM Ian Rogers <irogers@google.com> wrote: > > From: Alice Rogers <alice.mei.rogers@gmail.com> > > Implement a flamegraph widget that recursively walks down a tree > splitting line segments based on their value (summed up periods across > call chains). A visitor pattern is used so that the same logic can > both draw the line segments and locate which segment had a mouse > click. > > Add a tab for the flame graph widget. > > Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com> > Co-developed-by: Ian Rogers <irogers@google.com> > Signed-off-by: Ian Rogers <irogers@google.com> A link to a picture: https://fosstodon.org/@irogers/114912947565832897 The work was inspired by a similar flameshow tool by xt lai: https://github.com/laixintao/flameshow however, the implementation is completely different with the goal of something simple/robust enough it could be incorporated directly as a textual widget (hence the MIT license for compatibility with textual). Thanks, Ian > --- > tools/perf/scripts/python/treport.py | 342 ++++++++++++++++++++++++++- > 1 file changed, 341 insertions(+), 1 deletion(-) > > diff --git a/tools/perf/scripts/python/treport.py b/tools/perf/scripts/python/treport.py > index fd1ca79efdad..fd43a3dbe1c2 100644 > --- a/tools/perf/scripts/python/treport.py > +++ b/tools/perf/scripts/python/treport.py > @@ -1,10 +1,40 @@ > # treport.py - perf report like tool written using textual > # SPDX-License-Identifier: MIT > +from abc import ABC, abstractmethod > +from rich.segment import Segment > +from rich.style import Style > +from textual import events > from textual.app import App, ComposeResult > from textual.binding import Binding > +from textual.color import Color > +from textual.strip import Strip > from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree > from textual.widgets.tree import TreeNode > -from typing import Dict > +from textual.scroll_view import ScrollView > +from typing import Dict, Optional > + > +def make_fixed_length_string(s: str, length: int, pad_char=' '): > + """Make the string s a fixed length. > + > + Increases or decreases the length of s to be length. If the length is > + increased then pad_char is inserted on the right. > + """ > + return s[:length] if len(s) > length else s.ljust(length, pad_char) > + > + > +class FlameVisitor(ABC): > + """Parent for visitor used by ProfileNode.flame_walk""" > + @abstractmethod > + def visit(self, node: Optional["ProfileNode"], width: int) -> None: > + """Visit a profile node width the specified flame graph width. > + > + Args: > + node: The `ProfileNode` for the current segment. This may be `None` > + to represent a gap or an unknown portion of the stack. > + width: The calculated width of the flame graph rectangle for this > + node, which is proportional to its sample count. > + """ > + > > class ProfileNode: > """Represents a single node in a call stack tree. > @@ -118,6 +148,314 @@ class ProfileNode: > key=lambda pnode: pnode.value, reverse=True): > pnode.add_to_tree(new_node, root_value) > > + 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 > + > + 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): > + if child == sought: > + found = True > + elif found: > + return child > + return sought > + > + 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): > + if child == sought: > + return last if last else sought > + last = child > + return sought > + > + def has_child(self, sought: "ProfileNode") -> bool: > + """Checks if the sought node is a descendant of this node.""" > + for child in self.children.values(): > + if child == sought or child.has_child(sought): > + return True > + return False > + > + def has_parent(self, parent: "ProfileNode") -> bool: > + """Checks if the parent node is an ancestor of this node.""" > + p = self.parent > + while True: > + if p == parent: > + return True > + new_p = p.parent > + if new_p == p: > + break > + p = new_p > + return False > + > + def flame_walk(self, wanted_strip: int, cur_strip: int, parent_width: int, > + selected: "ProfileNode", visitor: FlameVisitor) -> None: > + """Recursively walks the tree to visit a single flame graph row. > + > + This method calculates the proportional width for each child > + based on its value (sample count) relative to its parent. It > + then invokes a `visitor` to process each segment of the flame > + graph row. > + > + Args: > + wanted_strip (int): The target depth (Y-axis) of the flame graph row > + to generate. > + cur_strip (int): The current depth of the traversal. > + parent_width (int): The width of the parent of this node. > + selected (ProfileNode): The currently selected node in the UI, used > + to adjust rendering to highlight the > + selected path. > + visitor (FlameVisitor): A visitor object whose `visit` method is > + called for each segment of the flame graph > + row. > + """ > + if parent_width == 0: > + return > + > + parent_selected = selected == self or self.has_parent(selected) > + child_selected = not parent_selected and self.has_child(selected) > + if not parent_selected and not child_selected: > + # Branches of the tree with no node selected aren't drawn. > + return > + > + # 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): > + if parent_selected: > + if self.value: > + desired_width = int((parent_width * child.value) / self.value) > + else: > + desired_width = parent_width // len(self.children) > + if desired_width == 0: > + # Nothing can be drawn for this node or later smaller children. > + break > + elif child == selected or child.has_child(selected): > + desired_width = parent_width > + else: > + # A sibling or its child are selected, but not this branch. > + continue > + > + # Either visit the wanted_strip or recurse to the next level. > + if wanted_strip == cur_strip: > + visitor.visit(child, desired_width) > + else: > + child.flame_walk(wanted_strip, cur_strip + 1, desired_width, > + selected, visitor) > + left_over -= desired_width > + if left_over == 0: > + # No space left to draw in. > + break > + > + # Always visit the left_over regardless of the wanted_strip as there > + # may be additional gap added to a line by a parent. > + if left_over: > + visitor.visit(None, left_over) > + > + def make_flame_strip(self, wanted_strip: int, parent_width: int, > + cursor: "ProfileNode", selected: "ProfileNode") -> Strip: > + """Creates a renderable 'Strip' for a single row of a flame graph. > + > + This method orchestrates the `flame_walk` traversal with a specialized > + visitor to generate a list of segments. The segments are used by a`Strip` > + object for rendering in the terminal. > + > + Args: > + wanted_strip (int): The target depth (Y-axis) of the flame graph row. > + parent_width (int): The total width (in characters) of the display > + area. > + cursor (ProfileNode): The node currently under the cursor, for > + highlighting. > + selected (ProfileNode): The node that is actively selected. > + > + Returns: > + Strip: A renderable strip of segments for the specified row. > + """ > + black = Color.parse("#000000") > + # Non-cursor values range from red up to white. > + normal_styles = [ > + Style(color=black.rich_color, bgcolor=Color(255, x, x).rich_color > + ) for x in range(0, 220, 25) > + ] > + # Cursor is red text with a black background. > + cursor_style = Style(color=Color.parse("#ff0000").rich_color, > + bgcolor=black.rich_color) > + > + class StripVisitor(FlameVisitor): > + """Visitor creating textual flame graph segments. > + > + Attributes: > + segments (list): The textual segments that will be placed in a > + `Strip`. > + gap_width (int): The width of any outstanding gap between the > + last and next node. > + ctr (int): Used to adjust the flame graph segment's color. > + """ > + def __init__(self): > + self.segments = [] > + self.gap_width = 0 > + self.ctr = wanted_strip > + > + def visit(self, node: Optional[ProfileNode], width: int) -> None: > + if node: > + if self.gap_width > 0: > + self.segments.append(Segment( > + make_fixed_length_string(" ", self.gap_width))) > + self.gap_width = 0 > + style = cursor_style > + if node != cursor: > + style = normal_styles[self.ctr % len(normal_styles)] > + self.segments.append(Segment( > + make_fixed_length_string(node.name, width), style)) > + else: > + self.gap_width += width > + self.ctr += 1 > + > + visitor = StripVisitor() > + self.flame_walk(wanted_strip, 0, parent_width, selected, visitor) > + return Strip(visitor.segments) if visitor.segments else Strip.blank(parent_width) > + > + def find_node(self, sought_x: int, sought_y: int, parent_width: int, > + selected: "ProfileNode") -> "ProfileNode": > + """Finds the ProfileNode corresponding to specific X, Y coordinates. > + > + This translates a mouse click on a flame graph back to the > + `ProfileNode` that it represents. > + > + Args: > + sought_x (int): The X coordinate (character column). > + sought_y (int): The Y coordinate (row or depth). > + parent_width (int): The total width of the display area. > + selected (ProfileNode): The currently selected node, which affects > + layout. > + > + Returns: > + Optional[ProfileNode]: The node found at the coordinates, or None. > + > + """ > + class FindVisitor(FlameVisitor): > + """Visitor locating a `ProfileNode`. > + > + Attributes: > + x (int): offset within line. > + found (Optional[ProfileNode]): located node > + gap_width (int): The width of any outstanding gap between the > + last and next node. > + ctr (int): Used to adjust the flame graph segment's color. > + """ > + def __init__(self): > + self.x = 0 > + self.found = None > + > + def visit(self, node: Optional[ProfileNode], width: int) -> None: > + if self.x <= sought_x and sought_x < self.x + width: > + self.found = node > + self.x += width > + > + visitor = FindVisitor() > + self.flame_walk(sought_y, 0, parent_width, selected, visitor) > + return visitor.found > + > + > +class FlameGraph(ScrollView): > + """A scrollable widget to display a flame graph from a profile. > + > + Attributes: > + root (ProfileNode): Root of the profile tree. > + cursor (ProfileNode): Currently highlighted cursor node. > + selected (ProfileNode): The currently selected node for zooming. > + """ > + > + # Define key bindings for navigating the flame graph. > + # Allows movement with vim-style keys (h,j,k,l) and arrow keys. > + BINDINGS = [ > + Binding("j,down", "move_down", "Down", key_display="↓", > + tooltip="Move cursor down to largest child"), > + Binding("k,up", "move_up", "Up", key_display="↑", > + tooltip="Move cursor up to parent"), > + Binding("l,right", "move_right", "Right", key_display="→", > + tooltip="Move cursor to the right sibling"), > + Binding("h,left", "move_left", "Left", key_display="←", > + tooltip="Move cursor to the left sibling"), > + Binding("enter", "zoom_in", "Zoom In", > + tooltip="Expand the cursor's node to be screen width"), > + Binding("escape", "zoom_out", "Zoom Out", > + tooltip="Zoom out to initial view."), > + ] > + > + # Default CSS for the widget to ensure it fills its container's width. > + DEFAULT_CSS = """ > + FlameGraph { > + width: 100%; > + } > + """ > + > + def __init__(self, root: ProfileNode, *args, **kwargs): > + """Initialize the FlameGraph widget.""" > + super().__init__(*args, **kwargs) > + self.root = root > + self.cursor = root > + self.selected = root > + > + def action_move_down(self) -> None: > + """Handle key press down.""" > + self.cursor = self.cursor.largest_child() > + self.refresh() > + > + def action_move_up(self) -> None: > + """Handle key press up.""" > + if self.cursor.parent != self.cursor.parent.parent: > + self.cursor = self.cursor.parent > + self.refresh() > + > + def action_move_right(self) -> None: > + """Handle key press right.""" > + 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) > + self.refresh() > + > + def action_zoom_in(self) -> None: > + """Handle key press zoom in.""" > + self.selected = self.cursor > + self.refresh() > + > + def action_zoom_out(self) -> None: > + """Handle key press zoom out.""" > + self.selected = self.root > + self.refresh() > + > + def render_line(self, y: int) -> Strip: > + """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) > + > + def on_mount(self) -> None: > + """Set the height of the widget when it is displayed.""" > + self.styles.height = self.root.depth() > + > + 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) > + if clicked_node: > + self.cursor = clicked_node > + self.refresh() > + > > class ReportApp(App): > """A Textual application to display profiling data.""" > @@ -152,6 +490,8 @@ class ReportApp(App): > with TabbedContent(initial="report"): > with TabPane("Report", id="report"): > yield self.make_report_tree() > + with TabPane("Flame Graph", id="flame"): > + yield FlameGraph(self.root) > yield Footer() > > > -- > 2.50.1.552.g942d659e1b-goog > ^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH v2 0/2] perf python TUI report and flamegraph 2025-07-25 8:38 ` Ian Rogers @ 2026-08-08 6:57 ` Ian Rogers 2026-08-08 6:57 ` [PATCH v2 1/2] perf python: New treport script Ian Rogers ` (2 more replies) 0 siblings, 3 replies; 24+ messages in thread From: Ian Rogers @ 2026-08-08 6:57 UTC (permalink / raw) To: irogers, acme, alice.mei.rogers, namhyung Cc: adrian.hunter, jolsa, laixintaoo, linux-kernel, linux-perf-users, mingo, peterz Implement a perf TUI using the python textual module. Using the textual framework means that in most modern terminals the mouse is supported, the color scheme can be configured, and screenshots made. v2: Migrate from inbuilt perf python interpreter to perf python module. Improve the flamegraph performance. Use the textual's color scheme rather than red and white. v1: https://lore.kernel.org/linux-perf-users/20250725082425.20999-1-irogers@google.com/ Alice Rogers (2): perf python: New treport script perf python: treport add flamegraph support tools/perf/python/treport.py | 560 +++++++++++++++++++++++++++++++++++ 1 file changed, 560 insertions(+) create mode 100755 tools/perf/python/treport.py -- 2.55.0.654.g21b8a5bc05-goog ^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH v2 1/2] perf python: New treport script 2026-08-08 6:57 ` [PATCH v2 0/2] perf python TUI report and flamegraph Ian Rogers @ 2026-08-08 6:57 ` Ian Rogers 2026-08-08 7:13 ` sashiko-bot 2026-08-08 6:57 ` [PATCH v2 2/2] perf python: treport add flamegraph support Ian Rogers 2026-08-09 4:16 ` [PATCH v3 0/2] perf python TUI report and flamegraph Ian Rogers 2 siblings, 1 reply; 24+ messages in thread From: Ian Rogers @ 2026-08-08 6:57 UTC (permalink / raw) To: irogers, acme, alice.mei.rogers, namhyung Cc: adrian.hunter, jolsa, laixintaoo, linux-kernel, linux-perf-users, mingo, peterz From: Alice Rogers <alice.mei.rogers@gmail.com> A textual app that displays the results of processing samples similar to perf report. The app displays a tree of first processes and then functions which drop down to show more detail on the functions they call. The functions with the largest number of samples are sorted first, after each function the percentage of time spent within it is highlighted. If more than one event is recorded then each event appears first, with the processes and functions sorted for that event beneath it. The app is written in python and requires the textual framework. Using the textual framework means that in most modern terminals the mouse is supported, the color scheme can be configured, and screenshots made. With perf report: ``` $ perf record -e cycles:u -g perf test -w brstack [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.008 MB perf.data (57 samples) ] $ perf report ... Samples: 57 of event 'cycles:u', Event count (approx.): 15907831 Children Self Command Shared Object Symbol - 88.86% 0.00% perf libc.so.6 [.] 0x000079b17df69ca8 0x79b17df69ca8 main handle_internal_command cmd_test - brstack - 84.27% brstack_bench - 40.69% brstack_foo brstack_bar 3.32% brstack_bar 3.31% brstack_bar 1.27% brstack_foo + 88.86% 0.00% perf perf [.] main + 88.86% 0.00% perf perf [.] handle_internal_command + 88.86% 0.00% perf perf [.] cmd_test + 88.86% 0.00% perf perf [.] brstack + 84.27% 40.26% perf perf [.] brstack_bench + 41.96% 30.71% perf perf [.] brstack_foo + 17.89% 17.89% perf perf [.] brstack_bar ``` With perf script treport: ``` $ perf record -e cycles:u -g perf test -w brstack [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.008 MB perf.data (57 samples) ] $ perf script tools/perf/scripts/python/treport.py O ReportApp Report ╸━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ▼ Profile └── ▼ cycles:u 100% └── ▼ perf (32963) 100% ├── ▼ /usr/lib/x86_64-linux-gnu/libc.so.6 0x79b17df6 │ └── ▼ main 56.1% │ └── ▼ handle_internal_command 56.1% │ └── ▼ cmd_test 56.1% │ └── ▼ brstack 56.1% ▁▁ │ ├── ▼ brstack_bench 52.6% │ │ ├── ▶ brstack_foo 24.6% │ │ └── brstack_bar 1.75% │ ├── brstack_foo 1.75% │ └── brstack_bar 1.75% ├── ▶ /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 ▌ ^q Quit ▏^p palette ``` Co-developed-by: Ian Rogers <irogers@google.com> Signed-off-by: Ian Rogers <irogers@google.com> Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com> --- tools/perf/python/treport.py | 207 +++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100755 tools/perf/python/treport.py diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py new file mode 100755 index 000000000000..528a640e4d35 --- /dev/null +++ b/tools/perf/python/treport.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""treport.py - perf report like tool written using textual.""" +from typing import Dict, Optional +import argparse +import os +import sys +import perf +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree +from textual.widgets.tree import TreeNode + +# Global session. +session :Optional[perf.session] = None + +class ProfileNode: + """Represents a single node in a call stack tree. + + Generally a ProfileNode corresponds to a symbol in a call stack. + The root is special, its children are events and the events + children are process names. After the process name come the + samples. + + Attributes: + name (str): The name of the function, process or event. + value (int): The sample count for this node including counts from its + children. + parent (ProfileNode): The parent of this node, this node belongs to its + children. + children (Dict[str, ProfileNode]): A dictionary of child nodes, keyed by + their names. + """ + def __init__(self, name: str, parent: "ProfileNode"): + """Initializes a ProfileNode.""" + self.name = name + self.value: int = 0 + self.parent = parent if parent else self + self.children: Dict[str, ProfileNode] = {} + + def find_or_create_node(self, name: str) -> "ProfileNode": + """Finds a child node by name or creates it if it doesn't exist.""" + if name in self.children: + return self.children[name] + child = ProfileNode(name, self) + self.children[name] = child + return child + + def depth(self) -> int: + """The maximum depth of the call stack tree from this node down.""" + if not self.children: + return 1 + return max(child.depth() for child in self.children.values()) + 1 + + def process_event(self, sample) -> None: + """Processes a single profiling event to update the call stack tree. + + Args: + sample: a single profiling sample. + """ + pid = sample.sample_pid + try: + assert session + thread = session.find_thread(sample.sample_tid) + comm = thread.comm() + except Exception: + comm = f"unknown ({pid})" + + period = sample.sample_period + self.value += period + + node = self.find_or_create_node(comm) + node.value += period + + if sample.callchain: + for entry in reversed(sample.callchain): + name = entry.symbol + if not name or name == "[unknown]": + name = entry.dso or "unknown" + if entry.ip: + name += f" 0x{entry.ip:x}" + node = node.find_or_create_node(name) + node.value += period + else: + name = sample.symbol + if not name or name == "[unknown]": + name = sample.dso or "unknown" + if sample.sample_ip: + name += f" 0x{sample.sample_ip:x}" + 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. + + Args: + node (TreeNode): The textual `TreeNode` object to which this + ProfileNode should be added. + root_value (int): Value at the root of the tree. + """ + if root_value == 0: + root_value = self.value + + # 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 + + # 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) + + +class ReportApp(App): + """A Textual application to display profiling data.""" + + # The ^q binding is implied but having it here adds it in the Footer. + BINDINGS = [ + Binding(key="^q", action="quit", description="Quit", + tooltip="Quit the app"), + ] + + def __init__(self, root: ProfileNode): + """Initialize the application.""" + super().__init__() + self.root = root + + 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 + + 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 Footer() + + +class ProfileBuilder: + """Constructs a profile tree from a stream of events.""" + def __init__(self): + self.root = ProfileNode("root", parent=None) + + def process_event(self, sample) -> None: + """Called by session.process_events to update the profile tree.""" + ev_name = str(sample.evsel)[6:-1] + ev_root = self.root.find_or_create_node(ev_name) + ev_root.process_event(sample) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Create flame graphs 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 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 Exception as e: + print(f"Error opening session: {e}", file=sys.stderr) + sys.exit(1) + + # profle.process_event is called for each perf event to build the profile. + session.process_events() + + # Visualize data. + app = ReportApp(profile.root) + app.run() -- 2.55.0.654.g21b8a5bc05-goog ^ permalink raw reply related [flat|nested] 24+ messages in thread
* Re: [PATCH v2 1/2] perf python: New treport script 2026-08-08 6:57 ` [PATCH v2 1/2] perf python: New treport script Ian Rogers @ 2026-08-08 7:13 ` sashiko-bot 0 siblings, 0 replies; 24+ messages in thread From: sashiko-bot @ 2026-08-08 7:13 UTC (permalink / raw) To: Ian Rogers; +Cc: linux-perf-users Thank you for your contribution! Sashiko AI review found 4 potential issue(s) to consider: - [High] The script uses multiple undefined attributes and methods from the `perf` Python module, guaranteeing an immediate crash. - [Low] Unconditional import of the third-party `textual` package will cause a raw `ModuleNotFoundError` stack trace if it is not installed. - [Low] Extracting the event name via hardcoded string slicing on the string representation of an object is extremely brittle. - [Low] The commit message demonstrates usage with a path that does not match the actual location of the script. -- --- Patch [1]: [PATCH v2 1/2] perf python: New treport script --- commit c8886e7317972e7aa7d9b4370a2487dc827a9013 Author: Alice Rogers <alice.mei.rogers@gmail.com> perf python: New treport script This commit introduces a new textual-based application, treport.py, which displays the results of perf sample processing in an interactive tree format. > With perf script treport: > > $ perf record -e cycles:u -g perf test -w brstack > [ perf record: Woken up 1 times to write data ] > [ perf record: Captured and wrote 0.008 MB perf.data (57 samples) ] > $ perf script tools/perf/scripts/python/treport.py [Severity: Low] This isn't a bug, but the commit message demonstrates usage with tools/perf/scripts/python/treport.py, while the patch creates the file at tools/perf/python/treport.py. Could this cause confusion for users trying to copy and paste the command? > diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py > new file mode 100755 > index 0000000000000..528a640e4d356 > --- /dev/null > +++ b/tools/perf/python/treport.py > @@ -0,0 +1,207 @@ > +#!/usr/bin/env python3 > +# SPDX-License-Identifier: MIT > +"""treport.py - perf report like tool written using textual.""" > +from typing import Dict, Optional > +import argparse > +import os > +import sys > +import perf > +from textual.app import App, ComposeResult [Severity: Low] Would it be helpful to catch the ImportError for the textual module and print a user-friendly instruction to install it? Currently, users without this third-party dependency installed will hit a raw ModuleNotFoundError stack trace. [ ... ] > +class ProfileBuilder: > + """Constructs a profile tree from a stream of events.""" > + def __init__(self): > + self.root = ProfileNode("root", parent=None) > + > + def process_event(self, sample) -> None: > + """Called by session.process_events to update the profile tree.""" > + ev_name = str(sample.evsel)[6:-1] [Severity: High] Does the perf Python C extension currently expose the evsel attribute on sample objects? It appears this might raise an AttributeError at runtime since sample.evsel doesn't seem to be implemented in tools/perf/util/python.c. [Severity: Low] Also, is it safe to rely on hardcoded string slicing [6:-1] here? If the string representation format of the event changes slightly, could this rigid slicing fail to correctly isolate the event name? > + ev_root = self.root.find_or_create_node(ev_name) > + ev_root.process_event(sample) [ ... ] > + input_file = args.input or "perf.data" > + 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) [Severity: High] Are perf.session() and perf.data() supported in the current python API? Looking at the existing perf python bindings, it doesn't appear these methods are implemented. Will this unconditionally crash with an AttributeError when executed? -- Sashiko AI review · https://sashiko.dev/#/patchset/20260808065743.2328212-1-irogers@google.com?part=1 ^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH v2 2/2] perf python: treport add flamegraph support 2026-08-08 6:57 ` [PATCH v2 0/2] perf python TUI report and flamegraph Ian Rogers 2026-08-08 6:57 ` [PATCH v2 1/2] perf python: New treport script Ian Rogers @ 2026-08-08 6:57 ` Ian Rogers 2026-08-09 4:16 ` [PATCH v3 0/2] perf python TUI report and flamegraph Ian Rogers 2 siblings, 0 replies; 24+ messages in thread From: Ian Rogers @ 2026-08-08 6:57 UTC (permalink / raw) To: irogers, acme, alice.mei.rogers, namhyung Cc: adrian.hunter, jolsa, laixintaoo, linux-kernel, linux-perf-users, mingo, peterz From: Alice Rogers <alice.mei.rogers@gmail.com> Implement a flamegraph widget that recursively walks down a tree splitting line segments based on their value (summed up periods across call chains). A visitor pattern is used so that the same logic can both draw the line segments and locate which segment had a mouse click. Add a tab for the flame graph widget. Co-developed-by: Ian Rogers <irogers@google.com> Signed-off-by: Ian Rogers <irogers@google.com> Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com> --- tools/perf/python/treport.py | 353 +++++++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py index 528a640e4d35..ec3263a6b625 100755 --- a/tools/perf/python/treport.py +++ b/tools/perf/python/treport.py @@ -1,19 +1,49 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT """treport.py - perf report like tool written using textual.""" +from abc import ABC, abstractmethod from typing import Dict, Optional import argparse import os import sys import perf +from rich.segment import Segment +from rich.style import Style +from textual import events from textual.app import App, ComposeResult from textual.binding import Binding +from textual.color import Color +from textual.scroll_view import ScrollView +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. + + Increases or decreases the length of s to be length. If the length is + increased then pad_char is inserted on the right. + """ + return s[:length] if len(s) > length else s.ljust(length, pad_char) + + +class FlameVisitor(ABC): + """Parent for visitor used by ProfileNode.flame_walk""" + @abstractmethod + def visit(self, node: Optional["ProfileNode"], width: int) -> None: + """Visit a profile node width the specified flame graph width. + + Args: + node: The `ProfileNode` for the current segment. This may be `None` + to represent a gap or an unknown portion of the stack. + width: The calculated width of the flame graph rectangle for this + node, which is proportional to its sample count. + """ + + class ProfileNode: """Represents a single node in a call stack tree. @@ -120,6 +150,327 @@ class ProfileNode: key=lambda pnode: pnode.value, reverse=True): pnode.add_to_tree(new_node, root_value) + 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 + + 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): + if child == sought: + found = True + elif found: + return child + return sought + + 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): + if child == sought: + return last if last else sought + last = child + return sought + + def has_parent(self, parent: "ProfileNode") -> bool: + """Checks if the parent node is an ancestor of this node.""" + p = self.parent + while True: + if p == parent: + return True + new_p = p.parent + if new_p == p: + break + p = new_p + return False + + def has_child(self, sought: "ProfileNode") -> bool: + """Checks if the sought node is a descendant of this node.""" + return sought.has_parent(self) + + def flame_walk(self, wanted_strip: int, cur_strip: int, parent_width: int, + selected: "ProfileNode", visitor: FlameVisitor) -> None: + """Recursively walks the tree to visit a single flame graph row. + + This method calculates the proportional width for each child + based on its value (sample count) relative to its parent. It + then invokes a `visitor` to process each segment of the flame + graph row. + + Args: + wanted_strip (int): The target depth (Y-axis) of the flame graph row + to generate. + cur_strip (int): The current depth of the traversal. + parent_width (int): The width of the parent of this node. + selected (ProfileNode): The currently selected node in the UI, used + to adjust rendering to highlight the + selected path. + visitor (FlameVisitor): A visitor object whose `visit` method is + called for each segment of the flame graph + row. + """ + if parent_width == 0: + return + + parent_selected = selected == self or self.has_parent(selected) + child_selected = not parent_selected and self.has_child(selected) + if not parent_selected and not child_selected: + # Branches of the tree with no node selected aren't drawn. + return + + # 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): + if parent_selected: + if self.value: + desired_width = int((parent_width * child.value) / self.value) + else: + desired_width = parent_width // len(self.children) + if desired_width == 0: + # Nothing can be drawn for this node or later smaller children. + break + elif child == selected or child.has_child(selected): + desired_width = parent_width + else: + # A sibling or its child are selected, but not this branch. + continue + + # Either visit the wanted_strip or recurse to the next level. + if wanted_strip == cur_strip: + visitor.visit(child, desired_width) + else: + child.flame_walk(wanted_strip, cur_strip + 1, desired_width, + selected, visitor) + left_over -= desired_width + if left_over == 0: + # No space left to draw in. + break + + # Always visit the left_over regardless of the wanted_strip as there + # may be additional gap added to a line by a parent. + if left_over: + visitor.visit(None, left_over) + + def make_flame_strip(self, wanted_strip: int, parent_width: int, + cursor: "ProfileNode", selected: "ProfileNode", + theme_variables: Dict[str, str]) -> Strip: + """Creates a renderable 'Strip' for a single row of a flame graph. + + This method orchestrates the `flame_walk` traversal with a specialized + visitor to generate a list of segments. The segments are used by a`Strip` + object for rendering in the terminal. + + Args: + wanted_strip (int): The target depth (Y-axis) of the flame graph row. + parent_width (int): The total width (in characters) of the display + area. + cursor (ProfileNode): The node currently under the cursor, for + highlighting. + selected (ProfileNode): The node that is actively selected. + theme_variables(Dict): Values of colors for the textual theme. + + Returns: + Strip: A renderable strip of segments for the specified row. + """ + primary = Color.parse(theme_variables["primary"]) + secondary = Color.parse(theme_variables["secondary"]) + surface = Color.parse(theme_variables["surface"]) + def luminance(color: Color) -> float: + """Computes the luminance of a color from the rgb""" + return color.r * 0.299 + color.g * 0.587 + color.b * 0.114 + + # Set of styles for different flamegraph segments, the styles are + # cycled through to provide contrast. + normal_styles = [] + for x in range(0, 125, 25): + fgcolor = secondary.blend(primary, x/100) + if luminance(fgcolor) > luminance(surface): + bgcolor = surface.lighten(0.05+x/500) + else: + bgcolor = surface.darken(0.05+x/500) + normal_styles.append(Style(color=fgcolor.rich_color, + bgcolor=bgcolor.rich_color)) + + # Style for the selected flame graph node. + accent = Color.parse(theme_variables["accent"]) + accent_muted = Color.parse(theme_variables["accent-muted"]) + cursor_style = Style(color=accent.rich_color, bgcolor=accent_muted.rich_color) + + class StripVisitor(FlameVisitor): + """Visitor creating textual flame graph segments. + + Attributes: + segments (list): The textual segments that will be placed in a + `Strip`. + gap_width (int): The width of any outstanding gap between the + last and next node. + ctr (int): Used to adjust the flame graph segment's color. + """ + def __init__(self): + self.segments = [] + self.gap_width = 0 + self.ctr = wanted_strip + + def visit(self, node: Optional[ProfileNode], width: int) -> None: + if node: + if self.gap_width > 0: + self.segments.append(Segment( + make_fixed_length_string(" ", self.gap_width))) + self.gap_width = 0 + style = cursor_style + if node != cursor: + style = normal_styles[self.ctr % len(normal_styles)] + self.segments.append(Segment( + make_fixed_length_string(node.name, width), style)) + else: + self.gap_width += width + self.ctr += 1 + + visitor = StripVisitor() + self.flame_walk(wanted_strip, 0, parent_width, selected, visitor) + return Strip(visitor.segments) if visitor.segments else Strip.blank(parent_width) + + def find_node(self, sought_x: int, sought_y: int, parent_width: int, + selected: "ProfileNode") -> "ProfileNode": + """Finds the ProfileNode corresponding to specific X, Y coordinates. + + This translates a mouse click on a flame graph back to the + `ProfileNode` that it represents. + + Args: + sought_x (int): The X coordinate (character column). + sought_y (int): The Y coordinate (row or depth). + parent_width (int): The total width of the display area. + selected (ProfileNode): The currently selected node, which affects + layout. + + Returns: + Optional[ProfileNode]: The node found at the coordinates, or None. + + """ + class FindVisitor(FlameVisitor): + """Visitor locating a `ProfileNode`. + + Attributes: + x (int): offset within line. + found (Optional[ProfileNode]): located node + gap_width (int): The width of any outstanding gap between the + last and next node. + ctr (int): Used to adjust the flame graph segment's color. + """ + def __init__(self): + self.x = 0 + self.found = None + + def visit(self, node: Optional[ProfileNode], width: int) -> None: + if self.x <= sought_x < self.x + width: + self.found = node + self.x += width + + visitor = FindVisitor() + self.flame_walk(sought_y, 0, parent_width, selected, visitor) + return visitor.found + + +class FlameGraph(ScrollView): + """A scrollable widget to display a flame graph from a profile. + + Attributes: + root (ProfileNode): Root of the profile tree. + cursor (ProfileNode): Currently highlighted cursor node. + selected (ProfileNode): The currently selected node for zooming. + """ + + # Define key bindings for navigating the flame graph. + # Allows movement with vim-style keys (h,j,k,l) and arrow keys. + BINDINGS = [ + Binding("j,down", "move_down", "Down", key_display="↓", + tooltip="Move cursor down to largest child"), + Binding("k,up", "move_up", "Up", key_display="↑", + tooltip="Move cursor up to parent"), + Binding("l,right", "move_right", "Right", key_display="→", + tooltip="Move cursor to the right sibling"), + Binding("h,left", "move_left", "Left", key_display="←", + tooltip="Move cursor to the left sibling"), + Binding("enter", "zoom_in", "Zoom In", + tooltip="Expand the cursor's node to be screen width"), + Binding("escape", "zoom_out", "Zoom Out", + tooltip="Zoom out to initial view."), + ] + + # Default CSS for the widget to ensure it fills its container's width. + DEFAULT_CSS = """ + FlameGraph { + width: 100%; + } + """ + + def __init__(self, root: ProfileNode, *args, **kwargs): + """Initialize the FlameGraph widget.""" + super().__init__(*args, **kwargs) + self.root = root + self.cursor = root + self.selected = root + + def action_move_down(self) -> None: + """Handle key press down.""" + self.cursor = self.cursor.largest_child() + self.refresh() + + def action_move_up(self) -> None: + """Handle key press up.""" + if self.cursor.parent != self.cursor.parent.parent: + self.cursor = self.cursor.parent + self.refresh() + + def action_move_right(self) -> None: + """Handle key press right.""" + 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) + self.refresh() + + def action_zoom_in(self) -> None: + """Handle key press zoom in.""" + self.selected = self.cursor + self.refresh() + + def action_zoom_out(self) -> None: + """Handle key press zoom out.""" + self.selected = self.root + self.refresh() + + def render_line(self, y: int) -> Strip: + """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) + + def on_mount(self) -> None: + """Set the height of the widget when it is displayed.""" + self.styles.height = self.root.depth() + + 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) + if clicked_node: + self.cursor = clicked_node + self.refresh() + class ReportApp(App): """A Textual application to display profiling data.""" @@ -168,6 +519,8 @@ class ReportApp(App): with TabbedContent(initial="report"): with TabPane("Report", id="report"): yield self.make_report_tree() + with TabPane("Flame Graph", id="flame"): + yield FlameGraph(self.root) yield Footer() -- 2.55.0.654.g21b8a5bc05-goog ^ permalink raw reply related [flat|nested] 24+ messages in thread
* [PATCH v3 0/2] perf python TUI report and flamegraph 2026-08-08 6:57 ` [PATCH v2 0/2] perf python TUI report and flamegraph Ian Rogers 2026-08-08 6:57 ` [PATCH v2 1/2] perf python: New treport script Ian Rogers 2026-08-08 6:57 ` [PATCH v2 2/2] perf python: treport add flamegraph support Ian Rogers @ 2026-08-09 4:16 ` Ian Rogers 2026-08-09 4:16 ` [PATCH v3 1/2] perf python: New treport script Ian Rogers ` (3 more replies) 2 siblings, 4 replies; 24+ messages in thread From: Ian Rogers @ 2026-08-09 4:16 UTC (permalink / raw) To: irogers, acme, alice.mei.rogers, namhyung Cc: adrian.hunter, jolsa, laixintaoo, linux-kernel, linux-perf-users, mingo, peterz Implement a perf TUI using the python textual module. Using the textual framework means that in most modern terminals the mouse is supported, the color scheme can be configured, and screenshots made. The work is dependent on the extensions to the perf python module merged into perf-tools-next such as commit 88439191ad5e ("perf python: Add callchain support"). It is also dependent on the python textual module for TUI support. v3: Tweaks to commit messages (Sashiko). v2: Migrate from inbuilt perf python interpreter to perf python module. Improve the flamegraph performance. Use the textual's color scheme rather than red and white. https://lore.kernel.org/linux-perf-users/20260808065743.2328212-1-irogers@google.com/ v1: https://lore.kernel.org/linux-perf-users/20250725082425.20999-1-irogers@google.com/ Alice Rogers (2): perf python: New treport script perf python: treport add flamegraph support tools/perf/python/treport.py | 560 +++++++++++++++++++++++++++++++++++ 1 file changed, 560 insertions(+) create mode 100755 tools/perf/python/treport.py -- 2.55.0.654.g21b8a5bc05-goog ^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH v3 1/2] perf python: New treport script 2026-08-09 4:16 ` [PATCH v3 0/2] perf python TUI report and flamegraph Ian Rogers @ 2026-08-09 4:16 ` Ian Rogers 2026-08-09 4:29 ` sashiko-bot 2026-08-09 4:16 ` [PATCH v3] perf test: Fixes for check branch stack sampling Ian Rogers ` (2 subsequent siblings) 3 siblings, 1 reply; 24+ messages in thread From: Ian Rogers @ 2026-08-09 4:16 UTC (permalink / raw) To: irogers, acme, alice.mei.rogers, namhyung Cc: adrian.hunter, jolsa, laixintaoo, linux-kernel, linux-perf-users, mingo, peterz From: Alice Rogers <alice.mei.rogers@gmail.com> A textual app that displays the results of processing samples similar to perf report. The app displays a tree of first processes and then functions which drop down to show more detail on the functions they call. The functions with the largest number of samples are sorted first, after each function the percentage of time spent within it is highlighted. If more than one event is recorded then each event appears first, with the processes and functions sorted for that event beneath it. The app is written in python and requires the textual framework. Using the textual framework means that in most modern terminals the mouse is supported, the color scheme can be configured, and screenshots made. With perf report: ``` $ perf record -e cycles:u -g perf test -w brstack [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.008 MB perf.data (57 samples) ] $ perf report ... Samples: 57 of event 'cycles:u', Event count (approx.): 15907831 Children Self Command Shared Object Symbol - 88.86% 0.00% perf libc.so.6 [.] 0x000079b17df69ca8 0x79b17df69ca8 main handle_internal_command cmd_test - brstack - 84.27% brstack_bench - 40.69% brstack_foo brstack_bar 3.32% brstack_bar 3.31% brstack_bar 1.27% brstack_foo + 88.86% 0.00% perf perf [.] main + 88.86% 0.00% perf perf [.] handle_internal_command + 88.86% 0.00% perf perf [.] cmd_test + 88.86% 0.00% perf perf [.] brstack + 84.27% 40.26% perf perf [.] brstack_bench + 41.96% 30.71% perf perf [.] brstack_foo + 17.89% 17.89% perf perf [.] brstack_bar ``` With the treport script: ``` $ perf record -e cycles:u -g perf test -w brstack [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.008 MB perf.data (57 samples) ] $ tools/perf/python/treport.py O ReportApp Report ╸━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ▼ Profile └── ▼ cycles:u 100% └── ▼ perf (32963) 100% ├── ▼ /usr/lib/x86_64-linux-gnu/libc.so.6 0x79b17df6 │ └── ▼ main 56.1% │ └── ▼ handle_internal_command 56.1% │ └── ▼ cmd_test 56.1% │ └── ▼ brstack 56.1% ▁▁ │ ├── ▼ brstack_bench 52.6% │ │ ├── ▶ brstack_foo 24.6% │ │ └── brstack_bar 1.75% │ ├── brstack_foo 1.75% │ └── brstack_bar 1.75% ├── ▶ /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 ▌ ^q Quit ▏^p palette ``` Co-developed-by: Ian Rogers <irogers@google.com> Signed-off-by: Ian Rogers <irogers@google.com> Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com> --- tools/perf/python/treport.py | 207 +++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100755 tools/perf/python/treport.py diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py new file mode 100755 index 000000000000..528a640e4d35 --- /dev/null +++ b/tools/perf/python/treport.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""treport.py - perf report like tool written using textual.""" +from typing import Dict, Optional +import argparse +import os +import sys +import perf +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree +from textual.widgets.tree import TreeNode + +# Global session. +session :Optional[perf.session] = None + +class ProfileNode: + """Represents a single node in a call stack tree. + + Generally a ProfileNode corresponds to a symbol in a call stack. + The root is special, its children are events and the events + children are process names. After the process name come the + samples. + + Attributes: + name (str): The name of the function, process or event. + value (int): The sample count for this node including counts from its + children. + parent (ProfileNode): The parent of this node, this node belongs to its + children. + children (Dict[str, ProfileNode]): A dictionary of child nodes, keyed by + their names. + """ + def __init__(self, name: str, parent: "ProfileNode"): + """Initializes a ProfileNode.""" + self.name = name + self.value: int = 0 + self.parent = parent if parent else self + self.children: Dict[str, ProfileNode] = {} + + def find_or_create_node(self, name: str) -> "ProfileNode": + """Finds a child node by name or creates it if it doesn't exist.""" + if name in self.children: + return self.children[name] + child = ProfileNode(name, self) + self.children[name] = child + return child + + def depth(self) -> int: + """The maximum depth of the call stack tree from this node down.""" + if not self.children: + return 1 + return max(child.depth() for child in self.children.values()) + 1 + + def process_event(self, sample) -> None: + """Processes a single profiling event to update the call stack tree. + + Args: + sample: a single profiling sample. + """ + pid = sample.sample_pid + try: + assert session + thread = session.find_thread(sample.sample_tid) + comm = thread.comm() + except Exception: + comm = f"unknown ({pid})" + + period = sample.sample_period + self.value += period + + node = self.find_or_create_node(comm) + node.value += period + + if sample.callchain: + for entry in reversed(sample.callchain): + name = entry.symbol + if not name or name == "[unknown]": + name = entry.dso or "unknown" + if entry.ip: + name += f" 0x{entry.ip:x}" + node = node.find_or_create_node(name) + node.value += period + else: + name = sample.symbol + if not name or name == "[unknown]": + name = sample.dso or "unknown" + if sample.sample_ip: + name += f" 0x{sample.sample_ip:x}" + 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. + + Args: + node (TreeNode): The textual `TreeNode` object to which this + ProfileNode should be added. + root_value (int): Value at the root of the tree. + """ + if root_value == 0: + root_value = self.value + + # 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 + + # 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) + + +class ReportApp(App): + """A Textual application to display profiling data.""" + + # The ^q binding is implied but having it here adds it in the Footer. + BINDINGS = [ + Binding(key="^q", action="quit", description="Quit", + tooltip="Quit the app"), + ] + + def __init__(self, root: ProfileNode): + """Initialize the application.""" + super().__init__() + self.root = root + + 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 + + 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 Footer() + + +class ProfileBuilder: + """Constructs a profile tree from a stream of events.""" + def __init__(self): + self.root = ProfileNode("root", parent=None) + + def process_event(self, sample) -> None: + """Called by session.process_events to update the profile tree.""" + ev_name = str(sample.evsel)[6:-1] + ev_root = self.root.find_or_create_node(ev_name) + ev_root.process_event(sample) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Create flame graphs 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 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 Exception as e: + print(f"Error opening session: {e}", file=sys.stderr) + sys.exit(1) + + # profle.process_event is called for each perf event to build the profile. + session.process_events() + + # Visualize data. + app = ReportApp(profile.root) + app.run() -- 2.55.0.654.g21b8a5bc05-goog ^ permalink raw reply related [flat|nested] 24+ messages in thread
* Re: [PATCH v3 1/2] perf python: New treport script 2026-08-09 4:16 ` [PATCH v3 1/2] perf python: New treport script Ian Rogers @ 2026-08-09 4:29 ` sashiko-bot 0 siblings, 0 replies; 24+ messages in thread From: sashiko-bot @ 2026-08-09 4:29 UTC (permalink / raw) To: Ian Rogers; +Cc: linux-perf-users Thank you for your contribution! Sashiko AI review found 3 potential issue(s) to consider: - [High] The script relies on missing `perf` Python C extension APIs, causing immediate crashes on import or execution. - [Low] The argparse description inaccurately claims the script creates flame graphs. - [Low] Typo in the source code comments ('profle' instead of 'profile'). -- --- Patch [1]: [PATCH v3 1/2] perf python: New treport script --- commit d9d175087e8fce3c5649b6249af90e23002fff56 Author: Alice Rogers <alice.mei.rogers@gmail.com> perf python: New treport script This commit adds a new treport.py script that uses the textual framework to display an interactive tree report of processed perf samples. > diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py > new file mode 100755 > index 0000000000000..528a640e4d356 > --- /dev/null > +++ b/tools/perf/python/treport.py [ ... ] > +from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree > +from textual.widgets.tree import TreeNode > + > +# Global session. > +session :Optional[perf.session] = None > + [Severity: High] Are there prerequisite patches missing from this series? The perf Python C extension does not currently expose perf.session. Because this type annotation is evaluated at module load time, running the script results in an immediate AttributeError. [ ... ] > + node.value += period > + > + if sample.callchain: > + for entry in reversed(sample.callchain): > + name = entry.symbol > + if not name or name == "[unknown]": [Severity: High] Does the Python sample_event object expose callchain and symbol? It appears these attributes are also missing from the current Python C extension, which would cause this to crash when processing samples. [ ... ] > +class ProfileBuilder: > + """Constructs a profile tree from a stream of events.""" > + def __init__(self): > + self.root = ProfileNode("root", parent=None) > + > + def process_event(self, sample) -> None: > + """Called by session.process_events to update the profile tree.""" > + ev_name = str(sample.evsel)[6:-1] [Severity: High] Does the sample object expose evsel? Similar to the other missing attributes, this seems to rely on Python C extension features that are not yet present in the tree. [ ... ] > +if __name__ == "__main__": > + parser = argparse.ArgumentParser(description="Create flame graphs using perf python module.") > + parser.add_argument("-i", "--input", help="input perf.data file") [Severity: Low] This isn't a bug, but the description says "Create flame graphs" instead of describing the interactive tree report this script actually generates. Is this a copy-paste error from another script? [ ... ] > + except Exception as e: > + print(f"Error opening session: {e}", file=sys.stderr) > + sys.exit(1) > + > + # profle.process_event is called for each perf event to build the profile. > + session.process_events() [Severity: Low] This isn't a bug, but there is a typo (profle) in this comment. -- Sashiko AI review · https://sashiko.dev/#/patchset/20260809041638.2402705-1-irogers@google.com?part=1 ^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH v3] perf test: Fixes for check branch stack sampling 2026-08-09 4:16 ` [PATCH v3 0/2] perf python TUI report and flamegraph Ian Rogers 2026-08-09 4:16 ` [PATCH v3 1/2] perf python: New treport script Ian Rogers @ 2026-08-09 4:16 ` Ian Rogers 2026-08-09 5:21 ` Ian Rogers 2026-08-09 4:16 ` [PATCH v3 2/2] perf python: treport add flamegraph support Ian Rogers 2026-08-09 5:18 ` [PATCH v4 0/2] perf python TUI report and flamegraph Ian Rogers 3 siblings, 1 reply; 24+ messages in thread From: Ian Rogers @ 2026-08-09 4:16 UTC (permalink / raw) To: irogers, acme, alice.mei.rogers, namhyung Cc: adrian.hunter, jolsa, laixintaoo, linux-kernel, linux-perf-users, mingo, peterz When filtering branch stack samples on user events they sample in user land but may have come from the kernel. Avoid the target address being a kernel address but allow the source to be the kernel. When filtering branch stack samples on kernel events they sample in kernel land but may have come from user land. Avoid the target being a user address but allow the source to be in user land. Increase the duration of the system call sampling test to make the likelihood of sampling a system call higher (increased from 1000 to 8000 loops - a number found through experimentation on an Intel Tigerlake laptop), also make the period of the event a prime number. Put unneeded perf record output into a temporary file so that the test output isn't cluttered. More clearly state which test is running and the pass, fail or skipped result of the test. These changes make the test on an Intel tigerlake laptop reliably pass rather than reliably fail. Signed-off-by: Ian Rogers <irogers@google.com> --- tools/perf/tests/shell/test_brstack.sh | 134 ++++++++++++++++--------- 1 file changed, 84 insertions(+), 50 deletions(-) diff --git a/tools/perf/tests/shell/test_brstack.sh b/tools/perf/tests/shell/test_brstack.sh index 85233d435be6..025ed9d3d110 100755 --- a/tools/perf/tests/shell/test_brstack.sh +++ b/tools/perf/tests/shell/test_brstack.sh @@ -40,7 +40,7 @@ is_arm64() { check_branches() { if ! tr -s ' ' '\n' < "$TMPDIR/perf.script" | grep -E -m1 -q "$1"; then - echo "Branches missing $1" + echo "ERROR: Branches missing $1" err=1 fi } @@ -48,6 +48,8 @@ check_branches() { test_user_branches() { echo "Testing user branch stack sampling" + start_err=$err + err=0 perf record -o "$TMPDIR/perf.data" --branch-filter any,save_type,u -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 perf script -i "$TMPDIR/perf.data" --fields brstacksym > "$TMPDIR/perf.script" @@ -73,59 +75,80 @@ test_user_branches() { perf script -i "$TMPDIR/perf.data" --fields brstack | \ tr ' ' '\n' > "$TMPDIR/perf.script" - # There should be no kernel addresses with the u option, in either - # source or target addresses. - if grep -E -m1 "0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then - echo "ERROR: Kernel address found in user mode" + # There should be no kernel addresses in the target with the u option. + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[89a-f][0-9a-f]{15}/" $TMPDIR/perf.script; then + echo "Testing user branch stack sampling [Failed kernel address found in user mode]" err=1 fi # some branch types are still not being tested: # IND COND_CALL COND_RET SYSRET SERROR NO_TX + if [ $err -eq 0 ]; then + echo "Testing user branch stack sampling [Passed]" + err=$start_err + else + echo "Testing user branch stack sampling [Failed]" + fi } test_trap_eret_branches() { echo "Testing trap & eret branches" + if ! is_arm64; then - echo "skip: not arm64" + echo "Testing trap & eret branches [Skipped not arm64]" + return + fi + start_err=$err + err=0 + perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ + perf test -w traploop 1000 > "$TMPDIR/record.txt" 2>&1 + perf script -i $TMPDIR/perf.data --fields brstacksym | \ + tr ' ' '\n' > $TMPDIR/perf.script + + # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver + check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" + check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" + if [ $err -eq 0 ]; then + echo "Testing trap & eret branches [Passed]" + err=$start_err else - perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ - perf test -w traploop 1000 - perf script -i $TMPDIR/perf.data --fields brstacksym | \ - tr ' ' '\n' > $TMPDIR/perf.script - - # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver - check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" - check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" + echo "Testing trap & eret branches [Failed]" fi } test_kernel_branches() { - echo "Testing that k option only includes kernel source addresses" + echo "Testing kernel branch sampling" - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then - echo "skip: not enough privileges" + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then + echo "Testing that k option [Skipped not enough privileges]" + return + fi + start_err=$err + err=0 + perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ + perf bench syscall basic --loop 1000 > "$TMPDIR/record.txt" 2>&1 + perf script -i $TMPDIR/perf.data --fields brstack | \ + tr ' ' '\n' > $TMPDIR/perf.script + + # Example of branch entries: + # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." + # Source addresses come first in user or kernel code. Next is the target + # address that must be in the kernel. + + # Look for source addresses with top bit set + if ! grep -q -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then + echo "Testing kernel branch sampling [Failed kernel branches missing]" + err=1 + fi + # Look for no target addresses without top bit set + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[0-7][0-9a-f]{0,15}/" $TMPDIR/perf.script; then + echo "Testing kernel branch sampling [Failed user branches found]" + err=1 + fi + if [ $err -eq 0 ]; then + echo "Testing kernel branch sampling [Passed]" + err=$start_err else - perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ - perf bench syscall basic --loop 1000 - perf script -i $TMPDIR/perf.data --fields brstack | \ - tr ' ' '\n' > $TMPDIR/perf.script - - # Example of branch entries: - # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." - # Source addresses come first and target address can be either - # userspace or kernel even with k option, as long as the source - # is in kernel. - - #Look for source addresses with top bit set - if ! grep -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then - echo "ERROR: Kernel branches missing" - err=1 - fi - # Look for no source addresses without top bit set - if grep -E -m1 "^0x[0-7][0-9a-f]{0,15}" $TMPDIR/perf.script; then - echo "ERROR: User branches found with kernel filter" - err=1 - fi + echo "Testing kernel branch sampling [Failed]" fi } @@ -136,14 +159,15 @@ test_filter() { test_filter_expect=$2 echo "Testing branch stack filtering permutation ($test_filter_filter,$test_filter_expect)" - perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 + perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- \ + ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 perf script -i "$TMPDIR/perf.data" --fields brstack > "$TMPDIR/perf.script" # fail if we find any branch type that doesn't match any of the expected ones # also consider UNKNOWN branch types (-) if [ ! -s "$TMPDIR/perf.script" ] then - echo "Empty script output" + echo "Testing branch stack filtering [Failed empty script output]" err=1 return fi @@ -154,26 +178,36 @@ test_filter() { > "$TMPDIR/perf.script-filtered" || true if [ -s "$TMPDIR/perf.script-filtered" ] then - echo "Unexpected branch filter in script output" + echo "Testing branch stack filtering [Failed unexpected branch filter]" cat "$TMPDIR/perf.script" err=1 return fi + echo "Testing branch stack filtering [Passed]" } test_syscall() { echo "Testing syscalls" # skip if perf doesn't have enough privileges - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then - echo "skip: not enough privileges" + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then + echo "Testing syscalls [Skipped: not enough privileges]" + return + fi + start_err=$err + err=0 + perf record -o $TMPDIR/perf.data --branch-filter \ + any_call,save_type,u,k -c 10007 -- \ + perf bench syscall basic --loop 8000 > "$TMPDIR/record.txt" 2>&1 + perf script -i $TMPDIR/perf.data --fields brstacksym | \ + tr ' ' '\n' > $TMPDIR/perf.script + + check_branches "getppid[^ ]*/SYSCALL/" + + if [ $err -eq 0 ]; then + echo "Testing syscalls [Passed]" + err=$start_err else - perf record -o $TMPDIR/perf.data --branch-filter \ - any_call,save_type,u,k -c 10000 -- \ - perf bench syscall basic --loop 1000 - perf script -i $TMPDIR/perf.data --fields brstacksym | \ - tr ' ' '\n' > $TMPDIR/perf.script - - check_branches "getppid[^ ]*/SYSCALL/" + echo "Testing syscalls [Failed]" fi } set -e -- 2.53.0.1213.gd9a14994de-goog ^ permalink raw reply related [flat|nested] 24+ messages in thread
* Re: [PATCH v3] perf test: Fixes for check branch stack sampling 2026-08-09 4:16 ` [PATCH v3] perf test: Fixes for check branch stack sampling Ian Rogers @ 2026-08-09 5:21 ` Ian Rogers 0 siblings, 0 replies; 24+ messages in thread From: Ian Rogers @ 2026-08-09 5:21 UTC (permalink / raw) To: irogers, acme, alice.mei.rogers, namhyung Cc: adrian.hunter, jolsa, laixintaoo, linux-kernel, linux-perf-users, mingo, peterz On Sat, Aug 8, 2026 at 9:16 PM Ian Rogers <irogers@google.com> wrote: > > When filtering branch stack samples on user events they sample in user > land but may have come from the kernel. Avoid the target address being > a kernel address but allow the source to be the kernel. > > When filtering branch stack samples on kernel events they sample in > kernel land but may have come from user land. Avoid the target being a > user address but allow the source to be in user land. > > Increase the duration of the system call sampling test to make the > likelihood of sampling a system call higher (increased from 1000 to > 8000 loops - a number found through experimentation on an Intel > Tigerlake laptop), also make the period of the event a prime number. > > Put unneeded perf record output into a temporary file so that the test > output isn't cluttered. More clearly state which test is running and > the pass, fail or skipped result of the test. > > These changes make the test on an Intel tigerlake laptop reliably pass > rather than reliably fail. > > Signed-off-by: Ian Rogers <irogers@google.com> Sorry, sent in error. This change is already merged. Thanks, Ian > --- > tools/perf/tests/shell/test_brstack.sh | 134 ++++++++++++++++--------- > 1 file changed, 84 insertions(+), 50 deletions(-) > > diff --git a/tools/perf/tests/shell/test_brstack.sh b/tools/perf/tests/shell/test_brstack.sh > index 85233d435be6..025ed9d3d110 100755 > --- a/tools/perf/tests/shell/test_brstack.sh > +++ b/tools/perf/tests/shell/test_brstack.sh > @@ -40,7 +40,7 @@ is_arm64() { > > check_branches() { > if ! tr -s ' ' '\n' < "$TMPDIR/perf.script" | grep -E -m1 -q "$1"; then > - echo "Branches missing $1" > + echo "ERROR: Branches missing $1" > err=1 > fi > } > @@ -48,6 +48,8 @@ check_branches() { > test_user_branches() { > echo "Testing user branch stack sampling" > > + start_err=$err > + err=0 > perf record -o "$TMPDIR/perf.data" --branch-filter any,save_type,u -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 > perf script -i "$TMPDIR/perf.data" --fields brstacksym > "$TMPDIR/perf.script" > > @@ -73,59 +75,80 @@ test_user_branches() { > perf script -i "$TMPDIR/perf.data" --fields brstack | \ > tr ' ' '\n' > "$TMPDIR/perf.script" > > - # There should be no kernel addresses with the u option, in either > - # source or target addresses. > - if grep -E -m1 "0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then > - echo "ERROR: Kernel address found in user mode" > + # There should be no kernel addresses in the target with the u option. > + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[89a-f][0-9a-f]{15}/" $TMPDIR/perf.script; then > + echo "Testing user branch stack sampling [Failed kernel address found in user mode]" > err=1 > fi > # some branch types are still not being tested: > # IND COND_CALL COND_RET SYSRET SERROR NO_TX > + if [ $err -eq 0 ]; then > + echo "Testing user branch stack sampling [Passed]" > + err=$start_err > + else > + echo "Testing user branch stack sampling [Failed]" > + fi > } > > test_trap_eret_branches() { > echo "Testing trap & eret branches" > + > if ! is_arm64; then > - echo "skip: not arm64" > + echo "Testing trap & eret branches [Skipped not arm64]" > + return > + fi > + start_err=$err > + err=0 > + perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ > + perf test -w traploop 1000 > "$TMPDIR/record.txt" 2>&1 > + perf script -i $TMPDIR/perf.data --fields brstacksym | \ > + tr ' ' '\n' > $TMPDIR/perf.script > + > + # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver > + check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" > + check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" > + if [ $err -eq 0 ]; then > + echo "Testing trap & eret branches [Passed]" > + err=$start_err > else > - perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ > - perf test -w traploop 1000 > - perf script -i $TMPDIR/perf.data --fields brstacksym | \ > - tr ' ' '\n' > $TMPDIR/perf.script > - > - # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver > - check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" > - check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" > + echo "Testing trap & eret branches [Failed]" > fi > } > > test_kernel_branches() { > - echo "Testing that k option only includes kernel source addresses" > + echo "Testing kernel branch sampling" > > - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then > - echo "skip: not enough privileges" > + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then > + echo "Testing that k option [Skipped not enough privileges]" > + return > + fi > + start_err=$err > + err=0 > + perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ > + perf bench syscall basic --loop 1000 > "$TMPDIR/record.txt" 2>&1 > + perf script -i $TMPDIR/perf.data --fields brstack | \ > + tr ' ' '\n' > $TMPDIR/perf.script > + > + # Example of branch entries: > + # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." > + # Source addresses come first in user or kernel code. Next is the target > + # address that must be in the kernel. > + > + # Look for source addresses with top bit set > + if ! grep -q -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then > + echo "Testing kernel branch sampling [Failed kernel branches missing]" > + err=1 > + fi > + # Look for no target addresses without top bit set > + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[0-7][0-9a-f]{0,15}/" $TMPDIR/perf.script; then > + echo "Testing kernel branch sampling [Failed user branches found]" > + err=1 > + fi > + if [ $err -eq 0 ]; then > + echo "Testing kernel branch sampling [Passed]" > + err=$start_err > else > - perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ > - perf bench syscall basic --loop 1000 > - perf script -i $TMPDIR/perf.data --fields brstack | \ > - tr ' ' '\n' > $TMPDIR/perf.script > - > - # Example of branch entries: > - # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." > - # Source addresses come first and target address can be either > - # userspace or kernel even with k option, as long as the source > - # is in kernel. > - > - #Look for source addresses with top bit set > - if ! grep -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then > - echo "ERROR: Kernel branches missing" > - err=1 > - fi > - # Look for no source addresses without top bit set > - if grep -E -m1 "^0x[0-7][0-9a-f]{0,15}" $TMPDIR/perf.script; then > - echo "ERROR: User branches found with kernel filter" > - err=1 > - fi > + echo "Testing kernel branch sampling [Failed]" > fi > } > > @@ -136,14 +159,15 @@ test_filter() { > test_filter_expect=$2 > > echo "Testing branch stack filtering permutation ($test_filter_filter,$test_filter_expect)" > - perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 > + perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- \ > + ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 > perf script -i "$TMPDIR/perf.data" --fields brstack > "$TMPDIR/perf.script" > > # fail if we find any branch type that doesn't match any of the expected ones > # also consider UNKNOWN branch types (-) > if [ ! -s "$TMPDIR/perf.script" ] > then > - echo "Empty script output" > + echo "Testing branch stack filtering [Failed empty script output]" > err=1 > return > fi > @@ -154,26 +178,36 @@ test_filter() { > > "$TMPDIR/perf.script-filtered" || true > if [ -s "$TMPDIR/perf.script-filtered" ] > then > - echo "Unexpected branch filter in script output" > + echo "Testing branch stack filtering [Failed unexpected branch filter]" > cat "$TMPDIR/perf.script" > err=1 > return > fi > + echo "Testing branch stack filtering [Passed]" > } > > test_syscall() { > echo "Testing syscalls" > # skip if perf doesn't have enough privileges > - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then > - echo "skip: not enough privileges" > + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then > + echo "Testing syscalls [Skipped: not enough privileges]" > + return > + fi > + start_err=$err > + err=0 > + perf record -o $TMPDIR/perf.data --branch-filter \ > + any_call,save_type,u,k -c 10007 -- \ > + perf bench syscall basic --loop 8000 > "$TMPDIR/record.txt" 2>&1 > + perf script -i $TMPDIR/perf.data --fields brstacksym | \ > + tr ' ' '\n' > $TMPDIR/perf.script > + > + check_branches "getppid[^ ]*/SYSCALL/" > + > + if [ $err -eq 0 ]; then > + echo "Testing syscalls [Passed]" > + err=$start_err > else > - perf record -o $TMPDIR/perf.data --branch-filter \ > - any_call,save_type,u,k -c 10000 -- \ > - perf bench syscall basic --loop 1000 > - perf script -i $TMPDIR/perf.data --fields brstacksym | \ > - tr ' ' '\n' > $TMPDIR/perf.script > - > - check_branches "getppid[^ ]*/SYSCALL/" > + echo "Testing syscalls [Failed]" > fi > } > set -e > -- > 2.53.0.1213.gd9a14994de-goog > ^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH v3 2/2] perf python: treport add flamegraph support 2026-08-09 4:16 ` [PATCH v3 0/2] perf python TUI report and flamegraph Ian Rogers 2026-08-09 4:16 ` [PATCH v3 1/2] perf python: New treport script Ian Rogers 2026-08-09 4:16 ` [PATCH v3] perf test: Fixes for check branch stack sampling Ian Rogers @ 2026-08-09 4:16 ` Ian Rogers 2026-08-09 4:36 ` sashiko-bot 2026-08-09 5:18 ` [PATCH v4 0/2] perf python TUI report and flamegraph Ian Rogers 3 siblings, 1 reply; 24+ messages in thread From: Ian Rogers @ 2026-08-09 4:16 UTC (permalink / raw) To: irogers, acme, alice.mei.rogers, namhyung Cc: adrian.hunter, jolsa, laixintaoo, linux-kernel, linux-perf-users, mingo, peterz From: Alice Rogers <alice.mei.rogers@gmail.com> Implement a flamegraph widget that recursively walks down a tree splitting line segments based on their value (summed up periods across call chains). A visitor pattern is used so that the same logic can both draw the line segments and locate which segment had a mouse click. Add a tab for the flame graph widget. Co-developed-by: Ian Rogers <irogers@google.com> Signed-off-by: Ian Rogers <irogers@google.com> Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com> --- tools/perf/python/treport.py | 353 +++++++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py index 528a640e4d35..ec3263a6b625 100755 --- a/tools/perf/python/treport.py +++ b/tools/perf/python/treport.py @@ -1,19 +1,49 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT """treport.py - perf report like tool written using textual.""" +from abc import ABC, abstractmethod from typing import Dict, Optional import argparse import os import sys import perf +from rich.segment import Segment +from rich.style import Style +from textual import events from textual.app import App, ComposeResult from textual.binding import Binding +from textual.color import Color +from textual.scroll_view import ScrollView +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. + + Increases or decreases the length of s to be length. If the length is + increased then pad_char is inserted on the right. + """ + return s[:length] if len(s) > length else s.ljust(length, pad_char) + + +class FlameVisitor(ABC): + """Parent for visitor used by ProfileNode.flame_walk""" + @abstractmethod + def visit(self, node: Optional["ProfileNode"], width: int) -> None: + """Visit a profile node width the specified flame graph width. + + Args: + node: The `ProfileNode` for the current segment. This may be `None` + to represent a gap or an unknown portion of the stack. + width: The calculated width of the flame graph rectangle for this + node, which is proportional to its sample count. + """ + + class ProfileNode: """Represents a single node in a call stack tree. @@ -120,6 +150,327 @@ class ProfileNode: key=lambda pnode: pnode.value, reverse=True): pnode.add_to_tree(new_node, root_value) + 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 + + 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): + if child == sought: + found = True + elif found: + return child + return sought + + 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): + if child == sought: + return last if last else sought + last = child + return sought + + def has_parent(self, parent: "ProfileNode") -> bool: + """Checks if the parent node is an ancestor of this node.""" + p = self.parent + while True: + if p == parent: + return True + new_p = p.parent + if new_p == p: + break + p = new_p + return False + + def has_child(self, sought: "ProfileNode") -> bool: + """Checks if the sought node is a descendant of this node.""" + return sought.has_parent(self) + + def flame_walk(self, wanted_strip: int, cur_strip: int, parent_width: int, + selected: "ProfileNode", visitor: FlameVisitor) -> None: + """Recursively walks the tree to visit a single flame graph row. + + This method calculates the proportional width for each child + based on its value (sample count) relative to its parent. It + then invokes a `visitor` to process each segment of the flame + graph row. + + Args: + wanted_strip (int): The target depth (Y-axis) of the flame graph row + to generate. + cur_strip (int): The current depth of the traversal. + parent_width (int): The width of the parent of this node. + selected (ProfileNode): The currently selected node in the UI, used + to adjust rendering to highlight the + selected path. + visitor (FlameVisitor): A visitor object whose `visit` method is + called for each segment of the flame graph + row. + """ + if parent_width == 0: + return + + parent_selected = selected == self or self.has_parent(selected) + child_selected = not parent_selected and self.has_child(selected) + if not parent_selected and not child_selected: + # Branches of the tree with no node selected aren't drawn. + return + + # 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): + if parent_selected: + if self.value: + desired_width = int((parent_width * child.value) / self.value) + else: + desired_width = parent_width // len(self.children) + if desired_width == 0: + # Nothing can be drawn for this node or later smaller children. + break + elif child == selected or child.has_child(selected): + desired_width = parent_width + else: + # A sibling or its child are selected, but not this branch. + continue + + # Either visit the wanted_strip or recurse to the next level. + if wanted_strip == cur_strip: + visitor.visit(child, desired_width) + else: + child.flame_walk(wanted_strip, cur_strip + 1, desired_width, + selected, visitor) + left_over -= desired_width + if left_over == 0: + # No space left to draw in. + break + + # Always visit the left_over regardless of the wanted_strip as there + # may be additional gap added to a line by a parent. + if left_over: + visitor.visit(None, left_over) + + def make_flame_strip(self, wanted_strip: int, parent_width: int, + cursor: "ProfileNode", selected: "ProfileNode", + theme_variables: Dict[str, str]) -> Strip: + """Creates a renderable 'Strip' for a single row of a flame graph. + + This method orchestrates the `flame_walk` traversal with a specialized + visitor to generate a list of segments. The segments are used by a`Strip` + object for rendering in the terminal. + + Args: + wanted_strip (int): The target depth (Y-axis) of the flame graph row. + parent_width (int): The total width (in characters) of the display + area. + cursor (ProfileNode): The node currently under the cursor, for + highlighting. + selected (ProfileNode): The node that is actively selected. + theme_variables(Dict): Values of colors for the textual theme. + + Returns: + Strip: A renderable strip of segments for the specified row. + """ + primary = Color.parse(theme_variables["primary"]) + secondary = Color.parse(theme_variables["secondary"]) + surface = Color.parse(theme_variables["surface"]) + def luminance(color: Color) -> float: + """Computes the luminance of a color from the rgb""" + return color.r * 0.299 + color.g * 0.587 + color.b * 0.114 + + # Set of styles for different flamegraph segments, the styles are + # cycled through to provide contrast. + normal_styles = [] + for x in range(0, 125, 25): + fgcolor = secondary.blend(primary, x/100) + if luminance(fgcolor) > luminance(surface): + bgcolor = surface.lighten(0.05+x/500) + else: + bgcolor = surface.darken(0.05+x/500) + normal_styles.append(Style(color=fgcolor.rich_color, + bgcolor=bgcolor.rich_color)) + + # Style for the selected flame graph node. + accent = Color.parse(theme_variables["accent"]) + accent_muted = Color.parse(theme_variables["accent-muted"]) + cursor_style = Style(color=accent.rich_color, bgcolor=accent_muted.rich_color) + + class StripVisitor(FlameVisitor): + """Visitor creating textual flame graph segments. + + Attributes: + segments (list): The textual segments that will be placed in a + `Strip`. + gap_width (int): The width of any outstanding gap between the + last and next node. + ctr (int): Used to adjust the flame graph segment's color. + """ + def __init__(self): + self.segments = [] + self.gap_width = 0 + self.ctr = wanted_strip + + def visit(self, node: Optional[ProfileNode], width: int) -> None: + if node: + if self.gap_width > 0: + self.segments.append(Segment( + make_fixed_length_string(" ", self.gap_width))) + self.gap_width = 0 + style = cursor_style + if node != cursor: + style = normal_styles[self.ctr % len(normal_styles)] + self.segments.append(Segment( + make_fixed_length_string(node.name, width), style)) + else: + self.gap_width += width + self.ctr += 1 + + visitor = StripVisitor() + self.flame_walk(wanted_strip, 0, parent_width, selected, visitor) + return Strip(visitor.segments) if visitor.segments else Strip.blank(parent_width) + + def find_node(self, sought_x: int, sought_y: int, parent_width: int, + selected: "ProfileNode") -> "ProfileNode": + """Finds the ProfileNode corresponding to specific X, Y coordinates. + + This translates a mouse click on a flame graph back to the + `ProfileNode` that it represents. + + Args: + sought_x (int): The X coordinate (character column). + sought_y (int): The Y coordinate (row or depth). + parent_width (int): The total width of the display area. + selected (ProfileNode): The currently selected node, which affects + layout. + + Returns: + Optional[ProfileNode]: The node found at the coordinates, or None. + + """ + class FindVisitor(FlameVisitor): + """Visitor locating a `ProfileNode`. + + Attributes: + x (int): offset within line. + found (Optional[ProfileNode]): located node + gap_width (int): The width of any outstanding gap between the + last and next node. + ctr (int): Used to adjust the flame graph segment's color. + """ + def __init__(self): + self.x = 0 + self.found = None + + def visit(self, node: Optional[ProfileNode], width: int) -> None: + if self.x <= sought_x < self.x + width: + self.found = node + self.x += width + + visitor = FindVisitor() + self.flame_walk(sought_y, 0, parent_width, selected, visitor) + return visitor.found + + +class FlameGraph(ScrollView): + """A scrollable widget to display a flame graph from a profile. + + Attributes: + root (ProfileNode): Root of the profile tree. + cursor (ProfileNode): Currently highlighted cursor node. + selected (ProfileNode): The currently selected node for zooming. + """ + + # Define key bindings for navigating the flame graph. + # Allows movement with vim-style keys (h,j,k,l) and arrow keys. + BINDINGS = [ + Binding("j,down", "move_down", "Down", key_display="↓", + tooltip="Move cursor down to largest child"), + Binding("k,up", "move_up", "Up", key_display="↑", + tooltip="Move cursor up to parent"), + Binding("l,right", "move_right", "Right", key_display="→", + tooltip="Move cursor to the right sibling"), + Binding("h,left", "move_left", "Left", key_display="←", + tooltip="Move cursor to the left sibling"), + Binding("enter", "zoom_in", "Zoom In", + tooltip="Expand the cursor's node to be screen width"), + Binding("escape", "zoom_out", "Zoom Out", + tooltip="Zoom out to initial view."), + ] + + # Default CSS for the widget to ensure it fills its container's width. + DEFAULT_CSS = """ + FlameGraph { + width: 100%; + } + """ + + def __init__(self, root: ProfileNode, *args, **kwargs): + """Initialize the FlameGraph widget.""" + super().__init__(*args, **kwargs) + self.root = root + self.cursor = root + self.selected = root + + def action_move_down(self) -> None: + """Handle key press down.""" + self.cursor = self.cursor.largest_child() + self.refresh() + + def action_move_up(self) -> None: + """Handle key press up.""" + if self.cursor.parent != self.cursor.parent.parent: + self.cursor = self.cursor.parent + self.refresh() + + def action_move_right(self) -> None: + """Handle key press right.""" + 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) + self.refresh() + + def action_zoom_in(self) -> None: + """Handle key press zoom in.""" + self.selected = self.cursor + self.refresh() + + def action_zoom_out(self) -> None: + """Handle key press zoom out.""" + self.selected = self.root + self.refresh() + + def render_line(self, y: int) -> Strip: + """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) + + def on_mount(self) -> None: + """Set the height of the widget when it is displayed.""" + self.styles.height = self.root.depth() + + 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) + if clicked_node: + self.cursor = clicked_node + self.refresh() + class ReportApp(App): """A Textual application to display profiling data.""" @@ -168,6 +519,8 @@ class ReportApp(App): with TabbedContent(initial="report"): with TabPane("Report", id="report"): yield self.make_report_tree() + with TabPane("Flame Graph", id="flame"): + yield FlameGraph(self.root) yield Footer() -- 2.55.0.654.g21b8a5bc05-goog ^ permalink raw reply related [flat|nested] 24+ messages in thread
* Re: [PATCH v3 2/2] perf python: treport add flamegraph support 2026-08-09 4:16 ` [PATCH v3 2/2] perf python: treport add flamegraph support Ian Rogers @ 2026-08-09 4:36 ` sashiko-bot 0 siblings, 0 replies; 24+ messages in thread From: sashiko-bot @ 2026-08-09 4:36 UTC (permalink / raw) To: Ian Rogers; +Cc: linux-perf-users Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider: - [Medium] Rich markup injection vulnerability from unescaped DSO/symbol names containing brackets. -- --- Patch [2]: [PATCH v3 2/2] perf python: treport add flamegraph support --- commit 0b23e0e4520eaa617a120b39e7e4595517aedb98 Author: Alice Rogers <alice.mei.rogers@gmail.com> perf python: treport add flamegraph support Implement a flamegraph widget that recursively walks down a tree splitting line segments based on their value. A visitor pattern is used so that the same logic can both draw the line segments and locate which segment had a mouse click. Add a tab for the flame graph widget. > diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py > index 528a640e4d356..ec3263a6b6257 100755 > --- a/tools/perf/python/treport.py > +++ b/tools/perf/python/treport.py [ ... ] > @@ -120,6 +150,327 @@ class ProfileNode: > key=lambda pnode: pnode.value, reverse=True): > pnode.add_to_tree(new_node, root_value) [Severity: Medium] Will this crash the TUI application when handling typical perf traces? Looking at ProfileNode.add_to_tree(), self.name is embedded directly into a formatted string alongside rich style tags: if root_value != 0: percent = self.value / root_value * 100 label = f"{self.name} [r]{percent:.3g}%[/]" Since perf trace data heavily uses brackets for kernel threads (e.g., [kworker/0:1]), DSOs (e.g., [kernel.kallsyms]), or placeholders like [unknown], the Textual Tree widget's rich parser may intercept these as unclosed or invalid markup tags. Does this unescaped input trigger a fatal rich.errors.MarkupError when constructing the Tree widget? -- Sashiko AI review · https://sashiko.dev/#/patchset/20260809041638.2402705-1-irogers@google.com?part=2 ^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH v4 0/2] perf python TUI report and flamegraph 2026-08-09 4:16 ` [PATCH v3 0/2] perf python TUI report and flamegraph Ian Rogers ` (2 preceding siblings ...) 2026-08-09 4:16 ` [PATCH v3 2/2] perf python: treport add flamegraph support Ian Rogers @ 2026-08-09 5:18 ` Ian Rogers 2026-08-09 5:18 ` [PATCH v4 1/2] perf python: New treport script Ian Rogers 2026-08-09 5:18 ` [PATCH v4 2/2] perf python: treport add flamegraph support Ian Rogers 3 siblings, 2 replies; 24+ messages in thread From: Ian Rogers @ 2026-08-09 5:18 UTC (permalink / raw) To: irogers, acme, alice.mei.rogers, namhyung Cc: adrian.hunter, jolsa, laixintaoo, linux-kernel, linux-perf-users, mingo, peterz Implement a perf TUI using the python textual module. Using the textual framework means that in most modern terminals the mouse is supported, the color scheme can be configured, and screenshots made. The work is dependent on the extensions to the perf python module merged into perf-tools-next such as commit 88439191ad5e ("perf python: Add callchain support"). It is also dependent on the python textual module for TUI support. v4: Fix typo of profle to profile and tweak argparse command description to only mention flamegraph after that support is added (Sashiko). Sashiko notes a high severity issue because the script depends on perf Python module extensions that are already merged. This is because Sashiko is testing the changes against the perf-tools rather than perf-tools-next. Verified rich.errors.MarkupError don't occur for kernel DSOs, primarily as there is always a space after the square brackets. v3: Tweaks to commit messages (Sashiko). https://lore.kernel.org/linux-perf-users/20260809041638.2402705-1-irogers@google.com/ v2: Migrate from inbuilt perf python interpreter to perf python module. Improve the flamegraph performance. Use the textual's color scheme rather than red and white. https://lore.kernel.org/linux-perf-users/20260808065743.2328212-1-irogers@google.com/ v1: https://lore.kernel.org/linux-perf-users/20250725082425.20999-1-irogers@google.com/ Alice Rogers (2): perf python: New treport script perf python: treport add flamegraph support tools/perf/python/treport.py | 561 +++++++++++++++++++++++++++++++++++ 1 file changed, 561 insertions(+) create mode 100755 tools/perf/python/treport.py -- 2.55.0.654.g21b8a5bc05-goog ^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH v4 1/2] perf python: New treport script 2026-08-09 5:18 ` [PATCH v4 0/2] perf python TUI report and flamegraph Ian Rogers @ 2026-08-09 5:18 ` Ian Rogers 2026-08-09 5:18 ` [PATCH v4 2/2] perf python: treport add flamegraph support Ian Rogers 1 sibling, 0 replies; 24+ messages in thread From: Ian Rogers @ 2026-08-09 5:18 UTC (permalink / raw) To: irogers, acme, alice.mei.rogers, namhyung Cc: adrian.hunter, jolsa, laixintaoo, linux-kernel, linux-perf-users, mingo, peterz From: Alice Rogers <alice.mei.rogers@gmail.com> A textual app that displays the results of processing samples similar to perf report. The app displays a tree of first processes and then functions which drop down to show more detail on the functions they call. The functions with the largest number of samples are sorted first, after each function the percentage of time spent within it is highlighted. If more than one event is recorded then each event appears first, with the processes and functions sorted for that event beneath it. The app is written in python and requires the textual framework. Using the textual framework means that in most modern terminals the mouse is supported, the color scheme can be configured, and screenshots made. With perf report: ``` $ perf record -e cycles:u -g perf test -w brstack [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.008 MB perf.data (57 samples) ] $ perf report ... Samples: 57 of event 'cycles:u', Event count (approx.): 15907831 Children Self Command Shared Object Symbol - 88.86% 0.00% perf libc.so.6 [.] 0x000079b17df69ca8 0x79b17df69ca8 main handle_internal_command cmd_test - brstack - 84.27% brstack_bench - 40.69% brstack_foo brstack_bar 3.32% brstack_bar 3.31% brstack_bar 1.27% brstack_foo + 88.86% 0.00% perf perf [.] main + 88.86% 0.00% perf perf [.] handle_internal_command + 88.86% 0.00% perf perf [.] cmd_test + 88.86% 0.00% perf perf [.] brstack + 84.27% 40.26% perf perf [.] brstack_bench + 41.96% 30.71% perf perf [.] brstack_foo + 17.89% 17.89% perf perf [.] brstack_bar ``` With the treport script: ``` $ perf record -e cycles:u -g perf test -w brstack [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.008 MB perf.data (57 samples) ] $ tools/perf/python/treport.py O ReportApp Report ╸━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ▼ Profile └── ▼ cycles:u 100% └── ▼ perf (32963) 100% ├── ▼ /usr/lib/x86_64-linux-gnu/libc.so.6 0x79b17df6 │ └── ▼ main 56.1% │ └── ▼ handle_internal_command 56.1% │ └── ▼ cmd_test 56.1% │ └── ▼ brstack 56.1% ▁▁ │ ├── ▼ brstack_bench 52.6% │ │ ├── ▶ brstack_foo 24.6% │ │ └── brstack_bar 1.75% │ ├── brstack_foo 1.75% │ └── brstack_bar 1.75% ├── ▶ /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 ▌ ^q Quit ▏^p palette ``` Co-developed-by: Ian Rogers <irogers@google.com> Signed-off-by: Ian Rogers <irogers@google.com> Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com> --- tools/perf/python/treport.py | 207 +++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100755 tools/perf/python/treport.py diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py new file mode 100755 index 000000000000..71a91775bd6a --- /dev/null +++ b/tools/perf/python/treport.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""treport.py - perf report like tool written using textual.""" +from typing import Dict, Optional +import argparse +import os +import sys +import perf +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree +from textual.widgets.tree import TreeNode + +# Global session. +session :Optional[perf.session] = None + +class ProfileNode: + """Represents a single node in a call stack tree. + + Generally a ProfileNode corresponds to a symbol in a call stack. + The root is special, its children are events and the events + children are process names. After the process name come the + samples. + + Attributes: + name (str): The name of the function, process or event. + value (int): The sample count for this node including counts from its + children. + parent (ProfileNode): The parent of this node, this node belongs to its + children. + children (Dict[str, ProfileNode]): A dictionary of child nodes, keyed by + their names. + """ + def __init__(self, name: str, parent: "ProfileNode"): + """Initializes a ProfileNode.""" + self.name = name + self.value: int = 0 + self.parent = parent if parent else self + self.children: Dict[str, ProfileNode] = {} + + def find_or_create_node(self, name: str) -> "ProfileNode": + """Finds a child node by name or creates it if it doesn't exist.""" + if name in self.children: + return self.children[name] + child = ProfileNode(name, self) + self.children[name] = child + return child + + def depth(self) -> int: + """The maximum depth of the call stack tree from this node down.""" + if not self.children: + return 1 + return max(child.depth() for child in self.children.values()) + 1 + + def process_event(self, sample) -> None: + """Processes a single profiling event to update the call stack tree. + + Args: + sample: a single profiling sample. + """ + pid = sample.sample_pid + try: + assert session + thread = session.find_thread(sample.sample_tid) + comm = thread.comm() + except Exception: + comm = f"unknown ({pid})" + + period = sample.sample_period + self.value += period + + node = self.find_or_create_node(comm) + node.value += period + + if sample.callchain: + for entry in reversed(sample.callchain): + name = entry.symbol + if not name or name == "[unknown]": + name = entry.dso or "unknown" + if entry.ip: + name += f" 0x{entry.ip:x}" + node = node.find_or_create_node(name) + node.value += period + else: + name = sample.symbol + if not name or name == "[unknown]": + name = sample.dso or "unknown" + if sample.sample_ip: + name += f" 0x{sample.sample_ip:x}" + 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. + + Args: + node (TreeNode): The textual `TreeNode` object to which this + ProfileNode should be added. + root_value (int): Value at the root of the tree. + """ + if root_value == 0: + root_value = self.value + + # 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 + + # 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) + + +class ReportApp(App): + """A Textual application to display profiling data.""" + + # The ^q binding is implied but having it here adds it in the Footer. + BINDINGS = [ + Binding(key="^q", action="quit", description="Quit", + tooltip="Quit the app"), + ] + + def __init__(self, root: ProfileNode): + """Initialize the application.""" + super().__init__() + self.root = root + + 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 + + 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 Footer() + + +class ProfileBuilder: + """Constructs a profile tree from a stream of events.""" + def __init__(self): + self.root = ProfileNode("root", parent=None) + + def process_event(self, sample) -> None: + """Called by session.process_events to update the profile tree.""" + ev_name = str(sample.evsel)[6:-1] + ev_root = self.root.find_or_create_node(ev_name) + ev_root.process_event(sample) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="TUI report 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 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 Exception as e: + print(f"Error opening session: {e}", file=sys.stderr) + sys.exit(1) + + # profile.process_event is called for each perf event to build the profile. + session.process_events() + + # Visualize data. + app = ReportApp(profile.root) + app.run() -- 2.55.0.654.g21b8a5bc05-goog ^ permalink raw reply related [flat|nested] 24+ messages in thread
* [PATCH v4 2/2] perf python: treport add flamegraph support 2026-08-09 5:18 ` [PATCH v4 0/2] perf python TUI report and flamegraph Ian Rogers 2026-08-09 5:18 ` [PATCH v4 1/2] perf python: New treport script Ian Rogers @ 2026-08-09 5:18 ` Ian Rogers 1 sibling, 0 replies; 24+ messages in thread From: Ian Rogers @ 2026-08-09 5:18 UTC (permalink / raw) To: irogers, acme, alice.mei.rogers, namhyung Cc: adrian.hunter, jolsa, laixintaoo, linux-kernel, linux-perf-users, mingo, peterz From: Alice Rogers <alice.mei.rogers@gmail.com> Implement a flamegraph widget that recursively walks down a tree splitting line segments based on their value (summed up periods across call chains). A visitor pattern is used so that the same logic can both draw the line segments and locate which segment had a mouse click. Add a tab for the flame graph widget. Co-developed-by: Ian Rogers <irogers@google.com> Signed-off-by: Ian Rogers <irogers@google.com> Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com> --- tools/perf/python/treport.py | 356 ++++++++++++++++++++++++++++++++++- 1 file changed, 355 insertions(+), 1 deletion(-) diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py index 71a91775bd6a..43542599a884 100755 --- a/tools/perf/python/treport.py +++ b/tools/perf/python/treport.py @@ -1,19 +1,49 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT """treport.py - perf report like tool written using textual.""" +from abc import ABC, abstractmethod from typing import Dict, Optional import argparse import os import sys import perf +from rich.segment import Segment +from rich.style import Style +from textual import events from textual.app import App, ComposeResult from textual.binding import Binding +from textual.color import Color +from textual.scroll_view import ScrollView +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. + + Increases or decreases the length of s to be length. If the length is + increased then pad_char is inserted on the right. + """ + return s[:length] if len(s) > length else s.ljust(length, pad_char) + + +class FlameVisitor(ABC): + """Parent for visitor used by ProfileNode.flame_walk""" + @abstractmethod + def visit(self, node: Optional["ProfileNode"], width: int) -> None: + """Visit a profile node width the specified flame graph width. + + Args: + node: The `ProfileNode` for the current segment. This may be `None` + to represent a gap or an unknown portion of the stack. + width: The calculated width of the flame graph rectangle for this + node, which is proportional to its sample count. + """ + + class ProfileNode: """Represents a single node in a call stack tree. @@ -120,6 +150,327 @@ class ProfileNode: key=lambda pnode: pnode.value, reverse=True): pnode.add_to_tree(new_node, root_value) + 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 + + 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): + if child == sought: + found = True + elif found: + return child + return sought + + 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): + if child == sought: + return last if last else sought + last = child + return sought + + def has_parent(self, parent: "ProfileNode") -> bool: + """Checks if the parent node is an ancestor of this node.""" + p = self.parent + while True: + if p == parent: + return True + new_p = p.parent + if new_p == p: + break + p = new_p + return False + + def has_child(self, sought: "ProfileNode") -> bool: + """Checks if the sought node is a descendant of this node.""" + return sought.has_parent(self) + + def flame_walk(self, wanted_strip: int, cur_strip: int, parent_width: int, + selected: "ProfileNode", visitor: FlameVisitor) -> None: + """Recursively walks the tree to visit a single flame graph row. + + This method calculates the proportional width for each child + based on its value (sample count) relative to its parent. It + then invokes a `visitor` to process each segment of the flame + graph row. + + Args: + wanted_strip (int): The target depth (Y-axis) of the flame graph row + to generate. + cur_strip (int): The current depth of the traversal. + parent_width (int): The width of the parent of this node. + selected (ProfileNode): The currently selected node in the UI, used + to adjust rendering to highlight the + selected path. + visitor (FlameVisitor): A visitor object whose `visit` method is + called for each segment of the flame graph + row. + """ + if parent_width == 0: + return + + parent_selected = selected == self or self.has_parent(selected) + child_selected = not parent_selected and self.has_child(selected) + if not parent_selected and not child_selected: + # Branches of the tree with no node selected aren't drawn. + return + + # 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): + if parent_selected: + if self.value: + desired_width = int((parent_width * child.value) / self.value) + else: + desired_width = parent_width // len(self.children) + if desired_width == 0: + # Nothing can be drawn for this node or later smaller children. + break + elif child == selected or child.has_child(selected): + desired_width = parent_width + else: + # A sibling or its child are selected, but not this branch. + continue + + # Either visit the wanted_strip or recurse to the next level. + if wanted_strip == cur_strip: + visitor.visit(child, desired_width) + else: + child.flame_walk(wanted_strip, cur_strip + 1, desired_width, + selected, visitor) + left_over -= desired_width + if left_over == 0: + # No space left to draw in. + break + + # Always visit the left_over regardless of the wanted_strip as there + # may be additional gap added to a line by a parent. + if left_over: + visitor.visit(None, left_over) + + def make_flame_strip(self, wanted_strip: int, parent_width: int, + cursor: "ProfileNode", selected: "ProfileNode", + theme_variables: Dict[str, str]) -> Strip: + """Creates a renderable 'Strip' for a single row of a flame graph. + + This method orchestrates the `flame_walk` traversal with a specialized + visitor to generate a list of segments. The segments are used by a`Strip` + object for rendering in the terminal. + + Args: + wanted_strip (int): The target depth (Y-axis) of the flame graph row. + parent_width (int): The total width (in characters) of the display + area. + cursor (ProfileNode): The node currently under the cursor, for + highlighting. + selected (ProfileNode): The node that is actively selected. + theme_variables(Dict): Values of colors for the textual theme. + + Returns: + Strip: A renderable strip of segments for the specified row. + """ + primary = Color.parse(theme_variables["primary"]) + secondary = Color.parse(theme_variables["secondary"]) + surface = Color.parse(theme_variables["surface"]) + def luminance(color: Color) -> float: + """Computes the luminance of a color from the rgb""" + return color.r * 0.299 + color.g * 0.587 + color.b * 0.114 + + # Set of styles for different flamegraph segments, the styles are + # cycled through to provide contrast. + normal_styles = [] + for x in range(0, 125, 25): + fgcolor = secondary.blend(primary, x/100) + if luminance(fgcolor) > luminance(surface): + bgcolor = surface.lighten(0.05+x/500) + else: + bgcolor = surface.darken(0.05+x/500) + normal_styles.append(Style(color=fgcolor.rich_color, + bgcolor=bgcolor.rich_color)) + + # Style for the selected flame graph node. + accent = Color.parse(theme_variables["accent"]) + accent_muted = Color.parse(theme_variables["accent-muted"]) + cursor_style = Style(color=accent.rich_color, bgcolor=accent_muted.rich_color) + + class StripVisitor(FlameVisitor): + """Visitor creating textual flame graph segments. + + Attributes: + segments (list): The textual segments that will be placed in a + `Strip`. + gap_width (int): The width of any outstanding gap between the + last and next node. + ctr (int): Used to adjust the flame graph segment's color. + """ + def __init__(self): + self.segments = [] + self.gap_width = 0 + self.ctr = wanted_strip + + def visit(self, node: Optional[ProfileNode], width: int) -> None: + if node: + if self.gap_width > 0: + self.segments.append(Segment( + make_fixed_length_string(" ", self.gap_width))) + self.gap_width = 0 + style = cursor_style + if node != cursor: + style = normal_styles[self.ctr % len(normal_styles)] + self.segments.append(Segment( + make_fixed_length_string(node.name, width), style)) + else: + self.gap_width += width + self.ctr += 1 + + visitor = StripVisitor() + self.flame_walk(wanted_strip, 0, parent_width, selected, visitor) + return Strip(visitor.segments) if visitor.segments else Strip.blank(parent_width) + + def find_node(self, sought_x: int, sought_y: int, parent_width: int, + selected: "ProfileNode") -> "ProfileNode": + """Finds the ProfileNode corresponding to specific X, Y coordinates. + + This translates a mouse click on a flame graph back to the + `ProfileNode` that it represents. + + Args: + sought_x (int): The X coordinate (character column). + sought_y (int): The Y coordinate (row or depth). + parent_width (int): The total width of the display area. + selected (ProfileNode): The currently selected node, which affects + layout. + + Returns: + Optional[ProfileNode]: The node found at the coordinates, or None. + + """ + class FindVisitor(FlameVisitor): + """Visitor locating a `ProfileNode`. + + Attributes: + x (int): offset within line. + found (Optional[ProfileNode]): located node + gap_width (int): The width of any outstanding gap between the + last and next node. + ctr (int): Used to adjust the flame graph segment's color. + """ + def __init__(self): + self.x = 0 + self.found = None + + def visit(self, node: Optional[ProfileNode], width: int) -> None: + if self.x <= sought_x < self.x + width: + self.found = node + self.x += width + + visitor = FindVisitor() + self.flame_walk(sought_y, 0, parent_width, selected, visitor) + return visitor.found + + +class FlameGraph(ScrollView): + """A scrollable widget to display a flame graph from a profile. + + Attributes: + root (ProfileNode): Root of the profile tree. + cursor (ProfileNode): Currently highlighted cursor node. + selected (ProfileNode): The currently selected node for zooming. + """ + + # Define key bindings for navigating the flame graph. + # Allows movement with vim-style keys (h,j,k,l) and arrow keys. + BINDINGS = [ + Binding("j,down", "move_down", "Down", key_display="↓", + tooltip="Move cursor down to largest child"), + Binding("k,up", "move_up", "Up", key_display="↑", + tooltip="Move cursor up to parent"), + Binding("l,right", "move_right", "Right", key_display="→", + tooltip="Move cursor to the right sibling"), + Binding("h,left", "move_left", "Left", key_display="←", + tooltip="Move cursor to the left sibling"), + Binding("enter", "zoom_in", "Zoom In", + tooltip="Expand the cursor's node to be screen width"), + Binding("escape", "zoom_out", "Zoom Out", + tooltip="Zoom out to initial view."), + ] + + # Default CSS for the widget to ensure it fills its container's width. + DEFAULT_CSS = """ + FlameGraph { + width: 100%; + } + """ + + def __init__(self, root: ProfileNode, *args, **kwargs): + """Initialize the FlameGraph widget.""" + super().__init__(*args, **kwargs) + self.root = root + self.cursor = root + self.selected = root + + def action_move_down(self) -> None: + """Handle key press down.""" + self.cursor = self.cursor.largest_child() + self.refresh() + + def action_move_up(self) -> None: + """Handle key press up.""" + if self.cursor.parent != self.cursor.parent.parent: + self.cursor = self.cursor.parent + self.refresh() + + def action_move_right(self) -> None: + """Handle key press right.""" + 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) + self.refresh() + + def action_zoom_in(self) -> None: + """Handle key press zoom in.""" + self.selected = self.cursor + self.refresh() + + def action_zoom_out(self) -> None: + """Handle key press zoom out.""" + self.selected = self.root + self.refresh() + + def render_line(self, y: int) -> Strip: + """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) + + def on_mount(self) -> None: + """Set the height of the widget when it is displayed.""" + self.styles.height = self.root.depth() + + 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) + if clicked_node: + self.cursor = clicked_node + self.refresh() + class ReportApp(App): """A Textual application to display profiling data.""" @@ -168,6 +519,8 @@ class ReportApp(App): with TabbedContent(initial="report"): with TabPane("Report", id="report"): yield self.make_report_tree() + with TabPane("Flame Graph", id="flame"): + yield FlameGraph(self.root) yield Footer() @@ -183,7 +536,8 @@ class ProfileBuilder: ev_root.process_event(sample) if __name__ == "__main__": - parser = argparse.ArgumentParser(description="TUI report using perf python module.") + 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() -- 2.55.0.654.g21b8a5bc05-goog ^ permalink raw reply related [flat|nested] 24+ messages in thread
* Re: [PATCH v1 2/2] perf script: treport add flamegraph support 2025-07-25 8:24 ` [PATCH v1 2/2] perf script: treport add flamegraph support Ian Rogers 2025-07-25 8:38 ` Ian Rogers @ 2025-07-26 6:43 ` Namhyung Kim 1 sibling, 0 replies; 24+ messages in thread From: Namhyung Kim @ 2025-07-26 6:43 UTC (permalink / raw) To: Ian Rogers Cc: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo, Mark Rutland, Alexander Shishkin, Jiri Olsa, Adrian Hunter, Kan Liang, Alice Rogers, linux-kernel, linux-perf-users On Fri, Jul 25, 2025 at 01:24:05AM -0700, Ian Rogers wrote: > From: Alice Rogers <alice.mei.rogers@gmail.com> > > Implement a flamegraph widget that recursively walks down a tree > splitting line segments based on their value (summed up periods across > call chains). A visitor pattern is used so that the same logic can > both draw the line segments and locate which segment had a mouse > click. > > Add a tab for the flame graph widget. Great! Having flame-graphs in a terminal would be convenient. I'm curious if it supports zoom in/out for a selected entry. Thanks, Namhyung > > Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com> > Co-developed-by: Ian Rogers <irogers@google.com> > Signed-off-by: Ian Rogers <irogers@google.com> > --- > tools/perf/scripts/python/treport.py | 342 ++++++++++++++++++++++++++- > 1 file changed, 341 insertions(+), 1 deletion(-) > > diff --git a/tools/perf/scripts/python/treport.py b/tools/perf/scripts/python/treport.py > index fd1ca79efdad..fd43a3dbe1c2 100644 > --- a/tools/perf/scripts/python/treport.py > +++ b/tools/perf/scripts/python/treport.py > @@ -1,10 +1,40 @@ > # treport.py - perf report like tool written using textual > # SPDX-License-Identifier: MIT > +from abc import ABC, abstractmethod > +from rich.segment import Segment > +from rich.style import Style > +from textual import events > from textual.app import App, ComposeResult > from textual.binding import Binding > +from textual.color import Color > +from textual.strip import Strip > from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree > from textual.widgets.tree import TreeNode > -from typing import Dict > +from textual.scroll_view import ScrollView > +from typing import Dict, Optional > + > +def make_fixed_length_string(s: str, length: int, pad_char=' '): > + """Make the string s a fixed length. > + > + Increases or decreases the length of s to be length. If the length is > + increased then pad_char is inserted on the right. > + """ > + return s[:length] if len(s) > length else s.ljust(length, pad_char) > + > + > +class FlameVisitor(ABC): > + """Parent for visitor used by ProfileNode.flame_walk""" > + @abstractmethod > + def visit(self, node: Optional["ProfileNode"], width: int) -> None: > + """Visit a profile node width the specified flame graph width. > + > + Args: > + node: The `ProfileNode` for the current segment. This may be `None` > + to represent a gap or an unknown portion of the stack. > + width: The calculated width of the flame graph rectangle for this > + node, which is proportional to its sample count. > + """ > + > > class ProfileNode: > """Represents a single node in a call stack tree. > @@ -118,6 +148,314 @@ class ProfileNode: > key=lambda pnode: pnode.value, reverse=True): > pnode.add_to_tree(new_node, root_value) > > + 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 > + > + 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): > + if child == sought: > + found = True > + elif found: > + return child > + return sought > + > + 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): > + if child == sought: > + return last if last else sought > + last = child > + return sought > + > + def has_child(self, sought: "ProfileNode") -> bool: > + """Checks if the sought node is a descendant of this node.""" > + for child in self.children.values(): > + if child == sought or child.has_child(sought): > + return True > + return False > + > + def has_parent(self, parent: "ProfileNode") -> bool: > + """Checks if the parent node is an ancestor of this node.""" > + p = self.parent > + while True: > + if p == parent: > + return True > + new_p = p.parent > + if new_p == p: > + break > + p = new_p > + return False > + > + def flame_walk(self, wanted_strip: int, cur_strip: int, parent_width: int, > + selected: "ProfileNode", visitor: FlameVisitor) -> None: > + """Recursively walks the tree to visit a single flame graph row. > + > + This method calculates the proportional width for each child > + based on its value (sample count) relative to its parent. It > + then invokes a `visitor` to process each segment of the flame > + graph row. > + > + Args: > + wanted_strip (int): The target depth (Y-axis) of the flame graph row > + to generate. > + cur_strip (int): The current depth of the traversal. > + parent_width (int): The width of the parent of this node. > + selected (ProfileNode): The currently selected node in the UI, used > + to adjust rendering to highlight the > + selected path. > + visitor (FlameVisitor): A visitor object whose `visit` method is > + called for each segment of the flame graph > + row. > + """ > + if parent_width == 0: > + return > + > + parent_selected = selected == self or self.has_parent(selected) > + child_selected = not parent_selected and self.has_child(selected) > + if not parent_selected and not child_selected: > + # Branches of the tree with no node selected aren't drawn. > + return > + > + # 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): > + if parent_selected: > + if self.value: > + desired_width = int((parent_width * child.value) / self.value) > + else: > + desired_width = parent_width // len(self.children) > + if desired_width == 0: > + # Nothing can be drawn for this node or later smaller children. > + break > + elif child == selected or child.has_child(selected): > + desired_width = parent_width > + else: > + # A sibling or its child are selected, but not this branch. > + continue > + > + # Either visit the wanted_strip or recurse to the next level. > + if wanted_strip == cur_strip: > + visitor.visit(child, desired_width) > + else: > + child.flame_walk(wanted_strip, cur_strip + 1, desired_width, > + selected, visitor) > + left_over -= desired_width > + if left_over == 0: > + # No space left to draw in. > + break > + > + # Always visit the left_over regardless of the wanted_strip as there > + # may be additional gap added to a line by a parent. > + if left_over: > + visitor.visit(None, left_over) > + > + def make_flame_strip(self, wanted_strip: int, parent_width: int, > + cursor: "ProfileNode", selected: "ProfileNode") -> Strip: > + """Creates a renderable 'Strip' for a single row of a flame graph. > + > + This method orchestrates the `flame_walk` traversal with a specialized > + visitor to generate a list of segments. The segments are used by a`Strip` > + object for rendering in the terminal. > + > + Args: > + wanted_strip (int): The target depth (Y-axis) of the flame graph row. > + parent_width (int): The total width (in characters) of the display > + area. > + cursor (ProfileNode): The node currently under the cursor, for > + highlighting. > + selected (ProfileNode): The node that is actively selected. > + > + Returns: > + Strip: A renderable strip of segments for the specified row. > + """ > + black = Color.parse("#000000") > + # Non-cursor values range from red up to white. > + normal_styles = [ > + Style(color=black.rich_color, bgcolor=Color(255, x, x).rich_color > + ) for x in range(0, 220, 25) > + ] > + # Cursor is red text with a black background. > + cursor_style = Style(color=Color.parse("#ff0000").rich_color, > + bgcolor=black.rich_color) > + > + class StripVisitor(FlameVisitor): > + """Visitor creating textual flame graph segments. > + > + Attributes: > + segments (list): The textual segments that will be placed in a > + `Strip`. > + gap_width (int): The width of any outstanding gap between the > + last and next node. > + ctr (int): Used to adjust the flame graph segment's color. > + """ > + def __init__(self): > + self.segments = [] > + self.gap_width = 0 > + self.ctr = wanted_strip > + > + def visit(self, node: Optional[ProfileNode], width: int) -> None: > + if node: > + if self.gap_width > 0: > + self.segments.append(Segment( > + make_fixed_length_string(" ", self.gap_width))) > + self.gap_width = 0 > + style = cursor_style > + if node != cursor: > + style = normal_styles[self.ctr % len(normal_styles)] > + self.segments.append(Segment( > + make_fixed_length_string(node.name, width), style)) > + else: > + self.gap_width += width > + self.ctr += 1 > + > + visitor = StripVisitor() > + self.flame_walk(wanted_strip, 0, parent_width, selected, visitor) > + return Strip(visitor.segments) if visitor.segments else Strip.blank(parent_width) > + > + def find_node(self, sought_x: int, sought_y: int, parent_width: int, > + selected: "ProfileNode") -> "ProfileNode": > + """Finds the ProfileNode corresponding to specific X, Y coordinates. > + > + This translates a mouse click on a flame graph back to the > + `ProfileNode` that it represents. > + > + Args: > + sought_x (int): The X coordinate (character column). > + sought_y (int): The Y coordinate (row or depth). > + parent_width (int): The total width of the display area. > + selected (ProfileNode): The currently selected node, which affects > + layout. > + > + Returns: > + Optional[ProfileNode]: The node found at the coordinates, or None. > + > + """ > + class FindVisitor(FlameVisitor): > + """Visitor locating a `ProfileNode`. > + > + Attributes: > + x (int): offset within line. > + found (Optional[ProfileNode]): located node > + gap_width (int): The width of any outstanding gap between the > + last and next node. > + ctr (int): Used to adjust the flame graph segment's color. > + """ > + def __init__(self): > + self.x = 0 > + self.found = None > + > + def visit(self, node: Optional[ProfileNode], width: int) -> None: > + if self.x <= sought_x and sought_x < self.x + width: > + self.found = node > + self.x += width > + > + visitor = FindVisitor() > + self.flame_walk(sought_y, 0, parent_width, selected, visitor) > + return visitor.found > + > + > +class FlameGraph(ScrollView): > + """A scrollable widget to display a flame graph from a profile. > + > + Attributes: > + root (ProfileNode): Root of the profile tree. > + cursor (ProfileNode): Currently highlighted cursor node. > + selected (ProfileNode): The currently selected node for zooming. > + """ > + > + # Define key bindings for navigating the flame graph. > + # Allows movement with vim-style keys (h,j,k,l) and arrow keys. > + BINDINGS = [ > + Binding("j,down", "move_down", "Down", key_display="↓", > + tooltip="Move cursor down to largest child"), > + Binding("k,up", "move_up", "Up", key_display="↑", > + tooltip="Move cursor up to parent"), > + Binding("l,right", "move_right", "Right", key_display="→", > + tooltip="Move cursor to the right sibling"), > + Binding("h,left", "move_left", "Left", key_display="←", > + tooltip="Move cursor to the left sibling"), > + Binding("enter", "zoom_in", "Zoom In", > + tooltip="Expand the cursor's node to be screen width"), > + Binding("escape", "zoom_out", "Zoom Out", > + tooltip="Zoom out to initial view."), > + ] > + > + # Default CSS for the widget to ensure it fills its container's width. > + DEFAULT_CSS = """ > + FlameGraph { > + width: 100%; > + } > + """ > + > + def __init__(self, root: ProfileNode, *args, **kwargs): > + """Initialize the FlameGraph widget.""" > + super().__init__(*args, **kwargs) > + self.root = root > + self.cursor = root > + self.selected = root > + > + def action_move_down(self) -> None: > + """Handle key press down.""" > + self.cursor = self.cursor.largest_child() > + self.refresh() > + > + def action_move_up(self) -> None: > + """Handle key press up.""" > + if self.cursor.parent != self.cursor.parent.parent: > + self.cursor = self.cursor.parent > + self.refresh() > + > + def action_move_right(self) -> None: > + """Handle key press right.""" > + 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) > + self.refresh() > + > + def action_zoom_in(self) -> None: > + """Handle key press zoom in.""" > + self.selected = self.cursor > + self.refresh() > + > + def action_zoom_out(self) -> None: > + """Handle key press zoom out.""" > + self.selected = self.root > + self.refresh() > + > + def render_line(self, y: int) -> Strip: > + """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) > + > + def on_mount(self) -> None: > + """Set the height of the widget when it is displayed.""" > + self.styles.height = self.root.depth() > + > + 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) > + if clicked_node: > + self.cursor = clicked_node > + self.refresh() > + > > class ReportApp(App): > """A Textual application to display profiling data.""" > @@ -152,6 +490,8 @@ class ReportApp(App): > with TabbedContent(initial="report"): > with TabPane("Report", id="report"): > yield self.make_report_tree() > + with TabPane("Flame Graph", id="flame"): > + yield FlameGraph(self.root) > yield Footer() > > > -- > 2.50.1.552.g942d659e1b-goog > ^ permalink raw reply [flat|nested] 24+ messages in thread
* Re: [PATCH v1 1/2] perf script: New treport script 2025-07-25 8:24 [PATCH v1 1/2] perf script: New treport script Ian Rogers 2025-07-25 8:24 ` [PATCH v1 2/2] perf script: treport add flamegraph support Ian Rogers @ 2025-07-26 6:39 ` Namhyung Kim 1 sibling, 0 replies; 24+ messages in thread From: Namhyung Kim @ 2025-07-26 6:39 UTC (permalink / raw) To: Ian Rogers Cc: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo, Mark Rutland, Alexander Shishkin, Jiri Olsa, Adrian Hunter, Kan Liang, Alice Rogers, linux-kernel, linux-perf-users On Fri, Jul 25, 2025 at 01:24:04AM -0700, Ian Rogers wrote: > From: Alice Rogers <alice.mei.rogers@gmail.com> Hello Alice, thanks for your contribution! > > A textual app that displays the results of processing samples similar > to perf report. The app displays a tree of first processed and then > functions which drop down to show more detail on the functions they > call. The functions with the largest number of samples are sorted > first, after each function the percentage of time spent within it is > highlighted. Can you please tell us how to run this script and what's needed to run it? Note that some people might not have textual in the system. How will it work in that case? Also please add some example output in the commit message. It'd be great if you can compare it with perf report output. Thanks, Namhyung > > Signed-off-by: Alice Rogers <alice.mei.rogers@gmail.com> > Co-developed-by: Ian Rogers <irogers@google.com> > Signed-off-by: Ian Rogers <irogers@google.com> > --- > tools/perf/scripts/python/treport.py | 177 +++++++++++++++++++++++++++ > 1 file changed, 177 insertions(+) > create mode 100644 tools/perf/scripts/python/treport.py > > diff --git a/tools/perf/scripts/python/treport.py b/tools/perf/scripts/python/treport.py > new file mode 100644 > index 000000000000..fd1ca79efdad > --- /dev/null > +++ b/tools/perf/scripts/python/treport.py > @@ -0,0 +1,177 @@ > +# treport.py - perf report like tool written using textual > +# SPDX-License-Identifier: MIT > +from textual.app import App, ComposeResult > +from textual.binding import Binding > +from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree > +from textual.widgets.tree import TreeNode > +from typing import Dict > + > +class ProfileNode: > + """Represents a single node in a call stack tree. > + > + Generally a ProfileNode corresponds to a symbol in a call stack. > + The root is special, its children are events and the events > + children are process names. After the process name come the > + samples. > + > + Attributes: > + name (str): The name of the function, process or event. > + value (int): The sample count for this node including counts from its > + children. > + parent (ProfileNode): The parent of this node, this node belongs to its > + children. > + children (Dict[str, ProfileNode]): A dictionary of child nodes, keyed by > + their names. > + """ > + def __init__(self, name: str, parent: "ProfileNode"): > + """Initializes a ProfileNode.""" > + self.name = name > + self.value: int = 0 > + self.parent = parent if parent else self > + self.children: Dict[str, ProfileNode] = {} > + > + def find_or_create_node(self, name: str) -> "ProfileNode": > + """Finds a child node by name or creates it if it doesn't exist.""" > + if name in self.children: > + return self.children[name] > + child = ProfileNode(name, self) > + self.children[name] = child > + return child > + > + def depth(self) -> int: > + """The maximum depth of the call stack tree from this node down.""" > + if not self.children: > + return 1 > + return max([child.depth() for child in self.children.values()]) + 1 > + > + def process_event(self, event: Dict) -> None: > + """Processes a single profiling event to update the call stack tree. > + > + Args: > + event (Dict): A dictionary representing a single profiling sample, > + expected to contain keys like 'comm', 'pid', 'period', > + and 'callchain'. > + """ > + pid = 0 > + if "sample" in event and "pid" in event["sample"]: > + pid = event["sample"]["pid"] > + > + if pid == 0: > + comm = event.get("comm", "kernel") > + else: > + comm = f"{event.get('comm', 'unknown')} ({pid})" > + > + period = int(event["period"]) if 'period' in event else 1 > + self.value += period > + > + node = self.find_or_create_node(comm) > + node.value += period > + > + if "callchain" in event: > + for entry in reversed(event["callchain"]): > + sym = entry.get("sym") > + name = None > + if sym: > + name = sym.get("name") > + if not name: > + name = entry.get("dso", "unknown") > + if "ip" in entry: > + name += f" 0x{entry['ip']:x}" > + node = node.find_or_create_node(name) > + node.value += period > + else: > + name = event.get("symbol") > + if not name: > + name = event.get("dso", "unknown") > + if "ip" in event: > + name += f" 0x{event['ip']:x}" > + 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. > + > + Args: > + node (TreeNode): The textual `TreeNode` object to which this > + ProfileNode should be added. > + root_value (int): Value at the root of the tree. > + """ > + if root_value == 0: > + root_value = self.value > + > + # 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 > + > + # 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) > + > + > +class ReportApp(App): > + """A Textual application to display profiling data.""" > + > + # The ^q binding is implied but having it here adds it in the Footer. > + BINDINGS = [ > + Binding(key="^q", action="quit", description="Quit", > + tooltip="Quit the app"), > + ] > + > + def __init__(self, root: ProfileNode): > + """Initialize the application.""" > + super().__init__() > + self.root = root > + > + 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 first 2 levels of the tree. > + tree.root.expand() > + for tnode in tree.root.children: > + tnode.expand() > + return tree > + > + 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 Footer() > + > + > +class ProfileBuilder: > + """Constructs a profile tree from a stream of events.""" > + def __init__(self): > + self.root = ProfileNode("root", parent=None) > + > + def process_event(self, event) -> None: > + """Called by `perf script` to update the profile tree.""" > + ev_name = event.get("ev_name", "default") > + ev_root = self.root.find_or_create_node(ev_name) > + ev_root.process_event(event) > + > +if __name__ == "__main__": > + # process_event is called for each perf event to build the profile. > + profile = ProfileBuilder() > + process_event = profile.process_event > + # trace_end will run the application, this can't be done > + # concurrently as perf expects to be the main thread as does > + # Textual. > + app = ReportApp(profile.root) > + trace_end = app.run > -- > 2.50.1.552.g942d659e1b-goog > ^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH v2] perf test: Fixes for check branch stack sampling @ 2026-04-08 6:20 Ian Rogers 2026-04-08 6:58 ` [PATCH v3] " Ian Rogers 0 siblings, 1 reply; 24+ messages in thread From: Ian Rogers @ 2026-04-08 6:20 UTC (permalink / raw) To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo, Namhyung Kim, Alexander Shishkin, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark, German Gomez, linux-perf-users, linux-kernel When filtering branch stack samples on user events they sample in user land but may have come from the kernel. Avoid the target address being a kernel address but allow the source to be the kernel. When filtering branch stack samples on kernel events they sample in kernel land but may have come from user land. Avoid the target being a user address but allow the source to be in user land. Increase the duration of the system call sampling test to make the likelihood of sampling a system call higher (increased from 1000 to 8000 loops - a number found through experimentation on an Intel Tigerlake laptop), also make the period of the event a prime number. Put unneeded perf record output into a temporary file so that the test output isn't cluttered. More clearly state which test is running and the pass, fail or skipped result of the test. These changes make the test on an Intel tigerlake laptop reliably pass rather than reliably fail. Signed-off-by: Ian Rogers <irogers@google.com> --- tools/perf/tests/shell/test_brstack.sh | 133 ++++++++++++++++--------- 1 file changed, 84 insertions(+), 49 deletions(-) diff --git a/tools/perf/tests/shell/test_brstack.sh b/tools/perf/tests/shell/test_brstack.sh index 85233d435be6..51cd1dcd1855 100755 --- a/tools/perf/tests/shell/test_brstack.sh +++ b/tools/perf/tests/shell/test_brstack.sh @@ -40,7 +40,7 @@ is_arm64() { check_branches() { if ! tr -s ' ' '\n' < "$TMPDIR/perf.script" | grep -E -m1 -q "$1"; then - echo "Branches missing $1" + echo "ERROR: Branches missing $1" err=1 fi } @@ -48,6 +48,8 @@ check_branches() { test_user_branches() { echo "Testing user branch stack sampling" + start_err=$err + err=0 perf record -o "$TMPDIR/perf.data" --branch-filter any,save_type,u -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 perf script -i "$TMPDIR/perf.data" --fields brstacksym > "$TMPDIR/perf.script" @@ -73,59 +75,81 @@ test_user_branches() { perf script -i "$TMPDIR/perf.data" --fields brstack | \ tr ' ' '\n' > "$TMPDIR/perf.script" - # There should be no kernel addresses with the u option, in either - # source or target addresses. - if grep -E -m1 "0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then - echo "ERROR: Kernel address found in user mode" + # There should be no kernel addresses in the target with the u option. + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[89a-f][0-9a-f]{15}/" $TMPDIR/perf.script; then + echo "Testing user branch stack sampling [Failed kernel address found in user mode]" err=1 fi # some branch types are still not being tested: # IND COND_CALL COND_RET SYSRET SERROR NO_TX + if [ $err -eq 0 ]; then + echo "Testing user branch stack sampling [Passed]" + err=$start_err + else + echo "Testing user branch stack sampling [Failed]" + fi } test_trap_eret_branches() { echo "Testing trap & eret branches" + if ! is_arm64; then - echo "skip: not arm64" + echo "Testing trap & eret branches [Skipped not arm64]" + return + fi + start_err=$err + err=0 + perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ + perf test -w traploop 1000 + perf script -i $TMPDIR/perf.data --fields brstacksym | \ + tr ' ' '\n' > $TMPDIR/perf.script + + # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver + check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" + check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" + if [ $err -eq 0 ]; then + echo "Testing trap & eret branches [Passed]" + err=$start_err else - perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ - perf test -w traploop 1000 - perf script -i $TMPDIR/perf.data --fields brstacksym | \ - tr ' ' '\n' > $TMPDIR/perf.script - - # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver - check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" - check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" + echo "Testing trap & eret branches [Failed]" fi } test_kernel_branches() { echo "Testing that k option only includes kernel source addresses" - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then - echo "skip: not enough privileges" + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then + echo "Testing that k option [Skipped not enough privileges]" + return + fi + start_err=$err + err=0 + perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ + perf bench syscall basic --loop 1000 > "$TMPDIR/record.txt" 2>&1 + perf script -i $TMPDIR/perf.data --fields brstack | \ + tr ' ' '\n' > $TMPDIR/perf.script + + # Example of branch entries: + # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." + # Source addresses come first and target address can be either + # userspace or kernel even with k option, as long as the source + # is in kernel. + + # Look for source addresses with top bit set + if ! grep -q -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then + echo "Testing that k option [Failed kernel branches missing]" + err=1 + fi + # Look for no target addresses without top bit set + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[0-7][0-9a-f]{0,15}/" $TMPDIR/perf.script; then + echo "Testing that k option [Failed user branches found with kernel filter]" + err=1 + fi + if [ $err -eq 0 ]; then + echo "Testing that k option [Passed]" + err=$start_err else - perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ - perf bench syscall basic --loop 1000 - perf script -i $TMPDIR/perf.data --fields brstack | \ - tr ' ' '\n' > $TMPDIR/perf.script - - # Example of branch entries: - # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." - # Source addresses come first and target address can be either - # userspace or kernel even with k option, as long as the source - # is in kernel. - - #Look for source addresses with top bit set - if ! grep -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then - echo "ERROR: Kernel branches missing" - err=1 - fi - # Look for no source addresses without top bit set - if grep -E -m1 "^0x[0-7][0-9a-f]{0,15}" $TMPDIR/perf.script; then - echo "ERROR: User branches found with kernel filter" - err=1 - fi + echo "Testing that k option [Failed]" fi } @@ -136,14 +160,15 @@ test_filter() { test_filter_expect=$2 echo "Testing branch stack filtering permutation ($test_filter_filter,$test_filter_expect)" - perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 + perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- \ + ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 perf script -i "$TMPDIR/perf.data" --fields brstack > "$TMPDIR/perf.script" # fail if we find any branch type that doesn't match any of the expected ones # also consider UNKNOWN branch types (-) if [ ! -s "$TMPDIR/perf.script" ] then - echo "Empty script output" + echo "Testing branch stack filtering [Failed empty script output]" err=1 return fi @@ -154,26 +179,36 @@ test_filter() { > "$TMPDIR/perf.script-filtered" || true if [ -s "$TMPDIR/perf.script-filtered" ] then - echo "Unexpected branch filter in script output" + echo "Testing branch stack filtering [Failed unexpected branch filter]" cat "$TMPDIR/perf.script" err=1 return fi + echo "Testing branch stack filtering [Passed]" } test_syscall() { echo "Testing syscalls" # skip if perf doesn't have enough privileges - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then - echo "skip: not enough privileges" + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then + echo "Testing syscalls [Skipped: not enough privileges]" + return + fi + start_err=$err + err=0 + perf record -o $TMPDIR/perf.data --branch-filter \ + any_call,save_type,u,k -c 10007 -- \ + perf bench syscall basic --loop 8000 > "$TMPDIR/record.txt" 2>&1 + perf script -i $TMPDIR/perf.data --fields brstacksym | \ + tr ' ' '\n' > $TMPDIR/perf.script + + check_branches "getppid[^ ]*/SYSCALL/" + + if [ $err -eq 0 ]; then + echo "Testing syscalls [Passed]" + err=$start_err else - perf record -o $TMPDIR/perf.data --branch-filter \ - any_call,save_type,u,k -c 10000 -- \ - perf bench syscall basic --loop 1000 - perf script -i $TMPDIR/perf.data --fields brstacksym | \ - tr ' ' '\n' > $TMPDIR/perf.script - - check_branches "getppid[^ ]*/SYSCALL/" + echo "Testing syscalls [Failed]" fi } set -e -- 2.53.0.1213.gd9a14994de-goog ^ permalink raw reply related [flat|nested] 24+ messages in thread
* [PATCH v3] perf test: Fixes for check branch stack sampling 2026-04-08 6:20 [PATCH v2] perf test: Fixes for check branch stack sampling Ian Rogers @ 2026-04-08 6:58 ` Ian Rogers 2026-04-08 7:16 ` sashiko-bot 2026-04-08 12:37 ` James Clark 0 siblings, 2 replies; 24+ messages in thread From: Ian Rogers @ 2026-04-08 6:58 UTC (permalink / raw) To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo, Namhyung Kim, Alexander Shishkin, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark, German Gomez, linux-perf-users, linux-kernel When filtering branch stack samples on user events they sample in user land but may have come from the kernel. Avoid the target address being a kernel address but allow the source to be the kernel. When filtering branch stack samples on kernel events they sample in kernel land but may have come from user land. Avoid the target being a user address but allow the source to be in user land. Increase the duration of the system call sampling test to make the likelihood of sampling a system call higher (increased from 1000 to 8000 loops - a number found through experimentation on an Intel Tigerlake laptop), also make the period of the event a prime number. Put unneeded perf record output into a temporary file so that the test output isn't cluttered. More clearly state which test is running and the pass, fail or skipped result of the test. These changes make the test on an Intel tigerlake laptop reliably pass rather than reliably fail. Signed-off-by: Ian Rogers <irogers@google.com> --- tools/perf/tests/shell/test_brstack.sh | 134 ++++++++++++++++--------- 1 file changed, 84 insertions(+), 50 deletions(-) diff --git a/tools/perf/tests/shell/test_brstack.sh b/tools/perf/tests/shell/test_brstack.sh index 85233d435be6..025ed9d3d110 100755 --- a/tools/perf/tests/shell/test_brstack.sh +++ b/tools/perf/tests/shell/test_brstack.sh @@ -40,7 +40,7 @@ is_arm64() { check_branches() { if ! tr -s ' ' '\n' < "$TMPDIR/perf.script" | grep -E -m1 -q "$1"; then - echo "Branches missing $1" + echo "ERROR: Branches missing $1" err=1 fi } @@ -48,6 +48,8 @@ check_branches() { test_user_branches() { echo "Testing user branch stack sampling" + start_err=$err + err=0 perf record -o "$TMPDIR/perf.data" --branch-filter any,save_type,u -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 perf script -i "$TMPDIR/perf.data" --fields brstacksym > "$TMPDIR/perf.script" @@ -73,59 +75,80 @@ test_user_branches() { perf script -i "$TMPDIR/perf.data" --fields brstack | \ tr ' ' '\n' > "$TMPDIR/perf.script" - # There should be no kernel addresses with the u option, in either - # source or target addresses. - if grep -E -m1 "0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then - echo "ERROR: Kernel address found in user mode" + # There should be no kernel addresses in the target with the u option. + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[89a-f][0-9a-f]{15}/" $TMPDIR/perf.script; then + echo "Testing user branch stack sampling [Failed kernel address found in user mode]" err=1 fi # some branch types are still not being tested: # IND COND_CALL COND_RET SYSRET SERROR NO_TX + if [ $err -eq 0 ]; then + echo "Testing user branch stack sampling [Passed]" + err=$start_err + else + echo "Testing user branch stack sampling [Failed]" + fi } test_trap_eret_branches() { echo "Testing trap & eret branches" + if ! is_arm64; then - echo "skip: not arm64" + echo "Testing trap & eret branches [Skipped not arm64]" + return + fi + start_err=$err + err=0 + perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ + perf test -w traploop 1000 > "$TMPDIR/record.txt" 2>&1 + perf script -i $TMPDIR/perf.data --fields brstacksym | \ + tr ' ' '\n' > $TMPDIR/perf.script + + # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver + check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" + check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" + if [ $err -eq 0 ]; then + echo "Testing trap & eret branches [Passed]" + err=$start_err else - perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ - perf test -w traploop 1000 - perf script -i $TMPDIR/perf.data --fields brstacksym | \ - tr ' ' '\n' > $TMPDIR/perf.script - - # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver - check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" - check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" + echo "Testing trap & eret branches [Failed]" fi } test_kernel_branches() { - echo "Testing that k option only includes kernel source addresses" + echo "Testing kernel branch sampling" - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then - echo "skip: not enough privileges" + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then + echo "Testing that k option [Skipped not enough privileges]" + return + fi + start_err=$err + err=0 + perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ + perf bench syscall basic --loop 1000 > "$TMPDIR/record.txt" 2>&1 + perf script -i $TMPDIR/perf.data --fields brstack | \ + tr ' ' '\n' > $TMPDIR/perf.script + + # Example of branch entries: + # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." + # Source addresses come first in user or kernel code. Next is the target + # address that must be in the kernel. + + # Look for source addresses with top bit set + if ! grep -q -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then + echo "Testing kernel branch sampling [Failed kernel branches missing]" + err=1 + fi + # Look for no target addresses without top bit set + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[0-7][0-9a-f]{0,15}/" $TMPDIR/perf.script; then + echo "Testing kernel branch sampling [Failed user branches found]" + err=1 + fi + if [ $err -eq 0 ]; then + echo "Testing kernel branch sampling [Passed]" + err=$start_err else - perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ - perf bench syscall basic --loop 1000 - perf script -i $TMPDIR/perf.data --fields brstack | \ - tr ' ' '\n' > $TMPDIR/perf.script - - # Example of branch entries: - # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." - # Source addresses come first and target address can be either - # userspace or kernel even with k option, as long as the source - # is in kernel. - - #Look for source addresses with top bit set - if ! grep -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then - echo "ERROR: Kernel branches missing" - err=1 - fi - # Look for no source addresses without top bit set - if grep -E -m1 "^0x[0-7][0-9a-f]{0,15}" $TMPDIR/perf.script; then - echo "ERROR: User branches found with kernel filter" - err=1 - fi + echo "Testing kernel branch sampling [Failed]" fi } @@ -136,14 +159,15 @@ test_filter() { test_filter_expect=$2 echo "Testing branch stack filtering permutation ($test_filter_filter,$test_filter_expect)" - perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 + perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- \ + ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 perf script -i "$TMPDIR/perf.data" --fields brstack > "$TMPDIR/perf.script" # fail if we find any branch type that doesn't match any of the expected ones # also consider UNKNOWN branch types (-) if [ ! -s "$TMPDIR/perf.script" ] then - echo "Empty script output" + echo "Testing branch stack filtering [Failed empty script output]" err=1 return fi @@ -154,26 +178,36 @@ test_filter() { > "$TMPDIR/perf.script-filtered" || true if [ -s "$TMPDIR/perf.script-filtered" ] then - echo "Unexpected branch filter in script output" + echo "Testing branch stack filtering [Failed unexpected branch filter]" cat "$TMPDIR/perf.script" err=1 return fi + echo "Testing branch stack filtering [Passed]" } test_syscall() { echo "Testing syscalls" # skip if perf doesn't have enough privileges - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then - echo "skip: not enough privileges" + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then + echo "Testing syscalls [Skipped: not enough privileges]" + return + fi + start_err=$err + err=0 + perf record -o $TMPDIR/perf.data --branch-filter \ + any_call,save_type,u,k -c 10007 -- \ + perf bench syscall basic --loop 8000 > "$TMPDIR/record.txt" 2>&1 + perf script -i $TMPDIR/perf.data --fields brstacksym | \ + tr ' ' '\n' > $TMPDIR/perf.script + + check_branches "getppid[^ ]*/SYSCALL/" + + if [ $err -eq 0 ]; then + echo "Testing syscalls [Passed]" + err=$start_err else - perf record -o $TMPDIR/perf.data --branch-filter \ - any_call,save_type,u,k -c 10000 -- \ - perf bench syscall basic --loop 1000 - perf script -i $TMPDIR/perf.data --fields brstacksym | \ - tr ' ' '\n' > $TMPDIR/perf.script - - check_branches "getppid[^ ]*/SYSCALL/" + echo "Testing syscalls [Failed]" fi } set -e -- 2.53.0.1213.gd9a14994de-goog ^ permalink raw reply related [flat|nested] 24+ messages in thread
* Re: [PATCH v3] perf test: Fixes for check branch stack sampling 2026-04-08 6:58 ` [PATCH v3] " Ian Rogers @ 2026-04-08 7:16 ` sashiko-bot 2026-04-08 7:40 ` Ian Rogers 2026-04-08 12:37 ` James Clark 1 sibling, 1 reply; 24+ messages in thread From: sashiko-bot @ 2026-04-08 7:16 UTC (permalink / raw) To: Ian Rogers; +Cc: linux-perf-users Sashiko AI review found 1 potential issue(s): - [Medium] Incomplete regex in `test_kernel_branches` misses user branch target addresses that don't start with 0-7, causing false negatives. -- commit 81a20991ebd7cf4f6e494b9b81ee11052acea653 Author: Ian Rogers <irogers@google.com> perf test: Fixes for check branch stack sampling This commit fixes and expands the branch stack sampling tests in perf. It updates user and kernel branch filters to properly check source and target addresses, increases system call sampling duration for reliability, and cleans up test output. > diff --git a/tools/perf/tests/shell/test_brstack.sh b/tools/perf/tests/shell/test_brstack.sh > index 85233d435be63..025ed9d3d1105 100755 > --- a/tools/perf/tests/shell/test_brstack.sh > +++ b/tools/perf/tests/shell/test_brstack.sh [ ... ] > @@ -73,59 +75,80 @@ test_user_branches() { [ ... ] > test_kernel_branches() { [ ... ] > + # Look for no target addresses without top bit set > + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[0-7][0-9a-f]{0,15}/" $TMPDIR/perf.script; then Does this regex reliably match all user space target addresses? Because perf script prints brstack addresses without zero-padding, a valid 32-bit user address like 0x8048000 begins with 8 but is much shorter than 16 characters. Since the regex strictly requires the first character after the prefix to be between 0 and 7, it seems like it might silently miss shorter user addresses that start with 8 through f, potentially causing the test to pass when it should fail. > + echo "Testing kernel branch sampling [Failed user branches found]" > + err=1 > + fi -- Sashiko AI review · https://sashiko.dev/#/patchset/20260408065841.165393-1-irogers@google.com?part=1 ^ permalink raw reply [flat|nested] 24+ messages in thread
* Re: [PATCH v3] perf test: Fixes for check branch stack sampling 2026-04-08 7:16 ` sashiko-bot @ 2026-04-08 7:40 ` Ian Rogers 0 siblings, 0 replies; 24+ messages in thread From: Ian Rogers @ 2026-04-08 7:40 UTC (permalink / raw) To: linux-perf-users On Wed, Apr 8, 2026 at 12:16 AM <sashiko-bot@kernel.org> wrote: > > Sashiko AI review found 1 potential issue(s): > - [Medium] Incomplete regex in `test_kernel_branches` misses user branch target addresses that don't start with 0-7, causing false negatives. > -- > > commit 81a20991ebd7cf4f6e494b9b81ee11052acea653 > Author: Ian Rogers <irogers@google.com> > > perf test: Fixes for check branch stack sampling > > This commit fixes and expands the branch stack sampling tests in perf. > It updates user and kernel branch filters to properly check source and target > addresses, increases system call sampling duration for reliability, and cleans > up test output. > > > diff --git a/tools/perf/tests/shell/test_brstack.sh b/tools/perf/tests/shell/test_brstack.sh > > index 85233d435be63..025ed9d3d1105 100755 > > --- a/tools/perf/tests/shell/test_brstack.sh > > +++ b/tools/perf/tests/shell/test_brstack.sh > [ ... ] > > @@ -73,59 +75,80 @@ test_user_branches() { > [ ... ] > > test_kernel_branches() { > [ ... ] > > + # Look for no target addresses without top bit set > > + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[0-7][0-9a-f]{0,15}/" $TMPDIR/perf.script; then > > Does this regex reliably match all user space target addresses? > > Because perf script prints brstack addresses without zero-padding, a valid > 32-bit user address like 0x8048000 begins with 8 but is much shorter than > 16 characters. > > Since the regex strictly requires the first character after the prefix to be > between 0 and 7, it seems like it might silently miss shorter user addresses > that start with 8 through f, potentially causing the test to pass when it > should fail. Expanding the test to support 32-bit and 64-bit addresses in the regular expressions is out-of-scope for this series. Thanks, Ian > > + echo "Testing kernel branch sampling [Failed user branches found]" > > + err=1 > > + fi > > -- > Sashiko AI review · https://sashiko.dev/#/patchset/20260408065841.165393-1-irogers@google.com?part=1 ^ permalink raw reply [flat|nested] 24+ messages in thread
* Re: [PATCH v3] perf test: Fixes for check branch stack sampling 2026-04-08 6:58 ` [PATCH v3] " Ian Rogers 2026-04-08 7:16 ` sashiko-bot @ 2026-04-08 12:37 ` James Clark 2026-04-08 22:02 ` Ian Rogers 1 sibling, 1 reply; 24+ messages in thread From: James Clark @ 2026-04-08 12:37 UTC (permalink / raw) To: Ian Rogers Cc: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo, Namhyung Kim, Alexander Shishkin, Jiri Olsa, Adrian Hunter, German Gomez, linux-perf-users, linux-kernel On 08/04/2026 7:58 am, Ian Rogers wrote: > When filtering branch stack samples on user events they sample in user > land but may have come from the kernel. Avoid the target address being > a kernel address but allow the source to be the kernel. Doesn't that leak kernel pointers then? I thought they were supposed to be semi secret to not break KASLR? With /u on Arm64 all kernel branch addresses are filtered out or zeroed. I think it might be worth adding an architecture condition with a different test for Intel if the behavior is different. Otherwise we could start leaking pointers on Arm and the test would not detect it. > > When filtering branch stack samples on kernel events they sample in > kernel land but may have come from user land. Avoid the target being a > user address but allow the source to be in user land. > > Increase the duration of the system call sampling test to make the > likelihood of sampling a system call higher (increased from 1000 to > 8000 loops - a number found through experimentation on an Intel > Tigerlake laptop), also make the period of the event a prime number. > > Put unneeded perf record output into a temporary file so that the test > output isn't cluttered. More clearly state which test is running and > the pass, fail or skipped result of the test. > > These changes make the test on an Intel tigerlake laptop reliably pass > rather than reliably fail. > > Signed-off-by: Ian Rogers <irogers@google.com> > --- > tools/perf/tests/shell/test_brstack.sh | 134 ++++++++++++++++--------- > 1 file changed, 84 insertions(+), 50 deletions(-) > > diff --git a/tools/perf/tests/shell/test_brstack.sh b/tools/perf/tests/shell/test_brstack.sh > index 85233d435be6..025ed9d3d110 100755 > --- a/tools/perf/tests/shell/test_brstack.sh > +++ b/tools/perf/tests/shell/test_brstack.sh > @@ -40,7 +40,7 @@ is_arm64() { > > check_branches() { > if ! tr -s ' ' '\n' < "$TMPDIR/perf.script" | grep -E -m1 -q "$1"; then > - echo "Branches missing $1" > + echo "ERROR: Branches missing $1" > err=1 > fi > } > @@ -48,6 +48,8 @@ check_branches() { > test_user_branches() { > echo "Testing user branch stack sampling" > > + start_err=$err > + err=0 > perf record -o "$TMPDIR/perf.data" --branch-filter any,save_type,u -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 > perf script -i "$TMPDIR/perf.data" --fields brstacksym > "$TMPDIR/perf.script" > > @@ -73,59 +75,80 @@ test_user_branches() { > perf script -i "$TMPDIR/perf.data" --fields brstack | \ > tr ' ' '\n' > "$TMPDIR/perf.script" > > - # There should be no kernel addresses with the u option, in either > - # source or target addresses. > - if grep -E -m1 "0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then > - echo "ERROR: Kernel address found in user mode" > + # There should be no kernel addresses in the target with the u option. > + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[89a-f][0-9a-f]{15}/" $TMPDIR/perf.script; then > + echo "Testing user branch stack sampling [Failed kernel address found in user mode]" > err=1 > fi > # some branch types are still not being tested: > # IND COND_CALL COND_RET SYSRET SERROR NO_TX > + if [ $err -eq 0 ]; then > + echo "Testing user branch stack sampling [Passed]" > + err=$start_err > + else > + echo "Testing user branch stack sampling [Failed]" > + fi > } > > test_trap_eret_branches() { > echo "Testing trap & eret branches" > + > if ! is_arm64; then > - echo "skip: not arm64" > + echo "Testing trap & eret branches [Skipped not arm64]" > + return > + fi > + start_err=$err > + err=0 > + perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ > + perf test -w traploop 1000 > "$TMPDIR/record.txt" 2>&1 > + perf script -i $TMPDIR/perf.data --fields brstacksym | \ > + tr ' ' '\n' > $TMPDIR/perf.script > + > + # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver > + check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" > + check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" > + if [ $err -eq 0 ]; then > + echo "Testing trap & eret branches [Passed]" > + err=$start_err > else > - perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ > - perf test -w traploop 1000 > - perf script -i $TMPDIR/perf.data --fields brstacksym | \ > - tr ' ' '\n' > $TMPDIR/perf.script > - > - # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver > - check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" > - check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" > + echo "Testing trap & eret branches [Failed]" > fi > } > > test_kernel_branches() { > - echo "Testing that k option only includes kernel source addresses" > + echo "Testing kernel branch sampling" > > - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then > - echo "skip: not enough privileges" > + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then > + echo "Testing that k option [Skipped not enough privileges]" > + return > + fi > + start_err=$err > + err=0 > + perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ > + perf bench syscall basic --loop 1000 > "$TMPDIR/record.txt" 2>&1 > + perf script -i $TMPDIR/perf.data --fields brstack | \ > + tr ' ' '\n' > $TMPDIR/perf.script > + > + # Example of branch entries: > + # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." > + # Source addresses come first in user or kernel code. Next is the target > + # address that must be in the kernel. > + > + # Look for source addresses with top bit set > + if ! grep -q -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then > + echo "Testing kernel branch sampling [Failed kernel branches missing]" > + err=1 > + fi > + # Look for no target addresses without top bit set > + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[0-7][0-9a-f]{0,15}/" $TMPDIR/perf.script; then Target can be "0x0" for jumps from the kernel back to userspace with only kernel branches turned on. Before we were only looking at source addresses but now it looks at targets the test fails on 0x0 targets on Arm. I suppose we could special case 0x0? It starts to get a bit horrible to do in bash with regexes though. The rest of the changes look good. > + echo "Testing kernel branch sampling [Failed user branches found]" > + err=1 > + fi > + if [ $err -eq 0 ]; then > + echo "Testing kernel branch sampling [Passed]" > + err=$start_err > else > - perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ > - perf bench syscall basic --loop 1000 > - perf script -i $TMPDIR/perf.data --fields brstack | \ > - tr ' ' '\n' > $TMPDIR/perf.script > - > - # Example of branch entries: > - # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." > - # Source addresses come first and target address can be either > - # userspace or kernel even with k option, as long as the source > - # is in kernel. > - > - #Look for source addresses with top bit set > - if ! grep -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then > - echo "ERROR: Kernel branches missing" > - err=1 > - fi > - # Look for no source addresses without top bit set > - if grep -E -m1 "^0x[0-7][0-9a-f]{0,15}" $TMPDIR/perf.script; then > - echo "ERROR: User branches found with kernel filter" > - err=1 > - fi > + echo "Testing kernel branch sampling [Failed]" > fi > } > > @@ -136,14 +159,15 @@ test_filter() { > test_filter_expect=$2 > > echo "Testing branch stack filtering permutation ($test_filter_filter,$test_filter_expect)" > - perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 > + perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- \ > + ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 > perf script -i "$TMPDIR/perf.data" --fields brstack > "$TMPDIR/perf.script" > > # fail if we find any branch type that doesn't match any of the expected ones > # also consider UNKNOWN branch types (-) > if [ ! -s "$TMPDIR/perf.script" ] > then > - echo "Empty script output" > + echo "Testing branch stack filtering [Failed empty script output]" > err=1 > return > fi > @@ -154,26 +178,36 @@ test_filter() { > > "$TMPDIR/perf.script-filtered" || true > if [ -s "$TMPDIR/perf.script-filtered" ] > then > - echo "Unexpected branch filter in script output" > + echo "Testing branch stack filtering [Failed unexpected branch filter]" > cat "$TMPDIR/perf.script" > err=1 > return > fi > + echo "Testing branch stack filtering [Passed]" > } > > test_syscall() { > echo "Testing syscalls" > # skip if perf doesn't have enough privileges > - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then > - echo "skip: not enough privileges" > + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then > + echo "Testing syscalls [Skipped: not enough privileges]" > + return > + fi > + start_err=$err > + err=0 > + perf record -o $TMPDIR/perf.data --branch-filter \ > + any_call,save_type,u,k -c 10007 -- \ > + perf bench syscall basic --loop 8000 > "$TMPDIR/record.txt" 2>&1 > + perf script -i $TMPDIR/perf.data --fields brstacksym | \ > + tr ' ' '\n' > $TMPDIR/perf.script > + > + check_branches "getppid[^ ]*/SYSCALL/" > + > + if [ $err -eq 0 ]; then > + echo "Testing syscalls [Passed]" > + err=$start_err > else > - perf record -o $TMPDIR/perf.data --branch-filter \ > - any_call,save_type,u,k -c 10000 -- \ > - perf bench syscall basic --loop 1000 > - perf script -i $TMPDIR/perf.data --fields brstacksym | \ > - tr ' ' '\n' > $TMPDIR/perf.script > - > - check_branches "getppid[^ ]*/SYSCALL/" > + echo "Testing syscalls [Failed]" > fi > } > set -e ^ permalink raw reply [flat|nested] 24+ messages in thread
* Re: [PATCH v3] perf test: Fixes for check branch stack sampling 2026-04-08 12:37 ` James Clark @ 2026-04-08 22:02 ` Ian Rogers 0 siblings, 0 replies; 24+ messages in thread From: Ian Rogers @ 2026-04-08 22:02 UTC (permalink / raw) To: James Clark Cc: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo, Namhyung Kim, Alexander Shishkin, Jiri Olsa, Adrian Hunter, German Gomez, linux-perf-users, linux-kernel On Wed, Apr 8, 2026 at 5:37 AM James Clark <james.clark@linaro.org> wrote: > > > > On 08/04/2026 7:58 am, Ian Rogers wrote: > > When filtering branch stack samples on user events they sample in user > > land but may have come from the kernel. Avoid the target address being > > a kernel address but allow the source to be the kernel. > > Doesn't that leak kernel pointers then? I thought they were supposed to > be semi secret to not break KASLR? With /u on Arm64 all kernel branch > addresses are filtered out or zeroed. I think it might be worth adding > an architecture condition with a different test for Intel if the > behavior is different. Otherwise we could start leaking pointers on Arm > and the test would not detect it. On x86, the filter applies to the LBR target, suggesting a KASLR leak for LBR sources in the kernel. Wrt the test we could say the test fails on x86 due to this, but this is kind of annoying at the moment. I suppose having some kind of flag to say broken sources are allowed and then setting it on x86 would be best. > > > > When filtering branch stack samples on kernel events they sample in > > kernel land but may have come from user land. Avoid the target being a > > user address but allow the source to be in user land. > > > > Increase the duration of the system call sampling test to make the > > likelihood of sampling a system call higher (increased from 1000 to > > 8000 loops - a number found through experimentation on an Intel > > Tigerlake laptop), also make the period of the event a prime number. > > > > Put unneeded perf record output into a temporary file so that the test > > output isn't cluttered. More clearly state which test is running and > > the pass, fail or skipped result of the test. > > > > These changes make the test on an Intel tigerlake laptop reliably pass > > rather than reliably fail. > > > > Signed-off-by: Ian Rogers <irogers@google.com> > > --- > > tools/perf/tests/shell/test_brstack.sh | 134 ++++++++++++++++--------- > > 1 file changed, 84 insertions(+), 50 deletions(-) > > > > diff --git a/tools/perf/tests/shell/test_brstack.sh b/tools/perf/tests/shell/test_brstack.sh > > index 85233d435be6..025ed9d3d110 100755 > > --- a/tools/perf/tests/shell/test_brstack.sh > > +++ b/tools/perf/tests/shell/test_brstack.sh > > @@ -40,7 +40,7 @@ is_arm64() { > > > > check_branches() { > > if ! tr -s ' ' '\n' < "$TMPDIR/perf.script" | grep -E -m1 -q "$1"; then > > - echo "Branches missing $1" > > + echo "ERROR: Branches missing $1" > > err=1 > > fi > > } > > @@ -48,6 +48,8 @@ check_branches() { > > test_user_branches() { > > echo "Testing user branch stack sampling" > > > > + start_err=$err > > + err=0 > > perf record -o "$TMPDIR/perf.data" --branch-filter any,save_type,u -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 > > perf script -i "$TMPDIR/perf.data" --fields brstacksym > "$TMPDIR/perf.script" > > > > @@ -73,59 +75,80 @@ test_user_branches() { > > perf script -i "$TMPDIR/perf.data" --fields brstack | \ > > tr ' ' '\n' > "$TMPDIR/perf.script" > > > > - # There should be no kernel addresses with the u option, in either > > - # source or target addresses. > > - if grep -E -m1 "0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then > > - echo "ERROR: Kernel address found in user mode" > > + # There should be no kernel addresses in the target with the u option. > > + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[89a-f][0-9a-f]{15}/" $TMPDIR/perf.script; then > > + echo "Testing user branch stack sampling [Failed kernel address found in user mode]" > > err=1 > > fi > > # some branch types are still not being tested: > > # IND COND_CALL COND_RET SYSRET SERROR NO_TX > > + if [ $err -eq 0 ]; then > > + echo "Testing user branch stack sampling [Passed]" > > + err=$start_err > > + else > > + echo "Testing user branch stack sampling [Failed]" > > + fi > > } > > > > test_trap_eret_branches() { > > echo "Testing trap & eret branches" > > + > > if ! is_arm64; then > > - echo "skip: not arm64" > > + echo "Testing trap & eret branches [Skipped not arm64]" > > + return > > + fi > > + start_err=$err > > + err=0 > > + perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ > > + perf test -w traploop 1000 > "$TMPDIR/record.txt" 2>&1 > > + perf script -i $TMPDIR/perf.data --fields brstacksym | \ > > + tr ' ' '\n' > $TMPDIR/perf.script > > + > > + # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver > > + check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" > > + check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" > > + if [ $err -eq 0 ]; then > > + echo "Testing trap & eret branches [Passed]" > > + err=$start_err > > else > > - perf record -o $TMPDIR/perf.data --branch-filter any,save_type,u,k -- \ > > - perf test -w traploop 1000 > > - perf script -i $TMPDIR/perf.data --fields brstacksym | \ > > - tr ' ' '\n' > $TMPDIR/perf.script > > - > > - # BRBINF<n>.TYPE == TRAP are mapped to PERF_BR_IRQ by the BRBE driver > > - check_branches "^trap_bench\+[^ ]+/[^ ]/IRQ/" > > - check_branches "^[^ ]+/trap_bench\+[^ ]+/ERET/" > > + echo "Testing trap & eret branches [Failed]" > > fi > > } > > > > test_kernel_branches() { > > - echo "Testing that k option only includes kernel source addresses" > > + echo "Testing kernel branch sampling" > > > > - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then > > - echo "skip: not enough privileges" > > + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then > > + echo "Testing that k option [Skipped not enough privileges]" > > + return > > + fi > > + start_err=$err > > + err=0 > > + perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ > > + perf bench syscall basic --loop 1000 > "$TMPDIR/record.txt" 2>&1 > > + perf script -i $TMPDIR/perf.data --fields brstack | \ > > + tr ' ' '\n' > $TMPDIR/perf.script > > + > > + # Example of branch entries: > > + # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." > > + # Source addresses come first in user or kernel code. Next is the target > > + # address that must be in the kernel. > > + > > + # Look for source addresses with top bit set > > + if ! grep -q -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then > > + echo "Testing kernel branch sampling [Failed kernel branches missing]" > > + err=1 > > + fi > > + # Look for no target addresses without top bit set > > + if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[0-7][0-9a-f]{0,15}/" $TMPDIR/perf.script; then > > Target can be "0x0" for jumps from the kernel back to userspace with > only kernel branches turned on. Before we were only looking at source > addresses but now it looks at targets the test fails on 0x0 targets on Arm. > > I suppose we could special case 0x0? It starts to get a bit horrible to > do in bash with regexes though. I wonder, then, if the target should require two numbers, like this: ``` if grep -q -E -m1 "^0x[0-9a-f]{0,16}/0x[0-7][0-9a-f]{1,15}/" $TMPDIR/perf.script; then ``` > The rest of the changes look good. Thanks, Ian > > + echo "Testing kernel branch sampling [Failed user branches found]" > > + err=1 > > + fi > > + if [ $err -eq 0 ]; then > > + echo "Testing kernel branch sampling [Passed]" > > + err=$start_err > > else > > - perf record -o $TMPDIR/perf.data --branch-filter any,k -- \ > > - perf bench syscall basic --loop 1000 > > - perf script -i $TMPDIR/perf.data --fields brstack | \ > > - tr ' ' '\n' > $TMPDIR/perf.script > > - > > - # Example of branch entries: > > - # "0xffffffff93bda241/0xffffffff93bda20f/M/-/-/..." > > - # Source addresses come first and target address can be either > > - # userspace or kernel even with k option, as long as the source > > - # is in kernel. > > - > > - #Look for source addresses with top bit set > > - if ! grep -E -m1 "^0x[89a-f][0-9a-f]{15}" $TMPDIR/perf.script; then > > - echo "ERROR: Kernel branches missing" > > - err=1 > > - fi > > - # Look for no source addresses without top bit set > > - if grep -E -m1 "^0x[0-7][0-9a-f]{0,15}" $TMPDIR/perf.script; then > > - echo "ERROR: User branches found with kernel filter" > > - err=1 > > - fi > > + echo "Testing kernel branch sampling [Failed]" > > fi > > } > > > > @@ -136,14 +159,15 @@ test_filter() { > > test_filter_expect=$2 > > > > echo "Testing branch stack filtering permutation ($test_filter_filter,$test_filter_expect)" > > - perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 > > + perf record -o "$TMPDIR/perf.data" --branch-filter "$test_filter_filter,save_type,u" -- \ > > + ${TESTPROG} > "$TMPDIR/record.txt" 2>&1 > > perf script -i "$TMPDIR/perf.data" --fields brstack > "$TMPDIR/perf.script" > > > > # fail if we find any branch type that doesn't match any of the expected ones > > # also consider UNKNOWN branch types (-) > > if [ ! -s "$TMPDIR/perf.script" ] > > then > > - echo "Empty script output" > > + echo "Testing branch stack filtering [Failed empty script output]" > > err=1 > > return > > fi > > @@ -154,26 +178,36 @@ test_filter() { > > > "$TMPDIR/perf.script-filtered" || true > > if [ -s "$TMPDIR/perf.script-filtered" ] > > then > > - echo "Unexpected branch filter in script output" > > + echo "Testing branch stack filtering [Failed unexpected branch filter]" > > cat "$TMPDIR/perf.script" > > err=1 > > return > > fi > > + echo "Testing branch stack filtering [Passed]" > > } > > > > test_syscall() { > > echo "Testing syscalls" > > # skip if perf doesn't have enough privileges > > - if ! perf record --branch-filter any,k -o- -- true > /dev/null; then > > - echo "skip: not enough privileges" > > + if ! perf record --branch-filter any,k -o- -- true > "$TMPDIR/record.txt" 2>&1; then > > + echo "Testing syscalls [Skipped: not enough privileges]" > > + return > > + fi > > + start_err=$err > > + err=0 > > + perf record -o $TMPDIR/perf.data --branch-filter \ > > + any_call,save_type,u,k -c 10007 -- \ > > + perf bench syscall basic --loop 8000 > "$TMPDIR/record.txt" 2>&1 > > + perf script -i $TMPDIR/perf.data --fields brstacksym | \ > > + tr ' ' '\n' > $TMPDIR/perf.script > > + > > + check_branches "getppid[^ ]*/SYSCALL/" > > + > > + if [ $err -eq 0 ]; then > > + echo "Testing syscalls [Passed]" > > + err=$start_err > > else > > - perf record -o $TMPDIR/perf.data --branch-filter \ > > - any_call,save_type,u,k -c 10000 -- \ > > - perf bench syscall basic --loop 1000 > > - perf script -i $TMPDIR/perf.data --fields brstacksym | \ > > - tr ' ' '\n' > $TMPDIR/perf.script > > - > > - check_branches "getppid[^ ]*/SYSCALL/" > > + echo "Testing syscalls [Failed]" > > fi > > } > > set -e > ^ permalink raw reply [flat|nested] 24+ messages in thread
end of thread, other threads:[~2026-08-09 5:21 UTC | newest] Thread overview: 24+ messages (download: mbox.gz follow: Atom feed -- links below jump to the message on this page -- 2025-07-25 8:24 [PATCH v1 1/2] perf script: New treport script Ian Rogers 2025-07-25 8:24 ` [PATCH v1 2/2] perf script: treport add flamegraph support Ian Rogers 2025-07-25 8:38 ` Ian Rogers 2026-08-08 6:57 ` [PATCH v2 0/2] perf python TUI report and flamegraph Ian Rogers 2026-08-08 6:57 ` [PATCH v2 1/2] perf python: New treport script Ian Rogers 2026-08-08 7:13 ` sashiko-bot 2026-08-08 6:57 ` [PATCH v2 2/2] perf python: treport add flamegraph support Ian Rogers 2026-08-09 4:16 ` [PATCH v3 0/2] perf python TUI report and flamegraph Ian Rogers 2026-08-09 4:16 ` [PATCH v3 1/2] perf python: New treport script Ian Rogers 2026-08-09 4:29 ` sashiko-bot 2026-08-09 4:16 ` [PATCH v3] perf test: Fixes for check branch stack sampling Ian Rogers 2026-08-09 5:21 ` Ian Rogers 2026-08-09 4:16 ` [PATCH v3 2/2] perf python: treport add flamegraph support Ian Rogers 2026-08-09 4:36 ` sashiko-bot 2026-08-09 5:18 ` [PATCH v4 0/2] perf python TUI report and flamegraph Ian Rogers 2026-08-09 5:18 ` [PATCH v4 1/2] perf python: New treport script Ian Rogers 2026-08-09 5:18 ` [PATCH v4 2/2] perf python: treport add flamegraph support Ian Rogers 2025-07-26 6:43 ` [PATCH v1 2/2] perf script: " Namhyung Kim 2025-07-26 6:39 ` [PATCH v1 1/2] perf script: New treport script Namhyung Kim -- strict thread matches above, loose matches on Subject: below -- 2026-04-08 6:20 [PATCH v2] perf test: Fixes for check branch stack sampling Ian Rogers 2026-04-08 6:58 ` [PATCH v3] " Ian Rogers 2026-04-08 7:16 ` sashiko-bot 2026-04-08 7:40 ` Ian Rogers 2026-04-08 12:37 ` James Clark 2026-04-08 22:02 ` Ian Rogers
This is a public inbox, see mirroring instructions for how to clone and mirror all data and code used for this inbox