mod.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. pub mod build;
  2. pub mod deploy;
  3. pub mod health;
  4. pub mod logs;
  5. pub mod mods;
  6. pub use logs::show_logs;
  7. pub mod sync;
  8. pub mod tail;
  9. use crate::{
  10. config::{Cluster, Config, NcclConfig, Node, Recipe, load_recipe},
  11. doctor::try_repaired_image,
  12. ssh::{run_ssh, run_ssh_with_retry},
  13. };
  14. use anyhow::{Context, Result};
  15. use std::borrow::Cow;
  16. use tokio::time::{Duration, sleep};
  17. use tracing::{info, warn};
  18. /// Options for the serve operation.
  19. #[derive(Clone, Debug)]
  20. pub struct ServeOpts {
  21. pub keep: bool,
  22. pub skip_before_serve: bool,
  23. pub dry_run: bool,
  24. pub auto_power_on: bool,
  25. pub sync_image: bool,
  26. pub sync_image_source: Option<String>,
  27. pub build_node: Option<String>,
  28. }
  29. pub(crate) fn shq(s: &str) -> String {
  30. let mut out = String::with_capacity(s.len() + 2);
  31. out.push('\'');
  32. for c in s.chars() {
  33. if c == '\'' {
  34. out.push_str(r"'\''");
  35. } else {
  36. out.push(c);
  37. }
  38. }
  39. out.push('\'');
  40. out
  41. }
  42. pub async fn serve(
  43. config: &Config,
  44. recipe_name: &str,
  45. cluster_name: &str,
  46. node_name: Option<&str>,
  47. opts: ServeOpts,
  48. ) -> Result<Cluster> {
  49. let recipe = load_recipe(recipe_name).await?;
  50. let rn = recipe.name.clone();
  51. let re = recipe.engine.clone();
  52. info!(
  53. "{}",
  54. fluent_i18n::t!("recipe-starting", { "name" => rn, "engine" => re, "backend" => recipe.distributed_backend.map_or("none".to_string(), |b| b.to_string()) })
  55. );
  56. // Prepare cluster: probe, auto-power-on if requested, select.
  57. use crate::ends::{SshNodePinger, SshProber, ha_controller_from_config};
  58. use crate::orchestrate::prepare_cluster;
  59. let power_ctrl = if opts.auto_power_on {
  60. ha_controller_from_config(config)
  61. } else {
  62. None
  63. };
  64. let (cluster, nccl) = prepare_cluster(
  65. config,
  66. &recipe,
  67. cluster_name,
  68. node_name,
  69. opts.auto_power_on,
  70. &SshProber,
  71. power_ctrl.as_ref(),
  72. &SshNodePinger,
  73. )
  74. .await?;
  75. crate::before_serve::run(
  76. config,
  77. &cluster,
  78. &recipe,
  79. opts.skip_before_serve,
  80. opts.dry_run,
  81. )
  82. .await?;
  83. use self::sync::sync_image_to_nodes;
  84. // Build image on specified node if not found on any cluster node.
  85. if let Some(ref build_node_name) = opts.build_node {
  86. if !opts.dry_run && !cluster.nodes.iter().any(|n| n.name == *build_node_name) {
  87. anyhow::bail!("build-node '{}' not in cluster", build_node_name);
  88. }
  89. if !opts.dry_run {
  90. let image_dir = recipe
  91. .docker_image_dir
  92. .clone()
  93. .unwrap_or_else(|| recipe_name.to_string());
  94. let local_dir = std::path::Path::new("docker-images").join(&image_dir);
  95. if !local_dir.join("build.sh").exists() {
  96. anyhow::bail!(
  97. "No build system at '{}' (missing build.sh)",
  98. local_dir.display()
  99. );
  100. }
  101. let build_node = cluster
  102. .nodes
  103. .iter()
  104. .find(|n| n.name == *build_node_name)
  105. .expect("build-node already validated");
  106. // Check image presence on the build node.
  107. let check = format!(
  108. "docker image ls --format '{{{{.ID}}}}' {} | head -1",
  109. recipe.docker_image
  110. );
  111. let present = run_ssh(config, build_node, check, false).await?;
  112. if present.trim().is_empty() {
  113. crate::docker::build::build_image_on_node(
  114. config,
  115. build_node,
  116. local_dir.as_path(),
  117. recipe_name,
  118. &recipe.docker_image,
  119. )
  120. .await?;
  121. } else {
  122. info!(
  123. "Image '{}' already present on '{}'",
  124. recipe.docker_image, build_node.name
  125. );
  126. }
  127. }
  128. }
  129. let mods_remote_path = mods::upload_mods_to_cluster(
  130. config,
  131. &cluster,
  132. &recipe,
  133. recipe_name,
  134. node_name,
  135. opts.dry_run,
  136. )
  137. .await?;
  138. let image = try_repaired_image(config, &cluster, &recipe).await?;
  139. let mut recipe = recipe;
  140. if image != recipe.docker_image {
  141. recipe.docker_image = image.clone();
  142. }
  143. // Sync Docker image across nodes if needed.
  144. let should_sync =
  145. opts.sync_image && recipe.sync_image && !opts.dry_run && cluster.nodes.len() > 1;
  146. if should_sync {
  147. sync_image_to_nodes(
  148. config,
  149. &cluster,
  150. &recipe.docker_image,
  151. opts.sync_image_source.as_deref(),
  152. )
  153. .await?;
  154. }
  155. // Drop page cache on all nodes right before starting containers.
  156. // Must run after image sync (docker load fills page cache).
  157. if config.drop_caches && !opts.dry_run {
  158. info!("Dropping page cache on all nodes...");
  159. for node in &cluster.nodes {
  160. let cmd = "echo 3 | sudo tee /proc/sys/vm/drop_caches".to_string();
  161. if let Err(e) = run_ssh(config, node, cmd, false).await {
  162. warn!("drop_caches on {} failed: {}", node.name, e);
  163. }
  164. }
  165. }
  166. let cache_dir = config.resolved_hf_cache_dir();
  167. start_cluster(
  168. config,
  169. &cluster,
  170. &nccl,
  171. recipe_name,
  172. &recipe,
  173. StartClusterOpts {
  174. cache_dir,
  175. keep: opts.keep,
  176. node_name: node_name.map(String::from),
  177. mods_remote_path,
  178. dry_run: opts.dry_run,
  179. },
  180. )
  181. .await?;
  182. Ok(cluster)
  183. }
  184. pub async fn stop_cluster(
  185. config: &Config,
  186. cluster: &Cluster,
  187. recipe_name: &str,
  188. nodes: &[String],
  189. dry_run: bool,
  190. ) -> Result<()> {
  191. info!(
  192. "{}",
  193. fluent_i18n::t!("cluster-stopping", { "name" => &cluster.name })
  194. );
  195. let nodes: Vec<&Node> = if nodes.is_empty() {
  196. cluster.nodes.iter().collect()
  197. } else {
  198. let mut found_nodes: Vec<&Node> = Vec::new();
  199. let mut not_found: Vec<String> = Vec::new();
  200. for nn in nodes {
  201. if let Some(node) = cluster.nodes.iter().find(|n| n.name == *nn) {
  202. found_nodes.push(node);
  203. } else {
  204. not_found.push(nn.clone());
  205. }
  206. }
  207. if !not_found.is_empty() {
  208. let names = not_found.join(", ");
  209. anyhow::bail!(
  210. fluent_i18n::t!("exec-node-not-found", { "name" => &names, "cluster" => &cluster.name })
  211. );
  212. }
  213. found_nodes
  214. };
  215. for node in &nodes {
  216. run_ssh(
  217. config,
  218. node,
  219. format!(
  220. "docker ps -q --filter \"label=sm121.recipe={}\" --filter \"label=sm121.cluster={}\" | xargs -r docker stop",
  221. recipe_name, cluster.name
  222. ),
  223. dry_run,
  224. )
  225. .await?;
  226. }
  227. info!("{}", fluent_i18n::t!("cluster-all-stopped"));
  228. Ok(())
  229. }
  230. pub async fn resolve_cluster_by_container_label<'c>(
  231. config: &'c Config,
  232. recipe_name: &str,
  233. ) -> Result<&'c Cluster> {
  234. // Scan all nodes for containers matching this recipe.
  235. for node in config.clusters.iter().flat_map(|c| &c.nodes) {
  236. let cmd = format!(
  237. "docker ps --filter \"label=sm121.recipe={}\" \
  238. --format '{{{{.Label \"sm121.cluster\"}}}}' | head -1",
  239. recipe_name
  240. );
  241. if let Ok(output) = run_ssh(config, node, cmd, false).await {
  242. let cluster_label = output.trim();
  243. if !cluster_label.is_empty() {
  244. // Found a container — match its cluster label against config.
  245. return config
  246. .clusters
  247. .iter()
  248. .find(|c| c.name == cluster_label)
  249. .context(format!(
  250. "Container label says cluster '{}', but no such cluster in config",
  251. cluster_label
  252. ));
  253. }
  254. }
  255. }
  256. anyhow::bail!("No running containers found for recipe '{}'", recipe_name);
  257. }
  258. /// Options for [start_cluster].
  259. struct StartClusterOpts {
  260. cache_dir: std::path::PathBuf,
  261. keep: bool,
  262. node_name: Option<String>,
  263. mods_remote_path: String,
  264. dry_run: bool,
  265. }
  266. /// Shared context for [`build_docker_command`] — constructed once, cloned per node.
  267. #[derive(Clone)]
  268. struct DockerCommandCtx {
  269. cluster: Cluster,
  270. nccl: NcclConfig,
  271. recipe: Recipe,
  272. recipe_name: String,
  273. pp_size: u32,
  274. tp_size: u32,
  275. nnodes: u32,
  276. cache_dir: String,
  277. keep: bool,
  278. init_port: u16,
  279. mods_remote_path: String,
  280. }
  281. async fn start_cluster(
  282. config: &Config,
  283. cluster: &Cluster,
  284. nccl: &NcclConfig,
  285. recipe_name: &str,
  286. recipe: &Recipe,
  287. opts: StartClusterOpts,
  288. ) -> Result<()> {
  289. let nodes: Vec<&Node> = if let Some(ref nn) = opts.node_name {
  290. let v: Vec<&Node> = cluster.nodes.iter().filter(|n| n.name == *nn).collect();
  291. if v.is_empty() {
  292. anyhow::bail!("Node '{}' not found in cluster '{}'", nn, cluster.name);
  293. }
  294. v
  295. } else {
  296. cluster.nodes.iter().collect()
  297. };
  298. let nnodes = nodes.len() as u32;
  299. let pp_size = if recipe.pipeline_parallel_size.unwrap_or(0) == 0
  300. || recipe.required_nodes == recipe.pipeline_parallel_size
  301. {
  302. nnodes
  303. } else {
  304. recipe.pipeline_parallel_size.unwrap()
  305. };
  306. let tp_size = if recipe.required_nodes == Some(recipe.tensor_parallel_size) {
  307. nnodes
  308. } else {
  309. recipe.tensor_parallel_size
  310. };
  311. let mut handles = Vec::new();
  312. for node in &nodes {
  313. let node = (*node).clone();
  314. let config = config.clone();
  315. let rn = recipe_name.to_string();
  316. let dry = opts.dry_run;
  317. let engine = recipe.engine.clone();
  318. let is_head = node.rank == 0;
  319. let cleanup = format!(
  320. "docker rm -f sm121_{}_{} 2>/dev/null; true",
  321. recipe_name, node.rank
  322. );
  323. let dm_ctx = DockerCommandCtx {
  324. cluster: cluster.clone(),
  325. nccl: nccl.clone(),
  326. recipe: recipe.clone(),
  327. recipe_name: rn.clone(),
  328. pp_size,
  329. tp_size,
  330. nnodes,
  331. cache_dir: opts.cache_dir.to_string_lossy().into_owned(),
  332. keep: opts.keep,
  333. init_port: config.init_port,
  334. mods_remote_path: opts.mods_remote_path.clone(),
  335. };
  336. handles.push(tokio::spawn(async move {
  337. if !dry {
  338. let _ = run_ssh(&config, &node, cleanup, dry).await;
  339. }
  340. let cmd = build_docker_command(&dm_ctx, &node, is_head).join(" ");
  341. let result = run_ssh_with_retry(&config, &node, cmd, dry, 2).await;
  342. (node.name, node.rank, engine, result)
  343. }));
  344. }
  345. for result in futures::future::join_all(handles).await {
  346. match result {
  347. Ok((name, rank, engine, Ok(_))) => info!(
  348. "{}",
  349. fluent_i18n::t!("engine-started", { "engine" => engine.to_uppercase(), "name" => &name, "rank" => rank })
  350. ),
  351. Ok((name, rank, engine, Err(e))) => anyhow::bail!(
  352. "{}",
  353. fluent_i18n::t!("engine-start-failed", { "engine" => engine.to_uppercase(), "name" => &name, "rank" => rank, "error" => e.to_string() })
  354. ),
  355. Err(e) => anyhow::bail!("Task join error: {}", e),
  356. }
  357. }
  358. if !opts.dry_run {
  359. sleep(Duration::from_secs(10)).await;
  360. info!(
  361. "{}",
  362. fluent_i18n::t!("cluster-started", { "ip" => &cluster.master_ib_ip, "port" => recipe.port })
  363. );
  364. }
  365. Ok(())
  366. }
  367. fn build_docker_command(ctx: &DockerCommandCtx, node: &Node, is_head: bool) -> Vec<String> {
  368. let is_sglang = ctx.recipe.engine == "sglang";
  369. let mut flags: Vec<String> = vec![
  370. "--gpus all".to_string(),
  371. "--ipc=host".to_string(),
  372. "--privileged".to_string(),
  373. "--network=host".to_string(),
  374. "--init".to_string(),
  375. "--ulimit memlock=-1".to_string(),
  376. "--ulimit stack=67108864".to_string(),
  377. "--ulimit nofile=1048576".to_string(),
  378. ];
  379. if !ctx.recipe.shm_size.is_empty() {
  380. flags.push(format!("--shm-size {}", ctx.recipe.shm_size.as_str()));
  381. }
  382. if !ctx.keep {
  383. flags.push("--rm".to_string());
  384. }
  385. let mut cmd = vec![
  386. format!("docker run -d {}", flags.join(" ")),
  387. format!("-v {}:/root/.cache/huggingface", ctx.cache_dir),
  388. ];
  389. if !ctx.mods_remote_path.is_empty() && !ctx.recipe.mods.commands.is_empty() {
  390. cmd.push(format!("-v {}:/mods:ro", ctx.mods_remote_path));
  391. }
  392. let mut env = vec![("HF_HOME", Cow::Borrowed("/root/.cache/huggingface"))];
  393. if ctx.nnodes > 1 {
  394. let worker_ip = Cow::Borrowed(node.ib_ip.as_str());
  395. if is_sglang {
  396. env.push(("SGLANG_WORKER_IP", worker_ip));
  397. } else {
  398. env.push(("VLLM_HOST_IP", worker_ip));
  399. }
  400. let sock = ctx.nccl.socket_ifname.as_str();
  401. let gloo_if = ctx.nccl.gloo_socket_ifname.as_deref().unwrap_or(sock);
  402. let ucx_dev = ctx.nccl.ucx_net_devices.as_deref().unwrap_or(sock);
  403. let mut ncclext: Vec<(&str, Cow<str>)> = vec![
  404. ("NCCL_IB_DISABLE", format!("{}", ctx.nccl.ib_disable).into()),
  405. ("NCCL_IB_HCA", ctx.nccl.ib_hca.as_str().into()),
  406. ("NCCL_SOCKET_IFNAME", sock.into()),
  407. ("GLOO_SOCKET_IFNAME", gloo_if.into()),
  408. ("TP_SOCKET_IFNAME", gloo_if.into()),
  409. (
  410. "NCCL_IB_GID_INDEX",
  411. ctx.nccl.ib_gid_index.to_string().into(),
  412. ),
  413. ("MN_IF_NAME", gloo_if.into()),
  414. ("UCX_NET_DEVICES", ucx_dev.into()),
  415. ("OMPI_MCA_btl_tcp_if_include", gloo_if.into()),
  416. ("OMP_NUM_THREADS", "4".into()),
  417. ("NCCL_IGNORE_CPU_AFFINITY", "1".into()),
  418. ("NCCL_NET", "IB".into()),
  419. ("NCCL_DEBUG", "WARN".into()),
  420. ];
  421. if let Some(v) = ctx.nccl.nccl_cross_nic {
  422. ncclext.push(("NCCL_CROSS_NIC", v.to_string().into()));
  423. }
  424. if let Some(ref v) = ctx.nccl.nccl_proto {
  425. ncclext.push(("NCCL_PROTO", v.as_str().into()));
  426. }
  427. if let Some(ref v) = ctx.nccl.nccl_p2p_level {
  428. ncclext.push(("NCCL_P2P_LEVEL", v.as_str().into()));
  429. }
  430. env.extend(ncclext);
  431. }
  432. if is_head {
  433. env.push(("VLLM_LOGGING_COLOR", "1".into()));
  434. env.push(("TERM", "xterm-256color".into()));
  435. }
  436. for (k, v) in &ctx.recipe.env {
  437. env.push((k.as_str(), v.as_str().into()));
  438. }
  439. cmd.push(format!("--name sm121_{}_{}", ctx.recipe_name, node.rank));
  440. cmd.push(format!("--label sm121.recipe={}", ctx.recipe_name));
  441. cmd.push(format!("--label sm121.rank={}", node.rank));
  442. cmd.push(format!("--label sm121.cluster={}", ctx.cluster.name));
  443. // Add solo-node label for single-node deployments
  444. if ctx.cluster.nodes.len() == 1 && ctx.cluster.extra_entities.is_empty() {
  445. cmd.push(format!("--label sm121.solo-node={}", node.name));
  446. }
  447. if ctx.recipe.distributed_backend == Some(crate::config::DistributedBackend::Ray) {
  448. env.push((
  449. "RAY_ADDRESS",
  450. format!("{}:6379", ctx.cluster.master_ib_ip).into(),
  451. ));
  452. env.push(("RAY_PORT", "6379".into()));
  453. env.push(("RAY_NODE_IP_ADDRESS", node.ib_ip.as_str().into()));
  454. env.push(("RAY_OVERRIDE_NODE_IP_ADDRESS", node.ib_ip.as_str().into()));
  455. }
  456. for (k, v) in env {
  457. cmd.push(format!("-e {k}={v}"));
  458. }
  459. let tctx = tail::TailCtx {
  460. recipe: &ctx.recipe,
  461. tp_size: ctx.tp_size,
  462. pp_size: ctx.pp_size,
  463. nnodes: ctx.nnodes,
  464. init_port: ctx.init_port,
  465. master_ib_ip: &ctx.cluster.master_ib_ip,
  466. };
  467. let tail = tail::build_tail(&tctx, node);
  468. if ctx.recipe.mods.commands.is_empty() {
  469. cmd.push(ctx.recipe.docker_image.clone());
  470. cmd.extend(tail);
  471. } else {
  472. let script = mods::build_mod_script(&ctx.recipe.mods.commands, &tail);
  473. cmd.extend([
  474. "--entrypoint sh".into(),
  475. ctx.recipe.docker_image.clone(),
  476. "-c".into(),
  477. script,
  478. ]);
  479. }
  480. cmd
  481. }
  482. #[cfg(test)]
  483. mod tests;