linux-perf-users.vger.kernel.org archive mirror
 help / color / mirror / Atom feed
* [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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ 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; 19+ 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] 19+ messages in thread

end of thread, other threads:[~2026-08-09  5:21 UTC | newest]

Thread overview: 19+ 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

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for NNTP newsgroup(s).