mod.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. //! Native (head-coordinated) deploy: an engine's own cluster manager runs on
  2. //! rank 0 and brings up the peers itself. Currently only DGPP
  3. //! (`HawkBearPig/dgpp`, `engine = "dgpp"`, `deploy = "native"`).
  4. //!
  5. //! The launcher generates DGPP's cluster JSON (model + world size + engine
  6. //! knobs + http port) and a sibling `.env` (node list + ssh user + ports) from
  7. //! the recipe + selected cluster, uploads both to rank 0's DGPP checkout, and
  8. //! runs `scripts/dgpp-cluster up --config <path>` there. Stop runs
  9. //! `scripts/dgpp-cluster down`. Readiness is the uniform `/v1/models` probe on
  10. //! the DGPP HTTP port (default 18080).
  11. use anyhow::Result;
  12. use tracing::info;
  13. use crate::config::{Cluster, Config, DgppConfig, Node, Recipe};
  14. use crate::docker::ServeOpts;
  15. /// DGPP HTTP port for a recipe (its `[dgpp].http_port`, else 18080).
  16. pub fn http_port(recipe: &Recipe) -> u16 {
  17. recipe
  18. .dgpp
  19. .as_ref()
  20. .and_then(|d| d.http_port)
  21. .unwrap_or(18080)
  22. }
  23. /// DGPP repo checkout path on the nodes (its `[dgpp].dir`, else `~/dgpp`).
  24. fn dgpp_dir(d: &DgppConfig) -> String {
  25. d.dir.clone().unwrap_or_else(|| "~/dgpp".to_string())
  26. }
  27. /// Rewrite a leading `~` to `$HOME` so the remote shell expands it (a `~`
  28. /// inside single quotes would be a literal directory named `~`). Callers wrap
  29. /// the result in double quotes.
  30. fn expand_home(path: &str) -> String {
  31. if path == "~" {
  32. "$HOME".to_string()
  33. } else if let Some(rest) = path.strip_prefix("~/") {
  34. format!("$HOME/{rest}")
  35. } else {
  36. path.to_string()
  37. }
  38. }
  39. /// Build the DGPP cluster JSON from the recipe. `model` and `world_size` come
  40. /// from the recipe + launch node count; `http` from `[dgpp].http_port`; the
  41. /// `engine` block is the recipe's `[dgpp.engine]` map serialized verbatim.
  42. pub fn generate_cluster_json(recipe: &Recipe, world_size: usize) -> Result<String> {
  43. let d = recipe
  44. .dgpp
  45. .as_ref()
  46. .ok_or_else(|| anyhow::anyhow!("recipe has no [dgpp] block"))?;
  47. let mut root = serde_json::Map::new();
  48. root.insert(
  49. "model".into(),
  50. serde_json::Value::String(recipe.model.clone()),
  51. );
  52. root.insert(
  53. "world_size".into(),
  54. serde_json::Value::from(world_size as i64),
  55. );
  56. if let Some(r) = &d.release {
  57. root.insert("release".into(), serde_json::Value::String(r.clone()));
  58. }
  59. if !d.engine.is_empty() {
  60. let mut eng = serde_json::Map::new();
  61. for (k, v) in &d.engine {
  62. eng.insert(k.clone(), v.clone());
  63. }
  64. root.insert("engine".into(), serde_json::Value::Object(eng));
  65. }
  66. let mut http = serde_json::Map::new();
  67. http.insert(
  68. "bind_host".into(),
  69. serde_json::Value::String("0.0.0.0".into()),
  70. );
  71. http.insert(
  72. "port".into(),
  73. serde_json::Value::from(http_port(recipe) as i64),
  74. );
  75. root.insert("http".into(), serde_json::Value::Object(http));
  76. Ok(serde_json::to_string_pretty(&serde_json::Value::Object(
  77. root,
  78. ))?)
  79. }
  80. /// Build the DGPP site `.env` (allowlisted KEY=value lines DGPP's site_env.py
  81. /// reads): the launch nodes (ssh_ip, rank order), the ssh user, and the ports.
  82. pub fn generate_site_env(ssh_user: &str, cluster: &Cluster, recipe: &Recipe) -> String {
  83. let nodes = cluster
  84. .nodes
  85. .iter()
  86. .map(|n| n.ssh_ip.clone())
  87. .collect::<Vec<_>>()
  88. .join(",");
  89. let port = http_port(recipe);
  90. format!(
  91. "DGPP_NODES={nodes}\nDGPP_SSH_USER={user}\nDGPP_HTTP_PORT={port}\nDGPP_FABRIC_PORT=29970\nDGPP_JOURNAL_PORT=29971\n",
  92. user = ssh_user,
  93. )
  94. }
  95. /// Write a file on `node` via a single-quoted heredoc (content has no line
  96. /// equal to the delimiter — JSON/env never do). `path` is already `$HOME`-
  97. /// expanded and is wrapped in double quotes so it expands on the remote shell.
  98. async fn write_remote(
  99. config: &Config,
  100. node: &Node,
  101. path: &str,
  102. content: &str,
  103. dry_run: bool,
  104. ) -> Result<()> {
  105. let cmd = format!("cat > \"{path}\" <<'SM121DGPP'\n{content}\nSM121DGPP\n");
  106. crate::ssh::run_ssh(config, node, cmd, dry_run).await?;
  107. Ok(())
  108. }
  109. /// Serve a DGPP recipe: select the cluster, run before_serve hooks, upload the
  110. /// generated cluster JSON + `.env` to rank 0, and launch `dgpp-cluster up`
  111. /// there (head-coordinated — the manager brings up the peers).
  112. pub async fn native_serve(
  113. config: &Config,
  114. recipe_name: &str,
  115. cluster_name: &str,
  116. node_name: Option<&str>,
  117. opts: &ServeOpts,
  118. ) -> Result<Cluster> {
  119. let dry_run = opts.dry_run;
  120. let recipe_obj = crate::config::load_recipe(recipe_name, &config.recipe_sources).await?;
  121. let dgpp = recipe_obj
  122. .dgpp
  123. .clone()
  124. .ok_or_else(|| anyhow::anyhow!("engine=dgpp requires a [dgpp] block"))?;
  125. let (mut cluster, _nccl) = if dry_run {
  126. crate::orchestrate::prepare_cluster_dry_run(config, &recipe_obj, cluster_name, node_name)?
  127. } else {
  128. use crate::ends::{SshNodePinger, SshProber, ha_controller_from_config};
  129. use crate::orchestrate::{BootTiming, PrepareRequest, prepare_cluster};
  130. let power = opts
  131. .auto_power_on
  132. .then(|| ha_controller_from_config(config))
  133. .flatten();
  134. prepare_cluster(
  135. config,
  136. &recipe_obj,
  137. cluster_name,
  138. node_name,
  139. &PrepareRequest {
  140. prober: &SshProber,
  141. power: power.as_ref(),
  142. pinger: &SshNodePinger,
  143. auto_power_on: opts.auto_power_on,
  144. boot_timing: BootTiming::default(),
  145. force_occupied: false,
  146. },
  147. )
  148. .await?
  149. };
  150. // Trim to the launch set (world size = required_nodes).
  151. if let Some(n) = recipe_obj.required_nodes
  152. && (n as usize) < cluster.nodes.len()
  153. {
  154. cluster.nodes.truncate(n as usize);
  155. }
  156. let head = cluster
  157. .nodes
  158. .iter()
  159. .find(|n| n.rank == 0)
  160. .cloned()
  161. .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))?;
  162. crate::before_serve::run(
  163. config,
  164. &cluster,
  165. &recipe_obj,
  166. opts.skip_before_serve,
  167. dry_run,
  168. )
  169. .await?;
  170. let dir = expand_home(&dgpp_dir(&dgpp));
  171. let cfg_name = format!("cluster_sm121_{recipe_name}.json");
  172. let cfg_path = format!("{dir}/{cfg_name}");
  173. let env_path = format!("{dir}/.env");
  174. let json = generate_cluster_json(&recipe_obj, cluster.nodes.len())?;
  175. let env = generate_site_env(&config.ssh_user, &cluster, &recipe_obj);
  176. crate::ssh::run_ssh(
  177. config,
  178. &head,
  179. format!("mkdir -p \"{dir}\" && mkdir -p \"{dir}/log\""),
  180. dry_run,
  181. )
  182. .await?;
  183. write_remote(config, &head, &cfg_path, &json, dry_run).await?;
  184. write_remote(config, &head, &env_path, &env, dry_run).await?;
  185. // Launch on rank 0 only; the DGPP manager SSHes to the peers. Detached so
  186. // it survives the SSH session (nohup + log file).
  187. let up = format!(
  188. "cd \"{dir}\" && set -a && . ./.env && set +a && nohup scripts/dgpp-cluster up --config '{cfg_name}' > 'log/sm121-{recipe_name}.log' 2>&1 &"
  189. );
  190. info!("🚀 {} (dgpp, head {})", recipe_obj.name, head.name);
  191. crate::ssh::run_ssh(config, &head, up, dry_run).await?;
  192. if !dry_run {
  193. crate::runlock::mark_start(recipe_name, &config.recipe_sources, Some(&cluster.name))?;
  194. if !opts.no_wait_ready
  195. && let Err(e) = crate::readiness::wait_engine(
  196. config,
  197. &cluster,
  198. http_port(&recipe_obj),
  199. 1800,
  200. recipe_name,
  201. )
  202. .await
  203. {
  204. let _ = crate::runlock::mark_stop(recipe_name, &config.recipe_sources);
  205. return Err(e);
  206. }
  207. }
  208. Ok(cluster)
  209. }
  210. /// Stop a DGPP run: `dgpp-cluster down` on rank 0 (the manager stops the
  211. /// peers), then the caller closes the ledger.
  212. pub async fn native_stop(
  213. config: &Config,
  214. recipe: &Recipe,
  215. cluster: &Cluster,
  216. dry_run: bool,
  217. ) -> Result<()> {
  218. let Some(d) = recipe.dgpp.as_ref() else {
  219. anyhow::bail!("engine=dgpp requires a [dgpp] block to stop");
  220. };
  221. let dir = expand_home(&dgpp_dir(d));
  222. let head = cluster
  223. .nodes
  224. .iter()
  225. .find(|n| n.rank == 0)
  226. .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))?;
  227. let down = format!("cd \"{dir}\" && scripts/dgpp-cluster down");
  228. crate::ssh::run_ssh(config, head, down, dry_run).await?;
  229. Ok(())
  230. }
  231. #[cfg(test)]
  232. mod tests {
  233. use super::*;
  234. use crate::config::{DgppConfig, Node};
  235. use serde_json::json;
  236. fn dgpp_recipe() -> Recipe {
  237. let mut r = crate::engine::tests::base_recipe();
  238. r.engine = "dgpp".into();
  239. r.name = "dgpp-qwen".into();
  240. r.model = "unsloth/Qwen3.8-27B-NVFP4".into();
  241. r.port = 18080;
  242. r.required_nodes = Some(1);
  243. r.deploy = crate::config::DeployTarget::Native;
  244. r.dgpp = Some(DgppConfig {
  245. http_port: Some(18080),
  246. engine: [
  247. ("kv_capacity".to_string(), json!(262144)),
  248. ("mtp".to_string(), json!(true)),
  249. (
  250. "dflash_model".to_string(),
  251. json!("z-lab/Qwen3.8-27B-DFlash2"),
  252. ),
  253. ]
  254. .into_iter()
  255. .collect(),
  256. ..Default::default()
  257. });
  258. r
  259. }
  260. #[test]
  261. fn cluster_json_carries_model_world_size_engine_and_http() {
  262. let r = dgpp_recipe();
  263. let json: serde_json::Value =
  264. serde_json::from_str(&generate_cluster_json(&r, 1).unwrap()).unwrap();
  265. assert_eq!(json["model"], "unsloth/Qwen3.8-27B-NVFP4");
  266. assert_eq!(json["world_size"], 1);
  267. assert_eq!(json["engine"]["kv_capacity"], 262144);
  268. assert_eq!(json["engine"]["mtp"], true);
  269. assert_eq!(json["engine"]["dflash_model"], "z-lab/Qwen3.8-27B-DFlash2");
  270. assert_eq!(json["http"]["port"], 18080);
  271. assert_eq!(json["http"]["bind_host"], "0.0.0.0");
  272. }
  273. #[test]
  274. fn expand_home_rewrites_leading_tilde() {
  275. assert_eq!(expand_home("~/dgpp"), "$HOME/dgpp");
  276. assert_eq!(expand_home("~"), "$HOME");
  277. assert_eq!(expand_home("/srv/dgpp"), "/srv/dgpp");
  278. }
  279. #[test]
  280. fn site_env_lists_nodes_ssh_user_and_ports() {
  281. let r = dgpp_recipe();
  282. let cluster = Cluster {
  283. name: "dgx1".into(),
  284. master_ib_ip: None,
  285. nodes: vec![
  286. Node {
  287. name: "dgx1".into(),
  288. ssh_ip: "192.168.88.10".into(),
  289. ib_ip: None,
  290. rank: 0,
  291. host_key: None,
  292. entity: None,
  293. },
  294. Node {
  295. name: "dgx2".into(),
  296. ssh_ip: "192.168.88.14".into(),
  297. ib_ip: None,
  298. rank: 1,
  299. host_key: None,
  300. entity: None,
  301. },
  302. ],
  303. extra_entities: vec![],
  304. };
  305. let env = generate_site_env("kilork", &cluster, &r);
  306. assert!(env.contains("DGPP_NODES=192.168.88.10,192.168.88.14"));
  307. assert!(env.contains("DGPP_SSH_USER=kilork"));
  308. assert!(env.contains("DGPP_HTTP_PORT=18080"));
  309. }
  310. }