mod.rs 19 KB

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