command.rs 18 KB

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