mod.rs 13 KB

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