Linux Perf Users
 help / color / mirror / Atom feed
From: sashiko-bot@kernel.org
To: "Ian Rogers" <irogers@google.com>
Cc: linux-perf-users@vger.kernel.org
Subject: Re: [PATCH v7 1/2] perf timechart: Add a --live mode to the TUI
Date: Fri, 13 Nov 2026 06:29:16 +0000	[thread overview]
Message-ID: <sashiko-outbox-212273@kernel.org> (raw)
In-Reply-To: <20261113055926.1159033-2-irogers@google.com>

Thank you for your contribution! Sashiko AI review found 9 potential
issues to consider:
- [Medium] perf timechart: live_prune() at last_time deletes all open
  states
- [Medium] perf timechart: checkpoint() and prune() drop blocked and
  waiting states
- [Medium] perf timechart: stale pre-exec comm in task() during live I/O
  mode
- [Medium] perf timechart: io_bytes and switches drift in add() and
  prune()
- [Low] perf timechart: Task.totals drift after checkpoint() and
  change_state()
- [Low] perf timechart: update_views() and dump() use sched tasks with
  -I
- [Low] perf timechart: fix switch count underflow in Task.prune() after
  gaps
- [Low] perf timechart: race in LiveRecorder.pump() clearing
  flush_round_target
- [Low] perf timechart: race on data_fd in LiveRecorder.backlog() during
  stop()
--

commit bf97da434173bd983d47c0d1625c5c56ef3fbfc7
Author: Alice Rogers <alice.mei.rogers@gmail.com>

perf timechart: Add a --live mode to the TUI

This commit adds a --live mode to perf timechart and ttimechart.py that
streams events from perf record into the TUI and prunes history outside a
sliding time window.

> diff --git a/tools/perf/python/ttimechart.py b/tools/perf/python/ttimechart.py
> index d7ba0276a9fb4..253116d96c717 100755
> --- a/tools/perf/python/ttimechart.py
> +++ b/tools/perf/python/ttimechart.py
[ ... ]
> @@ -265,6 +319,42 @@ 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)
> +        # A still running task's last segment, extended by checkpoint(), ends
> +        # at since. It hasn't been switched out yet.
> +        open_edge = len(segs) - 1 if self.state == STATE_RUNNING and segs and \
> +            segs.ends[-1] == self.since else -1
> +        for i in range(num):
> +            state = segs.keys[i]
> +            self.totals[state] -= segs.ends[i] - segs.starts[i]
> +            if state == STATE_RUNNING and i != open_edge:
> +                # Running segments end when the task is switched out.
> +                self.switches = max(self.switches - 1, 0)

[Severity: Low]
Does decrementing self.switches for every pruned STATE_RUNNING segment cause
the switch count to drift when running segments are closed without a switch?

TimechartData._close_gap(), TimechartData.finish() and the lost-switch path
in TimechartData.sched_switch() transition a running task to STATE_UNKNOWN
and add a STATE_RUNNING segment without incrementing task.switches.  When
those segments are later pruned, i != open_edge is true and Task.prune()
decrements self.switches anyway.  Conversely, if a switch-in was lost so
prev.state is not STATE_RUNNING in sched_switch(), prev.switches is
incremented without a matching STATE_RUNNING segment to decrement it on
prune.

> +        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)

[Severity: Medium]
Can SegmentList.add() merging or clipping segments cause Task.io_bytes and
syscall counts to drift across Task.prune()?

SegmentList.add() merges a new segment into the previous one when
start == self.ends[-1], key == self.keys[-1] and data == self.data[-1], and
drops segments that clip to end <= start.  In TimechartData.io_exit(),
task.io.add() uses iotype as key and (fd, ret) as data, while task.io_bytes
is incremented by ret on every call.  If two consecutive syscalls on the
same fd have the same type and return value and meet at self.ends[-1] (or
share the same timestamp so the second is clipped out), only one segment is
kept in task.io while task.io_bytes is incremented twice.  len(task.io) then
undercounts syscalls and Task.prune() only subtracts ret once, leaking ret
bytes in task.io_bytes.

> +        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

[Severity: Medium]
Does pruning a task whose state is STATE_BLOCKED or STATE_WAITING drop its
current state and history?

TimechartData.checkpoint() only extends open segments for tasks in
STATE_RUNNING, leaving STATE_BLOCKED and STATE_WAITING tasks unextended.
While a task remains blocked or waiting, that state is not shown in the live
timeline until a later event transitions the task, and once the prune window
reaches self.since, segs becomes empty and Task.prune() resets self.state to
STATE_UNKNOWN, allowing TimechartData.prune() to delete the task altogether.

[ ... ]
> @@ -342,13 +500,13 @@ class TimechartData:
>      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:

[Severity: Medium]
In live I/O-only mode, does this leave a task's pre-exec comm stuck after
execve()?

Syscall tracepoints call self.task(tid) with comm=None.  When a child
process (such as a workload started by perf record with comm "perf-exec")
performs an I/O syscall before execve(), self.session.find_thread() sets
task.comm to the pre-exec name.  After execve() emits PERF_RECORD_COMM,
task.comm is neither None nor "[{tid}]", so self.task() never queries
self.session.find_thread() again, and TimechartData.update_comms() is never
called in live mode because self.progress is None.

[ ... ]
> @@ -562,6 +752,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

[Severity: Low]
Can extending all running tasks to self.last_time cause Task.totals to drift
when a subsequent flush delivers an earlier event on another CPU?

checkpoint() advances task.segs.ends[-1] and task.since to self.last_time
for every running task.  If the next round flushes an event for one of those
tasks with sample_time < self.last_time, Task.change_state() sets
self.since = time (which is now earlier than self.segs.ends[-1]).  On the
following state change, change_state() adds the unclipped time - self.since
to self.totals, whereas self.segs.add() clips the segment start to
self.segs.ends[-1]; when Task.prune() later removes that segment, it
subtracts the clipped length from self.totals, leaking the difference.

[ ... ]
> @@ -604,7 +820,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 +830,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}")

[Severity: Low]
When io_only is true on a perf.data file that also contains scheduler
events, shouldn't dump() and TimechartApp.update_views() ignore
sched_tasks()?

In both dump() and TimechartApp.update_views(), tasks is still populated
from data.sched_tasks(filters) when io_only is set.  If a recorded file
contains scheduler events as well as I/O events, -I reports the scheduler
task count and fills the Summary tab with scheduler tasks instead of
io_tasks, and pressing Enter on a task without I/O fails to jump to the
hidden Tasks tab.

[ ... ]
> @@ -1486,6 +1743,218 @@ class TimechartApp(App):
[ ... ]
> +    def recording_finished(self) -> None:
> +        """Called on the UI thread when perf record exits, such as when the workload ends."""
> +        # Keep showing the window at the end of recording, or when paused,
> +        # which is after the history already discarded.
> +        self.finished_at = self.paused_at or monotonic_ns()
> +        self.data.finish()
> +        self.recording = False
> +        self.paused_at = 0
> +        self.loaded = True
> +        with self.data.lock:
> +            # Discard history from events flushed when perf record exited.
> +            self.live_prune()
> +        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.finished_at
> +        last = max(now, self.live.origin + self.live_window)
> +        return max(self.live.origin, last - self.live_window), last
> +
> +    def live_prune(self) -> None:
> +        """Discard history before the live window, the data's lock must be held.
> +
> +        Open states are only extended up to the last event, see checkpoint(),
> +        so history after it isn't discarded. Otherwise, when events haven't
> +        been seen for a window, a still open state would lose its history.
> +        """
> +        self.data.prune(min(self.live_bounds()[0], self.data.last_time))
> +        self.prune_rows()

[Severity: Medium]
What happens in live_prune() when no new events arrive for --window seconds
so self.live_bounds()[0] reaches self.data.last_time?

TimechartData.checkpoint() and TimechartData.finish() only extend open
segments up to self.last_time, and SegmentList.num_ending_by(time) uses
bisect.bisect_right(self.ends, time), which selects all segments with
end <= time.  Once self.live_bounds()[0] >= self.data.last_time, clamping
the prune time to self.data.last_time passes self.data.last_time to
self.data.prune(), which deletes every segment in the trace (including open
states just extended to self.data.last_time by checkpoint()).  In
recording_finished(), self.data.finish() also sets every task's state to
STATE_UNKNOWN before self.live_prune() runs, so all tasks become empty and
are deleted as well.

[ ... ]
> @@ -1726,52 +2210,642 @@ class TimechartApp(App):
[ ... ]
> +    def pump(self, src: int, dst: int, output: Any) -> None:
[ ... ]
> +                while True:
> +                    try:
> +                        buf = os.read(src, 1 << 16)
> +                    except BlockingIOError:
> +                        break
> +                    except OSError:
> +                        eof = True
> +                        break
> +                    if not buf:
> +                        eof = True
> +                        break
> +                    if output:
> +                        output.write(buf)
> +                    with self.round_lock:
> +                        tracker.feed(buf)
> +                    if forwarding and not self.stopping.is_set():
> +                        # Keep draining src if dst is no longer read.
> +                        forwarding = self.write_pump(dst, buf)
> +                if eof or not forwarding or self.stopping.is_set():
> +                    continue
> +                flush = False
> +                with self.round_lock:
> +                    target = self.flush_round_target
> +                    self.flush_round_target = None
> +                    if (target is not None and tracker.at_boundary() and
> +                            tracker.unflushed_round and
> +                            (target < 0 or tracker.rounds == target)):
> +                        tracker.unflushed_round = False
> +                        flush = True

[Severity: Low]
Can pump() clear self.flush_round_target before reading the data that
control_loop() wanted to flush?

pump thread                       control thread
-----------                       --------------
os.read(src) -> BlockingIOError
                                  send("ping")
                                    perf record writes round + FINISHED_ROUND
                                    perf record writes ack
                                  with self.round_lock:
                                    self.flush_round_target = -1
                                  wake_pump()
with self.round_lock:
  target = self.flush_round_target
  self.flush_round_target = None  <-- cleared before new data in src is read

On the next loop iteration pump() drains the new round from src (setting
tracker.unflushed_round = True), but self.flush_round_target has already
been reset to None, leaving the final round unflushed in ordered_events
while paused.  Snapshotting self.flush_round_target before draining src (and
only clearing it if it wasn't updated in the meantime) would avoid losing
the flush request.

[ ... ]
> +    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)

[Severity: Low]
Can backlog() race with close_data_fds() when recording stops and raise an
uncaught ValueError on the UI thread?

worker thread                     UI thread
-------------                     ---------
record_events()
  live.stop()
    close_data_fds()
      self.data_fd = -1
      os.close(tee_r)
                                  live_refreshed()
                                    tune_refresh()
                                      live.backlog()
                                        fcntl.ioctl(-1, ...)  <-- raises ValueError
  call_from_thread(recording_finished)
    self.recording = False

In Python, passing -1 to fcntl.ioctl() or fcntl.fcntl() raises ValueError
rather than OSError, which is not caught here.  In addition, if
close_data_fds() closes tee_r right after backlog() reads self.data_fd, a
concurrent open() (such as restore_own_comm() opening /proc/self/comm) can
reuse that file descriptor number before ioctl() runs.

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20261113055926.1159033-1-irogers@google.com?part=1

  reply	other threads:[~2026-11-13  6:29 UTC|newest]

Thread overview: 62+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
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-03  1:33   ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 02/13] perf python: Quietly stop processing events when a callback raises Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 03/13] perf python: Lazily copy events and samples from process_events Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 04/13] perf python: Lazily resolve sample callchains Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 05/13] perf python: Release the GIL while processing session events Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 06/13] perf list: Add a --tui option to launch ilist Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 07/13] perf test: Add a test for the ilist script Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 08/13] perf treport: Show the profile while it loads Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-03  9:31   ` Arnaldo Carvalho de Melo
2026-10-02 18:26 ` [PATCH v1 09/13] perf test: Add a test for the treport script Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-03  9:35   ` Arnaldo Carvalho de Melo
2026-10-02 18:26 ` [PATCH v1 10/13] perf timechart: Add an interactive --tui mode Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 11/13] perf test: Add a test for perf timechart --tui Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 12/13] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 13/13] perf test: Test perf timechart --live Ian Rogers
2026-10-03  1:33   ` sashiko-bot
2026-10-03  9:10 ` [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Arnaldo Carvalho de Melo
2026-10-03  9:12   ` Arnaldo Carvalho de Melo
2026-10-03  9:15     ` Arnaldo Carvalho de Melo
2026-10-14  4:22       ` [PATCH v2 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-10-14  4:22         ` [PATCH v2 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-14  4:35           ` sashiko-bot
2026-10-14  4:22         ` [PATCH v2 2/2] perf test: Test perf timechart --live Ian Rogers
2026-10-14  4:28           ` sashiko-bot
2026-10-23  4:18         ` [PATCH v3 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-10-23  4:18           ` [PATCH v3 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-23  4:37             ` sashiko-bot
2026-10-23  4:18           ` [PATCH v3 2/2] perf test: Test perf timechart --live Ian Rogers
2026-10-23  4:23             ` sashiko-bot
2026-10-23  5:02           ` [PATCH v4 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-10-23  5:02             ` [PATCH v4 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-23  5:25               ` sashiko-bot
2026-10-23  5:02             ` [PATCH v4 2/2] perf test: Test perf timechart --live Ian Rogers
2026-10-23  5:07               ` sashiko-bot
2026-10-23  5:49             ` [PATCH v5 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-10-23  5:49               ` [PATCH v5 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-23  6:16                 ` sashiko-bot
2026-10-23  5:49               ` [PATCH v5 2/2] perf test: Test perf timechart --live Ian Rogers
2026-10-23  6:01                 ` sashiko-bot
2026-11-12 23:37               ` [PATCH v6 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-11-12 23:37                 ` [PATCH v6 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-11-13  0:00                   ` sashiko-bot
2026-11-12 23:37                 ` [PATCH v6 2/2] perf test: Test perf timechart --live Ian Rogers
2026-11-12 23:43                   ` sashiko-bot
2026-11-13  5:59                 ` [PATCH v7 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-11-13  5:59                   ` [PATCH v7 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-11-13  6:29                     ` sashiko-bot [this message]
2026-11-13  5:59                   ` [PATCH v7 2/2] perf test: Test perf timechart --live Ian Rogers
2026-11-13  6:06                     ` sashiko-bot

Reply instructions:

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

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

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

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

  git send-email \
    --in-reply-to=sashiko-outbox-212273@kernel.org \
    --to=sashiko-bot@kernel.org \
    --cc=irogers@google.com \
    --cc=linux-perf-users@vger.kernel.org \
    --cc=sashiko-reviews@lists.linux.dev \
    /path/to/YOUR_REPLY

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

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