diff --git a/.flake8 b/.flake8 index d7c3fad..6228bf6 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] select = ANN,B,B9,C,D,E,F,W,I -ignore = ANN101,ANN102,B950,D100,D104,D107,E203,E402,E501,F401,W503,W606 +ignore = ANN101,ANN102,ANN401,B950,D100,D104,D107,E203,E402,E501,F401,W503,W606 max-line-length = 80 docstring-convention = google application-import-names = austin_tui diff --git a/README.md b/README.md index d150dd1..fdfe9b8 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,20 @@ profiling data is still being captured and processed in the background, so that when the view is resumed, the latest figures are shown. +## Graph mode + +A live flame graph visualisation of the current thread statistics can be +displayed by pressing G. This might help with identifying the largest +frames at a glance. + +

+ Austin TUI - Live flame graph +

+ +To toggle back to the top view, simply press G again. + ## Save statistics Peeking at a running Python application is nice but in many cases you would want diff --git a/art/austin-tui-flamegraph.gif b/art/austin-tui-flamegraph.gif new file mode 100644 index 0000000..760cd81 Binary files /dev/null and b/art/austin-tui-flamegraph.gif differ diff --git a/austin_tui/adapters.py b/austin_tui/adapters.py index 94d8f46..2072fed 100644 --- a/austin_tui/adapters.py +++ b/austin_tui/adapters.py @@ -32,6 +32,7 @@ from austin_tui.model.system import Percentage from austin_tui.model.system import SystemModel from austin_tui.view import View +from austin_tui.widgets.graph import FlameGraphData from austin_tui.widgets.markup import AttrString from austin_tui.widgets.table import TableData @@ -379,3 +380,48 @@ def _add_frame_stats( _add_frame_stats(children[-1], "└─ ", " ", 0, thread_stats.children) return frame_stats + + +class FlameGraphAdapter(Adapter): + """Flame graph data adapter.""" + + def transform(self) -> dict: + """Transform according to the right model.""" + austin = self._model.frozen_austin if self._model.frozen else self._model.austin + system = self._model.frozen_system if self._model.frozen else self._model.system + return self._transform(austin, system) # type: ignore[arg-type] + + def _transform( + self, austin: AustinModel, system: Union[SystemModel, FrozenSystemModel] + ) -> dict: + thread_key = austin.threads[austin.current_thread] + pid, _, thread = thread_key.partition(":") + + thread = austin.stats.processes[int(pid)].threads[thread] + + cs = {} # type: ignore[var-annotated] + total = thread.total.value + total_pct = min(int(total / system.duration / 1e4), 100) + data: FlameGraphData = { + f"THREAD {thread.label} ⏲️ {fmt_time(total)} ({total_pct}%)": (total, cs) + } + levels = [(c, cs) for c in thread.children.values()] + while levels: + level, c = levels.pop(0) + k = f"{level.label.function} ({level.label.filename})" + if k in c: + v, cs = c[k] + c[k] = (v + level.total.value, cs) + else: + cs = {} + c[k] = (level.total.value, cs) + levels.extend(((c, cs) for c in level.children.values())) + + return data + + def update(self, data: FlameGraphData) -> bool: + """Update the table.""" + (header,) = data + return self._view.flamegraph.set_data(data) | self._view.graph_header.set_text( + " FLAME GRAPH FOR " + header + ) diff --git a/austin_tui/controller.py b/austin_tui/controller.py index 42e0e9e..f20f7fd 100644 --- a/austin_tui/controller.py +++ b/austin_tui/controller.py @@ -33,6 +33,7 @@ from austin_tui.adapters import CpuAdapter from austin_tui.adapters import CurrentThreadAdapter from austin_tui.adapters import DurationAdapter +from austin_tui.adapters import FlameGraphAdapter from austin_tui.adapters import MemoryAdapter from austin_tui.adapters import ThreadDataAdapter from austin_tui.adapters import ThreadFullDataAdapter @@ -65,9 +66,11 @@ class AustinTUIController: thread_data = ThreadDataAdapter thread_full_data = ThreadFullDataAdapter command_line = CommandLineAdapter + flamegraph = FlameGraphAdapter def __init__(self) -> None: self._full_mode = False + self._graph = False self._scaler = None self._formatter = None self._last_timestamp = 0 @@ -93,10 +96,13 @@ def set_thread_data(self) -> None: if not self.model.austin.threads: return - if self._full_mode: - self.thread_full_data() # type: ignore[call-arg] + if self._graph: + self.flamegraph() # type: ignore[call-arg] else: - self.thread_data() # type: ignore[call-arg] + if self._full_mode: + self.thread_full_data() # type: ignore[call-arg] + else: + self.thread_data() # type: ignore[call-arg] self._last_timestamp = self.model.austin.stats.timestamp @@ -113,8 +119,24 @@ def set_thread(self) -> bool: return True + def _add_flamegraph_palette(self) -> None: + colors = [196, 202, 214, 124, 160, 166, 208] + palette = self.view.palette + + for i, color in enumerate(colors): + palette.add_color(f"fg{i}", 15, color) + palette.add_color(f"fgf{i}", color) + + self.view.flamegraph.set_palette( + ( + [palette.get_color(f"fg{i}") for i in range(len(colors))], + [palette.get_color(f"fgf{i}") for i in range(len(colors))], + ) + ) + def start(self) -> None: """Start event.""" + self._add_flamegraph_palette() self.view.open() self.view.submit_task(self.update_loop()) @@ -153,7 +175,10 @@ async def update_loop(self) -> None: """The UI update loop.""" while not self.view._stopped and self.view.is_open and self.view.root_widget: if self.update(): - self.view.table.draw() + if self._graph: + self.view.flamegraph.draw() + else: + self.view.table.draw() self.view.root_widget.refresh() @@ -180,21 +205,32 @@ def _change_thread(self, direction: ThreadNav) -> bool: async def on_next_thread(self) -> bool: """Handle next thread event.""" if self._change_thread(ThreadNav.NEXT): - self.view.table.draw() - self.view.stats_view.refresh() + if self._graph: + self.view.flamegraph.draw() + self.view.flame_view.refresh() + else: + self.view.table.draw() + self.view.stats_view.refresh() return True return False async def on_previous_thread(self) -> bool: """Handle previous thread event.""" if self._change_thread(ThreadNav.PREV): - self.view.table.draw() - self.view.stats_view.refresh() + if self._graph: + self.view.flamegraph.draw() + self.view.flame_view.refresh() + else: + self.view.table.draw() + self.view.stats_view.refresh() return True return False async def on_full_mode_toggled(self, _: Any = None) -> bool: """Toggle full mode.""" + if self._graph: + return False + self._full_mode = not self._full_mode self.set_thread_data() @@ -269,3 +305,16 @@ async def on_threshold_down(self, _: Any = None) -> bool: th = self._change_threshold(-0.01) * 100.0 self.view.threshold.set_text(f"{th:.0f}%") return True + + async def on_graph_toggled(self, _: Any = None) -> bool: + """Toggle graph visualisation.""" + self._graph = not self._graph + + self.view.dataview_selector.select(self._graph) + + if self._graph: + self.flamegraph() # type: ignore[call-arg] + else: + self.set_thread_data() + + return True diff --git a/austin_tui/view/austin.py b/austin_tui/view/austin.py index 6d60efc..8f244d8 100644 --- a/austin_tui/view/austin.py +++ b/austin_tui/view/austin.py @@ -72,9 +72,23 @@ async def on_quit(self) -> bool: async def on_full_mode_toggled(self) -> bool: """Handle Full Mode toggle.""" + if self.graph_cmd.state: + return False + self.full_mode_cmd.toggle() return True + async def on_graph_toggled(self) -> bool: + """Handle graph visualisation toggling.""" + self.graph_cmd.toggle() + self.dataview_selector.refresh() + if self.graph_cmd.state: + self.full_mode_cmd.set_color("disabled") + else: + self.full_mode_cmd.toggle() + self.full_mode_cmd.toggle() + return True + async def on_save(self, data: Any = None) -> bool: """Handle Save event.""" self.notification.set_text("Saving collected statistics ...") @@ -82,38 +96,52 @@ async def on_save(self, data: Any = None) -> bool: async def on_table_up(self, data: Any = None) -> bool: """Handle Up Arrow on the table widget.""" - self.stats_view.scroll_up() - self.stats_view.refresh() + view = self.flame_view if self.graph_cmd.state else self.stats_view + + view.scroll_up() + view.refresh() return False async def on_table_down(self, data: Any = None) -> bool: """Handle Down Arrow on the table widget.""" - self.stats_view.scroll_down() - self.stats_view.refresh() + view = self.flame_view if self.graph_cmd.state else self.stats_view + + view.scroll_down() + view.refresh() return False async def on_table_pgup(self, data: Any = None) -> bool: """Handle Page Up on the table widget.""" - self.stats_view.scroll_page_up() - self.stats_view.refresh() + view = self.flame_view if self.graph_cmd.state else self.stats_view + + view.scroll_page_up() + view.refresh() return False async def on_table_pgdown(self, data: Any = None) -> bool: """Handle Page Down on the table widget.""" - self.stats_view.scroll_page_down() - self.stats_view.refresh() + view = self.flame_view if self.graph_cmd.state else self.stats_view + + view.scroll_page_down() + view.refresh() return False async def on_table_home(self, _: Any = None) -> bool: """Handle Home key on the table widget.""" - self.stats_view.top() - self.stats_view.refresh() + view = self.flame_view if self.graph_cmd.state else self.stats_view + + view.top() + view.refresh() + return False async def on_table_end(self, _: Any = None) -> bool: """Handle End key on the table widget.""" - self.stats_view.bottom() - self.stats_view.refresh() + view = self.flame_view if self.graph_cmd.state else self.stats_view + + view.bottom() + view.refresh() + return False async def on_play_pause(self, _: Any = None) -> bool: diff --git a/austin_tui/view/tui.austinui b/austin_tui/view/tui.austinui index d152efe..5cf7b2b 100644 --- a/austin_tui/view/tui.austinui +++ b/austin_tui/view/tui.austinui @@ -119,40 +119,54 @@ along with this program. If not, see . --> - - - - - - - + + + + + + + + + - - - + + + + + + + + + + + + . --> width="3" color="cmd_off" /> . --> width="3" color="cmd_off" /> . --> width="6" align="center" /> + + + . --> + diff --git a/austin_tui/widgets/catalog.py b/austin_tui/widgets/catalog.py index 6c2d9fe..8aa399a 100644 --- a/austin_tui/widgets/catalog.py +++ b/austin_tui/widgets/catalog.py @@ -22,6 +22,7 @@ from austin_tui.widgets.box import Box from austin_tui.widgets.command_bar import CommandBar +from austin_tui.widgets.graph import FlameGraph from austin_tui.widgets.label import BarPlot from austin_tui.widgets.label import Label from austin_tui.widgets.label import Line diff --git a/austin_tui/widgets/graph.py b/austin_tui/widgets/graph.py new file mode 100644 index 0000000..d927651 --- /dev/null +++ b/austin_tui/widgets/graph.py @@ -0,0 +1,141 @@ +# This file is part of "austin-tui" which is released under GPL. +# +# See file LICENCE or go to http://www.gnu.org/licenses/ for full license +# details. +# +# austin-tui is top-like TUI for Austin. +# +# Copyright (c) 2018-2022 Gabriele N. Tornetta . +# All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import curses +from random import randrange +from typing import Dict, List, Optional, Tuple + +from austin_tui.widgets import Rect +from austin_tui.widgets import Widget + +FlameGraphData = Dict[str, Tuple[float, "FlameGraphData"]] # type: ignore[misc] + + +class FlameGraph(Widget): + """Flame graph widget.""" + + def __init__(self, name: str) -> None: + super().__init__(name) + + self._data: Optional[dict] = None + self._height = 40 + self._palette: Optional[Tuple[List[int], List[int]]] = None + + def set_palette(self, palette: Tuple[List[int], List[int]]) -> None: + """Set the flame graph palette.""" + self._palette = palette + + def resize(self, rect: Rect) -> bool: + """Resize the table.""" + if self.rect == rect: + return False + + self.rect = rect + + self.draw() + + return True + + def set_data(self, data: dict) -> bool: + """Set the graph data.""" + + def h(s: dict, scale: float) -> int: + if not s: + return 1 + + return 1 + max( + ( + h(c, scale) + for c in (_[1] for _ in s.values() if _[0] * scale * 8 >= 1) + ), + default=0, + ) + + if data != self._data: + self._data = data + + w = self.size.x + for _, (v, _) in self._data.items(): + scale = w / v + + self._height = h(data, scale) + self.parent.resize(self.parent.rect) + return True + + return False + + def _draw_frame(self, x: int, y: int, w: float, text: str) -> None: + win = self.win.get_win() + + iw = int(w) + fw = int((w - iw) * 8) + + assert self._palette is not None, self._palette + fg, fgf = self._palette + + i = randrange(0, len(fg)) + + color = curses.color_pair(fg[i]) + fcolor = curses.color_pair(fgf[i]) + + try: + win.addstr(y, x, " " * iw, color) + except curses.error: + pass + if fw: + c = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][fw] + try: + win.addstr(y, x + iw, c, fcolor) + except curses.error: + pass + if iw > 4: + if len(text) > iw - 2: + _text = text[: iw - 4] + ".." + else: + _text = text + win.addstr(y, x, " " + _text, color) + + def draw(self) -> bool: + """Draw the graph.""" + super().draw() + + if not self.win or not self._data: + return False + + self.win.get_win().clear() + + w = self.size.x + for _, (v, _) in self._data.items(): + scale = w / v + + levels = [(0, -1, (k, v)) for k, v in self._data.items()] + while levels: + x, y, (f, (v, cs)) = levels.pop(0) + w = v * scale + if y >= 0: + self._draw_frame(x, y, w, f) + i = 0 + for k, c in cs.items(): + levels.append((x + i, y + 1, (k, c))) + i += int(c[0] * scale + 0.5) + + return True diff --git a/austin_tui/widgets/scroll.py b/austin_tui/widgets/scroll.py index 2ff0e0b..a646d39 100644 --- a/austin_tui/widgets/scroll.py +++ b/austin_tui/widgets/scroll.py @@ -89,7 +89,6 @@ def hide(self) -> None: self.refresh() del self._win self._win = None - self.w = self.h = 1 def get_inner_size(self) -> Point: """Get the scroll view inner size. diff --git a/austin_tui/widgets/table.py b/austin_tui/widgets/table.py index 7107a02..eb3dd42 100644 --- a/austin_tui/widgets/table.py +++ b/austin_tui/widgets/table.py @@ -48,13 +48,6 @@ def _show_empty(self) -> bool: win.clear() - w, h = self.parent.rect.size.to_tuple - empty = "< Empty >" - if h < 1 or w < len(empty): - return True - - win.addstr(h >> 1, (w >> 1) - (len(empty) >> 1), empty, 0) - return True def _draw_row(self, i: int, row: List[Any]) -> None: @@ -73,7 +66,7 @@ def _draw_row(self, i: int, row: List[Any]) -> None: delta = min(available - x, len(text)) x += delta - def set_data(self, data: TableData) -> None: + def set_data(self, data: TableData) -> bool: """Set the table data. The format is a list of rows, with each row representing the content of @@ -83,6 +76,9 @@ def set_data(self, data: TableData) -> None: self._data = data self._height = len(data) self.parent.resize(self.parent.rect) + return True + + return False def resize(self, rect: Rect) -> bool: """Resize the table.""" diff --git a/noxfile.py b/noxfile.py index d66a16a..406c8b2 100644 --- a/noxfile.py +++ b/noxfile.py @@ -2,7 +2,7 @@ import nox -nox.options.sessions = ["lint", "mypy"] +nox.options.sessions = ["lint", "mypy", "tests"] # ---- Configuration ---- diff --git a/tests/test_view.py b/tests/test_view.py index 8d3c19d..4b05567 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -19,3 +19,9 @@ def test_austin_view(): assert view.info_box.rect == Rect(0, 80 + 4j) assert view.dataview_selector.rect == Rect(4j, 80 + 27j) + + view.dataview_selector.select(1) + root.resize(Rect(0, root.get_size())) + + assert view.dataview_selector.rect == Rect(4j, 80 + 27j) + assert view.flame_view.rect == Rect(5j, 80 + 26j)