* [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements
@ 2026-10-02 18:26 Ian Rogers
2026-10-02 18:26 ` [PATCH v1 01/13] perf session: Don't flush remaining events once processing is done Ian Rogers
` (12 more replies)
0 siblings, 13 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
This series adds interactive terminal user interfaces (TUIs) built with
Python's textual framework for 'perf timechart' (--tui and --live) and
'perf list' (--tui), updates 'treport' to progressively display profiles
while loading in the background, adds headless shell tests for all three
TUIs, and improves the 'perf' Python extension module to support
responsive background and live pipe event processing:
1. Session and Python extension improvements (patches 1-5):
- Skip flushing remaining deferred samples and auxtrace events once
session_done() is set so stopping event processing is immediate.
- Quietly stop processing events when a Python session callback raises
an exception (e.g., to cancel a background load on quit) without
corrupting TUI output with reader error messages.
- Lazily copy perf_event and perf_sample in process_events() only when
a Python callback retains a reference beyond its return, shrinking
struct pyrf_event to fit in CPython's small-object allocator.
- Lazily unwind and resolve sample callchains only when
event.callchain is accessed.
- Release the Python GIL around perf_session__new() and
perf_session__process_events() (reacquiring it in callbacks and
guarding concurrent thread/comm/symbol access) so blocking pipe
reads do not freeze TUI threads.
2. 'perf list --tui' and ilist test (patches 6-7):
- Add 'perf list --tui' to launch the interactive ilist script via
'perf script', and add a shell test driving ilist headlessly.
3. Progressive loading in treport and test (patches 8-9):
- Load profiles in a background thread in treport.py so the report
tree and flame graph are interactive while loading, with lazy tree
node creation and a headless shell test.
4. 'perf timechart --tui' and '--live' and tests (patches 10-13):
- Add ttimechart.py and 'perf timechart --tui' to interactively view,
zoom, and pan per-CPU (busy, frequency, idle), per-task (running,
runnable, blocked, wakeups), and I/O timelines along with a summary
table.
- Add 'perf timechart --live' to stream live scheduler, power, or I/O
events from a 'perf record' subprocess over a pipe with a rolling
time window, pause/resume via the control pipe, and optional output
recording (-o).
- Add headless shell tests covering both '--tui' and '--live' modes.
Alice Rogers (6):
perf treport: Show the profile while it loads
perf test: Add a test for the treport script
perf timechart: Add an interactive --tui mode
perf test: Add a test for perf timechart --tui
perf timechart: Add a --live mode to the TUI
perf test: Test perf timechart --live
Ian Rogers (7):
perf session: Don't flush remaining events once processing is done
perf python: Quietly stop processing events when a callback raises
perf python: Lazily copy events and samples from process_events
perf python: Lazily resolve sample callchains
perf python: Release the GIL while processing session events
perf list: Add a --tui option to launch ilist
perf test: Add a test for the ilist script
tools/perf/Documentation/perf-list.txt | 10 +
tools/perf/Documentation/perf-timechart.txt | 24 +
tools/perf/builtin-list.c | 34 +
tools/perf/builtin-timechart.c | 114 +-
tools/perf/python/treport.py | 458 ++-
tools/perf/python/ttimechart.py | 2467 +++++++++++++++++
tools/perf/tests/shell/test_ilist_python.sh | 181 ++
tools/perf/tests/shell/test_treport_python.sh | 247 ++
.../tests/shell/test_ttimechart_python.sh | 537 ++++
tools/perf/util/auxtrace.c | 7 +
tools/perf/util/python.c | 1191 +++++---
tools/perf/util/session.c | 8 +
12 files changed, 4754 insertions(+), 524 deletions(-)
create mode 100755 tools/perf/python/ttimechart.py
create mode 100755 tools/perf/tests/shell/test_ilist_python.sh
create mode 100755 tools/perf/tests/shell/test_treport_python.sh
create mode 100755 tools/perf/tests/shell/test_ttimechart_python.sh
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 01/13] perf session: Don't flush remaining events once processing is done
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 02/13] perf python: Quietly stop processing events when a callback raises Ian Rogers
` (11 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
When session_done() is set, for example by SIGINT or by a python
callback requesting processing stop, the reader loops stop but the
deferred samples and auxtrace data are still flushed afterwards. For
large traces decoding and delivering the remaining auxtrace data can be
slow, and the events are delivered to tools that asked to stop.
Discard the remaining deferred samples, and skip
auxtrace__flush_events(), when session_done() is set. The
ordered_events flush already honors session_done().
Assisted-by: Antigravity:gemini-3.1-pro
Signed-off-by: Ian Rogers <irogers@google.com>
---
tools/perf/util/auxtrace.c | 7 +++++++
tools/perf/util/session.c | 8 ++++++++
2 files changed, 15 insertions(+)
diff --git a/tools/perf/util/auxtrace.c b/tools/perf/util/auxtrace.c
index ed2113190785..f30f86156a3a 100644
--- a/tools/perf/util/auxtrace.c
+++ b/tools/perf/util/auxtrace.c
@@ -2978,6 +2978,13 @@ int auxtrace__flush_events(struct perf_session *session, const struct perf_tool
if (!session->auxtrace)
return 0;
+ /*
+ * Processing was stopped early, e.g. by SIGINT, so don't decode and
+ * deliver the remaining trace data which can be slow.
+ */
+ if (session_done())
+ return 0;
+
return session->auxtrace->flush_events(session, tool);
}
diff --git a/tools/perf/util/session.c b/tools/perf/util/session.c
index 7fea9e72726c..74b0fc0514d3 100644
--- a/tools/perf/util/session.c
+++ b/tools/perf/util/session.c
@@ -2018,6 +2018,14 @@ static int session__flush_deferred_samples(struct perf_session *session,
struct perf_sample sample;
struct evsel *new_evsel;
+ if (session_done()) {
+ /* Processing was stopped early, discard the sample. */
+ list_del(&de->list);
+ free(de->event);
+ free(de);
+ continue;
+ }
+
perf_sample__init(&sample, /*all=*/false);
ret = evlist__parse_sample(evlist, de->event, &sample);
if (ret < 0) {
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 02/13] perf python: Quietly stop processing events when a callback raises
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
2026-10-02 18:26 ` [PATCH v1 01/13] perf session: Don't flush remaining events once processing is done Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 03/13] perf python: Lazily copy events and samples from process_events Ian Rogers
` (10 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
When a session callback raises a python exception the callback returned
-1 causing reader__read_event() to print a "processing failed for event
of type: ..." error. This is redundant with the python exception that is
raised from process_events(), and corrupts the display of a TUI that
raises from a callback to deliberately stop processing, such as when
cancelling a background load.
Instead set session_done so that processing stops and return success,
the pending exception is then raised by process_events(). As a single
event may still cause further callbacks, for example multiple
call_return callbacks followed by a sample callback, skip calling python
while an exception is pending.
The session whose callback raised resets session_done after processing
so later sessions process all their events. As session_done is a global,
a session processed concurrently in another thread may be stopped early
too. Count the stop requests so that such a session raises an error
rather than silently dropping events.
Assisted-by: Antigravity:gemini-3.1-pro
Signed-off-by: Ian Rogers <irogers@google.com>
---
tools/perf/util/python.c | 149 ++++++++++++++++++++++++++++++++-------
1 file changed, 122 insertions(+), 27 deletions(-)
diff --git a/tools/perf/util/python.c b/tools/perf/util/python.c
index 95140dfaef9c..5b4963d5784d 100644
--- a/tools/perf/util/python.c
+++ b/tools/perf/util/python.c
@@ -4120,6 +4120,11 @@ struct pyrf_session {
*/
u64 call_return_last_db_id;
u64 sample_last_db_id;
+ /**
+ * @callback_raised: A python callback raised an exception and requested
+ * that event processing stops, see pyrf_session__callback_raised().
+ */
+ bool callback_raised;
char *vmlinux_name;
char *kallsyms_name;
char *symfs;
@@ -4439,6 +4444,55 @@ static PyObject *pyrf_call_return__new(struct call_return *cr)
return (PyObject *)pevent;
}
+/*
+ * Number of times a python callback has requested that event processing
+ * stops, see pyrf_session__callback_raised().
+ */
+static unsigned int pyrf_session__stop_requests;
+
+/*
+ * A python callback raised an exception.
+ *
+ * Returning an error to the session code would cause reader__read_event() to
+ * print a "processing failed for event of type: ..." error to stderr. This is
+ * noise, and in the case of a TUI corrupts the display, when a callback raises
+ * to deliberately stop processing (e.g. cancelling a background load) and the
+ * error message is redundant with the python exception that is raised anyway.
+ *
+ * Instead request that event processing stops by setting session_done and
+ * return success. The exception remains pending (PyErr_Occurred()) and
+ * pyrf_session__find_thread_events() returns NULL so python raises it.
+ *
+ * Note, session_done is a process-global rather than per-session. Were
+ * multiple sessions processing events concurrently in different python
+ * threads then an exception in one would stop all of them. Only the session
+ * whose callback raised clears session_done, and the other sessions detect
+ * the stop request, using pyrf_session__stop_requests, and raise an error
+ * rather than silently dropping events.
+ */
+static int pyrf_session__callback_raised(struct pyrf_session *psession)
+{
+ psession->callback_raised = true;
+ pyrf_session__stop_requests++;
+ session_done = 1;
+ return 0;
+}
+
+/*
+ * session_done stops the reader loops, the ordered_events and deferred sample
+ * flushes, and auxtrace__flush_events(), but some callbacks may still happen
+ * after a callback has raised. For example, a single sample may generate
+ * multiple call_return callbacks, and perf_session__flush_thread_stacks()
+ * delivers call returns for the remaining stack entries (skipping it would
+ * only defer those callbacks until the threads are deleted). Calling into
+ * python with an exception pending is an error, and the events would be
+ * discarded anyway, so callbacks check this and skip calling python.
+ */
+static bool pyrf_session__exception_pending(void)
+{
+ return PyErr_Occurred() != NULL;
+}
+
static int pyrf_session__call_return_process(struct call_return *cr,
u64 *parent_db_id,
void *data)
@@ -4446,7 +4500,7 @@ static int pyrf_session__call_return_process(struct call_return *cr,
struct pyrf_session *psession = data;
PyObject *pyevent, *ret;
- if (!psession->call_return)
+ if (!psession->call_return || pyrf_session__exception_pending())
return 0;
if (!cr->db_id)
@@ -4463,12 +4517,10 @@ static int pyrf_session__call_return_process(struct call_return *cr,
return -1;
ret = PyObject_CallFunctionObjArgs(psession->call_return, pyevent, NULL);
- if (!ret) {
- Py_DECREF(pyevent);
- return -1;
- }
- Py_DECREF(ret);
Py_DECREF(pyevent);
+ if (!ret)
+ return pyrf_session__callback_raised(psession);
+ Py_DECREF(ret);
return 0;
}
@@ -4480,6 +4532,9 @@ static int pyrf_session_tool__sample(const struct perf_tool *tool,
struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
u64 sample_db_id = ++psession->sample_last_db_id;
+ if (pyrf_session__exception_pending())
+ return 0;
+
if (psession->crp) {
struct addr_location al, addr_al;
struct thread *thread = NULL;
@@ -4510,6 +4565,10 @@ static int pyrf_session_tool__sample(const struct perf_tool *tool,
addr_location__exit(&addr_al);
addr_location__exit(&al);
+
+ /* A call_return callback may have raised. */
+ if (pyrf_session__exception_pending())
+ return 0;
}
if (psession->sample) {
@@ -4524,7 +4583,7 @@ static int pyrf_session_tool__sample(const struct perf_tool *tool,
ret = PyObject_CallFunction(psession->sample, "O", pyevent);
Py_DECREF(pyevent);
if (!ret)
- return -1;
+ return pyrf_session__callback_raised(psession);
Py_DECREF(ret);
}
return 0;
@@ -4536,9 +4595,19 @@ static int pyrf_session_tool__context_switch(const struct perf_tool *tool,
struct machine *machine)
{
struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
- PyObject *pyevent = pyrf_event__new(event, sample->evsel, psession->session, machine, NULL);
+ PyObject *pyevent;
PyObject *ret;
+ if (pyrf_session__exception_pending()) {
+ /*
+ * Skip calling python, but keep the machine's thread state
+ * consistent as perf_event__process_switch() would have been
+ * called had no python callback been registered.
+ */
+ return perf_event__process_switch(tool, event, sample, machine);
+ }
+
+ pyevent = pyrf_event__new(event, sample->evsel, psession->session, machine, NULL);
if (perf_event__process_switch(tool, event, sample, machine) < 0) {
Py_XDECREF(pyevent);
return -1;
@@ -4548,12 +4617,10 @@ static int pyrf_session_tool__context_switch(const struct perf_tool *tool,
return -ENOMEM;
ret = PyObject_CallFunction(psession->context_switch, "O", pyevent);
- if (!ret) {
- Py_DECREF(pyevent);
- return -1;
- }
- Py_DECREF(ret);
Py_DECREF(pyevent);
+ if (!ret)
+ return pyrf_session__callback_raised(psession);
+ Py_DECREF(ret);
return 0;
}
@@ -4563,21 +4630,23 @@ static int pyrf_session_tool__stat(const struct perf_tool *tool,
{
struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
struct evsel *evsel = evlist__id2evsel(session->evlist, event->stat.id);
- PyObject *pyevent = pyrf_event__new(event, /*evsel=*/NULL, psession->session,
- /*machine=*/NULL, NULL);
const char *name = evsel ? evsel__name(evsel) : "unknown";
+ PyObject *pyevent;
PyObject *ret;
+ if (pyrf_session__exception_pending())
+ return 0;
+
+ pyevent = pyrf_event__new(event, /*evsel=*/NULL, psession->session,
+ /*machine=*/NULL, NULL);
if (pyevent == NULL)
return -ENOMEM;
ret = PyObject_CallFunction(psession->stat, "Oz", pyevent, name);
- if (!ret) {
- Py_DECREF(pyevent);
- return -1;
- }
- Py_DECREF(ret);
Py_DECREF(pyevent);
+ if (!ret)
+ return pyrf_session__callback_raised(psession);
+ Py_DECREF(ret);
return 0;
}
@@ -4586,20 +4655,22 @@ static int pyrf_session_tool__stat_round(const struct perf_tool *tool,
union perf_event *event)
{
struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
- PyObject *pyevent = pyrf_event__new(event, /*evsel=*/NULL, psession->session,
- /*machine=*/NULL, NULL);
+ PyObject *pyevent;
PyObject *ret;
+ if (pyrf_session__exception_pending())
+ return 0;
+
+ pyevent = pyrf_event__new(event, /*evsel=*/NULL, psession->session,
+ /*machine=*/NULL, NULL);
if (pyevent == NULL)
return -ENOMEM;
ret = PyObject_CallFunction(psession->stat, "Oz", pyevent, NULL);
- if (!ret) {
- Py_DECREF(pyevent);
- return -1;
- }
- Py_DECREF(ret);
Py_DECREF(pyevent);
+ if (!ret)
+ return pyrf_session__callback_raised(psession);
+ Py_DECREF(ret);
return 0;
}
@@ -4680,6 +4751,7 @@ static PyObject *pyrf_session__new(PyTypeObject *type, PyObject *args, PyObject
psession->crp = NULL;
psession->call_return_last_db_id = 0;
psession->sample_last_db_id = 0;
+ psession->callback_raised = false;
psession->vmlinux_name = NULL;
psession->kallsyms_name = NULL;
psession->symfs = NULL;
@@ -4847,11 +4919,34 @@ static void pyrf_session__delete(struct pyrf_session *psession)
static PyObject *pyrf_session__find_thread_events(struct pyrf_session *psession)
{
+ unsigned int stop_requests = pyrf_session__stop_requests;
int err;
CHECK_INITIALIZED(psession->session, "session");
+ psession->callback_raised = false;
err = perf_session__process_events(psession->session);
+ if (psession->callback_raised) {
+ /*
+ * Clear the early stop requested by this session's callback, in
+ * pyrf_session__callback_raised(), so that later sessions process
+ * all their events. That function is the only writer of
+ * session_done in the python module, so resetting it here after
+ * processing is sufficient and there is no need to also reset it
+ * before processing.
+ */
+ session_done = 0;
+ } else if (stop_requests != pyrf_session__stop_requests && !PyErr_Occurred()) {
+ /*
+ * A callback of a session being processed concurrently, in
+ * another thread, raised and set session_done which may have
+ * stopped processing this session's events early. Raise an error
+ * rather than silently dropping events.
+ */
+ PyErr_SetString(PyExc_RuntimeError,
+ "Processing events may have stopped early as a callback of a concurrently processed session raised an exception");
+ return NULL;
+ }
if (PyErr_Occurred())
return NULL;
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 03/13] perf python: Lazily copy events and samples from process_events
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
2026-10-02 18:26 ` [PATCH v1 01/13] perf session: Don't flush remaining events once processing is done Ian Rogers
2026-10-02 18:26 ` [PATCH v1 02/13] perf python: Quietly stop processing events when a callback raises Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 04/13] perf python: Lazily resolve sample callchains Ian Rogers
` (9 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
When perf.session.process_events() delivers an event to a Python
callback, pyrf_event__new() previously copied the raw perf_event into a
4,160-byte union perf_event embedded in struct pyrf_event and called
evsel__parse_sample() a second time to re-parse the sample against the
copied buffer.
In the common case, the Python callback inspects the event and returns
without storing a reference to it. Avoid the event memcpy, the duplicate
evsel__parse_sample() call, and the large PyObject allocation by:
1. Storing pointers to the underlying union perf_event and
struct perf_sample in struct pyrf_event (using PyGetSetDef descriptors
backed by PyMember_GetOne() to read fields through those pointers),
shrinking struct pyrf_event so it fits in CPython's small-object
allocator pool.
2. Borrowing the event and pre-parsed sample pointers from
process_events() for the duration of the Python callback.
3. Checking Py_REFCNT(pyevent) > 1 after the callback returns and only
allocating event_copy and re-parsing into sample_storage when the
Python callback retained a reference to the event object (or immediately
in pyrf_evlist__read_on_cpu() where no pre-parsed sample is passed).
Assisted-by: Antigravity:gemini-3.1-pro
Signed-off-by: Ian Rogers <irogers@google.com>
---
tools/perf/util/python.c | 664 ++++++++++++++++++++++-----------------
1 file changed, 384 insertions(+), 280 deletions(-)
diff --git a/tools/perf/util/python.c b/tools/perf/util/python.c
index 5b4963d5784d..a40e89c1b329 100644
--- a/tools/perf/util/python.c
+++ b/tools/perf/util/python.c
@@ -70,16 +70,6 @@ PyMODINIT_FUNC PyInit_perf(void);
static PyObject *pyrf_evsel__from_evsel(struct evsel *evsel);
-#define member_def(type, member, ptype, help) \
- { #member, ptype, \
- offsetof(struct pyrf_event, event) + offsetof(struct type, member), \
- 0, help }
-
-#define sample_member_def(name, member, ptype, help) \
- { #name, ptype, \
- offsetof(struct pyrf_event, sample) + offsetof(struct perf_sample, member), \
- 0, help }
-
#define CHECK_INITIALIZED(ptr, msg) \
do { \
if (!(ptr)) { \
@@ -98,8 +88,12 @@ static PyObject *pyrf_evsel__from_evsel(struct evsel *evsel);
struct pyrf_event {
PyObject_HEAD
- /** @sample: The parsed sample from the event. */
- struct perf_sample sample;
+ /** @sample: The parsed sample, pointing to @sample_storage or a borrowed sample. */
+ struct perf_sample *sample;
+ /** @sample_storage: Storage for @sample when owned by this object. */
+ struct perf_sample sample_storage;
+ /** @evsel: The evsel associated with the event. */
+ struct evsel *evsel;
/** @machine: The machine associated with the sample/event. */
struct machine *machine;
/** @al: The address location from machine__resolve, lazily computed. */
@@ -110,11 +104,85 @@ struct pyrf_event {
PyObject *callchain;
/** @brstack: Resolved branch stack, eagerly computed if requested. */
PyObject *brstack;
- /** @event: The underlying perf_event that may be in a file or ring buffer. */
- union perf_event event;
+ /** @event: The underlying perf_event, pointing to @event_copy or a borrowed event. */
+ const union perf_event *event;
+ /** @event_copy: Copy of @event when owned by this object. */
+ union perf_event *event_copy;
+};
+
+static const union perf_event zero_event;
+
+static PyObject *pyrf_event__get_member(PyObject *self, void *closure)
+{
+ struct pyrf_event *pevent = (void *)self;
+ const union perf_event *event = pevent->event ?: &zero_event;
+
+ return PyMember_GetOne((const char *)event, closure);
+}
+
+static PyObject *pyrf_event__get_sample_member(PyObject *self, void *closure)
+{
+ struct pyrf_event *pevent = (void *)self;
+ const struct perf_sample *sample = pevent->sample ?: &pevent->sample_storage;
+
+ return PyMember_GetOne((const char *)sample, closure);
+}
+
+#define named_member_def(field, type, member, ptype, help) \
+ { .name = #field, .get = pyrf_event__get_member, .doc = help, \
+ .closure = &(struct PyMemberDef){ #field, ptype, \
+ offsetof(struct type, member), READONLY, help } }
+
+#define member_def(type, member, ptype, help) \
+ named_member_def(member, type, member, ptype, help)
+
+struct pyrf_string_member {
+ size_t offset;
+ size_t maxlen;
};
+static size_t pyrf_event__str_len(const struct pyrf_event *pevent, size_t offset, size_t maxlen)
+{
+ const char *base = (const char *)(pevent->event ?: &zero_event);
+ size_t event_size = pevent->event ? pevent->event->header.size : 0;
+
+ if (event_size <= offset)
+ return 0;
+ return strnlen(base + offset, min(event_size - offset, maxlen));
+}
+
+static PyObject *pyrf_event__get_string_member(PyObject *self, void *closure)
+{
+ struct pyrf_event *pevent = (void *)self;
+ const struct pyrf_string_member *sm = closure;
+ const char *base = (const char *)(pevent->event ?: &zero_event);
+
+ return PyUnicode_FromStringAndSize(base + sm->offset,
+ pyrf_event__str_len(pevent, sm->offset, sm->maxlen));
+}
+
+#define string_member_def(type, member, help) \
+ { .name = #member, .get = pyrf_event__get_string_member, .doc = help, \
+ .closure = &(struct pyrf_string_member){ offsetof(struct type, member), \
+ sizeof(((struct type *)0)->member) } }
+
+#define sample_member_def(field, member, ptype, help) \
+ { .name = #field, .get = pyrf_event__get_sample_member, .doc = help, \
+ .closure = &(struct PyMemberDef){ #field, ptype, \
+ offsetof(struct perf_sample, member), READONLY, help } }
+
+static PyObject *pyrf_event__get_evsel(PyObject *self, void *closure __maybe_unused)
+{
+ struct pyrf_event *pevent = (void *)self;
+
+ if (!pevent->sample || !pevent->sample->evsel)
+ Py_RETURN_NONE;
+
+ return pyrf_evsel__from_evsel(pevent->sample->evsel);
+}
+
#define sample_members \
+ { .name = "evsel", .get = pyrf_event__get_evsel, .doc = "tracking event." }, \
sample_member_def(sample_pid, pid, T_INT, "event pid"), \
sample_member_def(sample_tid, tid, T_INT, "event tid"), \
sample_member_def(sample_time, time, T_ULONGLONG, "event timestamp"), \
@@ -125,39 +193,21 @@ struct pyrf_event {
sample_member_def(sample_period, period, T_ULONGLONG, "event period"), \
sample_member_def(sample_cpu, cpu, T_UINT, "event cpu"),
-static PyObject *pyrf_event__get_evsel(PyObject *self, void *closure __maybe_unused)
-{
- struct pyrf_event *pevent = (void *)self;
-
- if (!pevent->sample.evsel)
- Py_RETURN_NONE;
-
- return pyrf_evsel__from_evsel(pevent->sample.evsel);
-}
-
-static PyGetSetDef pyrf_event__getset[] = {
- {
- .name = "evsel",
- .get = pyrf_event__get_evsel,
- .set = NULL,
- .doc = "tracking event.",
- },
- { .name = NULL, },
-};
-
static void pyrf_event__delete(struct pyrf_event *pevent)
{
if (pevent->al_resolved)
addr_location__exit(&pevent->al);
Py_XDECREF(pevent->callchain);
Py_XDECREF(pevent->brstack);
- perf_sample__exit(&pevent->sample);
+ perf_sample__exit(&pevent->sample_storage);
+ evsel__put(pevent->evsel);
+ free(pevent->event_copy);
Py_TYPE(pevent)->tp_free((PyObject *)pevent);
}
static const char pyrf_mmap_event__doc[] = PyDoc_STR("perf mmap event object.");
-static PyMemberDef pyrf_mmap_event__members[] = {
+static PyGetSetDef pyrf_mmap_event__getset[] = {
sample_members
member_def(perf_event_header, type, T_UINT, "event type"),
member_def(perf_event_header, misc, T_USHORT, "event misc"),
@@ -166,7 +216,7 @@ static PyMemberDef pyrf_mmap_event__members[] = {
member_def(perf_record_mmap, start, T_ULONGLONG, "start of the map"),
member_def(perf_record_mmap, len, T_ULONGLONG, "map length"),
member_def(perf_record_mmap, pgoff, T_ULONGLONG, "page offset"),
- member_def(perf_record_mmap, filename, T_STRING_INPLACE, "backing store"),
+ string_member_def(perf_record_mmap, filename, "backing store"),
{ .name = NULL, },
};
@@ -174,13 +224,15 @@ static PyObject *pyrf_mmap_event__repr(const struct pyrf_event *pevent)
{
PyObject *ret;
char *s;
+ int fn_len = pyrf_event__str_len(pevent, offsetof(struct perf_record_mmap, filename),
+ sizeof(pevent->event->mmap.filename));
if (asprintf(&s, "{ type: mmap, pid: %u, tid: %u, start: %#" PRI_lx64 ", "
"length: %#" PRI_lx64 ", offset: %#" PRI_lx64 ", "
- "filename: %s }",
- pevent->event.mmap.pid, pevent->event.mmap.tid,
- pevent->event.mmap.start, pevent->event.mmap.len,
- pevent->event.mmap.pgoff, pevent->event.mmap.filename) < 0) {
+ "filename: %.*s }",
+ pevent->event->mmap.pid, pevent->event->mmap.tid,
+ pevent->event->mmap.start, pevent->event->mmap.len,
+ pevent->event->mmap.pgoff, fn_len, pevent->event->mmap.filename) < 0) {
ret = PyErr_NoMemory();
} else {
ret = PyUnicode_FromString(s);
@@ -196,8 +248,7 @@ static PyTypeObject pyrf_mmap_event__type = {
.tp_dealloc = (destructor)pyrf_event__delete,
.tp_flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE,
.tp_doc = pyrf_mmap_event__doc,
- .tp_members = pyrf_mmap_event__members,
- .tp_getset = pyrf_event__getset,
+ .tp_getset = pyrf_mmap_event__getset,
.tp_repr = (reprfunc)pyrf_mmap_event__repr,
};
@@ -207,64 +258,69 @@ static PyObject *pyrf_mmap2_event__get_maj(PyObject *self, void *closure __maybe
{
struct pyrf_event *pevent = (void *)self;
- if (pevent->event.header.misc & PERF_RECORD_MISC_MMAP_BUILD_ID)
+ if (pevent->event->header.misc & PERF_RECORD_MISC_MMAP_BUILD_ID)
Py_RETURN_NONE;
- return PyLong_FromUnsignedLong(pevent->event.mmap2.maj);
+ return PyLong_FromUnsignedLong(pevent->event->mmap2.maj);
}
static PyObject *pyrf_mmap2_event__get_min(PyObject *self, void *closure __maybe_unused)
{
struct pyrf_event *pevent = (void *)self;
- if (pevent->event.header.misc & PERF_RECORD_MISC_MMAP_BUILD_ID)
+ if (pevent->event->header.misc & PERF_RECORD_MISC_MMAP_BUILD_ID)
Py_RETURN_NONE;
- return PyLong_FromUnsignedLong(pevent->event.mmap2.min);
+ return PyLong_FromUnsignedLong(pevent->event->mmap2.min);
}
static PyObject *pyrf_mmap2_event__get_ino(PyObject *self, void *closure __maybe_unused)
{
struct pyrf_event *pevent = (void *)self;
- if (pevent->event.header.misc & PERF_RECORD_MISC_MMAP_BUILD_ID)
+ if (pevent->event->header.misc & PERF_RECORD_MISC_MMAP_BUILD_ID)
Py_RETURN_NONE;
- return PyLong_FromUnsignedLongLong(pevent->event.mmap2.ino);
+ return PyLong_FromUnsignedLongLong(pevent->event->mmap2.ino);
}
static PyObject *pyrf_mmap2_event__get_ino_generation(PyObject *self, void *closure __maybe_unused)
{
struct pyrf_event *pevent = (void *)self;
- if (pevent->event.header.misc & PERF_RECORD_MISC_MMAP_BUILD_ID)
+ if (pevent->event->header.misc & PERF_RECORD_MISC_MMAP_BUILD_ID)
Py_RETURN_NONE;
- return PyLong_FromUnsignedLongLong(pevent->event.mmap2.ino_generation);
+ return PyLong_FromUnsignedLongLong(pevent->event->mmap2.ino_generation);
}
static PyObject *pyrf_mmap2_event__get_build_id(PyObject *self, void *closure __maybe_unused)
{
struct pyrf_event *pevent = (void *)self;
- if (!(pevent->event.header.misc & PERF_RECORD_MISC_MMAP_BUILD_ID))
+ if (!(pevent->event->header.misc & PERF_RECORD_MISC_MMAP_BUILD_ID))
Py_RETURN_NONE;
- int size = pevent->event.mmap2.build_id_size;
+ int size = pevent->event->mmap2.build_id_size;
if (size > 20)
size = 20;
- return PyBytes_FromStringAndSize((const char *)pevent->event.mmap2.build_id, size);
+ return PyBytes_FromStringAndSize((const char *)pevent->event->mmap2.build_id, size);
}
static PyGetSetDef pyrf_mmap2_event__getset[] = {
- {
- .name = "evsel",
- .get = pyrf_event__get_evsel,
- .set = NULL,
- .doc = "tracking event.",
- },
+ sample_members
+ member_def(perf_event_header, type, T_UINT, "event type"),
+ member_def(perf_event_header, misc, T_USHORT, "event misc"),
+ member_def(perf_record_mmap2, pid, T_UINT, "event pid"),
+ member_def(perf_record_mmap2, tid, T_UINT, "event tid"),
+ member_def(perf_record_mmap2, start, T_ULONGLONG, "start of the map"),
+ member_def(perf_record_mmap2, len, T_ULONGLONG, "map length"),
+ member_def(perf_record_mmap2, pgoff, T_ULONGLONG, "page offset"),
+ member_def(perf_record_mmap2, prot, T_UINT, "protection"),
+ member_def(perf_record_mmap2, flags, T_UINT, "flags"),
+ string_member_def(perf_record_mmap2, filename, "backing store"),
{
.name = "maj",
.get = pyrf_mmap2_event__get_maj,
@@ -298,31 +354,18 @@ static PyGetSetDef pyrf_mmap2_event__getset[] = {
{ .name = NULL, },
};
-static PyMemberDef pyrf_mmap2_event__members[] = {
- sample_members
- member_def(perf_event_header, type, T_UINT, "event type"),
- member_def(perf_event_header, misc, T_USHORT, "event misc"),
- member_def(perf_record_mmap2, pid, T_UINT, "event pid"),
- member_def(perf_record_mmap2, tid, T_UINT, "event tid"),
- member_def(perf_record_mmap2, start, T_ULONGLONG, "start of the map"),
- member_def(perf_record_mmap2, len, T_ULONGLONG, "map length"),
- member_def(perf_record_mmap2, pgoff, T_ULONGLONG, "page offset"),
- member_def(perf_record_mmap2, prot, T_UINT, "protection"),
- member_def(perf_record_mmap2, flags, T_UINT, "flags"),
- member_def(perf_record_mmap2, filename, T_STRING_INPLACE, "backing store"),
- { .name = NULL, },
-};
-
static PyObject *pyrf_mmap2_event__repr(const struct pyrf_event *pevent)
{
PyObject *ret;
char *s;
-
- if (asprintf(&s, "{ type: mmap2, pid: %u, tid: %u, start: %#" PRI_lx64 ", length: %#" PRI_lx64 ", offset: %#" PRI_lx64 ", flags: %#x, prot: %#x, filename: %s }",
- pevent->event.mmap2.pid, pevent->event.mmap2.tid,
- pevent->event.mmap2.start, pevent->event.mmap2.len,
- pevent->event.mmap2.pgoff, pevent->event.mmap2.flags,
- pevent->event.mmap2.prot, pevent->event.mmap2.filename) < 0)
+ int fn_len = pyrf_event__str_len(pevent, offsetof(struct perf_record_mmap2, filename),
+ sizeof(pevent->event->mmap2.filename));
+
+ if (asprintf(&s, "{ type: mmap2, pid: %u, tid: %u, start: %#" PRI_lx64 ", length: %#" PRI_lx64 ", offset: %#" PRI_lx64 ", flags: %#x, prot: %#x, filename: %.*s }",
+ pevent->event->mmap2.pid, pevent->event->mmap2.tid,
+ pevent->event->mmap2.start, pevent->event->mmap2.len,
+ pevent->event->mmap2.pgoff, pevent->event->mmap2.flags,
+ pevent->event->mmap2.prot, fn_len, pevent->event->mmap2.filename) < 0)
return PyErr_NoMemory();
ret = PyUnicode_FromString(s);
@@ -337,14 +380,13 @@ static PyTypeObject pyrf_mmap2_event__type = {
.tp_dealloc = (destructor)pyrf_event__delete,
.tp_flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE,
.tp_doc = pyrf_mmap2_event__doc,
- .tp_members = pyrf_mmap2_event__members,
.tp_getset = pyrf_mmap2_event__getset,
.tp_repr = (reprfunc)pyrf_mmap2_event__repr,
};
static const char pyrf_task_event__doc[] = PyDoc_STR("perf task (fork/exit) event object.");
-static PyMemberDef pyrf_task_event__members[] = {
+static PyGetSetDef pyrf_task_event__getset[] = {
sample_members
member_def(perf_event_header, type, T_UINT, "event type"),
member_def(perf_record_fork, pid, T_UINT, "event pid"),
@@ -359,12 +401,12 @@ static PyObject *pyrf_task_event__repr(const struct pyrf_event *pevent)
{
return PyUnicode_FromFormat("{ type: %s, pid: %u, ppid: %u, tid: %u, "
"ptid: %u, time: %" PRI_lu64 "}",
- pevent->event.header.type == PERF_RECORD_FORK ? "fork" : "exit",
- pevent->event.fork.pid,
- pevent->event.fork.ppid,
- pevent->event.fork.tid,
- pevent->event.fork.ptid,
- pevent->event.fork.time);
+ pevent->event->header.type == PERF_RECORD_FORK ? "fork" : "exit",
+ pevent->event->fork.pid,
+ pevent->event->fork.ppid,
+ pevent->event->fork.tid,
+ pevent->event->fork.ptid,
+ pevent->event->fork.time);
}
static PyTypeObject pyrf_task_event__type = {
@@ -374,28 +416,36 @@ static PyTypeObject pyrf_task_event__type = {
.tp_dealloc = (destructor)pyrf_event__delete,
.tp_flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE,
.tp_doc = pyrf_task_event__doc,
- .tp_members = pyrf_task_event__members,
- .tp_getset = pyrf_event__getset,
+ .tp_getset = pyrf_task_event__getset,
.tp_repr = (reprfunc)pyrf_task_event__repr,
};
static const char pyrf_comm_event__doc[] = PyDoc_STR("perf comm event object.");
-static PyMemberDef pyrf_comm_event__members[] = {
+static PyGetSetDef pyrf_comm_event__getset[] = {
sample_members
member_def(perf_event_header, type, T_UINT, "event type"),
member_def(perf_record_comm, pid, T_UINT, "event pid"),
member_def(perf_record_comm, tid, T_UINT, "event tid"),
- member_def(perf_record_comm, comm, T_STRING_INPLACE, "process name"),
+ string_member_def(perf_record_comm, comm, "process name"),
{ .name = NULL, },
};
static PyObject *pyrf_comm_event__repr(const struct pyrf_event *pevent)
{
- return PyUnicode_FromFormat("{ type: comm, pid: %u, tid: %u, comm: %s }",
- pevent->event.comm.pid,
- pevent->event.comm.tid,
- pevent->event.comm.comm);
+ PyObject *ret;
+ char *s;
+ int comm_len = pyrf_event__str_len(pevent, offsetof(struct perf_record_comm, comm),
+ sizeof(pevent->event->comm.comm));
+
+ if (asprintf(&s, "{ type: comm, pid: %u, tid: %u, comm: %.*s }",
+ pevent->event->comm.pid, pevent->event->comm.tid,
+ comm_len, pevent->event->comm.comm) < 0)
+ return PyErr_NoMemory();
+
+ ret = PyUnicode_FromString(s);
+ free(s);
+ return ret;
}
static PyTypeObject pyrf_comm_event__type = {
@@ -405,14 +455,13 @@ static PyTypeObject pyrf_comm_event__type = {
.tp_dealloc = (destructor)pyrf_event__delete,
.tp_flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE,
.tp_doc = pyrf_comm_event__doc,
- .tp_members = pyrf_comm_event__members,
- .tp_getset = pyrf_event__getset,
+ .tp_getset = pyrf_comm_event__getset,
.tp_repr = (reprfunc)pyrf_comm_event__repr,
};
static const char pyrf_throttle_event__doc[] = PyDoc_STR("perf throttle event object.");
-static PyMemberDef pyrf_throttle_event__members[] = {
+static PyGetSetDef pyrf_throttle_event__getset[] = {
sample_members
member_def(perf_event_header, type, T_UINT, "event type"),
member_def(perf_record_throttle, time, T_ULONGLONG, "timestamp"),
@@ -424,11 +473,11 @@ static PyMemberDef pyrf_throttle_event__members[] = {
static PyObject *pyrf_throttle_event__repr(const struct pyrf_event *pevent)
{
const struct perf_record_throttle *te = (const struct perf_record_throttle *)
- (&pevent->event.header + 1);
+ (&pevent->event->header + 1);
return PyUnicode_FromFormat("{ type: %sthrottle, time: %" PRI_lu64 ", id: %" PRI_lu64
", stream_id: %" PRI_lu64 " }",
- pevent->event.header.type == PERF_RECORD_THROTTLE ? "" : "un",
+ pevent->event->header.type == PERF_RECORD_THROTTLE ? "" : "un",
te->time, te->id, te->stream_id);
}
@@ -439,14 +488,13 @@ static PyTypeObject pyrf_throttle_event__type = {
.tp_dealloc = (destructor)pyrf_event__delete,
.tp_flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE,
.tp_doc = pyrf_throttle_event__doc,
- .tp_members = pyrf_throttle_event__members,
- .tp_getset = pyrf_event__getset,
+ .tp_getset = pyrf_throttle_event__getset,
.tp_repr = (reprfunc)pyrf_throttle_event__repr,
};
static const char pyrf_lost_event__doc[] = PyDoc_STR("perf lost event object.");
-static PyMemberDef pyrf_lost_event__members[] = {
+static PyGetSetDef pyrf_lost_event__getset[] = {
sample_members
member_def(perf_event_header, type, T_UINT, "event type"),
member_def(perf_record_lost, id, T_ULONGLONG, "event id"),
@@ -461,7 +509,7 @@ static PyObject *pyrf_lost_event__repr(const struct pyrf_event *pevent)
if (asprintf(&s, "{ type: lost, id: %#" PRI_lx64 ", "
"lost: %#" PRI_lx64 " }",
- pevent->event.lost.id, pevent->event.lost.lost) < 0) {
+ pevent->event->lost.id, pevent->event->lost.lost) < 0) {
ret = PyErr_NoMemory();
} else {
ret = PyUnicode_FromString(s);
@@ -477,14 +525,13 @@ static PyTypeObject pyrf_lost_event__type = {
.tp_dealloc = (destructor)pyrf_event__delete,
.tp_flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE,
.tp_doc = pyrf_lost_event__doc,
- .tp_members = pyrf_lost_event__members,
- .tp_getset = pyrf_event__getset,
+ .tp_getset = pyrf_lost_event__getset,
.tp_repr = (reprfunc)pyrf_lost_event__repr,
};
static const char pyrf_stat_event__doc[] = PyDoc_STR("perf stat event object.");
-static PyMemberDef pyrf_stat_event__members[] = {
+static PyGetSetDef pyrf_stat_event__getset[] = {
sample_members
member_def(perf_event_header, type, T_UINT, "event type"),
member_def(perf_record_stat, id, T_ULONGLONG, "event id"),
@@ -498,14 +545,16 @@ static PyMemberDef pyrf_stat_event__members[] = {
static PyObject *pyrf_stat_event__repr(const struct pyrf_event *pevent)
{
+ const union perf_event *event = pevent->event ?: &zero_event;
+
return PyUnicode_FromFormat(
"{ type: stat, id: %llu, cpu: %u, thread: %u, val: %llu, ena: %llu, run: %llu }",
- pevent->event.stat.id,
- pevent->event.stat.cpu,
- pevent->event.stat.thread,
- pevent->event.stat.val,
- pevent->event.stat.ena,
- pevent->event.stat.run);
+ event->stat.id,
+ event->stat.cpu,
+ event->stat.thread,
+ event->stat.val,
+ event->stat.ena,
+ event->stat.run);
}
static PyTypeObject pyrf_stat_event__type = {
@@ -516,28 +565,27 @@ static PyTypeObject pyrf_stat_event__type = {
.tp_dealloc = (destructor)pyrf_event__delete,
.tp_flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE,
.tp_doc = pyrf_stat_event__doc,
- .tp_members = pyrf_stat_event__members,
- .tp_getset = pyrf_event__getset,
+ .tp_getset = pyrf_stat_event__getset,
.tp_repr = (reprfunc)pyrf_stat_event__repr,
};
static const char pyrf_stat_round_event__doc[] = PyDoc_STR("perf stat round event object.");
-static PyMemberDef pyrf_stat_round_event__members[] = {
+static PyGetSetDef pyrf_stat_round_event__getset[] = {
sample_members
member_def(perf_event_header, type, T_UINT, "event type"),
- { .name = "stat_round_type", .type = T_ULONGLONG,
- .offset = offsetof(struct pyrf_event, event) + offsetof(struct perf_record_stat_round, type),
- .doc = "round type" },
+ named_member_def(stat_round_type, perf_record_stat_round, type, T_ULONGLONG, "round type"),
member_def(perf_record_stat_round, time, T_ULONGLONG, "round time"),
{ .name = NULL, },
};
static PyObject *pyrf_stat_round_event__repr(const struct pyrf_event *pevent)
{
+ const union perf_event *event = pevent->event ?: &zero_event;
+
return PyUnicode_FromFormat("{ type: stat_round, type: %llu, time: %llu }",
- pevent->event.stat_round.type,
- pevent->event.stat_round.time);
+ event->stat_round.type,
+ event->stat_round.time);
}
static PyTypeObject pyrf_stat_round_event__type = {
@@ -548,14 +596,13 @@ static PyTypeObject pyrf_stat_round_event__type = {
.tp_dealloc = (destructor)pyrf_event__delete,
.tp_flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE,
.tp_doc = pyrf_stat_round_event__doc,
- .tp_members = pyrf_stat_round_event__members,
- .tp_getset = pyrf_event__getset,
+ .tp_getset = pyrf_stat_round_event__getset,
.tp_repr = (reprfunc)pyrf_stat_round_event__repr,
};
static const char pyrf_read_event__doc[] = PyDoc_STR("perf read event object.");
-static PyMemberDef pyrf_read_event__members[] = {
+static PyGetSetDef pyrf_read_event__getset[] = {
sample_members
member_def(perf_event_header, type, T_UINT, "event type"),
member_def(perf_record_read, pid, T_UINT, "event pid"),
@@ -566,8 +613,8 @@ static PyMemberDef pyrf_read_event__members[] = {
static PyObject *pyrf_read_event__repr(const struct pyrf_event *pevent)
{
return PyUnicode_FromFormat("{ type: read, pid: %u, tid: %u }",
- pevent->event.read.pid,
- pevent->event.read.tid);
+ pevent->event->read.pid,
+ pevent->event->read.tid);
/*
* FIXME: return the array of read values,
* making this method useful ;-)
@@ -581,27 +628,12 @@ static PyTypeObject pyrf_read_event__type = {
.tp_dealloc = (destructor)pyrf_event__delete,
.tp_flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE,
.tp_doc = pyrf_read_event__doc,
- .tp_members = pyrf_read_event__members,
- .tp_getset = pyrf_event__getset,
+ .tp_getset = pyrf_read_event__getset,
.tp_repr = (reprfunc)pyrf_read_event__repr,
};
static const char pyrf_sample_event__doc[] = PyDoc_STR("perf sample event object.");
-static PyMemberDef pyrf_sample_event__members[] = {
- sample_members
- sample_member_def(sample_ip, ip, T_ULONGLONG, "event ip"),
- sample_member_def(sample_addr, addr, T_ULONGLONG, "event addr"),
- sample_member_def(sample_phys_addr, phys_addr, T_ULONGLONG, "event physical addr"),
- sample_member_def(sample_weight, weight, T_ULONGLONG, "event weight"),
- sample_member_def(sample_data_src, data_src, T_ULONGLONG, "event data source"),
- sample_member_def(sample_insn_count, insn_cnt, T_ULONGLONG, "event instruction count"),
- sample_member_def(sample_cyc_count, cyc_cnt, T_ULONGLONG, "event cycle count"),
- sample_member_def(flags, flags, T_UINT, "event flags"),
- member_def(perf_event_header, type, T_UINT, "event type"),
- { .name = NULL, },
-};
-
static PyObject *pyrf_sample_event__repr(const struct pyrf_event *pevent)
{
PyObject *ret;
@@ -619,16 +651,18 @@ static PyObject *pyrf_sample_event__repr(const struct pyrf_event *pevent)
#ifdef HAVE_LIBTRACEEVENT
static bool is_tracepoint(const struct pyrf_event *pevent)
{
- if (!pevent->sample.evsel)
+ struct evsel *evsel = pevent->sample->evsel ?: pevent->evsel;
+
+ if (!evsel)
return false;
- return pevent->sample.evsel->core.attr.type == PERF_TYPE_TRACEPOINT;
+ return evsel->core.attr.type == PERF_TYPE_TRACEPOINT;
}
static PyObject*
tracepoint_field(const struct pyrf_event *pe, struct tep_format_field *field)
{
struct tep_handle *pevent = field->event->tep;
- void *data = pe->sample.raw_data;
+ void *data = pe->sample->raw_data;
PyObject *ret = NULL;
unsigned long long val;
unsigned int offset, len;
@@ -668,7 +702,7 @@ tracepoint_field(const struct pyrf_event *pe, struct tep_format_field *field)
static PyObject*
get_tracepoint_field(struct pyrf_event *pevent, PyObject *attr_name)
{
- struct evsel *evsel = pevent->sample.evsel;
+ struct evsel *evsel = pevent->sample->evsel ?: pevent->evsel;
struct tep_event *tp_format = evsel__tp_format(evsel);
struct tep_format_field *field;
@@ -693,7 +727,7 @@ get_tracepoint_field(struct pyrf_event *pevent, PyObject *attr_name)
static int pyrf_sample_event__resolve_al(struct pyrf_event *pevent)
{
- struct evsel *evsel = pevent->sample.evsel;
+ struct evsel *evsel = pevent->sample->evsel ?: pevent->evsel;
struct evlist *evlist = evsel ? evsel->evlist : NULL;
struct perf_session *session = evlist ? evlist__session(evlist) : NULL;
struct machine *machine;
@@ -714,7 +748,7 @@ static int pyrf_sample_event__resolve_al(struct pyrf_event *pevent)
machine = pevent->machine ? pevent->machine : &session->machines.host;
addr_location__init(&pevent->al);
- if (machine__resolve(machine, &pevent->al, &pevent->sample) < 0) {
+ if (machine__resolve(machine, &pevent->al, pevent->sample) < 0) {
addr_location__exit(&pevent->al);
return -1;
}
@@ -819,17 +853,17 @@ static PyObject *pyrf_sample_event__get_sym_end(struct pyrf_event *pevent,
static PyObject *pyrf_sample_event__get_raw_buf(struct pyrf_event *pevent,
void *closure __maybe_unused)
{
- if (pevent->event.header.type != PERF_RECORD_SAMPLE)
+ if (pevent->event->header.type != PERF_RECORD_SAMPLE)
Py_RETURN_NONE;
- return PyBytes_FromStringAndSize((const char *)pevent->sample.raw_data,
- pevent->sample.raw_size);
+ return PyBytes_FromStringAndSize((const char *)pevent->sample->raw_data,
+ pevent->sample->raw_size);
}
static PyObject *pyrf_sample_event__srccode(PyObject *self, PyObject *args)
{
struct pyrf_event *pevent = (void *)self;
- u64 addr = pevent->sample.ip;
+ u64 addr = pevent->sample->ip;
char *srcfile = NULL;
char *srccode = NULL;
unsigned int line = 0;
@@ -838,8 +872,8 @@ static PyObject *pyrf_sample_event__srccode(PyObject *self, PyObject *args)
struct addr_location al;
/*
- * addr defaults to pevent->sample.ip when omitted, and when
- * addr != pevent->sample.ip a local addr_location is resolved so
+ * addr defaults to pevent->sample->ip when omitted, and when
+ * addr != pevent->sample->ip a local addr_location is resolved so
* callers can inspect callchain/branch addresses without mutating
* the sample's cached pevent->al.
*/
@@ -849,9 +883,9 @@ static PyObject *pyrf_sample_event__srccode(PyObject *self, PyObject *args)
if (pyrf_sample_event__resolve_al(pevent) < 0)
Py_RETURN_NONE;
- if (addr != pevent->sample.ip) {
+ if (addr != pevent->sample->ip) {
addr_location__init(&al);
- thread__find_symbol_fb(pevent->al.thread, pevent->sample.cpumode, addr, &al);
+ thread__find_symbol_fb(pevent->al.thread, pevent->sample->cpumode, addr, &al);
} else {
addr_location__init(&al);
al.thread = thread__get(pevent->al.thread);
@@ -905,14 +939,14 @@ static PyObject *pyrf_sample_event__insn(PyObject *self, PyObject *args __maybe_
if (!machine)
Py_RETURN_NONE;
- if (pevent->sample.ip && !pevent->sample.insn_len)
- perf_sample__fetch_insn(&pevent->sample, thread, machine);
+ if (pevent->sample->ip && !pevent->sample->insn_len)
+ perf_sample__fetch_insn(pevent->sample, thread, machine);
- if (!pevent->sample.insn_len)
+ if (!pevent->sample->insn_len)
Py_RETURN_NONE;
- return PyBytes_FromStringAndSize((const char *)pevent->sample.insn,
- pevent->sample.insn_len);
+ return PyBytes_FromStringAndSize((const char *)pevent->sample->insn,
+ pevent->sample->insn_len);
}
struct pyrf_callchain_node {
@@ -1242,8 +1276,8 @@ static int pyrf_sample_event__resolve_addr_al(struct pyrf_event *pevent,
if (pyrf_sample_event__resolve_al(pevent) < 0 || !pevent->al.thread)
return -1;
- thread__find_symbol_fb(pevent->al.thread, pevent->sample.cpumode,
- pevent->sample.addr, addr_al);
+ thread__find_symbol_fb(pevent->al.thread, pevent->sample->cpumode,
+ pevent->sample->addr, addr_al);
return 0;
}
@@ -1295,23 +1329,32 @@ static PyObject *pyrf_sample_event__get_addr_sym_offset(struct pyrf_event *peven
static PyObject *pyrf_sample_event__get_branch_type(struct pyrf_event *pevent,
void *closure __maybe_unused)
{
- return PyLong_FromUnsignedLong(pevent->sample.flags & PERF_BRANCH_MASK);
+ return PyLong_FromUnsignedLong(pevent->sample->flags & PERF_BRANCH_MASK);
}
static PyObject *pyrf_sample_event__get_in_tx(struct pyrf_event *pevent,
void *closure __maybe_unused)
{
- return PyLong_FromUnsignedLong(!!(pevent->sample.flags & PERF_IP_FLAG_IN_TX));
+ return PyLong_FromUnsignedLong(!!(pevent->sample->flags & PERF_IP_FLAG_IN_TX));
}
static PyObject *pyrf_sample_event__get_transaction(struct pyrf_event *pevent,
void *closure __maybe_unused)
{
- return PyLong_FromUnsignedLongLong(pevent->sample.transaction);
+ return PyLong_FromUnsignedLongLong(pevent->sample->transaction);
}
static PyGetSetDef pyrf_sample_event__getset[] = {
-
+ sample_members
+ sample_member_def(sample_ip, ip, T_ULONGLONG, "event ip"),
+ sample_member_def(sample_addr, addr, T_ULONGLONG, "event addr"),
+ sample_member_def(sample_phys_addr, phys_addr, T_ULONGLONG, "event physical addr"),
+ sample_member_def(sample_weight, weight, T_ULONGLONG, "event weight"),
+ sample_member_def(sample_data_src, data_src, T_ULONGLONG, "event data source"),
+ sample_member_def(sample_insn_count, insn_cnt, T_ULONGLONG, "event instruction count"),
+ sample_member_def(sample_cyc_count, cyc_cnt, T_ULONGLONG, "event cycle count"),
+ sample_member_def(flags, flags, T_UINT, "event flags"),
+ member_def(perf_event_header, type, T_UINT, "event type"),
{
.name = "addr_dso",
.get = (getter)pyrf_sample_event__get_addr_dso,
@@ -1361,12 +1404,6 @@ static PyGetSetDef pyrf_sample_event__getset[] = {
.set = NULL,
.doc = "event raw buffer.",
},
- {
- .name = "evsel",
- .get = pyrf_event__get_evsel,
- .set = NULL,
- .doc = "tracking event.",
- },
{
.name = "dso",
.get = (getter)pyrf_sample_event__get_dso,
@@ -1453,7 +1490,6 @@ static PyTypeObject pyrf_sample_event__type = {
.tp_dealloc = (destructor)pyrf_event__delete,
.tp_flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE,
.tp_doc = pyrf_sample_event__doc,
- .tp_members = pyrf_sample_event__members,
.tp_getset = pyrf_sample_event__getset,
.tp_methods = pyrf_sample_event__methods,
.tp_repr = (reprfunc)pyrf_sample_event__repr,
@@ -1462,36 +1498,26 @@ static PyTypeObject pyrf_sample_event__type = {
static const char pyrf_context_switch_event__doc[] = PyDoc_STR("perf context_switch event object.");
-static PyMemberDef pyrf_context_switch_event__members[] = {
- sample_members
- member_def(perf_event_header, type, T_UINT, "event type"),
- member_def(perf_event_header, misc, T_USHORT, "event misc"),
- { .name = NULL, },
-};
-
static PyObject *pyrf_context_switch_event__get_next_prev_pid(const struct pyrf_event *pevent,
void *closure __maybe_unused)
{
- if (pevent->event.header.type == PERF_RECORD_SWITCH_CPU_WIDE)
- return PyLong_FromUnsignedLong(pevent->event.context_switch.next_prev_pid);
+ if (pevent->event->header.type == PERF_RECORD_SWITCH_CPU_WIDE)
+ return PyLong_FromUnsignedLong(pevent->event->context_switch.next_prev_pid);
Py_RETURN_NONE;
}
static PyObject *pyrf_context_switch_event__get_next_prev_tid(const struct pyrf_event *pevent,
void *closure __maybe_unused)
{
- if (pevent->event.header.type == PERF_RECORD_SWITCH_CPU_WIDE)
- return PyLong_FromUnsignedLong(pevent->event.context_switch.next_prev_tid);
+ if (pevent->event->header.type == PERF_RECORD_SWITCH_CPU_WIDE)
+ return PyLong_FromUnsignedLong(pevent->event->context_switch.next_prev_tid);
Py_RETURN_NONE;
}
static PyGetSetDef pyrf_context_switch_event__getset[] = {
- {
- .name = "evsel",
- .get = pyrf_event__get_evsel,
- .set = NULL,
- .doc = "tracking event.",
- },
+ sample_members
+ member_def(perf_event_header, type, T_UINT, "event type"),
+ member_def(perf_event_header, misc, T_USHORT, "event misc"),
{
.name = "next_prev_pid",
.get = (getter)pyrf_context_switch_event__get_next_prev_pid,
@@ -1513,15 +1539,15 @@ static PyObject *pyrf_context_switch_event__repr(const struct pyrf_event *pevent
char *s;
int res;
- if (pevent->event.header.type == PERF_RECORD_SWITCH_CPU_WIDE) {
+ if (pevent->event->header.type == PERF_RECORD_SWITCH_CPU_WIDE) {
res = asprintf(&s,
"{ type: context_switch, next_prev_pid: %u, next_prev_tid: %u, switch_out: %u }",
- pevent->event.context_switch.next_prev_pid,
- pevent->event.context_switch.next_prev_tid,
- !!(pevent->event.header.misc & PERF_RECORD_MISC_SWITCH_OUT));
+ pevent->event->context_switch.next_prev_pid,
+ pevent->event->context_switch.next_prev_tid,
+ !!(pevent->event->header.misc & PERF_RECORD_MISC_SWITCH_OUT));
} else {
res = asprintf(&s, "{ type: context_switch, switch_out: %u }",
- !!(pevent->event.header.misc & PERF_RECORD_MISC_SWITCH_OUT));
+ !!(pevent->event->header.misc & PERF_RECORD_MISC_SWITCH_OUT));
}
if (res < 0) {
ret = PyErr_NoMemory();
@@ -1539,7 +1565,6 @@ static PyTypeObject pyrf_context_switch_event__type = {
.tp_dealloc = (destructor)pyrf_event__delete,
.tp_flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE,
.tp_doc = pyrf_context_switch_event__doc,
- .tp_members = pyrf_context_switch_event__members,
.tp_getset = pyrf_context_switch_event__getset,
.tp_repr = (reprfunc)pyrf_context_switch_event__repr,
};
@@ -1614,15 +1639,102 @@ static PyTypeObject *pyrf_event__type[] = {
[PERF_RECORD_STAT_ROUND] = &pyrf_stat_round_event__type,
};
+static int pyrf_event__copy(struct pyrf_event *pevent)
+{
+ const union perf_event *orig_event = pevent->event;
+ const struct perf_sample *orig_sample = pevent->sample;
+ size_t copy_size = orig_event->header.size;
+ int err = -EINVAL;
+
+ if (pevent->event_copy)
+ return 0;
+
+ /*
+ * Clear borrowed pointers immediately so that even if copying fails,
+ * pevent does not retain dangling pointers to the caller's stack or
+ * ring buffer.
+ */
+ pevent->event = &zero_event;
+ pevent->sample = &pevent->sample_storage;
+
+ /*
+ * Allocate an extra zero byte so string fields such as mmap/mmap2
+ * filenames or comm strings are always NUL-terminated.
+ */
+ pevent->event_copy = calloc(1, copy_size + 1);
+ if (!pevent->event_copy) {
+ PyErr_NoMemory();
+ return -ENOMEM;
+ }
+
+ memcpy(pevent->event_copy, orig_event, copy_size);
+ pevent->event = pevent->event_copy;
+
+ if (pevent->evsel) {
+ bool needs_swap = pevent->evsel->needs_swap;
+
+ pevent->evsel->needs_swap = false;
+ err = evsel__parse_sample(pevent->evsel, pevent->event_copy,
+ &pevent->sample_storage);
+ pevent->evsel->needs_swap = needs_swap;
+ }
+
+ if (err == 0) {
+ if (orig_sample != &pevent->sample_storage) {
+ if (orig_sample->id)
+ pevent->sample_storage.id = orig_sample->id;
+ if (orig_sample->period)
+ pevent->sample_storage.period = orig_sample->period;
+ if (orig_sample->machine_pid)
+ pevent->sample_storage.machine_pid =
+ orig_sample->machine_pid;
+ if (orig_sample->vcpu != (u32)-1)
+ pevent->sample_storage.vcpu = orig_sample->vcpu;
+ if (orig_sample->insn_len && !pevent->sample_storage.insn_len) {
+ memcpy(pevent->sample_storage.insn, orig_sample->insn,
+ sizeof(pevent->sample_storage.insn));
+ pevent->sample_storage.insn_len = orig_sample->insn_len;
+ }
+ }
+ pevent->sample = &pevent->sample_storage;
+ } else if (orig_sample != &pevent->sample_storage) {
+ /*
+ * Synthesized events (e.g. Intel PT itrace) or events without
+ * an evsel may not have raw sample buffers for
+ * evsel__parse_sample(); copy the scalar fields from the
+ * borrowed sample.
+ */
+ perf_sample__exit(&pevent->sample_storage);
+ pevent->sample_storage = *orig_sample;
+ if (pevent->sample_storage.evsel)
+ pevent->sample_storage.evsel =
+ evsel__get(pevent->sample_storage.evsel);
+ pevent->sample_storage.callchain = NULL;
+ pevent->sample_storage.merged_callchain = false;
+ pevent->sample_storage.raw_data = NULL;
+ pevent->sample_storage.raw_size = 0;
+ pevent->sample_storage.branch_stack = NULL;
+ pevent->sample_storage.user_regs = NULL;
+ pevent->sample_storage.intr_regs = NULL;
+ pevent->sample_storage.user_stack.size = 0;
+ pevent->sample_storage.user_stack.data = NULL;
+ pevent->sample_storage.aux_sample.size = 0;
+ pevent->sample_storage.aux_sample.data = NULL;
+ pevent->sample = &pevent->sample_storage;
+ } else if (pevent->evsel) {
+ PyErr_Format(PyExc_OSError, "perf: can't parse sample, err=%d", err);
+ return err;
+ }
+ return 0;
+}
+
static PyObject *pyrf_event__new(const union perf_event *event, struct evsel *evsel,
struct perf_session *session, struct machine *machine,
struct perf_sample *sample_arg)
{
struct pyrf_event *pevent;
struct perf_sample *sample;
- int err;
u32 min_size;
- bool needs_swap;
if (event->header.type >= ARRAY_SIZE(pyrf_event__type) ||
pyrf_event__type[event->header.type] == NULL) {
@@ -1635,31 +1747,35 @@ static PyObject *pyrf_event__new(const union perf_event *event, struct evsel *ev
event->header.size, event->header.type);
}
- size_t copy_size = event->header.size;
-
- if (copy_size > sizeof(pevent->event)) {
- return PyErr_Format(PyExc_TypeError, "Unexpected event size: %zd < %zu",
- sizeof(pevent->event), copy_size);
+ if (event->header.size > sizeof(union perf_event)) {
+ return PyErr_Format(PyExc_TypeError, "Unexpected event size: %zu < %u",
+ sizeof(union perf_event), event->header.size);
}
pevent = PyObject_New(struct pyrf_event, pyrf_event__type[event->header.type]);
if (pevent == NULL)
return PyErr_NoMemory();
- /* Copy the event for memory safety and initialize variables. */
- memcpy(&pevent->event, event, copy_size);
- if (copy_size < sizeof(pevent->event))
- memset((char *)&pevent->event + copy_size, 0, sizeof(pevent->event) - copy_size);
-
- if (event->header.type == PERF_RECORD_MMAP2)
- pevent->event.mmap2.filename[sizeof(pevent->event.mmap2.filename) - 1] = '\0';
-
- perf_sample__init(&pevent->sample, /*all=*/true);
+ perf_sample__init(&pevent->sample_storage, /*all=*/true);
+ pevent->sample = sample_arg ?: &pevent->sample_storage;
+ pevent->evsel = evsel ? evsel__get(evsel) : NULL;
pevent->machine = machine;
pevent->callchain = NULL;
pevent->brstack = NULL;
pevent->al_resolved = false;
addr_location__init(&pevent->al);
+ pevent->event = event;
+ pevent->event_copy = NULL;
+
+ /*
+ * When called outside a session callback (e.g. pyrf_evlist__read_on_cpu)
+ * or without a pre-parsed sample for an evsel, copy the event and parse
+ * its sample into sample_storage immediately.
+ */
+ if ((!session || (evsel && !sample_arg)) && pyrf_event__copy(pevent) < 0) {
+ Py_DECREF(pevent);
+ return NULL;
+ }
if (!evsel) {
if (!pevent->machine && session)
@@ -1667,53 +1783,25 @@ static PyObject *pyrf_event__new(const union perf_event *event, struct evsel *ev
return (PyObject *)pevent;
}
- /* Parse the sample again so that pointers are within the copied event. */
- needs_swap = evsel->needs_swap;
-
- evsel->needs_swap = false;
- err = evsel__parse_sample(evsel, &pevent->event, &pevent->sample);
- evsel->needs_swap = needs_swap;
- if (err < 0) {
- /*
- * Synthesized events (e.g. Intel PT itrace) may not have raw sample
- * buffers for evsel__parse_sample(); use the pre-parsed sample_arg.
- */
- if (sample_arg) {
- perf_sample__exit(&pevent->sample);
- pevent->sample = *sample_arg;
- if (pevent->sample.evsel)
- pevent->sample.evsel = evsel__get(pevent->sample.evsel);
- pevent->sample.merged_callchain = false;
-
- pevent->sample.user_regs = NULL;
- pevent->sample.intr_regs = NULL;
- pevent->sample.raw_data = NULL;
- pevent->sample.raw_size = 0;
- } else {
- Py_DECREF(pevent);
- return PyErr_Format(PyExc_OSError,
- "perf: can't parse sample, err=%d", err);
- }
- }
- if (session && session->evlist && perf_guest && pevent->sample.id) {
- struct perf_sample_id *sid = evlist__id2sid(session->evlist, pevent->sample.id);
+ sample = pevent->sample;
+ if (session && session->evlist && perf_guest && sample->id) {
+ struct perf_sample_id *sid = evlist__id2sid(session->evlist, sample->id);
if (sid) {
- pevent->sample.machine_pid = sid->machine_pid;
- pevent->sample.vcpu = sid->vcpu.cpu;
+ sample->machine_pid = sid->machine_pid;
+ sample->vcpu = sid->vcpu.cpu;
}
}
if (!machine && session) {
- machine = pevent->sample.machine_pid ?
- machines__find(&session->machines, pevent->sample.machine_pid) :
+ machine = sample->machine_pid ?
+ machines__find(&session->machines, sample->machine_pid) :
&session->machines.host;
if (!machine)
machine = &session->machines.host;
}
pevent->machine = machine;
- if (machine && machine->pid > 0 && !pevent->sample.machine_pid)
- pevent->sample.machine_pid = machine->pid;
- sample = &pevent->sample;
+ if (machine && machine->pid > 0 && !sample->machine_pid)
+ sample->machine_pid = machine->pid;
if (machine && sample->callchain) {
struct addr_location al;
struct callchain_cursor *cursor;
@@ -4524,6 +4612,41 @@ static int pyrf_session__call_return_process(struct call_return *cr,
return 0;
}
+static int pyrf_session__call_event(struct pyrf_session *psession, PyObject *callback,
+ PyObject *pyevent, const char *extra_arg, bool has_extra_arg)
+{
+ PyObject *ret;
+ int err = 0;
+
+ if (has_extra_arg)
+ ret = PyObject_CallFunction(callback, "Oz", pyevent, extra_arg);
+ else
+ ret = PyObject_CallFunctionObjArgs(callback, pyevent, NULL);
+
+ Py_XDECREF(ret);
+ if (Py_REFCNT(pyevent) > 1) {
+ PyObject *exc_type = NULL, *exc_value = NULL, *exc_tb = NULL;
+
+ /*
+ * The Python callback retained a reference to the event
+ * beyond the callback's return (or in an exception frame);
+ * copy the underlying event and sample before process_events
+ * reuses or unmaps its buffer.
+ */
+ if (!ret)
+ PyErr_Fetch(&exc_type, &exc_value, &exc_tb);
+ err = pyrf_event__copy((struct pyrf_event *)pyevent);
+ if (!ret) {
+ PyErr_Clear();
+ PyErr_Restore(exc_type, exc_value, exc_tb);
+ }
+ }
+ Py_DECREF(pyevent);
+ if (!ret || err < 0)
+ return pyrf_session__callback_raised(psession);
+ return 0;
+}
+
static int pyrf_session_tool__sample(const struct perf_tool *tool,
union perf_event *event,
struct perf_sample *sample,
@@ -4575,16 +4698,12 @@ static int pyrf_session_tool__sample(const struct perf_tool *tool,
PyObject *pyevent = pyrf_event__new(event, sample->evsel,
psession->session,
machine, sample);
- PyObject *ret;
if (pyevent == NULL)
return -ENOMEM;
- ret = PyObject_CallFunction(psession->sample, "O", pyevent);
- Py_DECREF(pyevent);
- if (!ret)
- return pyrf_session__callback_raised(psession);
- Py_DECREF(ret);
+ return pyrf_session__call_event(psession, psession->sample, pyevent,
+ /*extra_arg=*/NULL, /*has_extra_arg=*/false);
}
return 0;
}
@@ -4596,7 +4715,6 @@ static int pyrf_session_tool__context_switch(const struct perf_tool *tool,
{
struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
PyObject *pyevent;
- PyObject *ret;
if (pyrf_session__exception_pending()) {
/*
@@ -4607,7 +4725,7 @@ static int pyrf_session_tool__context_switch(const struct perf_tool *tool,
return perf_event__process_switch(tool, event, sample, machine);
}
- pyevent = pyrf_event__new(event, sample->evsel, psession->session, machine, NULL);
+ pyevent = pyrf_event__new(event, sample->evsel, psession->session, machine, sample);
if (perf_event__process_switch(tool, event, sample, machine) < 0) {
Py_XDECREF(pyevent);
return -1;
@@ -4616,12 +4734,8 @@ static int pyrf_session_tool__context_switch(const struct perf_tool *tool,
if (pyevent == NULL)
return -ENOMEM;
- ret = PyObject_CallFunction(psession->context_switch, "O", pyevent);
- Py_DECREF(pyevent);
- if (!ret)
- return pyrf_session__callback_raised(psession);
- Py_DECREF(ret);
- return 0;
+ return pyrf_session__call_event(psession, psession->context_switch, pyevent,
+ /*extra_arg=*/NULL, /*has_extra_arg=*/false);
}
static int pyrf_session_tool__stat(const struct perf_tool *tool,
@@ -4632,7 +4746,6 @@ static int pyrf_session_tool__stat(const struct perf_tool *tool,
struct evsel *evsel = evlist__id2evsel(session->evlist, event->stat.id);
const char *name = evsel ? evsel__name(evsel) : "unknown";
PyObject *pyevent;
- PyObject *ret;
if (pyrf_session__exception_pending())
return 0;
@@ -4642,12 +4755,8 @@ static int pyrf_session_tool__stat(const struct perf_tool *tool,
if (pyevent == NULL)
return -ENOMEM;
- ret = PyObject_CallFunction(psession->stat, "Oz", pyevent, name);
- Py_DECREF(pyevent);
- if (!ret)
- return pyrf_session__callback_raised(psession);
- Py_DECREF(ret);
- return 0;
+ return pyrf_session__call_event(psession, psession->stat, pyevent,
+ name, /*has_extra_arg=*/true);
}
static int pyrf_session_tool__stat_round(const struct perf_tool *tool,
@@ -4656,7 +4765,6 @@ static int pyrf_session_tool__stat_round(const struct perf_tool *tool,
{
struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
PyObject *pyevent;
- PyObject *ret;
if (pyrf_session__exception_pending())
return 0;
@@ -4666,12 +4774,8 @@ static int pyrf_session_tool__stat_round(const struct perf_tool *tool,
if (pyevent == NULL)
return -ENOMEM;
- ret = PyObject_CallFunction(psession->stat, "Oz", pyevent, NULL);
- Py_DECREF(pyevent);
- if (!ret)
- return pyrf_session__callback_raised(psession);
- Py_DECREF(ret);
- return 0;
+ return pyrf_session__call_event(psession, psession->stat, pyevent,
+ /*extra_arg=*/NULL, /*has_extra_arg=*/true);
}
static PyObject *pyrf_session__find_thread(struct pyrf_session *psession, PyObject *args)
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 04/13] perf python: Lazily resolve sample callchains
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
` (2 preceding siblings ...)
2026-10-02 18:26 ` [PATCH v1 03/13] perf python: Lazily copy events and samples from process_events Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 05/13] perf python: Release the GIL while processing session events Ian Rogers
` (8 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
Previously, pyrf_event__new() eagerly resolved the sample's address
location and callchain (including DWARF/FP unwinding and symbol lookup)
for every sample with a callchain, even when the Python callback never
accessed event.callchain (such as in ttimechart.py).
Move callchain resolution to pyrf_sample_event__get_callchain() so that
callchains are only unwound and resolved when event.callchain is
accessed, reusing the sample's cached pevent->al via
pyrf_sample_event__resolve_al().
Because process_events() borrows the event and sample during the
callback, callchains are never copied in the common case. Only when a
callback retains a reference to the event beyond its return
(Py_REFCNT(pyevent) > 1), the callchain was not already resolved during
the callback, and the callchain is synthesized or LBR-merged (not backed
by event_copy) does pyrf_event__copy() allocate a copy of
sample->callchain.
Assisted-by: Antigravity:gemini-3.1-pro
Signed-off-by: Ian Rogers <irogers@google.com>
---
tools/perf/util/python.c | 160 +++++++++++++++++++--------------------
1 file changed, 79 insertions(+), 81 deletions(-)
diff --git a/tools/perf/util/python.c b/tools/perf/util/python.c
index a40e89c1b329..251b7d517ad5 100644
--- a/tools/perf/util/python.c
+++ b/tools/perf/util/python.c
@@ -100,7 +100,7 @@ struct pyrf_event {
struct addr_location al;
/** @al_resolved: True when machine__resolve been called. */
bool al_resolved;
- /** @callchain: Resolved callchain, eagerly computed if requested. */
+ /** @callchain: Resolved callchain, lazily computed. */
PyObject *callchain;
/** @brstack: Resolved branch stack, eagerly computed if requested. */
PyObject *brstack;
@@ -727,28 +727,19 @@ get_tracepoint_field(struct pyrf_event *pevent, PyObject *attr_name)
static int pyrf_sample_event__resolve_al(struct pyrf_event *pevent)
{
- struct evsel *evsel = pevent->sample->evsel ?: pevent->evsel;
- struct evlist *evlist = evsel ? evsel->evlist : NULL;
- struct perf_session *session = evlist ? evlist__session(evlist) : NULL;
- struct machine *machine;
-
if (pevent->al_resolved)
return 0;
- if (!session)
- return -1;
-
/*
- * Use pevent->machine, which pyrf_event__new() initializes either from
- * the machine resolved by perf_session (machines__find_for_cpumode(),
- * preserving DEFAULT_GUEST_KERNEL_ID == 0 for default guests) or via
- * sample.machine_pid when pyrf_event__new() is called with a NULL
- * machine.
+ * Use pevent->machine, which pyrf_event__new() initializes while the
+ * session callback is active and pyrf_event__copy() clears after
+ * resolving pevent->al if a reference is retained beyond the callback.
*/
- machine = pevent->machine ? pevent->machine : &session->machines.host;
+ if (!pevent->machine)
+ return -1;
addr_location__init(&pevent->al);
- if (machine__resolve(machine, &pevent->al, pevent->sample) < 0) {
+ if (machine__resolve(pevent->machine, &pevent->al, pevent->sample) < 0) {
addr_location__exit(&pevent->al);
return -1;
}
@@ -924,23 +915,15 @@ static PyObject *pyrf_sample_event__srccode(PyObject *self, PyObject *args)
static PyObject *pyrf_sample_event__insn(PyObject *self, PyObject *args __maybe_unused)
{
struct pyrf_event *pevent = (void *)self;
- struct thread *thread;
- struct machine *machine;
- if (pyrf_sample_event__resolve_al(pevent) < 0)
- Py_RETURN_NONE;
-
- thread = pevent->al.thread;
-
- if (!thread || !thread__maps(thread))
- Py_RETURN_NONE;
-
- machine = maps__machine(thread__maps(thread));
- if (!machine)
- Py_RETURN_NONE;
+ if (pevent->sample->ip && !pevent->sample->insn_len) {
+ if (pyrf_sample_event__resolve_al(pevent) < 0 ||
+ !pevent->al.thread || !pevent->machine)
+ Py_RETURN_NONE;
- if (pevent->sample->ip && !pevent->sample->insn_len)
- perf_sample__fetch_insn(pevent->sample, thread, machine);
+ perf_sample__fetch_insn(pevent->sample, pevent->al.thread,
+ pevent->machine);
+ }
if (!pevent->sample->insn_len)
Py_RETURN_NONE;
@@ -1097,10 +1080,53 @@ static PyTypeObject pyrf_callchain__type = {
.tp_as_sequence = &pyrf_callchain__sequence_methods,
};
+static int pyrf_sample_event__resolve_callchain(struct pyrf_event *pevent)
+{
+ struct callchain_cursor *cursor;
+ struct pyrf_callchain *pchain;
+ struct callchain_cursor_node *node;
+
+ if (pevent->callchain || !pevent->sample->callchain || !pevent->machine)
+ return 0;
+
+ if (pyrf_sample_event__resolve_al(pevent) < 0)
+ return 0;
+
+ cursor = get_tls_callchain_cursor();
+ if (thread__resolve_callchain(pevent->al.thread, cursor, pevent->sample,
+ NULL, NULL, PERF_MAX_STACK_DEPTH) != 0)
+ return 0;
+
+ callchain_cursor_commit(cursor);
+ pchain = PyObject_New(struct pyrf_callchain, &pyrf_callchain__type);
+ if (!pchain)
+ return -ENOMEM;
+
+ pchain->nr_frames = cursor->nr;
+ pchain->frames = calloc(pchain->nr_frames, sizeof(*pchain->frames));
+ if (!pchain->frames) {
+ Py_DECREF(pchain);
+ PyErr_NoMemory();
+ return -ENOMEM;
+ }
+ for (u64 i = 0; i < pchain->nr_frames; i++) {
+ node = callchain_cursor_current(cursor);
+ pchain->frames[i].ip = node->ip;
+ pchain->frames[i].map = map__get(node->ms.map);
+ pchain->frames[i].sym = node->ms.sym;
+ callchain_cursor_advance(cursor);
+ }
+ pevent->callchain = (PyObject *)pchain;
+ return 0;
+}
+
static PyObject *pyrf_sample_event__get_callchain(PyObject *self, void *closure __maybe_unused)
{
struct pyrf_event *pevent = (void *)self;
+ if (pyrf_sample_event__resolve_callchain(pevent) < 0)
+ return NULL;
+
if (!pevent->callchain)
Py_RETURN_NONE;
@@ -1646,13 +1672,31 @@ static int pyrf_event__copy(struct pyrf_event *pevent)
size_t copy_size = orig_event->header.size;
int err = -EINVAL;
+ /*
+ * If a session callback retained a reference to pevent, resolve its
+ * address location and callchain now while pevent->machine is still
+ * live and thread->maps reflects the point in time of the sample.
+ */
+ if (pevent->machine && pevent->evsel) {
+ pyrf_sample_event__resolve_al(pevent);
+ if (pyrf_sample_event__resolve_callchain(pevent) < 0) {
+ pevent->machine = NULL;
+ if (!pevent->event_copy) {
+ pevent->event = &zero_event;
+ pevent->sample = &pevent->sample_storage;
+ }
+ return -ENOMEM;
+ }
+ }
+ pevent->machine = NULL;
+
if (pevent->event_copy)
return 0;
/*
* Clear borrowed pointers immediately so that even if copying fails,
- * pevent does not retain dangling pointers to the caller's stack or
- * ring buffer.
+ * pevent does not retain dangling pointers to the caller's stack,
+ * ring buffer, or session.
*/
pevent->event = &zero_event;
pevent->sample = &pevent->sample_storage;
@@ -1759,7 +1803,7 @@ static PyObject *pyrf_event__new(const union perf_event *event, struct evsel *ev
perf_sample__init(&pevent->sample_storage, /*all=*/true);
pevent->sample = sample_arg ?: &pevent->sample_storage;
pevent->evsel = evsel ? evsel__get(evsel) : NULL;
- pevent->machine = machine;
+ pevent->machine = NULL;
pevent->callchain = NULL;
pevent->brstack = NULL;
pevent->al_resolved = false;
@@ -1777,11 +1821,8 @@ static PyObject *pyrf_event__new(const union perf_event *event, struct evsel *ev
return NULL;
}
- if (!evsel) {
- if (!pevent->machine && session)
- pevent->machine = &session->machines.host;
+ if (!evsel)
return (PyObject *)pevent;
- }
sample = pevent->sample;
if (session && session->evlist && perf_guest && sample->id) {
@@ -1802,49 +1843,6 @@ static PyObject *pyrf_event__new(const union perf_event *event, struct evsel *ev
pevent->machine = machine;
if (machine && machine->pid > 0 && !sample->machine_pid)
sample->machine_pid = machine->pid;
- if (machine && sample->callchain) {
- struct addr_location al;
- struct callchain_cursor *cursor;
- u64 i;
- struct pyrf_callchain *pchain;
-
- addr_location__init(&al);
- if (machine__resolve(machine, &al, sample) >= 0) {
- cursor = get_tls_callchain_cursor();
- if (thread__resolve_callchain(al.thread, cursor, sample,
- NULL, NULL, PERF_MAX_STACK_DEPTH) == 0) {
- callchain_cursor_commit(cursor);
-
- pchain = PyObject_New(struct pyrf_callchain, &pyrf_callchain__type);
- if (!pchain) {
- addr_location__exit(&al);
- Py_DECREF(pevent);
- return NULL;
- }
- pchain->nr_frames = cursor->nr;
- pchain->frames = calloc(pchain->nr_frames,
- sizeof(*pchain->frames));
- if (!pchain->frames) {
- Py_DECREF(pchain);
- addr_location__exit(&al);
- Py_DECREF(pevent);
- return PyErr_NoMemory();
- }
- struct callchain_cursor_node *node;
-
- for (i = 0; i < pchain->nr_frames; i++) {
- node = callchain_cursor_current(cursor);
- pchain->frames[i].ip = node->ip;
- pchain->frames[i].map =
- map__get(node->ms.map);
- pchain->frames[i].sym = node->ms.sym;
- callchain_cursor_advance(cursor);
- }
- pevent->callchain = (PyObject *)pchain;
- }
- addr_location__exit(&al);
- }
- }
if (sample->branch_stack) {
struct branch_stack *bs = sample->branch_stack;
struct branch_entry *entries = perf_sample__branch_entries(sample);
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 05/13] perf python: Release the GIL while processing session events
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
` (3 preceding siblings ...)
2026-10-02 18:26 ` [PATCH v1 04/13] perf python: Lazily resolve sample callchains Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 06/13] perf list: Add a --tui option to launch ilist Ian Rogers
` (7 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
When a python session reads from a pipe, perf_session__new() and
perf_session__process_events() block waiting for data with the GIL held,
freezing any other python threads such as a TUI thread. Release the GIL
around both calls and acquire it with PyGILState_Ensure() in the tool and
call-return callbacks that invoke python.
Assisted-by: Antigravity:gemini-3.1-pro
Signed-off-by: Ian Rogers <irogers@google.com>
---
tools/perf/util/python.c | 258 ++++++++++++++++++++++++++++++++-------
1 file changed, 215 insertions(+), 43 deletions(-)
diff --git a/tools/perf/util/python.c b/tools/perf/util/python.c
index 251b7d517ad5..4eb17bdc5fd1 100644
--- a/tools/perf/util/python.c
+++ b/tools/perf/util/python.c
@@ -52,6 +52,7 @@
#include "srccode.h"
#include "srcline.h"
#include "strbuf.h"
+#include "strlist.h"
#include "symbol.h"
#include "stat.h"
#include "header.h"
@@ -4104,16 +4105,38 @@ static void pyrf_thread__delete(struct pyrf_thread *pthread)
static PyObject *pyrf_thread__comm(PyObject *obj)
{
struct pyrf_thread *pthread = (void *)obj;
- const char *str;
+ struct thread *thread = pthread->thread;
+ bool has_comm = false;
+ char *str = NULL;
+ PyObject *ret;
- CHECK_INITIALIZED(pthread->thread, "perf.thread");
+ CHECK_INITIALIZED(thread, "perf.thread");
- str = thread__comm_str(pthread->thread);
+ /*
+ * Copy the comm string under comm_lock so a concurrent PERF_RECORD_COMM
+ * in perf_session__process_events() (which runs with the GIL released)
+ * cannot free the old comm_str via comm__override() while it is being
+ * read, and drop comm_lock before calling into the Python allocator.
+ */
+ down_read(thread__comm_lock(thread));
+ if (!list_empty(thread__comm_list(thread))) {
+ const struct comm *comm;
+
+ comm = list_first_entry(thread__comm_list(thread), struct comm, list);
+ if (comm && comm__str(comm)) {
+ has_comm = true;
+ str = strdup(comm__str(comm));
+ }
+ }
+ up_read(thread__comm_lock(thread));
- if (!str)
+ if (!has_comm)
Py_RETURN_NONE;
-
- return PyUnicode_FromString(str);
+ if (!str)
+ return PyErr_NoMemory();
+ ret = PyUnicode_FromString(str);
+ free(str);
+ return ret;
}
static PyMethodDef pyrf_thread__methods[] = {
@@ -4211,6 +4234,8 @@ struct pyrf_session {
* that event processing stops, see pyrf_session__callback_raised().
*/
bool callback_raised;
+ unsigned int callback_depth;
+ unsigned long callback_thread;
char *vmlinux_name;
char *kallsyms_name;
char *symfs;
@@ -4579,11 +4604,10 @@ static bool pyrf_session__exception_pending(void)
return PyErr_Occurred() != NULL;
}
-static int pyrf_session__call_return_process(struct call_return *cr,
- u64 *parent_db_id,
- void *data)
+static int __pyrf_session__call_return_process(struct call_return *cr,
+ u64 *parent_db_id,
+ struct pyrf_session *psession)
{
- struct pyrf_session *psession = data;
PyObject *pyevent, *ret;
if (!psession->call_return || pyrf_session__exception_pending())
@@ -4610,6 +4634,38 @@ static int pyrf_session__call_return_process(struct call_return *cr,
return 0;
}
+static PyGILState_STATE pyrf_session__callback_enter(struct pyrf_session *psession)
+{
+ PyGILState_STATE gstate = PyGILState_Ensure();
+
+ psession->callback_thread = PyThread_get_thread_ident();
+ psession->callback_depth++;
+ return gstate;
+}
+
+static void pyrf_session__callback_leave(struct pyrf_session *psession,
+ PyGILState_STATE gstate)
+{
+ psession->callback_depth--;
+ PyGILState_Release(gstate);
+}
+
+/*
+ * Call returns may be processed during a sample callback, or when thread
+ * stacks are flushed, so acquire the GIL, see pyrf_session_tool__sample().
+ */
+static int pyrf_session__call_return_process(struct call_return *cr,
+ u64 *parent_db_id,
+ void *data)
+{
+ struct pyrf_session *psession = data;
+ PyGILState_STATE gstate = pyrf_session__callback_enter(psession);
+ int ret = __pyrf_session__call_return_process(cr, parent_db_id, psession);
+
+ pyrf_session__callback_leave(psession, gstate);
+ return ret;
+}
+
static int pyrf_session__call_event(struct pyrf_session *psession, PyObject *callback,
PyObject *pyevent, const char *extra_arg, bool has_extra_arg)
{
@@ -4645,10 +4701,10 @@ static int pyrf_session__call_event(struct pyrf_session *psession, PyObject *cal
return 0;
}
-static int pyrf_session_tool__sample(const struct perf_tool *tool,
- union perf_event *event,
- struct perf_sample *sample,
- struct machine *machine)
+static int __pyrf_session_tool__sample(const struct perf_tool *tool,
+ union perf_event *event,
+ struct perf_sample *sample,
+ struct machine *machine)
{
struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
u64 sample_db_id = ++psession->sample_last_db_id;
@@ -4706,10 +4762,10 @@ static int pyrf_session_tool__sample(const struct perf_tool *tool,
return 0;
}
-static int pyrf_session_tool__context_switch(const struct perf_tool *tool,
- union perf_event *event,
- struct perf_sample *sample,
- struct machine *machine)
+static int __pyrf_session_tool__context_switch(const struct perf_tool *tool,
+ union perf_event *event,
+ struct perf_sample *sample,
+ struct machine *machine)
{
struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
PyObject *pyevent;
@@ -4736,9 +4792,9 @@ static int pyrf_session_tool__context_switch(const struct perf_tool *tool,
/*extra_arg=*/NULL, /*has_extra_arg=*/false);
}
-static int pyrf_session_tool__stat(const struct perf_tool *tool,
- struct perf_session *session,
- union perf_event *event)
+static int __pyrf_session_tool__stat(const struct perf_tool *tool,
+ struct perf_session *session,
+ union perf_event *event)
{
struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
struct evsel *evsel = evlist__id2evsel(session->evlist, event->stat.id);
@@ -4757,9 +4813,9 @@ static int pyrf_session_tool__stat(const struct perf_tool *tool,
name, /*has_extra_arg=*/true);
}
-static int pyrf_session_tool__stat_round(const struct perf_tool *tool,
- struct perf_session *session __maybe_unused,
- union perf_event *event)
+static int __pyrf_session_tool__stat_round(const struct perf_tool *tool,
+ struct perf_session *session __maybe_unused,
+ union perf_event *event)
{
struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
PyObject *pyevent;
@@ -4776,6 +4832,84 @@ static int pyrf_session_tool__stat_round(const struct perf_tool *tool,
/*extra_arg=*/NULL, /*has_extra_arg=*/true);
}
+/*
+ * The session currently inside perf_session__new() or
+ * perf_session__process_events() with the GIL released. Because symbol_conf
+ * and session_done are process-globals and perf_session is not thread-safe,
+ * only one session may be active at a time. Modified with the GIL held.
+ */
+static struct pyrf_session *pyrf_session__active;
+
+static bool pyrf_session__check_not_busy(struct pyrf_session *psession)
+{
+ if (pyrf_session__active == psession &&
+ (!psession->callback_depth ||
+ psession->callback_thread != PyThread_get_thread_ident())) {
+ PyErr_SetString(PyExc_RuntimeError,
+ "perf.session is busy processing events in another thread");
+ return false;
+ }
+ return true;
+}
+
+/*
+ * The GIL is released while events are processed, see
+ * pyrf_session__find_thread_events(), so that other python threads can run,
+ * such as a UI thread while a session reads from a pipe that may block. The
+ * tool callbacks that use python acquire the GIL, PyGILState_Ensure() is
+ * reentrant so nested callbacks, like call returns during a sample, work.
+ */
+
+static int pyrf_session_tool__sample(const struct perf_tool *tool,
+ union perf_event *event,
+ struct perf_sample *sample,
+ struct machine *machine)
+{
+ struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
+ PyGILState_STATE gstate = pyrf_session__callback_enter(psession);
+ int ret = __pyrf_session_tool__sample(tool, event, sample, machine);
+
+ pyrf_session__callback_leave(psession, gstate);
+ return ret;
+}
+
+static int pyrf_session_tool__context_switch(const struct perf_tool *tool,
+ union perf_event *event,
+ struct perf_sample *sample,
+ struct machine *machine)
+{
+ struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
+ PyGILState_STATE gstate = pyrf_session__callback_enter(psession);
+ int ret = __pyrf_session_tool__context_switch(tool, event, sample, machine);
+
+ pyrf_session__callback_leave(psession, gstate);
+ return ret;
+}
+
+static int pyrf_session_tool__stat(const struct perf_tool *tool,
+ struct perf_session *session,
+ union perf_event *event)
+{
+ struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
+ PyGILState_STATE gstate = pyrf_session__callback_enter(psession);
+ int ret = __pyrf_session_tool__stat(tool, session, event);
+
+ pyrf_session__callback_leave(psession, gstate);
+ return ret;
+}
+
+static int pyrf_session_tool__stat_round(const struct perf_tool *tool,
+ struct perf_session *session,
+ union perf_event *event)
+{
+ struct pyrf_session *psession = container_of(tool, struct pyrf_session, tool);
+ PyGILState_STATE gstate = pyrf_session__callback_enter(psession);
+ int ret = __pyrf_session_tool__stat_round(tool, session, event);
+
+ pyrf_session__callback_leave(psession, gstate);
+ return ret;
+}
+
static PyObject *pyrf_session__find_thread(struct pyrf_session *psession, PyObject *args)
{
struct machine *machine;
@@ -4784,6 +4918,8 @@ static PyObject *pyrf_session__find_thread(struct pyrf_session *psession, PyObje
int pid, tid = -1;
CHECK_INITIALIZED(psession->session, "session");
+ if (!pyrf_session__check_not_busy(psession))
+ return NULL;
if (!PyArg_ParseTuple(args, "i|i", &pid, &tid))
return NULL;
@@ -4854,6 +4990,8 @@ static PyObject *pyrf_session__new(PyTypeObject *type, PyObject *args, PyObject
psession->call_return_last_db_id = 0;
psession->sample_last_db_id = 0;
psession->callback_raised = false;
+ psession->callback_depth = 0;
+ psession->callback_thread = 0;
psession->vmlinux_name = NULL;
psession->kallsyms_name = NULL;
psession->symfs = NULL;
@@ -4936,7 +5074,6 @@ static PyObject *pyrf_session__new(PyTypeObject *type, PyObject *args, PyObject
PyErr_NoMemory();
goto err_out;
}
- symbol_conf.vmlinux_name = psession->vmlinux_name;
}
if (!kallsyms_str)
@@ -4947,7 +5084,6 @@ static PyObject *pyrf_session__new(PyTypeObject *type, PyObject *args, PyObject
PyErr_NoMemory();
goto err_out;
}
- symbol_conf.kallsyms_name = psession->kallsyms_name;
}
if (!symfs_str)
@@ -4958,16 +5094,7 @@ static PyObject *pyrf_session__new(PyTypeObject *type, PyObject *args, PyObject
PyErr_NoMemory();
goto err_out;
}
- symbol_conf.symfs = psession->symfs;
- }
-
- session = perf_session__new(&pdata->data, &psession->tool);
- if (IS_ERR(session)) {
- PyErr_Format(PyExc_IOError, "failed to create session: %ld", PTR_ERR(session));
- goto err_out;
}
- psession->session = session;
- psession->session->itrace_synth_opts = &psession->itrace_opts;
if (!itrace_str)
itrace_str = getenv("PERF_ITRACE");
@@ -4975,18 +5102,43 @@ static PyObject *pyrf_session__new(PyTypeObject *type, PyObject *args, PyObject
memset(&psession->itrace_opts, 0, sizeof(psession->itrace_opts));
if (itrace_do_parse_synth_opts(&psession->itrace_opts, itrace_str, 0) < 0) {
PyErr_SetString(PyExc_ValueError, "Failed to parse itrace options");
- Py_DECREF(psession);
- return NULL;
+ goto err_out;
}
}
+ if (pyrf_session__active) {
+ PyErr_SetString(PyExc_RuntimeError,
+ "Another perf.session is currently active");
+ goto err_out;
+ }
+
+ symbol_conf.vmlinux_name = psession->vmlinux_name;
+ symbol_conf.kallsyms_name = psession->kallsyms_name;
+ symbol_conf.symfs = psession->symfs ?: "";
symbol_conf.use_callchain = true;
symbol_conf.show_kernel_path = true;
symbol_conf.inline_name = false;
+
+ /* Reading the header of a pipe may block, let other python threads run. */
+ pyrf_session__active = psession;
+ Py_BEGIN_ALLOW_THREADS
+ session = perf_session__new(&pdata->data, &psession->tool);
+ Py_END_ALLOW_THREADS
+ pyrf_session__active = NULL;
+ if (!psession->kallsyms_name && symbol_conf.kallsyms_name)
+ psession->kallsyms_name = (char *)symbol_conf.kallsyms_name;
+ if (IS_ERR(session)) {
+ PyErr_Format(PyExc_IOError, "failed to create session: %ld", PTR_ERR(session));
+ goto err_out;
+ }
+ psession->session = session;
+ psession->session->itrace_synth_opts = &psession->itrace_opts;
if (symbol__init(perf_session__env(session)) < 0) {
PyErr_SetString(PyExc_OSError, "perf: symbol__init failed");
goto err_out;
}
+ if (psession->symfs && !symbol_conf.symfs[0])
+ zfree(&psession->symfs);
return (PyObject *)psession;
err_out:
@@ -4997,14 +5149,16 @@ static PyObject *pyrf_session__new(PyTypeObject *type, PyObject *args, PyObject
static void pyrf_session__delete(struct pyrf_session *psession)
{
perf_session__delete(psession->session);
- if (symbol_conf.vmlinux_name == psession->vmlinux_name)
- symbol_conf.vmlinux_name = NULL;
+ if (!pyrf_session__active) {
+ if (symbol_conf.vmlinux_name == psession->vmlinux_name)
+ symbol_conf.vmlinux_name = NULL;
+ if (symbol_conf.kallsyms_name == psession->kallsyms_name)
+ symbol_conf.kallsyms_name = NULL;
+ if (symbol_conf.symfs == psession->symfs)
+ symbol_conf.symfs = "";
+ }
free(psession->vmlinux_name);
- if (symbol_conf.kallsyms_name == psession->kallsyms_name)
- symbol_conf.kallsyms_name = NULL;
free(psession->kallsyms_name);
- if (symbol_conf.symfs == psession->symfs)
- symbol_conf.symfs = "";
free(psession->symfs);
free(psession->itrace_opts.vm_tm_corr_args);
free(psession->itrace_opts.cpu_bitmap);
@@ -5025,9 +5179,23 @@ static PyObject *pyrf_session__find_thread_events(struct pyrf_session *psession)
int err;
CHECK_INITIALIZED(psession->session, "session");
+ if (pyrf_session__active) {
+ PyErr_SetString(PyExc_RuntimeError,
+ "Another perf.session is currently active");
+ return NULL;
+ }
+
+ symbol_conf.vmlinux_name = psession->vmlinux_name;
+ symbol_conf.kallsyms_name = psession->kallsyms_name;
+ symbol_conf.symfs = psession->symfs ?: "";
psession->callback_raised = false;
+ /* Callbacks that use python acquire the GIL, see pyrf_session_tool__sample(). */
+ pyrf_session__active = psession;
+ Py_BEGIN_ALLOW_THREADS
err = perf_session__process_events(psession->session);
+ Py_END_ALLOW_THREADS
+ pyrf_session__active = NULL;
if (psession->callback_raised) {
/*
* Clear the early stop requested by this session's callback, in
@@ -5089,6 +5257,8 @@ static PyObject *pyrf_session__getattro(struct pyrf_session *psession, PyObject
PyErr_SetString(PyExc_ValueError, "session not initialized");
return NULL;
}
+ if (!pyrf_session__check_not_busy(psession))
+ return NULL;
if (!strcmp(name_str, "e_machine"))
return PyLong_FromLong(perf_session__e_machine(psession->session,
/*e_flags=*/NULL));
@@ -5139,6 +5309,8 @@ static int pyrf_session__setattro(struct pyrf_session *psession, PyObject *attr_
PyErr_SetString(PyExc_ValueError, "session not initialized");
return -1;
}
+ if (!pyrf_session__check_not_busy(psession))
+ return -1;
return PyObject_GenericSetAttr((PyObject *) psession, attr_name, value);
}
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 06/13] perf list: Add a --tui option to launch ilist
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
` (4 preceding siblings ...)
2026-10-02 18:26 ` [PATCH v1 05/13] perf python: Release the GIL while processing session events Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 07/13] perf test: Add a test for the ilist script Ian Rogers
` (6 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
Add a --tui option to perf list that launches the textual based ilist
script through perf script, similar to perf timechart --tui. Rather
than printing the events, the PMUs, events and metrics can be browsed
interactively and the selected event or metric is counted on each CPU.
Arguments after '--' are passed to the script.
Assisted-by: Antigravity:gemini-3.1-pro
Signed-off-by: Ian Rogers <irogers@google.com>
---
tools/perf/Documentation/perf-list.txt | 10 ++++++++
tools/perf/builtin-list.c | 34 ++++++++++++++++++++++++++
2 files changed, 44 insertions(+)
diff --git a/tools/perf/Documentation/perf-list.txt b/tools/perf/Documentation/perf-list.txt
index a4378a0cd914..17e582998474 100644
--- a/tools/perf/Documentation/perf-list.txt
+++ b/tools/perf/Documentation/perf-list.txt
@@ -10,6 +10,7 @@ SYNOPSIS
[verse]
'perf list' [<options>]
[hw|sw|cache|tracepoint|pmu|sdt|metric|metricgroup|event_glob]
+'perf list' --tui [-- <ilist options>]
DESCRIPTION
-----------
@@ -51,6 +52,15 @@ Output in JSON format.
--output=::
Output file name. By default output is written to stdout.
+--tui::
+Rather than printing the events, interactively browse the PMUs, events and
+metrics in the terminal using the 'ilist' python script (see
+linkperf:perf-script[1]). Selecting an event or metric shows its description
+and counts it on each CPU. The script requires the perf python module and the
+python 'textual' library. Other options are ignored, arguments after '--' are
+passed to the script, for example 'perf list --tui -- -I 0.5' to update the
+counts every 0.5 seconds.
+
[[EVENT_MODIFIERS]]
EVENT MODIFIERS
---------------
diff --git a/tools/perf/builtin-list.c b/tools/perf/builtin-list.c
index 4118424cb364..47668d66c395 100644
--- a/tools/perf/builtin-list.c
+++ b/tools/perf/builtin-list.c
@@ -24,8 +24,10 @@
#include <subcmd/parse-options.h>
#include <linux/zalloc.h>
#include <ctype.h>
+#include <errno.h>
#include <stdarg.h>
#include <stdio.h>
+#include <stdlib.h>
/**
* struct print_state - State and configuration passed to the default_print
@@ -538,6 +540,31 @@ static bool default_skip_duplicate_pmus(void *ps)
return !print_state->long_desc;
}
+/*
+ * Launch the interactive textual based ilist python script via 'perf script'
+ * that finds the script and sets up the python environment. Arguments are
+ * passed to the script.
+ */
+static int list__tui(int argc, const char **argv)
+{
+ const char **script_argv;
+ int script_argc = 0, ret;
+
+ script_argv = calloc(argc + 4, sizeof(*script_argv));
+ if (!script_argv)
+ return -ENOMEM;
+
+ script_argv[script_argc++] = "script";
+ script_argv[script_argc++] = "ilist";
+ script_argv[script_argc++] = "--";
+ for (int i = 0; i < argc; i++)
+ script_argv[script_argc++] = argv[i];
+
+ ret = cmd_script(script_argc, script_argv);
+ free(script_argv);
+ return ret;
+}
+
int cmd_list(int argc, const char **argv)
{
int i, ret = 0;
@@ -562,6 +589,7 @@ int cmd_list(int argc, const char **argv)
const char *unit_name = NULL;
const char *output_path = NULL;
bool json = false;
+ bool use_tui = false;
struct option list_options[] = {
OPT_BOOLEAN(0, "raw-dump", &default_ps.name_only, "Dump raw events"),
OPT_BOOLEAN('j', "json", &json, "JSON encode events and metrics"),
@@ -578,6 +606,8 @@ int cmd_list(int argc, const char **argv)
"Limit PMU or metric printing to the given PMU (e.g. cpu, core or atom)."),
OPT_STRING(0, "unit", &unit_name, "PMU name",
"Limit PMU or metric printing to the specified PMU."),
+ OPT_BOOLEAN(0, "tui", &use_tui,
+ "Interactively browse and count events and metrics with ilist"),
OPT_INCR(0, "debug", &verbose,
"Enable debugging output"),
OPT_END()
@@ -588,6 +618,7 @@ int cmd_list(int argc, const char **argv)
#else
"perf list [<options>] [hw|sw|cache|tracepoint|pmu|sdt|metric|metricgroup|event_glob]",
#endif
+ "perf list --tui [-- <ilist options>]",
NULL
};
@@ -598,6 +629,9 @@ int cmd_list(int argc, const char **argv)
argc = parse_options(argc, argv, list_options, list_usage,
PARSE_OPT_STOP_AT_NON_OPTION);
+ if (use_tui)
+ return list__tui(argc, argv);
+
if (json)
ps = &json_ps.common;
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 07/13] perf test: Add a test for the ilist script
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
` (5 preceding siblings ...)
2026-10-02 18:26 ` [PATCH v1 06/13] perf list: Add a --tui option to launch ilist Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 08/13] perf treport: Show the profile while it loads Ian Rogers
` (5 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
Test launching ilist through perf script and perf list --tui, and drive
the textual app headless: check the tree of PMUs and metrics, search for
and select the software task-clock event checking its counters are
shown, check a failed search shows an error and select a metric. Failing
to open counters, for example for want of permissions, is tolerated as
an error dialog is shown.
Assisted-by: Antigravity:gemini-3.1-pro
Signed-off-by: Ian Rogers <irogers@google.com>
---
tools/perf/tests/shell/test_ilist_python.sh | 181 ++++++++++++++++++++
1 file changed, 181 insertions(+)
create mode 100755 tools/perf/tests/shell/test_ilist_python.sh
diff --git a/tools/perf/tests/shell/test_ilist_python.sh b/tools/perf/tests/shell/test_ilist_python.sh
new file mode 100755
index 000000000000..83048474710d
--- /dev/null
+++ b/tools/perf/tests/shell/test_ilist_python.sh
@@ -0,0 +1,181 @@
+#!/bin/bash
+# perf script ilist and perf list --tui test
+# SPDX-License-Identifier: GPL-2.0
+
+set -e
+
+shelldir=$(dirname "$0")
+# shellcheck source=lib/setup_python.sh
+. "${shelldir}"/lib/setup_python.sh
+
+if ! "$PYTHON" -c 'import perf' > /dev/null 2>&1; then
+ echo "Skipping test, perf python module not found"
+ exit 2
+fi
+
+if ! "$PYTHON" -c 'import textual' > /dev/null 2>&1; then
+ echo "Skipping test, python textual library not found"
+ exit 2
+fi
+
+err=0
+output=$(mktemp /tmp/__perf_ilist_test.output.XXXXX)
+
+cleanup() {
+ rm -f "${output}"
+ trap - EXIT TERM INT
+}
+
+trap_cleanup() {
+ echo "Unexpected signal in ${FUNCNAME[1]}"
+ cleanup
+ exit 1
+}
+trap trap_cleanup EXIT TERM INT
+
+test_plumbing() {
+ echo "ilist plumbing test"
+ # The script should be found and run by perf script, --help doesn't
+ # need a terminal.
+ if ! perf script ilist --help > "${output}" 2>&1; then
+ echo "ilist plumbing test [Failed: script failed]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if ! grep -q -- "--interval" "${output}"; then
+ echo "ilist plumbing test [Failed: script not launched]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "ilist plumbing test [Success]"
+}
+
+test_list_tui() {
+ echo "perf list --tui plumbing test"
+ # Arguments after -- are passed to the script launched by perf list.
+ if ! perf list --tui -- --help > "${output}" 2>&1; then
+ echo "perf list --tui plumbing test [Failed: script failed]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if ! grep -q -- "--interval" "${output}"; then
+ echo "perf list --tui plumbing test [Failed: script not launched]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "perf list --tui plumbing test [Success]"
+}
+
+test_headless_ui() {
+ echo "ilist headless UI test"
+ # Drive the textual app without a terminal. Opening counters may fail
+ # for want of permissions, in which case an error dialog is shown.
+ if ! "$PYTHON" > "${output}" 2>&1 <<'EOF'
+import asyncio
+import ilist
+from textual.widgets import Label, Tree
+
+async def dismiss_error(app, pilot) -> bool:
+ """Close the error dialog if shown, returning whether it was."""
+ if not isinstance(app.screen, ilist.ErrorScreen):
+ return False
+ await pilot.click("#error")
+ await pilot.pause()
+ if isinstance(app.screen, ilist.ErrorScreen):
+ raise RuntimeError("Error dialog not dismissed")
+ return True
+
+async def search(pilot, text: str) -> None:
+ await pilot.press("s")
+ await pilot.pause()
+ await pilot.press(*text, "enter")
+ await pilot.pause()
+
+def find_leaf(node, cls):
+ """Find the first node whose data is of type cls."""
+ if isinstance(node.data, cls):
+ return node
+ for child in node.children:
+ found = find_leaf(child, cls)
+ if found:
+ return found
+ return None
+
+async def check_counting(app, pilot, name: str) -> None:
+ """Check the selected event is shown and counting, or that it failed to open."""
+ await pilot.pause(0.5)
+ if await dismiss_error(app, pilot):
+ print(f"{name}: failed to open")
+ return
+ shown = str(app.query_one("#event_name", Label).render())
+ if shown != name:
+ raise RuntimeError(f"Selected {shown} rather than {name}")
+ if not app.query(ilist.CounterSparkline) or not app.query("#counter_total"):
+ raise RuntimeError(f"No counters shown for {name}")
+ print(f"{name}: total {app.query_one('#counter_total', Label).render()}")
+
+async def run() -> None:
+ app = ilist.IListApp(0.1)
+ async with app.run_test(size=(120, 40)) as pilot:
+ await pilot.pause()
+ tree = app.query_one("#root", Tree)
+ top = [str(node.label) for node in tree.root.children]
+ if top != ["PMUs", "Metrics"]:
+ raise RuntimeError(f"Unexpected tree: {top}")
+ pmus = [str(node.label) for node in tree.root.children[0].children]
+ if "software" not in pmus:
+ raise RuntimeError(f"No software PMU in: {pmus}")
+
+ # Search for a software event, which exists everywhere. The search
+ # result is selected which opens the event.
+ await search(pilot, "task-clock")
+ found = app.cur_search_result
+ if not found or not isinstance(found.data, ilist.PmuEvent) or \
+ "task-clock" not in str(found.label):
+ raise RuntimeError(f"Search didn't find task-clock: {found}")
+ await check_counting(app, pilot, found.data.name())
+ if not app.query_one("#active_search", Label).display:
+ raise RuntimeError("Active search not shown")
+ await pilot.press("n", "p", "down", "up", "c")
+ await pilot.pause(0.2)
+ await dismiss_error(app, pilot)
+
+ # Searching for something that doesn't exist shows an error.
+ await search(pilot, "no-such-event-xyzzy")
+ if not await dismiss_error(app, pilot):
+ raise RuntimeError("No error for a failed search")
+
+ # Select a metric, if there are any.
+ metric = find_leaf(tree.root, ilist.Metric)
+ if metric:
+ app.expand_and_select(metric)
+ await check_counting(app, pilot, metric.data.name())
+ print("headless UI ok")
+
+asyncio.run(run())
+EOF
+ then
+ echo "ilist headless UI test [Failed: exception]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if ! grep -q "headless UI ok" "${output}"; then
+ echo "ilist headless UI test [Failed: unexpected output]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "ilist headless UI test [Success]"
+}
+
+test_plumbing
+test_list_tui
+test_headless_ui
+
+cleanup
+exit $err
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 08/13] perf treport: Show the profile while it loads
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
` (6 preceding siblings ...)
2026-10-02 18:26 ` [PATCH v1 07/13] perf test: Add a test for the ilist script Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 09/13] perf test: Add a test for the treport script Ian Rogers
` (4 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
From: Alice Rogers <alice.mei.rogers@gmail.com>
Reading a large perf.data file caused a long stall before the treport
app started. Start the app immediately and build the profile in a
background thread, showing the report tree and flame graph as they are
built with the progress in the header. The time between updates grows
with the load time as updating the views costs more as the profile
grows. A lock guards the profile shared between the threads. Quitting
while loading cancels the load.
Tree nodes are now created lazily as they are expanded, expanded nodes
and the cursor are kept across updates, and events are kept in the order
they are first seen as their values aren't comparable. Node names are
escaped so they aren't interpreted as markup.
Assisted-by: Antigravity:gemini-3.1-pro
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/python/treport.py | 458 ++++++++++++++++++++++++++---------
1 file changed, 350 insertions(+), 108 deletions(-)
diff --git a/tools/perf/python/treport.py b/tools/perf/python/treport.py
index 0921b428b1aa..e02121475370 100755
--- a/tools/perf/python/treport.py
+++ b/tools/perf/python/treport.py
@@ -2,14 +2,17 @@
# SPDX-License-Identifier: MIT
"""treport.py - perf report like tool written using textual."""
from abc import ABC, abstractmethod
-from typing import Dict, Optional
+from typing import Callable, Dict, List, Optional, Set
import argparse
import os
import sys
+import threading
+import time
import perf
+from rich.markup import escape
from rich.segment import Segment
from rich.style import Style
-from textual import events
+from textual import events, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.color import Color
@@ -18,9 +21,6 @@ from textual.strip import Strip
from textual.widgets import Footer, Header, TabbedContent, TabPane, Tree
from textual.widgets.tree import TreeNode
-# Global session.
-session :Optional[perf.session] = None
-
def make_fixed_length_string(s: str, length: int, pad_char=' '):
"""Make the string s a fixed length.
@@ -61,7 +61,7 @@ class ProfileNode:
children (Dict[str, ProfileNode]): A dictionary of child nodes, keyed by
their names.
"""
- def __init__(self, name: str, parent: "ProfileNode"):
+ def __init__(self, name: str, parent: Optional["ProfileNode"]):
"""Initializes a ProfileNode."""
self.name = name
self.value: int = 0
@@ -82,20 +82,13 @@ class ProfileNode:
return 1
return max(child.depth() for child in self.children.values()) + 1
- def process_event(self, sample) -> None:
+ def process_event(self, sample, comm: str) -> None:
"""Processes a single profiling event to update the call stack tree.
Args:
sample: a single profiling sample.
+ comm: the command name of the sampled thread.
"""
- pid = sample.sample_pid
- try:
- assert session
- thread = session.find_thread(pid, sample.sample_tid)
- comm = (thread.comm() if thread else None) or f"unknown ({pid})"
- except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError):
- comm = f"unknown ({pid})"
-
period = sample.sample_period
self.value += period
@@ -120,47 +113,36 @@ class ProfileNode:
node = node.find_or_create_node(name)
node.value += period
- def add_to_tree(self, node: TreeNode, root_value: int) -> None:
- """Recursively adds this node and its children to a textual TreeNode.
+ def sorted_children(self) -> List["ProfileNode"]:
+ """The children, largest value first.
- Args:
- node (TreeNode): The textual `TreeNode` object to which this
- ProfileNode should be added.
- root_value (int): Value at the root of the tree.
+ The root's children are events whose values aren't comparable, each is
+ 100% of itself, so they are kept in the order they were first seen
+ rather than reordering as they load.
"""
- if root_value == 0:
- root_value = self.value
+ if self.parent is self:
+ return list(self.children.values())
+ return sorted(self.children.values(), key=lambda pnode: pnode.value, reverse=True)
- # Calculate the percentage for the node, highlighting the
- # percentage with reversed colors.
- if root_value != 0:
- percent = self.value / root_value * 100
- label = f"{self.name} [r]{percent:.3g}%[/]"
- else:
- label = self.name
-
- # Add a standalone leaf.
- if not self.children:
- node.add_leaf(label)
- return
+ def tree_label(self, root_value: int) -> str:
+ """Label for the node in a Tree, with a percentage of root_value.
- # Recursively add children.
- new_node = node.add(label)
- for pnode in sorted(self.children.values(),
- key=lambda pnode: pnode.value, reverse=True):
- pnode.add_to_tree(new_node, root_value)
+ The percentage is highlighted with reversed colors.
+ """
+ if root_value == 0:
+ return escape(self.name)
+ return f"{escape(self.name)} [r]{self.value / root_value * 100:.3g}%[/]"
def largest_child(self) -> "ProfileNode":
"""Finds the child with the highest value (sample count)."""
if self.children:
- return max(self.children.values(), key=lambda node: node.value)
+ return self.sorted_children()[0]
return self
def child_after(self, sought: "ProfileNode") -> "ProfileNode":
"""Finds the next sibling after the given node, sorted by value."""
found = False
- for child in sorted(self.children.values(), key=lambda node: node.value,
- reverse=True):
+ for child in self.sorted_children():
if child == sought:
found = True
elif found:
@@ -170,8 +152,7 @@ class ProfileNode:
def child_before(self, sought: "ProfileNode") -> "ProfileNode":
"""Finds the previous sibling before the given node, sorted by value."""
last = None
- for child in sorted(self.children.values(), key=lambda node: node.value,
- reverse=True):
+ for child in self.sorted_children():
if child == sought:
return last if last else sought
last = child
@@ -226,8 +207,7 @@ class ProfileNode:
# left_over is used to check for a gap after the children due
# to samples being in the parent.
left_over = parent_width
- for child in sorted(self.children.values(), key=lambda node: node.value,
- reverse=True):
+ for child in self.sorted_children():
if parent_selected:
if self.value:
desired_width = int((parent_width * child.value) / self.value)
@@ -412,16 +392,22 @@ class FlameGraph(ScrollView):
}
"""
- def __init__(self, root: ProfileNode, *pos_args, **kwargs):
- """Initialize the FlameGraph widget."""
+ def __init__(self, root: ProfileNode, lock: threading.Lock, *pos_args, **kwargs):
+ """Initialize the FlameGraph widget.
+
+ The lock must be held when reading the profile as it may be being built
+ in another thread.
+ """
super().__init__(*pos_args, **kwargs)
self.root = root
+ self.profile_lock = lock
self.cursor = root
self.selected = root
def action_move_down(self) -> None:
"""Handle key press down."""
- self.cursor = self.cursor.largest_child()
+ with self.profile_lock:
+ self.cursor = self.cursor.largest_child()
self.refresh()
def action_move_up(self) -> None:
@@ -432,12 +418,14 @@ class FlameGraph(ScrollView):
def action_move_right(self) -> None:
"""Handle key press right."""
- self.cursor = self.cursor.parent.child_after(self.cursor)
+ with self.profile_lock:
+ self.cursor = self.cursor.parent.child_after(self.cursor)
self.refresh()
def action_move_left(self) -> None:
"""Handle key press left."""
- self.cursor = self.cursor.parent.child_before(self.cursor)
+ with self.profile_lock:
+ self.cursor = self.cursor.parent.child_before(self.cursor)
self.refresh()
def action_zoom_in(self) -> None:
@@ -454,24 +442,148 @@ class FlameGraph(ScrollView):
"""Render a single line (row) of the flame graph."""
_, scroll_y = self.scroll_offset
y += scroll_y
- return self.root.make_flame_strip(y, self.size.width, self.cursor,
- self.selected, self.app.theme_variables)
+ with self.profile_lock:
+ return self.root.make_flame_strip(y, self.size.width, self.cursor,
+ self.selected, self.app.theme_variables)
+
+ def profile_changed(self) -> None:
+ """Resize and redraw after the profile changed."""
+ with self.profile_lock:
+ self.styles.height = self.root.depth()
+ self.refresh()
def on_mount(self) -> None:
"""Set the height of the widget when it is displayed."""
- self.styles.height = self.root.depth()
+ self.profile_changed()
def on_click(self, click: events.Click) -> None:
"""Handles a mouse click and update the cursor position."""
_, scroll_y = self.scroll_offset
y = scroll_y + click.y
- clicked_node = self.root.find_node(click.x, y, self.size.width,
- self.selected)
+ with self.profile_lock:
+ clicked_node = self.root.find_node(click.x, y, self.size.width,
+ self.selected)
if clicked_node:
self.cursor = clicked_node
self.refresh()
+class ProfileTree(Tree):
+ """A tree view of the profile that can be updated while it is built.
+
+ Tree nodes are created lazily when expanded, the data of each tree node is
+ its ProfileNode. The lock must be held when reading the profile.
+ """
+
+ def __init__(self, root: ProfileNode, lock: threading.Lock, *pos_args, **kwargs):
+ super().__init__("Profile", *pos_args, **kwargs)
+ self.profile = root
+ self.profile_lock = lock
+ # Events whose initial expansion has been done.
+ self.seen_events: Set[str] = set()
+
+ def pnode_of(self, tnode: TreeNode) -> ProfileNode:
+ """The ProfileNode shown by tnode, the tree's root shows the profile's root."""
+ return tnode.data if tnode.data is not None else self.profile
+
+ def event_value(self, pnode: ProfileNode) -> int:
+ """Value of the event containing pnode, percentages are relative to it."""
+ while pnode.parent is not self.profile and pnode.parent is not pnode:
+ pnode = pnode.parent
+ return pnode.value
+
+ @staticmethod
+ def expanded_descendants(tnode: TreeNode, expanded: Set[ProfileNode]) -> None:
+ """Add the profile nodes of the expanded descendants of tnode to expanded."""
+ for tchild in tnode.children:
+ if tchild.is_expanded and tchild.data is not None:
+ expanded.add(tchild.data)
+ if tchild.children:
+ ProfileTree.expanded_descendants(tchild, expanded)
+
+ def sync_node(self, tnode: TreeNode, expanded: Optional[Set[ProfileNode]] = None) -> None:
+ """Update the children of tnode, and expanded descendants, from the profile.
+
+ expanded holds the profile nodes of descendants that were expanded
+ before an ancestor's children were recreated. The lock must be held.
+ """
+ children = self.pnode_of(tnode).sorted_children()
+ if expanded is not None or [tchild.data for tchild in tnode.children] != children:
+ # Recreate the children in the new order keeping the expanded
+ # ones, and their expanded descendants.
+ if expanded is None:
+ expanded = set()
+ self.expanded_descendants(tnode, expanded)
+ tnode.remove_children()
+ ancestors: Set[ProfileNode] = set()
+ for pnode in expanded:
+ parent = pnode.parent
+ while parent is not None and parent not in ancestors:
+ ancestors.add(parent)
+ parent = parent.parent
+ for child in children:
+ label = child.tree_label(self.event_value(child))
+ new = tnode.add(label, child, allow_expand=bool(child.children))
+ if child in expanded:
+ new.expand()
+ if child in expanded or child in ancestors:
+ self.sync_node(new, expanded)
+ return
+ for tchild in tnode.children:
+ child = self.pnode_of(tchild)
+ tchild.set_label(child.tree_label(self.event_value(child)))
+ tchild.allow_expand = bool(child.children)
+ if tchild.is_expanded or tchild.children:
+ self.sync_node(tchild)
+
+ def expand_largest(self, tnode: TreeNode) -> None:
+ """Expand the chain of largest children below tnode.
+
+ The lock must be held.
+ """
+ while self.pnode_of(tnode).children:
+ self.sync_node(tnode)
+ tnode = tnode.children[0]
+ tnode.expand()
+
+ def find(self, tnode: TreeNode, pnode: ProfileNode) -> Optional[TreeNode]:
+ """Find the visible tree node for pnode."""
+ for tchild in tnode.children:
+ if tchild.data is pnode:
+ return tchild
+ if tchild.is_expanded:
+ found = self.find(tchild, pnode)
+ if found:
+ return found
+ return None
+
+ def sync(self) -> None:
+ """Update the tree from the profile keeping the expanded nodes and cursor."""
+ cursor = self.cursor_node.data if self.cursor_node else None
+ with self.profile_lock:
+ self.root.expand()
+ self.sync_node(self.root)
+ for tnode in self.root.children:
+ name = self.pnode_of(tnode).name
+ if name in self.seen_events:
+ continue
+ self.seen_events.add(name)
+ self.expand_largest(tnode)
+ # If there is only one event, expand it also.
+ if len(self.root.children) == 1:
+ tnode.expand()
+ if cursor is not None:
+ found = self.find(self.root, cursor)
+ if found:
+ self.move_cursor(found)
+
+ def on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
+ """Create the children of a node when it is expanded."""
+ if event.node is not self.root:
+ with self.profile_lock:
+ self.sync_node(event.node)
+
+
class ReportApp(App):
"""A Textual application to display profiling data."""
@@ -481,84 +593,214 @@ class ReportApp(App):
tooltip="Quit the app"),
]
- def __init__(self, root: ProfileNode):
- """Initialize the application."""
- super().__init__()
- self.root = root
+ def __init__(self, input_file: str, root: Optional[ProfileNode] = None):
+ """Initialize the application.
- def make_report_tree(self) -> Tree:
- """Make a Tree widget from the profile data."""
- tree: Tree[None] = Tree("Profile")
- # Add events to tree skipping the root.
- for pnode in sorted(self.root.children.values(),
- key=lambda node: node.value, reverse=True):
- pnode.add_to_tree(tree.root, root_value=0)
-
- # Expand the root tree (shows all events) and the largest of the children
- # for each event.
- def expand_first_child(tnode: TreeNode) -> None:
- """Recursively expand the first child node"""
- if not tnode.children:
- return
- first = tnode.children[0]
- first.expand()
- expand_first_child(first)
- tree.root.expand()
- for tnode in tree.root.children:
- expand_first_child(tnode)
-
- # If there is only one event, expand it also.
- if len(tree.root.children) == 1:
- tree.root.children[0].expand()
-
- return tree
+ If root isn't given the profile is loaded from input_file in the
+ background after the application starts, the views show the profile
+ as it is built.
+ """
+ super().__init__()
+ self.input_file = input_file
+ self.root = root if root else ProfileNode("root", parent=None)
+ self.loaded = root is not None
+ # Held when reading or changing the profile, which is built in a
+ # background thread.
+ self.profile_lock = threading.Lock()
+ # The profile being built in a background thread.
+ self.loading: Optional[ProfileBuilder] = None
def compose(self) -> ComposeResult:
"""Composes the user interface of the application."""
yield Header()
with TabbedContent(initial="report"):
with TabPane("Report", id="report"):
- yield self.make_report_tree()
+ yield ProfileTree(self.root, self.profile_lock)
with TabPane("Flame Graph", id="flame"):
- yield FlameGraph(self.root)
+ yield FlameGraph(self.root, self.profile_lock)
yield Footer()
+ def on_mount(self) -> None:
+ """Start loading the profile unless it was given."""
+ self.sub_title = self.input_file
+ if self.loaded:
+ self.update_views()
+ else:
+ self.sub_title = f"Loading {self.input_file}"
+ self.loading = ProfileBuilder(self.root, self.profile_lock)
+ self.load_profile()
+
+ def update_views(self) -> None:
+ """Show the latest state of the profile."""
+ self.query_one(ProfileTree).sync()
+ self.query_one(FlameGraph).profile_changed()
+
+ @work(thread=True, exclusive=True)
+ def load_profile(self) -> None:
+ """Build the profile in a thread so the UI stays responsive."""
+ profile = self.loading
+ assert profile is not None
+
+ def progress() -> None:
+ try:
+ self.call_from_thread(self.update_progress, profile)
+ except RuntimeError:
+ # The app is no longer running.
+ profile.cancelled = True
+
+ profile.progress = progress
+ try:
+ profile.read(self.input_file)
+ except LoadCancelled:
+ return
+ except (OSError, ValueError, RuntimeError) as e:
+ self.call_from_thread(self.exit, None, 1,
+ f"Error processing {self.input_file}: {e}")
+ return
+ finally:
+ profile.progress = None
+ self.loading = None
+ if not profile.cancelled:
+ self.call_from_thread(self.profile_loaded, profile)
+
+ def update_progress(self, profile: "ProfileBuilder") -> None:
+ """Show how much of the file has been processed and the profile so far."""
+ self.sub_title = (f"Loading {self.input_file}: {profile.nr_samples:,} samples"
+ f"{profile.duration_str()}")
+ self.update_views()
+
+ def profile_loaded(self, profile: "ProfileBuilder") -> None:
+ """Called on the UI thread when loading completes."""
+ self.loaded = True
+ self.sub_title = (f"{self.input_file}: {profile.nr_samples:,} samples"
+ f"{profile.duration_str()}")
+ self.update_views()
+
+ def cancel_loading(self) -> None:
+ """Stop a background load, noticed at the next progress interval."""
+ loading = self.loading
+ if loading is not None:
+ loading.cancelled = True
+
+ async def action_quit(self) -> None:
+ """Quit, stopping any background load."""
+ self.cancel_loading()
+ await super().action_quit()
+
+ def on_unmount(self) -> None:
+ """Stop any background load when the app exits."""
+ self.cancel_loading()
+
+
+class LoadCancelled(Exception):
+ """Raised from the sample callback to stop processing events early."""
+
class ProfileBuilder:
"""Constructs a profile tree from a stream of events."""
- def __init__(self):
- self.root = ProfileNode("root", parent=None)
+ # Number of samples between checks for cancellation and progress.
+ PROGRESS_INTERVAL = 1000
+ # Minimum and maximum time between calls to the progress callback.
+ PROGRESS_SECONDS = 1.0
+ PROGRESS_MAX_SECONDS = 10.0
+ # Updating the views costs more as the profile grows, so the time between
+ # progress calls grows as this fraction of the time spent loading. This
+ # bounds the fraction of the load time spent updating the views.
+ PROGRESS_FRACTION = 0.25
+
+ def __init__(self, root: Optional[ProfileNode] = None,
+ lock: Optional[threading.Lock] = None) -> None:
+ """Build into root, holding lock when changing it."""
+ self.root = root if root else ProfileNode("root", parent=None)
+ self.profile_lock = lock if lock else threading.Lock()
+ self.session: Optional[perf.session] = None
+ self.nr_samples = 0
+ self.first_time = 0
+ self.last_time = 0
+ # Set, possibly from another thread, to stop processing events.
+ self.cancelled = False
+ # Called periodically, see PROGRESS_FRACTION, while processing events.
+ self.progress: Optional[Callable[[], None]] = None
+ self.start_progress = time.monotonic()
+ self.last_progress = self.start_progress
+
+ def duration_str(self) -> str:
+ """Description of the time covered by the processed samples."""
+ if not self.last_time:
+ return ""
+ return f", {(self.last_time - self.first_time) / 1e9:.3f}s of trace"
+
+ def comm(self, sample) -> str:
+ """The command name of the thread of the sample."""
+ pid = sample.sample_pid
+ try:
+ assert self.session
+ thread = self.session.find_thread(pid, sample.sample_tid)
+ return (thread.comm() if thread else None) or f"unknown ({pid})"
+ except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError,
+ AssertionError):
+ return f"unknown ({pid})"
def process_event(self, sample) -> None:
"""Called by session.process_events to update the profile tree."""
+ self.nr_samples += 1
+ if self.nr_samples % self.PROGRESS_INTERVAL == 0:
+ if self.cancelled:
+ raise LoadCancelled()
+ now = time.monotonic()
+ interval = min(max(self.PROGRESS_SECONDS,
+ (now - self.start_progress) * self.PROGRESS_FRACTION),
+ self.PROGRESS_MAX_SECONDS)
+ if self.progress and now - self.last_progress >= interval:
+ # Must not hold the lock as the callback may read the profile.
+ self.progress()
+ # Time from when the callback, that may block, returns.
+ self.last_progress = time.monotonic()
+ sample_time = sample.sample_time
+ if sample_time:
+ if not self.first_time or sample_time < self.first_time:
+ self.first_time = sample_time
+ self.last_time = max(self.last_time, sample_time)
ev_name = str(sample.evsel)[6:-1]
- ev_root = self.root.find_or_create_node(ev_name)
- ev_root.process_event(sample)
+ comm = self.comm(sample)
+ with self.profile_lock:
+ ev_root = self.root.find_or_create_node(ev_name)
+ ev_root.process_event(sample, comm)
+
+ def read(self, input_file: str) -> None:
+ """Process the events in input_file, raising on errors."""
+ if self.cancelled:
+ raise LoadCancelled()
+ self.start_progress = self.last_progress = time.monotonic()
+ try:
+ self.session = perf.session(perf.data(input_file), sample=self.process_event)
+ self.session.process_events()
+ finally:
+ # Break the reference cycle between the session and the callback.
+ self.session = None
-if __name__ == "__main__":
+
+def main() -> None:
+ """Parse arguments and run the app."""
parser = argparse.ArgumentParser(
description="TUI report and flame graph using perf python module.")
parser.add_argument("-i", "--input", help="input perf.data file")
args = parser.parse_args()
input_file = args.input or "perf.data"
+ if input_file == "-":
+ # The interactive UI reads the keyboard from stdin.
+ print("Error: reading perf.data from stdin isn't supported.", file=sys.stderr)
+ sys.exit(1)
if not os.path.exists(input_file):
print(f"Error: {input_file} not found. (try 'perf record' first)", file=sys.stderr)
sys.exit(1)
- profile = ProfileBuilder()
- try:
- session = perf.session(perf.data(input_file), sample=profile.process_event)
- except (OSError, ValueError, RuntimeError) as e:
- print(f"Error opening session: {e}", file=sys.stderr)
- sys.exit(1)
+ # The app starts immediately and builds the profile in the background.
+ app = ReportApp(input_file)
+ app.run()
+ sys.exit(app.return_code or 0)
- # profile.process_event is called for each perf event to build the profile.
- try:
- session.process_events()
- finally:
- session = None
- # Visualize data.
- app = ReportApp(profile.root)
- app.run()
+if __name__ == "__main__":
+ main()
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 09/13] perf test: Add a test for the treport script
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
` (7 preceding siblings ...)
2026-10-02 18:26 ` [PATCH v1 08/13] perf treport: Show the profile while it loads Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 10/13] perf timechart: Add an interactive --tui mode Ian Rogers
` (3 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
From: Alice Rogers <alice.mei.rogers@gmail.com>
Test launching treport through perf script, the error for a file that
isn't a perf.data file, drive the textual app headless checking the
profile matches one built without the UI and exercising the key
bindings, and that quitting while loading stops the background load.
Assisted-by: Antigravity:gemini-3.1-pro
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/tests/shell/test_treport_python.sh | 247 ++++++++++++++++++
1 file changed, 247 insertions(+)
create mode 100755 tools/perf/tests/shell/test_treport_python.sh
diff --git a/tools/perf/tests/shell/test_treport_python.sh b/tools/perf/tests/shell/test_treport_python.sh
new file mode 100755
index 000000000000..155993b8be79
--- /dev/null
+++ b/tools/perf/tests/shell/test_treport_python.sh
@@ -0,0 +1,247 @@
+#!/bin/bash
+# perf script treport test
+# SPDX-License-Identifier: GPL-2.0
+
+set -e
+
+shelldir=$(dirname "$0")
+# shellcheck source=lib/setup_python.sh
+. "${shelldir}"/lib/setup_python.sh
+
+if ! "$PYTHON" -c 'import perf' > /dev/null 2>&1; then
+ echo "Skipping test, perf python module not found"
+ exit 2
+fi
+
+if ! "$PYTHON" -c 'import textual' > /dev/null 2>&1; then
+ echo "Skipping test, python textual library not found"
+ exit 2
+fi
+
+err=0
+perfdata=
+output=
+badfile=
+
+cleanup() {
+ [ -n "${perfdata}" ] && rm -f "${perfdata}"*
+ rm -f "${output}" "${badfile}"
+ trap - EXIT TERM INT
+}
+
+trap_cleanup() {
+ echo "Unexpected signal in ${FUNCNAME[1]}"
+ cleanup
+ exit 1
+}
+trap trap_cleanup EXIT TERM INT
+
+perfdata=$(mktemp /tmp/__perf_treport_test.perf.data.XXXXX)
+output=$(mktemp /tmp/__perf_treport_test.output.XXXXX)
+badfile=$(mktemp /tmp/__perf_treport_test.bad.XXXXX)
+
+test_plumbing() {
+ echo "treport plumbing test"
+ # A missing input file should be reported by the script launched
+ # through perf script, this doesn't need a terminal.
+ if perf script treport -i "${perfdata}.missing" > "${output}" 2>&1; then
+ echo "treport plumbing test [Failed: missing file not an error]"
+ err=1
+ return
+ fi
+ if ! grep -q "not found" "${output}"; then
+ echo "treport plumbing test [Failed: script not launched]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "treport plumbing test [Success]"
+}
+
+test_bad_file() {
+ echo "treport bad file test"
+ echo "not a perf.data file" > "${badfile}"
+ # The app starts before the file is read, so the error is reported by
+ # the app exiting with a failure.
+ if ! "$PYTHON" - "${badfile}" > "${output}" 2>&1 <<'EOF'
+import asyncio
+import sys
+import treport
+
+async def run() -> None:
+ app = treport.ReportApp(sys.argv[1])
+ async with app.run_test(size=(120, 40)) as pilot:
+ for _ in range(100):
+ if app.return_code is not None:
+ break
+ await pilot.pause(0.1)
+ if app.return_code != 1:
+ raise RuntimeError(f"Unexpected return code {app.return_code}")
+ print("bad file ok")
+
+asyncio.run(run())
+EOF
+ then
+ echo "treport bad file test [Failed: exception]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if ! grep -q "bad file ok" "${output}"; then
+ echo "treport bad file test [Failed: unexpected output]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "treport bad file test [Success]"
+}
+
+record() {
+ # A software event with callchains that doesn't need privileges.
+ perf record -e task-clock -g -o "${perfdata}" -- perf test -w noploop \
+ > /dev/null 2>&1
+}
+
+test_headless_ui() {
+ echo "treport headless UI test"
+ # Drive the textual app without a terminal, check the profile shown
+ # matches one built without the UI and exercise the key bindings.
+ if ! "$PYTHON" - "${perfdata}" > "${output}" 2>&1 <<'EOF'
+import asyncio
+import sys
+import treport
+
+def labels(tree, tnode, out):
+ for child in tnode.children:
+ out.append(str(child.label))
+ if child.is_expanded:
+ labels(tree, child, out)
+ return out
+
+async def run() -> None:
+ expected = treport.ProfileBuilder()
+ expected.read(sys.argv[1])
+ if not expected.root.children:
+ raise RuntimeError("No samples in the profile")
+
+ # The app starts before the profile is built in a background thread.
+ app = treport.ReportApp(sys.argv[1])
+ async with app.run_test(size=(120, 40)) as pilot:
+ for _ in range(600):
+ if app.loaded:
+ break
+ await pilot.pause(0.1)
+ if not app.loaded:
+ raise RuntimeError("Timed out loading data")
+ await pilot.pause()
+ if app.sub_title.startswith("Loading") or "samples" not in app.sub_title:
+ raise RuntimeError(f"Unexpected sub-title: {app.sub_title}")
+ totals = {name: node.value for name, node in app.root.children.items()}
+ want = {name: node.value for name, node in expected.root.children.items()}
+ if totals != want:
+ raise RuntimeError(f"Profile {totals} differs from {want}")
+ tree = app.query_one(treport.ProfileTree)
+ shown = labels(tree, tree.root, [])
+ if not any("noploop" in label for label in shown):
+ raise RuntimeError(f"noploop not shown in: {shown}")
+ await pilot.press("down", "down", "enter", "up", "enter")
+ tabs = app.query_one(treport.TabbedContent)
+ tabs.active = "flame"
+ await pilot.pause()
+ await pilot.press("down", "down", "right", "enter", "escape", "up", "left")
+ await pilot.pause()
+ print("headless UI ok")
+
+asyncio.run(run())
+EOF
+ then
+ echo "treport headless UI test [Failed: exception]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if ! grep -q "headless UI ok" "${output}"; then
+ echo "treport headless UI test [Failed: unexpected output]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "treport headless UI test [Success]"
+}
+
+test_cancel() {
+ echo "treport cancel test"
+ # Quitting while loading should stop the background thread without a
+ # "processing failed" error from the session.
+ if ! "$PYTHON" - "${perfdata}" > "${output}" 2>&1 <<'EOF'
+import asyncio
+import sys
+import time
+import treport
+
+# Check for cancellation often and slow processing each sample so that the
+# load can't finish before it is cancelled.
+treport.ProfileBuilder.PROGRESS_INTERVAL = 1
+process_event = treport.ProfileBuilder.process_event
+
+def slow_process_event(self, sample) -> None:
+ time.sleep(0.001)
+ process_event(self, sample)
+
+treport.ProfileBuilder.process_event = slow_process_event
+
+async def run() -> None:
+ app = treport.ReportApp(sys.argv[1])
+ async with app.run_test(size=(120, 40)) as pilot:
+ for _ in range(100):
+ loading = app.loading
+ if loading is not None and loading.nr_samples > 0:
+ break
+ await pilot.pause(0.1)
+ if loading is None or app.loaded:
+ raise RuntimeError("Background load not in progress")
+ await pilot.press("ctrl+q")
+ for _ in range(100):
+ if app.loading is None:
+ break
+ time.sleep(0.1)
+ if app.loading is not None:
+ raise RuntimeError("Background load didn't stop")
+ if not loading.cancelled or app.loaded:
+ raise RuntimeError("Background load wasn't cancelled")
+ print("cancel ok")
+
+asyncio.run(run())
+EOF
+ then
+ echo "treport cancel test [Failed: exception]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if ! grep -q "cancel ok" "${output}" || grep -q "processing failed" "${output}"; then
+ echo "treport cancel test [Failed: unexpected output]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "treport cancel test [Success]"
+}
+
+test_plumbing
+test_bad_file
+
+if ! record; then
+ echo "Skipping remaining tests, failed to record samples"
+ if [ $err -eq 0 ]; then
+ err=2
+ fi
+ cleanup
+ exit $err
+fi
+
+test_headless_ui
+test_cancel
+
+cleanup
+exit $err
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 10/13] perf timechart: Add an interactive --tui mode
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
` (8 preceding siblings ...)
2026-10-02 18:26 ` [PATCH v1 09/13] perf test: Add a test for the treport script Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 11/13] perf test: Add a test for perf timechart --tui Ian Rogers
` (2 subsequent siblings)
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
From: Alice Rogers <alice.mei.rogers@gmail.com>
Add ttimechart.py, a textual based interactive timechart, and a --tui
option to perf timechart that launches it through perf script. Rather
than writing an SVG file, per-CPU (busy, frequency and idle state),
per-task (running, runnable and blocked) and I/O timelines are shown in
the terminal along with a summary table. The timelines can be zoomed,
panned and the state of the selected row at the cursor is described,
including the task that woke it. The -i, -P, -T and -p options are
passed to the script.
The data is loaded in a background thread and the timelines are shown
and updated as it loads. A --dump option prints a text summary without
the UI.
Assisted-by: Antigravity:gemini-3.1-pro
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/Documentation/perf-timechart.txt | 8 +
tools/perf/builtin-timechart.c | 44 +
tools/perf/python/ttimechart.py | 1807 +++++++++++++++++++
3 files changed, 1859 insertions(+)
create mode 100755 tools/perf/python/ttimechart.py
diff --git a/tools/perf/Documentation/perf-timechart.txt b/tools/perf/Documentation/perf-timechart.txt
index bacc5df3c400..f768ee8acf47 100644
--- a/tools/perf/Documentation/perf-timechart.txt
+++ b/tools/perf/Documentation/perf-timechart.txt
@@ -53,6 +53,14 @@ TIMECHART OPTIONS
-f::
--force::
Don't complain, do it.
+--tui::
+ Rather than writing an SVG file, interactively display the timechart
+ in the terminal using the 'ttimechart' python script (see
+ linkperf:perf-script[1]). The script requires the perf python module
+ and the python 'textual' library. The CPU, task, I/O and summary views
+ can be zoomed ('+'/'-'), panned (shift+arrows) and the state of the
+ selected row at the cursor is described. The -i, -P, -T and -p options
+ are passed to the script, other output options are ignored.
--symfs=<directory[,layout]>::
Look for files with symbols relative to this directory. The optional
layout can be 'hierarchy' (default, matches full path) or 'flat'
diff --git a/tools/perf/builtin-timechart.c b/tools/perf/builtin-timechart.c
index 3d88c90c6573..9eb335d9db16 100644
--- a/tools/perf/builtin-timechart.c
+++ b/tools/perf/builtin-timechart.c
@@ -10,6 +10,7 @@
#include <errno.h>
#include <inttypes.h>
+#include <stdlib.h>
#include "builtin.h"
#include "util/color.h"
@@ -68,6 +69,7 @@ struct timechart {
with_backtrace,
topology;
bool force;
+ bool use_tui;
/* IO related settings */
bool io_only,
skip_eagain;
@@ -1756,6 +1758,41 @@ static int __cmd_timechart(struct timechart *tchart, const char *output_name)
return ret;
}
+/*
+ * Launch the interactive textual based python script via 'perf script' that
+ * finds the script, sets up the python environment and passes the global
+ * input_name (set by -i) to the script as '-i <input_name>'.
+ */
+static int timechart__tui(struct timechart *tchart)
+{
+ struct process_filter *filt;
+ const char **script_argv;
+ int script_argc = 0, nr_args = 5, ret;
+
+ for (filt = process_filter; filt; filt = filt->next)
+ nr_args += 2;
+
+ script_argv = calloc(nr_args + 1, sizeof(*script_argv));
+ if (!script_argv)
+ return -ENOMEM;
+
+ script_argv[script_argc++] = "script";
+ script_argv[script_argc++] = "ttimechart";
+ script_argv[script_argc++] = "--";
+ if (tchart->power_only)
+ script_argv[script_argc++] = "-P";
+ if (tchart->tasks_only)
+ script_argv[script_argc++] = "-T";
+ for (filt = process_filter; filt; filt = filt->next) {
+ script_argv[script_argc++] = "-p";
+ script_argv[script_argc++] = filt->name;
+ }
+
+ ret = cmd_script(script_argc, script_argv);
+ free(script_argv);
+ return ret;
+}
+
static int timechart__io_record(int argc, const char **argv, const char *output_data)
{
unsigned int rec_argc, i;
@@ -2079,6 +2116,8 @@ int cmd_timechart(int argc, const char **argv)
"merge events that are merge-dist us apart",
parse_time),
OPT_BOOLEAN('f', "force", &tchart.force, "don't complain, do it"),
+ OPT_BOOLEAN(0, "tui", &tchart.use_tui,
+ "interactive terminal timechart using the ttimechart python script"),
OPT_PARENT(timechart_common_options),
};
const char * const timechart_subcommands[] = { "record", NULL };
@@ -2146,6 +2185,11 @@ int cmd_timechart(int argc, const char **argv)
} else if (argc)
usage_with_options(timechart_usage, timechart_options);
+ if (tchart.use_tui) {
+ ret = timechart__tui(&tchart);
+ goto out;
+ }
+
setup_pager();
ret = __cmd_timechart(&tchart, output_name);
diff --git a/tools/perf/python/ttimechart.py b/tools/perf/python/ttimechart.py
new file mode 100755
index 000000000000..34ecf84e68c1
--- /dev/null
+++ b/tools/perf/python/ttimechart.py
@@ -0,0 +1,1807 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+"""ttimechart.py - interactive perf timechart written using textual.
+
+Reads a perf.data file, typically created with 'perf timechart record', and
+displays per-CPU and per-task timelines in the terminal. Scheduler
+(sched:sched_switch, sched:sched_wakeup), power (power:cpu_idle,
+power:cpu_frequency) and I/O syscall (perf timechart record -I) tracepoints
+are understood. Unlike 'perf timechart', which writes an SVG file, the
+timeline can be zoomed, panned and queried interactively.
+
+Usage:
+ perf timechart record -- <workload>
+ perf timechart --tui
+or:
+ perf script ttimechart [-i perf.data]
+"""
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+import argparse
+import bisect
+from collections import defaultdict
+from dataclasses import dataclass, replace
+import math
+import os
+import sys
+import threading
+from time import monotonic
+from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
+
+import perf
+from rich.segment import Segment
+from rich.style import Style
+from textual import events, on, work
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual.color import Color
+from textual.geometry import Size
+from textual.message import Message
+from textual.reactive import reactive
+from textual.scroll_view import ScrollView
+from textual.strip import Strip
+from textual.widgets import DataTable, Footer, Header, Static, TabbedContent, TabPane
+from textual.widgets.data_table import CellDoesNotExist, RowDoesNotExist
+
+# Task states, SLEEPING and UNKNOWN aren't drawn.
+STATE_UNKNOWN = -1
+STATE_SLEEPING = 0
+STATE_RUNNING = 1
+STATE_WAITING = 2
+STATE_BLOCKED = 3
+NUM_STATES = 4
+STATE_NAMES = {
+ STATE_UNKNOWN: "unknown",
+ STATE_SLEEPING: "sleeping",
+ STATE_RUNNING: "running",
+ STATE_WAITING: "runnable (waiting for a CPU)",
+ STATE_BLOCKED: "blocked (uninterruptible)",
+}
+
+# I/O types, matching builtin-timechart.c.
+IOTYPE_READ = 0
+IOTYPE_WRITE = 1
+IOTYPE_SYNC = 2
+IOTYPE_TX = 3
+IOTYPE_RX = 4
+IOTYPE_POLL = 5
+NUM_IOTYPES = 6
+IOTYPE_NAMES = ["read", "write", "sync", "tx", "rx", "poll"]
+
+IO_SYSCALLS = {
+ "read": IOTYPE_READ, "pread64": IOTYPE_READ, "readv": IOTYPE_READ,
+ "preadv": IOTYPE_READ,
+ "write": IOTYPE_WRITE, "pwrite64": IOTYPE_WRITE, "writev": IOTYPE_WRITE,
+ "pwritev": IOTYPE_WRITE,
+ "sync": IOTYPE_SYNC, "sync_file_range": IOTYPE_SYNC, "fsync": IOTYPE_SYNC,
+ "msync": IOTYPE_SYNC,
+ "recvfrom": IOTYPE_RX, "recvmmsg": IOTYPE_RX, "recvmsg": IOTYPE_RX,
+ "sendto": IOTYPE_TX, "sendmsg": IOTYPE_TX, "sendmmsg": IOTYPE_TX,
+ "epoll_pwait": IOTYPE_POLL, "epoll_wait": IOTYPE_POLL, "poll": IOTYPE_POLL,
+ "ppoll": IOTYPE_POLL, "pselect6": IOTYPE_POLL, "select": IOTYPE_POLL,
+}
+
+# Trace flags for interrupt context in the common_flags tracepoint field.
+TRACE_FLAG_HARDIRQ = 0x08
+TRACE_FLAG_SOFTIRQ = 0x10
+# Value of state in power:cpu_idle when leaving idle.
+PWR_EVENT_EXIT = 0xffffffff
+# Width of the row label column.
+LABEL_WIDTH = 28
+# Characters for drawing fractional bars.
+BARS = " ▁▂▃▄▅▆▇█"
+NSEC_PER_SEC = 1_000_000_000
+
+
+def fmt_duration(nsecs: float) -> str:
+ """Format a duration in nanoseconds with an appropriate unit."""
+ if nsecs >= NSEC_PER_SEC:
+ return f"{nsecs / NSEC_PER_SEC:.3f}s"
+ if nsecs >= 1_000_000:
+ return f"{nsecs / 1_000_000:.3f}ms"
+ if nsecs >= 1_000:
+ return f"{nsecs / 1_000:.3f}us"
+ return f"{nsecs:.0f}ns"
+
+
+def escape(text: str) -> str:
+ """Escape text, such as a task name, for use in textual markup.
+
+ rich.markup.escape doesn't escape tags like "[1]", the name given to tasks
+ with an unknown command, but textual fails to parse them.
+ """
+ return text.replace("[", "\\[")
+
+
+def make_fixed_length_string(s: str, length: int, pad_char: str = ' ') -> str:
+ """Truncate or right pad s so that it is length characters long."""
+ return s[:length] if len(s) > length else s.ljust(length, pad_char)
+
+
+def bar_char(frac: float) -> str:
+ """A block character whose height represents frac, in the range [0, 1]."""
+ if frac <= 0:
+ return BARS[0]
+ return BARS[max(1, min(8, round(frac * 8)))]
+
+
+class SegmentList:
+ """A time ordered list of non-overlapping [start, end) segments.
+
+ Each segment has a small integer key, used for computing coverage, and
+ arbitrary associated data.
+ """
+ def __init__(self) -> None:
+ self.starts: List[int] = []
+ self.ends: List[int] = []
+ self.keys: List[int] = []
+ self.data: List[Any] = []
+
+ def __len__(self) -> int:
+ return len(self.starts)
+
+ def add(self, start: int, end: int, key: int, data: Any = None) -> None:
+ """Append a segment, segments must be added in time order."""
+ if end <= start:
+ return
+ if self.ends and start < self.ends[-1]:
+ # Clip overlaps caused by inconsistent data.
+ start = self.ends[-1]
+ if end <= start:
+ return
+ self.starts.append(start)
+ self.ends.append(end)
+ self.keys.append(key)
+ self.data.append(data)
+
+ def find(self, time: float) -> int:
+ """Index of the segment containing time or -1."""
+ i = bisect.bisect_right(self.starts, time) - 1
+ if i >= 0 and time < self.ends[i]:
+ return i
+ return -1
+
+ def last_before(self, time: float) -> int:
+ """Index of the last segment starting at or before time or -1."""
+ return bisect.bisect_right(self.starts, time) - 1
+
+ def next_change(self, time: float) -> Optional[int]:
+ """The first segment start or end after time."""
+ candidates = []
+ i = bisect.bisect_right(self.starts, time)
+ if i < len(self.starts):
+ candidates.append(self.starts[i])
+ i = bisect.bisect_right(self.ends, time)
+ if i < len(self.ends):
+ candidates.append(self.ends[i])
+ return min(candidates) if candidates else None
+
+ def prev_change(self, time: float) -> Optional[int]:
+ """The last segment start or end before time."""
+ candidates = []
+ i = bisect.bisect_left(self.starts, time) - 1
+ if i >= 0:
+ candidates.append(self.starts[i])
+ i = bisect.bisect_left(self.ends, time) - 1
+ if i >= 0:
+ candidates.append(self.ends[i])
+ return max(candidates) if candidates else None
+
+ def coverage(self, t0: float, dt: float, width: int, nkeys: int,
+ weight_fn=None) -> List[List[float]]:
+ """Time covered by each key in each of width columns of size dt.
+
+ If weight_fn is given then, rather than the time covered, the time
+ covered multiplied by weight_fn(data) is accumulated.
+ """
+ cols = [[0.0] * nkeys for _ in range(width)]
+ t1 = t0 + dt * width
+ i = bisect.bisect_right(self.ends, t0)
+ num = len(self.starts)
+ while i < num and self.starts[i] < t1:
+ start = max(self.starts[i], t0)
+ end = min(self.ends[i], t1)
+ key = self.keys[i]
+ weight = weight_fn(self.data[i]) if weight_fn else 1.0
+ x0 = min(int((start - t0) / dt), width - 1)
+ x1 = min(int((end - t0) / dt), width - 1)
+ if x0 == x1:
+ cols[x0][key] += (end - start) * weight
+ else:
+ cols[x0][key] += (t0 + (x0 + 1) * dt - start) * weight
+ for x in range(x0 + 1, x1):
+ cols[x][key] += dt * weight
+ cols[x1][key] += (end - (t0 + x1 * dt)) * weight
+ i += 1
+ return cols
+
+
+class Task:
+ """Scheduling and I/O history of a single thread."""
+ def __init__(self, tid: int, comm: str) -> None:
+ self.tid = tid
+ self.comm = comm
+ self.comms: List[str] = [comm]
+ self.state = STATE_UNKNOWN
+ self.since = 0
+ self.cpu = -1
+ # Segments with a state key and the CPU as data.
+ self.segs = SegmentList()
+ # Segments with an I/O type key and (fd, ret) as data.
+ self.io = SegmentList()
+ self.io_pending: Optional[Tuple[int, int, int]] = None
+ # Wakeups of this task as (time, waker tid) pairs.
+ self.wakeups: List[Tuple[int, int]] = []
+ self.totals = [0] * NUM_STATES
+ self.switches = 0
+ self.io_bytes = 0
+
+ def name(self) -> str:
+ """Name for the task used in labels."""
+ return f"{self.comm} ({self.tid})"
+
+ def set_comm(self, comm: Optional[str]) -> None:
+ """Update the task's command name."""
+ if not comm or comm == self.comm:
+ return
+ self.comm = comm
+ if comm not in self.comms:
+ self.comms.append(comm)
+
+ def change_state(self, time: int, state: int, cpu: int = -1) -> None:
+ """Record the current state as a segment and switch to a new state."""
+ if self.state in (STATE_RUNNING, STATE_WAITING, STATE_BLOCKED) and time > self.since:
+ self.segs.add(self.since, time, self.state, self.cpu)
+ self.totals[self.state] += time - self.since
+ self.state = state
+ self.since = time
+ if cpu >= 0:
+ self.cpu = cpu
+
+ def passes_filter(self, filters: Sequence[str]) -> bool:
+ """Does the task match one of the process filters (pid or name)?"""
+ if not filters:
+ return True
+ return any(f == str(self.tid) or f in self.comms for f in filters)
+
+
+class Cpu:
+ """Activity on a single CPU."""
+ def __init__(self, cpu: int) -> None:
+ self.cpu = cpu
+ self.cur_tid = -1
+ self.since = 0
+ # Busy segments, key 1, with the running tid as data.
+ self.run = SegmentList()
+ # Idle state segments, key 1, with the C-state as data.
+ self.cstate = SegmentList()
+ self.cstate_cur: Optional[Tuple[int, int]] = None
+ # Frequency segments, key 1, with the frequency in kHz as data.
+ self.pstate = SegmentList()
+ self.pstate_cur: Optional[Tuple[int, int]] = None
+
+
+class LoadCancelled(Exception):
+ """Raised from the sample callback to stop processing events early."""
+
+
+class TimechartData:
+ """Builds per-task and per-CPU timelines from perf events.
+
+ The data may be displayed while it is loaded in another thread, lock must
+ be held when modifying it or when reading it from another thread.
+ """
+ # Number of samples between checks for cancellation and progress.
+ PROGRESS_INTERVAL = 1000
+ # Minimum and maximum time between calls to the progress callback.
+ PROGRESS_SECONDS = 1.0
+ PROGRESS_MAX_SECONDS = 10.0
+ # Updating the views costs more as more data is loaded, so the time between
+ # progress calls grows as this fraction of the time spent loading. This
+ # bounds the fraction of the load time spent updating the views.
+ PROGRESS_FRACTION = 0.25
+
+ def __init__(self) -> None:
+ self.tasks: Dict[int, Task] = {}
+ self.cpus: Dict[int, Cpu] = {}
+ self.first_time = 0
+ self.last_time = 0
+ self.min_freq = 0
+ self.max_freq = 0
+ self.max_cstate = 0
+ self.sched_events = 0
+ self.power_events = 0
+ self.io_events = 0
+ self.nr_samples = 0
+ self.unhandled: Dict[str, int] = defaultdict(int)
+ self.session: Optional[perf.session] = None
+ # Evsel name and event handler, keyed by sample ID.
+ self._handlers: Dict[int, Tuple[str, Optional[Callable[[int, perf.sample_event],
+ None]]]] = {}
+ self.lock = threading.Lock()
+ # Set, possibly from another thread, to stop processing events.
+ self.cancelled = False
+ # Called periodically, see PROGRESS_FRACTION, while processing events.
+ self.progress: Optional[Callable[[], None]] = None
+ self.start_progress = monotonic()
+ self.last_progress = self.start_progress
+
+ def has_events(self) -> bool:
+ """Were any events that can be displayed processed?"""
+ return bool(self.sched_events or self.power_events or self.io_events)
+
+ def task(self, tid: int, comm: Optional[str] = None) -> Task:
+ """Find or create a task."""
+ task = self.tasks.get(tid)
+ if task is None:
+ if not comm and self.session:
+ try:
+ thread = self.session.find_thread(tid, tid)
+ comm = thread.comm() if thread else None
+ except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError):
+ comm = None
+ task = Task(tid, comm or f"[{tid}]")
+ self.tasks[tid] = task
+ else:
+ task.set_comm(comm)
+ return task
+
+ def cpu(self, cpu: int) -> Cpu:
+ """Find or create a CPU."""
+ c = self.cpus.get(cpu)
+ if c is None:
+ c = Cpu(cpu)
+ self.cpus[cpu] = c
+ return c
+
+ def sched_switch(self, time: int, cpu: int, prev_tid: int, prev_comm: Optional[str],
+ prev_state: int, next_tid: int, next_comm: Optional[str]) -> None:
+ """Process a sched:sched_switch event."""
+ self.sched_events += 1
+ c = self.cpu(cpu)
+ if c.cur_tid == -1 and prev_tid != 0:
+ # Assume the task was running from the start of the trace.
+ c.cur_tid = prev_tid
+ c.since = self.first_time
+ if c.cur_tid > 0:
+ c.run.add(c.since, time, 1, c.cur_tid)
+ c.cur_tid = next_tid
+ c.since = time
+
+ if prev_tid != 0:
+ prev = self.task(prev_tid, prev_comm)
+ if prev.state == STATE_UNKNOWN:
+ prev.state = STATE_RUNNING
+ prev.since = self.first_time
+ prev.cpu = cpu
+ # Ignore bits like TASK_REPORT_MAX used to report preemption.
+ state = prev_state & 0xff
+ if state == 0:
+ new_state = STATE_WAITING
+ elif state & 2:
+ new_state = STATE_BLOCKED
+ else:
+ new_state = STATE_SLEEPING
+ prev.change_state(time, new_state)
+ prev.switches += 1
+
+ if next_tid != 0:
+ nxt = self.task(next_tid, next_comm)
+ nxt.change_state(time, STATE_RUNNING, cpu)
+
+ def sched_wakeup(self, time: int, wakee: int, comm: Optional[str], waker: int) -> None:
+ """Process a sched:sched_wakeup or sched:sched_wakeup_new event."""
+ self.sched_events += 1
+ if wakee == 0:
+ return
+ task = self.task(wakee, comm)
+ task.wakeups.append((time, waker))
+ if task.state in (STATE_UNKNOWN, STATE_SLEEPING, STATE_BLOCKED):
+ task.change_state(time, STATE_WAITING)
+
+ def cstate_start(self, time: int, cpu: int, state: int) -> None:
+ """Enter an idle state."""
+ self.power_events += 1
+ c = self.cpu(cpu)
+ if c.cstate_cur:
+ c.cstate.add(c.cstate_cur[0], time, 1, c.cstate_cur[1])
+ c.cstate_cur = (time, state)
+ self.max_cstate = max(self.max_cstate, state)
+
+ def cstate_end(self, time: int, cpu: int) -> None:
+ """Leave an idle state."""
+ self.power_events += 1
+ c = self.cpu(cpu)
+ if c.cstate_cur:
+ c.cstate.add(c.cstate_cur[0], time, 1, c.cstate_cur[1])
+ c.cstate_cur = None
+
+ def pstate_change(self, time: int, cpu: int, freq: int) -> None:
+ """Change of CPU frequency, freq is in kHz."""
+ if freq <= 0 or freq > 8000000:
+ return
+ self.power_events += 1
+ c = self.cpu(cpu)
+ if c.pstate_cur:
+ c.pstate.add(c.pstate_cur[0], time, 1, c.pstate_cur[1])
+ c.pstate_cur = (time, freq)
+ self.max_freq = max(self.max_freq, freq)
+ self.min_freq = freq if not self.min_freq else min(self.min_freq, freq)
+
+ def io_enter(self, time: int, tid: int, iotype: int, fd: int) -> None:
+ """Entry to an I/O syscall."""
+ self.io_events += 1
+ self.task(tid).io_pending = (time, iotype, fd)
+
+ def io_exit(self, time: int, tid: int, iotype: int, ret: int) -> None:
+ """Exit from an I/O syscall."""
+ self.io_events += 1
+ task = self.task(tid)
+ pending = task.io_pending
+ task.io_pending = None
+ if not pending or pending[1] != iotype:
+ return
+ start = pending[0]
+ task.io.add(start, max(time, start + 1), iotype, (pending[2], ret))
+ if ret > 0 and iotype in (IOTYPE_READ, IOTYPE_WRITE, IOTYPE_TX, IOTYPE_RX):
+ task.io_bytes += ret
+
+ def _on_sched_switch(self, time: int, sample: perf.sample_event) -> None:
+ self.sched_switch(time, sample.sample_cpu, sample.prev_pid,
+ getattr(sample, "prev_comm", None), sample.prev_state,
+ sample.next_pid, getattr(sample, "next_comm", None))
+
+ def _on_sched_wakeup(self, time: int, sample: perf.sample_event) -> None:
+ waker = getattr(sample, "common_pid", sample.sample_tid)
+ flags = getattr(sample, "common_flags", 0)
+ if flags & (TRACE_FLAG_HARDIRQ | TRACE_FLAG_SOFTIRQ):
+ waker = -1
+ self.sched_wakeup(time, sample.pid, getattr(sample, "comm", None), waker)
+
+ def _cstate(self, time: int, cpu: int, state: int) -> None:
+ if (state & 0xffffffff) == PWR_EVENT_EXIT:
+ self.cstate_end(time, cpu)
+ else:
+ self.cstate_start(time, cpu, state)
+
+ def _on_cpu_idle(self, time: int, sample: perf.sample_event) -> None:
+ self._cstate(time, sample.cpu_id, sample.state)
+
+ def _on_power_start(self, time: int, sample: perf.sample_event) -> None:
+ self._cstate(time, sample.cpu_id, sample.value)
+
+ def _on_power_end(self, time: int, sample: perf.sample_event) -> None:
+ self.cstate_end(time, sample.sample_cpu)
+
+ def _on_cpu_frequency(self, time: int, sample: perf.sample_event) -> None:
+ self.pstate_change(time, sample.cpu_id, sample.state)
+
+ def _on_power_frequency(self, time: int, sample: perf.sample_event) -> None:
+ self.pstate_change(time, sample.cpu_id, sample.value)
+
+ def _handler_for(self, name: str) -> Optional[Callable[[int, perf.sample_event], None]]:
+ """Find the handler for events with the given evsel name."""
+ handlers: Dict[str, Callable[[int, perf.sample_event], None]] = {
+ "sched:sched_switch": self._on_sched_switch,
+ "sched:sched_wakeup": self._on_sched_wakeup,
+ "sched:sched_wakeup_new": self._on_sched_wakeup,
+ "power:cpu_idle": self._on_cpu_idle,
+ "power:power_start": self._on_power_start,
+ "power:power_end": self._on_power_end,
+ "power:cpu_frequency": self._on_cpu_frequency,
+ "power:power_frequency": self._on_power_frequency,
+ }
+ if name in handlers:
+ return handlers[name]
+ if name.startswith("syscalls:sys_enter_") and name[19:] in IO_SYSCALLS:
+ iotype = IO_SYSCALLS[name[19:]]
+ return lambda time, sample: self.io_enter(time, sample.sample_tid, iotype,
+ getattr(sample, "fd", -1))
+ if name.startswith("syscalls:sys_exit_") and name[18:] in IO_SYSCALLS:
+ iotype = IO_SYSCALLS[name[18:]]
+ return lambda time, sample: self.io_exit(time, sample.sample_tid, iotype,
+ sample.ret)
+ return None
+
+ def process_event(self, sample: perf.sample_event) -> None:
+ """Callback from perf.session for each sample."""
+ self.nr_samples += 1
+ if self.nr_samples % self.PROGRESS_INTERVAL == 0:
+ if self.cancelled:
+ raise LoadCancelled()
+ now = monotonic()
+ interval = min(max(self.PROGRESS_SECONDS,
+ (now - self.start_progress) * self.PROGRESS_FRACTION),
+ self.PROGRESS_MAX_SECONDS)
+ if self.progress and now - self.last_progress >= interval:
+ with self.lock:
+ self.update_comms()
+ # Must not hold the lock as the callback may read the data.
+ self.progress()
+ # Time from when the callback, that may block, returns.
+ self.last_progress = monotonic()
+ with self.lock:
+ self._process_event(sample)
+
+ def _process_event(self, sample: perf.sample_event) -> None:
+ """Update the data from a sample, the lock must be held."""
+ time = sample.sample_time
+ if not self.first_time or time < self.first_time:
+ self.first_time = time
+ self.last_time = max(self.last_time, time)
+
+ # Computing the evsel name and matching it is relatively expensive,
+ # so cache the result by sample ID. Each ID belongs to a single evsel.
+ sample_id = sample.sample_id
+ cached = self._handlers.get(sample_id)
+ if cached is None:
+ name = str(sample.evsel)
+ if name.startswith("evsel(") and name.endswith(")"):
+ name = name[6:-1]
+ cached = (name, self._handler_for(name))
+ self._handlers[sample_id] = cached
+ name, handler = cached
+ if handler is None:
+ self.unhandled[name] += 1
+ return
+ try:
+ handler(time, sample)
+ except AttributeError:
+ self.unhandled[name] += 1
+
+ def update_comms(self) -> None:
+ """Refresh task command names from the session, the lock must be held."""
+ if not self.session:
+ return
+ for tid, task in self.tasks.items():
+ try:
+ thread = self.session.find_thread(tid, tid)
+ if thread:
+ task.set_comm(thread.comm())
+ except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError):
+ pass
+
+ def finish(self) -> None:
+ """Close open segments at the end of the trace."""
+ with self.lock:
+ self.update_comms()
+ end = self.last_time
+ for task in self.tasks.values():
+ task.change_state(end, STATE_UNKNOWN)
+ for c in self.cpus.values():
+ if c.cur_tid > 0:
+ c.run.add(c.since, end, 1, c.cur_tid)
+ if c.cstate_cur:
+ c.cstate.add(c.cstate_cur[0], end, 1, c.cstate_cur[1])
+ c.cstate_cur = None
+ if c.pstate_cur:
+ c.pstate.add(c.pstate_cur[0], end, 1, c.pstate_cur[1])
+ c.pstate_cur = None
+
+ def fmt_time(self, time: float) -> str:
+ """Format an absolute timestamp relative to the trace start."""
+ return f"{(time - self.first_time) / NSEC_PER_SEC:.6f}s"
+
+ def task_name(self, tid: int) -> str:
+ """Name of a task, or a description for special tids."""
+ if tid == 0:
+ return "idle"
+ if tid < 0:
+ return "interrupt"
+ task = self.tasks.get(tid)
+ return task.name() if task else f"[{tid}]"
+
+ def sched_tasks(self, filters: Sequence[str]) -> List[Task]:
+ """Tasks with scheduling history passing the filters, sorted by tid."""
+ return sorted((t for t in self.tasks.values()
+ if len(t.segs) and t.passes_filter(filters)),
+ key=lambda t: t.tid)
+
+ def io_tasks(self, filters: Sequence[str]) -> List[Task]:
+ """Tasks with I/O passing the filters, most I/O first."""
+ return sorted((t for t in self.tasks.values()
+ if len(t.io) and t.passes_filter(filters)),
+ key=lambda t: -len(t.io))
+
+ def dump(self, power_only: bool, tasks_only: bool, filters: Sequence[str]) -> None:
+ """Print a plain text summary, for use without a terminal UI."""
+ duration = self.last_time - self.first_time
+ span = max(duration, 1)
+ tasks = self.sched_tasks(filters)
+ io_tasks = self.io_tasks(filters)
+ print(f"Duration: {fmt_duration(duration)}, CPUs: {len(self.cpus)}, "
+ f"tasks: {len(tasks) or len(io_tasks)}, sched events: {self.sched_events}, "
+ f"power events: {self.power_events}, I/O events: {self.io_events}")
+ if not tasks_only:
+ for c in sorted(self.cpus.values(), key=lambda c: c.cpu):
+ busy = sum(e - s for s, e in zip(c.run.starts, c.run.ends))
+ print(f"CPU {c.cpu}: busy {busy * 100 / span:.1f}%, "
+ f"{len(c.run)} runs, {len(c.cstate)} idle periods, "
+ f"{len(c.pstate)} frequency periods")
+ if power_only:
+ return
+ print(f"{'Task':<24} {'TID':>8} {'Running':>12} {'Waiting':>12} {'Blocked':>12} "
+ f"{'Switches':>9} {'Wakeups':>8}")
+ for task in sorted(tasks, key=lambda t: -t.totals[STATE_RUNNING]):
+ print(f"{task.comm[:24]:<24} {task.tid:>8} "
+ f"{fmt_duration(task.totals[STATE_RUNNING]):>12} "
+ f"{fmt_duration(task.totals[STATE_WAITING]):>12} "
+ f"{fmt_duration(task.totals[STATE_BLOCKED]):>12} "
+ f"{task.switches:>9} {len(task.wakeups):>8}")
+ for task in io_tasks:
+ print(f"I/O {task.name()}: {len(task.io)} syscalls, {task.io_bytes} bytes")
+
+
+Cell = Tuple[str, Style]
+
+
+class ThemeColors:
+ """Colors and Rich styles derived from the active Textual theme."""
+ def __init__(self, theme_variables: Mapping[str, str]) -> None:
+ self.running = theme_variables["primary"]
+ self.waiting = theme_variables["error"]
+ self.blocked = theme_variables["warning"]
+ self.idle_light = theme_variables["secondary-lighten-2"]
+ self.idle_dark = theme_variables["secondary-darken-2"]
+ self.freq_low = theme_variables["success"]
+ self.freq_high = theme_variables["error"]
+ self.io = [
+ theme_variables["success"],
+ theme_variables["error"],
+ theme_variables["warning"],
+ theme_variables["accent"],
+ theme_variables["primary"],
+ theme_variables["secondary-lighten-2"],
+ ]
+ run_color = Color.parse(self.running).rich_color
+ wait_color = Color.parse(self.waiting).rich_color
+ block_color = Color.parse(self.blocked).rich_color
+ self.run_style = Style(color=run_color)
+ self.wait_style = Style(color=run_color, bgcolor=wait_color)
+ self.block_style = Style(color=run_color, bgcolor=block_color)
+ low = Color.parse(self.freq_low)
+ high = Color.parse(self.freq_high)
+ self.freq_styles = [Style(color=low.blend(high, x / 8).rich_color) for x in range(9)]
+ light = Color.parse(self.idle_light)
+ dark = Color.parse(self.idle_dark)
+ self.idle_styles = [Style(color=light.blend(dark, x / 8).rich_color) for x in range(9)]
+ self.io_styles = [Style(color=Color.parse(c).rich_color) for c in self.io]
+ self.io_err_styles = [Style(color=Color.parse(c).rich_color, underline=True)
+ for c in self.io]
+ accent = Color.parse(theme_variables["accent"]).rich_color
+ accent_muted = Color.parse(theme_variables["accent-muted"]).rich_color
+ self.selected_style = Style(color=accent, bgcolor=accent_muted, bold=True)
+
+
+class Row(ABC):
+ """A row within a timeline view."""
+ def __init__(self, label: str) -> None:
+ self._label = label
+
+ def label(self) -> str:
+ """Label shown to the left of the row."""
+ return self._label
+
+ @abstractmethod
+ def segments(self) -> SegmentList:
+ """Segments used for navigating between changes."""
+
+ @abstractmethod
+ def cells(self, t0: float, dt: float, width: int, colors: ThemeColors) -> List[Cell]:
+ """Cells for the columns starting at time t0 and dt wide."""
+
+ @abstractmethod
+ def describe(self, time: float, t0: float, t1: float) -> str:
+ """Rich markup describing the row at time within the window [t0, t1)."""
+
+
+class TaskRow(Row):
+ """Row showing the running, waiting and blocked states of a task.
+
+ The height of the bar shows the fraction of the time running, the
+ background shows waiting for a CPU or being blocked.
+ """
+ def __init__(self, data: TimechartData, task: Task) -> None:
+ super().__init__(task.name())
+ self.data = data
+ self.task = task
+
+ def label(self) -> str:
+ return self.task.name()
+
+ def segments(self) -> SegmentList:
+ return self.task.segs
+
+ def cells(self, t0: float, dt: float, width: int, colors: ThemeColors) -> List[Cell]:
+ result = []
+ for col in self.task.segs.coverage(t0, dt, width, NUM_STATES):
+ run = col[STATE_RUNNING] / dt
+ wait = col[STATE_WAITING]
+ block = col[STATE_BLOCKED]
+ if wait > 0 and wait >= block:
+ style = colors.wait_style
+ elif block > 0:
+ style = colors.block_style
+ else:
+ style = colors.run_style
+ result.append((bar_char(run), style))
+ return result
+
+ def describe(self, time: float, t0: float, t1: float) -> str:
+ task = self.task
+ i = task.segs.find(time)
+ if i >= 0:
+ state = task.segs.keys[i]
+ desc = STATE_NAMES[state]
+ if state == STATE_RUNNING:
+ desc += f" on CPU {task.segs.data[i]}"
+ elif state == STATE_WAITING and task.segs.data[i] >= 0:
+ desc += f", last ran on CPU {task.segs.data[i]}"
+ start = task.segs.starts[i]
+ end = task.segs.ends[i]
+ desc += (f" from {self.data.fmt_time(start)} for "
+ f"{fmt_duration(end - start)}")
+ else:
+ desc = "sleeping or not traced"
+ window = task.segs.coverage(t0, max(t1 - t0, 1), 1, NUM_STATES)[0]
+ span = max(t1 - t0, 1)
+ lines = [
+ f"[b]{escape(task.name())}[/b]: {desc}",
+ f"In view: running {window[STATE_RUNNING] * 100 / span:.1f}%, "
+ f"waiting {window[STATE_WAITING] * 100 / span:.1f}%, "
+ f"blocked {window[STATE_BLOCKED] * 100 / span:.1f}%",
+ f"Total: running {fmt_duration(task.totals[STATE_RUNNING])}, "
+ f"waiting {fmt_duration(task.totals[STATE_WAITING])}, "
+ f"blocked {fmt_duration(task.totals[STATE_BLOCKED])}, "
+ f"{task.switches} switches, {len(task.wakeups)} wakeups",
+ ]
+ idx = bisect.bisect_right(task.wakeups, (time, sys.maxsize)) - 1
+ if idx >= 0:
+ wake_time, waker = task.wakeups[idx]
+ lines.append(f"Last woken at {self.data.fmt_time(wake_time)} by "
+ f"{escape(self.data.task_name(waker))} (press 'w' to go to waker)")
+ if len(task.comms) > 1:
+ lines.append(f"Names: {escape(', '.join(task.comms))}")
+ return "\n".join(lines)
+
+
+class CpuRow(Row):
+ """Row showing how busy a CPU is, colored by frequency if known."""
+ def __init__(self, data: TimechartData, cpu: Cpu) -> None:
+ super().__init__(f"CPU {cpu.cpu}")
+ self.data = data
+ self.cpu = cpu
+
+ def segments(self) -> SegmentList:
+ return self.cpu.run
+
+ def cells(self, t0: float, dt: float, width: int, colors: ThemeColors) -> List[Cell]:
+ busy = self.cpu.run.coverage(t0, dt, width, 2)
+ freqs: Optional[List[List[float]]] = None
+ weighted: Optional[List[List[float]]] = None
+ if len(self.cpu.pstate) and self.data.max_freq > self.data.min_freq:
+ freqs = self.cpu.pstate.coverage(t0, dt, width, 2)
+ weighted = self.cpu.pstate.coverage(t0, dt, width, 2, weight_fn=float)
+ result = []
+ for x in range(width):
+ style = colors.run_style
+ if freqs and weighted and freqs[x][1] > 0:
+ freq = weighted[x][1] / freqs[x][1]
+ frac = (freq - self.data.min_freq) / (self.data.max_freq - self.data.min_freq)
+ style = colors.freq_styles[max(0, min(8, round(frac * 8)))]
+ result.append((bar_char(busy[x][1] / dt), style))
+ return result
+
+ def describe(self, time: float, t0: float, t1: float) -> str:
+ c = self.cpu
+ i = c.run.find(time)
+ if i >= 0:
+ desc = (f"running {escape(self.data.task_name(c.run.data[i]))} from "
+ f"{self.data.fmt_time(c.run.starts[i])} for "
+ f"{fmt_duration(c.run.ends[i] - c.run.starts[i])}")
+ else:
+ desc = "idle"
+ i = c.cstate.find(time)
+ if i >= 0:
+ desc += f", C-state C{c.cstate.data[i]}"
+ i = c.pstate.find(time)
+ if i >= 0:
+ desc += f", {c.pstate.data[i] / 1000:.0f} MHz"
+ span = max(t1 - t0, 1)
+ window = c.run.coverage(t0, span, 1, 2)[0]
+ return "\n".join([
+ f"[b]CPU {c.cpu}[/b]: {desc}",
+ f"In view: busy {window[1] * 100 / span:.1f}%",
+ "Bar height is the fraction of time busy" +
+ (", color is the frequency from low to high"
+ if len(c.pstate) else ""),
+ ])
+
+
+class CStateRow(Row):
+ """Row showing the idle states of a CPU."""
+ def __init__(self, data: TimechartData, cpu: Cpu) -> None:
+ super().__init__(f" CPU {cpu.cpu} idle")
+ self.data = data
+ self.cpu = cpu
+
+ def segments(self) -> SegmentList:
+ return self.cpu.cstate
+
+ def cells(self, t0: float, dt: float, width: int, colors: ThemeColors) -> List[Cell]:
+ cov = self.cpu.cstate.coverage(t0, dt, width, 2)
+ depth = self.cpu.cstate.coverage(t0, dt, width, 2, weight_fn=float)
+ max_cstate = max(self.data.max_cstate, 1)
+ result = []
+ for x in range(width):
+ frac = cov[x][1] / dt
+ style = colors.idle_styles[0]
+ if cov[x][1] > 0:
+ avg = depth[x][1] / cov[x][1]
+ style = colors.idle_styles[max(0, min(8, round(avg * 8 / max_cstate)))]
+ result.append((bar_char(frac), style))
+ return result
+
+ def describe(self, time: float, t0: float, t1: float) -> str:
+ c = self.cpu
+ i = c.cstate.find(time)
+ if i >= 0:
+ desc = (f"C{c.cstate.data[i]} from {self.data.fmt_time(c.cstate.starts[i])} for "
+ f"{fmt_duration(c.cstate.ends[i] - c.cstate.starts[i])}")
+ else:
+ desc = "not idle"
+ span = max(t1 - t0, 1)
+ window = c.cstate.coverage(t0, span, 1, 2)[0]
+ return "\n".join([
+ f"[b]CPU {c.cpu} idle state[/b]: {desc}",
+ f"In view: idle {window[1] * 100 / span:.1f}%",
+ "Bar height is the fraction of time idle, darker colors are deeper C-states",
+ ])
+
+
+class FreqRow(Row):
+ """Row showing the frequency of a CPU."""
+ def __init__(self, data: TimechartData, cpu: Cpu) -> None:
+ super().__init__(f" CPU {cpu.cpu} freq")
+ self.data = data
+ self.cpu = cpu
+
+ def segments(self) -> SegmentList:
+ return self.cpu.pstate
+
+ def cells(self, t0: float, dt: float, width: int, colors: ThemeColors) -> List[Cell]:
+ cov = self.cpu.pstate.coverage(t0, dt, width, 2)
+ weighted = self.cpu.pstate.coverage(t0, dt, width, 2, weight_fn=float)
+ max_freq = max(self.data.max_freq, 1)
+ min_freq = self.data.min_freq
+ result = []
+ for x in range(width):
+ if cov[x][1] <= 0:
+ result.append((" ", colors.freq_styles[0]))
+ continue
+ freq = weighted[x][1] / cov[x][1]
+ frac = (freq - min_freq) / (max_freq - min_freq) if max_freq > min_freq else 1.0
+ result.append((bar_char(max(freq / max_freq, 1 / 8)),
+ colors.freq_styles[max(0, min(8, round(frac * 8)))]))
+ return result
+
+ def describe(self, time: float, t0: float, t1: float) -> str:
+ c = self.cpu
+ i = c.pstate.find(time)
+ if i >= 0:
+ desc = (f"{c.pstate.data[i] / 1000:.0f} MHz from "
+ f"{self.data.fmt_time(c.pstate.starts[i])} for "
+ f"{fmt_duration(c.pstate.ends[i] - c.pstate.starts[i])}")
+ else:
+ desc = "unknown"
+ return "\n".join([
+ f"[b]CPU {c.cpu} frequency[/b]: {desc}",
+ f"Range: {self.data.min_freq / 1000:.0f} - {self.data.max_freq / 1000:.0f} MHz",
+ "Bar height is the frequency relative to the maximum",
+ ])
+
+
+class IoRow(Row):
+ """Row showing the I/O syscalls of a task."""
+ def __init__(self, data: TimechartData, task: Task) -> None:
+ super().__init__(task.name())
+ self.data = data
+ self.task = task
+
+ def label(self) -> str:
+ return self.task.name()
+
+ def segments(self) -> SegmentList:
+ return self.task.io
+
+ def cells(self, t0: float, dt: float, width: int, colors: ThemeColors) -> List[Cell]:
+ errs = self.task.io.coverage(t0, dt, width, NUM_IOTYPES,
+ weight_fn=lambda d: 1.0 if d[1] < 0 else 0.0)
+ result = []
+ for x, col in enumerate(self.task.io.coverage(t0, dt, width, NUM_IOTYPES)):
+ total = sum(col)
+ if total <= 0:
+ result.append((" ", colors.io_styles[0]))
+ continue
+ iotype = col.index(max(col))
+ styles = colors.io_err_styles if sum(errs[x]) > 0 else colors.io_styles
+ result.append((bar_char(max(total / dt, 1 / 8)), styles[iotype]))
+ return result
+
+ def describe(self, time: float, t0: float, t1: float) -> str:
+ task = self.task
+ i = task.io.find(time)
+ if i < 0:
+ i = task.io.last_before(time)
+ prefix = "last I/O"
+ else:
+ prefix = "in"
+ if i >= 0:
+ fd, ret = task.io.data[i]
+ result = f"returned {ret}" if ret >= 0 else f"failed with error {-ret}"
+ desc = (f"{prefix} {IOTYPE_NAMES[task.io.keys[i]]} fd={fd} at "
+ f"{self.data.fmt_time(task.io.starts[i])} for "
+ f"{fmt_duration(task.io.ends[i] - task.io.starts[i])}, {result}")
+ else:
+ desc = "no I/O"
+ span = max(t1 - t0, 1)
+ window = task.io.coverage(t0, span, 1, NUM_IOTYPES)[0]
+ in_view = ", ".join(f"{IOTYPE_NAMES[t]} {window[t] * 100 / span:.1f}%"
+ for t in range(NUM_IOTYPES) if window[t] > 0)
+ return "\n".join([
+ f"[b]{escape(task.name())}[/b]: {desc}",
+ f"In view: {in_view or 'no I/O'}",
+ f"Total: {len(task.io)} I/O syscalls, {task.io_bytes} bytes",
+ ])
+
+
+@dataclass(frozen=True)
+class TimeWindow:
+ """The visible time range and the cursor, shared between views.
+
+ Immutable so that it can be a reactive value, the methods return new windows.
+ """
+ first: int
+ last: int
+ start: float
+ end: float
+ cursor: float
+
+ @staticmethod
+ def whole(first: int, last: int) -> "TimeWindow":
+ """A window showing the whole trace with the cursor at the start."""
+ last = max(last, first + 1)
+ return TimeWindow(first, last, first, last, first)
+
+ def span(self) -> float:
+ """Length of the visible time range."""
+ return self.end - self.start
+
+ def reset(self) -> "TimeWindow":
+ """Show the whole trace."""
+ return replace(self, start=self.first, end=self.last)
+
+ def with_range(self, start: float, span: float) -> "TimeWindow":
+ """Set the visible range clamped to the trace."""
+ span = min(max(span, 100.0), self.last - self.first)
+ start = min(max(start, self.first), self.last - span)
+ return replace(self, start=start, end=start + span)
+
+ def zoom(self, factor: float) -> "TimeWindow":
+ """Zoom by factor keeping the cursor at the same position."""
+ cursor = self.cursor
+ if not self.start <= cursor <= self.end:
+ cursor = (self.start + self.end) / 2
+ rel = (cursor - self.start) / self.span()
+ span = self.span() * factor
+ return replace(self, cursor=cursor).with_range(cursor - rel * span, span)
+
+ def pan(self, frac: float) -> "TimeWindow":
+ """Pan the view by the fraction of the visible span."""
+ delta = self.span() * frac
+ win = self.with_range(self.start + delta, self.span())
+ return replace(win, cursor=min(max(self.cursor + delta, win.start), win.end))
+
+ def with_cursor(self, time: float) -> "TimeWindow":
+ """Move the cursor, scrolling to keep it visible."""
+ win = replace(self, cursor=min(max(time, self.first), self.last))
+ if win.cursor < win.start:
+ return win.with_range(win.cursor, win.span())
+ if win.cursor >= win.end:
+ return win.with_range(win.cursor - win.span() * 0.9, win.span())
+ return win
+
+ def extend(self, first: int, last: int) -> "TimeWindow":
+ """Change the bounds of the trace as more of it is loaded.
+
+ If the whole trace was visible then it still is, otherwise the visible
+ range is kept.
+ """
+ last = max(last, first + 1)
+ whole = self.start == self.first and self.end == self.last
+ win = replace(self, first=first, last=last, cursor=min(max(self.cursor, first), last))
+ return win.reset() if whole else win.with_range(self.start, self.span())
+
+
+class TimelineView(ScrollView):
+ """A scrollable view of rows against a time axis.
+
+ Line 0 is a time ruler that stays at the top, the other lines are rows.
+ """
+ BINDINGS = [
+ Binding("up,k", "row_up", "Up", show=False),
+ Binding("down,j", "row_down", "Down", show=False),
+ Binding("left,h", "cursor_left", "Cursor ←", key_display="←"),
+ Binding("right,l", "cursor_right", "Cursor →", key_display="→"),
+ Binding("shift+left,H", "pan_left", "Pan left", show=False),
+ Binding("shift+right,L", "pan_right", "Pan right", show=False),
+ Binding("plus,equals_sign", "zoom_in", "Zoom in", key_display="+"),
+ Binding("minus", "zoom_out", "Zoom out", key_display="-"),
+ Binding("0,escape", "zoom_reset", "Reset zoom", key_display="0"),
+ Binding("n", "next_change", "Next change"),
+ Binding("p", "prev_change", "Prev change"),
+ Binding("pageup", "page_up", "Page up", show=False),
+ Binding("pagedown", "page_down", "Page down", show=False),
+ ]
+
+ DEFAULT_CSS = """
+ TimelineView {
+ width: 100%;
+ height: 1fr;
+ }
+ """
+
+ class SelectionChanged(Message):
+ """Posted when the selected row changes."""
+
+ # Bound to TimechartApp.window, changing it repaints the view.
+ window: reactive[TimeWindow] = reactive(TimeWindow.whole(0, 1))
+ # Index of the selected row.
+ selected: reactive[int] = reactive(0)
+
+ def __init__(self, data: TimechartData, rows: List[Row], *pos_args, **kwargs) -> None:
+ super().__init__(*pos_args, **kwargs)
+ self.can_focus = True
+ self.data = data
+ self.rows = rows
+ self.cache: Dict[int, List[Cell]] = {}
+ self.cache_key: Tuple[float, float, int, Optional[ThemeColors]] = (0.0, 0.0, 0, None)
+ self.label_style = Style()
+ self.ruler_style = Style(dim=True)
+ self.cursor_style = Style(reverse=True)
+
+ def selected_row(self) -> Optional[Row]:
+ """The selected row, if any."""
+ if 0 <= self.selected < len(self.rows):
+ return self.rows[self.selected]
+ return None
+
+ def timeline_width(self) -> int:
+ """Number of columns used for the timeline."""
+ return max(self.scrollable_content_region.width - LABEL_WIDTH, 1)
+
+ def update_size(self) -> None:
+ """Update the virtual size after rows change or the widget resizes."""
+ self.virtual_size = Size(self.scrollable_content_region.width, len(self.rows) + 1)
+
+ def on_mount(self) -> None:
+ """Size the view when mounted."""
+ self.update_size()
+
+ def on_resize(self) -> None:
+ """Size the view when resized."""
+ self.update_size()
+ self.refresh()
+
+ def set_rows(self, rows: List[Row]) -> None:
+ """Replace the rows, after sorting or as more data is loaded.
+
+ The selected row is kept, if it moves watch_selected scrolls to it.
+ """
+ selected = self.selected_row()
+ self.rows = rows
+ self.cache.clear()
+ self.update_size()
+ self.selected = rows.index(selected) if selected in rows else 0
+ self.refresh()
+
+ def validate_selected(self, idx: int) -> int:
+ """Keep the selection within the rows."""
+ return max(0, min(idx, len(self.rows) - 1))
+
+ def watch_selected(self) -> None:
+ """Keep the selection visible and tell the app."""
+ self.scroll_to_selected()
+ self.post_message(self.SelectionChanged())
+
+ def scroll_to_selected(self) -> None:
+ """Scroll so that the selected row is visible below the ruler."""
+ _, scroll_y = self.scroll_offset
+ visible = max(self.scrollable_content_region.height - 1, 1)
+ if self.selected < scroll_y:
+ self.scroll_to(y=self.selected, animate=False)
+ elif self.selected >= scroll_y + visible:
+ self.scroll_to(y=self.selected - visible + 1, animate=False)
+
+ def set_window(self, window: TimeWindow) -> None:
+ """Change the window shared by all views, owned by the app."""
+ app = self.app
+ if isinstance(app, TimechartApp):
+ app.window = window
+
+ def ruler(self, width: int) -> Strip:
+ """The time axis, labeled in seconds relative to the trace start."""
+ win = self.window
+ dt = win.span() / width
+ tick = 14
+ decimals = max(0, min(9, 1 - math.floor(math.log10(max(dt * tick, 1) / NSEC_PER_SEC))))
+ chars = [" "] * width
+ x = 0
+ while x < width:
+ label = f"|{(win.start + x * dt - self.data.first_time) / NSEC_PER_SEC:.{decimals}f}"
+ if x + len(label) > width:
+ break
+ chars[x:x + len(label)] = list(label)
+ x += max(tick, len(label) + 2)
+ segments = [Segment(make_fixed_length_string("Time (s)", LABEL_WIDTH), self.ruler_style)]
+ cursor_x = self.cursor_column(width)
+ if 0 <= cursor_x < width:
+ segments.append(Segment("".join(chars[:cursor_x]), self.ruler_style))
+ segments.append(Segment("▼"))
+ segments.append(Segment("".join(chars[cursor_x + 1:]), self.ruler_style))
+ else:
+ segments.append(Segment("".join(chars), self.ruler_style))
+ return Strip(segments)
+
+ def cursor_column(self, width: int) -> int:
+ """Column of the cursor or -1 if not visible."""
+ win = self.window
+ if not win.start <= win.cursor < win.end:
+ return -1
+ return min(int((win.cursor - win.start) * width / win.span()), width - 1)
+
+ def render_line(self, y: int) -> Strip:
+ """Render the ruler or a row."""
+ width = self.timeline_width()
+ if y == 0:
+ return self.ruler(width)
+ _, scroll_y = self.scroll_offset
+ idx = scroll_y + y - 1
+ if idx >= len(self.rows):
+ return Strip.blank(self.scrollable_content_region.width)
+
+ colors = self.app.theme_colors if isinstance(self.app, TimechartApp) else \
+ ThemeColors(self.app.theme_variables)
+ win = self.window
+ key = (win.start, win.end, width, colors)
+ if key != self.cache_key:
+ self.cache.clear()
+ self.cache_key = key
+ row = self.rows[idx]
+ with self.data.lock:
+ cells = self.cache.get(idx)
+ if cells is None:
+ cells = row.cells(win.start, win.span() / width, width, colors)
+ self.cache[idx] = cells
+ label = row.label()
+
+ label_style = colors.selected_style if idx == self.selected else self.label_style
+ segments = [Segment(make_fixed_length_string(label, LABEL_WIDTH - 1) + " ",
+ label_style)]
+ cursor_x = self.cursor_column(width)
+ # Merge runs of cells with the same style into one segment.
+ text = ""
+ style: Optional[Style] = None
+ for x, (char, cell_style) in enumerate(cells):
+ if x == cursor_x:
+ cell_style = cell_style + self.cursor_style
+ if cell_style is not style and text:
+ segments.append(Segment(text, style))
+ text = ""
+ text += char
+ style = cell_style
+ if text:
+ segments.append(Segment(text, style))
+ return Strip(segments)
+
+ def column_time(self, x: int) -> float:
+ """Time at the center of the column containing screen offset x."""
+ width = self.timeline_width()
+ return self.window.start + (x - LABEL_WIDTH + 0.5) * self.window.span() / width
+
+ def on_click(self, click: events.Click) -> None:
+ """Select the clicked row and move the cursor to the clicked time."""
+ if click.x >= LABEL_WIDTH:
+ self.set_window(self.window.with_cursor(self.column_time(click.x)))
+ if click.y > 0:
+ _, scroll_y = self.scroll_offset
+ self.selected = scroll_y + click.y - 1
+
+ def action_row_up(self) -> None:
+ """Select the previous row."""
+ self.selected -= 1
+
+ def action_row_down(self) -> None:
+ """Select the next row."""
+ self.selected += 1
+
+ def action_page_up(self) -> None:
+ """Select a row a page up."""
+ self.selected -= max(self.scrollable_content_region.height - 2, 1)
+
+ def action_page_down(self) -> None:
+ """Select a row a page down."""
+ self.selected += max(self.scrollable_content_region.height - 2, 1)
+
+ def action_cursor_left(self) -> None:
+ """Move the cursor one column left."""
+ win = self.window
+ self.set_window(win.with_cursor(win.cursor - win.span() / self.timeline_width()))
+
+ def action_cursor_right(self) -> None:
+ """Move the cursor one column right."""
+ win = self.window
+ self.set_window(win.with_cursor(win.cursor + win.span() / self.timeline_width()))
+
+ def action_pan_left(self) -> None:
+ """Pan a quarter of the view left."""
+ self.set_window(self.window.pan(-0.25))
+
+ def action_pan_right(self) -> None:
+ """Pan a quarter of the view right."""
+ self.set_window(self.window.pan(0.25))
+
+ def action_zoom_in(self) -> None:
+ """Halve the visible time range around the cursor."""
+ self.set_window(self.window.zoom(0.5))
+
+ def action_zoom_out(self) -> None:
+ """Double the visible time range around the cursor."""
+ self.set_window(self.window.zoom(2))
+
+ def action_zoom_reset(self) -> None:
+ """Show the whole trace."""
+ self.set_window(self.window.reset())
+
+ def action_next_change(self) -> None:
+ """Move the cursor to the next change in the selected row."""
+ row = self.selected_row()
+ if row:
+ # Skip changes within the cursor's column.
+ win = self.window
+ with self.data.lock:
+ time = row.segments().next_change(
+ win.cursor + win.span() / self.timeline_width() / 2)
+ if time is not None:
+ self.set_window(win.with_cursor(time))
+
+ def action_prev_change(self) -> None:
+ """Move the cursor to the previous change in the selected row."""
+ row = self.selected_row()
+ if row:
+ win = self.window
+ with self.data.lock:
+ time = row.segments().prev_change(
+ win.cursor - win.span() / self.timeline_width() / 2)
+ if time is not None:
+ self.set_window(win.with_cursor(time))
+
+
+class TimechartApp(App):
+ """A Textual application to display a timechart."""
+ TITLE = "perf timechart"
+
+ BINDINGS = [
+ Binding("s", "sort", "Sort tasks", tooltip="Cycle task sort order"),
+ Binding("w", "goto_waker", "Go to waker",
+ tooltip="Select the task that last woke the selected task"),
+ Binding(key="^q", action="quit", description="Quit", tooltip="Quit the app"),
+ ]
+
+ CSS = """
+ TabbedContent, TabbedContent > ContentSwitcher, TabPane {
+ height: 1fr;
+ }
+ TabPane {
+ padding: 0;
+ }
+ .legend {
+ height: 1;
+ padding: 0 1;
+ }
+ #details {
+ height: 8;
+ border: round $primary;
+ padding: 0 1;
+ }
+ """
+
+ SORT_ORDERS = ["run time", "tid", "name", "first run"]
+
+ # The visible time range and cursor, bound to each TimelineView's window.
+ window: reactive[TimeWindow] = reactive(TimeWindow.whole(0, 1), init=False)
+ # Index into SORT_ORDERS for the tasks view.
+ sort_order: reactive[int] = reactive(0, init=False)
+
+ def __init__(self, input_name: str, power_only: bool, tasks_only: bool,
+ filters: Sequence[str], data: Optional[TimechartData] = None) -> None:
+ """Create the app, if data isn't given it is loaded from input_name.
+
+ While the data is loading the views show what has been read so far.
+ """
+ super().__init__()
+ self.input_name = input_name
+ self.power_only = power_only
+ self.tasks_only = tasks_only
+ self.filters = filters
+ # The data being displayed, possibly still being loaded.
+ self.data = data if data else TimechartData()
+ self.loaded = data is not None
+ # The data being loaded in a background thread.
+ self.loading: Optional[TimechartData] = None
+ self.tasks: List[Task] = []
+ self.io_tasks: List[Task] = []
+ # Rows keyed by type and CPU or tid. They are reused as the data loads
+ # so that the views can keep the selected row.
+ self.row_cache: Dict[Tuple[str, int], Row] = {}
+ self.io_tab_shown = True
+ # Does the summary table need updating before it is next shown?
+ self.summary_stale = True
+ self.theme_colors = ThemeColors(self.theme_variables)
+
+ def cached_row(self, cls: Callable[[TimechartData, Any], Row], num: int, obj: Any) -> Row:
+ """Find or create the row of type cls for the CPU or task obj numbered num."""
+ key = (getattr(cls, "__name__", ""), num)
+ row = self.row_cache.get(key)
+ if row is None:
+ row = cls(self.data, obj)
+ self.row_cache[key] = row
+ return row
+
+ def cpu_rows(self) -> List[Row]:
+ """Rows for the CPUs tab, the data's lock must be held."""
+ rows: List[Row] = []
+ for cpu in sorted(self.data.cpus.values(), key=lambda c: c.cpu):
+ if len(cpu.run) or len(cpu.cstate) or len(cpu.pstate):
+ rows.append(self.cached_row(CpuRow, cpu.cpu, cpu))
+ if len(cpu.cstate):
+ rows.append(self.cached_row(CStateRow, cpu.cpu, cpu))
+ if len(cpu.pstate):
+ rows.append(self.cached_row(FreqRow, cpu.cpu, cpu))
+ return rows
+
+ def task_rows(self) -> List[Row]:
+ """Rows for the tasks tab in the current sort order, the data's lock must be held."""
+ order = self.SORT_ORDERS[self.sort_order]
+ if order == "run time":
+ tasks = sorted(self.tasks, key=lambda t: -t.totals[STATE_RUNNING])
+ elif order == "name":
+ tasks = sorted(self.tasks, key=lambda t: (t.comm, t.tid))
+ elif order == "first run":
+ tasks = sorted(self.tasks, key=lambda t: t.segs.starts[0])
+ else:
+ tasks = self.tasks
+ return [self.cached_row(TaskRow, t.tid, t) for t in tasks]
+
+ def compose(self) -> ComposeResult:
+ """Composes the user interface of the application."""
+ yield Header()
+ with TabbedContent():
+ if not self.tasks_only:
+ with TabPane("CPUs", id="cpus"):
+ yield Static(id="cpus_legend", classes="legend")
+ yield TimelineView(self.data, [],
+ id="cpus_view").data_bind(TimechartApp.window)
+ if not self.power_only:
+ with TabPane("Tasks", id="tasks"):
+ yield Static(id="tasks_legend", classes="legend")
+ yield TimelineView(self.data, [],
+ id="tasks_view").data_bind(TimechartApp.window)
+ # Hidden until there are tasks doing I/O.
+ with TabPane("I/O", id="io"):
+ yield Static(id="io_legend", classes="legend")
+ yield TimelineView(self.data, [],
+ id="io_view").data_bind(TimechartApp.window)
+ with TabPane("Summary", id="summary"):
+ yield DataTable(id="summary_table", cursor_type="row")
+ yield Static(id="details")
+ yield Footer()
+
+ def on_mount(self) -> None:
+ """Show the data, loading it in the background if it wasn't given."""
+ self.theme_changed_signal.subscribe(self, self.on_theme_changed)
+ self.update_legends()
+ self.update_views()
+ view = self.active_view()
+ if view:
+ view.focus()
+ if self.loaded:
+ self.finish_loading()
+ else:
+ self.sub_title = f"Loading {self.input_name}"
+ self.loading = self.data
+ self.load_events()
+
+ def on_theme_changed(self, _theme: Any) -> None:
+ """Update colors, legends and timeline views when the theme changes."""
+ self.theme_colors = ThemeColors(self.theme_variables)
+ self.update_legends()
+ for view in self.query(TimelineView):
+ view.refresh()
+
+ def update_legends(self) -> None:
+ """Update the legend strings using the active theme's colors."""
+ c = self.theme_colors
+ with self.data.lock:
+ cpus_legend = self.cpu_legend()
+ for static in self.query("#cpus_legend").results(Static):
+ static.update(cpus_legend)
+ tasks_legend = (f"[{c.running}]█[/] running "
+ f"[on {c.waiting}] [/] runnable "
+ f"[on {c.blocked}] [/] blocked "
+ "(bar height is the fraction of time running)")
+ for static in self.query("#tasks_legend").results(Static):
+ static.update(tasks_legend)
+ io_legend = (" ".join(f"[{c.io[t]}]█[/] {IOTYPE_NAMES[t]}"
+ for t in range(NUM_IOTYPES)) +
+ " (underline is an error)")
+ for static in self.query("#io_legend").results(Static):
+ static.update(io_legend)
+
+ @work(thread=True, exclusive=True)
+ def load_events(self) -> None:
+ """Read the perf.data file in a thread so the UI stays responsive."""
+ data = self.loading or self.data
+ self.loading = data
+
+ def progress() -> None:
+ try:
+ # Blocks until the UI has shown the data read so far.
+ self.call_from_thread(self.update_progress, data)
+ except RuntimeError:
+ # The app is no longer running.
+ data.cancelled = True
+
+ data.progress = progress
+ data.start_progress = data.last_progress = monotonic()
+ try:
+ read_events(data, self.input_name)
+ except LoadCancelled:
+ return
+ except (OSError, ValueError, RuntimeError) as e:
+ self.call_from_thread(self.exit, None, 1,
+ f"Error processing {self.input_name}: {e}")
+ return
+ finally:
+ data.progress = None
+ self.loading = None
+ if data.cancelled:
+ return
+ if not data.has_events():
+ self.call_from_thread(self.exit, None, 1,
+ f"Error: no scheduler, power or I/O events found in "
+ f"{self.input_name}.\n"
+ "Record them with 'perf timechart record'.")
+ return
+ self.call_from_thread(self.data_loaded)
+
+ def data_loaded(self) -> None:
+ """Called on the UI thread when loading completes."""
+ self.loaded = True
+ self.finish_loading()
+
+ def update_progress(self, data: TimechartData) -> None:
+ """Show how much of the file has been processed and the data so far."""
+ self.sub_title = (f"Loading {self.input_name}: {data.nr_samples:,} samples, "
+ f"{fmt_duration(data.last_time - data.first_time)} of trace")
+ self.update_views()
+
+ def finish_loading(self) -> None:
+ """Show all of the data and a summary of it."""
+ self.update_views()
+ views = self.query("#cpus_view")
+ if views and not views.first(TimelineView).rows:
+ self.query_one(TabbedContent).hide_tab("cpus")
+ task_views = self.query("#tasks_view")
+ if task_views and not task_views.first(TimelineView).rows and self.io_tasks:
+ self.query_one(TabbedContent).hide_tab("tasks")
+ nr_tasks = len(self.tasks) or len(self.io_tasks)
+ self.sub_title = (f"{self.input_name}: "
+ f"{fmt_duration(self.data.last_time - self.data.first_time)}, "
+ f"{len(self.data.cpus)} CPUs, {nr_tasks} tasks")
+
+ def cancel_loading(self) -> None:
+ """Stop a background load, the worker notices at the next progress interval."""
+ loading = self.loading
+ if loading is not None:
+ loading.cancelled = True
+
+ async def action_quit(self) -> None:
+ """Quit, stopping any background load."""
+ self.cancel_loading()
+ await super().action_quit()
+
+ def on_unmount(self) -> None:
+ """Stop any background load when the app exits."""
+ self.cancel_loading()
+
+ def cpu_legend(self) -> str:
+ """Legend for the CPUs tab, saying which power events are missing.
+
+ The data's lock must be held.
+ """
+ c = self.theme_colors
+ data = self.data
+ has_pstate = any(len(cpu.pstate) for cpu in data.cpus.values())
+ has_cstate = any(len(cpu.cstate) for cpu in data.cpus.values())
+ parts = [f"[{c.running}]█[/] busy"]
+ if has_pstate and data.max_freq > data.min_freq:
+ parts.append(f"[{c.freq_low}]█[/]→[{c.freq_high}]█[/] frequency")
+ elif has_pstate:
+ parts.append(f"frequency constant at {data.max_freq / 1000:.0f} MHz")
+ if has_cstate:
+ parts.append(f"[{c.idle_light}]█[/]→[{c.idle_dark}]█[/] idle state")
+ missing = []
+ if not has_pstate:
+ missing.append("power:cpu_frequency")
+ if not has_cstate:
+ missing.append("power:cpu_idle")
+ if missing:
+ parts.append(f"[dim](no {' or '.join(missing)} events)[/dim]")
+ return " ".join(parts)
+
+ def update_views(self) -> None:
+ """Update the views from the data, which may still be loading."""
+ data = self.data
+ with data.lock:
+ first, last = data.first_time, data.last_time
+ self.tasks = data.sched_tasks(self.filters)
+ self.io_tasks = data.io_tasks(self.filters)
+ all_rows = {
+ "cpus_view": self.cpu_rows(),
+ "tasks_view": self.task_rows(),
+ "io_view": [self.cached_row(IoRow, t.tid, t) for t in self.io_tasks],
+ }
+ legend = self.cpu_legend()
+ for view_id, rows in all_rows.items():
+ for view in self.query(f"#{view_id}").results(TimelineView):
+ view.set_rows(rows)
+ for static in self.query("#cpus_legend").results(Static):
+ static.update(legend)
+ if self.query("#io") and bool(self.io_tasks) != self.io_tab_shown:
+ self.io_tab_shown = bool(self.io_tasks)
+ tabbed = self.query_one(TabbedContent)
+ if self.io_tab_shown:
+ tabbed.show_tab("io")
+ else:
+ tabbed.hide_tab("io")
+ if first or last:
+ self.window = self.window.extend(first, last)
+ self.summary_stale = True
+ tabs = self.query(TabbedContent)
+ if tabs and tabs.first(TabbedContent).active == "summary":
+ self.update_summary()
+ self.update_details()
+
+ def update_summary(self) -> None:
+ """Fill in the summary table keeping the selected task."""
+ tables = self.query("#summary_table")
+ if not tables:
+ return
+ self.summary_stale = False
+ table = tables.first(DataTable)
+ if not table.columns:
+ table.add_columns("Task", "TID", "Running", "Waiting", "Blocked",
+ "Switches", "Wakeups", "I/O bytes")
+ selected = None
+ if table.row_count:
+ try:
+ selected = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
+ except CellDoesNotExist:
+ pass
+ summary_tasks = self.tasks if self.tasks else self.io_tasks
+ with self.data.lock:
+ rows = [(task.comm, task.tid,
+ fmt_duration(task.totals[STATE_RUNNING]),
+ fmt_duration(task.totals[STATE_WAITING]),
+ fmt_duration(task.totals[STATE_BLOCKED]),
+ task.switches, len(task.wakeups), task.io_bytes)
+ for task in sorted(summary_tasks,
+ key=lambda t: (-t.totals[STATE_RUNNING], -t.io_bytes))]
+ table.clear()
+ for row in rows:
+ table.add_row(*row, key=str(row[1]))
+ if selected is not None:
+ try:
+ table.move_cursor(row=table.get_row_index(selected), animate=False)
+ except RowDoesNotExist:
+ pass
+
+ def active_view(self) -> Optional[TimelineView]:
+ """The timeline view in the active tab, if any."""
+ tabs = self.query(TabbedContent)
+ if not tabs:
+ return None
+ active = tabs.first(TabbedContent).active
+ views = self.query(f"#{active}_view") if active else None
+ return views.first(TimelineView) if views else None
+
+ def watch_window(self) -> None:
+ """The bound views repaint themselves, update the details."""
+ self.update_details()
+
+ @on(TimelineView.SelectionChanged)
+ def on_selection_changed(self) -> None:
+ """Describe the newly selected row."""
+ self.update_details()
+
+ def update_details(self) -> None:
+ """Describe the selected row at the cursor."""
+ details = self.query("#details")
+ if not details:
+ return
+ win = self.window
+ text = (f"Cursor [b]{self.data.fmt_time(win.cursor)}[/b] "
+ f"View {self.data.fmt_time(win.start)} - {self.data.fmt_time(win.end)} "
+ f"({fmt_duration(win.span())})\n")
+ view = self.active_view()
+ row = view.selected_row() if view else None
+ if row:
+ with self.data.lock:
+ text += row.describe(win.cursor, win.start, win.end)
+ elif view is None:
+ text += "Select a task and press enter to show it in the Tasks timeline"
+ details.first(Static).update(text)
+
+ @on(TabbedContent.TabActivated)
+ def on_tab_activated(self) -> None:
+ """Focus the view in the newly active tab."""
+ view = self.active_view()
+ if view:
+ view.focus()
+ else:
+ if self.summary_stale:
+ self.update_summary()
+ tables = self.query("#summary_table")
+ if tables:
+ tables.first(DataTable).focus()
+ self.update_details()
+
+ @on(DataTable.RowSelected)
+ def on_row_selected(self, event: DataTable.RowSelected) -> None:
+ """Show the selected summary task in the tasks timeline."""
+ if event.row_key.value is not None:
+ self.goto_task(int(event.row_key.value), None)
+
+ def goto_task(self, tid: int, time: Optional[float]) -> None:
+ """Select the task in the tasks or I/O view, optionally moving the cursor."""
+ for tab_id, view_id, row_type in (("tasks", "#tasks_view", TaskRow),
+ ("io", "#io_view", IoRow)):
+ views = self.query(view_id)
+ if not views:
+ continue
+ view = views.first(TimelineView)
+ for idx, row in enumerate(view.rows):
+ if isinstance(row, row_type) and row.task.tid == tid:
+ self.query_one(TabbedContent).active = tab_id
+ if time is not None:
+ self.window = self.window.with_cursor(time)
+ view.selected = idx
+ view.focus()
+ return
+ self.notify(f"Task {tid} isn't shown in the tasks view", severity="warning")
+
+ def action_sort(self) -> None:
+ """Cycle the sort order of the tasks view."""
+ self.sort_order = (self.sort_order + 1) % len(self.SORT_ORDERS)
+
+ def watch_sort_order(self) -> None:
+ """Re-sort the tasks view."""
+ views = self.query("#tasks_view")
+ if not views:
+ return
+ with self.data.lock:
+ rows = self.task_rows()
+ views.first(TimelineView).set_rows(rows)
+ self.notify(f"Tasks sorted by {self.SORT_ORDERS[self.sort_order]}")
+ self.update_details()
+
+ def action_goto_waker(self) -> None:
+ """Select the task that woke the selected task before the cursor."""
+ view = self.active_view()
+ row = view.selected_row() if view else None
+ if not isinstance(row, (TaskRow, IoRow)):
+ return
+ with self.data.lock:
+ wakeups = row.task.wakeups
+ idx = bisect.bisect_right(wakeups, (self.window.cursor, sys.maxsize)) - 1
+ wake_time, waker = wakeups[idx] if idx >= 0 else (0, 0)
+ if idx < 0:
+ self.notify("No wakeup before the cursor", severity="warning")
+ return
+ if waker <= 0:
+ self.notify(f"Woken by {self.data.task_name(waker)} at "
+ f"{self.data.fmt_time(wake_time)}")
+ return
+ self.goto_task(waker, wake_time)
+
+
+def main() -> None:
+ """Parse arguments, read the perf.data file and run the app."""
+ parser = argparse.ArgumentParser(
+ description="Interactive timechart of CPU, task and I/O activity.")
+ parser.add_argument("-i", "--input", default="perf.data", help="input perf.data file")
+ parser.add_argument("-P", "--power-only", action="store_true",
+ help="only show CPU power information")
+ parser.add_argument("-T", "--tasks-only", action="store_true",
+ help="only show task information")
+ parser.add_argument("-p", "--process", action="append", default=[],
+ help="only show processes with the given name or PID, may be repeated")
+ parser.add_argument("--dump", action="store_true",
+ help="print a text summary rather than running the interactive UI")
+ args = parser.parse_args()
+
+ if args.power_only and args.tasks_only:
+ print("Error: -P and -T options cannot be used at the same time.", file=sys.stderr)
+ sys.exit(1)
+
+ if args.input == "-":
+ if not args.dump:
+ # The interactive UI reads the keyboard from stdin.
+ print("Error: reading perf.data from stdin requires --dump.", file=sys.stderr)
+ sys.exit(1)
+ elif not os.path.exists(args.input):
+ print(f"Error: {args.input} not found. (try 'perf timechart record' first)",
+ file=sys.stderr)
+ sys.exit(1)
+
+ if args.dump:
+ data = load_data(args.input)
+ data.dump(args.power_only, args.tasks_only, args.process)
+ return
+
+ # The app starts immediately and loads the data in the background.
+ app = TimechartApp(args.input, args.power_only, args.tasks_only, args.process)
+ app.run()
+ sys.exit(app.return_code or 0)
+
+
+def read_events(data: TimechartData, input_name: str) -> None:
+ """Process the events in input_name into data, raising on errors."""
+ if data.cancelled:
+ raise LoadCancelled()
+ try:
+ data.session = perf.session(perf.data(input_name), sample=data.process_event)
+ data.session.process_events()
+ data.finish()
+ finally:
+ # Break the reference cycle between the session and the callback.
+ data.session = None
+
+
+def load_data(input_name: str) -> TimechartData:
+ """Read the perf.data file exiting on errors or if there's nothing to show."""
+ if input_name != "-" and not os.path.exists(input_name):
+ print(f"Error: {input_name} not found. (try 'perf timechart record' first)",
+ file=sys.stderr)
+ sys.exit(1)
+
+ data = TimechartData()
+ try:
+ read_events(data, input_name)
+ except (OSError, ValueError, RuntimeError) as e:
+ print(f"Error processing {input_name}: {e}", file=sys.stderr)
+ sys.exit(1)
+ except KeyboardInterrupt:
+ data.finish()
+
+ if not data.has_events():
+ print(f"Error: no scheduler, power or I/O events found in {input_name}.\n"
+ "Record them with 'perf timechart record'.", file=sys.stderr)
+ sys.exit(1)
+ return data
+
+
+if __name__ == "__main__":
+ main()
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 11/13] perf test: Add a test for perf timechart --tui
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
` (9 preceding siblings ...)
2026-10-02 18:26 ` [PATCH v1 10/13] perf timechart: Add an interactive --tui mode Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 12/13] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-02 18:26 ` [PATCH v1 13/13] perf test: Test perf timechart --live Ian Rogers
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
From: Alice Rogers <alice.mei.rogers@gmail.com>
Test launching ttimechart through perf timechart --tui, the error when
there are no scheduler events, the --dump output with the -P, -T and -p
options, and drive the textual app headless exercising the views and
key bindings.
Assisted-by: Antigravity:gemini-3.1-pro
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>
---
.../tests/shell/test_ttimechart_python.sh | 237 ++++++++++++++++++
1 file changed, 237 insertions(+)
create mode 100755 tools/perf/tests/shell/test_ttimechart_python.sh
diff --git a/tools/perf/tests/shell/test_ttimechart_python.sh b/tools/perf/tests/shell/test_ttimechart_python.sh
new file mode 100755
index 000000000000..87b91ed8fc16
--- /dev/null
+++ b/tools/perf/tests/shell/test_ttimechart_python.sh
@@ -0,0 +1,237 @@
+#!/bin/bash
+# perf timechart --tui (ttimechart.py) test
+# SPDX-License-Identifier: GPL-2.0
+
+set -e
+
+shelldir=$(dirname "$0")
+# shellcheck source=lib/setup_python.sh
+. "${shelldir}"/lib/setup_python.sh
+
+if ! perf check feature -q libtraceevent; then
+ echo "Skipping test, libtraceevent is disabled"
+ exit 2
+fi
+
+if ! "$PYTHON" -c 'import perf' > /dev/null 2>&1; then
+ echo "Skipping test, perf python module not found"
+ exit 2
+fi
+
+if ! "$PYTHON" -c 'import textual' > /dev/null 2>&1; then
+ echo "Skipping test, python textual library not found"
+ exit 2
+fi
+
+err=0
+perfdata=
+output=
+clockdata=
+
+cleanup() {
+ [ -n "${perfdata}" ] && rm -f "${perfdata}"*
+ [ -n "${clockdata}" ] && rm -f "${clockdata}"*
+ rm -f "${output}"
+ trap - EXIT TERM INT
+}
+
+trap_cleanup() {
+ echo "Unexpected signal in ${FUNCNAME[1]}"
+ cleanup
+ exit 1
+}
+trap trap_cleanup EXIT TERM INT
+
+perfdata=$(mktemp /tmp/__perf_ttimechart_test.perf.data.XXXXX)
+output=$(mktemp /tmp/__perf_ttimechart_test.output.XXXXX)
+clockdata=$(mktemp /tmp/__perf_ttimechart_test.clock.data.XXXXX)
+
+workload() {
+ for _ in 1 2 3 4 5; do
+ sleep 0.01
+ done
+}
+
+record() {
+ # perf timechart record is system wide and so may need privileges,
+ # fall back to recording just the workload's scheduler events.
+ if perf timechart record -o "${perfdata}" -- \
+ bash -c "$(declare -f workload); workload" > /dev/null 2>&1; then
+ return 0
+ fi
+ if perf record -R -c 1 -e sched:sched_switch -e sched:sched_wakeup \
+ -o "${perfdata}" -- bash -c "$(declare -f workload); workload" \
+ > /dev/null 2>&1; then
+ return 0
+ fi
+ return 1
+}
+
+test_plumbing() {
+ echo "perf timechart --tui plumbing test"
+ # A missing input file should be reported by the script launched
+ # through perf script, this doesn't need a terminal.
+ if perf timechart --tui -i "${perfdata}.missing" > "${output}" 2>&1; then
+ echo "perf timechart --tui plumbing test [Failed: missing file not an error]"
+ err=1
+ return
+ fi
+ if ! grep -q "not found" "${output}"; then
+ echo "perf timechart --tui plumbing test [Failed: script not launched]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "perf timechart --tui plumbing test [Success]"
+}
+
+test_no_events() {
+ echo "ttimechart no events test"
+ if ! perf record -e task-clock -o "${clockdata}" -- true > /dev/null 2>&1; then
+ echo "ttimechart no events test [Skipped: perf record failed]"
+ return
+ fi
+ if perf script ttimechart -i "${clockdata}" --dump > "${output}" 2>&1; then
+ echo "ttimechart no events test [Failed: expected an error]"
+ err=1
+ return
+ fi
+ if ! grep -q "no scheduler, power or I/O events" "${output}"; then
+ echo "ttimechart no events test [Failed: unexpected error message]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ # The same should be reported when reading the data from a pipe.
+ if perf record -e task-clock -o - -- true 2> /dev/null | \
+ perf script ttimechart -i - --dump > "${output}" 2>&1; then
+ echo "ttimechart no events test [Failed: expected an error from a pipe]"
+ err=1
+ return
+ fi
+ if ! grep -q "no scheduler, power or I/O events" "${output}"; then
+ echo "ttimechart no events test [Failed: unexpected error message from a pipe]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "ttimechart no events test [Success]"
+}
+
+test_dump() {
+ echo "ttimechart dump test"
+ if ! perf script ttimechart -i "${perfdata}" --dump > "${output}" 2>&1; then
+ echo "ttimechart dump test [Failed: script failed]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if ! grep -q "^Duration: .*sched events: [1-9]" "${output}" ||
+ ! grep -q "^CPU [0-9]*: busy" "${output}" ||
+ ! grep -q "^Task .*Running .*Waiting .*Blocked" "${output}" ||
+ ! grep -q "sleep" "${output}"; then
+ echo "ttimechart dump test [Failed: unexpected output]"
+ cat "${output}"
+ err=1
+ return
+ fi
+
+ # Tasks only shouldn't show CPUs, power only shouldn't show tasks.
+ perf script ttimechart -i "${perfdata}" --dump -T > "${output}" 2>&1 || true
+ if grep -q "^CPU [0-9]*: " "${output}" ||
+ ! grep -q "^Task .*Running .*Waiting .*Blocked" "${output}"; then
+ echo "ttimechart dump test [Failed: -T output]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ perf script ttimechart -i "${perfdata}" --dump -P > "${output}" 2>&1 || true
+ if grep -q "^Task .*Running .*Waiting .*Blocked" "${output}" ||
+ ! grep -q "^CPU [0-9]*: " "${output}"; then
+ echo "ttimechart dump test [Failed: -P output]"
+ cat "${output}"
+ err=1
+ return
+ fi
+
+ # Filtering on the workload shouldn't show other tasks.
+ perf script ttimechart -i "${perfdata}" --dump -p sleep > "${output}" 2>&1 || true
+ if ! grep -q "^sleep " "${output}" || grep -q "^bash " "${output}"; then
+ echo "ttimechart dump test [Failed: -p output]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "ttimechart dump test [Success]"
+}
+
+test_headless_ui() {
+ echo "ttimechart headless UI test"
+ # Drive the textual app without a terminal, exercising the views and
+ # key bindings.
+ if ! "$PYTHON" - "${perfdata}" > "${output}" 2>&1 <<'EOF'
+import asyncio
+import sys
+import ttimechart as tc
+
+async def run() -> None:
+ # The app starts before the data is loaded in a background thread.
+ app = tc.TimechartApp(sys.argv[1], False, False, [])
+ async with app.run_test(size=(120, 40)) as pilot:
+ for _ in range(600):
+ if app.loaded:
+ break
+ await pilot.pause(0.1)
+ if not app.loaded:
+ raise RuntimeError("Timed out loading data")
+ await pilot.pause()
+ tabs = app.query_one(tc.TabbedContent)
+ for tab in ["cpus", "tasks", "summary"]:
+ tabs.active = tab
+ await pilot.pause()
+ await pilot.press("down", "plus", "plus", "right", "n", "p", "minus",
+ "shift+right", "shift+left", "s", "w", "0")
+ await pilot.pause()
+ tabs.active = "summary"
+ await pilot.pause()
+ await pilot.press("enter")
+ await pilot.pause()
+ details = str(app.query_one("#details", tc.Static).render())
+ if "Cursor" not in details:
+ raise RuntimeError(f"Unexpected details: {details}")
+ print("headless UI ok")
+
+asyncio.run(run())
+EOF
+ then
+ echo "ttimechart headless UI test [Failed: exception]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if ! grep -q "headless UI ok" "${output}"; then
+ echo "ttimechart headless UI test [Failed: unexpected output]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "ttimechart headless UI test [Success]"
+}
+
+test_plumbing
+test_no_events
+
+if ! record; then
+ echo "Skipping remaining tests, failed to record scheduler events (permissions?)"
+ if [ $err -eq 0 ]; then
+ err=2
+ fi
+ cleanup
+ exit $err
+fi
+
+test_dump
+test_headless_ui
+
+cleanup
+exit $err
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 12/13] perf timechart: Add a --live mode to the TUI
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
` (10 preceding siblings ...)
2026-10-02 18:26 ` [PATCH v1 11/13] perf test: Add a test for perf timechart --tui Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
2026-10-02 18:26 ` [PATCH v1 13/13] perf test: Test perf timechart --live Ian Rogers
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
From: Alice Rogers <alice.mei.rogers@gmail.com>
Add a --live option to perf timechart and ttimechart.py that runs 'perf
record' writing CLOCK_MONOTONIC scheduler and power events, or I/O
syscall events when -I/--io-only is given, to a pipe and displays them
in the TUI as they happen. Only the most recent --window seconds of
history (default 10) are kept in memory, pruning older segments and
tasks that have no remaining history in the window.
Pressing space pauses and resumes recording using perf record's
--control pipe. Because perf record only reads its ring buffers when a
watermark is reached, periodic 'ping' commands are sent on the control
pipe to flush events promptly. The refresh interval adapts to the
measured screen refresh cost and backs off if unprocessed data builds up
in the pipe, with two flushes per refresh to account for ordered-event
buffering across rounds.
An optional workload may be given after '--' to record just that command
rather than the whole system, and '-o' optionally saves a copy of the
recorded perf.data stream.
Assisted-by: Antigravity:gemini-3.1-pro
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/Documentation/perf-timechart.txt | 20 +-
tools/perf/builtin-timechart.c | 80 +-
tools/perf/python/ttimechart.py | 786 ++++++++++++++++++--
3 files changed, 811 insertions(+), 75 deletions(-)
diff --git a/tools/perf/Documentation/perf-timechart.txt b/tools/perf/Documentation/perf-timechart.txt
index f768ee8acf47..8b5c65770479 100644
--- a/tools/perf/Documentation/perf-timechart.txt
+++ b/tools/perf/Documentation/perf-timechart.txt
@@ -47,6 +47,9 @@ TIMECHART OPTIONS
-T::
--tasks-only::
Don't output processor state transitions
+-I::
+--io-only::
+ In '--live' or '--tui' mode, only record or show I/O activity
-p::
--process::
Select the processes to display, by name or PID
@@ -59,8 +62,21 @@ TIMECHART OPTIONS
linkperf:perf-script[1]). The script requires the perf python module
and the python 'textual' library. The CPU, task, I/O and summary views
can be zoomed ('+'/'-'), panned (shift+arrows) and the state of the
- selected row at the cursor is described. The -i, -P, -T and -p options
- are passed to the script, other output options are ignored.
+ selected row at the cursor is described. The -i, -P, -T, -I and -p
+ options are passed to the script, other output options are ignored.
+--live::
+ Record scheduler and power events, or I/O events with '-I', with
+ linkperf:perf-record[1] and display them as they happen in the
+ terminal timechart, implying --tui. Only the most recent '--window'
+ seconds of events are kept and shown. Pressing space pauses and
+ resumes recording; zooming or panning stops the view following the
+ latest events until '0' is pressed. If a command is given after '--'
+ then only that workload is recorded and recording stops when it exits,
+ otherwise the whole system is recorded until the UI is quit. If '-o'
+ is given the recorded perf data is also saved to that file.
+--window=<seconds>::
+ In '--live' mode, the number of seconds of recent events kept in
+ memory and shown in the timeline (default: 10).
--symfs=<directory[,layout]>::
Look for files with symbols relative to this directory. The optional
layout can be 'hierarchy' (default, matches full path) or 'flat'
diff --git a/tools/perf/builtin-timechart.c b/tools/perf/builtin-timechart.c
index 9eb335d9db16..d558ea656fe4 100644
--- a/tools/perf/builtin-timechart.c
+++ b/tools/perf/builtin-timechart.c
@@ -10,7 +10,9 @@
#include <errno.h>
#include <inttypes.h>
+#include <limits.h>
#include <stdlib.h>
+#include <unistd.h>
#include "builtin.h"
#include "util/color.h"
@@ -70,6 +72,9 @@ struct timechart {
topology;
bool force;
bool use_tui;
+ /* Live --tui mode settings */
+ bool live;
+ const char *live_window;
/* IO related settings */
bool io_only,
skip_eagain;
@@ -1761,16 +1766,25 @@ static int __cmd_timechart(struct timechart *tchart, const char *output_name)
/*
* Launch the interactive textual based python script via 'perf script' that
* finds the script, sets up the python environment and passes the global
- * input_name (set by -i) to the script as '-i <input_name>'.
+ * input_name (set by -i) to the script as '-i <input_name>'. In live mode the
+ * script runs 'perf record', so pass this perf's path, the optional data output
+ * file and the workload given in argc/argv.
*/
-static int timechart__tui(struct timechart *tchart)
+static int timechart__tui(struct timechart *tchart, const char *live_output,
+ int argc, const char **argv)
{
struct process_filter *filt;
const char **script_argv;
- int script_argc = 0, nr_args = 5, ret;
+ int script_argc = 0, nr_args = 6, ret, i;
+ char perf_exe[PATH_MAX];
+ ssize_t len;
for (filt = process_filter; filt; filt = filt->next)
nr_args += 2;
+ if (tchart->live_window)
+ nr_args += 2;
+ if (tchart->live)
+ nr_args += 6 + argc;
script_argv = calloc(nr_args + 1, sizeof(*script_argv));
if (!script_argv)
@@ -1783,10 +1797,34 @@ static int timechart__tui(struct timechart *tchart)
script_argv[script_argc++] = "-P";
if (tchart->tasks_only)
script_argv[script_argc++] = "-T";
+ if (tchart->io_only)
+ script_argv[script_argc++] = "-I";
for (filt = process_filter; filt; filt = filt->next) {
script_argv[script_argc++] = "-p";
script_argv[script_argc++] = filt->name;
}
+ if (tchart->live_window) {
+ script_argv[script_argc++] = "--window";
+ script_argv[script_argc++] = tchart->live_window;
+ }
+ if (tchart->live) {
+ script_argv[script_argc++] = "--live";
+ if (live_output) {
+ script_argv[script_argc++] = "-o";
+ script_argv[script_argc++] = live_output;
+ }
+ len = readlink("/proc/self/exe", perf_exe, sizeof(perf_exe) - 1);
+ if (len > 0) {
+ perf_exe[len] = '\0';
+ script_argv[script_argc++] = "--perf";
+ script_argv[script_argc++] = perf_exe;
+ }
+ if (argc) {
+ script_argv[script_argc++] = "--";
+ for (i = 0; i < argc; i++)
+ script_argv[script_argc++] = argv[i];
+ }
+ }
ret = cmd_script(script_argc, script_argv);
free(script_argv);
@@ -2084,11 +2122,13 @@ int cmd_timechart(int argc, const char **argv)
.min_time = NSEC_PER_MSEC,
.merge_dist = 1000,
};
- const char *output_name = "output.svg";
+ const char *default_output_name = "output.svg";
+ const char *output_name = default_output_name;
const char *output_record_data = "perf.data";
const struct option timechart_common_options[] = {
OPT_BOOLEAN('P', "power-only", &tchart.power_only, "output power data only"),
OPT_BOOLEAN('T', "tasks-only", &tchart.tasks_only, "output processes data only"),
+ OPT_BOOLEAN('I', "io-only", &tchart.io_only, "record only IO data"),
OPT_END()
};
const struct option timechart_options[] = {
@@ -2118,6 +2158,10 @@ int cmd_timechart(int argc, const char **argv)
OPT_BOOLEAN('f', "force", &tchart.force, "don't complain, do it"),
OPT_BOOLEAN(0, "tui", &tchart.use_tui,
"interactive terminal timechart using the ttimechart python script"),
+ OPT_BOOLEAN(0, "live", &tchart.live,
+ "record and show events as they happen, implies --tui"),
+ OPT_STRING(0, "window", &tchart.live_window, "seconds",
+ "seconds of recent events kept and shown in live mode, default 10"),
OPT_PARENT(timechart_common_options),
};
const char * const timechart_subcommands[] = { "record", NULL };
@@ -2126,8 +2170,6 @@ int cmd_timechart(int argc, const char **argv)
NULL
};
const struct option timechart_record_options[] = {
- OPT_BOOLEAN('I', "io-only", &tchart.io_only,
- "record only IO data"),
OPT_BOOLEAN('g', "callchain", &tchart.with_backtrace, "record callchain"),
OPT_STRING('o', "output", &output_record_data, "file", "output data file name"),
OPT_PARENT(timechart_common_options),
@@ -2165,8 +2207,13 @@ int cmd_timechart(int argc, const char **argv)
ret = -1;
goto out;
}
+ if (tchart.io_only && (tchart.power_only || tchart.tasks_only)) {
+ pr_err("-I cannot be used with -P or -T.\n");
+ ret = -1;
+ goto out;
+ }
- if (argc && strlen(argv[0]) > 2 && strstarts("record", argv[0])) {
+ if (!tchart.live && argc && strlen(argv[0]) > 2 && strstarts("record", argv[0])) {
argc = parse_options(argc, argv, timechart_record_options,
timechart_record_usage,
PARSE_OPT_STOP_AT_NON_OPTION);
@@ -2176,17 +2223,30 @@ int cmd_timechart(int argc, const char **argv)
ret = -1;
goto out;
}
+ if (tchart.io_only && (tchart.power_only || tchart.tasks_only)) {
+ pr_err("-I cannot be used with -P or -T.\n");
+ ret = -1;
+ goto out;
+ }
if (tchart.io_only)
ret = timechart__io_record(argc, argv, output_record_data);
else
ret = timechart__record(&tchart, argc, argv, output_record_data);
goto out;
- } else if (argc)
+ } else if (argc && !tchart.live)
usage_with_options(timechart_usage, timechart_options);
- if (tchart.use_tui) {
- ret = timechart__tui(&tchart);
+ if (tchart.use_tui || tchart.live) {
+ /* In live mode -o names the file to save the recorded data to. */
+ ret = timechart__tui(&tchart,
+ output_name != default_output_name ? output_name : NULL,
+ argc, argv);
+ goto out;
+ }
+ if (tchart.live_window) {
+ pr_err("--window requires --live.\n");
+ ret = -1;
goto out;
}
diff --git a/tools/perf/python/ttimechart.py b/tools/perf/python/ttimechart.py
index 34ecf84e68c1..61176aca2cf6 100755
--- a/tools/perf/python/ttimechart.py
+++ b/tools/perf/python/ttimechart.py
@@ -2,32 +2,44 @@
# SPDX-License-Identifier: GPL-2.0
"""ttimechart.py - interactive perf timechart written using textual.
-Reads a perf.data file, typically created with 'perf timechart record', and
-displays per-CPU and per-task timelines in the terminal. Scheduler
-(sched:sched_switch, sched:sched_wakeup), power (power:cpu_idle,
-power:cpu_frequency) and I/O syscall (perf timechart record -I) tracepoints
-are understood. Unlike 'perf timechart', which writes an SVG file, the
-timeline can be zoomed, panned and queried interactively.
+Reads a perf.data file, typically created with 'perf timechart record', or
+records events in live mode, and displays per-CPU and per-task timelines in
+the terminal. Scheduler (sched:sched_switch, sched:sched_wakeup), power
+(power:cpu_idle, power:cpu_frequency) and I/O syscall (perf timechart record
+-I) tracepoints are understood. Unlike 'perf timechart', which writes an SVG
+file, the timeline can be zoomed, panned and queried interactively.
Usage:
- perf timechart record -- <workload>
+ perf timechart record [-I] -- <workload>
perf timechart --tui
+ perf timechart --live [-I] [--window <seconds>] [-- <workload>]
or:
perf script ttimechart [-i perf.data]
+ perf script ttimechart -- --live [-I] [--window <seconds>] [-- <workload>]
"""
from __future__ import annotations
from abc import ABC, abstractmethod
import argparse
+import array
import bisect
from collections import defaultdict
from dataclasses import dataclass, replace
+import fcntl
import math
import os
+import queue
+import select
+import shutil
+import signal
+import subprocess
import sys
+import tempfile
+import termios
import threading
-from time import monotonic
-from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
+from time import monotonic, monotonic_ns
+from typing import (Any, Callable, Dict, List, Mapping, NoReturn, Optional, Sequence, Set, Tuple,
+ Union)
import perf
from rich.segment import Segment
@@ -93,6 +105,23 @@ LABEL_WIDTH = 28
BARS = " ▁▂▃▄▅▆▇█"
NSEC_PER_SEC = 1_000_000_000
+# Default seconds of history kept and shown in live mode.
+LIVE_WINDOW = 10.0
+# Bounds on the seconds between refreshes of the live display. Within them
+# the time is adapted to the measured cost of a refresh, which reflects the
+# machine's speed and load, so that refreshing takes about
+# LIVE_REFRESH_FRACTION of the time and the rest is left for processing events.
+LIVE_MIN_REFRESH = 0.1
+LIVE_MAX_REFRESH = 2.0
+LIVE_REFRESH_FRACTION = 0.2
+# If processing events falls behind, refreshes are slowed by up to this factor.
+LIVE_MAX_BACKOFF = 8.0
+# Bounds on the seconds between asking perf record to read its buffers.
+LIVE_MIN_FLUSH = 0.05
+LIVE_MAX_FLUSH = 1.0
+# Size requested for the pipe of recorded data, to absorb bursts of events.
+LIVE_PIPE_SIZE = 1 << 20
+
def fmt_duration(nsecs: float) -> str:
"""Format a duration in nanoseconds with an appropriate unit."""
@@ -142,10 +171,18 @@ class SegmentList:
return len(self.starts)
def add(self, start: int, end: int, key: int, data: Any = None) -> None:
- """Append a segment, segments must be added in time order."""
+ """Append a segment, segments must be added in time order.
+
+ A segment continuing the last one with the same key and data extends
+ it, such as when live mode closes a still open state, see
+ TimechartData.checkpoint().
+ """
if end <= start:
return
- if self.ends and start < self.ends[-1]:
+ if self.ends and start <= self.ends[-1]:
+ if start == self.ends[-1] and key == self.keys[-1] and data == self.data[-1]:
+ self.ends[-1] = end
+ return
# Clip overlaps caused by inconsistent data.
start = self.ends[-1]
if end <= start:
@@ -162,6 +199,21 @@ class SegmentList:
return i
return -1
+ def num_ending_by(self, time: float) -> int:
+ """Number of segments ending at or before time, which are the first ones."""
+ return bisect.bisect_right(self.ends, time)
+
+ def prune(self, time: int) -> None:
+ """Remove segments ending at or before time and clip any straddling time."""
+ num = self.num_ending_by(time)
+ if num:
+ del self.starts[:num]
+ del self.ends[:num]
+ del self.keys[:num]
+ del self.data[:num]
+ if self.starts and self.starts[0] < time:
+ self.starts[0] = time
+
def last_before(self, time: float) -> int:
"""Index of the last segment starting at or before time or -1."""
return bisect.bisect_right(self.starts, time) - 1
@@ -265,6 +317,38 @@ class Task:
return True
return any(f == str(self.tid) or f in self.comms for f in filters)
+ def prune(self, time: int) -> None:
+ """Discard history ending at or before time, removing it from the totals."""
+ segs = self.segs
+ num = segs.num_ending_by(time)
+ for i in range(num):
+ state = segs.keys[i]
+ self.totals[state] -= segs.ends[i] - segs.starts[i]
+ if state == STATE_RUNNING:
+ # Running segments end when the task is switched out.
+ self.switches = max(self.switches - 1, 0)
+ if num < len(segs.starts) and segs.starts[num] < time:
+ self.totals[segs.keys[num]] -= time - segs.starts[num]
+ segs.prune(time)
+ io = self.io
+ for i in range(io.num_ending_by(time)):
+ ret = io.data[i][1]
+ if ret > 0 and io.keys[i] in (IOTYPE_READ, IOTYPE_WRITE, IOTYPE_TX, IOTYPE_RX):
+ self.io_bytes -= ret
+ io.prune(time)
+ if self.io_pending and self.io_pending[0] < time:
+ self.io_pending = (time, self.io_pending[1], self.io_pending[2])
+ del self.wakeups[:bisect.bisect_right(self.wakeups, (time, sys.maxsize))]
+ if self.since <= time:
+ self.since = time
+ if not segs and self.state != STATE_RUNNING:
+ self.state = STATE_UNKNOWN
+
+ def is_empty(self) -> bool:
+ """Is there no history or pending state, so the task can be forgotten?"""
+ return (not self.segs and not self.io and not self.wakeups and
+ self.io_pending is None and self.state in (STATE_SLEEPING, STATE_UNKNOWN))
+
class Cpu:
"""Activity on a single CPU."""
@@ -281,6 +365,17 @@ class Cpu:
self.pstate = SegmentList()
self.pstate_cur: Optional[Tuple[int, int]] = None
+ def prune(self, time: int) -> None:
+ """Discard history ending at or before time."""
+ self.run.prune(time)
+ self.cstate.prune(time)
+ self.pstate.prune(time)
+ self.since = max(self.since, time)
+ if self.cstate_cur and self.cstate_cur[0] < time:
+ self.cstate_cur = (time, self.cstate_cur[1])
+ if self.pstate_cur and self.pstate_cur[0] < time:
+ self.pstate_cur = (time, self.pstate_cur[1])
+
class LoadCancelled(Exception):
"""Raised from the sample callback to stop processing events early."""
@@ -326,21 +421,46 @@ class TimechartData:
self.progress: Optional[Callable[[], None]] = None
self.start_progress = monotonic()
self.last_progress = self.start_progress
+ # History before this time has been discarded, see prune().
+ self.pruned_to = 0
+ # Process and thread IDs whose I/O events should be ignored.
+ self.ignore_pids: Set[int] = set()
def has_events(self) -> bool:
"""Were any events that can be displayed processed?"""
return bool(self.sched_events or self.power_events or self.io_events)
+ def start_time(self) -> int:
+ """Start of the history that is kept."""
+ return max(self.first_time, self.pruned_to)
+
+ def prune(self, time: int) -> None:
+ """Discard history ending at or before time, the lock must be held.
+
+ Live mode uses this to bound the memory used to the displayed window.
+ Tasks left without history are forgotten, if they run again they are
+ recreated.
+ """
+ if time <= self.pruned_to:
+ return
+ self.pruned_to = time
+ for c in self.cpus.values():
+ c.prune(time)
+ for tid, task in list(self.tasks.items()):
+ task.prune(time)
+ if task.is_empty():
+ del self.tasks[tid]
+
def task(self, tid: int, comm: Optional[str] = None) -> Task:
"""Find or create a task."""
task = self.tasks.get(tid)
+ if not comm and self.session and (task is None or task.comm == f"[{tid}]"):
+ try:
+ thread = self.session.find_thread(tid, tid)
+ comm = thread.comm() if thread else None
+ except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError):
+ comm = None
if task is None:
- if not comm and self.session:
- try:
- thread = self.session.find_thread(tid, tid)
- comm = thread.comm() if thread else None
- except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError):
- comm = None
task = Task(tid, comm or f"[{tid}]")
self.tasks[tid] = task
else:
@@ -363,7 +483,7 @@ class TimechartData:
if c.cur_tid == -1 and prev_tid != 0:
# Assume the task was running from the start of the trace.
c.cur_tid = prev_tid
- c.since = self.first_time
+ c.since = self.start_time()
if c.cur_tid > 0:
c.run.add(c.since, time, 1, c.cur_tid)
c.cur_tid = next_tid
@@ -373,7 +493,7 @@ class TimechartData:
prev = self.task(prev_tid, prev_comm)
if prev.state == STATE_UNKNOWN:
prev.state = STATE_RUNNING
- prev.since = self.first_time
+ prev.since = self.start_time()
prev.cpu = cpu
# Ignore bits like TASK_REPORT_MAX used to report preemption.
state = prev_state & 0xff
@@ -429,13 +549,17 @@ class TimechartData:
self.max_freq = max(self.max_freq, freq)
self.min_freq = freq if not self.min_freq else min(self.min_freq, freq)
- def io_enter(self, time: int, tid: int, iotype: int, fd: int) -> None:
+ def io_enter(self, time: int, tid: int, iotype: int, fd: int, pid: int = 0) -> None:
"""Entry to an I/O syscall."""
+ if tid in self.ignore_pids or (pid and pid in self.ignore_pids):
+ return
self.io_events += 1
self.task(tid).io_pending = (time, iotype, fd)
- def io_exit(self, time: int, tid: int, iotype: int, ret: int) -> None:
+ def io_exit(self, time: int, tid: int, iotype: int, ret: int, pid: int = 0) -> None:
"""Exit from an I/O syscall."""
+ if tid in self.ignore_pids or (pid and pid in self.ignore_pids):
+ return
self.io_events += 1
task = self.task(tid)
pending = task.io_pending
@@ -497,11 +621,13 @@ class TimechartData:
if name.startswith("syscalls:sys_enter_") and name[19:] in IO_SYSCALLS:
iotype = IO_SYSCALLS[name[19:]]
return lambda time, sample: self.io_enter(time, sample.sample_tid, iotype,
- getattr(sample, "fd", -1))
+ getattr(sample, "fd", -1),
+ getattr(sample, "sample_pid", 0))
if name.startswith("syscalls:sys_exit_") and name[18:] in IO_SYSCALLS:
iotype = IO_SYSCALLS[name[18:]]
return lambda time, sample: self.io_exit(time, sample.sample_tid, iotype,
- sample.ret)
+ sample.ret,
+ getattr(sample, "sample_pid", 0))
return None
def process_event(self, sample: perf.sample_event) -> None:
@@ -562,6 +688,32 @@ class TimechartData:
except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError):
pass
+ def checkpoint(self) -> None:
+ """Extend open running, idle and frequency states to last_time.
+
+ Live mode calls this before displaying and pruning so that states that
+ haven't changed recently are shown up to the latest event, rather than
+ only once they end. The lock must be held.
+ """
+ end = self.last_time
+ if not end:
+ return
+ for task in self.tasks.values():
+ if task.state == STATE_RUNNING and end > task.since:
+ task.segs.add(task.since, end, STATE_RUNNING, task.cpu)
+ task.totals[STATE_RUNNING] += end - task.since
+ task.since = end
+ for c in self.cpus.values():
+ if c.cur_tid > 0 and end > c.since:
+ c.run.add(c.since, end, 1, c.cur_tid)
+ c.since = end
+ if c.cstate_cur and end > c.cstate_cur[0]:
+ c.cstate.add(c.cstate_cur[0], end, 1, c.cstate_cur[1])
+ c.cstate_cur = (end, c.cstate_cur[1])
+ if c.pstate_cur and end > c.pstate_cur[0]:
+ c.pstate.add(c.pstate_cur[0], end, 1, c.pstate_cur[1])
+ c.pstate_cur = (end, c.pstate_cur[1])
+
def finish(self) -> None:
"""Close open segments at the end of the trace."""
with self.lock:
@@ -604,7 +756,8 @@ class TimechartData:
if len(t.io) and t.passes_filter(filters)),
key=lambda t: -len(t.io))
- def dump(self, power_only: bool, tasks_only: bool, filters: Sequence[str]) -> None:
+ def dump(self, power_only: bool, tasks_only: bool, filters: Sequence[str],
+ io_only: bool = False) -> None:
"""Print a plain text summary, for use without a terminal UI."""
duration = self.last_time - self.first_time
span = max(duration, 1)
@@ -613,7 +766,7 @@ class TimechartData:
print(f"Duration: {fmt_duration(duration)}, CPUs: {len(self.cpus)}, "
f"tasks: {len(tasks) or len(io_tasks)}, sched events: {self.sched_events}, "
f"power events: {self.power_events}, I/O events: {self.io_events}")
- if not tasks_only:
+ if not tasks_only and not io_only:
for c in sorted(self.cpus.values(), key=lambda c: c.cpu):
busy = sum(e - s for s, e in zip(c.run.starts, c.run.ends))
print(f"CPU {c.cpu}: busy {busy * 100 / span:.1f}%, "
@@ -621,14 +774,15 @@ class TimechartData:
f"{len(c.pstate)} frequency periods")
if power_only:
return
- print(f"{'Task':<24} {'TID':>8} {'Running':>12} {'Waiting':>12} {'Blocked':>12} "
- f"{'Switches':>9} {'Wakeups':>8}")
- for task in sorted(tasks, key=lambda t: -t.totals[STATE_RUNNING]):
- print(f"{task.comm[:24]:<24} {task.tid:>8} "
- f"{fmt_duration(task.totals[STATE_RUNNING]):>12} "
- f"{fmt_duration(task.totals[STATE_WAITING]):>12} "
- f"{fmt_duration(task.totals[STATE_BLOCKED]):>12} "
- f"{task.switches:>9} {len(task.wakeups):>8}")
+ if not io_only:
+ print(f"{'Task':<24} {'TID':>8} {'Running':>12} {'Waiting':>12} {'Blocked':>12} "
+ f"{'Switches':>9} {'Wakeups':>8}")
+ for task in sorted(tasks, key=lambda t: -t.totals[STATE_RUNNING]):
+ print(f"{task.comm[:24]:<24} {task.tid:>8} "
+ f"{fmt_duration(task.totals[STATE_RUNNING]):>12} "
+ f"{fmt_duration(task.totals[STATE_WAITING]):>12} "
+ f"{fmt_duration(task.totals[STATE_BLOCKED]):>12} "
+ f"{task.switches:>9} {len(task.wakeups):>8}")
for task in io_tasks:
print(f"I/O {task.name()}: {len(task.io)} syscalls, {task.io_bytes} bytes")
@@ -1128,7 +1282,7 @@ class TimelineView(ScrollView):
"""Change the window shared by all views, owned by the app."""
app = self.app
if isinstance(app, TimechartApp):
- app.window = window
+ app.user_window(window)
def ruler(self, width: int) -> Strip:
"""The time axis, labeled in seconds relative to the trace start."""
@@ -1296,6 +1450,8 @@ class TimechartApp(App):
Binding("s", "sort", "Sort tasks", tooltip="Cycle task sort order"),
Binding("w", "goto_waker", "Go to waker",
tooltip="Select the task that last woke the selected task"),
+ Binding("space", "toggle_pause", "Pause/resume", key_display="space",
+ tooltip="Pause or resume live recording"),
Binding(key="^q", action="quit", description="Quit", tooltip="Quit the app"),
]
@@ -1325,15 +1481,20 @@ class TimechartApp(App):
sort_order: reactive[int] = reactive(0, init=False)
def __init__(self, input_name: str, power_only: bool, tasks_only: bool,
- filters: Sequence[str], data: Optional[TimechartData] = None) -> None:
+ filters: Sequence[str], data: Optional[TimechartData] = None,
+ live: Optional[LiveRecorder] = None, window: float = LIVE_WINDOW,
+ io_only: bool = False) -> None:
"""Create the app, if data isn't given it is loaded from input_name.
- While the data is loading the views show what has been read so far.
+ While the data is loading the views show what has been read so far. In
+ live mode the data is read from the started recorder and only the last
+ window seconds of it are kept.
"""
super().__init__()
self.input_name = input_name
self.power_only = power_only
self.tasks_only = tasks_only
+ self.io_only = io_only
self.filters = filters
# The data being displayed, possibly still being loaded.
self.data = data if data else TimechartData()
@@ -1349,6 +1510,27 @@ class TimechartApp(App):
# Does the summary table need updating before it is next shown?
self.summary_stale = True
self.theme_colors = ThemeColors(self.theme_variables)
+ # Live mode state.
+ self.live = live
+ self.live_window = int(window * NSEC_PER_SEC)
+ # Is perf record still running?
+ self.recording = live is not None
+ # CLOCK_MONOTONIC time when paused, or 0.
+ self.paused_at = 0
+ # Does the view follow the latest events? Zooming or panning stops it.
+ self.following = True
+ self.follow_cursor = True
+ # Seconds between refreshes, adapted to their cost, see tune_refresh().
+ self.refresh_interval = LIVE_MIN_REFRESH
+ self.refresh_cost = 0.0
+ self.backoff = 1.0
+ self.refreshed_samples = -1
+ if live:
+ self.data.first_time = live.origin
+ self.data.ignore_pids.add(os.getpid())
+ proc = getattr(live, "proc", None)
+ if proc:
+ self.data.ignore_pids.add(proc.pid)
def cached_row(self, cls: Callable[[TimechartData, Any], Row], num: int, obj: Any) -> Row:
"""Find or create the row of type cls for the CPU or task obj numbered num."""
@@ -1388,17 +1570,18 @@ class TimechartApp(App):
"""Composes the user interface of the application."""
yield Header()
with TabbedContent():
- if not self.tasks_only:
+ if not self.tasks_only and not self.io_only:
with TabPane("CPUs", id="cpus"):
yield Static(id="cpus_legend", classes="legend")
yield TimelineView(self.data, [],
id="cpus_view").data_bind(TimechartApp.window)
if not self.power_only:
- with TabPane("Tasks", id="tasks"):
- yield Static(id="tasks_legend", classes="legend")
- yield TimelineView(self.data, [],
- id="tasks_view").data_bind(TimechartApp.window)
- # Hidden until there are tasks doing I/O.
+ if not self.io_only:
+ with TabPane("Tasks", id="tasks"):
+ yield Static(id="tasks_legend", classes="legend")
+ yield TimelineView(self.data, [],
+ id="tasks_view").data_bind(TimechartApp.window)
+ # Hidden until there are tasks doing I/O, unless in I/O-only mode.
with TabPane("I/O", id="io"):
yield Static(id="io_legend", classes="legend")
yield TimelineView(self.data, [],
@@ -1418,6 +1601,11 @@ class TimechartApp(App):
view.focus()
if self.loaded:
self.finish_loading()
+ elif self.live:
+ self.update_live_status()
+ self.loading = self.data
+ self.record_events()
+ self.set_timer(self.refresh_interval, self.live_refresh)
else:
self.sub_title = f"Loading {self.input_name}"
self.loading = self.data
@@ -1486,6 +1674,186 @@ class TimechartApp(App):
return
self.call_from_thread(self.data_loaded)
+ @work(thread=True, exclusive=True)
+ def record_events(self) -> None:
+ """Process events from perf record as they arrive, until it exits.
+
+ Unlike loading a file, the views are refreshed by live_refresh() on a
+ timer, as events may arrive slowly.
+ """
+ data = self.loading or self.data
+ live = self.live
+ assert live
+ self.loading = data
+ error = None
+ try:
+ read_events(data, live.data_fd)
+ except LoadCancelled:
+ return
+ except (OSError, ValueError, RuntimeError) as e:
+ error = f"Error processing the recorded events: {e}"
+ finally:
+ self.loading = None
+ live.stop()
+ if data.cancelled:
+ return
+ if error or not data.has_events():
+ details = live.error_text()
+ if not data.has_events() and details:
+ msg = f"Error: perf record failed:\n{details}"
+ else:
+ msg = error or "Error: no scheduler, power or I/O events were recorded."
+ if details:
+ msg = f"{msg}\n{details}"
+ try:
+ self.call_from_thread(self.exit, None, 1, msg)
+ except RuntimeError:
+ pass
+ return
+ try:
+ self.call_from_thread(self.recording_finished)
+ except RuntimeError:
+ # The app is no longer running.
+ pass
+
+ def recording_finished(self) -> None:
+ """Called on the UI thread when perf record exits, such as when the workload ends."""
+ self.data.finish()
+ self.recording = False
+ self.paused_at = 0
+ self.loaded = True
+ self.refresh_bindings()
+ self.update_views()
+ self.update_live_status()
+
+ def live_bounds(self) -> Tuple[int, int]:
+ """The time range kept and shown in live mode, the last window of the recording.
+
+ Until a window of time has been recorded the range is the first window,
+ so the timeline fills from the left, after that it moves with time.
+ """
+ assert self.live
+ if self.paused_at:
+ now = self.paused_at
+ elif self.recording:
+ now = monotonic_ns()
+ else:
+ now = self.data.last_time
+ last = max(now, self.live.origin + self.live_window)
+ return max(self.live.origin, last - self.live_window), last
+
+ def live_refresh(self) -> None:
+ """Show newly recorded events and discard those no longer in the window."""
+ start = monotonic()
+ with self.data.lock:
+ samples = self.data.nr_samples
+ # When paused, once the buffered events are shown there's nothing new.
+ if not self.paused_at or samples != self.refreshed_samples:
+ self.refreshed_samples = samples
+ with self.data.lock:
+ self.data.checkpoint()
+ if self.recording and not self.paused_at:
+ self.data.prune(self.live_bounds()[0])
+ self.prune_rows()
+ self.update_views()
+ self.update_live_status()
+ # Measure the cost of the refresh including drawing the screen.
+ self.call_after_refresh(self.live_refreshed, start)
+
+ def live_refreshed(self, start: float) -> None:
+ """Schedule the next refresh after the screen is drawn."""
+ if not self.recording:
+ return
+ self.tune_refresh(monotonic() - start)
+ self.set_timer(self.refresh_interval, self.live_refresh)
+
+ def tune_refresh(self, cost: float) -> None:
+ """Adapt the time between refreshes, and flushes, to the machine and its load.
+
+ The cost of a refresh grows with the number of events shown, the
+ speed of the machine and how loaded it is, including by processing
+ events in the other thread. The time between refreshes is chosen so
+ that refreshing takes about LIVE_REFRESH_FRACTION of the time. If data
+ builds up in the pipe then processing events is falling behind, so
+ refreshes are slowed further to give it more time.
+ """
+ assert self.live
+ self.refresh_cost = cost if not self.refresh_cost else \
+ 0.7 * self.refresh_cost + 0.3 * cost
+ if self.live.backlog() > 0.25:
+ self.backoff = min(self.backoff * 1.5, LIVE_MAX_BACKOFF)
+ else:
+ self.backoff = max(self.backoff / 1.25, 1.0)
+ interval = self.refresh_cost / LIVE_REFRESH_FRACTION * self.backoff
+ self.refresh_interval = min(max(interval, LIVE_MIN_REFRESH), LIVE_MAX_REFRESH)
+ # Events are only processed once a later flush shows that the
+ # earlier ones have all been read, so flush twice per refresh.
+ self.live.flush_interval = min(max(self.refresh_interval / 2, LIVE_MIN_FLUSH),
+ LIVE_MAX_FLUSH)
+
+ def prune_rows(self) -> None:
+ """Forget the rows of tasks that the data forgot, the data's lock must be held."""
+ tasks = self.data.tasks
+ self.row_cache = {key: row for key, row in self.row_cache.items()
+ if not isinstance(row, (TaskRow, IoRow)) or
+ tasks.get(row.task.tid) is row.task}
+
+ def update_live_status(self) -> None:
+ """Describe the state of live recording in the sub-title."""
+ if self.paused_at:
+ state = "❚❚ Paused (space to resume)"
+ elif not self.recording:
+ state = "Finished"
+ elif self.following:
+ state = "● Live"
+ else:
+ state = "● Live (not following, press 0 to follow)"
+ data = self.data
+ nr_tasks = len(self.tasks) or len(self.io_tasks)
+ self.sub_title = (f"{state}: {data.nr_samples:,} events, {len(data.cpus)} CPUs, "
+ f"{nr_tasks} tasks, refreshing every "
+ f"{self.refresh_interval * 1000:.0f}ms")
+
+ def check_action(self, action: str, parameters: Tuple[object, ...]) -> Optional[bool]:
+ """Only show pausing while live recording."""
+ if action == "toggle_pause":
+ return self.recording
+ return True
+
+ def action_toggle_pause(self) -> None:
+ """Pause or resume live recording, resuming shows the latest events again."""
+ if not self.live or not self.recording:
+ return
+ if self.paused_at:
+ self.paused_at = 0
+ self.following = True
+ self.follow_cursor = True
+ else:
+ self.paused_at = monotonic_ns()
+ self.live.set_paused(bool(self.paused_at))
+ self.update_views()
+ self.update_live_status()
+
+ def user_window(self, window: TimeWindow) -> None:
+ """Change the window for the user.
+
+ In live mode, zooming or panning so that the whole window isn't shown
+ stops the view following the latest events.
+ """
+ if self.live:
+ whole = window.start == window.first and window.end == window.last
+ if window.cursor != self.window.cursor:
+ self.follow_cursor = False
+ elif whole and not self.following:
+ self.follow_cursor = True
+ if self.data.last_time:
+ window = replace(window, cursor=min(max(self.data.last_time, window.first),
+ window.last))
+ if self.following != whole:
+ self.following = whole
+ self.update_live_status()
+ self.window = window
+
def data_loaded(self) -> None:
"""Called on the UI thread when loading completes."""
self.loaded = True
@@ -1512,10 +1880,15 @@ class TimechartApp(App):
f"{len(self.data.cpus)} CPUs, {nr_tasks} tasks")
def cancel_loading(self) -> None:
- """Stop a background load, the worker notices at the next progress interval."""
+ """Stop a background load, the worker notices at the next progress interval.
+
+ In live mode stop perf record, the worker then sees the end of the data.
+ """
loading = self.loading
if loading is not None:
loading.cancelled = True
+ if self.live:
+ self.live.stop()
async def action_quit(self) -> None:
"""Quit, stopping any background load."""
@@ -1569,14 +1942,23 @@ class TimechartApp(App):
view.set_rows(rows)
for static in self.query("#cpus_legend").results(Static):
static.update(legend)
- if self.query("#io") and bool(self.io_tasks) != self.io_tab_shown:
- self.io_tab_shown = bool(self.io_tasks)
+ show_io = bool(self.io_tasks) or self.io_only
+ if self.query("#io") and show_io != self.io_tab_shown:
+ self.io_tab_shown = show_io
tabbed = self.query_one(TabbedContent)
if self.io_tab_shown:
tabbed.show_tab("io")
else:
tabbed.hide_tab("io")
- if first or last:
+ if self.live:
+ live_first, live_last = self.live_bounds()
+ win = self.window.extend(live_first, live_last)
+ if self.following:
+ win = win.reset()
+ if self.follow_cursor and last:
+ win = replace(win, cursor=min(max(last, live_first), live_last))
+ self.window = win
+ elif first or last:
self.window = self.window.extend(first, last)
self.summary_stale = True
tabs = self.query(TabbedContent)
@@ -1651,7 +2033,8 @@ class TimechartApp(App):
with self.data.lock:
text += row.describe(win.cursor, win.start, win.end)
elif view is None:
- text += "Select a task and press enter to show it in the Tasks timeline"
+ tab = "I/O" if self.io_only or (not self.tasks and self.io_tasks) else "Tasks"
+ text += f"Select a task and press enter to show it in the {tab} timeline"
details.first(Static).update(text)
@on(TabbedContent.TabActivated)
@@ -1727,52 +2110,329 @@ class TimechartApp(App):
self.goto_task(waker, wake_time)
+class LiveRecorder:
+ """Runs 'perf record' writing events to a pipe for live display.
+
+ perf record is controlled through its --control file descriptors. It only
+ reads its ring buffers when a watermark is reached, so a periodic 'ping'
+ makes it read them, bounding the delay before events are displayed.
+ 'disable' and 'enable' pause and resume recording. Timestamps use
+ CLOCK_MONOTONIC so that they can be compared with the current time.
+ """
+ def __init__(self, perf_exe: str, power_only: bool, tasks_only: bool,
+ workload: Sequence[str], output: Optional[str],
+ io_only: bool = False) -> None:
+ self.perf_exe = perf_exe
+ self.power_only = power_only
+ self.tasks_only = tasks_only
+ self.io_only = io_only
+ self.workload = list(workload)
+ self.output = output
+ self.proc: Optional[subprocess.Popen] = None
+ # perf record's stderr, and the workload's output, to explain errors.
+ self.stderr = tempfile.TemporaryFile()
+ self.ctl_fd = -1
+ self.ack_fd = -1
+ # Read end of the pipe that the recorded data is processed from.
+ self.data_fd = -1
+ # CLOCK_MONOTONIC time in nanoseconds when recording started.
+ self.origin = 0
+ # Seconds between flushes of perf record's buffers, tuned by the app.
+ self.flush_interval = LIVE_MIN_REFRESH / 2
+ self.paused = False
+ # Commands to send from the control thread, see control_loop().
+ self.commands: queue.Queue[str] = queue.Queue()
+ self.stopping = threading.Event()
+ self.fd_lock = threading.Lock()
+ self.control_thread: Optional[threading.Thread] = None
+ self.tee_thread: Optional[threading.Thread] = None
+
+ def close_control_fds(self) -> None:
+ """Close the control and acknowledgment pipe file descriptors."""
+ with self.fd_lock:
+ ctl_fd, self.ctl_fd = self.ctl_fd, -1
+ ack_fd, self.ack_fd = self.ack_fd, -1
+ for fd in (ctl_fd, ack_fd):
+ if fd >= 0:
+ try:
+ os.close(fd)
+ except OSError:
+ pass
+
+ def events(self) -> List[str]:
+ """Tracepoints to record, those that the kernel lacks are skipped."""
+ if self.io_only:
+ names = [f"syscalls:sys_{direction}_{sc}"
+ for sc in IO_SYSCALLS
+ for direction in ("enter", "exit")]
+ else:
+ names = []
+ if not self.power_only:
+ names += ["sched:sched_switch", "sched:sched_wakeup", "sched:sched_wakeup_new"]
+ if not self.tasks_only:
+ names += ["power:cpu_idle", "power:cpu_frequency"]
+ present = [n for n in names if perf.tracepoint(*n.split(":")) >= 0]
+ # If none are found tracefs may not be readable, let perf record
+ # report the problem.
+ return present or names
+
+ def start(self) -> None:
+ """Start perf record, the data is then read from data_fd."""
+ output = open(self.output, "wb") if self.output else None
+ ctl_r, self.ctl_fd = os.pipe()
+ try:
+ self.ack_fd, ack_w = os.pipe()
+ except OSError:
+ os.close(ctl_r)
+ self.close_control_fds()
+ if output:
+ output.close()
+ raise
+ # Quiet so that only errors are written to stderr, see error_text().
+ cmd = [self.perf_exe, "record", "-q", "-o", "-", "-R", "-c", "1", "-k", "mono",
+ "--synth=task", "--control", f"fd:{ctl_r},{ack_w}"]
+ filt = f"common_pid != {os.getpid()}"
+ for name in self.events():
+ cmd += ["-e", name]
+ if name.startswith("syscalls:"):
+ cmd += ["--filter", filt]
+ # Without a workload record the whole system until stopped.
+ cmd += ["--", *self.workload] if self.workload else ["-a"]
+ self.origin = monotonic_ns()
+ try:
+ # A new session so that signals for the terminal aren't received.
+ self.proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
+ stderr=self.stderr, pass_fds=(ctl_r, ack_w),
+ start_new_session=True)
+ except OSError:
+ self.close_control_fds()
+ if output:
+ output.close()
+ raise
+ finally:
+ os.close(ctl_r)
+ os.close(ack_w)
+ assert self.proc.stdout
+ self.data_fd = self.proc.stdout.fileno()
+ self.grow_pipe(self.data_fd)
+ if output:
+ # Save the data as it is passed on to be processed.
+ tee_r, tee_w = os.pipe()
+ self.tee_thread = threading.Thread(target=self.tee,
+ args=(self.data_fd, tee_w, output),
+ daemon=True)
+ self.tee_thread.start()
+ self.data_fd = tee_r
+ self.grow_pipe(self.data_fd)
+ self.control_thread = threading.Thread(target=self.control_loop, daemon=True)
+ self.control_thread.start()
+
+ @staticmethod
+ def grow_pipe(fd: int) -> None:
+ """Make the pipe bigger, if allowed, to absorb bursts of events."""
+ try:
+ fcntl.fcntl(fd, fcntl.F_SETPIPE_SZ, LIVE_PIPE_SIZE)
+ except OSError:
+ pass
+
+ def tee(self, src: int, dst: int, output: Any) -> None:
+ """Copy the data from src to dst and the output file."""
+ os.set_blocking(dst, False)
+ forwarding = True
+ try:
+ while True:
+ buf = os.read(src, 1 << 16)
+ if not buf:
+ break
+ output.write(buf)
+ view = memoryview(buf)
+ while forwarding and not self.stopping.is_set() and view:
+ try:
+ view = view[os.write(dst, view):]
+ except BlockingIOError:
+ select.select([], [dst], [], 0.1)
+ except OSError:
+ # The data is no longer processed, keep saving it.
+ forwarding = False
+ finally:
+ output.close()
+ os.close(dst)
+
+ def send(self, cmd: str) -> bool:
+ """Send a command to perf record and wait for its acknowledgment."""
+ try:
+ os.write(self.ctl_fd, f"{cmd}\n".encode())
+ # The acknowledgment is "ack\n" followed by a NUL, which may be
+ # read before or with the next acknowledgment.
+ ack = b""
+ while b"\n" not in ack:
+ # perf record may be slow to respond if it is blocked writing
+ # data, so wait until it exits or recording is stopped.
+ if self.stopping.is_set() or (self.proc and self.proc.poll() is not None):
+ return False
+ ready, _, _ = select.select([self.ack_fd], [], [], 0.5)
+ if ready:
+ buf = os.read(self.ack_fd, 16)
+ if not buf:
+ return False
+ ack += buf
+ except OSError:
+ return False
+ return True
+
+ def control_loop(self) -> None:
+ """Send queued commands, otherwise pings that flush perf record's buffers."""
+ try:
+ while not self.stopping.is_set():
+ try:
+ cmd = self.commands.get(timeout=self.flush_interval)
+ except queue.Empty:
+ if self.paused:
+ continue
+ cmd = "ping"
+ if not cmd or self.stopping.is_set() or not self.send(cmd):
+ break
+ finally:
+ self.close_control_fds()
+
+ def set_paused(self, paused: bool) -> None:
+ """Pause or resume recording."""
+ self.paused = paused
+ self.commands.put("disable" if paused else "enable")
+
+ def backlog(self) -> float:
+ """The fraction of the data pipe filled with data waiting to be processed."""
+ try:
+ pending = array.array("i", [0])
+ fcntl.ioctl(self.data_fd, termios.FIONREAD, pending)
+ size = fcntl.fcntl(self.data_fd, fcntl.F_GETPIPE_SZ)
+ except OSError:
+ return 0.0
+ return pending[0] / max(size, 1)
+
+ def stop(self) -> None:
+ """Stop recording, perf record then writes any remaining data and exits."""
+ self.stopping.set()
+ self.commands.put("")
+ proc = self.proc
+ if proc is not None and proc.poll() is None:
+ try:
+ proc.send_signal(signal.SIGINT)
+ proc.wait(timeout=1)
+ except subprocess.TimeoutExpired:
+ # Probably blocked writing data that is no longer processed.
+ proc.kill()
+ proc.wait()
+ if self.tee_thread and self.tee_thread is not threading.current_thread():
+ self.tee_thread.join(timeout=1)
+ if self.control_thread and self.control_thread is not threading.current_thread():
+ self.control_thread.join(timeout=1)
+ self.close_control_fds()
+
+ def error_text(self) -> str:
+ """The last lines written to stderr by perf record, to explain failures.
+
+ Usage text that follows an error in the options is dropped.
+ """
+ self.stderr.seek(0)
+ lines = self.stderr.read().decode(errors="replace").strip().splitlines()
+ for i, line in enumerate(lines):
+ if line.strip().startswith("Usage: perf record"):
+ lines = lines[:i]
+ break
+ return "\n".join(line for line in lines[-10:] if line.strip())
+
+
def main() -> None:
"""Parse arguments, read the perf.data file and run the app."""
parser = argparse.ArgumentParser(
description="Interactive timechart of CPU, task and I/O activity.")
- parser.add_argument("-i", "--input", default="perf.data", help="input perf.data file")
+ parser.add_argument("-i", "--input", help="input perf.data file, default perf.data")
parser.add_argument("-P", "--power-only", action="store_true",
help="only show CPU power information")
parser.add_argument("-T", "--tasks-only", action="store_true",
help="only show task information")
+ parser.add_argument("-I", "--io-only", action="store_true",
+ help="only record or show I/O information")
parser.add_argument("-p", "--process", action="append", default=[],
help="only show processes with the given name or PID, may be repeated")
parser.add_argument("--dump", action="store_true",
help="print a text summary rather than running the interactive UI")
+ parser.add_argument("--live", action="store_true",
+ help="record events with perf record and show them as they happen")
+ parser.add_argument("--window", type=float,
+ help=f"seconds of recent events kept and shown in live mode, "
+ f"default {LIVE_WINDOW:g}")
+ parser.add_argument("-o", "--output",
+ help="in live mode, also save the recorded events to this file")
+ parser.add_argument("--perf", help="perf executable used to record in live mode")
+ parser.add_argument("workload", nargs=argparse.REMAINDER,
+ help="in live mode, a command to record rather than the whole system")
args = parser.parse_args()
+ workload = args.workload[1:] if args.workload[:1] == ["--"] else args.workload
- if args.power_only and args.tasks_only:
- print("Error: -P and -T options cannot be used at the same time.", file=sys.stderr)
+ def error(msg: str) -> NoReturn:
+ print(f"Error: {msg}", file=sys.stderr)
sys.exit(1)
- if args.input == "-":
+ if args.power_only and args.tasks_only:
+ error("-P and -T options cannot be used at the same time.")
+ if args.io_only and (args.power_only or args.tasks_only):
+ error("-I cannot be used with -P or -T.")
+
+ if args.live:
+ if args.input or args.dump:
+ error("--live can't be used with -i or --dump.")
+ window = LIVE_WINDOW if args.window is None else args.window
+ if math.isnan(window) or window <= 0:
+ error("--window must be a positive number of seconds.")
+ perf_exe = args.perf or shutil.which("perf") or "perf"
+ recorder = LiveRecorder(perf_exe, args.power_only, args.tasks_only, workload,
+ args.output, io_only=args.io_only)
+ try:
+ recorder.start()
+ except OSError as e:
+ error(f"failed to start '{perf_exe} record': {e}")
+ app = TimechartApp("live", args.power_only, args.tasks_only, args.process,
+ live=recorder, window=window, io_only=args.io_only)
+ try:
+ app.run()
+ finally:
+ recorder.stop()
+ sys.exit(app.return_code or 0)
+
+ if args.window is not None or args.output or workload:
+ error("--window, -o and a workload require --live.")
+ input_name = args.input or "perf.data"
+ if input_name == "-":
if not args.dump:
# The interactive UI reads the keyboard from stdin.
- print("Error: reading perf.data from stdin requires --dump.", file=sys.stderr)
- sys.exit(1)
- elif not os.path.exists(args.input):
- print(f"Error: {args.input} not found. (try 'perf timechart record' first)",
- file=sys.stderr)
- sys.exit(1)
+ error("reading perf.data from stdin requires --dump.")
+ elif not os.path.exists(input_name):
+ error(f"{input_name} not found. (try 'perf timechart record' first)")
if args.dump:
- data = load_data(args.input)
- data.dump(args.power_only, args.tasks_only, args.process)
+ data = load_data(input_name)
+ data.dump(args.power_only, args.tasks_only, args.process, io_only=args.io_only)
return
# The app starts immediately and loads the data in the background.
- app = TimechartApp(args.input, args.power_only, args.tasks_only, args.process)
+ app = TimechartApp(input_name, args.power_only, args.tasks_only, args.process,
+ io_only=args.io_only)
app.run()
sys.exit(app.return_code or 0)
-def read_events(data: TimechartData, input_name: str) -> None:
- """Process the events in input_name into data, raising on errors."""
+def read_events(data: TimechartData, source: Union[str, int]) -> None:
+ """Process the events in the named file, or pipe file descriptor, into data.
+
+ Raises on errors.
+ """
if data.cancelled:
raise LoadCancelled()
try:
- data.session = perf.session(perf.data(input_name), sample=data.process_event)
+ pdata = perf.data(source) if isinstance(source, str) else perf.data(fd=source)
+ data.session = perf.session(pdata, sample=data.process_event)
data.session.process_events()
data.finish()
finally:
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
* [PATCH v1 13/13] perf test: Test perf timechart --live
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
` (11 preceding siblings ...)
2026-10-02 18:26 ` [PATCH v1 12/13] perf timechart: Add a --live mode to the TUI Ian Rogers
@ 2026-10-02 18:26 ` Ian Rogers
12 siblings, 0 replies; 14+ messages in thread
From: Ian Rogers @ 2026-10-02 18:26 UTC (permalink / raw)
To: Peter Zijlstra, Ingo Molnar, Arnaldo Carvalho de Melo,
Namhyung Kim, Jiri Olsa, Ian Rogers, Adrian Hunter, James Clark,
Thomas Falcon, Alice Rogers, Changbin Du, Tengda Wu, tanze,
Athira Rajeev, Dapeng Mi, linux-kernel, linux-perf-users
From: Alice Rogers <alice.mei.rogers@gmail.com>
Extend test_ttimechart_python.sh to cover --live option validation,
LiveRecorder's perf record subprocess, control pipe and -o output file,
a synthetic live UI test exercising the rolling window, pruning,
pause/resume and zoom following, and a live workload recording test when
scheduler tracepoints are accessible.
Assisted-by: Antigravity:gemini-3.1-pro
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>
---
.../tests/shell/test_ttimechart_python.sh | 300 ++++++++++++++++++
1 file changed, 300 insertions(+)
diff --git a/tools/perf/tests/shell/test_ttimechart_python.sh b/tools/perf/tests/shell/test_ttimechart_python.sh
index 87b91ed8fc16..495060586cfc 100755
--- a/tools/perf/tests/shell/test_ttimechart_python.sh
+++ b/tools/perf/tests/shell/test_ttimechart_python.sh
@@ -82,6 +82,34 @@ test_plumbing() {
err=1
return
fi
+ if perf timechart --live -i "${perfdata}" > "${output}" 2>&1 ||
+ ! grep -q -- "--live can't be used with -i" "${output}"; then
+ echo "perf timechart --tui plumbing test [Failed: --live with -i not rejected]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if perf timechart --live --window 0 < /dev/null > "${output}" 2>&1 ||
+ ! grep -q -- "--window must be a positive" "${output}"; then
+ echo "perf timechart --tui plumbing test [Failed: --window 0 not rejected]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if perf timechart --tui --window 5 < /dev/null > "${output}" 2>&1 ||
+ ! grep -q -- "require --live" "${output}"; then
+ echo "perf timechart --tui plumbing test [Failed: --window without --live]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if perf timechart --live -I -P < /dev/null > "${output}" 2>&1 ||
+ ! grep -q -- "-I cannot be used with -P or -T" "${output}"; then
+ echo "perf timechart --tui plumbing test [Failed: -I with -P not rejected]"
+ cat "${output}"
+ err=1
+ return
+ fi
echo "perf timechart --tui plumbing test [Success]"
}
@@ -115,6 +143,43 @@ test_no_events() {
err=1
return
fi
+ # Exercise LiveRecorder's perf record subprocess, control pipe and
+ # -o output file using task-clock when tracepoints may be unavailable.
+ rm -f "${clockdata}"
+ if ! "$PYTHON" - "${PERF_BIN:-perf}" "${clockdata}" \
+ "$(declare -f workload); workload" > "${output}" 2>&1 <<'EOF'
+import asyncio
+import os
+import sys
+import ttimechart as tc
+
+async def run() -> None:
+ rec = tc.LiveRecorder(sys.argv[1], False, False, ["bash", "-c", sys.argv[3]], sys.argv[2])
+ rec.events = lambda: ["task-clock"]
+ rec.start()
+ app = tc.TimechartApp("live", False, False, [], live=rec, window=5.0)
+ try:
+ async with app.run_test(size=(120, 40)) as pilot:
+ await pilot.press("space")
+ await pilot.pause(0.1)
+ await pilot.press("space")
+ for _ in range(300):
+ if app.return_code is not None:
+ break
+ await pilot.pause(0.05)
+ finally:
+ rec.stop()
+ if app.return_code != 1 or not app.data.nr_samples or not os.path.getsize(sys.argv[2]):
+ raise RuntimeError(f"Unexpected result: rc={app.return_code} samples={app.data.nr_samples}")
+
+asyncio.run(run())
+EOF
+ then
+ echo "ttimechart no events test [Failed: LiveRecorder task-clock test]"
+ cat "${output}"
+ err=1
+ return
+ fi
echo "ttimechart no events test [Success]"
}
@@ -218,8 +283,242 @@ EOF
echo "ttimechart headless UI test [Success]"
}
+test_live_synthetic() {
+ echo "ttimechart live UI test (synthetic)"
+ # Exercise live mode's rolling window, pruning, pause/resume and
+ # following with a fake recorder, which doesn't need tracepoint
+ # permissions.
+ if ! "$PYTHON" - > "${output}" 2>&1 <<'EOF'
+import asyncio
+import threading
+import time
+from time import monotonic_ns
+import ttimechart as tc
+
+SEC = 1_000_000_000
+
+class Ev:
+ def __init__(self, name):
+ self.name = name
+ def __str__(self):
+ return f"evsel({self.name})"
+
+class Sample:
+ def __init__(self, name, t, cpu, tid, **kw):
+ self.evsel = Ev(name)
+ self.sample_time = t
+ self.sample_cpu = cpu
+ self.sample_tid = tid
+ self.sample_pid = tid
+ self.sample_id = hash(name)
+ for k, v in kw.items():
+ setattr(self, k, v)
+
+class FakeRecorder:
+ def __init__(self):
+ self.origin = monotonic_ns()
+ self.data_fd = -1
+ self.flush_interval = 0.05
+ self.paused = False
+ self.pause_calls = []
+ self.stopped = threading.Event()
+ self.finish = False
+
+ def backlog(self):
+ return 0.0
+
+ def set_paused(self, paused):
+ self.paused = paused
+ self.pause_calls.append(paused)
+
+ def stop(self):
+ self.stopped.set()
+
+ def error_text(self):
+ return ""
+
+async def run() -> None:
+ rec = FakeRecorder()
+
+ def fake_read(data, _fd):
+ t0 = rec.origin
+ # Task 300 only runs at the start so pruning should drop it once the
+ # 1s window advances past t0 + 3000.
+ data.process_event(Sample("sched:sched_wakeup", t0 + 1000, 0, 1,
+ pid=300, comm="early", common_pid=1, common_flags=0))
+ data.process_event(Sample("sched:sched_switch", t0 + 2000, 0, 0,
+ prev_pid=0, prev_comm="swapper", prev_state=1,
+ next_pid=300, next_comm="early"))
+ data.process_event(Sample("sched:sched_switch", t0 + 3000, 0, 300,
+ prev_pid=300, prev_comm="early", prev_state=1,
+ next_pid=0, next_comm="swapper"))
+ cur = 0
+ while not rec.stopped.is_set() and not rec.finish:
+ now = monotonic_ns()
+ if rec.paused:
+ time.sleep(0.01)
+ continue
+ nxt = 100 if cur == 0 else 0
+ comm = "worker" if nxt == 100 else "swapper"
+ prev_comm = "worker" if cur == 100 else "swapper"
+ if nxt:
+ data.process_event(Sample("sched:sched_wakeup", now, 0, cur or 1,
+ pid=nxt, comm=comm, common_pid=cur or 1,
+ common_flags=0))
+ data.process_event(Sample("sched:sched_switch", now, 0, cur,
+ prev_pid=cur, prev_comm=prev_comm, prev_state=1,
+ next_pid=nxt, next_comm=comm))
+ cur = nxt
+ time.sleep(0.005)
+
+ tc.read_events = fake_read
+ app = tc.TimechartApp("live", False, False, [], live=rec, window=1.0)
+ async with app.run_test(size=(120, 40)) as pilot:
+ for _ in range(300):
+ with app.data.lock:
+ if app.data.pruned_to > rec.origin + 3000:
+ break
+ await pilot.pause(0.05)
+ with app.data.lock:
+ if app.data.pruned_to <= rec.origin + 3000 or 300 in app.data.tasks:
+ raise RuntimeError("Task outside the window was not pruned")
+ starts = [t.segs.starts[0] for t in app.data.tasks.values() if len(t.segs)]
+ if not starts or min(starts) < app.data.pruned_to:
+ raise RuntimeError(f"Unpruned segments: {starts} < {app.data.pruned_to}")
+ if app.window.last - app.window.first != SEC:
+ raise RuntimeError(f"Unexpected window span: {app.window}")
+
+ await pilot.press("space")
+ await pilot.pause(0.2)
+ if rec.pause_calls != [True] or "Paused" not in app.sub_title:
+ raise RuntimeError(f"Did not pause: {rec.pause_calls} {app.sub_title}")
+ frozen = app.window.last
+ await pilot.pause(0.4)
+ if app.window.last != frozen:
+ raise RuntimeError("Window moved while paused")
+ await pilot.press("space")
+ await pilot.pause(0.3)
+ if rec.pause_calls != [True, False] or not app.following:
+ raise RuntimeError("Did not resume")
+
+ await pilot.press("plus")
+ await pilot.pause(0.2)
+ if app.following:
+ raise RuntimeError("Zooming did not stop following")
+ await pilot.press("0")
+ await pilot.pause(0.2)
+ if not app.following:
+ raise RuntimeError("Resetting zoom did not resume following")
+
+ rec.finish = True
+ for _ in range(100):
+ if not app.recording:
+ break
+ await pilot.pause(0.05)
+ if app.recording or "Finished" not in app.sub_title:
+ raise RuntimeError(f"Did not finish: {app.sub_title}")
+
+ # Also test live I/O-only mode (-I).
+ io_events = tc.LiveRecorder("perf", False, False, [], None, io_only=True).events()
+ if not io_events or not all(e.startswith("syscalls:sys_") for e in io_events):
+ raise RuntimeError(f"Unexpected I/O events: {io_events}")
+ io_rec = FakeRecorder()
+
+ def fake_io_read(data, _fd):
+ now = monotonic_ns()
+ # Self I/O should be ignored in live mode.
+ own = tc.os.getpid()
+ data.process_event(Sample("syscalls:sys_enter_read", now, 0, own, fd=3))
+ data.process_event(Sample("syscalls:sys_exit_read", now + 1000, 0, own, ret=64))
+ # Workload I/O should be recorded.
+ data.process_event(Sample("syscalls:sys_enter_read", now + 2000, 0, 4242, fd=4))
+ data.process_event(Sample("syscalls:sys_exit_read", now + 5000, 0, 4242, ret=4096))
+ data.process_event(Sample("syscalls:sys_enter_write", now + 6000, 0, 4242, fd=5))
+ data.process_event(Sample("syscalls:sys_exit_write", now + 9000, 0, 4242, ret=2048))
+
+ tc.read_events = fake_io_read
+ io_app = tc.TimechartApp("live", False, False, [], live=io_rec, window=1.0, io_only=True)
+ async with io_app.run_test(size=(120, 40)) as pilot:
+ for _ in range(100):
+ if not io_app.recording:
+ break
+ await pilot.pause(0.05)
+ await pilot.pause()
+ tabs = io_app.query_one(tc.TabbedContent)
+ if tabs.active != "io" or not io_app.io_tasks or io_app.data.io_events != 4:
+ raise RuntimeError(f"Unexpected I/O state: active={tabs.active} "
+ f"tasks={len(io_app.io_tasks)} events={io_app.data.io_events}")
+ tabs.active = "summary"
+ await pilot.pause()
+ await pilot.press("enter")
+ await pilot.pause()
+ if tabs.active != "io":
+ raise RuntimeError(f"Summary enter did not activate I/O tab: {tabs.active}")
+ print("live synthetic ok")
+
+asyncio.run(run())
+EOF
+ then
+ echo "ttimechart live UI test (synthetic) [Failed: exception]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if ! grep -q "live synthetic ok" "${output}"; then
+ echo "ttimechart live UI test (synthetic) [Failed: unexpected output]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "ttimechart live UI test (synthetic) [Success]"
+}
+
+test_live_record() {
+ echo "ttimechart live record test"
+ rm -f "${perfdata}.live"
+ if ! "$PYTHON" - "${PERF_BIN:-perf}" "${perfdata}.live" \
+ "$(declare -f workload); workload" > "${output}" 2>&1 <<'EOF'
+import asyncio
+import sys
+import ttimechart as tc
+
+async def run() -> None:
+ rec = tc.LiveRecorder(sys.argv[1], False, False, ["bash", "-c", sys.argv[3]], sys.argv[2])
+ rec.start()
+ app = tc.TimechartApp("live", False, False, [], live=rec, window=5.0)
+ try:
+ async with app.run_test(size=(120, 40)) as pilot:
+ for _ in range(600):
+ if not app.recording:
+ break
+ await pilot.pause(0.1)
+ if app.recording or not app.data.has_events():
+ raise RuntimeError("Live recording of workload did not complete")
+ print("live record ok")
+ finally:
+ rec.stop()
+
+asyncio.run(run())
+EOF
+ then
+ echo "ttimechart live record test [Failed: exception]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ if ! perf script ttimechart -i "${perfdata}.live" --dump > "${output}" 2>&1 ||
+ ! grep -q "^Duration: .*sched events: [1-9]" "${output}"; then
+ echo "ttimechart live record test [Failed: saved perf.data unreadable]"
+ cat "${output}"
+ err=1
+ return
+ fi
+ echo "ttimechart live record test [Success]"
+}
+
test_plumbing
test_no_events
+test_live_synthetic
if ! record; then
echo "Skipping remaining tests, failed to record scheduler events (permissions?)"
@@ -232,6 +531,7 @@ fi
test_dump
test_headless_ui
+test_live_record
cleanup
exit $err
--
2.56.0.rc1.315.gc6ed9934b7-goog
^ permalink raw reply [flat|nested] 14+ messages in thread
end of thread, other threads:[~2026-10-02 18:27 UTC | newest]
Thread overview: 14+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
2026-10-02 18:26 ` [PATCH v1 01/13] perf session: Don't flush remaining events once processing is done Ian Rogers
2026-10-02 18:26 ` [PATCH v1 02/13] perf python: Quietly stop processing events when a callback raises Ian Rogers
2026-10-02 18:26 ` [PATCH v1 03/13] perf python: Lazily copy events and samples from process_events Ian Rogers
2026-10-02 18:26 ` [PATCH v1 04/13] perf python: Lazily resolve sample callchains Ian Rogers
2026-10-02 18:26 ` [PATCH v1 05/13] perf python: Release the GIL while processing session events Ian Rogers
2026-10-02 18:26 ` [PATCH v1 06/13] perf list: Add a --tui option to launch ilist Ian Rogers
2026-10-02 18:26 ` [PATCH v1 07/13] perf test: Add a test for the ilist script Ian Rogers
2026-10-02 18:26 ` [PATCH v1 08/13] perf treport: Show the profile while it loads Ian Rogers
2026-10-02 18:26 ` [PATCH v1 09/13] perf test: Add a test for the treport script Ian Rogers
2026-10-02 18:26 ` [PATCH v1 10/13] perf timechart: Add an interactive --tui mode Ian Rogers
2026-10-02 18:26 ` [PATCH v1 11/13] perf test: Add a test for perf timechart --tui Ian Rogers
2026-10-02 18:26 ` [PATCH v1 12/13] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-02 18:26 ` [PATCH v1 13/13] perf test: Test perf timechart --live Ian Rogers
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
all inboxes | Powered by JetHome®