use crate::sha256::{Sha256, hex_lower};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::ModelVariant;
pub(crate) fn coreml_cache_version_dir() -> String {
format!("ort-{}", ort::MINOR_VERSION)
}
#[cfg(feature = "coreml")]
pub(crate) fn coreml_cache_dir(model_dir: &Path) -> PathBuf {
model_dir
.join("coreml_cache")
.join(coreml_cache_version_dir())
}
pub fn optimized_cache_basename(encoder_path: &Path) -> Option<String> {
encoder_path
.file_stem()
.map(|s| format!("{}_optimized.ort", s.to_string_lossy()))
}
pub fn preferred_encoder_path(variant: ModelVariant, dir: &Path) -> Option<PathBuf> {
let int8 = dir.join(variant.encoder_int8_file());
if int8.exists() {
return Some(int8);
}
let enc = variant.encoder_file();
if !enc.is_empty() {
let fp32 = dir.join(enc);
if fp32.exists() {
return Some(fp32);
}
}
None
}
#[derive(Debug, Default, Clone)]
pub struct OptimizedCachePruneReport {
pub kept: Vec<PathBuf>,
pub removed: Vec<PathBuf>,
pub freed_bytes: u64,
pub dry_run: bool,
}
#[derive(Debug, Default, Clone)]
pub struct CoremlCachePruneReport {
pub kept: Option<PathBuf>,
pub removed: Vec<PathBuf>,
pub freed_bytes: u64,
pub dry_run: bool,
}
#[derive(Debug, Default, Clone)]
pub struct DedupeReport {
pub groups: usize,
pub hardlinked: usize,
pub freed_bytes: u64,
pub dry_run: bool,
}
pub fn prune_optimized_cache(model_dir: &Path, dry_run: bool) -> Result<OptimizedCachePruneReport> {
let mut report = OptimizedCachePruneReport {
dry_run,
..Default::default()
};
let cache_dir = model_dir.join("optimized_cache");
if !cache_dir.is_dir() {
return Ok(report);
}
let mut keep_names: std::collections::HashSet<String> = ModelVariant::ALL
.into_iter()
.filter_map(|v| preferred_encoder_path(v, model_dir))
.filter_map(|p| optimized_cache_basename(&p))
.collect();
if let Some(manifest) = super::manifest::ModelManifest::load(model_dir)?
&& let Some(name) = optimized_cache_basename(&manifest.preferred_encoder_path(model_dir))
{
keep_names.insert(name);
}
if keep_names.is_empty() {
tracing::info!(
"optimized_cache prune: no usable encoder in {}; leaving cache untouched",
model_dir.display()
);
return Ok(report);
}
for entry in std::fs::read_dir(&cache_dir)
.with_context(|| format!("failed to read optimized_cache at {}", cache_dir.display()))?
{
let entry = entry.context("failed to read optimized_cache entry")?;
let path = entry.path();
if !path.is_file() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.ends_with("_optimized.ort") && !name.ends_with("_optimized.onnx") {
continue;
}
if keep_names.contains(name.as_ref()) {
report.kept.push(path);
continue;
}
let len = entry.metadata().map(|m| m.len()).unwrap_or(0);
if !dry_run {
std::fs::remove_file(&path).with_context(|| {
format!("failed to remove stale optimized graph {}", path.display())
})?;
}
report.removed.push(path);
report.freed_bytes = report.freed_bytes.saturating_add(len);
}
if !report.removed.is_empty() {
tracing::info!(
dry_run,
kept = report.kept.len(),
removed = report.removed.len(),
freed_mib = report.freed_bytes / (1024 * 1024),
"optimized_cache prune finished"
);
}
Ok(report)
}
pub fn prune_coreml_cache(model_dir: &Path, dry_run: bool) -> Result<CoremlCachePruneReport> {
let mut report = CoremlCachePruneReport {
dry_run,
..Default::default()
};
let cache_root = model_dir.join("coreml_cache");
if !cache_root.is_dir() {
return Ok(report);
}
let keep_name = coreml_cache_version_dir();
for entry in std::fs::read_dir(&cache_root)
.with_context(|| format!("failed to read coreml_cache at {}", cache_root.display()))?
{
let entry = entry.context("failed to read coreml_cache entry")?;
let path = entry.path();
if !path.is_dir() {
continue;
}
if entry.file_name().to_string_lossy() == keep_name {
report.kept = Some(path);
continue;
}
let size = dir_size_bytes(&path);
if !dry_run {
std::fs::remove_dir_all(&path).with_context(|| {
format!("failed to remove stale coreml cache {}", path.display())
})?;
}
report.removed.push(path);
report.freed_bytes = report.freed_bytes.saturating_add(size);
}
if !report.removed.is_empty() {
tracing::info!(
dry_run,
kept = report.kept.is_some(),
removed = report.removed.len(),
freed_mib = report.freed_bytes / (1024 * 1024),
"coreml_cache prune finished"
);
}
Ok(report)
}
fn dir_size_bytes(path: &Path) -> u64 {
let mut total = 0u64;
let mut stack = vec![path.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
match entry.file_type() {
Ok(ft) if ft.is_dir() => stack.push(entry.path()),
Ok(ft) if ft.is_file() => {
total = total.saturating_add(entry.metadata().map(|m| m.len()).unwrap_or(0));
}
_ => {}
}
}
}
total
}
pub fn dedupe_model_dir(model_dir: &Path, dry_run: bool) -> Result<DedupeReport> {
let mut report = DedupeReport {
dry_run,
..Default::default()
};
if !model_dir.is_dir() {
return Ok(report);
}
let mut by_hash: HashMap<String, Vec<PathBuf>> = HashMap::new();
collect_regular_files(model_dir, &mut by_hash)?;
for mut paths in by_hash.into_values() {
if paths.len() < 2 {
continue;
}
paths.sort();
report.groups += 1;
let keep = paths[0].clone();
let size = std::fs::metadata(&keep).map(|m| m.len()).unwrap_or(0);
for other in paths.into_iter().skip(1) {
if same_file(&keep, &other)? {
continue;
}
if dry_run {
report.hardlinked += 1;
report.freed_bytes = report.freed_bytes.saturating_add(size);
continue;
}
let tmp = other.with_extension(format!("dedupe-tmp.{}", std::process::id()));
std::fs::rename(&other, &tmp)
.with_context(|| format!("failed to stage {} for hardlink", other.display()))?;
match std::fs::hard_link(&keep, &other) {
Ok(()) => {
let _ = std::fs::remove_file(&tmp);
report.hardlinked += 1;
report.freed_bytes = report.freed_bytes.saturating_add(size);
}
Err(e) => {
let _ = std::fs::rename(&tmp, &other);
tracing::warn!(
keep = %keep.display(),
other = %other.display(),
error = %e,
"hardlink dedupe skipped"
);
}
}
}
}
if report.hardlinked > 0 {
tracing::info!(
dry_run,
groups = report.groups,
hardlinked = report.hardlinked,
freed_mib = report.freed_bytes / (1024 * 1024),
"model-dir content-hash dedupe finished"
);
}
Ok(report)
}
fn collect_regular_files(root: &Path, by_hash: &mut HashMap<String, Vec<PathBuf>>) -> Result<()> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
Err(e) => {
tracing::warn!(path = %dir.display(), error = %e, "skip unreadable dir");
continue;
}
};
for entry in rd {
let entry = entry.with_context(|| format!("read_dir {}", dir.display()))?;
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name.contains(".partial") || name.ends_with(".lock") || name.starts_with('.') {
continue;
}
let ft = entry
.file_type()
.with_context(|| format!("file_type {}", path.display()))?;
if ft.is_dir() {
stack.push(path);
continue;
}
if !ft.is_file() {
continue;
}
let digest =
sha256_file_streaming(&path).with_context(|| format!("hash {}", path.display()))?;
by_hash.entry(digest).or_default().push(path);
}
}
Ok(())
}
fn sha256_file_streaming(path: &Path) -> Result<String> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex_lower(&hasher.finalize()))
}
fn same_file(a: &Path, b: &Path) -> Result<bool> {
let ma = std::fs::metadata(a)?;
let mb = std::fs::metadata(b)?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
Ok(ma.dev() == mb.dev() && ma.ino() == mb.ino())
}
#[cfg(not(unix))]
{
let _ = (ma, mb);
Ok(false)
}
}
#[cfg(test)]
mod tests;