use std::fmt::Write as _;
use clap::{Parser, Subcommand};
use hypomnesis::{Result, Snapshot, device_count, device_info, gpu_processes};
#[derive(Parser, Debug)]
#[command(
name = "hmn",
version,
about = "GPU memory CLI: device summary (default) + compute-process listing (`hmn ps`).",
long_about = "GPU memory CLI for hypomnesis.\n\
\n\
Default subcommand: prints one line per visible GPU with free / total VRAM \
(NVIDIA dGPUs, plus AMD / Intel iGPUs on Windows).\n\
\n\
`hmn ps`: lists compute processes holding GPU memory.\n\
\n\
Limitations:\n\
- Compute-only. Both backends (NVML on Linux, nvidia-smi on Windows) only \
see processes with an active CUDA context. Browsers using GPU compositing, \
games, and pure-graphics apps do not appear.\n\
- Windows process names may be `?` for protected processes whose image name \
nvidia-smi cannot read.\n\
- The R570 u64::MAX sentinel and used > total checks are applied per-row; \
affected rows are dropped rather than reported as garbage."
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
enum Commands {
Ps {
#[arg(long, value_name = "PID")]
pid: Option<u32>,
#[arg(long, value_name = "INDEX")]
device: Option<u32>,
#[arg(long)]
json: bool,
},
}
fn main() -> std::process::ExitCode {
let cli = Cli::parse();
let outcome = match cli.command {
None => run_summary(),
Some(Commands::Ps { pid, device, json }) => run_ps(pid, device, json),
};
match outcome {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(e) => {
eprintln!("hmn: {e}");
std::process::ExitCode::FAILURE
}
}
}
fn run_summary() -> Result<()> {
let snaps = Snapshot::all()?;
if snaps.is_empty() {
println!("hmn: no visible GPUs.");
return Ok(());
}
print!("{}", format_summary(&snaps));
Ok(())
}
#[allow(clippy::missing_panics_doc)] fn format_summary(snaps: &[Snapshot]) -> String {
let mut out = String::new();
for snap in snaps {
let Some(dev) = &snap.gpu_device else {
continue;
};
let free_mib = bytes_to_mib(dev.free_bytes);
let total_mib = bytes_to_mib(dev.total_bytes);
let name_suffix = dev
.name
.as_deref()
.map_or(String::new(), |n| format!(" [{n}]"));
let _ = writeln!(
out,
"GPU {}{name_suffix}: free {free_mib} MiB / {total_mib} MiB",
dev.index,
);
}
out
}
#[derive(Debug, Clone)]
struct PsRow {
pid: u32,
name: Option<String>,
used_bytes: u64,
device_index: u32,
device_name: Option<String>,
}
#[allow(clippy::unnecessary_wraps)]
fn run_ps(pid_filter: Option<u32>, device_filter: Option<u32>, json: bool) -> Result<()> {
let device_indices: Vec<u32> = device_filter.map_or_else(
|| (0..device_count().unwrap_or(0)).collect(),
|idx| vec![idx],
);
let mut rows: Vec<PsRow> = Vec::new();
for &idx in &device_indices {
let device_name = device_info(idx).ok().and_then(|d| d.name);
let Ok(entries) = gpu_processes(idx) else {
continue;
};
for entry in entries {
if let Some(want) = pid_filter
&& entry.pid != want
{
continue;
}
rows.push(PsRow {
pid: entry.pid,
name: entry.name,
used_bytes: entry.used_bytes,
device_index: idx,
device_name: device_name.clone(),
});
}
}
if json {
print!("{}", format_ps_json(&rows));
} else {
print!("{}", format_ps_table(&rows));
}
eprintln!(
"hmn: {}",
format_ps_summary(rows.len(), pid_filter, device_filter)
);
Ok(())
}
fn format_ps_summary(count: usize, pid_filter: Option<u32>, device_filter: Option<u32>) -> String {
let noun = if count == 1 {
"compute process"
} else {
"compute processes"
};
let mut out = format!("{count} {noun} found");
let filter_clause = match (pid_filter, device_filter) {
(Some(p), Some(d)) => Some(format!("pid={p} device={d}")),
(Some(p), None) => Some(format!("pid={p}")),
(None, Some(d)) => Some(format!("device={d}")),
(None, None) => None,
};
if let Some(clause) = filter_clause {
let _ = write!(out, " matching {clause}");
}
out.push('.');
out
}
#[allow(clippy::missing_panics_doc)] fn format_ps_table(rows: &[PsRow]) -> String {
let pid_header = "PID";
let name_header = "NAME";
let vram_header = "VRAM";
let device_header = "DEVICE";
let pid_cells: Vec<String> = rows.iter().map(|r| r.pid.to_string()).collect();
let name_cells: Vec<&str> = rows
.iter()
.map(|r| r.name.as_deref().unwrap_or("?"))
.collect();
let vram_cells: Vec<String> = rows.iter().map(|r| format_vram(r.used_bytes)).collect();
let device_cells: Vec<String> = rows
.iter()
.map(|r| {
r.device_name
.clone()
.unwrap_or_else(|| format!("GPU {}", r.device_index))
})
.collect();
let pid_w = column_width(pid_header, pid_cells.iter().map(String::as_str));
let name_w = column_width(name_header, name_cells.iter().copied());
let vram_w = column_width(vram_header, vram_cells.iter().map(String::as_str));
let device_w = column_width(device_header, device_cells.iter().map(String::as_str));
let mut out = String::new();
let _ = writeln!(
out,
"{pid_header:<pid_w$} {name_header:<name_w$} {vram_header:<vram_w$} {device_header:<device_w$}",
);
for (((pid, name), vram), device) in pid_cells
.iter()
.zip(&name_cells)
.zip(&vram_cells)
.zip(&device_cells)
{
let _ = writeln!(
out,
"{pid:<pid_w$} {name:<name_w$} {vram:<vram_w$} {device:<device_w$}",
);
}
out
}
#[allow(clippy::missing_panics_doc)] fn format_ps_json(rows: &[PsRow]) -> String {
let mut out = String::from("[");
for (i, row) in rows.iter().enumerate() {
if i > 0 {
out.push(',');
}
let name_json = row.name.as_deref().map_or_else(
|| String::from("null"),
|n| format!("\"{}\"", json_escape(n)),
);
let device_name_json = row.device_name.as_deref().map_or_else(
|| String::from("null"),
|n| format!("\"{}\"", json_escape(n)),
);
let _ = write!(
out,
r#"{{"pid":{},"name":{name_json},"used_bytes":{},"device_index":{},"device_name":{device_name_json}}}"#,
row.pid, row.used_bytes, row.device_index,
);
}
out.push_str("]\n");
out
}
const fn bytes_to_mib(bytes: u64) -> u64 {
bytes / 1_048_576
}
fn format_vram(bytes: u64) -> String {
const MIB: u64 = 1024 * 1024;
const GIB: u64 = MIB * 1024;
if bytes >= GIB {
#[allow(clippy::cast_precision_loss, clippy::as_conversions)]
let g = (bytes as f64) / (GIB as f64);
format!("{g:.1} GiB")
} else {
let mib = bytes / MIB;
format!("{mib} MiB")
}
}
fn column_width<'a>(header: &str, cells: impl IntoIterator<Item = &'a str>) -> usize {
cells
.into_iter()
.map(str::len)
.chain(std::iter::once(header.len()))
.max()
.unwrap_or(0)
}
#[allow(clippy::missing_panics_doc)] fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
#[allow(clippy::as_conversions)]
let code = c as u32;
let _ = write!(out, "\\u{code:04x}");
}
c => out.push(c),
}
}
out
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::missing_docs_in_private_items
)]
mod tests {
use super::*;
fn row(
pid: u32,
name: Option<&str>,
used_bytes: u64,
device_index: u32,
device_name: Option<&str>,
) -> PsRow {
PsRow {
pid,
name: name.map(str::to_owned),
used_bytes,
device_index,
device_name: device_name.map(str::to_owned),
}
}
#[test]
fn format_vram_sub_gib() {
assert_eq!(format_vram(0), "0 MiB");
assert_eq!(format_vram(1024 * 1024), "1 MiB");
assert_eq!(format_vram(512 * 1024 * 1024), "512 MiB");
}
#[test]
fn format_vram_gib_one_decimal() {
let one_gib = 1024_u64 * 1024 * 1024;
assert_eq!(format_vram(one_gib), "1.0 GiB");
assert_eq!(format_vram(one_gib + one_gib / 2), "1.5 GiB");
let bytes_8_2_gib = 8 * one_gib + 200 * 1024 * 1024;
assert_eq!(format_vram(bytes_8_2_gib), "8.2 GiB");
}
#[test]
fn bytes_to_mib_basic() {
assert_eq!(bytes_to_mib(0), 0);
assert_eq!(bytes_to_mib(1_048_576), 1);
assert_eq!(bytes_to_mib(16_384 * 1_048_576), 16_384);
}
#[test]
fn column_width_picks_max() {
assert_eq!(column_width("PID", ["1", "12345"]), 5);
assert_eq!(column_width("HEADER", ["a", "bc"]), 6);
assert_eq!(column_width("PID", std::iter::empty::<&str>()), 3);
}
#[test]
fn json_escape_passthrough() {
assert_eq!(json_escape("python.exe"), "python.exe");
}
#[test]
fn json_escape_quotes_and_backslash() {
assert_eq!(json_escape("a\"b\\c"), "a\\\"b\\\\c");
}
#[test]
fn json_escape_control_chars() {
assert_eq!(json_escape("a\nb"), "a\\nb");
assert_eq!(json_escape("a\tb"), "a\\tb");
assert_eq!(json_escape("\u{0001}"), "\\u0001");
}
#[test]
fn format_ps_table_empty_prints_header_only() {
let s = format_ps_table(&[]);
assert_eq!(s, "PID NAME VRAM DEVICE\n");
}
#[test]
fn format_ps_table_single_row() {
let r = row(
12345,
Some("python.exe"),
8_589_934_592, 0,
Some("RTX 5060 Ti"),
);
let s = format_ps_table(&[r]);
let expected = "PID NAME VRAM DEVICE \n\
12345 python.exe 8.0 GiB RTX 5060 Ti\n";
assert_eq!(s, expected);
}
#[test]
fn format_ps_table_protected_name_renders_question_mark() {
let r = row(99, Some("?"), 268_435_456, 0, Some("RTX 5060 Ti"));
let s = format_ps_table(&[r]);
let expected = "PID NAME VRAM DEVICE \n\
99 ? 256 MiB RTX 5060 Ti\n";
assert_eq!(s, expected);
}
#[test]
fn format_ps_table_missing_name_renders_question_mark() {
let r = row(99, None, 268_435_456, 0, Some("RTX 5060 Ti"));
let s = format_ps_table(&[r]);
let expected = "PID NAME VRAM DEVICE \n\
99 ? 256 MiB RTX 5060 Ti\n";
assert_eq!(s, expected);
}
#[test]
fn format_ps_table_falls_back_to_gpu_n_when_no_device_name() {
let r = row(99, Some("python.exe"), 268_435_456, 3, None);
let s = format_ps_table(&[r]);
assert!(s.contains("python.exe 256 MiB GPU 3"));
}
#[test]
fn format_ps_json_empty() {
assert_eq!(format_ps_json(&[]), "[]\n");
}
#[test]
fn format_ps_json_single_row() {
let r = row(
12345,
Some("python.exe"),
8 * 1_048_576,
0,
Some("RTX 5060 Ti"),
);
let s = format_ps_json(&[r]);
assert_eq!(
s,
"[{\"pid\":12345,\"name\":\"python.exe\",\"used_bytes\":8388608,\"device_index\":0,\"device_name\":\"RTX 5060 Ti\"}]\n"
);
}
#[test]
fn format_ps_json_null_name() {
let r = row(42, None, 0, 0, None);
let s = format_ps_json(&[r]);
assert_eq!(
s,
"[{\"pid\":42,\"name\":null,\"used_bytes\":0,\"device_index\":0,\"device_name\":null}]\n"
);
}
#[test]
fn format_ps_json_two_rows_comma_separated() {
let a = row(1, Some("a.exe"), 1_048_576, 0, Some("GPU"));
let b = row(2, Some("b.exe"), 2_097_152, 0, Some("GPU"));
let s = format_ps_json(&[a, b]);
assert_eq!(
s,
"[{\"pid\":1,\"name\":\"a.exe\",\"used_bytes\":1048576,\"device_index\":0,\"device_name\":\"GPU\"},\
{\"pid\":2,\"name\":\"b.exe\",\"used_bytes\":2097152,\"device_index\":0,\"device_name\":\"GPU\"}]\n"
);
}
#[test]
fn format_ps_json_escapes_quotes_in_name() {
let r = row(1, Some(r#"weird"name"#), 0, 0, None);
let s = format_ps_json(&[r]);
assert!(s.contains(r#""name":"weird\"name""#));
}
#[test]
fn format_summary_empty_input() {
assert_eq!(format_summary(&[]), "");
}
#[test]
fn format_ps_summary_zero_no_filters() {
assert_eq!(
format_ps_summary(0, None, None),
"0 compute processes found."
);
}
#[test]
fn format_ps_summary_one_no_filters() {
assert_eq!(format_ps_summary(1, None, None), "1 compute process found.");
}
#[test]
fn format_ps_summary_many_no_filters() {
assert_eq!(
format_ps_summary(7, None, None),
"7 compute processes found."
);
}
#[test]
fn format_ps_summary_with_pid_filter() {
assert_eq!(
format_ps_summary(0, Some(12345), None),
"0 compute processes found matching pid=12345."
);
}
#[test]
fn format_ps_summary_with_device_filter() {
assert_eq!(
format_ps_summary(2, None, Some(0)),
"2 compute processes found matching device=0."
);
}
#[test]
fn format_ps_summary_with_both_filters() {
assert_eq!(
format_ps_summary(1, Some(99), Some(1)),
"1 compute process found matching pid=99 device=1."
);
}
}