mod.rs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. pub mod build;
  2. pub mod command;
  3. pub mod compose;
  4. pub mod deploy;
  5. pub mod health;
  6. pub(crate) mod launch_node;
  7. pub mod logs;
  8. pub mod mirror;
  9. pub mod mods;
  10. pub(crate) mod path_ref;
  11. pub mod push;
  12. pub use logs::show_logs;
  13. pub mod build_dir;
  14. pub mod stale;
  15. pub mod stop;
  16. pub mod sync;
  17. pub mod tail;
  18. use crate::{
  19. config::{Cluster, Config, NcclConfig, Node, Recipe, load_recipe},
  20. doctor::try_repaired_image,
  21. ssh::run_ssh,
  22. };
  23. use anyhow::{Context, Result};
  24. use tokio::time::{Duration, sleep};
  25. use tracing::{info, warn};
  26. /// Options for the serve operation.
  27. #[derive(Clone, Debug)]
  28. pub struct ServeOpts {
  29. pub keep: bool,
  30. pub skip_before_serve: bool,
  31. pub dry_run: bool,
  32. /// Skip the engine-readiness wait after launch (`--no-wait`).
  33. pub no_wait_ready: bool,
  34. pub auto_power_on: bool,
  35. pub sync_image: bool,
  36. pub sync_image_source: Option<String>,
  37. /// Skip the post-build registry push even when `[registry]` is set.
  38. pub no_push: bool,
  39. pub build_node: Option<String>,
  40. pub force_build: bool,
  41. /// Ignore occupied nodes during cluster selection (`--force`).
  42. pub force: bool,
  43. /// CLI override for recipe `solo`: `Some(true)` (--solo), `Some(false)`
  44. /// (--no-solo), `None` (unset → use recipe `solo` field).
  45. pub solo: Option<bool>,
  46. /// Mirror the model into a per-node local folder and mount that instead
  47. /// of the shared HF cache. `Some("")` = bare `--mirror` → default
  48. /// `~/.cache/hf-models`; `Some(path)` = that exact path; `None` = off.
  49. pub mirror: Option<String>,
  50. /// CLI override for the host-side HF cache dir (`--hf-cache`); wins over
  51. /// the config `hf_cache_dir` default. Mutually exclusive with `mirror`
  52. /// (both pick the HF mount source).
  53. pub hf_cache: Option<String>,
  54. /// CLI override for recipe scaling: `Some("")` = bare `--fit` → scale to
  55. /// the whole selected cluster; `Some("N")` = scale to N nodes;
  56. /// `None` = unset. See `docker::serve`.
  57. pub fit: Option<String>,
  58. pub clear: bool,
  59. }
  60. /// Shell-quote for remote `sh -c` (single-quote wrapper, escapes quotes).
  61. /// Re-exported so docker submodules can `use super::shq;`; the impl lives
  62. /// in [`crate::ssh`].
  63. pub(crate) use crate::ssh::shq;
  64. pub async fn serve(
  65. config: &Config,
  66. recipe_name: &str,
  67. cluster_name: &str,
  68. node_name: Option<&str>,
  69. opts: ServeOpts,
  70. ) -> Result<Cluster> {
  71. let mut recipe = load_recipe(recipe_name, &config.recipe_sources).await?;
  72. let rn = recipe.name.clone();
  73. let re = recipe.engine.clone();
  74. // CLI `--cluster auto` (the default) falls back to the recipe's `cluster`
  75. // field — same behavior as the tmux path.
  76. let cluster_name = if cluster_name == "auto" {
  77. recipe.cluster.clone().unwrap_or_else(|| "auto".to_string())
  78. } else {
  79. cluster_name.to_string()
  80. };
  81. info!(
  82. "{}",
  83. fluent_i18n::t!("recipe-starting", { "name" => rn, "engine" => re, "backend" => recipe.distributed_backend.map_or("none".to_string(), |b| b.to_string()) })
  84. );
  85. // `--fit`: override required_nodes + tensor_parallel_size so TP tracks
  86. // the node count (the recipe's `required_nodes == Some(tp_size)` path in
  87. // `start_cluster` makes tp_size follow nnodes). `pipeline_parallel_size`
  88. // is left untouched. Bare `--fit` (whole cluster) is handled after
  89. // cluster selection, when nnodes is known; for now just clear the cap so
  90. // selection/trim keeps every cluster node.
  91. //
  92. // Mutually exclusive with `--solo` (solo = 1 node, no dist flags).
  93. let fit: Option<u32> = match opts.fit.as_deref() {
  94. None => None,
  95. Some("") => Some(0),
  96. Some(s) => Some(
  97. s.parse()
  98. .context(format!("--fit expects a node count, got '{}'", s))?,
  99. ),
  100. };
  101. if fit.is_some() && opts.solo.is_some() {
  102. anyhow::bail!("--fit and --solo are mutually exclusive");
  103. }
  104. match fit {
  105. Some(0) => recipe.required_nodes = None,
  106. Some(n) => {
  107. recipe.required_nodes = Some(n);
  108. recipe.tensor_parallel_size = n;
  109. info!("--fit: scaling to {} nodes (tp = {})", n, n);
  110. }
  111. None => {}
  112. }
  113. // Prepare cluster: probe, auto-power-on if requested, select.
  114. use crate::ends::{SshNodePinger, SshProber, ha_controller_from_config};
  115. use crate::orchestrate::{BootTiming, PrepareRequest, prepare_cluster};
  116. let power_ctrl = opts
  117. .auto_power_on
  118. .then(|| ha_controller_from_config(config))
  119. .flatten();
  120. // Effective solo: CLI flag overrides recipe field. When solo and no
  121. // explicit --node, launch on exactly one node (the first free node in
  122. // the smallest cluster that has one) with no distributed flags. When
  123. // solo and --node is given, the explicit node wins but we still run
  124. // single-node (start_cluster sees one node → no distributed flags).
  125. let effective_solo = opts.solo.unwrap_or(recipe.solo.unwrap_or(false));
  126. // For solo, ignore the recipe's required_nodes — we always want one node.
  127. // Build a shallow clone with required_nodes overridden so cluster
  128. // selection picks the smallest cluster with >= 1 free node.
  129. let recipe_for_select = if effective_solo {
  130. let mut r = recipe.clone();
  131. r.required_nodes = Some(1);
  132. r
  133. } else {
  134. recipe.clone()
  135. };
  136. let (mut cluster, nccl) = if opts.dry_run {
  137. // Dry-run: skip all SSH probing/power/pinging so it works even when the
  138. // cluster is off or unreachable (treat every configured node as free).
  139. crate::orchestrate::prepare_cluster_dry_run(
  140. config,
  141. &recipe_for_select,
  142. &cluster_name,
  143. node_name,
  144. )?
  145. } else {
  146. prepare_cluster(
  147. config,
  148. &recipe_for_select,
  149. &cluster_name,
  150. node_name,
  151. &PrepareRequest {
  152. prober: &SshProber,
  153. power: power_ctrl.as_ref(),
  154. pinger: &SshNodePinger,
  155. auto_power_on: opts.auto_power_on,
  156. boot_timing: BootTiming::default(),
  157. force_occupied: opts.force,
  158. },
  159. )
  160. .await?
  161. };
  162. if effective_solo && node_name.is_none() && !cluster.nodes.is_empty() {
  163. // Solo: keep only the first node. `prepare_cluster` already selected
  164. // the smallest cluster with >= required_nodes free (required_nodes is
  165. // ignored for solo — we always want one node), so the first free node
  166. // is the solo target.
  167. let solo_node = cluster.nodes[0].clone();
  168. info!(
  169. "{}",
  170. fluent_i18n::t!("solo-node-selected", { "name" => &solo_node.name, "cluster" => &cluster.name })
  171. );
  172. cluster.nodes = vec![solo_node];
  173. }
  174. // Bare `--fit` (whole cluster): now that `cluster.nodes` is resolved, fix
  175. // tp to the node count so `start_cluster`'s `tp_size` auto-track fires
  176. // (required_nodes == Some(tp_size) → tp_size = nnodes). required_nodes is
  177. // already None (no trim), so every node is kept.
  178. if fit == Some(0) {
  179. let nn = cluster.nodes.len() as u32;
  180. recipe.required_nodes = Some(nn);
  181. recipe.tensor_parallel_size = nn;
  182. info!(
  183. "--fit: scaling to whole cluster '{}' ({} nodes, tp = {})",
  184. cluster.name, nn, nn
  185. );
  186. }
  187. crate::before_serve::run(
  188. config,
  189. &cluster,
  190. &recipe,
  191. opts.skip_before_serve,
  192. opts.dry_run,
  193. )
  194. .await?;
  195. // `built_on` records the node that (re)built the image this serve: after
  196. // a fresh build that image is authoritative — the sync step below must
  197. // pull FROM it, never let a peer's older image (by creation timestamp)
  198. // overwrite the new build.
  199. let mut built_on: Option<String> = None;
  200. if let Some(ref build_node_name) = opts.build_node {
  201. if !opts.dry_run && !cluster.nodes.iter().any(|n| n.name == *build_node_name) {
  202. anyhow::bail!("build-node '{}' not in cluster", build_node_name);
  203. }
  204. if !opts.dry_run {
  205. let did_build = crate::docker::build::handle_build(
  206. config,
  207. &cluster,
  208. recipe_name,
  209. &recipe,
  210. &opts,
  211. build_node_name,
  212. )
  213. .await?;
  214. if did_build {
  215. built_on = Some(build_node_name.clone());
  216. }
  217. }
  218. }
  219. let mods_remote_path = mods::upload_mods_to_cluster(
  220. config,
  221. &cluster,
  222. &recipe,
  223. recipe_name,
  224. node_name,
  225. opts.dry_run,
  226. )
  227. .await?;
  228. let image = try_repaired_image(config, &cluster, &recipe).await?;
  229. if image != recipe.docker_image {
  230. recipe.docker_image = image.clone();
  231. }
  232. // Surface image staleness (bumped Dockerfile vs image checksum label)
  233. // when not building. Non-blocking; runs on dry-run too (read-only inspect).
  234. if opts.build_node.is_none() {
  235. stale::warn_if_stale(config, &cluster, &recipe).await?;
  236. }
  237. // Sync Docker image across nodes if needed.
  238. let should_sync =
  239. opts.sync_image && recipe.sync_image && !opts.dry_run && cluster.nodes.len() > 1;
  240. if should_sync {
  241. // After a fresh build the build node holds the authoritative image:
  242. // force it as the sync source so a peer's older image (by creation
  243. // timestamp, or from a no-op `docker load` round-trip) can't overwrite
  244. // the new build. The "most recently created" heuristic only applies
  245. // when nothing was built here and no explicit --sync-image-source was
  246. // given — i.e. the nodes merely disagree and we pick the newest.
  247. let source = built_on.as_deref().or(opts.sync_image_source.as_deref());
  248. self::sync::sync_image_to_nodes(config, &cluster, &recipe.docker_image, source).await?;
  249. }
  250. // Drop page cache on all nodes right before starting containers.
  251. // Must run after image sync (docker load fills page cache).
  252. if config.drop_caches && !opts.dry_run {
  253. info!("Dropping page cache on all nodes...");
  254. for node in &cluster.nodes {
  255. let cmd = "echo 3 | sudo tee /proc/sys/vm/drop_caches".to_string();
  256. if let Err(e) = run_ssh(config, node, cmd, false).await {
  257. warn!("drop_caches on {} failed: {}", node.name, e);
  258. }
  259. }
  260. }
  261. // `--mirror`: mount a per-node local mirror of the HF cache instead of
  262. // the shared one (first serve copies the model per node; later serves
  263. // load it locally). See mirror.rs.
  264. if opts.mirror.is_some() && opts.hf_cache.is_some() {
  265. anyhow::bail!(
  266. "--hf-cache and --mirror are mutually exclusive (both pick the HF mount source)"
  267. );
  268. }
  269. let (mirror, cache_dir) =
  270. mirror::resolve(config, opts.mirror.as_deref(), opts.hf_cache.as_deref());
  271. if let Some(ref m) = mirror {
  272. info!(
  273. "serving from local HF mirror {} (models copied per node on first use)",
  274. m.display()
  275. );
  276. }
  277. start_cluster(
  278. config,
  279. &cluster,
  280. nccl,
  281. recipe_name,
  282. &recipe,
  283. StartClusterOpts {
  284. cache_dir,
  285. mirror,
  286. keep: opts.keep,
  287. node_name: node_name.map(String::from),
  288. mods_remote_path,
  289. dry_run: opts.dry_run,
  290. clear: opts.clear,
  291. },
  292. )
  293. .await?;
  294. // Launch ledger + readiness: the run is recorded at launch time; then
  295. // the engine must actually come up (bench-style /v1/models probe). A
  296. // run that never reaches readiness is closed with its uptime and the
  297. // serve fails loudly instead of returning a half-started serve.
  298. if !opts.dry_run {
  299. crate::runlock::mark_start(recipe_name, &config.recipe_sources, Some(&cluster.name))?;
  300. if !opts.no_wait_ready
  301. && let Err(e) = crate::readiness::wait_engine(
  302. &cluster,
  303. recipe.port,
  304. crate::readiness::SERVE_WAIT_SECS,
  305. )
  306. .await
  307. {
  308. let _ = crate::runlock::mark_stop(recipe_name, &config.recipe_sources);
  309. return Err(e);
  310. }
  311. }
  312. Ok(cluster)
  313. }
  314. pub async fn resolve_cluster_by_container_label<'c>(
  315. config: &'c Config,
  316. recipe_name: &str,
  317. ) -> Result<&'c Cluster> {
  318. // Scan all nodes for containers matching this recipe.
  319. for node in config.clusters.iter().flat_map(|c| &c.nodes) {
  320. let cmd = format!(
  321. "docker ps --filter {} \
  322. --format '{{{{.Label \"sm121.cluster\"}}}}' | head -1",
  323. shq(&format!("label=sm121.recipe={}", recipe_name))
  324. );
  325. if let Ok(output) = run_ssh(config, node, cmd, false).await {
  326. let cluster_label = output.trim();
  327. if !cluster_label.is_empty() {
  328. // Found a container — match its cluster label against config.
  329. return config
  330. .clusters
  331. .iter()
  332. .find(|c| c.name == cluster_label)
  333. .context(format!(
  334. "Container label says cluster '{}', but no such cluster in config",
  335. cluster_label
  336. ));
  337. }
  338. }
  339. }
  340. anyhow::bail!("No running containers found for recipe '{}'", recipe_name);
  341. }
  342. /// Options for [start_cluster].
  343. struct StartClusterOpts {
  344. cache_dir: std::path::PathBuf,
  345. /// When set, the per-node mirror dir mounted at /root/.cache/huggingface
  346. /// (repos are copied into it from the shared cache before launch).
  347. mirror: Option<std::path::PathBuf>,
  348. keep: bool,
  349. node_name: Option<String>,
  350. mods_remote_path: String,
  351. dry_run: bool,
  352. clear: bool,
  353. }
  354. async fn start_cluster(
  355. config: &Config,
  356. cluster: &Cluster,
  357. nccl: Option<NcclConfig>,
  358. recipe_name: &str,
  359. recipe: &Recipe,
  360. opts: StartClusterOpts,
  361. ) -> Result<()> {
  362. let nodes: Vec<&Node> = if let Some(ref nn) = opts.node_name {
  363. let v: Vec<&Node> = cluster.nodes.iter().filter(|n| n.name == *nn).collect();
  364. if v.is_empty() {
  365. anyhow::bail!("Node '{}' not found in cluster '{}'", nn, cluster.name);
  366. }
  367. v
  368. } else {
  369. // Trim to `required_nodes` when set: a recipe that needs N nodes
  370. // must launch on exactly N nodes, not on every free node in the
  371. // cluster. Previously a `required_nodes = 1` recipe on a 4-node
  372. // cluster launched 4 containers, each told `--nnodes 4 --tp 4` —
  373. // a broken rendezvous that never coordinated. `prepare_cluster`
  374. // guarantees >= required_nodes are free, so taking the first N is
  375. // safe and deterministic.
  376. let all = cluster.nodes.iter().collect::<Vec<&Node>>();
  377. match recipe.required_nodes {
  378. Some(n) if (n as usize) < all.len() => all.into_iter().take(n as usize).collect(),
  379. _ => all,
  380. }
  381. };
  382. let nnodes = nodes.len() as u32;
  383. // `--mirror`: ensure every repo the recipe needs exists in the per-node
  384. // mirror before launch (copies from the shared HF cache when missing).
  385. if let Some(ref mirror) = opts.mirror {
  386. mirror::ensure_mirrors(
  387. config,
  388. &nodes,
  389. recipe,
  390. &config.resolved_hf_cache_dir(),
  391. mirror,
  392. opts.dry_run,
  393. )
  394. .await?;
  395. }
  396. let pp_size = if recipe.pipeline_parallel_size.unwrap_or(0) == 0
  397. || recipe.required_nodes == recipe.pipeline_parallel_size
  398. {
  399. nnodes
  400. } else {
  401. recipe.pipeline_parallel_size.unwrap()
  402. };
  403. let tp_size = if recipe.required_nodes == Some(recipe.tensor_parallel_size) {
  404. nnodes
  405. } else {
  406. recipe.tensor_parallel_size
  407. };
  408. // Resolve static mounts once (alias/host + tilde). Model path references
  409. // (`repo`+`subfolder`) are kept as specs and resolved per node inside the
  410. // spawn loop, since the HF snapshot hash can differ across nodes.
  411. let resolved = command::resolve_extra_mounts(config, recipe)?;
  412. command::clear_extra_mounts(config, &nodes, &resolved, opts.clear, opts.dry_run).await?;
  413. let mut handles = Vec::new();
  414. for node in &nodes {
  415. let node = (*node).clone();
  416. let config = config.clone();
  417. let rn = recipe_name.to_string();
  418. let dry = opts.dry_run;
  419. let engine = recipe.engine.clone();
  420. let is_head = node.rank == 0;
  421. let cleanup = format!(
  422. "docker rm -f {} 2>/dev/null; true",
  423. shq(&format!("sm121_{}_{}", recipe_name, node.rank))
  424. );
  425. let dm_ctx = command::DockerCommandCtx {
  426. cluster: cluster.clone(),
  427. nccl: nccl.clone(),
  428. recipe: recipe.clone(),
  429. recipe_name: rn.clone(),
  430. pp_size,
  431. tp_size,
  432. nnodes,
  433. cache_dir: opts.cache_dir.to_string_lossy().into_owned(),
  434. keep: opts.keep,
  435. init_port: config.init_port,
  436. mods_remote_path: opts.mods_remote_path.clone(),
  437. extra_mounts: resolved.static_mounts.clone(),
  438. };
  439. let hf_cache = opts.cache_dir.clone();
  440. let path_refs = resolved.path_refs.clone();
  441. handles.push(crate::locale::spawn_with_locale(async move {
  442. launch_node::launch_node(
  443. &config,
  444. launch_node::LaunchNodeCtx {
  445. node: &node,
  446. rn: &rn,
  447. dry,
  448. engine: &engine,
  449. is_head,
  450. cleanup,
  451. dm_ctx: &dm_ctx,
  452. hf_cache: &hf_cache,
  453. path_refs: &path_refs,
  454. },
  455. )
  456. .await
  457. }));
  458. }
  459. // NOTE: all node launches are awaited (join_all) before reporting — a
  460. // failure on one node does not cancel the others, so a partial cluster
  461. // may be left running. Intentional: reporting every node's result is
  462. // more useful than aborting mid-launch and leaving the operator to
  463. // guess which nodes actually started.
  464. for result in futures::future::join_all(handles).await {
  465. match result {
  466. Ok((name, rank, engine, Ok(_))) => info!(
  467. "{}",
  468. fluent_i18n::t!("engine-started", { "engine" => engine.to_uppercase(), "name" => &name, "rank" => rank })
  469. ),
  470. Ok((name, rank, engine, Err(e))) => anyhow::bail!(
  471. "{}",
  472. fluent_i18n::t!("engine-start-failed", { "engine" => engine.to_uppercase(), "name" => &name, "rank" => rank, "error" => e.to_string() })
  473. ),
  474. Err(e) => anyhow::bail!("Task join error: {}", e),
  475. }
  476. }
  477. if !opts.dry_run {
  478. sleep(Duration::from_secs(10)).await;
  479. info!(
  480. "{}",
  481. fluent_i18n::t!("cluster-started", { "ip" => cluster.effective_master_ip(), "port" => recipe.port })
  482. );
  483. }
  484. Ok(())
  485. }
  486. #[cfg(test)]
  487. mod tests;