tests.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  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. },
  15. Node {
  16. name: "n1".into(),
  17. ssh_ip: "10.0.0.2".into(),
  18. ib_ip: "2".into(),
  19. rank: 1,
  20. host_key: None,
  21. },
  22. ],
  23. }
  24. }
  25. fn tnccl() -> NcclConfig {
  26. NcclConfig {
  27. ib_hca: "r".into(),
  28. socket_ifname: "e".into(),
  29. ib_gid_index: 3,
  30. ib_disable: 0,
  31. }
  32. }
  33. fn trec() -> Recipe {
  34. Recipe {
  35. engine: "vllm".into(),
  36. name: "T".into(),
  37. docker_image: "img".into(),
  38. model: "mdl".into(),
  39. port: 8000,
  40. required_nodes: Some(2),
  41. pipeline_parallel_size: Some(2),
  42. tensor_parallel_size: 1,
  43. args: vec!["--x".into(), "4096".into()],
  44. env: HashMap::from([("K".into(), "V".into())]),
  45. cluster: None,
  46. entrypoint: vec![],
  47. distributed_backend: "mp".into(),
  48. before_serve: vec![],
  49. }
  50. }
  51. fn run(recipe: &Recipe, node: &Node, head: bool) -> String {
  52. build_docker_command(
  53. &tcls(),
  54. &tnccl(),
  55. recipe,
  56. "r",
  57. node,
  58. head,
  59. 2,
  60. 2,
  61. "/home/user/.cache/huggingface",
  62. false,
  63. 25000,
  64. )
  65. .join(" ")
  66. }
  67. fn run_keep(recipe: &Recipe, node: &Node, head: bool) -> String {
  68. build_docker_command(
  69. &tcls(),
  70. &tnccl(),
  71. recipe,
  72. "r",
  73. node,
  74. head,
  75. 2,
  76. 2,
  77. "/home/user/.cache/huggingface",
  78. true,
  79. 25000,
  80. )
  81. .join(" ")
  82. }
  83. #[test]
  84. fn mp() {
  85. let cmd = run(&trec(), &tcls().nodes[0], true);
  86. assert!(cmd.contains("docker run -d --gpus all"));
  87. assert!(cmd.contains("-e NCCL_IB_HCA=r"));
  88. assert!(cmd.contains("-e K=V"));
  89. assert!(cmd.contains("-p 8000:8000"));
  90. assert!(cmd.contains("--name sm121_r_0"));
  91. assert!(cmd.contains("--label sm121.recipe=r"));
  92. assert!(cmd.contains("--label sm121.rank=0"));
  93. assert!(cmd.contains("img"));
  94. assert!(cmd.contains("mdl"));
  95. assert!(cmd.contains("--tensor-parallel-size 1"));
  96. assert!(cmd.contains("--pipeline-parallel-size 2"));
  97. assert!(cmd.contains("--nnodes 2"));
  98. assert!(cmd.contains("--node-rank 0"));
  99. assert!(cmd.contains("--master-addr 1"));
  100. assert!(cmd.contains("--master-port 25000"));
  101. assert!(cmd.contains("--distributed-executor-backend mp"));
  102. assert!(!cmd.contains("RAY_ADDRESS"));
  103. assert!(cmd.contains("--rm"), "default --rm should be present");
  104. // New: defaults & cluster env
  105. assert!(cmd.contains("--shm-size 10g"), "default shm-size missing");
  106. assert!(cmd.contains("--ulimit memlock=-1"));
  107. assert!(cmd.contains("--ulimit stack=67108864"));
  108. assert!(
  109. cmd.contains("-e NCCL_CUMEM_ENABLE=0"),
  110. "default NCCL_CUMEM_ENABLE missing"
  111. );
  112. assert!(
  113. cmd.contains("-e OMP_NUM_THREADS=4"),
  114. "default OMP_NUM_THREADS missing"
  115. );
  116. // Head node: no --headless
  117. assert!(!cmd.contains("--headless"));
  118. }
  119. #[test]
  120. fn worker_no_port() {
  121. let cmd = run(&trec(), &tcls().nodes[1], false);
  122. assert!(!cmd.contains("-p 8000:8000"));
  123. assert!(cmd.contains("--node-rank 1"));
  124. assert!(cmd.contains("--label sm121.rank=1"));
  125. assert!(cmd.contains("--name sm121_r_1"));
  126. // New: vLLM workers run --headless
  127. assert!(cmd.contains("--headless"), "vLLM worker should be headless");
  128. }
  129. #[test]
  130. fn ray_backend() {
  131. let mut r = trec();
  132. r.distributed_backend = "ray".into();
  133. let cmd = run(&r, &tcls().nodes[0], true);
  134. assert!(cmd.contains("-e RAY_ADDRESS=1:6379"));
  135. assert!(cmd.contains("-e RAY_PORT=8000"));
  136. assert!(cmd.contains("--node-rank 0"));
  137. assert!(cmd.contains("--distributed-executor-backend ray"));
  138. }
  139. #[test]
  140. fn auto_pp_size() {
  141. let cmd = run(&trec(), &tcls().nodes[0], true);
  142. assert!(cmd.contains("--pipeline-parallel-size 2"));
  143. }
  144. #[test]
  145. fn entrypoint() {
  146. let mut r = trec();
  147. r.entrypoint = vec!["py".into(), "-m".into(), "mod".into()];
  148. let cmd = run(&r, &tcls().nodes[0], true);
  149. let ip = cmd.find("img").unwrap();
  150. let mp = cmd.find("mdl").unwrap();
  151. let ep = cmd.find("py -m mod").unwrap();
  152. assert!(
  153. ip < ep && ep < mp,
  154. "entrypoint should be between image and model"
  155. );
  156. }
  157. // ── SGLang engine tests ──────────────────────────────────────
  158. fn sglang_recipe() -> Recipe {
  159. Recipe {
  160. engine: "sglang".into(),
  161. name: "S".into(),
  162. docker_image: "sglang-img".into(),
  163. model: "org/sgl-model".into(),
  164. port: 8000,
  165. required_nodes: Some(4),
  166. pipeline_parallel_size: None,
  167. tensor_parallel_size: 4,
  168. args: vec!["--mem-fraction-static".into(), "0.8".into()],
  169. env: HashMap::from([("TORCH_NCCL_ASYNC_ERROR_HANDLING".into(), "1".into())]),
  170. cluster: None,
  171. entrypoint: vec!["sglang".into(), "serve".into()],
  172. distributed_backend: "mp".into(),
  173. before_serve: vec![],
  174. }
  175. }
  176. fn srun(recipe: &Recipe, node: &Node, head: bool) -> String {
  177. build_docker_command(
  178. &tcls(),
  179. &tnccl(),
  180. recipe,
  181. "sg",
  182. node,
  183. head,
  184. 4,
  185. 4,
  186. "/home/user/.cache/huggingface",
  187. false,
  188. 25000,
  189. )
  190. .join(" ")
  191. }
  192. #[test]
  193. fn sglang_host_ip() {
  194. let cmd = srun(&sglang_recipe(), &tcls().nodes[0], true);
  195. assert!(cmd.contains("-e SGLANG_WORKER_IP=1"));
  196. assert!(!cmd.contains("VLLM_HOST_IP"));
  197. }
  198. #[test]
  199. fn sglang_head() {
  200. let cmd = srun(&sglang_recipe(), &tcls().nodes[0], true);
  201. assert!(cmd.contains("--model-path"));
  202. assert!(cmd.contains("org/sgl-model"));
  203. assert!(cmd.contains("--tp 4"));
  204. assert!(cmd.contains("--nnodes 4"));
  205. assert!(cmd.contains("--node-rank 0"));
  206. assert!(cmd.contains("--dist-init-addr 1:25000"));
  207. assert!(!cmd.contains("--master-addr"));
  208. assert!(!cmd.contains("--tensor-parallel-size"));
  209. assert!(!cmd.contains("--pipeline-parallel-size"));
  210. assert!(!cmd.contains("--distributed-executor-backend"));
  211. assert!(cmd.contains("-p 8000:8000"));
  212. assert!(cmd.contains("-e TORCH_NCCL_ASYNC_ERROR_HANDLING=1"));
  213. assert!(cmd.contains("--port 8000"));
  214. assert!(cmd.contains("-e HF_HOME=/cache/huggingface"));
  215. assert!(cmd.contains("-v /home/user/.cache/huggingface:/cache/huggingface"));
  216. assert!(
  217. cmd.contains("--rm"),
  218. "SGLang default --rm should be present"
  219. );
  220. }
  221. #[test]
  222. fn sglang_worker() {
  223. let cmd = srun(&sglang_recipe(), &tcls().nodes[1], false);
  224. assert!(!cmd.contains("-p 8000:8000"));
  225. assert!(cmd.contains("--node-rank 1"));
  226. assert!(cmd.contains("--port 8000"));
  227. }
  228. #[test]
  229. fn sglang_entrypoint_model_path_order() {
  230. let cmd = srun(&sglang_recipe(), &tcls().nodes[0], true);
  231. let ep = cmd.find("sglang serve").unwrap();
  232. let mp = cmd.find("--model-path").unwrap();
  233. let mdl = cmd.find("org/sgl-model").unwrap();
  234. let tp = cmd.find("--tp 4").unwrap();
  235. assert!(
  236. ep < mp && mp < mdl,
  237. "entrypoint before model-path before model"
  238. );
  239. assert!(mdl < tp, "model before --tp");
  240. let port = cmd.find("--port 8000").unwrap();
  241. assert!(tp < port, "--tp before --port");
  242. }
  243. #[test]
  244. fn keep_disables_rm() {
  245. let cmd = run_keep(&trec(), &tcls().nodes[0], true);
  246. assert!(!cmd.contains("--rm"), "--keep should suppress --rm");
  247. assert!(cmd.contains("docker run -d"));
  248. assert!(cmd.contains("--gpus all"));
  249. assert!(cmd.contains("-e HF_HOME=/cache/huggingface"));
  250. assert!(cmd.contains("-v /home/user/.cache/huggingface:/cache/huggingface"));
  251. }
  252. #[test]
  253. fn recipe_env_overrides_cluster_defaults() {
  254. // Recipe-level env should win over the defaults we inject.
  255. let mut r = trec();
  256. r.env.insert("NCCL_CUMEM_ENABLE".into(), "1".into());
  257. r.env.insert("OMP_NUM_THREADS".into(), "16".into());
  258. let cmd = run(&r, &tcls().nodes[0], true);
  259. // Docker uses the last -e for duplicate keys, so recipe values come after.
  260. // rfind returns the last occurrence, which should be the recipe override.
  261. let cumem_pos = cmd.rfind("NCCL_CUMEM_ENABLE=").unwrap();
  262. assert!(
  263. cmd[cumem_pos..].starts_with("NCCL_CUMEM_ENABLE=1"),
  264. "recipe override should be last: slice starts at {}",
  265. cumem_pos
  266. );
  267. let omp_pos = cmd.rfind("OMP_NUM_THREADS=").unwrap();
  268. assert!(
  269. cmd[omp_pos..].starts_with("OMP_NUM_THREADS=16"),
  270. "recipe override should be last"
  271. );
  272. }
  273. #[test]
  274. fn custom_init_port() {
  275. // Non-default init_port should propagate to vLLM master-port and
  276. // SGLang dist-init-addr.
  277. let cmd = build_docker_command(
  278. &tcls(),
  279. &tnccl(),
  280. &trec(),
  281. "r",
  282. &tcls().nodes[0],
  283. true,
  284. 2,
  285. 2,
  286. "/home/user/.cache/huggingface",
  287. false,
  288. 29500,
  289. )
  290. .join(" ");
  291. assert!(cmd.contains("--master-port 29500"));
  292. assert!(cmd.contains("--master-addr 1"));
  293. }
  294. // ── Logs command format ───────────────────────────────────────
  295. #[test]
  296. fn logs_tail_cmd_format() {
  297. let cmd = super::logs_tail_cmd("test-sleep", 0, 100);
  298. assert!(cmd.contains("docker ps -q"));
  299. assert!(cmd.contains("--filter \"label=sm121.recipe=test-sleep\""));
  300. assert!(cmd.contains("--filter \"label=sm121.rank=0\""));
  301. assert!(cmd.contains("xargs -r docker logs"));
  302. assert!(cmd.contains("--timestamps"));
  303. assert!(cmd.contains("--tail=100"));
  304. assert!(cmd.ends_with(" 2>&1"));
  305. }
  306. #[test]
  307. fn logs_tail_cmd_per_rank() {
  308. let cmd0 = super::logs_tail_cmd("glm51", 0, 100);
  309. let cmd2 = super::logs_tail_cmd("glm51", 2, 100);
  310. assert!(cmd0.contains("label=sm121.rank=0"));
  311. assert!(cmd2.contains("label=sm121.rank=2"));
  312. assert!(!cmd0.contains("label=sm121.rank=2"));
  313. }
  314. #[test]
  315. fn logs_follow_cmd_format() {
  316. let cmd = super::logs_follow_cmd("test-sleep");
  317. assert!(cmd.contains("xargs -r docker logs -f"));
  318. assert!(cmd.contains("--timestamps"));
  319. // Follow targets the head node only (rank=0).
  320. assert!(cmd.contains("--filter \"label=sm121.rank=0\""));
  321. // Follow should not include --tail (we want a live stream from now).
  322. assert!(!cmd.contains("--tail"));
  323. // Follow must redirect stderr so docker's own errors are captured
  324. // (mirrors the behaviour of logs_tail_cmd).
  325. assert!(
  326. cmd.contains("2>&1"),
  327. "follow cmd must capture stderr, got: {cmd}"
  328. );
  329. }
  330. // ── Shell quoting ─────────────────────────────────────────────
  331. #[test]
  332. fn shq_wraps_simple_strings() {
  333. assert_eq!(shq("foo"), "'foo'");
  334. assert_eq!(shq(""), "''");
  335. assert_eq!(shq("foo bar"), "'foo bar'");
  336. }
  337. #[test]
  338. fn shq_escapes_inner_single_quotes() {
  339. // "don't" → 'don'\''t'
  340. assert_eq!(shq("don't"), "'don'\\''t'");
  341. }
  342. #[test]
  343. fn entrypoint_script_is_shell_quoted() {
  344. // sh -c SCRIPT should wrap the script in single quotes so it survives
  345. // bash word-splitting on the SSH server.
  346. let mut r = trec();
  347. r.entrypoint = vec!["sh".into(), "-c".into(), "echo $i; sleep 1".into()];
  348. let cmd = run(&r, &tcls().nodes[0], true);
  349. // The script element should appear wrapped in single quotes
  350. assert!(
  351. cmd.contains("'echo $i; sleep 1'"),
  352. "script should be shell-quoted, got: {cmd}"
  353. );
  354. }
  355. #[test]
  356. fn entrypoint_script_non_sh_passes_through() {
  357. // For non `sh -c` entrypoints (e.g. ["sh", "-c", "...", "extra"]) or
  358. // direct binaries, we leave the args alone — no false positive shq.
  359. let mut r = trec();
  360. r.entrypoint = vec!["sh".into(), "-c".into()];
  361. let cmd = run(&r, &tcls().nodes[0], true);
  362. assert!(cmd.contains("docker run"));
  363. }