Linux Media Controller development
 help / color / mirror / Atom feed
* [PATCH RFC 0/3] utils: Add the v4l2top tool
@ 2026-09-16 15:20 Detlev Casanova
  2026-09-16 15:20 ` [PATCH RFC v4l-utils 1/3] v4l-util: Add the v4l2top utility Detlev Casanova
                   ` (3 more replies)
  0 siblings, 4 replies; 6+ messages in thread
From: Detlev Casanova @ 2026-09-16 15:20 UTC (permalink / raw)
  To: linux-media, Detlev Casanova

Hello !

As we are currently adding support for fdinfo[1] and memory tracking[2]
in v4l2 drivers, I propose the v4l2top utility to show
media devices time and memory usage in a nice TUI, much like htop.

The tool is written in Rust and only built when cargo is detected,
using a wrapper in the meson build system.

Currently, the code has been copied in the tree from my git repository[3],
but if needed, we can use a submodule or recreate the git history.

As [1] and [2] have not been accepted upstream yet, this should not be
merged yet and be regarded as an RFC.

Regards,
Detlev

[1]: https://lore.kernel.org/all/20260706-v4l2-add-fdinfo-v3-0-d556568cf38e@collabora.com/
[2]: https://lore.kernel.org/all/20260916-v4l2-add-mem-tracker-v1-0-900fa45e3e6a@collabora.com/
[3]: https://github.com/cazou/v4l2top # upstream branch

Signed-off-by: Detlev Casanova <detlev.casanova@collabora.com>
---
Detlev Casanova (3):
      v4l-util: Add the v4l2top utility
      meson: Add meson build file for v4l2top
      README: Add v4l2top util

 README.md                         |   7 +
 meson.build                       |   1 +
 meson_options.txt                 |   2 +
 utils/meson.build                 |   2 +
 utils/v4l2top/.gitignore          |   2 +
 utils/v4l2top/Cargo.toml          |  13 +
 utils/v4l2top/README.md           |  43 +++
 utils/v4l2top/cargo-wrapper.sh    |  22 ++
 utils/v4l2top/meson.build         |  43 +++
 utils/v4l2top/src/main.rs         |  51 ++++
 utils/v4l2top/src/renderers.rs    | 593 ++++++++++++++++++++++++++++++++++++++
 utils/v4l2top/src/v4l2_mem.rs     |  56 ++++
 utils/v4l2top/src/v4l2_metrics.rs | 148 ++++++++++
 13 files changed, 983 insertions(+)
---
base-commit: fa05ca502293f65e25930e6321c82eeaccf382ab
change-id: 20260910-add-v4l2top-671130a4d43e

Best regards,
--  
Detlev Casanova <detlev.casanova@collabora.com>


^ permalink raw reply	[flat|nested] 6+ messages in thread

* [PATCH RFC v4l-utils 1/3] v4l-util: Add the v4l2top utility
  2026-09-16 15:20 [PATCH RFC 0/3] utils: Add the v4l2top tool Detlev Casanova
@ 2026-09-16 15:20 ` Detlev Casanova
  2026-09-16 15:20 ` [PATCH RFC v4l-utils 2/3] meson: Add meson build file for v4l2top Detlev Casanova
                   ` (2 subsequent siblings)
  3 siblings, 0 replies; 6+ messages in thread
From: Detlev Casanova @ 2026-09-16 15:20 UTC (permalink / raw)
  To: linux-media, Detlev Casanova

v4l2top is a top-like tool used to show v4l2 mem2mem device usage
statistics.
In this version, it uses the v4l2 fdinfo file to retrieve usage time
information, as well as the driver name to identify the hardware.

It is written in Rust, but will still be built with meson, using a cargo
wrapper script.

Signed-off-by: Detlev Casanova <detlev.casanova@collabora.com>
---
 utils/meson.build                 |   2 +
 utils/v4l2top/.gitignore          |   2 +
 utils/v4l2top/Cargo.toml          |  13 +
 utils/v4l2top/README.md           |  43 +++
 utils/v4l2top/src/main.rs         |  51 ++++
 utils/v4l2top/src/renderers.rs    | 593 ++++++++++++++++++++++++++++++++++++++
 utils/v4l2top/src/v4l2_mem.rs     |  56 ++++
 utils/v4l2top/src/v4l2_metrics.rs | 148 ++++++++++
 8 files changed, 908 insertions(+)

diff --git a/utils/meson.build b/utils/meson.build
index 77c2013e..7e60ef3e 100644
--- a/utils/meson.build
+++ b/utils/meson.build
@@ -38,6 +38,8 @@ subdir('v4l2-ctl')
 subdir('v4l2-dbg')
 subdir('v4l2-sysfs-path')
 subdir('v4l2-tracer')
+# FIXME: Should it be renamed to v4l2-top ? Follows the utils naming but not the other top-like utils...
+subdir('v4l2top')
 
 # Compliance tools
 subdir('cec-compliance')
diff --git a/utils/v4l2top/.gitignore b/utils/v4l2top/.gitignore
new file mode 100644
index 00000000..96ef6c0b
--- /dev/null
+++ b/utils/v4l2top/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/utils/v4l2top/Cargo.toml b/utils/v4l2top/Cargo.toml
new file mode 100644
index 00000000..f19518ca
--- /dev/null
+++ b/utils/v4l2top/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "v4l2top"
+version = "0.1.0"
+edition = "2024"
+license = "MIT or GPL-2.0"
+
+[dependencies]
+ratatui = "0.30.0"
+anyhow = "1.0.68"
+crossterm = "0.29.0"
+chrono = "0.4.44"
+regex = "1.12.3"
+cli-log = "2.1.0"
diff --git a/utils/v4l2top/README.md b/utils/v4l2top/README.md
new file mode 100644
index 00000000..c943f168
--- /dev/null
+++ b/utils/v4l2top/README.md
@@ -0,0 +1,43 @@
+# v4l2top
+
+An `htop`-like utility to show v4l2 mem2mem hardware and memory usage.
+
+## Dependency
+
+Linux needs to be patched to be able to retrieve data from the driver.
+A work in progress branch can be found [here](https://gitlab.collabora.com/detlev/linux/-/tree/rkvdec/list-hantro-buffers).
+It only supports the rkvdec driver, and only h264 on RK3588 for buffer details.
+
+## Installation
+
+The crate is not published yet, use the git url to install locally with cargo:
+```bash
+cargo install https://github.com/cazou/v4l2top.git
+```
+
+### Cross compiling
+
+To build an arm64 binary, install an arm64 Rust toolchain with rustup:
+```bash
+rustup target add aarch64-unknown-linux-gnu
+```
+
+And that you have an arm64 linker.
+Debian:
+```bash
+apt install gcc-aarch64-linux-gnu
+```
+Arch Linux:
+```bash
+pacman -S aarch64-linux-gnu-gcc
+```
+
+Other linkers can be used, but adapt the `.cargo/config.toml` file.
+
+Then you can cross compile for arm64 with:
+```bash
+cargo build --target aarch64-unknown-linux-gnu
+```
+
+Add `--release` for a smaller binary.
+You can retrieve it in `target/aarch64-unknown-linux-gnu/[release|debug]/v4l2top`
diff --git a/utils/v4l2top/src/main.rs b/utils/v4l2top/src/main.rs
new file mode 100644
index 00000000..30e21176
--- /dev/null
+++ b/utils/v4l2top/src/main.rs
@@ -0,0 +1,51 @@
+mod renderers;
+mod v4l2_mem;
+mod v4l2_metrics;
+
+use std::time::Duration;
+
+use anyhow::Result;
+use crossterm::event::KeyModifiers;
+use ratatui::DefaultTerminal;
+
+#[warn(unused_imports)]
+use cli_log::*;
+
+use renderers::TopRenderer;
+
+fn main() -> Result<()> {
+    cli_log::init_cli_log!();
+    ratatui::run(app)?;
+    Ok(())
+}
+
+fn app(terminal: &mut DefaultTerminal) -> Result<()> {
+    let mut renderer = TopRenderer::new();
+
+    terminal.draw(|frame| renderer.render(frame))?;
+
+    loop {
+        if crossterm::event::poll(Duration::from_millis(100))?
+            && let Some(event) = crossterm::event::read()?.as_key_press_event()
+        {
+            match event.code {
+                crossterm::event::KeyCode::Char('q') => break Ok(()),
+                crossterm::event::KeyCode::Char('c') => {
+                    if event.modifiers.contains(KeyModifiers::CONTROL) {
+                        break Ok(());
+                    }
+                }
+                crossterm::event::KeyCode::F(2) => {
+                    renderer.show_bytes_flip();
+                }
+                crossterm::event::KeyCode::F(3) => {
+                    renderer.full_cmd_flip();
+                }
+                crossterm::event::KeyCode::Up => renderer.select_previous(),
+                crossterm::event::KeyCode::Down => renderer.select_next(),
+                _ => {}
+            }
+        }
+        terminal.draw(|frame| renderer.render(frame))?;
+    }
+}
diff --git a/utils/v4l2top/src/renderers.rs b/utils/v4l2top/src/renderers.rs
new file mode 100644
index 00000000..d70813d5
--- /dev/null
+++ b/utils/v4l2top/src/renderers.rs
@@ -0,0 +1,593 @@
+use std::{
+    collections::{BTreeMap, HashMap, VecDeque},
+    time::Instant,
+};
+
+use anyhow::{Result, bail};
+use ratatui::{
+    Frame,
+    layout::{Constraint, Direction, Layout},
+    style::{Color, Style, Stylize},
+    text::Line,
+    widgets::{
+        Block, Cell, LineGauge, Row, Table,
+        TableState,
+    },
+};
+
+#[warn(unused_imports)]
+use cli_log::*;
+
+use crate::v4l2_mem::{DMABuffer, v4l2_mem_get_usage};
+use crate::v4l2_metrics::{V4L2Stream, V4l2FdInfo, find_all_v4l2_fdinfo};
+
+const NUM_BARS: usize = 12;
+const BAR_COLS: usize = 2;
+const BAR_ROWS: usize = NUM_BARS / BAR_COLS;
+
+/// Compact byte formatting
+pub fn format_bytes(bytes: u64, show_bytes: bool) -> String {
+    if show_bytes {
+        return format!("{}B", bytes);
+    }
+
+    const KB: f64 = 1024.0;
+    const MB: f64 = KB * 1024.0;
+    const GB: f64 = MB * 1024.0;
+
+    let b = bytes as f64;
+    if b >= GB {
+        format!("{:.1}G", b / GB)
+    } else if b >= MB {
+        format!("{:.1}M", b / MB)
+    } else if b >= KB {
+        format!("{:.0}K", b / KB)
+    } else {
+        format!("{b}B")
+    }
+}
+
+/// Compact frequency formatting
+pub fn format_freq(freq: u64, show_hertz: bool) -> String {
+    if show_hertz {
+        return format!("{}Hz", freq);
+    }
+
+    const KHZ: f64 = 1024.0;
+    const MHZ: f64 = KHZ * 1024.0;
+    const GHZ: f64 = MHZ * 1024.0;
+
+    let f = freq as f64;
+    if f >= GHZ {
+        format!("{:.1}GHz", f / GHZ)
+    } else if f >= MHZ {
+        format!("{:.1}MHz", f / MHZ)
+    } else if f >= KHZ {
+        format!("{:.0}KHz", f / KHZ)
+    } else {
+        format!("{f}Hz")
+    }
+}
+
+/// Follows the hw usage of a given FD.
+#[derive(Debug, Clone)]
+struct CodecUsage {
+    last_read: Option<Instant>,
+    last_value_ns: Option<u64>,
+    current_usage: Option<u8>,
+}
+
+impl CodecUsage {
+    /// Update the usage based on the latest fdinfo.
+    fn update(&mut self, info: &V4l2FdInfo) {
+        // TODO: Check if v4l2-core-usage-cycles-<core_id> exists.
+        let value_ns = info
+            .fields
+            .get("v4l2-core-usage-time-0")
+            .unwrap_or(&"0 ns".to_string())
+            .trim_end_matches(" ns")
+            .parse::<u64>()
+            .ok();
+        if let (Some(last_read), Some(last_value_ns), Some(value_ns)) =
+            (self.last_read, self.last_value_ns, value_ns)
+        {
+            if value_ns < last_value_ns {
+                // Counter reset (e.g. due to FD reuse by the same PID): discard history.
+                self.last_read = Some(info.timestamp);
+                self.last_value_ns = Some(value_ns);
+                self.current_usage = None;
+                return;
+            }
+
+            let elapsed = last_read.elapsed().as_secs_f64();
+            if elapsed > 0.0 {
+                let usage =
+                    ((value_ns - last_value_ns) as f64 / (elapsed * 1_000_000_000.0)) * 100.0;
+                self.current_usage = Some(usage.min(100.0) as u8);
+            }
+        }
+
+        self.last_read = Some(info.timestamp);
+        self.last_value_ns = value_ns;
+    }
+}
+
+/// Tracks sticky assignment of Stream to bar slots.
+struct StreamBarRenderer {
+    assignments: [(Option<V4L2Stream>, u8); NUM_BARS],
+    slots: [Option<V4L2Stream>; NUM_BARS],
+    usage: BTreeMap<String, usize>,
+    selected: Option<V4L2Stream>,
+}
+
+impl StreamBarRenderer {
+    fn new() -> Self {
+        Self {
+            assignments: [(None, 0u8); NUM_BARS],
+            slots: [None; NUM_BARS],
+            usage: BTreeMap::new(),
+            selected: None,
+        }
+    }
+
+    #[allow(dead_code)]
+    fn total_usage(&self) -> usize {
+        self.usage.values().sum()
+    }
+
+    /// Update assignments given current mem_map. Returns ordered (slot_index, pid, usage) for
+    /// occupied slots and None for empty ones.
+    fn update(&mut self, info: &HashMap<V4L2Stream, StreamInfo>, selected: Option<V4L2Stream>) {
+        self.selected = selected;
+
+        // Remove PIDs that are no longer present.
+        for slot in self.slots.iter_mut() {
+            if let Some(stream) = *slot
+                && !info.contains_key(&stream)
+            {
+                *slot = None;
+            }
+        }
+
+        // Compute per-driver usage totals for the summary bars.
+        self.usage = BTreeMap::new();
+        for stream_info in info.values() {
+            let driver = match stream_info.v4l2_info.fields.get("media-driver") {
+                Some(d) => d,
+                None => {
+                    continue;
+                }
+            };
+            let entry = self.usage.entry(driver.clone()).or_insert(0);
+            *entry += stream_info.usage.current_usage.unwrap_or(0) as usize;
+        }
+
+        // Sort Streams by usage descending; keep only top NUM_BARS.
+        let mut ranked: Vec<(V4L2Stream, u8)> = info
+            .iter()
+            .filter_map(|(&p, b)| b.usage.current_usage.map(|pct| (p, pct)))
+            .collect();
+        ranked.sort_by_key(|a| std::cmp::Reverse(a.1));
+        ranked.truncate(NUM_BARS);
+        let top_pids: HashMap<V4L2Stream, u8> = ranked.iter().copied().collect();
+
+        // Evict Streams that fell out of the max NUM_BARS.
+        for slot in self.slots.iter_mut() {
+            if let Some(pid) = *slot
+                && !top_pids.contains_key(&pid)
+            {
+                *slot = None;
+            }
+        }
+
+        // Assign new Streams to free slots.
+        let assigned: HashMap<V4L2Stream, usize> = self
+            .slots
+            .iter()
+            .enumerate()
+            .filter_map(|(i, s)| s.map(|pid| (pid, i)))
+            .collect();
+        let mut free_slots: VecDeque<usize> =
+            (0..NUM_BARS).filter(|i| self.slots[*i].is_none()).collect();
+
+        for &(stream, _) in &ranked {
+            if !assigned.contains_key(&stream)
+                && let Some(slot_idx) = free_slots.pop_front()
+            {
+                self.slots[slot_idx] = Some(stream);
+            }
+        }
+
+        // Build result array.
+        self.assignments = [(None, 0u8); NUM_BARS];
+        for (i, slot) in self.slots.iter().enumerate() {
+            self.assignments[i] = match slot {
+                Some(stream) => (
+                    Some(*stream),
+                    info.get(stream).unwrap().usage.current_usage.unwrap_or(0),
+                ),
+                None => (None, 0),
+            };
+        }
+    }
+
+    pub fn render(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
+        let row_constraints: Vec<Constraint> =
+            (0..BAR_ROWS + 1).map(|_| Constraint::Length(1)).collect();
+        let col_constraints = (0..BAR_COLS)
+            .map(|_| Constraint::Percentage(100 / BAR_COLS as u16))
+            .collect::<Vec<Constraint>>();
+
+        let rows_layout = Layout::default()
+            .direction(Direction::Vertical)
+            .constraints(row_constraints)
+            .split(Block::bordered().title("VPU Usage (per PID)").inner(area));
+        frame.render_widget(Block::bordered().title("VPU Usage (per PID)"), area);
+
+        for row in 0..BAR_ROWS {
+            let cols_layout = Layout::default()
+                .direction(Direction::Horizontal)
+                .constraints(col_constraints.clone())
+                .split(rows_layout[row]);
+
+            for col in 0..BAR_COLS {
+                let idx = row * BAR_COLS + col;
+                let (stream_opt, pct) = self.assignments[idx];
+
+                if let Some(stream) = stream_opt {
+                    let color = match pct {
+                        0..=50 => Color::Green,
+                        51..=80 => Color::Yellow,
+                        _ => Color::Red,
+                    };
+                    let style = if let Some(selected_stream) = self.selected {
+                        if stream == selected_stream {
+                            Style::default().bold()
+                        } else {
+                            Style::default()
+                        }
+                    } else {
+                        Style::default()
+                    };
+                    frame.render_widget(
+                        LineGauge::default()
+                            .label(format!("{:>9}({:>6}): {pct:>3}%", stream.pid, stream.fd))
+                            .ratio(pct as f64 / 100.0)
+                            .filled_symbol("|")
+                            .filled_style(color)
+                            .style(style),
+                        cols_layout[col],
+                    );
+                }
+            }
+        }
+
+        let total_usages_constraints: Vec<Constraint> = (0..self.usage.len())
+            .map(|_| Constraint::Percentage(100 / self.usage.len() as u16))
+            .collect();
+        let total_usages_layout = Layout::default()
+            .spacing(2)
+            .direction(Direction::Horizontal)
+            .constraints(total_usages_constraints)
+            .split(rows_layout[BAR_ROWS]);
+
+        for (layout, (driver, usage)) in self.usage.iter().enumerate() {
+            let color = match usage {
+                0..=50 => Color::Green,
+                51..=80 => Color::Yellow,
+                _ => Color::Red,
+            };
+
+            frame.render_widget(
+                LineGauge::default()
+                    .label(format!("{driver}: {usage:>3}%"))
+                    .ratio(*usage as f64 / 100.0)
+                    .filled_symbol("|")
+                    .filled_style(Style::default().fg(color)),
+                total_usages_layout[layout],
+            );
+        }
+    }
+}
+
+/// Renders the main table of V4L2 streams and their usage.
+struct StreamTableRenderer {
+    table_state: TableState,
+    show_bytes: bool,
+    full_cmd: bool,
+    keys: Vec<V4L2Stream>,
+}
+
+impl StreamTableRenderer {
+    fn new() -> Self {
+        Self {
+            table_state: TableState::default().with_selected(0),
+            show_bytes: false,
+            full_cmd: false,
+            keys: vec![],
+        }
+    }
+
+    fn render(
+        &mut self,
+        frame: &mut Frame,
+        area: ratatui::layout::Rect,
+        infos: &HashMap<V4L2Stream, StreamInfo>,
+    ) {
+        let columns = [
+            "Driver",
+            "PID",
+            "FD",
+            "Clock rate",
+            "Total Memory",
+            "Command",
+        ];
+        let mut widths = (0..columns.len() - 1)
+            .map(|_| Constraint::Percentage(60 / (columns.len() - 1) as u16))
+            .collect::<Vec<Constraint>>();
+        widths.push(Constraint::Percentage(40));
+
+        let header = Row::new(columns)
+            .style(Style::new().bold())
+            .bottom_margin(1);
+
+        self.keys = infos.keys().copied().collect::<Vec<V4L2Stream>>();
+        self.keys
+            .sort_by(|a, b| a.pid.cmp(&b.pid).then(a.fd.cmp(&b.fd)));
+
+        let rows = self
+            .keys
+            .iter()
+            .map(|stream| {
+                let info = infos.get(stream).unwrap();
+                Row::new([
+                    Cell::from(
+                        info.v4l2_info
+                            .fields
+                            .get("v4l2-driver")
+                            .unwrap_or(&"unknown".to_string())
+                            .to_string(),
+                    ),
+                    Cell::from(stream.pid.to_string()),
+                    Cell::from(stream.fd.to_string()),
+                    Cell::from({
+                        let freq = info
+                            .v4l2_info
+                            .fields
+                            .get("v4l2-curfreq-0")
+                            .unwrap_or(&"unknown".to_string())
+                            .to_string()
+                            .trim_end_matches(" Hz")
+                            .parse::<u64>()
+                            .unwrap_or(0);
+                        format_freq(freq, self.show_bytes)
+                    }),
+                    Cell::from({
+                        let total_mem = info.mem_usage.iter().fold(0, |mem, e| mem + e.size);
+                        format_bytes(total_mem as u64, self.show_bytes)
+                    }),
+                    Cell::from(if self.full_cmd {
+                        info.cmdline.clone()
+                    } else {
+                        info.comm.clone()
+                    }),
+                ])
+            })
+            .collect::<Vec<_>>();
+
+        let footer = Row::new(vec![Cell::from(
+            format!("Total V4L2 FDs: {}", infos.len(),),
+        )]);
+
+        let table = Table::new(rows, widths)
+            .block(
+                Block::bordered()
+                    .title("V4L2 Codec Usage (press 'q' to quit)")
+                    .title_alignment(ratatui::layout::Alignment::Center),
+            )
+            .header(header)
+            .footer(footer)
+            .column_spacing(1)
+            .style(Color::White)
+            .row_highlight_style(Style::new().reversed())
+            .highlight_symbol(">> ");
+
+        frame.render_stateful_widget(table, area, &mut self.table_state);
+    }
+
+    pub fn selected_stream(&self) -> Option<V4L2Stream> {
+        if let Some(idx) = self.table_state.selected() {
+            self.keys.get(idx).copied()
+        } else {
+            None
+        }
+    }
+
+    fn render_mem_details(
+        &self,
+        frame: &mut Frame,
+        area: ratatui::layout::Rect,
+        infos: &HashMap<V4L2Stream, StreamInfo>,
+    ) {
+        let mem_details = Block::bordered()
+            .title("Memory usage details")
+            .title_alignment(ratatui::layout::Alignment::Center);
+
+        let mem_rows = if let Some(stream) = self.selected_stream() {
+            infos[&stream]
+                .mem_usage
+                .iter()
+                .map(|e| {
+                    Row::new([
+                        e.label.to_string(),
+                        format_bytes(e.size as u64, self.show_bytes),
+                    ])
+                })
+                .collect()
+        } else {
+            vec![]
+        };
+
+        let mem_table = Table::new(
+            mem_rows,
+            [Constraint::Percentage(50), Constraint::Percentage(50)],
+        )
+        .block(mem_details);
+
+        frame.render_widget(mem_table, area);
+    }
+
+    pub fn select_previous(&mut self) {
+        self.table_state.select_previous();
+    }
+
+    pub fn select_next(&mut self) {
+        self.table_state.select_next();
+    }
+
+    pub fn show_bytes_flip(&mut self) {
+        self.show_bytes = !self.show_bytes;
+    }
+}
+
+/// Represents all information about a V4L2 stream, including its usage and memory usage.
+struct StreamInfo {
+    v4l2_info: V4l2FdInfo,
+    comm: String,
+    cmdline: String,
+    usage: CodecUsage,
+    mem_usage: Vec<DMABuffer>,
+}
+
+/// The main renderer that combines the stream bars and the stream table.
+pub struct TopRenderer {
+    stream_bars: StreamBarRenderer,
+    table_renderer: StreamTableRenderer,
+    infos: HashMap<V4L2Stream, StreamInfo>,
+}
+
+impl TopRenderer {
+    pub fn new() -> Self {
+        Self {
+            stream_bars: StreamBarRenderer::new(),
+            table_renderer: StreamTableRenderer::new(),
+            infos: HashMap::new(),
+        }
+    }
+
+    fn read_file_to_string(path: String) -> Result<String> {
+        std::fs::read_to_string(path)
+            .map(|s| s.trim().replace("\0", " ").to_string())
+            .map_err(|e| e.into())
+    }
+
+    fn update_data(&mut self) -> Result<()> {
+        let infos = match find_all_v4l2_fdinfo(None) {
+            Ok(infos) => infos,
+            Err(e) => {
+                bail!("Error fetching V4L2 metrics: {e}");
+            }
+        };
+
+        let mem_list = match v4l2_mem_get_usage() {
+            Ok(mem) => mem,
+            Err(e) => {
+                error!("Error fetching V4L2 memory usage: {e}");
+                HashMap::new()
+            }
+        };
+
+        // Remove entries for closed FDs and update usage for active ones.
+        let to_remove = self
+            .infos
+            .keys()
+            .filter(|stream| !infos.keys().any(|info| info == *stream))
+            .cloned()
+            .collect::<Vec<_>>();
+        for stream in to_remove {
+            self.infos.remove(&stream);
+        }
+
+        for (stream, info) in &infos {
+            let stream_info = self.infos.entry(*stream).or_insert(StreamInfo {
+                v4l2_info: info.clone(),
+                comm: Self::read_file_to_string(format!("/proc/{}/comm", stream.pid))
+                    .unwrap_or("unknown".to_string()),
+                cmdline: Self::read_file_to_string(format!("/proc/{}/cmdline", stream.pid))
+                    .unwrap_or("unknown".to_string()),
+                usage: CodecUsage {
+                    last_read: None,
+                    last_value_ns: None,
+                    current_usage: None,
+                },
+                mem_usage: vec![],
+            });
+
+            stream_info.usage.update(info);
+            stream_info.mem_usage = mem_list.get(stream).unwrap_or(&vec![]).clone();
+        }
+
+        Ok(())
+    }
+
+    pub fn render(&mut self, frame: &mut Frame) {
+        if let Err(e) = self.update_data() {
+            frame.render_widget(
+                Block::bordered()
+                    .title(e.to_string())
+                    .title_alignment(ratatui::layout::Alignment::Center),
+                frame.area(),
+            );
+        }
+
+        // Split layout: memory chart on top, table on bottom
+        let chunks = Layout::default()
+            .direction(Direction::Vertical)
+            .constraints([
+                Constraint::Length(9),
+                Constraint::Min(12),
+                Constraint::Length(1),
+            ])
+            .split(frame.area());
+
+        self.stream_bars
+            .update(&self.infos, self.table_renderer.selected_stream());
+        self.stream_bars.render(frame, chunks[0]);
+
+        let [layout_left, layout_right] =
+            Layout::horizontal([Constraint::Percentage(75), Constraint::Percentage(25)])
+                .areas(chunks[1]);
+
+        self.table_renderer.render(frame, layout_left, &self.infos);
+        self.table_renderer
+            .render_mem_details(frame, layout_right, &self.infos);
+
+        // Render shortcuts
+        let line = Line::default().spans([
+            "F2".bold(),
+            "Compact/Unit".black().on_light_green(),
+            "F3".bold(),
+            "FullCmd".black().on_light_green(),
+            "q".bold(),
+            "Quit".black().on_light_green(),
+        ]);
+
+        frame.render_widget(line, chunks[2]);
+    }
+
+    pub fn show_bytes_flip(&mut self) {
+        self.table_renderer.show_bytes_flip();
+    }
+
+    pub fn full_cmd_flip(&mut self) {
+        self.table_renderer.full_cmd = !self.table_renderer.full_cmd;
+    }
+
+    pub fn select_previous(&mut self) {
+        self.table_renderer.select_previous();
+    }
+
+    pub fn select_next(&mut self) {
+        self.table_renderer.select_next();
+    }
+}
diff --git a/utils/v4l2top/src/v4l2_mem.rs b/utils/v4l2top/src/v4l2_mem.rs
new file mode 100644
index 00000000..96492f06
--- /dev/null
+++ b/utils/v4l2top/src/v4l2_mem.rs
@@ -0,0 +1,56 @@
+use anyhow::Result;
+use regex::Regex;
+use std::{
+    collections::HashMap,
+    fs::{File, read_dir},
+    io::{BufRead, BufReader},
+    path::PathBuf,
+};
+
+use crate::v4l2_metrics::V4L2Stream;
+
+#[derive(Debug, Clone)]
+pub struct DMABuffer {
+    pub label: String,
+    pub size: usize,
+}
+
+fn parse_mem_file(
+    mem_file_path: &PathBuf,
+    mem: &mut HashMap<V4L2Stream, Vec<DMABuffer>>,
+) -> Result<()> {
+    let line_regex =
+        Regex::new(r"(?<creator>[^ ]+) +(?<fd>\d+) +(?<pid>\d+) +(?<size>\d+) +(?<label>[^ ].*)")?;
+    let file = File::open(mem_file_path)?;
+    let reader = BufReader::new(file);
+
+    for line in reader.lines() {
+        if let Some(caps) = line_regex.captures(&line?) {
+            let entry = mem
+                .entry(V4L2Stream::new(caps["pid"].parse()?, caps["fd"].parse()?))
+                .or_default();
+            entry.push(DMABuffer {
+                label: caps["label"].to_string(),
+                size: caps["size"].parse()?,
+            });
+        }
+    }
+    Ok(())
+}
+
+///
+/// Retrieve the memory usage of each media process through the v4l2 debugfs file.
+///
+pub fn v4l2_mem_get_usage() -> Result<HashMap<V4L2Stream, Vec<DMABuffer>>> {
+    let mut ret = HashMap::new();
+    let debugfs_path = PathBuf::from("/sys/kernel/debug/v4l2/");
+
+    for dir in read_dir(debugfs_path)? {
+        let mut dev_path = dir?.path();
+        dev_path.push("mem");
+
+        parse_mem_file(&dev_path, &mut ret)?;
+    }
+
+    Ok(ret)
+}
diff --git a/utils/v4l2top/src/v4l2_metrics.rs b/utils/v4l2top/src/v4l2_metrics.rs
new file mode 100644
index 00000000..76069ede
--- /dev/null
+++ b/utils/v4l2top/src/v4l2_metrics.rs
@@ -0,0 +1,148 @@
+//! V4L2 fdinfo metrics parser.
+//!
+//! Scans `/proc` to find file descriptors pointing to V4L2 character devices
+//! and parses their associated `fdinfo` entries into key–value maps.
+//!
+//! # Example
+//!
+//! ```rust,no_run
+//! use v4l2_metrics::{find_all_v4l2_fdinfo, parse_fdinfo};
+//!
+//! // Parse a specific fdinfo file directly
+//! let fields = parse_fdinfo("/proc/self/fdinfo/0").unwrap();
+//! println!("pos: {}", fields.get("pos").unwrap_or(&"unknown".to_string()));
+//!
+//! // Scan all processes for V4L2 file descriptors
+//! let infos = find_all_v4l2_fdinfo(None).unwrap();
+//! for info in &infos {
+//!     println!("pid={} fd={} buf-size={:?}", info.pid, info.fd, info.fields.get("buf-size"));
+//! }
+//!
+//! // Scan a specific process
+//! let infos = find_all_v4l2_fdinfo(Some(1234)).unwrap();
+//! ```
+
+use anyhow::Result;
+use std::collections::HashMap;
+use std::fs::{self, File};
+use std::io::{BufRead, BufReader};
+use std::os::unix::fs::{FileTypeExt, MetadataExt};
+use std::path::{Path, PathBuf};
+use std::time::Instant;
+
+const V4L2_DEV_MAJOR_ID: u64 = 81;
+
+/// A unique identifier for a V4L2 Stream (i.e. a file descriptor pointing to a V4L2 char device).
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct V4L2Stream {
+    pub pid: usize,
+    pub fd: usize,
+}
+
+impl V4L2Stream {
+    pub fn new(pid: usize, fd: usize) -> Self {
+        Self { pid, fd }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct V4l2FdInfo {
+    /// All key–value pairs from the fdinfo file.
+    pub fields: HashMap<String, String>,
+    /// The time at which the file was read.
+    pub timestamp: Instant,
+}
+
+/// Parse a single fdinfo file into a [`FdInfoMap`].
+///
+/// Lines that do not contain a `:` separator are silently skipped (e.g. the
+/// standard `pos`, `flags`,... and lines that happen to have no value are still
+/// split on `:` so they are included; truly malformed lines are dropped).
+pub fn parse_fdinfo(path: impl AsRef<Path>) -> Result<HashMap<String, String>> {
+    let file = File::open(path)?;
+    let reader = BufReader::new(file);
+    let mut map = HashMap::new();
+
+    for line in reader.lines() {
+        if let Some((key, value)) = line?.split_once(':') {
+            map.insert(key.trim().to_string(), value.trim().to_string());
+        }
+    }
+
+    Ok(map)
+}
+
+/// Return the Linux device major number from a raw `dev_t` value.
+fn linux_major(dev: u64) -> u64 {
+    // From <sys/sysmacros.h>: major = bits [19:8] | bits [63:32]
+    ((dev >> 8) & 0xfff) | ((dev >> 32) & !0xfff_u64)
+}
+
+/// Return `true` if the symlink `fd_dir/<fd_name>` resolves to a V4L2 char device.
+fn is_v4l2_fd(fd_dir: &Path, fd_name: &str) -> bool {
+    // metadata() follows the symlink, giving us the target's attributes.
+    fs::metadata(fd_dir.join(fd_name))
+        .map(|m| m.file_type().is_char_device() && linux_major(m.rdev()) == V4L2_DEV_MAJOR_ID)
+        .unwrap_or(false)
+}
+
+/// Find all V4L2 fdinfo entries for a single process.
+fn find_v4l2_fdinfo_for_pid(pid: usize) -> Result<HashMap<V4L2Stream, V4l2FdInfo>> {
+    let pid_dir = PathBuf::from(format!("/proc/{pid}"));
+    let fd_dir = pid_dir.join("fd");
+    let fdinfo_dir = pid_dir.join("fdinfo");
+
+    let mut results = HashMap::new();
+
+    for entry in fs::read_dir(&fdinfo_dir)? {
+        let entry = entry?;
+        let fd_name = entry.file_name();
+        let fd_name_str = fd_name.to_string_lossy();
+
+        // Only consider numeric entries (actual file descriptors).
+        if !fd_name_str.chars().all(|c| c.is_ascii_digit()) {
+            continue;
+        }
+
+        if !is_v4l2_fd(&fd_dir, &fd_name_str) {
+            continue;
+        }
+
+        let fd: usize = fd_name_str.parse()?;
+
+        let timestamp = Instant::now();
+        let fields = parse_fdinfo(entry.path())?;
+
+        results.insert(V4L2Stream::new(pid, fd), V4l2FdInfo { fields, timestamp });
+    }
+
+    Ok(results)
+}
+
+/// Find V4L2 fdinfo metrics for the given PID, or all PIDs if `pid` is [`None`].
+///
+/// Returns a [`HashMap`] of ['V4L2Stream'] -> [`V4l2FdInfo`] entries, one per V4L2 file descriptor
+/// found.  Errors from individual processes are silently ignored when scanning
+/// all PIDs (the process may have exited mid-scan); errors from a specific PID
+/// are propagated.
+pub fn find_all_v4l2_fdinfo(pid: Option<usize>) -> Result<HashMap<V4L2Stream, V4l2FdInfo>> {
+    match pid {
+        Some(pid) => find_v4l2_fdinfo_for_pid(pid),
+        None => {
+            let mut all = HashMap::new();
+
+            for entry in fs::read_dir("/proc")? {
+                let name = entry?.file_name();
+                let name_str = name.to_string_lossy();
+
+                if let Ok(pid) = name_str.parse::<usize>()
+                    && let Ok(infos) = find_v4l2_fdinfo_for_pid(pid)
+                {
+                    all.extend(infos);
+                }
+            }
+
+            Ok(all)
+        }
+    }
+}

-- 
2.55.0


^ permalink raw reply related	[flat|nested] 6+ messages in thread

* [PATCH RFC v4l-utils 2/3] meson: Add meson build file for v4l2top
  2026-09-16 15:20 [PATCH RFC 0/3] utils: Add the v4l2top tool Detlev Casanova
  2026-09-16 15:20 ` [PATCH RFC v4l-utils 1/3] v4l-util: Add the v4l2top utility Detlev Casanova
@ 2026-09-16 15:20 ` Detlev Casanova
  2026-09-16 15:20 ` [PATCH RFC v4l-utils 3/3] README: Add v4l2top util Detlev Casanova
  2026-09-16 15:33 ` [PATCH RFC 0/3] utils: Add the v4l2top tool Mauro Carvalho Chehab
  3 siblings, 0 replies; 6+ messages in thread
From: Detlev Casanova @ 2026-09-16 15:20 UTC (permalink / raw)
  To: linux-media, Detlev Casanova

The build file uses a cargo wrapper to build the project.

It will convert the build type (release/debug) to what cargo understands
and install the binary at the install step.

If cargo is not present on the build machine, v4l2top will be skipped.

Signed-off-by: Detlev Casanova <detlev.casanova@collabora.com>
---
 meson.build                    |  1 +
 meson_options.txt              |  2 ++
 utils/v4l2top/cargo-wrapper.sh | 22 +++++++++++++++++++++
 utils/v4l2top/meson.build      | 43 ++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 68 insertions(+)

diff --git a/meson.build b/meson.build
index 07a603ed..db94e149 100644
--- a/meson.build
+++ b/meson.build
@@ -496,4 +496,5 @@ summary({
             'v4l2-ctl-32' : is_variable('v4l2_ctl_32'),
             'v4l2-ctl-32-time64' : is_variable('v4l2_ctl_32_time64'),
             'v4l2-tracer' : is_variable('v4l2_tracer'),
+            'v4l2top' : is_variable('v4l2top'),
         }, bool_yn : true, section : 'Applications')
diff --git a/meson_options.txt b/meson_options.txt
index 3d04cfb4..0a7c681b 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -12,6 +12,8 @@ option('qvidcap', type : 'feature', value : 'auto',
        description : 'Enable qvidcap compilation')
 option('v4l2-tracer', type : 'feature', value : 'auto',
        description : 'Enable v4l2-tracer compilation')
+option('v4l2top', type : 'feature', value : 'auto',
+       description : 'Enable v4l2top compilation (requires cargo)')
 
 # Options
 option('v4l-plugins', type : 'boolean',
diff --git a/utils/v4l2top/cargo-wrapper.sh b/utils/v4l2top/cargo-wrapper.sh
new file mode 100755
index 00000000..05c728aa
--- /dev/null
+++ b/utils/v4l2top/cargo-wrapper.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+# Wrapper that drives `cargo build` from meson and copies the resulting
+# binary to the location meson expects.
+#
+# Usage: cargo-wrapper.sh <cargo> <manifest> <target-dir> <profile> <binary> <output>
+
+set -e
+
+CARGO="$1"
+MANIFEST="$2"
+TARGET_DIR="$3"
+PROFILE="$4"
+BINARY="$5"
+OUTPUT="$6"
+
+if [ "$PROFILE" = "release" ]; then
+    "$CARGO" build --manifest-path "$MANIFEST" --target-dir "$TARGET_DIR" --release
+else
+    "$CARGO" build --manifest-path "$MANIFEST" --target-dir "$TARGET_DIR"
+fi
+
+cp -f "$TARGET_DIR/$PROFILE/$BINARY" "$OUTPUT"
diff --git a/utils/v4l2top/meson.build b/utils/v4l2top/meson.build
new file mode 100644
index 00000000..2bdfa7da
--- /dev/null
+++ b/utils/v4l2top/meson.build
@@ -0,0 +1,43 @@
+v4l2top_opt = get_option('v4l2top')
+
+cargo = find_program('cargo', required : v4l2top_opt)
+
+if not cargo.found()
+    subdir_done()
+endif
+
+v4l2top_sources = files(
+    'Cargo.toml',
+    'Cargo.lock',
+    'src/main.rs',
+    'src/renderers.rs',
+    'src/v4l2_mem.rs',
+    'src/v4l2_metrics.rs',
+)
+
+# Map meson buildtypes to cargo profiles. Cargo only knows two built-in
+# profiles: 'dev' (target/debug) and 'release' (target/release). Treat any
+# optimised meson buildtype as a cargo release build.
+if get_option('buildtype') in ['release', 'debugoptimized', 'minsize', 'custom']
+    cargo_profile = 'release'
+else
+    cargo_profile = 'debug'
+endif
+
+v4l2top = custom_target('v4l2top',
+    output : 'v4l2top',
+    input : v4l2top_sources,
+    build_by_default : true,
+    install : true,
+    install_dir : get_option('bindir'),
+    console : true,
+    command : [
+        files('cargo-wrapper.sh'),
+        cargo.full_path(),
+        meson.current_source_dir() / 'Cargo.toml',
+        meson.current_build_dir() / 'target',
+        cargo_profile,
+        'v4l2top',
+        '@OUTPUT@',
+    ],
+)

-- 
2.55.0


^ permalink raw reply related	[flat|nested] 6+ messages in thread

* [PATCH RFC v4l-utils 3/3] README: Add v4l2top util
  2026-09-16 15:20 [PATCH RFC 0/3] utils: Add the v4l2top tool Detlev Casanova
  2026-09-16 15:20 ` [PATCH RFC v4l-utils 1/3] v4l-util: Add the v4l2top utility Detlev Casanova
  2026-09-16 15:20 ` [PATCH RFC v4l-utils 2/3] meson: Add meson build file for v4l2top Detlev Casanova
@ 2026-09-16 15:20 ` Detlev Casanova
  2026-09-16 15:33 ` [PATCH RFC 0/3] utils: Add the v4l2top tool Mauro Carvalho Chehab
  3 siblings, 0 replies; 6+ messages in thread
From: Detlev Casanova @ 2026-09-16 15:20 UTC (permalink / raw)
  To: linux-media, Detlev Casanova

Signed-off-by: Detlev Casanova <detlev.casanova@collabora.com>
---
 README.md | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/README.md b/README.md
index f3865e2c..de63ec48 100644
--- a/README.md
+++ b/README.md
@@ -293,6 +293,13 @@ memory-to-memory stateless video decoder interface.
 
 Installed by `make install` under `<prefix>/bin`.
 
+### v4l2top
+
+Tool to show media devices hardware and memory usage for each opened FD. It is
+currently limited to v4l2 memory-to-memory stateless video decoder interface
+
+Installed under `<prefix>/bin`.
+
 ## Utilities in contrib
 
 Some utilities are stored in the `contrib/` directory.

-- 
2.55.0


^ permalink raw reply related	[flat|nested] 6+ messages in thread

* Re: [PATCH RFC 0/3] utils: Add the v4l2top tool
  2026-09-16 15:20 [PATCH RFC 0/3] utils: Add the v4l2top tool Detlev Casanova
                   ` (2 preceding siblings ...)
  2026-09-16 15:20 ` [PATCH RFC v4l-utils 3/3] README: Add v4l2top util Detlev Casanova
@ 2026-09-16 15:33 ` Mauro Carvalho Chehab
  2026-09-16 17:07   ` Detlev Casanova
  3 siblings, 1 reply; 6+ messages in thread
From: Mauro Carvalho Chehab @ 2026-09-16 15:33 UTC (permalink / raw)
  To: Detlev Casanova; +Cc: linux-media

On Wed, 16 Sep 2026 11:20:41 -0400
Detlev Casanova <detlev.casanova@collabora.com> wrote:

> Hello !

For consistency (and to help scripts we may use) add "v4l-utils" at
the title, e.g.:

Subject: [PATCH RFC v4l-utils 0/3] utils: Add the v4l2top tool

Thanks!
Mauro

^ permalink raw reply	[flat|nested] 6+ messages in thread

* Re: [PATCH RFC 0/3] utils: Add the v4l2top tool
  2026-09-16 15:33 ` [PATCH RFC 0/3] utils: Add the v4l2top tool Mauro Carvalho Chehab
@ 2026-09-16 17:07   ` Detlev Casanova
  0 siblings, 0 replies; 6+ messages in thread
From: Detlev Casanova @ 2026-09-16 17:07 UTC (permalink / raw)
  To: Mauro Carvalho Chehab; +Cc: linux-media

Hi Mauro,

This is strange, maybe a b4 bug, because the commits have the v4l-utils in the 
title, it's only missing from the cover-letter, for some reasons. I'll have a 
look at what b4 is doing.

Detlev.

On Wednesday, 16 September 2026 11:33:05 EDT Mauro Carvalho Chehab wrote:
> On Wed, 16 Sep 2026 11:20:41 -0400
> 
> Detlev Casanova <detlev.casanova@collabora.com> wrote:
> > Hello !
> 
> For consistency (and to help scripts we may use) add "v4l-utils" at
> the title, e.g.:
> 
> Subject: [PATCH RFC v4l-utils 0/3] utils: Add the v4l2top tool
> 
> Thanks!
> Mauro





^ permalink raw reply	[flat|nested] 6+ messages in thread

end of thread, other threads:[~2026-09-16 17:07 UTC | newest]

Thread overview: 6+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-16 15:20 [PATCH RFC 0/3] utils: Add the v4l2top tool Detlev Casanova
2026-09-16 15:20 ` [PATCH RFC v4l-utils 1/3] v4l-util: Add the v4l2top utility Detlev Casanova
2026-09-16 15:20 ` [PATCH RFC v4l-utils 2/3] meson: Add meson build file for v4l2top Detlev Casanova
2026-09-16 15:20 ` [PATCH RFC v4l-utils 3/3] README: Add v4l2top util Detlev Casanova
2026-09-16 15:33 ` [PATCH RFC 0/3] utils: Add the v4l2top tool Mauro Carvalho Chehab
2026-09-16 17:07   ` Detlev Casanova

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox