tests.rs 15 KB

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