command.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  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 crate::config::{Cluster, Config, NcclConfig, Node, Recipe};
  7. use anyhow::Result;
  8. use std::borrow::Cow;
  9. use super::{mods, tail};
  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. pub nccl: NcclConfig,
  15. pub recipe: Recipe,
  16. pub recipe_name: String,
  17. pub pp_size: u32,
  18. pub tp_size: u32,
  19. pub nnodes: u32,
  20. pub cache_dir: String,
  21. pub keep: bool,
  22. pub init_port: u16,
  23. pub mods_remote_path: String,
  24. /// Static `extra_mounts` (alias/host) with host paths already resolved
  25. /// once against `Config.mount_aliases` / `ssh_user`. Model-subfolder mounts
  26. /// are NOT here — `start_cluster` resolves those per node and merges the
  27. /// results into this `Vec` (as `ResolvedMount`s) before calling
  28. /// [`build_docker_command`].
  29. pub extra_mounts: Vec<ResolvedMount>,
  30. }
  31. /// A recipe `ExtraMount` after host-path resolution (alias + tilde), ready to
  32. /// turn into a `-v` flag / `-e` env pair. Also the final form a model-subfolder
  33. /// mount takes after per-node resolution.
  34. #[derive(Clone)]
  35. pub(crate) struct ResolvedMount {
  36. pub host: String,
  37. pub container: String,
  38. pub read_only: bool,
  39. pub env: Option<String>,
  40. }
  41. /// A model-subfolder mount spec (`repo`+`subfolder`) before per-node
  42. /// resolution. The host path is resolved at serve time from each node's HF
  43. /// cache (so it can differ across nodes); `container` is the fixed path the
  44. /// recipe references in args.
  45. #[derive(Clone)]
  46. pub(crate) struct ModelMountSpec {
  47. pub repo: String,
  48. pub subfolder: Option<String>,
  49. pub container: String,
  50. pub read_only: bool,
  51. pub env: Option<String>,
  52. }
  53. /// Partition a recipe's `extra_mounts` into static (resolved now) and
  54. /// model-subfolder (deferred to per-node resolution). Validates that a mount
  55. /// is one or the other — `repo` set means `alias`/`host` must be empty, and
  56. /// `subfolder` without `repo` is an error.
  57. pub(crate) fn resolve_extra_mounts(
  58. config: &Config,
  59. recipe: &Recipe,
  60. ) -> Result<ResolvedExtraMounts> {
  61. let mut static_mounts = Vec::new();
  62. let mut model_mounts = Vec::new();
  63. for m in &recipe.extra_mounts {
  64. if m.repo.as_deref().is_some_and(|r| !r.trim().is_empty()) {
  65. if m.alias.is_some() || m.host.is_some() {
  66. anyhow::bail!(
  67. "extra_mount '{}': `repo` is mutually exclusive with `alias`/`host`",
  68. m.container
  69. );
  70. }
  71. model_mounts.push(ModelMountSpec {
  72. repo: m.repo.as_ref().unwrap().trim().to_string(),
  73. subfolder: m
  74. .subfolder
  75. .as_deref()
  76. .filter(|s| !s.trim().is_empty())
  77. .map(|s| s.trim().to_string()),
  78. container: m.container.clone(),
  79. read_only: m.read_only,
  80. env: m.env.clone(),
  81. });
  82. } else if m.subfolder.as_deref().is_some_and(|s| !s.trim().is_empty()) {
  83. anyhow::bail!("extra_mount '{}': `subfolder` requires `repo`", m.container);
  84. } else {
  85. let host = crate::config::resolve_mount_host(
  86. m.alias.as_deref(),
  87. m.host.as_deref(),
  88. &config.mount_aliases,
  89. &config.ssh_user,
  90. &m.container,
  91. )?;
  92. static_mounts.push(ResolvedMount {
  93. host,
  94. container: m.container.clone(),
  95. read_only: m.read_only,
  96. env: m.env.clone(),
  97. });
  98. }
  99. }
  100. Ok(ResolvedExtraMounts {
  101. static_mounts,
  102. model_mounts,
  103. })
  104. }
  105. /// Result of [`resolve_extra_mounts`]: static mounts ready to clone per node,
  106. /// model-subfolder specs deferred to per-node resolution.
  107. pub(crate) struct ResolvedExtraMounts {
  108. pub static_mounts: Vec<ResolvedMount>,
  109. pub model_mounts: Vec<ModelMountSpec>,
  110. }
  111. /// Dry-run placeholder for a model-subfolder mount: nodes may be off, so no
  112. /// HF cache to read. Emits a descriptive path so the printed `docker run`
  113. /// command shows *what* would be mounted, not a broken lookup.
  114. pub(crate) fn model_mount_placeholder(spec: &ModelMountSpec) -> ResolvedMount {
  115. let sub = spec.subfolder.as_deref().unwrap_or("");
  116. let host = format!(
  117. "<hf_cache>/hub/{}/snapshots/<refs/main>/{}",
  118. crate::config::model_cache_dir_name(&spec.repo),
  119. sub
  120. );
  121. ResolvedMount {
  122. host,
  123. container: spec.container.clone(),
  124. read_only: spec.read_only,
  125. env: spec.env.clone(),
  126. }
  127. }
  128. /// Collect NCCL cluster env vars for multi-node deployments.
  129. fn collect_nccl_env<'a>(ctx: &'a DockerCommandCtx, node: &'a Node) -> Vec<(&'a str, Cow<'a, str>)> {
  130. let is_sglang = ctx.recipe.engine == "sglang";
  131. let sock = ctx.nccl.socket_ifname.as_str();
  132. let gloo_if = ctx.nccl.gloo_socket_ifname.as_deref().unwrap_or(sock);
  133. let ucx_dev = ctx.nccl.ucx_net_devices.as_deref().unwrap_or(sock);
  134. let mut env: Vec<(&str, Cow<str>)> = vec![
  135. ("NCCL_IB_DISABLE", format!("{}", ctx.nccl.ib_disable).into()),
  136. ("NCCL_IB_HCA", ctx.nccl.ib_hca.as_str().into()),
  137. ("NCCL_SOCKET_IFNAME", sock.into()),
  138. ("GLOO_SOCKET_IFNAME", gloo_if.into()),
  139. ("TP_SOCKET_IFNAME", gloo_if.into()),
  140. (
  141. "NCCL_IB_GID_INDEX",
  142. ctx.nccl.ib_gid_index.to_string().into(),
  143. ),
  144. ("MN_IF_NAME", gloo_if.into()),
  145. ("UCX_NET_DEVICES", ucx_dev.into()),
  146. ("OMPI_MCA_btl_tcp_if_include", gloo_if.into()),
  147. ("OMP_NUM_THREADS", "4".into()),
  148. ("NCCL_IGNORE_CPU_AFFINITY", "1".into()),
  149. ("NCCL_NET", "IB".into()),
  150. ("NCCL_DEBUG", "WARN".into()),
  151. ];
  152. if let Some(v) = ctx.nccl.nccl_cross_nic {
  153. env.push(("NCCL_CROSS_NIC", v.to_string().into()));
  154. }
  155. if let Some(ref v) = ctx.nccl.nccl_proto {
  156. env.push(("NCCL_PROTO", v.as_str().into()));
  157. }
  158. if let Some(ref v) = ctx.nccl.nccl_p2p_level {
  159. env.push(("NCCL_P2P_LEVEL", v.as_str().into()));
  160. }
  161. let worker_ip = Cow::Borrowed(node.ib_ip.as_str());
  162. if is_sglang {
  163. env.push(("SGLANG_WORKER_IP", worker_ip));
  164. } else {
  165. env.push(("VLLM_HOST_IP", worker_ip));
  166. }
  167. env
  168. }
  169. /// Build the list of `-e KEY=VALUE` Docker env var pairs for a node.
  170. fn collect_docker_env(ctx: &DockerCommandCtx, node: &Node, is_head: bool) -> Vec<String> {
  171. let mut pairs: Vec<(&str, Cow<str>)> =
  172. vec![("HF_HOME", Cow::Borrowed("/root/.cache/huggingface"))];
  173. if ctx.nnodes > 1 {
  174. pairs.extend(collect_nccl_env(ctx, node));
  175. }
  176. if is_head {
  177. pairs.push(("VLLM_LOGGING_COLOR", "1".into()));
  178. pairs.push(("TERM", "xterm-256color".into()));
  179. }
  180. if ctx.recipe.distributed_backend == Some(crate::config::DistributedBackend::Ray) {
  181. pairs.push((
  182. "RAY_ADDRESS",
  183. format!("{}:6379", ctx.cluster.master_ib_ip).into(),
  184. ));
  185. pairs.push(("RAY_PORT", "6379".into()));
  186. pairs.push(("RAY_NODE_IP_ADDRESS", node.ib_ip.as_str().into()));
  187. pairs.push(("RAY_OVERRIDE_NODE_IP_ADDRESS", node.ib_ip.as_str().into()));
  188. }
  189. // extra_mount `env` defaults (point at the container path) are injected
  190. // before recipe [env] so the recipe can still override them (last wins).
  191. for m in &ctx.extra_mounts {
  192. if let Some(name) = &m.env {
  193. pairs.push((name.as_str(), m.container.as_str().into()));
  194. }
  195. }
  196. for (k, v) in &ctx.recipe.env {
  197. pairs.push((k.as_str(), v.as_str().into()));
  198. }
  199. pairs
  200. .into_iter()
  201. .map(|(k, v)| format!("-e {k}={v}"))
  202. .collect()
  203. }
  204. /// Build the list of Docker run flags (--gpus, --ipc, --ulimit, etc.).
  205. fn build_docker_flags(ctx: &DockerCommandCtx) -> Vec<String> {
  206. let mut flags: Vec<String> = vec![
  207. "--gpus all".to_string(),
  208. "--ipc=host".to_string(),
  209. "--privileged".to_string(),
  210. "--network=host".to_string(),
  211. "--init".to_string(),
  212. "--ulimit memlock=-1".to_string(),
  213. "--ulimit stack=67108864".to_string(),
  214. "--ulimit nofile=1048576".to_string(),
  215. ];
  216. if !ctx.recipe.shm_size.is_empty() {
  217. flags.push(format!("--shm-size {}", ctx.recipe.shm_size.as_str()));
  218. }
  219. if !ctx.keep {
  220. flags.push("--rm".to_string());
  221. }
  222. flags
  223. }
  224. /// Build the full Docker command line for a node.
  225. pub(crate) fn build_docker_command(
  226. ctx: &DockerCommandCtx,
  227. node: &Node,
  228. is_head: bool,
  229. ) -> Vec<String> {
  230. let flags = build_docker_flags(ctx);
  231. let mut cmd = vec![
  232. format!("docker run -d {}", flags.join(" ")),
  233. format!("-v {}:/root/.cache/huggingface", ctx.cache_dir),
  234. ];
  235. if !ctx.mods_remote_path.is_empty() && !ctx.recipe.mods.commands.is_empty() {
  236. cmd.push(format!("-v {}:/mods:ro", ctx.mods_remote_path));
  237. }
  238. for m in &ctx.extra_mounts {
  239. let mut spec = format!("-v {}:{}", m.host, m.container);
  240. if m.read_only {
  241. spec.push_str(":ro");
  242. }
  243. cmd.push(spec);
  244. }
  245. cmd.extend(collect_docker_env(ctx, node, is_head));
  246. cmd.push(format!("--name sm121_{}_{}", ctx.recipe_name, node.rank));
  247. cmd.push(format!("--label sm121.recipe={}", ctx.recipe_name));
  248. cmd.push(format!("--label sm121.rank={}", node.rank));
  249. cmd.push(format!("--label sm121.cluster={}", ctx.cluster.name));
  250. // Add solo-node label for single-node deployments
  251. if ctx.cluster.nodes.len() == 1 && ctx.cluster.extra_entities.is_empty() {
  252. cmd.push(format!("--label sm121.solo-node={}", node.name));
  253. }
  254. let tctx = tail::TailCtx {
  255. recipe: &ctx.recipe,
  256. tp_size: ctx.tp_size,
  257. pp_size: ctx.pp_size,
  258. nnodes: ctx.nnodes,
  259. init_port: ctx.init_port,
  260. master_ib_ip: &ctx.cluster.master_ib_ip,
  261. };
  262. let tail = tail::build_tail(&tctx, node);
  263. if ctx.recipe.mods.commands.is_empty() {
  264. cmd.push(ctx.recipe.docker_image.clone());
  265. cmd.extend(tail);
  266. } else {
  267. let script = mods::build_mod_script(&ctx.recipe.mods.commands, &tail);
  268. cmd.extend([
  269. "--entrypoint sh".into(),
  270. ctx.recipe.docker_image.clone(),
  271. "-c".into(),
  272. script,
  273. ]);
  274. }
  275. cmd
  276. }
  277. #[cfg(test)]
  278. mod tests {
  279. use super::*;
  280. use crate::config::{
  281. Cluster, DistributedBackend, ExtraMount, NcclConfig, Node, Recipe, RecipeArgs,
  282. };
  283. use std::collections::HashMap;
  284. fn tcls() -> Cluster {
  285. Cluster {
  286. name: "t".into(),
  287. master_ib_ip: "1".into(),
  288. nodes: vec![Node {
  289. name: "n0".into(),
  290. ssh_ip: "10.0.0.1".into(),
  291. ib_ip: "1".into(),
  292. rank: 0,
  293. host_key: None,
  294. entity: None,
  295. }],
  296. extra_entities: vec![],
  297. }
  298. }
  299. fn tnccl() -> NcclConfig {
  300. NcclConfig {
  301. ib_hca: "r".into(),
  302. socket_ifname: "e".into(),
  303. ib_gid_index: 3,
  304. ib_disable: 0,
  305. gloo_socket_ifname: None,
  306. ucx_net_devices: None,
  307. nccl_cross_nic: None,
  308. nccl_proto: None,
  309. nccl_p2p_level: None,
  310. }
  311. }
  312. fn minimal_config() -> Config {
  313. Config {
  314. ssh_user: "u".into(),
  315. ssh_key_path: "/tmp/key".into(),
  316. clusters: vec![],
  317. nccl: HashMap::new(),
  318. hf_cache_dir: None,
  319. init_port: 25000,
  320. before_serve: HashMap::new(),
  321. exec_aliases: HashMap::new(),
  322. home_assistant: None,
  323. global_mods_dir: None,
  324. drop_caches: true,
  325. mount_aliases: HashMap::from([("models".to_string(), "/mnt/nas/models".to_string())]),
  326. }
  327. }
  328. fn run_with(recipe: &Recipe) -> String {
  329. let resolved = resolve_extra_mounts(&minimal_config(), recipe).unwrap();
  330. let ctx = DockerCommandCtx {
  331. cluster: tcls(),
  332. nccl: tnccl(),
  333. recipe: recipe.clone(),
  334. recipe_name: "r".to_string(),
  335. pp_size: 2,
  336. tp_size: 1,
  337. nnodes: 2,
  338. cache_dir: "/home/user/.cache/huggingface".to_string(),
  339. keep: false,
  340. init_port: 25000,
  341. mods_remote_path: String::new(),
  342. extra_mounts: resolved.static_mounts,
  343. };
  344. build_docker_command(&ctx, &tcls().nodes[0], true).join(" ")
  345. }
  346. fn base_recipe() -> Recipe {
  347. Recipe {
  348. engine: "vllm".into(),
  349. name: "T".into(),
  350. docker_image: "img".into(),
  351. model: "mdl".into(),
  352. port: 8000,
  353. required_nodes: Some(2),
  354. pipeline_parallel_size: Some(2),
  355. tensor_parallel_size: 1,
  356. args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
  357. env: HashMap::new(),
  358. cluster: None,
  359. entrypoint: vec![],
  360. distributed_backend: Some(DistributedBackend::Mp),
  361. before_serve: vec![],
  362. mods: Default::default(),
  363. shm_size: String::new(),
  364. sync_image: true,
  365. docker_image_dir: None,
  366. extra_mounts: vec![],
  367. }
  368. }
  369. #[test]
  370. fn extra_mounts_are_mounted() {
  371. let mut r = base_recipe();
  372. r.extra_mounts.push(ExtraMount {
  373. alias: Some("models".into()),
  374. host: Some("~/.cache/models".into()),
  375. repo: None,
  376. subfolder: None,
  377. container: "/models:ro".into(),
  378. read_only: true,
  379. env: Some("MODEL_DIR".into()),
  380. });
  381. let cmd = run_with(&r);
  382. // alias present in minimal_config() → alias host wins
  383. assert!(cmd.contains("-v /mnt/nas/models:/models:ro:ro"));
  384. assert!(cmd.contains("-v /home/user/.cache/huggingface:/root/.cache/huggingface"));
  385. // env injected pointing at the container path, before recipe env
  386. assert!(cmd.contains("-e MODEL_DIR=/models:ro"));
  387. }
  388. #[test]
  389. fn extra_mounts_fallback_to_host_when_alias_absent() {
  390. let mut r = base_recipe();
  391. r.extra_mounts.push(ExtraMount {
  392. alias: Some("no_such_alias".into()),
  393. host: Some("~/.cache/jit".into()),
  394. repo: None,
  395. subfolder: None,
  396. container: "/root/.cache/jit".into(),
  397. read_only: false,
  398. env: None,
  399. });
  400. let cmd = run_with(&r);
  401. // alias not in config → host default used with ~ expansion (ssh_user=u)
  402. assert!(cmd.contains("-v /home/u/.cache/jit:/root/.cache/jit"));
  403. assert!(!cmd.contains(":ro"));
  404. }
  405. #[test]
  406. fn model_subfolder_mount_uses_placeholder_in_dry_run() {
  407. // In build_docker_command (no per-node SSH resolution here), a model
  408. // mount shows the descriptive placeholder host so the printed command
  409. // documents what would be mounted. start_cluster replaces this with
  410. // the real resolved path at serve time (or keeps it under dry-run).
  411. let mut r = base_recipe();
  412. r.extra_mounts.push(ExtraMount {
  413. alias: None,
  414. host: None,
  415. repo: Some("aidendle94/GLM-5.2-Hybrid-FP8-MXFP4".into()),
  416. subfolder: Some("mtp-draft".into()),
  417. container: "/models/mtp-draft".into(),
  418. read_only: true,
  419. env: Some("MTP_DRAFT_PATH".into()),
  420. });
  421. let cmd = run_with(&r);
  422. // model_mounts are NOT in extra_mounts here (run_with doesn't resolve
  423. // them per-node), so the -v flag for the model mount is absent — only
  424. // start_cluster emits it. Verify the spec is parsed + the env is NOT
  425. // injected yet (env injection follows extra_mounts, which is empty of
  426. // the model mount here). This test guards the partition.
  427. assert!(!cmd.contains("-v /models/mtp-draft"));
  428. assert!(!cmd.contains("MTP_DRAFT_PATH"));
  429. // But the recipe parsed into a model_mount spec:
  430. let resolved = resolve_extra_mounts(&minimal_config(), &r).unwrap();
  431. assert_eq!(resolved.model_mounts.len(), 1);
  432. assert_eq!(resolved.static_mounts.len(), 0);
  433. let ph = model_mount_placeholder(&resolved.model_mounts[0]);
  434. assert_eq!(ph.container, "/models/mtp-draft");
  435. assert!(
  436. ph.host
  437. .contains("models--aidendle94--GLM-5.2-Hybrid-FP8-MXFP4")
  438. );
  439. assert!(ph.host.contains("mtp-draft"));
  440. assert!(ph.read_only);
  441. }
  442. }