tests.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. use super::command::{DockerCommandCtx, build_docker_command};
  2. use super::*;
  3. use crate::config::{DistributedBackend, RecipeArgs};
  4. use std::collections::HashMap;
  5. fn tcls() -> Cluster {
  6. Cluster {
  7. name: "t".into(),
  8. master_ib_ip: "1".into(),
  9. nodes: vec![
  10. Node {
  11. name: "n0".into(),
  12. ssh_ip: "10.0.0.1".into(),
  13. ib_ip: "1".into(),
  14. rank: 0,
  15. host_key: None,
  16. entity: None,
  17. },
  18. Node {
  19. name: "n1".into(),
  20. ssh_ip: "10.0.0.2".into(),
  21. ib_ip: "2".into(),
  22. rank: 1,
  23. host_key: None,
  24. entity: None,
  25. },
  26. ],
  27. extra_entities: vec![],
  28. }
  29. }
  30. fn tnccl() -> NcclConfig {
  31. NcclConfig {
  32. ib_hca: "r".into(),
  33. socket_ifname: "e".into(),
  34. ib_gid_index: 3,
  35. ib_disable: 0,
  36. gloo_socket_ifname: None,
  37. ucx_net_devices: None,
  38. nccl_cross_nic: None,
  39. nccl_proto: None,
  40. nccl_p2p_level: None,
  41. }
  42. }
  43. fn trec() -> Recipe {
  44. Recipe {
  45. engine: "vllm".into(),
  46. name: "T".into(),
  47. docker_image: "img".into(),
  48. model: "mdl".into(),
  49. port: 8000,
  50. required_nodes: Some(2),
  51. pipeline_parallel_size: Some(2),
  52. tensor_parallel_size: 1,
  53. args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
  54. env: HashMap::from([("K".into(), "V".into())]),
  55. cluster: None,
  56. entrypoint: vec![],
  57. distributed_backend: Some(DistributedBackend::Mp),
  58. before_serve: vec![],
  59. after_stop: vec![],
  60. solo: None,
  61. mods: Default::default(),
  62. shm_size: String::new(),
  63. sync_image: true,
  64. docker_image_dir: None,
  65. extra_mounts: vec![],
  66. }
  67. }
  68. fn make_dm_ctx(recipe: &Recipe, nnodes: u32, pp: u32, tp: u32, keep: bool) -> DockerCommandCtx {
  69. DockerCommandCtx {
  70. cluster: tcls(),
  71. nccl: tnccl(),
  72. recipe: recipe.clone(),
  73. recipe_name: "r".to_string(),
  74. pp_size: pp,
  75. tp_size: tp,
  76. nnodes,
  77. cache_dir: "/home/user/.cache/huggingface".to_string(),
  78. keep,
  79. init_port: 25000,
  80. mods_remote_path: String::new(),
  81. extra_mounts: vec![],
  82. }
  83. }
  84. fn run(recipe: &Recipe, node: &Node, head: bool) -> String {
  85. let tp = if recipe.required_nodes == Some(recipe.tensor_parallel_size) {
  86. 2
  87. } else {
  88. recipe.tensor_parallel_size
  89. };
  90. build_docker_command(&make_dm_ctx(recipe, 2, 2, tp, false), node, head).join(" ")
  91. }
  92. fn run_keep(recipe: &Recipe, node: &Node, head: bool) -> String {
  93. let tp = if recipe.required_nodes == Some(recipe.tensor_parallel_size) {
  94. 2
  95. } else {
  96. recipe.tensor_parallel_size
  97. };
  98. build_docker_command(&make_dm_ctx(recipe, 2, 2, tp, true), node, head).join(" ")
  99. }
  100. #[test]
  101. fn mp() {
  102. let cmd = run(&trec(), &tcls().nodes[0], true);
  103. assert!(cmd.contains("docker run -d --gpus all"));
  104. assert!(cmd.contains("-e NCCL_IB_HCA=r"));
  105. assert!(cmd.contains("-e K=V"));
  106. assert!(cmd.contains("--name sm121_r_0"));
  107. assert!(
  108. !cmd.contains("-p "),
  109. "host networking: no port mapping expected"
  110. );
  111. assert!(cmd.contains("--label sm121.recipe=r"));
  112. assert!(cmd.contains("--label sm121.rank=0"));
  113. assert!(cmd.contains("img"));
  114. assert!(cmd.contains("mdl"));
  115. assert!(cmd.contains("--tensor-parallel-size 1"));
  116. assert!(cmd.contains("--pipeline-parallel-size 2"));
  117. assert!(cmd.contains("--nnodes 2"));
  118. assert!(cmd.contains("--node-rank 0"));
  119. assert!(cmd.contains("--master-addr 1"));
  120. assert!(cmd.contains("--master-port 25000"));
  121. // vLLM auto-detects mp; only explicitly pass for ray
  122. assert!(
  123. !cmd.contains("--distributed-executor-backend"),
  124. "mp backend should be auto-detected"
  125. );
  126. assert!(!cmd.contains("RAY_ADDRESS"));
  127. assert!(cmd.contains("--rm"), "default --rm should be present");
  128. // New: defaults & cluster env
  129. assert!(
  130. !cmd.contains("--shm-size"),
  131. "shm-size should not be set by default"
  132. );
  133. assert!(cmd.contains("--ulimit memlock=-1"));
  134. assert!(cmd.contains("--ulimit stack=67108864"));
  135. assert!(
  136. cmd.contains("-e GLOO_SOCKET_IFNAME=e"),
  137. "default GLOO_SOCKET_IFNAME missing"
  138. );
  139. assert!(
  140. cmd.contains("-e TP_SOCKET_IFNAME=e"),
  141. "default TP_SOCKET_IFNAME missing"
  142. );
  143. assert!(
  144. cmd.contains("-e OMP_NUM_THREADS=4"),
  145. "default OMP_NUM_THREADS missing"
  146. );
  147. assert!(
  148. cmd.contains("-e NCCL_IGNORE_CPU_AFFINITY=1"),
  149. "default NCCL_IGNORE_CPU_AFFINITY missing"
  150. );
  151. assert!(cmd.contains("-e NCCL_NET=IB"), "default NCCL_NET missing");
  152. assert!(
  153. cmd.contains("-e NCCL_DEBUG=WARN"),
  154. "default NCCL_DEBUG missing"
  155. );
  156. assert!(
  157. cmd.contains("-e MN_IF_NAME=e"),
  158. "cluster MN_IF_NAME missing"
  159. );
  160. assert!(
  161. cmd.contains("-e UCX_NET_DEVICES=e"),
  162. "cluster UCX_NET_DEVICES missing"
  163. );
  164. assert!(
  165. cmd.contains("-e OMPI_MCA_btl_tcp_if_include=e"),
  166. "cluster OMPI_MCA_btl_tcp_if_include missing"
  167. );
  168. // Head node: no --headless
  169. assert!(!cmd.contains("--headless"));
  170. }
  171. #[test]
  172. fn worker_no_port() {
  173. let cmd = run(&trec(), &tcls().nodes[1], false);
  174. assert!(!cmd.contains("-p 8000:8000"));
  175. assert!(cmd.contains("--node-rank 1"));
  176. assert!(cmd.contains("--label sm121.rank=1"));
  177. assert!(cmd.contains("--name sm121_r_1"));
  178. // New: vLLM workers run --headless
  179. assert!(cmd.contains("--headless"), "vLLM worker should be headless");
  180. }
  181. #[test]
  182. fn ray_backend() {
  183. let mut r = trec();
  184. r.distributed_backend = Some(DistributedBackend::Ray);
  185. let cmd = run(&r, &tcls().nodes[0], true);
  186. assert!(cmd.contains("-e RAY_ADDRESS=1:6379"));
  187. assert!(cmd.contains("-e RAY_PORT=6379"));
  188. assert!(cmd.contains("--node-rank 0"));
  189. assert!(cmd.contains("--distributed-executor-backend ray"));
  190. }
  191. #[test]
  192. fn custom_tensor_parallel_size() {
  193. // Per-node TP=1 is the default. Custom TP (e.g. multi-node TP)
  194. // can be set via recipe.tensor_parallel_size.
  195. let mut r = trec();
  196. r.tensor_parallel_size = 2;
  197. let cmd = run(&r, &tcls().nodes[0], true);
  198. assert!(cmd.contains("--tensor-parallel-size 2"), "custom TP");
  199. }
  200. #[test]
  201. fn auto_pp_size() {
  202. let cmd = run(&trec(), &tcls().nodes[0], true);
  203. assert!(cmd.contains("--pipeline-parallel-size 2"));
  204. }
  205. #[test]
  206. fn entrypoint() {
  207. let mut r = trec();
  208. r.entrypoint = vec!["py".into(), "-m".into(), "mod".into()];
  209. let cmd = run(&r, &tcls().nodes[0], true);
  210. let ip = cmd.find("img").unwrap();
  211. let mp = cmd.find("mdl").unwrap();
  212. let ep = cmd.find("py -m mod").unwrap();
  213. assert!(
  214. ip < ep && ep < mp,
  215. "entrypoint should be between image and model"
  216. );
  217. }
  218. // ── SGLang engine tests ──────────────────────────────────────
  219. fn sglang_recipe() -> Recipe {
  220. Recipe {
  221. engine: "sglang".into(),
  222. name: "S".into(),
  223. docker_image: "sglang-img".into(),
  224. model: "org/sgl-model".into(),
  225. port: 8000,
  226. required_nodes: Some(4),
  227. pipeline_parallel_size: None,
  228. tensor_parallel_size: 4,
  229. args: RecipeArgs::List(vec!["--mem-fraction-static".into(), "0.8".into()]),
  230. env: HashMap::from([("TORCH_NCCL_ASYNC_ERROR_HANDLING".into(), "1".into())]),
  231. cluster: None,
  232. entrypoint: vec!["sglang".into(), "serve".into()],
  233. distributed_backend: Some(DistributedBackend::Mp),
  234. before_serve: vec![],
  235. after_stop: vec![],
  236. solo: None,
  237. mods: Default::default(),
  238. shm_size: String::new(),
  239. sync_image: true,
  240. docker_image_dir: None,
  241. extra_mounts: vec![],
  242. }
  243. }
  244. fn srun(recipe: &Recipe, node: &Node, head: bool) -> String {
  245. let tp = if recipe.required_nodes == Some(recipe.tensor_parallel_size) {
  246. 4
  247. } else {
  248. recipe.tensor_parallel_size
  249. };
  250. let mut ctx = make_dm_ctx(recipe, 4, 4, tp, false);
  251. ctx.recipe_name = "sg".to_string();
  252. build_docker_command(&ctx, node, head).join(" ")
  253. }
  254. #[test]
  255. fn sglang_host_ip() {
  256. let cmd = srun(&sglang_recipe(), &tcls().nodes[0], true);
  257. assert!(cmd.contains("-e SGLANG_WORKER_IP=1"));
  258. assert!(!cmd.contains("VLLM_HOST_IP"));
  259. }
  260. #[test]
  261. fn sglang_head() {
  262. let cmd = srun(&sglang_recipe(), &tcls().nodes[0], true);
  263. assert!(cmd.contains("--model-path"));
  264. assert!(cmd.contains("org/sgl-model"));
  265. assert!(cmd.contains("--tp 4"));
  266. assert!(cmd.contains("--nnodes 4"));
  267. assert!(cmd.contains("--node-rank 0"));
  268. assert!(cmd.contains("--dist-init-addr 1:25000"));
  269. assert!(!cmd.contains("--master-addr"));
  270. assert!(!cmd.contains("--tensor-parallel-size"));
  271. assert!(!cmd.contains("--pipeline-parallel-size"));
  272. assert!(!cmd.contains("--distributed-executor-backend"));
  273. assert!(cmd.contains("-e TORCH_NCCL_ASYNC_ERROR_HANDLING=1"));
  274. assert!(
  275. !cmd.contains("-p "),
  276. "host networking: no port mapping expected"
  277. );
  278. assert!(cmd.contains("--port 8000"));
  279. assert!(cmd.contains("-e HF_HOME=/root/.cache/huggingface"));
  280. assert!(cmd.contains("-v /home/user/.cache/huggingface:/root/.cache/huggingface"));
  281. assert!(
  282. cmd.contains("--rm"),
  283. "SGLang default --rm should be present"
  284. );
  285. }
  286. #[test]
  287. fn sglang_worker() {
  288. let cmd = srun(&sglang_recipe(), &tcls().nodes[1], false);
  289. assert!(!cmd.contains("-p 8000:8000"));
  290. assert!(cmd.contains("--node-rank 1"));
  291. assert!(cmd.contains("--port 8000"));
  292. }
  293. #[test]
  294. fn sglang_entrypoint_model_path_order() {
  295. let cmd = srun(&sglang_recipe(), &tcls().nodes[0], true);
  296. let ep = cmd.find("sglang serve").unwrap();
  297. let mp = cmd.find("--model-path").unwrap();
  298. let mdl = cmd.find("org/sgl-model").unwrap();
  299. let tp = cmd.find("--tp 4").unwrap();
  300. assert!(
  301. ep < mp && mp < mdl,
  302. "entrypoint before model-path before model"
  303. );
  304. assert!(mdl < tp, "model before --tp");
  305. let port = cmd.find("--port 8000").unwrap();
  306. assert!(tp < port, "--tp before --port");
  307. }
  308. #[test]
  309. fn keep_disables_rm() {
  310. let cmd = run_keep(&trec(), &tcls().nodes[0], true);
  311. assert!(!cmd.contains("--rm"), "--keep should suppress --rm");
  312. assert!(cmd.contains("docker run -d"));
  313. assert!(cmd.contains("--gpus all"));
  314. assert!(cmd.contains("-e HF_HOME=/root/.cache/huggingface"));
  315. assert!(cmd.contains("-v /home/user/.cache/huggingface:/root/.cache/huggingface"));
  316. }
  317. #[test]
  318. fn recipe_env_overrides_cluster_defaults() {
  319. // Recipe-level env should win over the defaults we inject.
  320. let mut r = trec();
  321. r.env.insert("NCCL_CUMEM_ENABLE".into(), "1".into());
  322. r.env.insert("OMP_NUM_THREADS".into(), "16".into());
  323. r.env.insert("NCCL_IGNORE_CPU_AFFINITY".into(), "0".into());
  324. r.env.insert("NCCL_NET".into(), "SOCKET".into());
  325. r.env.insert("NCCL_CROSS_NIC".into(), "0".into());
  326. r.env.insert("NCCL_DEBUG".into(), "INFO".into());
  327. let cmd = run(&r, &tcls().nodes[0], true);
  328. // Docker uses the last -e for duplicate keys, so recipe values come after.
  329. // rfind returns the last occurrence, which should be the recipe override.
  330. let cumem_pos = cmd.rfind("NCCL_CUMEM_ENABLE=").unwrap();
  331. assert!(
  332. cmd[cumem_pos..].starts_with("NCCL_CUMEM_ENABLE=1"),
  333. "recipe override should be last: slice starts at {}",
  334. cumem_pos
  335. );
  336. let omp_pos = cmd.rfind("OMP_NUM_THREADS=").unwrap();
  337. assert!(
  338. cmd[omp_pos..].starts_with("OMP_NUM_THREADS=16"),
  339. "recipe override should be last"
  340. );
  341. // New NCCL defaults can also be overridden.
  342. let ignore_pos = cmd.rfind("NCCL_IGNORE_CPU_AFFINITY=").unwrap();
  343. assert!(
  344. cmd[ignore_pos..].starts_with("NCCL_IGNORE_CPU_AFFINITY=0"),
  345. "NCCL_IGNORE_CPU_AFFINITY should be overridable"
  346. );
  347. let net_pos = cmd.rfind("NCCL_NET=").unwrap();
  348. assert!(
  349. cmd[net_pos..].starts_with("NCCL_NET=SOCKET"),
  350. "NCCL_NET should be overridable"
  351. );
  352. let cross_pos = cmd.rfind("NCCL_CROSS_NIC=").unwrap();
  353. assert!(
  354. cmd[cross_pos..].starts_with("NCCL_CROSS_NIC=0"),
  355. "NCCL_CROSS_NIC should be overridable"
  356. );
  357. let debug_pos = cmd.rfind("NCCL_DEBUG=").unwrap();
  358. assert!(
  359. cmd[debug_pos..].starts_with("NCCL_DEBUG=INFO"),
  360. "NCCL_DEBUG should be overridable"
  361. );
  362. }
  363. #[test]
  364. fn custom_init_port() {
  365. // Non-default init_port should propagate to vLLM master-port and
  366. // SGLang dist-init-addr.
  367. let r = trec();
  368. let tp = if r.required_nodes == Some(r.tensor_parallel_size) {
  369. 2
  370. } else {
  371. r.tensor_parallel_size
  372. };
  373. let mut ctx = make_dm_ctx(&r, 2, 2, tp, false);
  374. ctx.init_port = 29500;
  375. let cmd = build_docker_command(&ctx, &tcls().nodes[0], true).join(" ");
  376. assert!(cmd.contains("--master-port 29500"));
  377. assert!(cmd.contains("--master-addr 1"));
  378. }
  379. // ── Logs command format ───────────────────────────────────────
  380. #[test]
  381. fn logs_tail_cmd_format() {
  382. let cmd = super::logs::logs_tail_cmd("test-sleep", 0, 100);
  383. assert!(cmd.contains("docker ps -q"));
  384. assert!(cmd.contains("--filter 'label=sm121.recipe=test-sleep'"));
  385. assert!(cmd.contains("--filter label=sm121.rank=0"));
  386. assert!(cmd.contains("xargs -r docker logs"));
  387. assert!(cmd.contains("--timestamps"));
  388. assert!(cmd.contains("--tail=100"));
  389. assert!(cmd.ends_with(" 2>&1"));
  390. }
  391. #[test]
  392. fn logs_tail_cmd_per_rank() {
  393. let cmd0 = super::logs::logs_tail_cmd("glm51", 0, 100);
  394. let cmd2 = super::logs::logs_tail_cmd("glm51", 2, 100);
  395. assert!(cmd0.contains("label=sm121.rank=0"));
  396. assert!(cmd2.contains("label=sm121.rank=2"));
  397. assert!(!cmd0.contains("label=sm121.rank=2"));
  398. }
  399. #[test]
  400. fn logs_follow_cmd_format() {
  401. let cmd = super::logs::logs_follow_cmd("test-sleep");
  402. assert!(cmd.contains("xargs -r docker logs -f"));
  403. assert!(cmd.contains("--timestamps"));
  404. // Follow targets the head node only (rank=0).
  405. assert!(cmd.contains("--filter label=sm121.rank=0"));
  406. // Follow should not include --tail (we want a live stream from now).
  407. assert!(!cmd.contains("--tail"));
  408. // Follow must redirect stderr so docker's own errors are captured
  409. // (mirrors the behaviour of logs_tail_cmd).
  410. assert!(
  411. cmd.contains("2>&1"),
  412. "follow cmd must capture stderr, got: {cmd}"
  413. );
  414. }
  415. // ── Shell quoting ─────────────────────────────────────────────
  416. #[test]
  417. fn shq_wraps_simple_strings() {
  418. assert_eq!(shq("foo"), "'foo'");
  419. assert_eq!(shq(""), "''");
  420. assert_eq!(shq("foo bar"), "'foo bar'");
  421. }
  422. #[test]
  423. fn shq_escapes_inner_single_quotes() {
  424. // "don't" → 'don'\''t'
  425. assert_eq!(shq("don't"), "'don'\\''t'");
  426. }
  427. #[test]
  428. fn entrypoint_script_is_shell_quoted() {
  429. // sh -c SCRIPT should wrap the script in single quotes so it survives
  430. // bash word-splitting on the SSH server.
  431. let mut r = trec();
  432. r.entrypoint = vec!["sh".into(), "-c".into(), "echo $i; sleep 1".into()];
  433. let cmd = run(&r, &tcls().nodes[0], true);
  434. // The script element should appear wrapped in single quotes
  435. assert!(
  436. cmd.contains("'echo $i; sleep 1'"),
  437. "script should be shell-quoted, got: {cmd}"
  438. );
  439. }
  440. #[test]
  441. fn entrypoint_script_non_sh_passes_through() {
  442. // For non `sh -c` entrypoints (e.g. ["sh", "-c", "...", "extra"]) or
  443. // direct binaries, we leave the args alone — no false positive shq.
  444. let mut r = trec();
  445. r.entrypoint = vec!["sh".into(), "-c".into()];
  446. let cmd = run(&r, &tcls().nodes[0], true);
  447. assert!(cmd.contains("docker run"));
  448. }
  449. #[path = "solo_tests.rs"]
  450. mod solo_tests;