mirror.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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. docker_image_sources: Vec::new(),
  165. cluster_sources: Vec::new(),
  166. registry: None,
  167. }
  168. }
  169. fn trec() -> Recipe {
  170. Recipe {
  171. build_profile: None,
  172. parent_model: None,
  173. archived: false,
  174. engine: "vllm".into(),
  175. name: "T".into(),
  176. docker_image: "img".into(),
  177. model: "org/main".into(),
  178. port: 8000,
  179. required_nodes: Some(1),
  180. pipeline_parallel_size: Some(1),
  181. tensor_parallel_size: 1,
  182. args: RecipeArgs::List(vec![]),
  183. env: HashMap::new(),
  184. cluster: None,
  185. entrypoint: vec![],
  186. distributed_backend: Some(DistributedBackend::Mp),
  187. before_serve: vec![],
  188. after_stop: vec![],
  189. solo: None,
  190. deploy: DeployTarget::Docker,
  191. venv: None,
  192. venv_setup: VenvSetup::default(),
  193. venv_setup_script: None,
  194. mods: Default::default(),
  195. shm_size: String::new(),
  196. sync_image: true,
  197. docker_image_dir: None,
  198. extra_mounts: vec![],
  199. build: Default::default(),
  200. composed_model: None,
  201. }
  202. }
  203. #[test]
  204. fn mirror_dir_default_tilde_and_explicit() {
  205. let c = cfg();
  206. // Bare `--mirror` -> default under the SSH user's home.
  207. assert_eq!(
  208. resolve_mirror_dir(&c, ""),
  209. PathBuf::from("/home/u/.cache/hf-models")
  210. );
  211. // `~/...` expands to the SSH user's home.
  212. assert_eq!(resolve_mirror_dir(&c, "~/m"), PathBuf::from("/home/u/m"));
  213. // Absolute paths pass through unchanged.
  214. assert_eq!(
  215. resolve_mirror_dir(&c, "/abs/path"),
  216. PathBuf::from("/abs/path")
  217. );
  218. // root user -> /root.
  219. let mut root = cfg();
  220. root.ssh_user = "root".into();
  221. assert_eq!(
  222. resolve_mirror_dir(&root, ""),
  223. PathBuf::from("/root/.cache/hf-models")
  224. );
  225. }
  226. #[test]
  227. fn resolve_returns_mirror_and_falls_back_to_shared() {
  228. let c = cfg();
  229. // No flag -> mount the shared HF cache, no mirror.
  230. let (mirror, cache) = resolve(&c, None, None);
  231. assert!(mirror.is_none());
  232. assert_eq!(cache, PathBuf::from("/home/u/.cache/huggingface"));
  233. // Bare `--mirror` -> default mirror becomes the mounted cache dir.
  234. let (mirror, cache) = resolve(&c, Some(""), None);
  235. assert_eq!(mirror, Some(PathBuf::from("/home/u/.cache/hf-models")));
  236. assert_eq!(cache, PathBuf::from("/home/u/.cache/hf-models"));
  237. // Explicit path.
  238. let (mirror, cache) = resolve(&c, Some("/abs/mirror"), None);
  239. assert_eq!(mirror, Some(PathBuf::from("/abs/mirror")));
  240. assert_eq!(cache, PathBuf::from("/abs/mirror"));
  241. // `--hf-cache` override (tilde expands to the node's SSH-user home).
  242. let (mirror, cache) = resolve(&c, None, Some("~/hf-local"));
  243. assert!(mirror.is_none());
  244. assert_eq!(cache, PathBuf::from("/home/u/hf-local"));
  245. // An absolute override passes through.
  246. let (_, cache) = resolve(&c, None, Some("/mnt/nas/ai/huggingface"));
  247. assert_eq!(cache, PathBuf::from("/mnt/nas/ai/huggingface"));
  248. // A mirror wins over the shared/override source (the flag conflict
  249. // is rejected upstream in serve()).
  250. let (_, cache) = resolve(&c, Some(""), Some("~/hf-local"));
  251. assert_eq!(cache, PathBuf::from("/home/u/.cache/hf-models"));
  252. }
  253. #[test]
  254. fn repos_gather_model_pathrefs_and_composed() {
  255. let mut r = trec();
  256. r.extra_mounts = vec![
  257. crate::config::ExtraMount {
  258. repo: Some("org/draft".into()),
  259. container: "draft".into(),
  260. ..Default::default()
  261. },
  262. crate::config::ExtraMount {
  263. host: Some("/x".into()),
  264. container: "/y".into(),
  265. ..Default::default()
  266. },
  267. ];
  268. r.composed_model = Some(crate::config::ComposedModel {
  269. mount: "/root/model".into(),
  270. sources: vec![
  271. crate::config::ComposedSource {
  272. repo: "org/base".into(),
  273. glob: None,
  274. exclude: None,
  275. },
  276. crate::config::ComposedSource {
  277. repo: "org/main".into(), // duplicate of model — dedup
  278. glob: None,
  279. exclude: None,
  280. },
  281. ],
  282. files: vec![],
  283. local_files: vec![],
  284. });
  285. assert_eq!(
  286. repos_for_recipe(&r),
  287. vec!["org/base", "org/draft", "org/main"]
  288. );
  289. }
  290. #[test]
  291. fn copy_command_branches_and_quoting() {
  292. let c = mirror_copy_command(
  293. Path::new("/home/u/.cache/huggingface"),
  294. Path::new("/home/u/.cache/hf-models"),
  295. "org/main",
  296. );
  297. // All three markers present, in order.
  298. assert!(c.contains("MIRROR_PRESENT"));
  299. assert!(c.contains("MIRROR_COPIED"));
  300. assert!(c.contains("MIRROR_MISSING_SOURCE"));
  301. // Hub layout + model dir name.
  302. assert!(c.contains("models--org--main"));
  303. assert!(c.contains("/home/u/.cache/huggingface/hub/"));
  304. assert!(c.contains("/home/u/.cache/hf-models/hub/"));
  305. // refs/main presence gate.
  306. assert!(c.contains("refs/main"));
  307. // Paths are shell-quoted.
  308. assert!(c.contains("'/home/u/.cache/hf-models/hub/models--org--main'"));
  309. }
  310. }