mod.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. pub mod ops;
  2. pub mod serve;
  3. use anyhow::Result;
  4. use std::time::Duration;
  5. use tokio::time::sleep;
  6. use tracing::{info, warn};
  7. use crate::cli::TmuxCommands;
  8. use crate::config::{Cluster, Config, NcclConfig, Node, Recipe};
  9. use crate::ssh::*;
  10. /// Manages tmux sessions for model inference across a DGX Spark cluster.
  11. #[derive(Clone)]
  12. pub struct TmuxManager {
  13. #[allow(dead_code)]
  14. pub session_prefix: String,
  15. }
  16. impl TmuxManager {
  17. /// Generates a namespaced session name for a recipe and node rank.
  18. /// Format: `{recipe_name}-{engine}-r{rank}`
  19. pub fn session_name(&self, recipe: &Recipe, rank: u32) -> String {
  20. let name = recipe.name.replace(' ', "_");
  21. format!("{}-{}-r{}", name, recipe.engine, rank)
  22. }
  23. /// Builds the list of `export` commands for NCCL environment variables.
  24. pub fn build_nccl_setup(&self, nccl: &NcclConfig) -> Vec<String> {
  25. vec![
  26. format!("export NCCL_IB_HCA=\"{}\"", nccl.ib_hca),
  27. format!("export NCCL_SOCKET_IFNAME=\"{}\"", nccl.socket_ifname),
  28. format!("export NCCL_IB_GID_INDEX={}", nccl.ib_gid_index),
  29. format!("export NCCL_IB_DISABLE={}", nccl.ib_disable),
  30. ]
  31. }
  32. /// Builds engine-specific host IP environment variable.
  33. pub fn build_host_ip_export(&self, engine: &str, node: &Node) -> Vec<String> {
  34. match engine {
  35. "vllm" => vec![format!("export VLLM_HOST_IP={}", node.ib_ip)],
  36. "sglang" => vec![format!("export SGLANG_WORKER_IP={}", node.ib_ip)],
  37. _ => vec![
  38. format!("export VLLM_HOST_IP={}", node.ib_ip),
  39. format!("export SGLANG_WORKER_IP={}", node.ib_ip),
  40. ],
  41. }
  42. }
  43. /// Builds the full model launch command for a given node.
  44. pub fn build_launch_command(&self, recipe: &Recipe, node: &Node, cluster: &Cluster) -> String {
  45. let nnodes = cluster.nodes.len() as u32;
  46. let pp_size = recipe.pipeline_parallel_size.unwrap_or(nnodes);
  47. let is_sglang = recipe.engine == "sglang";
  48. let mut parts: Vec<String> = Vec::new();
  49. // Engine entrypoint
  50. match recipe.engine.as_str() {
  51. "vllm" => {
  52. parts.push("python -m vllm.entrypoints.openai.api_server".to_string());
  53. }
  54. "sglang" => {
  55. parts.push("python -m sglang.launch_server".to_string());
  56. }
  57. other => {
  58. parts.push(format!("python -m {}", other));
  59. }
  60. }
  61. // Model — engine-specific
  62. if is_sglang {
  63. parts.push("--model-path".to_string());
  64. }
  65. parts.push(recipe.model.clone());
  66. // Recipe-specific args
  67. parts.extend(recipe.args.clone());
  68. // Distributed flags — engine-specific
  69. if is_sglang {
  70. // SGLang uses --tp (not --tensor-parallel-size),
  71. // and has no --pipeline-parallel-size / --distributed-executor-backend
  72. parts.extend(vec![
  73. "--tp".to_string(),
  74. recipe.tensor_parallel_size.to_string(),
  75. "--nnodes".to_string(),
  76. nnodes.to_string(),
  77. "--node-rank".to_string(),
  78. node.rank.to_string(),
  79. "--master-addr".to_string(),
  80. cluster.master_ib_ip.clone(),
  81. ]);
  82. } else {
  83. match recipe.distributed_backend.as_str() {
  84. "ray" => {
  85. parts.extend(vec![
  86. "--tensor-parallel-size".to_string(),
  87. "1".to_string(),
  88. "--pipeline-parallel-size".to_string(),
  89. pp_size.to_string(),
  90. "--nnodes".to_string(),
  91. nnodes.to_string(),
  92. "--node-rank".to_string(),
  93. node.rank.to_string(),
  94. "--master-addr".to_string(),
  95. cluster.master_ib_ip.clone(),
  96. "--distributed-executor-backend".to_string(),
  97. "ray".to_string(),
  98. ]);
  99. }
  100. _ => {
  101. // MP mode (default)
  102. parts.extend(vec![
  103. "--tensor-parallel-size".to_string(),
  104. "1".to_string(),
  105. "--pipeline-parallel-size".to_string(),
  106. pp_size.to_string(),
  107. "--nnodes".to_string(),
  108. nnodes.to_string(),
  109. "--node-rank".to_string(),
  110. node.rank.to_string(),
  111. "--master-addr".to_string(),
  112. cluster.master_ib_ip.clone(),
  113. "--distributed-executor-backend".to_string(),
  114. recipe.distributed_backend.clone(),
  115. ]);
  116. }
  117. }
  118. }
  119. // Port — SGLang needs it on all nodes, vLLM only on head node
  120. if is_sglang || node.rank == 0 {
  121. parts.extend(vec!["--port".to_string(), recipe.port.to_string()]);
  122. }
  123. parts.join(" ")
  124. }
  125. /// Builds the full sequence of tmux commands for a session, with multi-window layout.
  126. pub fn build_session_commands(
  127. &self,
  128. node: &Node,
  129. recipe: &Recipe,
  130. nccl: &NcclConfig,
  131. cluster: &Cluster,
  132. ) -> Vec<String> {
  133. let sess = self.session_name(recipe, node.rank);
  134. let mut cmds: Vec<String> = Vec::new();
  135. // Kill existing session if it exists
  136. cmds.push(format!("tmux kill-session -t {} 2>/dev/null || true", sess));
  137. // Create new detached session (window 0)
  138. cmds.push(format!("tmux new-session -d -s {} -n model", sess));
  139. // --- Window 0: model process ---
  140. for export in self.build_nccl_setup(nccl) {
  141. cmds.push(format!("tmux send-keys -t {}:0 '{}' Enter", sess, export));
  142. }
  143. for export in self.build_host_ip_export(&recipe.engine, node) {
  144. cmds.push(format!("tmux send-keys -t {}:0 '{}' Enter", sess, export));
  145. }
  146. if recipe.distributed_backend == "ray" && node.rank > 0 {
  147. cmds.push(format!(
  148. "tmux send-keys -t {}:0 'export RAY_ADDRESS={}:6379' Enter",
  149. sess, cluster.master_ib_ip
  150. ));
  151. }
  152. for (k, v) in &recipe.env {
  153. cmds.push(format!(
  154. "tmux send-keys -t {}:0 'export {}=\"{}\"' Enter",
  155. sess, k, v
  156. ));
  157. }
  158. let launch_cmd = self.build_launch_command(recipe, node, cluster);
  159. cmds.push(format!(
  160. "tmux send-keys -t {}:0 '{}' Enter",
  161. sess, launch_cmd
  162. ));
  163. // --- Window 1: GPU monitor ---
  164. cmds.push(format!("tmux new-window -t {}:1 -n gpu", sess));
  165. cmds.push(format!(
  166. "tmux send-keys -t {}:1 'nvidia-smi -l 5' Enter",
  167. sess
  168. ));
  169. // --- Window 2: shell (debug) ---
  170. cmds.push(format!("tmux new-window -t {}:2 -n shell", sess));
  171. cmds.push(format!(
  172. "tmux send-keys -t {}:2 'echo \"ready for debugging\"' Enter",
  173. sess
  174. ));
  175. cmds.push(format!("tmux select-window -t {}:0", sess));
  176. cmds
  177. }
  178. /// Executes a list of commands via SSH on a node to create a tmux session.
  179. pub async fn create_session(
  180. &self,
  181. config: &Config,
  182. node: &Node,
  183. commands: &[String],
  184. dry_run: bool,
  185. ) -> Result<()> {
  186. if dry_run {
  187. info!(" ssh {}@{}:", config.ssh_user, node.ssh_ip);
  188. for cmd in commands {
  189. info!(" {}", cmd);
  190. }
  191. return Ok(());
  192. }
  193. let script = commands.join(" && ");
  194. let output = run_ssh(config, node, script, dry_run).await?;
  195. if output.trim().is_empty() {
  196. info!(" ✅ tmux session created on {}", node.name);
  197. } else {
  198. info!(" tmux session output on {}: {}", node.name, output.trim());
  199. }
  200. Ok(())
  201. }
  202. /// Checks whether a tmux session exists on the given node.
  203. pub async fn check_session(
  204. &self,
  205. config: &Config,
  206. node: &Node,
  207. session_name: &str,
  208. dry_run: bool,
  209. ) -> Result<bool> {
  210. if dry_run {
  211. info!(
  212. " ssh {}@{}: tmux list-sessions 2>/dev/null | grep '{}'",
  213. config.ssh_user, node.ssh_ip, session_name
  214. );
  215. return Ok(true);
  216. }
  217. let cmd = format!(
  218. "tmux list-sessions 2>/dev/null | grep -c '{}'",
  219. session_name
  220. );
  221. let output = run_ssh(config, node, cmd, dry_run).await?;
  222. let found = output.trim().parse::<usize>().unwrap_or(0) > 0;
  223. if found {
  224. info!(" Session '{}' found on {}", session_name, node.name);
  225. } else {
  226. warn!(" Session '{}' NOT found on {}", session_name, node.name);
  227. }
  228. Ok(found)
  229. }
  230. /// Performs a deep health check on a tmux session.
  231. pub async fn deep_check_session(
  232. &self,
  233. config: &Config,
  234. node: &Node,
  235. _cluster: &Cluster,
  236. session_name: &str,
  237. recipe: &Recipe,
  238. dry_run: bool,
  239. ) -> Result<SessionHealth> {
  240. if dry_run {
  241. return Ok(SessionHealth {
  242. session_exists: true,
  243. process_alive: true,
  244. gpu_ok: true,
  245. api_ok: true,
  246. });
  247. }
  248. // 1. Check session exists
  249. let exists = self
  250. .check_session(config, node, session_name, dry_run)
  251. .await?;
  252. // 2. Check process is alive inside tmux
  253. let process_alive = if exists {
  254. let pid_cmd = format!(
  255. "tmux list-panes -t {}:0 -F '#{{pane_pid}}' | head -1",
  256. session_name
  257. );
  258. match run_ssh(config, node, pid_cmd, dry_run).await {
  259. Ok(pid_str) => {
  260. let pid = pid_str.trim();
  261. if !pid.is_empty() && pid != "0" {
  262. let ps_cmd = format!("ps -p {} -o pid= 2>/dev/null", pid);
  263. run_ssh(config, node, ps_cmd, dry_run)
  264. .await
  265. .map(|o| !o.trim().is_empty())
  266. .unwrap_or(false)
  267. } else {
  268. false
  269. }
  270. }
  271. Err(_) => false,
  272. }
  273. } else {
  274. false
  275. };
  276. // 3. Check GPU is available
  277. let gpu_ok = match run_ssh(
  278. config,
  279. node,
  280. "nvidia-smi --query-gpu=index,utilization.gpu,memory.used --format=csv,noheader 2>&1 | head -5"
  281. .to_string(),
  282. dry_run,
  283. )
  284. .await
  285. {
  286. Ok(out) => !out.contains("failed") && !out.contains("error") && !out.trim().is_empty(),
  287. Err(_) => false,
  288. };
  289. // 4. Check API endpoint (head node only)
  290. let api_ok = if node.rank == 0 && process_alive {
  291. let api_check = format!(
  292. "curl -s -o /dev/null -w '%{{http_code}}' http://127.0.0.1:{}/v1/models 2>/dev/null || echo 'fail'",
  293. recipe.port
  294. );
  295. match run_ssh(config, node, api_check, dry_run).await {
  296. Ok(out) => {
  297. let code = out.trim();
  298. code == "200" || !code.contains("fail")
  299. }
  300. Err(_) => false,
  301. }
  302. } else {
  303. node.rank != 0 || process_alive
  304. };
  305. Ok(SessionHealth {
  306. session_exists: exists,
  307. process_alive,
  308. gpu_ok,
  309. api_ok,
  310. })
  311. }
  312. /// Gracefully stops a tmux session with timeout escalation.
  313. pub async fn graceful_stop(
  314. &self,
  315. config: &Config,
  316. node: &Node,
  317. session_name: &str,
  318. dry_run: bool,
  319. ) -> Result<()> {
  320. if dry_run {
  321. info!(
  322. " ssh {}@{}: graceful stop of session '{}'",
  323. config.ssh_user, node.ssh_ip, session_name
  324. );
  325. return Ok(());
  326. }
  327. info!(
  328. " Sending Ctrl+C to session '{}' on {}",
  329. session_name, node.name
  330. );
  331. let _ = run_ssh(
  332. config,
  333. node,
  334. format!("tmux send-keys -t {}:0 C-c", session_name),
  335. dry_run,
  336. )
  337. .await;
  338. let mut exited = false;
  339. for i in 0..15 {
  340. sleep(Duration::from_secs(2)).await;
  341. let check_cmd = format!(
  342. "tmux list-panes -t {}:0 -F '#{{pane_pid}}' 2>/dev/null | head -1",
  343. session_name
  344. );
  345. match run_ssh(config, node, check_cmd, dry_run).await {
  346. Ok(pid_str) => {
  347. let pid = pid_str.trim();
  348. if pid.is_empty() || pid == "0" || pid == "No" {
  349. exited = true;
  350. info!(
  351. " Session '{}' exited gracefully (attempt {})",
  352. session_name,
  353. i + 1
  354. );
  355. break;
  356. }
  357. let ps_cmd = format!("ps -p {} -o pid= 2>/dev/null", pid);
  358. match run_ssh(config, node, ps_cmd, dry_run).await {
  359. Ok(ps_out) if ps_out.trim().is_empty() => {
  360. exited = true;
  361. info!(
  362. " Session '{}' process exited (attempt {})",
  363. session_name,
  364. i + 1
  365. );
  366. break;
  367. }
  368. _ => continue,
  369. }
  370. }
  371. Err(_) => {
  372. exited = true;
  373. break;
  374. }
  375. }
  376. }
  377. if !exited {
  378. warn!(
  379. " Session '{}' did not exit gracefully, force killing...",
  380. session_name
  381. );
  382. let _ = run_ssh(
  383. config,
  384. node,
  385. format!("tmux kill-session -t {}", session_name),
  386. dry_run,
  387. )
  388. .await;
  389. }
  390. Ok(())
  391. }
  392. /// Streams the content of a tmux pane, optionally following new output.
  393. pub async fn stream_pane(
  394. &self,
  395. config: &Config,
  396. node: &Node,
  397. session_name: &str,
  398. window: u32,
  399. follow: bool,
  400. dry_run: bool,
  401. ) -> Result<String> {
  402. if dry_run {
  403. if follow {
  404. info!(
  405. " ssh {}@{}: tail -f (streaming from session '{}' window {})",
  406. config.ssh_user, node.ssh_ip, session_name, window
  407. );
  408. } else {
  409. info!(
  410. " ssh {}@{}: tmux capture-pane -t {}:{} -p -S -100",
  411. config.ssh_user, node.ssh_ip, session_name, window
  412. );
  413. }
  414. return Ok(String::new());
  415. }
  416. let cmd = if follow {
  417. format!(
  418. "tmux pipe-pane -o -t {}:{} 'cat > /tmp/sm121_pipe_{}_{}.log' && tail -f /tmp/sm121_pipe_{}_{}.log",
  419. session_name,
  420. window,
  421. session_name.replace('-', "_"),
  422. window,
  423. session_name.replace('-', "_"),
  424. window
  425. )
  426. } else {
  427. format!(
  428. "tmux capture-pane -t {}:{} -p -S -200 2>/dev/null || echo 'no output'",
  429. session_name, window
  430. )
  431. };
  432. run_ssh_with_retry(config, node, cmd, dry_run, 1).await
  433. }
  434. /// Attaches to a tmux session on a node via SSH (interactive).
  435. pub async fn attach_session(
  436. &self,
  437. config: &Config,
  438. node: &Node,
  439. session_name: &str,
  440. dry_run: bool,
  441. ) -> Result<()> {
  442. if dry_run {
  443. info!(
  444. " Would attach: ssh -t {}@{} tmux attach-session -t {}",
  445. config.ssh_user, node.ssh_ip, session_name
  446. );
  447. return Ok(());
  448. }
  449. info!(
  450. " Attaching to '{}' on {}... Press Ctrl+B D to detach.",
  451. session_name, node.name
  452. );
  453. info!(" (russh does not support interactive PTY. Use the following command directly:)");
  454. println!(
  455. " ssh -t {}@{} tmux attach-session -t {}",
  456. config.ssh_user, node.ssh_ip, session_name
  457. );
  458. println!();
  459. info!(" Streaming pane content instead:");
  460. let output = self
  461. .stream_pane(config, node, session_name, 0, true, dry_run)
  462. .await?;
  463. println!("{}", output);
  464. Ok(())
  465. }
  466. }
  467. /// Detailed health status of a tmux session.
  468. #[derive(Debug, Clone)]
  469. pub struct SessionHealth {
  470. pub session_exists: bool,
  471. pub process_alive: bool,
  472. pub gpu_ok: bool,
  473. pub api_ok: bool,
  474. }
  475. impl SessionHealth {
  476. pub fn is_healthy(&self) -> bool {
  477. self.session_exists && self.process_alive && self.gpu_ok && self.api_ok
  478. }
  479. #[allow(dead_code)]
  480. pub fn is_degraded(&self) -> bool {
  481. self.session_exists && !self.process_alive
  482. }
  483. }
  484. /// Top-level handler for all `cargo run tmux <subcommand>` commands.
  485. pub async fn handle_tmux(config: &Config, cmd: TmuxCommands, dry_run: bool) -> Result<()> {
  486. match cmd {
  487. TmuxCommands::Serve { recipe, cluster } => {
  488. serve::tmux_serve(config, &recipe, &cluster, dry_run).await?;
  489. }
  490. TmuxCommands::Stop { recipe, cluster } => {
  491. ops::tmux_stop(config, &recipe, &cluster, dry_run).await?;
  492. }
  493. TmuxCommands::Status { recipe, cluster } => {
  494. ops::tmux_status(config, &recipe, &cluster, dry_run).await?;
  495. }
  496. TmuxCommands::Logs {
  497. recipe,
  498. follow,
  499. cluster,
  500. } => {
  501. ops::tmux_logs(config, &recipe, &cluster, follow, dry_run).await?;
  502. }
  503. TmuxCommands::Attach { recipe, cluster } => {
  504. ops::tmux_attach(config, &recipe, &cluster, dry_run).await?;
  505. }
  506. }
  507. Ok(())
  508. }