command.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. //! Assembly of the `docker run` command per node — flags, env vars, mounts,
  2. //! and the vLLM/SGLang tail (engine flags + distributed rendezvous).
  3. //!
  4. //! [`build_docker_command`] is the single entry point; everything else here is
  5. //! private to this module.
  6. use super::{mods, tail};
  7. use crate::config::{Cluster, Config, NcclConfig, Node, Recipe};
  8. use anyhow::Result;
  9. use std::borrow::Cow;
  10. /// Shared context for [`build_docker_command`] — constructed once, cloned per node.
  11. #[derive(Clone)]
  12. pub(crate) struct DockerCommandCtx {
  13. pub cluster: Cluster,
  14. /// NCCL config — `None` for single-node recipes (no NCCL env vars
  15. /// are emitted). For multi-node, always `Some`.
  16. pub nccl: Option<NcclConfig>,
  17. pub recipe: Recipe,
  18. pub recipe_name: String,
  19. pub pp_size: u32,
  20. pub tp_size: u32,
  21. pub nnodes: u32,
  22. pub cache_dir: String,
  23. pub keep: bool,
  24. pub init_port: u16,
  25. pub mods_remote_path: String,
  26. /// Static `extra_mounts` (alias/host) with host paths already resolved
  27. /// once against `Config.mount_aliases` / `ssh_user`. Path references
  28. /// (`repo`/`subfolder`) are NOT here — `start_cluster` resolves those
  29. /// per-node and substitutes `{{<name>}}` into the recipe args/env before
  30. /// calling [`build_docker_command`].
  31. pub extra_mounts: Vec<ResolvedMount>,
  32. }
  33. /// A recipe `ExtraMount` after host-path resolution (alias + tilde), ready to
  34. /// turn into a `-v` flag / `-e` env pair.
  35. #[derive(Clone)]
  36. pub(crate) struct ResolvedMount {
  37. pub host: String,
  38. pub container: String,
  39. pub read_only: bool,
  40. pub env: Option<String>,
  41. }
  42. /// A model path reference (`repo`+`subfolder`) before per-node resolution.
  43. /// At serve time, the launcher resolves the path on each node (reading
  44. /// `refs/main`) and substitutes `{{<name>}}` in the recipe args/env with the
  45. /// resolved full HF cache path. The HF cache is already mounted at
  46. /// `/root/.cache/huggingface`, so the resolved path's symlinks resolve
  47. /// correctly (a separate bind-mount would break them).
  48. #[derive(Clone)]
  49. pub(crate) struct PathRef {
  50. pub repo: String,
  51. pub subfolder: Option<String>,
  52. pub name: String,
  53. }
  54. /// Partition a recipe's `extra_mounts` into static (resolved now) and
  55. /// path references (deferred to per-node resolution + substitution).
  56. /// Validates that a mount is one or the other — `repo` set means
  57. /// `alias`/`host` must be empty, and `subfolder` without `repo` is an error.
  58. pub(crate) fn resolve_extra_mounts(
  59. config: &Config,
  60. recipe: &Recipe,
  61. ) -> Result<ResolvedExtraMounts> {
  62. let mut static_mounts = Vec::new();
  63. let mut path_refs = Vec::new();
  64. for m in &recipe.extra_mounts {
  65. if m.repo.as_deref().is_some_and(|r| !r.trim().is_empty()) {
  66. if m.alias.is_some() || m.host.is_some() {
  67. anyhow::bail!(
  68. "extra_mount '{}': `repo` is mutually exclusive with `alias`/`host`",
  69. m.container
  70. );
  71. }
  72. path_refs.push(PathRef {
  73. repo: m.repo.as_ref().unwrap().trim().to_string(),
  74. subfolder: m
  75. .subfolder
  76. .as_deref()
  77. .filter(|s| !s.trim().is_empty())
  78. .map(|s| s.trim().to_string()),
  79. name: m.container.clone(),
  80. });
  81. } else if m.subfolder.as_deref().is_some_and(|s| !s.trim().is_empty()) {
  82. anyhow::bail!("extra_mount '{}': `subfolder` requires `repo`", m.container);
  83. } else {
  84. let host = crate::config::resolve_mount_host(
  85. m.alias.as_deref(),
  86. m.host.as_deref(),
  87. &config.mount_aliases,
  88. &config.ssh_user,
  89. &m.container,
  90. )?;
  91. static_mounts.push(ResolvedMount {
  92. host,
  93. container: m.container.clone(),
  94. read_only: m.read_only,
  95. env: m.env.clone(),
  96. });
  97. }
  98. }
  99. Ok(ResolvedExtraMounts {
  100. static_mounts,
  101. path_refs,
  102. })
  103. }
  104. /// Result of [`resolve_extra_mounts`]: static mounts ready to clone per node,
  105. /// path references deferred to per-node resolution + substitution.
  106. pub(crate) struct ResolvedExtraMounts {
  107. pub static_mounts: Vec<ResolvedMount>,
  108. pub path_refs: Vec<PathRef>,
  109. }
  110. /// Substitute `{{<name>}}` placeholders in `args` and `env` with resolved paths.
  111. /// `path_refs` maps placeholder name → resolved path (per-node). Done before
  112. /// building the docker command so vLLM args reference the correct snapshot.
  113. pub(crate) fn substitute_path_refs(recipe: &Recipe, path_refs: &[(String, String)]) -> Recipe {
  114. let mut r = recipe.clone();
  115. for (name, path) in path_refs {
  116. let placeholder = format!("{{{{{}}}}}", name);
  117. r.args = r.args.substitute(&placeholder, path);
  118. for v in r.env.values_mut() {
  119. *v = v.replace(&placeholder, path);
  120. }
  121. }
  122. r
  123. }
  124. /// Dry-run placeholder for a model path reference: nodes may be off, so no
  125. /// HF cache to read. Emits a descriptive path so the printed `docker run`
  126. /// command shows *what* would be substituted, not a broken lookup.
  127. pub(crate) fn path_ref_placeholder(spec: &PathRef) -> String {
  128. let sub = spec.subfolder.as_deref().unwrap_or("");
  129. format!(
  130. "<hf_cache>/hub/{}/snapshots/<refs/main>/{}",
  131. crate::config::model_cache_dir_name(&spec.repo),
  132. sub
  133. )
  134. }
  135. /// Collect NCCL cluster env vars for multi-node deployments.
  136. ///
  137. /// `ctx.nccl` is `None` for single-node recipes — but this function is
  138. /// only called when `ctx.nnodes > 1`, so `None` indicates a configuration
  139. /// error (multi-node without NCCL config). In that case, only the worker
  140. /// IP env var is emitted (NCCL env vars are skipped), which will cause
  141. /// NCCL to fail at runtime with a visible error.
  142. fn collect_nccl_env<'a>(ctx: &'a DockerCommandCtx, node: &'a Node) -> Vec<(&'a str, Cow<'a, str>)> {
  143. let is_sglang = ctx.recipe.engine == "sglang";
  144. let mut env: Vec<(&str, Cow<str>)> = Vec::new();
  145. if let Some(nccl) = ctx.nccl.as_ref() {
  146. let sock = nccl.socket_ifname.as_str();
  147. let gloo_if = nccl.gloo_socket_ifname.as_deref().unwrap_or(sock);
  148. let ucx_dev = nccl.ucx_net_devices.as_deref().unwrap_or(sock);
  149. env.extend(vec![
  150. ("NCCL_IB_DISABLE", format!("{}", nccl.ib_disable).into()),
  151. ("NCCL_IB_HCA", nccl.ib_hca.as_str().into()),
  152. ("NCCL_SOCKET_IFNAME", sock.into()),
  153. ("GLOO_SOCKET_IFNAME", gloo_if.into()),
  154. ("TP_SOCKET_IFNAME", gloo_if.into()),
  155. ("NCCL_IB_GID_INDEX", nccl.ib_gid_index.to_string().into()),
  156. ("MN_IF_NAME", gloo_if.into()),
  157. ("UCX_NET_DEVICES", ucx_dev.into()),
  158. ("OMPI_MCA_btl_tcp_if_include", gloo_if.into()),
  159. ("OMP_NUM_THREADS", "4".into()),
  160. ("NCCL_IGNORE_CPU_AFFINITY", "1".into()),
  161. ("NCCL_NET", "IB".into()),
  162. ("NCCL_DEBUG", "WARN".into()),
  163. ]);
  164. if let Some(v) = nccl.nccl_cross_nic {
  165. env.push(("NCCL_CROSS_NIC", v.to_string().into()));
  166. }
  167. if let Some(ref v) = nccl.nccl_proto {
  168. env.push(("NCCL_PROTO", v.as_str().into()));
  169. }
  170. if let Some(ref v) = nccl.nccl_p2p_level {
  171. env.push(("NCCL_P2P_LEVEL", v.as_str().into()));
  172. }
  173. }
  174. // Worker IP is always set for multi-node (collect_nccl_env is only
  175. // called when nnodes > 1).
  176. let worker_ip = Cow::Borrowed(node.ib_ip.as_str());
  177. if is_sglang {
  178. env.push(("SGLANG_WORKER_IP", worker_ip));
  179. } else {
  180. env.push(("VLLM_HOST_IP", worker_ip));
  181. }
  182. env
  183. }
  184. /// Build the list of `-e KEY=VALUE` Docker env var pairs for a node.
  185. fn collect_docker_env(ctx: &DockerCommandCtx, node: &Node, is_head: bool) -> Vec<String> {
  186. let mut pairs: Vec<(&str, Cow<str>)> =
  187. vec![("HF_HOME", Cow::Borrowed("/root/.cache/huggingface"))];
  188. if ctx.nnodes > 1 {
  189. pairs.extend(collect_nccl_env(ctx, node));
  190. }
  191. if is_head {
  192. pairs.push(("VLLM_LOGGING_COLOR", "1".into()));
  193. pairs.push(("TERM", "xterm-256color".into()));
  194. }
  195. if ctx.recipe.distributed_backend == Some(crate::config::DistributedBackend::Ray) {
  196. pairs.push((
  197. "RAY_ADDRESS",
  198. format!("{}:6379", ctx.cluster.master_ib_ip).into(),
  199. ));
  200. pairs.push(("RAY_PORT", "6379".into()));
  201. pairs.push(("RAY_NODE_IP_ADDRESS", node.ib_ip.as_str().into()));
  202. pairs.push(("RAY_OVERRIDE_NODE_IP_ADDRESS", node.ib_ip.as_str().into()));
  203. }
  204. // extra_mount `env` defaults (point at the container path) are injected
  205. // before recipe [env] so the recipe can still override them (last wins).
  206. for m in &ctx.extra_mounts {
  207. if let Some(name) = &m.env {
  208. pairs.push((name.as_str(), m.container.as_str().into()));
  209. }
  210. }
  211. for (k, v) in &ctx.recipe.env {
  212. pairs.push((k.as_str(), v.as_str().into()));
  213. }
  214. pairs
  215. .into_iter()
  216. .map(|(k, v)| format!("-e {k}={v}"))
  217. .collect()
  218. }
  219. /// Build the list of Docker run flags (--gpus, --ipc, --ulimit, etc.).
  220. fn build_docker_flags(ctx: &DockerCommandCtx) -> Vec<String> {
  221. let mut flags: Vec<String> = vec![
  222. "--gpus all".to_string(),
  223. "--ipc=host".to_string(),
  224. "--privileged".to_string(),
  225. "--network=host".to_string(),
  226. "--init".to_string(),
  227. "--ulimit memlock=-1".to_string(),
  228. "--ulimit stack=67108864".to_string(),
  229. "--ulimit nofile=1048576".to_string(),
  230. ];
  231. if !ctx.recipe.shm_size.is_empty() {
  232. flags.push(format!("--shm-size {}", ctx.recipe.shm_size.as_str()));
  233. }
  234. if !ctx.keep {
  235. flags.push("--rm".to_string());
  236. }
  237. flags
  238. }
  239. /// Build the full Docker command line for a node.
  240. pub(crate) fn build_docker_command(
  241. ctx: &DockerCommandCtx,
  242. node: &Node,
  243. is_head: bool,
  244. ) -> Vec<String> {
  245. let flags = build_docker_flags(ctx);
  246. let mut cmd = vec![
  247. format!("docker run -d {}", flags.join(" ")),
  248. format!("-v {}:/root/.cache/huggingface", ctx.cache_dir),
  249. ];
  250. if !ctx.mods_remote_path.is_empty() && !ctx.recipe.mods.commands.is_empty() {
  251. cmd.push(format!("-v {}:/mods:ro", ctx.mods_remote_path));
  252. }
  253. for m in &ctx.extra_mounts {
  254. let mut spec = format!("-v {}:{}", m.host, m.container);
  255. if m.read_only {
  256. spec.push_str(":ro");
  257. }
  258. cmd.push(spec);
  259. }
  260. cmd.extend(collect_docker_env(ctx, node, is_head));
  261. cmd.push(format!("--name sm121_{}_{}", ctx.recipe_name, node.rank));
  262. cmd.push(format!("--label sm121.recipe={}", ctx.recipe_name));
  263. cmd.push(format!("--label sm121.rank={}", node.rank));
  264. cmd.push(format!("--label sm121.cluster={}", ctx.cluster.name));
  265. // Add solo-node label for single-node deployments
  266. if ctx.cluster.nodes.len() == 1 && ctx.cluster.extra_entities.is_empty() {
  267. cmd.push(format!("--label sm121.solo-node={}", node.name));
  268. }
  269. let tctx = tail::TailCtx {
  270. recipe: &ctx.recipe,
  271. tp_size: ctx.tp_size,
  272. pp_size: ctx.pp_size,
  273. nnodes: ctx.nnodes,
  274. init_port: ctx.init_port,
  275. master_ib_ip: &ctx.cluster.master_ib_ip,
  276. };
  277. let tail = tail::build_tail(&tctx, node);
  278. if ctx.recipe.mods.commands.is_empty() {
  279. cmd.push(ctx.recipe.docker_image.clone());
  280. cmd.extend(tail);
  281. } else {
  282. let script = mods::build_mod_script(&ctx.recipe.mods.commands, &tail);
  283. cmd.extend([
  284. "--entrypoint sh".into(),
  285. ctx.recipe.docker_image.clone(),
  286. "-c".into(),
  287. script,
  288. ]);
  289. }
  290. cmd
  291. }
  292. #[cfg(test)]
  293. #[path = "command_tests.rs"]
  294. mod tests;