use crate::{MIError, Result};
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct MemorySnapshot {
pub ram_bytes: u64,
pub vram_bytes: Option<u64>,
pub vram_total_bytes: Option<u64>,
pub vram_per_process: Option<bool>,
pub gpu_name: Option<String>,
pub vram_reserved_bytes: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct MemoryReport {
pub before: MemorySnapshot,
pub after: MemorySnapshot,
}
impl MemorySnapshot {
pub fn now(device: &candle_core::Device) -> Result<Self> {
if device.is_cuda() || device.is_metal() {
let snapshot = hypomnesis::Snapshot::now(0)
.map_err(|e| MIError::Memory(format!("failed to query GPU snapshot: {e}")))?;
Ok(Self::from_hypomnesis(&snapshot))
} else {
let ram_bytes = hypomnesis::process_rss()
.map_err(|e| MIError::Memory(format!("failed to query process RSS: {e}")))?;
Ok(Self {
ram_bytes,
vram_bytes: None,
vram_total_bytes: None,
vram_per_process: None,
gpu_name: None,
vram_reserved_bytes: None,
})
}
}
fn from_hypomnesis(snapshot: &hypomnesis::Snapshot) -> Self {
let (vram_bytes, vram_per_process) = snapshot.gpu.as_ref().map_or((None, None), |gpu| {
(Some(gpu.used_bytes), Some(gpu.is_per_process))
});
let (vram_total_bytes, gpu_name, vram_reserved_bytes) = snapshot
.gpu_device
.as_ref()
.map_or((None, None, None), |dev| {
(Some(dev.total_bytes), dev.name.clone(), dev.reserved_bytes)
});
Self {
ram_bytes: snapshot.ram_bytes,
vram_bytes,
vram_total_bytes,
vram_per_process,
gpu_name,
vram_reserved_bytes,
}
}
#[must_use]
pub fn ram_mb(&self) -> f64 {
#[allow(clippy::cast_precision_loss, clippy::as_conversions)]
let mb = self.ram_bytes as f64 / 1_048_576.0;
mb
}
#[must_use]
pub fn vram_mb(&self) -> Option<f64> {
#[allow(clippy::cast_precision_loss, clippy::as_conversions)]
self.vram_bytes.map(|b| b as f64 / 1_048_576.0)
}
#[must_use]
pub fn vram_reserved_mb(&self) -> Option<f64> {
#[allow(clippy::cast_precision_loss, clippy::as_conversions)]
self.vram_reserved_bytes.map(|b| b as f64 / 1_048_576.0)
}
}
#[allow(clippy::missing_const_for_fn)]
pub fn sync_and_trim_gpu(device: &candle_core::Device) {
#[cfg(feature = "cuda")]
if let candle_core::Device::Cuda(cuda_dev) = device {
use candle_core::backend::BackendDevice;
let _ = cuda_dev.synchronize();
#[allow(unsafe_code)]
{
use candle_core::cuda_backend::cudarc::driver::sys;
let stream = cuda_dev.cuda_stream();
if let Ok(probe) = stream.null::<u8>() {
let ctx = probe.context();
let cu_device = ctx.cu_device();
unsafe {
let mut pool = std::mem::zeroed();
let rc = sys::cuDeviceGetDefaultMemPool(&raw mut pool, cu_device);
if rc == sys::CUresult::CUDA_SUCCESS {
let _ = sys::cuMemPoolTrimTo(pool, 0);
}
}
}
}
}
#[cfg(not(feature = "cuda"))]
let _ = device;
}
impl MemoryReport {
#[must_use]
pub const fn new(before: MemorySnapshot, after: MemorySnapshot) -> 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,
}
}
pub fn print_delta(&self, label: &str) {
let ram = self.ram_delta_mb();
print!(" {label}: RAM {ram:+.0} MB");
if let Some(vram) = self.vram_delta_mb() {
let qualifier = self.vram_qualifier();
print!(" | VRAM {vram:+.0} MB{qualifier}");
}
println!();
}
pub fn print_before_after(&self, label: &str) {
println!(
" {label}: RAM {:.0} MB → {:.0} MB ({:+.0} MB)",
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.vram_total_bytes.map_or(String::new(), |t| {
format!(" / {:.0} MB", t as f64 / 1_048_576.0)
});
#[allow(clippy::cast_precision_loss, clippy::as_conversions)]
let reserved = self.after.vram_reserved_bytes.map_or(String::new(), |r| {
format!(", {:.0} MB reserved", r as f64 / 1_048_576.0)
});
let qualifier = self.vram_qualifier();
let gpu = self
.after
.gpu_name
.as_deref()
.map_or(String::new(), |name| format!(" [{name}]"));
println!(
" {label}: VRAM {before:.0} MB → {after:.0} MB ({:+.0} MB{total}{reserved}){qualifier}{gpu}",
after - before,
);
}
}
#[must_use]
const fn vram_qualifier(&self) -> &'static str {
match self.after.vram_per_process {
Some(true) => " [per-process]",
Some(false) => " [device-wide]",
None => "",
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn snapshot_cpu_has_ram() {
let snap = MemorySnapshot::now(&candle_core::Device::Cpu).unwrap();
assert!(snap.ram_bytes > 0, "RAM should be non-zero");
assert!(snap.vram_bytes.is_none(), "CPU should have no VRAM");
assert!(
snap.vram_per_process.is_none(),
"CPU should have no VRAM qualifier"
);
}
#[test]
fn report_delta_positive_for_allocation() {
let before = MemorySnapshot {
ram_bytes: 100 * 1_048_576, vram_bytes: Some(500 * 1_048_576),
vram_total_bytes: Some(16_384 * 1_048_576),
vram_per_process: Some(true),
gpu_name: None,
vram_reserved_bytes: None,
};
let after = MemorySnapshot {
ram_bytes: 200 * 1_048_576, vram_bytes: Some(1_000 * 1_048_576),
vram_total_bytes: Some(16_384 * 1_048_576),
vram_per_process: Some(true),
gpu_name: None,
vram_reserved_bytes: 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_none_when_no_vram() {
let before = MemorySnapshot {
ram_bytes: 100,
vram_bytes: None,
vram_total_bytes: None,
vram_per_process: None,
gpu_name: None,
vram_reserved_bytes: None,
};
let after = MemorySnapshot {
ram_bytes: 200,
vram_bytes: None,
vram_total_bytes: None,
vram_per_process: None,
gpu_name: None,
vram_reserved_bytes: None,
};
let report = MemoryReport::new(before, after);
assert!(report.vram_delta_mb().is_none());
}
#[test]
fn ram_mb_conversion() {
let snap = MemorySnapshot {
ram_bytes: 1_048_576, vram_bytes: None,
vram_total_bytes: None,
vram_per_process: None,
gpu_name: None,
vram_reserved_bytes: None,
};
assert!((snap.ram_mb() - 1.0).abs() < 0.001);
}
#[test]
fn vram_reserved_mb_conversion() {
let snap = MemorySnapshot {
ram_bytes: 100,
vram_bytes: Some(500 * 1_048_576),
vram_total_bytes: Some(16_311 * 1_048_576),
vram_per_process: Some(true),
gpu_name: None,
vram_reserved_bytes: Some(259 * 1_048_576),
};
assert!((snap.vram_reserved_mb().unwrap() - 259.0).abs() < 0.001);
assert!(snap.vram_reserved_bytes.unwrap() < snap.vram_total_bytes.unwrap());
}
#[test]
fn vram_reserved_mb_none_when_absent() {
let snap = MemorySnapshot {
ram_bytes: 100,
vram_bytes: Some(500),
vram_total_bytes: Some(1000),
vram_per_process: Some(true),
gpu_name: None,
vram_reserved_bytes: None,
};
assert!(snap.vram_reserved_mb().is_none());
}
#[test]
fn vram_qualifier_per_process() {
let snap = MemorySnapshot {
ram_bytes: 100,
vram_bytes: Some(500),
vram_total_bytes: Some(1000),
vram_per_process: Some(true),
gpu_name: None,
vram_reserved_bytes: None,
};
let report = MemoryReport::new(snap.clone(), snap);
assert_eq!(report.vram_qualifier(), " [per-process]");
}
#[test]
fn vram_qualifier_device_wide() {
let snap = MemorySnapshot {
ram_bytes: 100,
vram_bytes: Some(500),
vram_total_bytes: Some(1000),
vram_per_process: Some(false),
gpu_name: None,
vram_reserved_bytes: None,
};
let report = MemoryReport::new(snap.clone(), snap);
assert_eq!(report.vram_qualifier(), " [device-wide]");
}
}