mirror.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. //! `--mirror`: serve from a per-node local mirror of the HF cache instead of
  2. //! the shared one. On first serve each node copies the required HF repos
  3. //! (main model + `{{repo}}` path refs + composed-model sources) from the
  4. //! shared cache into the mirror; later serves mount the mirror directly.
  5. //! Local reads skip streaming the weights over slow shared storage at load
  6. //! time — the copy is a one-time, per-node cost.
  7. //!
  8. //! The mirror keeps the HF hub layout (`<mirror>/hub/models--org--model/...`),
  9. //! so the container still sees the canonical `/root/.cache/huggingface`
  10. //! structure: `refs/main`, path-ref resolution, and composed-model symlinks
  11. //! all keep working unchanged.
  12. use crate::config::{Config, Node, Recipe, model_cache_dir_name};
  13. use crate::docker::shq;
  14. use crate::ssh::run_ssh_long_running;
  15. use anyhow::{Result, bail};
  16. use std::path::{Path, PathBuf};
  17. use tracing::{info, warn};
  18. /// Default mirror root when `--mirror` is given without a value.
  19. pub const DEFAULT_MIRROR: &str = "~/.cache/hf-models";
  20. /// Resolve the `--mirror` argument to the host-side mirror dir that is
  21. /// mounted at `/root/.cache/huggingface`. A bare `--mirror` (empty value)
  22. /// uses the default `~/.cache/hf-models`; `--mirror <path>` uses that path.
  23. /// `~` / `~/` expands to the SSH user's home (mirrors are per-node dirs).
  24. pub fn resolve_mirror_dir(config: &Config, arg: &str) -> PathBuf {
  25. let p = if arg.is_empty() { DEFAULT_MIRROR } else { arg };
  26. PathBuf::from(crate::config::expand_tilde(p, &config.ssh_user))
  27. }
  28. /// Resolve the `--mirror` flag for a serve into the mirror dir (if any) and
  29. /// the cache dir to mount at `/root/.cache/huggingface`: mirror when set,
  30. /// else the `--hf-cache` override (tilde-expanded per node), else the shared
  31. /// HF cache from config.
  32. pub fn resolve(
  33. config: &Config,
  34. mirror: Option<&str>,
  35. hf_cache: Option<&str>,
  36. ) -> (Option<PathBuf>, PathBuf) {
  37. let mirror = mirror.map(|m| resolve_mirror_dir(config, m));
  38. let cache_dir = mirror.clone().unwrap_or_else(|| match hf_cache {
  39. Some(p) => PathBuf::from(crate::config::expand_tilde(p, &config.ssh_user)),
  40. None => config.resolved_hf_cache_dir(),
  41. });
  42. (mirror, cache_dir)
  43. }
  44. /// All HF repos a recipe needs at serve time, deduped and sorted (stable
  45. /// order): the main model, every repo-based path ref in `extra_mounts`, and
  46. /// every composed-model source repo.
  47. pub fn repos_for_recipe(recipe: &Recipe) -> Vec<String> {
  48. let mut repos = vec![recipe.model.clone()];
  49. for m in &recipe.extra_mounts {
  50. if let Some(r) = &m.repo {
  51. repos.push(r.clone());
  52. }
  53. }
  54. if let Some(cm) = &recipe.composed_model {
  55. repos.extend(cm.sources.iter().map(|s| s.repo.clone()));
  56. }
  57. repos.sort();
  58. repos.dedup();
  59. repos
  60. }
  61. /// Remote command that ensures one repo's HF hub dir exists in the mirror,
  62. /// copying it from the shared cache when missing. Prints exactly one marker
  63. /// line: `MIRROR_PRESENT`, `MIRROR_COPIED`, or `MIRROR_MISSING_SOURCE`.
  64. /// A broken/partial mirror dir (`snapshots`/`refs/main` missing) is replaced.
  65. pub fn mirror_copy_command(source: &Path, mirror: &Path, repo: &str) -> String {
  66. let dir = model_cache_dir_name(repo);
  67. let src_dir = format!("{}/hub/{}", source.display(), dir);
  68. let dst_dir = format!("{}/hub/{}", mirror.display(), dir);
  69. let hub = format!("{}/hub", mirror.display());
  70. format!(
  71. "if test -d {d} && test -f {r}; then echo MIRROR_PRESENT; \
  72. elif test -d {s}; then rm -rf {d}; mkdir -p {hub}; cp -a {s} {hub}/. && echo MIRROR_COPIED; \
  73. else echo MIRROR_MISSING_SOURCE; fi",
  74. d = shq(&dst_dir),
  75. r = shq(&format!("{dst_dir}/refs/main")),
  76. s = shq(&src_dir),
  77. hub = shq(&hub),
  78. )
  79. }
  80. /// Per node (in parallel): ensure every repo the recipe needs exists in the
  81. /// mirror, copying missing ones from the shared cache. Aborts when a repo is
  82. /// absent from both locations (hint: `huggingface-cli download`). Dry-run
  83. /// only prints the intended copies — no SSH.
  84. pub async fn ensure_mirrors(
  85. config: &Config,
  86. nodes: &[&Node],
  87. recipe: &Recipe,
  88. source: &Path,
  89. mirror: &Path,
  90. dry: bool,
  91. ) -> Result<()> {
  92. let repos = repos_for_recipe(recipe);
  93. if dry {
  94. for node in nodes {
  95. for repo in &repos {
  96. info!(
  97. "dry-run: would mirror {} on {} from {}",
  98. repo,
  99. node.name,
  100. source.display()
  101. );
  102. }
  103. }
  104. return Ok(());
  105. }
  106. let mut handles = Vec::new();
  107. for node in nodes {
  108. let node = (*node).clone();
  109. let config = config.clone();
  110. let repos = repos.clone();
  111. let source = source.to_path_buf();
  112. let mirror = mirror.to_path_buf();
  113. handles.push(crate::locale::spawn_with_locale(async move {
  114. let mut missing = Vec::new();
  115. for repo in &repos {
  116. let cmd = mirror_copy_command(&source, &mirror, repo);
  117. let out = run_ssh_long_running(&config, &node, cmd, false).await?;
  118. match out.trim() {
  119. "MIRROR_PRESENT" => info!("{}: {} already mirrored", node.name, repo),
  120. "MIRROR_COPIED" => {
  121. info!("{}: copied {} into mirror {}", node.name, repo, mirror.display())
  122. }
  123. "MIRROR_MISSING_SOURCE" => missing.push(repo.clone()),
  124. other => warn!("{}: mirror status for {}: {:?}", node.name, repo, other),
  125. }
  126. }
  127. if !missing.is_empty() {
  128. bail!(
  129. "{}: model repos absent from shared HF cache: {} — run `huggingface-cli download <repo>` on that node first",
  130. node.name,
  131. missing.join(", ")
  132. );
  133. }
  134. Ok(())
  135. }));
  136. }
  137. for r in futures::future::join_all(handles).await {
  138. r??;
  139. }
  140. Ok(())
  141. }
  142. #[cfg(test)]
  143. mod tests {
  144. use super::*;
  145. use crate::config::{DeployTarget, DistributedBackend};
  146. use crate::pre_serve::{RecipeArgs, VenvSetup};
  147. use std::collections::HashMap;
  148. fn cfg() -> Config {
  149. Config {
  150. ssh_user: "u".into(),
  151. ssh_key_path: "/tmp/k".into(),
  152. clusters: vec![],
  153. nccl: HashMap::new(),
  154. hf_cache_dir: None,
  155. init_port: 25000,
  156. before_serve: HashMap::new(),
  157. exec_aliases: HashMap::new(),
  158. home_assistant: None,
  159. mikrotik: None,
  160. global_mods_dir: None,
  161. drop_caches: true,
  162. mount_aliases: HashMap::new(),
  163. recipe_sources: Vec::new(),
  164. recipe_repos: Vec::new(),
  165. docker_image_sources: Vec::new(),
  166. cluster_sources: Vec::new(),
  167. registry: None,
  168. }
  169. }
  170. fn trec() -> Recipe {
  171. Recipe {
  172. build_profile: None,
  173. parent_model: None,
  174. archived: false,
  175. engine: "vllm".into(),
  176. name: "T".into(),
  177. docker_image: "img".into(),
  178. model: "org/main".into(),
  179. port: 8000,
  180. required_nodes: Some(1),
  181. pipeline_parallel_size: Some(1),
  182. tensor_parallel_size: 1,
  183. args: RecipeArgs::List(vec![]),
  184. env: HashMap::new(),
  185. cluster: None,
  186. entrypoint: vec![],
  187. distributed_backend: Some(DistributedBackend::Mp),
  188. before_serve: vec![],
  189. after_stop: vec![],
  190. solo: None,
  191. deploy: DeployTarget::Docker,
  192. venv: None,
  193. venv_setup: VenvSetup::default(),
  194. venv_setup_script: None,
  195. mods: Default::default(),
  196. shm_size: String::new(),
  197. devices: vec![],
  198. memory_limit: None,
  199. cpus: None,
  200. sync_image: true,
  201. docker_image_dir: None,
  202. extra_mounts: vec![],
  203. extra_mounts_manifest: None,
  204. build: Default::default(),
  205. composed_model: None,
  206. staging: vec![],
  207. }
  208. }
  209. #[test]
  210. fn mirror_dir_default_tilde_and_explicit() {
  211. let c = cfg();
  212. // Bare `--mirror` -> default under the SSH user's home.
  213. assert_eq!(
  214. resolve_mirror_dir(&c, ""),
  215. PathBuf::from("/home/u/.cache/hf-models")
  216. );
  217. // `~/...` expands to the SSH user's home.
  218. assert_eq!(resolve_mirror_dir(&c, "~/m"), PathBuf::from("/home/u/m"));
  219. // Absolute paths pass through unchanged.
  220. assert_eq!(
  221. resolve_mirror_dir(&c, "/abs/path"),
  222. PathBuf::from("/abs/path")
  223. );
  224. // root user -> /root.
  225. let mut root = cfg();
  226. root.ssh_user = "root".into();
  227. assert_eq!(
  228. resolve_mirror_dir(&root, ""),
  229. PathBuf::from("/root/.cache/hf-models")
  230. );
  231. }
  232. #[test]
  233. fn resolve_returns_mirror_and_falls_back_to_shared() {
  234. let c = cfg();
  235. // No flag -> mount the shared HF cache, no mirror.
  236. let (mirror, cache) = resolve(&c, None, None);
  237. assert!(mirror.is_none());
  238. assert_eq!(cache, PathBuf::from("/home/u/.cache/huggingface"));
  239. // Bare `--mirror` -> default mirror becomes the mounted cache dir.
  240. let (mirror, cache) = resolve(&c, Some(""), None);
  241. assert_eq!(mirror, Some(PathBuf::from("/home/u/.cache/hf-models")));
  242. assert_eq!(cache, PathBuf::from("/home/u/.cache/hf-models"));
  243. // Explicit path.
  244. let (mirror, cache) = resolve(&c, Some("/abs/mirror"), None);
  245. assert_eq!(mirror, Some(PathBuf::from("/abs/mirror")));
  246. assert_eq!(cache, PathBuf::from("/abs/mirror"));
  247. // `--hf-cache` override (tilde expands to the node's SSH-user home).
  248. let (mirror, cache) = resolve(&c, None, Some("~/hf-local"));
  249. assert!(mirror.is_none());
  250. assert_eq!(cache, PathBuf::from("/home/u/hf-local"));
  251. // An absolute override passes through.
  252. let (_, cache) = resolve(&c, None, Some("/mnt/nas/ai/huggingface"));
  253. assert_eq!(cache, PathBuf::from("/mnt/nas/ai/huggingface"));
  254. // A mirror wins over the shared/override source (the flag conflict
  255. // is rejected upstream in serve()).
  256. let (_, cache) = resolve(&c, Some(""), Some("~/hf-local"));
  257. assert_eq!(cache, PathBuf::from("/home/u/.cache/hf-models"));
  258. }
  259. #[test]
  260. fn repos_gather_model_pathrefs_and_composed() {
  261. let mut r = trec();
  262. r.extra_mounts = vec![
  263. crate::config::ExtraMount {
  264. repo: Some("org/draft".into()),
  265. container: "draft".into(),
  266. ..Default::default()
  267. },
  268. crate::config::ExtraMount {
  269. host: Some("/x".into()),
  270. container: "/y".into(),
  271. ..Default::default()
  272. },
  273. ];
  274. r.composed_model = Some(crate::config::ComposedModel {
  275. mount: "/root/model".into(),
  276. sources: vec![
  277. crate::config::ComposedSource {
  278. repo: "org/base".into(),
  279. glob: None,
  280. exclude: None,
  281. },
  282. crate::config::ComposedSource {
  283. repo: "org/main".into(), // duplicate of model — dedup
  284. glob: None,
  285. exclude: None,
  286. },
  287. ],
  288. files: vec![],
  289. local_files: vec![],
  290. });
  291. assert_eq!(
  292. repos_for_recipe(&r),
  293. vec!["org/base", "org/draft", "org/main"]
  294. );
  295. }
  296. #[test]
  297. fn copy_command_branches_and_quoting() {
  298. let c = mirror_copy_command(
  299. Path::new("/home/u/.cache/huggingface"),
  300. Path::new("/home/u/.cache/hf-models"),
  301. "org/main",
  302. );
  303. // All three markers present, in order.
  304. assert!(c.contains("MIRROR_PRESENT"));
  305. assert!(c.contains("MIRROR_COPIED"));
  306. assert!(c.contains("MIRROR_MISSING_SOURCE"));
  307. // Hub layout + model dir name.
  308. assert!(c.contains("models--org--main"));
  309. assert!(c.contains("/home/u/.cache/huggingface/hub/"));
  310. assert!(c.contains("/home/u/.cache/hf-models/hub/"));
  311. // refs/main presence gate.
  312. assert!(c.contains("refs/main"));
  313. // Paths are shell-quoted.
  314. assert!(c.contains("'/home/u/.cache/hf-models/hub/models--org--main'"));
  315. }
  316. }