use crate::Snapshot;
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct MemoryReport {
pub before: Snapshot,
pub after: Snapshot,
}
impl MemoryReport {
#[must_use]
pub const fn new(before: Snapshot, after: Snapshot) -> Self {
Self { before, after }
}
#[must_use]
pub fn ram_delta_mb(&self) -> f64 {
self.after.ram_mb() - self.before.ram_mb()
}
#[must_use]
pub fn vram_delta_mb(&self) -> Option<f64> {
match (self.after.vram_mb(), self.before.vram_mb()) {
(Some(after), Some(before)) => Some(after - before),
(Some(_) | None, None) | (None, Some(_)) => None,
}
}
#[must_use]
pub fn format_delta(&self, label: &str) -> String {
let ram = self.ram_delta_mb();
self.vram_delta_mb().map_or_else(
|| format!(" {label}: RAM {ram:+.0} MB\n"),
|vram| {
let qualifier = self.vram_qualifier();
format!(" {label}: RAM {ram:+.0} MB | VRAM {vram:+.0} MB{qualifier}\n")
},
)
}
#[must_use]
pub fn format_before_after(&self, label: &str) -> String {
let mut out = format!(
" {label}: RAM {:.0} MB → {:.0} MB ({:+.0} MB)\n",
self.before.ram_mb(),
self.after.ram_mb(),
self.ram_delta_mb(),
);
if let (Some(before), Some(after)) = (self.before.vram_mb(), self.after.vram_mb()) {
#[allow(clippy::cast_precision_loss, clippy::as_conversions)]
let total = self.after.gpu_device.as_ref().map_or(String::new(), |d| {
format!(" / {:.0} MB", d.total_bytes as f64 / 1_048_576.0)
});
let qualifier = self.vram_qualifier();
let gpu = self
.after
.gpu_device
.as_ref()
.and_then(|d| d.name.as_deref())
.map_or(String::new(), |name| format!(" [{name}]"));
let line2 = format!(
" {label}: VRAM {before:.0} MB → {after:.0} MB ({:+.0} MB{total}){qualifier}{gpu}\n",
after - before,
);
out.push_str(&line2);
}
out
}
pub fn print_delta(&self, label: &str) {
print!("{}", self.format_delta(label));
}
pub fn print_before_after(&self, label: &str) {
print!("{}", self.format_before_after(label));
}
const fn vram_qualifier(&self) -> &'static str {
match self.after.gpu.as_ref() {
Some(g) if g.is_per_process => " [per-process]",
Some(_) => " [device-wide]",
None => "",
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::missing_docs_in_private_items
)]
mod tests {
use super::*;
use crate::{GpuDeviceInfo, GpuQuerySource, ProcessGpuInfo};
fn snapshot_with(
ram: u64,
vram_used: Option<u64>,
is_per_process: bool,
total: u64,
name: Option<&str>,
) -> Snapshot {
Snapshot {
ram_bytes: ram,
gpu: vram_used.map(|used| ProcessGpuInfo {
used_bytes: used,
is_per_process,
source: if is_per_process {
GpuQuerySource::Nvml
} else {
GpuQuerySource::NvidiaSmi
},
}),
gpu_device: Some(GpuDeviceInfo {
index: 0,
name: name.map(str::to_owned),
total_bytes: total,
free_bytes: total.saturating_sub(vram_used.unwrap_or(0)),
used_bytes: vram_used.unwrap_or(0),
}),
}
}
fn snapshot_no_gpu(ram: u64) -> Snapshot {
Snapshot {
ram_bytes: ram,
gpu: None,
gpu_device: None,
}
}
#[test]
fn report_delta_positive_for_allocation() {
let before = snapshot_with(
100 * 1_048_576,
Some(500 * 1_048_576),
true,
16_384 * 1_048_576,
None,
);
let after = snapshot_with(
200 * 1_048_576,
Some(1_000 * 1_048_576),
true,
16_384 * 1_048_576,
None,
);
let report = MemoryReport::new(before, after);
let ram_delta = report.ram_delta_mb();
assert!(
(ram_delta - 100.0).abs() < 0.01,
"RAM delta should be ~100 MB, got {ram_delta}"
);
let vram_delta = report.vram_delta_mb().unwrap();
assert!(
(vram_delta - 500.0).abs() < 0.01,
"VRAM delta should be ~500 MB, got {vram_delta}"
);
}
#[test]
fn report_delta_negative_for_deallocation() {
let before = snapshot_with(
500 * 1_048_576,
Some(2_000 * 1_048_576),
true,
16_384 * 1_048_576,
None,
);
let after = snapshot_with(
300 * 1_048_576,
Some(800 * 1_048_576),
true,
16_384 * 1_048_576,
None,
);
let report = MemoryReport::new(before, after);
assert!(report.ram_delta_mb() < 0.0);
assert!(report.vram_delta_mb().unwrap() < 0.0);
}
#[test]
fn report_delta_none_when_no_vram() {
let report = MemoryReport::new(snapshot_no_gpu(100), snapshot_no_gpu(200));
assert!(report.vram_delta_mb().is_none());
}
#[test]
fn report_delta_none_when_only_one_side_has_vram() {
let before = snapshot_no_gpu(100);
let after = snapshot_with(200, Some(500), true, 1000, None);
let report = MemoryReport::new(before, after);
assert!(report.vram_delta_mb().is_none());
}
#[test]
fn vram_qualifier_per_process() {
let snap = snapshot_with(100, Some(500), true, 1000, None);
let report = MemoryReport::new(snap.clone(), snap);
assert_eq!(report.vram_qualifier(), " [per-process]");
}
#[test]
fn vram_qualifier_device_wide() {
let snap = snapshot_with(100, Some(500), false, 1000, None);
let report = MemoryReport::new(snap.clone(), snap);
assert_eq!(report.vram_qualifier(), " [device-wide]");
}
#[test]
fn vram_qualifier_empty_when_no_gpu() {
let snap = snapshot_no_gpu(100);
let report = MemoryReport::new(snap.clone(), snap);
assert_eq!(report.vram_qualifier(), "");
}
#[test]
fn format_delta_with_vram_per_process() {
let before = snapshot_with(
100 * 1_048_576,
Some(500 * 1_048_576),
true,
16_384 * 1_048_576,
None,
);
let after = snapshot_with(
200 * 1_048_576,
Some(1_000 * 1_048_576),
true,
16_384 * 1_048_576,
None,
);
let report = MemoryReport::new(before, after);
let s = report.format_delta("alloc");
assert_eq!(s, " alloc: RAM +100 MB | VRAM +500 MB [per-process]\n");
}
#[test]
fn format_delta_with_vram_device_wide() {
let before = snapshot_with(
100 * 1_048_576,
Some(500 * 1_048_576),
false, 16_384 * 1_048_576,
None,
);
let after = snapshot_with(
150 * 1_048_576,
Some(750 * 1_048_576),
false,
16_384 * 1_048_576,
None,
);
let report = MemoryReport::new(before, after);
let s = report.format_delta("step");
assert_eq!(s, " step: RAM +50 MB | VRAM +250 MB [device-wide]\n");
}
#[test]
fn format_delta_without_vram() {
let report = MemoryReport::new(
snapshot_no_gpu(50 * 1_048_576),
snapshot_no_gpu(80 * 1_048_576),
);
let s = report.format_delta("cpu");
assert_eq!(s, " cpu: RAM +30 MB\n");
}
#[test]
fn format_delta_negative_ram() {
let report = MemoryReport::new(
snapshot_no_gpu(80 * 1_048_576),
snapshot_no_gpu(50 * 1_048_576),
);
let s = report.format_delta("free");
assert_eq!(s, " free: RAM -30 MB\n");
}
#[test]
fn format_before_after_with_vram_and_name() {
let before = snapshot_with(
100 * 1_048_576,
Some(500 * 1_048_576),
true,
16_384 * 1_048_576,
Some("NVIDIA Test GPU"),
);
let after = snapshot_with(
200 * 1_048_576,
Some(1_000 * 1_048_576),
true,
16_384 * 1_048_576,
Some("NVIDIA Test GPU"),
);
let report = MemoryReport::new(before, after);
let s = report.format_before_after("model_load");
assert_eq!(
s,
" model_load: RAM 100 MB → 200 MB (+100 MB)\n \
model_load: VRAM 500 MB → 1000 MB (+500 MB / 16384 MB) [per-process] [NVIDIA Test GPU]\n"
);
}
#[test]
fn format_before_after_without_vram() {
let before = snapshot_no_gpu(100 * 1_048_576);
let after = snapshot_no_gpu(150 * 1_048_576);
let report = MemoryReport::new(before, after);
let s = report.format_before_after("cpu_only");
assert_eq!(s, " cpu_only: RAM 100 MB → 150 MB (+50 MB)\n");
}
#[test]
fn print_delta_does_not_panic() {
let snap = snapshot_with(100, Some(200), true, 1000, None);
let report = MemoryReport::new(snap.clone(), snap);
report.print_delta("smoke"); }
#[test]
fn print_before_after_does_not_panic() {
let snap = snapshot_with(100, Some(200), true, 1000, None);
let report = MemoryReport::new(snap.clone(), snap);
report.print_before_after("smoke");
}
}