Linux Perf Users
 help / color / mirror / Atom feed
From: Ian Rogers <irogers@google.com>
To: irogers@google.com, acme@kernel.org, alice.mei.rogers@gmail.com,
	 namhyung@kernel.org
Cc: adrian.hunter@intel.com, jolsa@kernel.org, laixintaoo@gmail.com,
	 linux-kernel@vger.kernel.org, linux-perf-users@vger.kernel.org,
	 mingo@redhat.com, peterz@infradead.org
Subject: [PATCH v3 1/2] perf python: New treport script
Date: Sat,  8 Aug 2026 21:16:32 -0700	[thread overview]
Message-ID: <20260809041638.2402705-2-irogers@google.com> (raw)
In-Reply-To: <20260809041638.2402705-1-irogers@google.com>

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


  reply	other threads:[~2026-08-09  4:16 UTC|newest]

Thread overview: 19+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
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         ` Ian Rogers [this message]
2026-08-09  4:29           ` [PATCH v3 1/2] perf python: New treport script 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

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260809041638.2402705-2-irogers@google.com \
    --to=irogers@google.com \
    --cc=acme@kernel.org \
    --cc=adrian.hunter@intel.com \
    --cc=alice.mei.rogers@gmail.com \
    --cc=jolsa@kernel.org \
    --cc=laixintaoo@gmail.com \
    --cc=linux-kernel@vger.kernel.org \
    --cc=linux-perf-users@vger.kernel.org \
    --cc=mingo@redhat.com \
    --cc=namhyung@kernel.org \
    --cc=peterz@infradead.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox